From 17e958f535542459a23db8ee6997897890a47842 Mon Sep 17 00:00:00 2001 From: Michael De Luca Date: Fri, 18 Oct 2024 15:28:50 -0400 Subject: [PATCH 1/3] feat: Upgradable Earner Manager --- script/DeployBase.sol | 67 ++- script/DeployProduction.s.sol | 31 +- src/EarnerManager.sol | 248 ++++++++ src/WrappedMToken.sol | 89 +-- ...atorV1.sol => WrappedMTokenMigratorV1.sol} | 2 +- src/interfaces/IEarnerManager.sol | 167 ++++++ src/interfaces/IWrappedMToken.sol | 6 + test/integration/Deploy.t.sol | 39 +- test/integration/TestBase.sol | 22 +- test/unit/EarnerManager.sol | 557 ++++++++++++++++++ test/unit/Migration.t.sol | 96 --- test/unit/Migrations.t.sol | 140 +++++ test/unit/Stories.t.sol | 31 +- test/unit/WrappedMToken.t.sol | 95 +-- test/utils/EarnerManagerHarness.sol | 17 + test/utils/Mocks.sol | 20 + test/utils/WrappedMTokenHarness.sol | 9 +- 17 files changed, 1413 insertions(+), 223 deletions(-) create mode 100644 src/EarnerManager.sol rename src/{MigratorV1.sol => WrappedMTokenMigratorV1.sol} (95%) create mode 100644 src/interfaces/IEarnerManager.sol create mode 100644 test/unit/EarnerManager.sol delete mode 100644 test/unit/Migration.t.sol create mode 100644 test/unit/Migrations.t.sol create mode 100644 test/utils/EarnerManagerHarness.sol diff --git a/script/DeployBase.sol b/script/DeployBase.sol index d63e903..9885381 100644 --- a/script/DeployBase.sol +++ b/script/DeployBase.sol @@ -5,35 +5,76 @@ pragma solidity 0.8.26; import { ContractHelper } from "../lib/common/src/libs/ContractHelper.sol"; import { WrappedMToken } from "../src/WrappedMToken.sol"; +import { EarnerManager } from "../src/EarnerManager.sol"; import { Proxy } from "../src/Proxy.sol"; contract DeployBase { /** * @dev Deploys Wrapped M Token. - * @param mToken_ The address the M Token contract. - * @param registrar_ The address the Registrar contract. - * @param migrationAdmin_ The address the Migration Admin. - * @return implementation_ The address of the deployed Wrapped M Token implementation. - * @return proxy_ The address of the deployed Wrapped M Token proxy. + * @param mToken_ The address the M Token contract. + * @param registrar_ The address the Registrar contract. + * @param excessDestination_ The address the excess destination. + * @param migrationAdmin_ The address the Migration Admin. + * @return earnerManagerImplementation_ The address of the deployed Earner Manager implementation. + * @return earnerManagerProxy_ The address of the deployed Earner Manager proxy. + * @return wrappedMTokenImplementation_ The address of the deployed Wrapped M Token implementation. + * @return wrappedMTokenProxy_ The address of the deployed Wrapped M Token proxy. */ function deploy( address mToken_, address registrar_, address excessDestination_, address migrationAdmin_ - ) public virtual returns (address implementation_, address proxy_) { - // Wrapped M token needs `mToken_`, `registrar_`, and `migrationAdmin_` addresses. - // Proxy needs `implementation_` addresses. + ) + public + virtual + returns ( + address earnerManagerImplementation_, + address earnerManagerProxy_, + address wrappedMTokenImplementation_, + address wrappedMTokenProxy_ + ) + { + // Earner Manager Proxy constructor needs only known values. + // Earner Manager Implementation constructor needs `earnerManagerImplementation_`. + // Wrapped M Token Implementation constructor needs `earnerManagerProxy_`. + // Wrapped M Token Proxy constructor needs `wrappedMTokenImplementation_`. - implementation_ = address(new WrappedMToken(mToken_, registrar_, excessDestination_, migrationAdmin_)); - proxy_ = address(new Proxy(implementation_)); + earnerManagerImplementation_ = address(new EarnerManager(registrar_, migrationAdmin_)); + + earnerManagerProxy_ = address(new Proxy(earnerManagerImplementation_)); + + wrappedMTokenImplementation_ = address( + new WrappedMToken(mToken_, registrar_, earnerManagerProxy_, excessDestination_, migrationAdmin_) + ); + + wrappedMTokenProxy_ = address(new Proxy(wrappedMTokenImplementation_)); + } + + function _getExpectedEarnerManager(address deployer_, uint256 deployerNonce_) internal pure returns (address) { + return ContractHelper.getContractFrom(deployer_, deployerNonce_); + } + + function getExpectedEarnerManager(address deployer_, uint256 deployerNonce_) public pure virtual returns (address) { + return _getExpectedEarnerManager(deployer_, deployerNonce_); + } + + function _getExpectedEarnerManagerProxy(address deployer_, uint256 deployerNonce_) internal pure returns (address) { + return ContractHelper.getContractFrom(deployer_, deployerNonce_ + 1); + } + + function getExpectedEarnerManagerProxy( + address deployer_, + uint256 deployerNonce_ + ) public pure virtual returns (address) { + return _getExpectedEarnerManagerProxy(deployer_, deployerNonce_); } function _getExpectedWrappedMTokenImplementation( address deployer_, uint256 deployerNonce_ ) internal pure returns (address) { - return ContractHelper.getContractFrom(deployer_, deployerNonce_); + return ContractHelper.getContractFrom(deployer_, deployerNonce_ + 2); } function getExpectedWrappedMTokenImplementation( @@ -44,7 +85,7 @@ contract DeployBase { } function _getExpectedWrappedMTokenProxy(address deployer_, uint256 deployerNonce_) internal pure returns (address) { - return ContractHelper.getContractFrom(deployer_, deployerNonce_ + 1); + return ContractHelper.getContractFrom(deployer_, deployerNonce_ + 3); } function getExpectedWrappedMTokenProxy( @@ -55,6 +96,6 @@ contract DeployBase { } function getDeployerNonceAfterProtocolDeployment(uint256 deployerNonce_) public pure virtual returns (uint256) { - return deployerNonce_ + 2; + return deployerNonce_ + 4; } } diff --git a/script/DeployProduction.s.sol b/script/DeployProduction.s.sol index 154c966..2dfb74e 100644 --- a/script/DeployProduction.s.sol +++ b/script/DeployProduction.s.sol @@ -37,8 +37,11 @@ contract DeployProduction is Script, DeployBase { // NOTE: Ensure this is the correct nonce to use to deploy the Proxy on testnet/mainnet. uint256 internal constant _DEPLOYER_PROXY_NONCE = 40; - // NOTE: Ensure this is the correct expected testnet/mainnet address for the Proxy. - address internal constant _EXPECTED_PROXY = 0x437cc33344a0B27A429f795ff6B469C72698B291; + // NOTE: Ensure this is the correct expected testnet/mainnet address for the Wrapped M Token Proxy. + address internal constant _EXPECTED_WRAPPED_M_TOKEN_PROXY = address(0); + + // NOTE: Ensure this is the correct expected testnet/mainnet address for the Earner Manager Proxy. + address internal constant _EXPECTED_EARNER_MANAGER_PROXY = address(0); function run() external { address deployer_ = vm.rememberKey(vm.envUint("PRIVATE_KEY")); @@ -53,7 +56,8 @@ contract DeployProduction is Script, DeployBase { address expectedProxy_ = getExpectedWrappedMTokenProxy(deployer_, _DEPLOYER_PROXY_NONCE); - if (expectedProxy_ != _EXPECTED_PROXY) revert ExpectedProxyMismatch(_EXPECTED_PROXY, expectedProxy_); + if (expectedProxy_ != _EXPECTED_WRAPPED_M_TOKEN_PROXY) + revert ExpectedProxyMismatch(_EXPECTED_WRAPPED_M_TOKEN_PROXY, expectedProxy_); vm.startBroadcast(deployer_); @@ -67,13 +71,26 @@ contract DeployProduction is Script, DeployBase { if (currentNonce_ != _DEPLOYER_PROXY_NONCE - 1) revert UnexpectedDeployerNonce(); - (address implementation_, address proxy_) = deploy(_M_TOKEN, _REGISTRAR, _EXCESS_DESTINATION, _MIGRATION_ADMIN); + ( + address earnerManagerProxy_, + address earnerManagerImplementation_, + address wrappedMTokenImplementation_, + address wrappedMTokenProxy_ + ) = deploy(_M_TOKEN, _REGISTRAR, _EXCESS_DESTINATION, _MIGRATION_ADMIN); vm.stopBroadcast(); - console2.log("Wrapped M Implementation address:", implementation_); - console2.log("Wrapped M Proxy address:", proxy_); + console2.log("Earner Manager Proxy address:", earnerManagerProxy_); + console2.log("Earner Manager Implementation address:", earnerManagerImplementation_); + console2.log("Wrapped M Implementation address:", wrappedMTokenImplementation_); + console2.log("Wrapped M Proxy address:", wrappedMTokenProxy_); + + if (wrappedMTokenProxy_ != _EXPECTED_WRAPPED_M_TOKEN_PROXY) { + revert ResultingProxyMismatch(_EXPECTED_WRAPPED_M_TOKEN_PROXY, wrappedMTokenProxy_); + } - if (proxy_ != _EXPECTED_PROXY) revert ResultingProxyMismatch(_EXPECTED_PROXY, proxy_); + if (earnerManagerProxy_ != _EXPECTED_EARNER_MANAGER_PROXY) { + revert ResultingProxyMismatch(_EXPECTED_EARNER_MANAGER_PROXY, earnerManagerProxy_); + } } } diff --git a/src/EarnerManager.sol b/src/EarnerManager.sol new file mode 100644 index 0000000..3afde5e --- /dev/null +++ b/src/EarnerManager.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity 0.8.26; + +import { IEarnerManager } from "./interfaces/IEarnerManager.sol"; +import { IRegistrarLike } from "./interfaces/IRegistrarLike.sol"; + +import { Migratable } from "./Migratable.sol"; + +/** + * @title Earner Manager allows admins to define earners without governance, and take fees from yield. + * @author M^0 Labs + */ +contract EarnerManager is IEarnerManager, Migratable { + /* ============ Structs ============ */ + + struct EarnerDetails { + address admin; + uint16 feeRate; + } + + /* ============ Variables ============ */ + + /// @inheritdoc IEarnerManager + uint16 public constant MAX_FEE_RATE = 10_000; + + /// @inheritdoc IEarnerManager + bytes32 public constant ADMINS_LIST_NAME = "em_admins"; + + /// @inheritdoc IEarnerManager + bytes32 public constant EARNERS_LIST_IGNORED_KEY = "earners_list_ignored"; + + /// @inheritdoc IEarnerManager + bytes32 public constant EARNERS_LIST_NAME = "earners"; + + /// @inheritdoc IEarnerManager + bytes32 public constant MIGRATOR_KEY_PREFIX = "em_migrator_v1"; + + /// @inheritdoc IEarnerManager + address public immutable registrar; + + /// @inheritdoc IEarnerManager + address public immutable migrationAdmin; + + /// @dev Mapping of account to earner details. + mapping(address account => EarnerDetails earnerDetails) internal _earnerDetails; + + /* ============ Modifiers ============ */ + + modifier onlyAdmin() { + _revertIfNotAdmin(); + _; + } + + /* ============ Constructor ============ */ + + /** + * @dev Constructs the contract. + * @param registrar_ The address of a Registrar contract. + * @param migrationAdmin_ The address of a migration admin. + */ + constructor(address registrar_, address migrationAdmin_) { + if ((registrar = registrar_) == address(0)) revert ZeroRegistrar(); + if ((migrationAdmin = migrationAdmin_) == address(0)) revert ZeroMigrationAdmin(); + } + + /* ============ Interactive Functions ============ */ + + /// @inheritdoc IEarnerManager + function setEarnerDetails(address account_, bool status_, uint16 feeRate_) external onlyAdmin { + if (isEarnersListIgnored()) revert EarnersListIgnored(); + + _setDetails(account_, status_, msg.sender, feeRate_); + } + + /// @inheritdoc IEarnerManager + function setEarnerDetails( + address[] calldata accounts_, + bool[] calldata statuses_, + uint16[] calldata feeRates_ + ) external onlyAdmin { + if (accounts_.length == 0) revert ArrayLengthZero(); + if (accounts_.length != statuses_.length) revert ArrayLengthMismatch(); + if (accounts_.length != feeRates_.length) revert ArrayLengthMismatch(); + if (isEarnersListIgnored()) revert EarnersListIgnored(); + + for (uint256 index_; index_ < accounts_.length; ++index_) { + // NOTE: The `isAdmin` check in `_setDetails` will make this costly to re-set details for multiple accounts + // that have already been set by the same admin, due to the redundant queries to the registrar. + // Consider transient storage in `isAdmin` to memoize admins. + _setDetails(accounts_[index_], statuses_[index_], msg.sender, feeRates_[index_]); + } + } + + /* ============ Temporary Admin Migration ============ */ + + /// @inheritdoc IEarnerManager + function migrate(address migrator_) external { + if (msg.sender != migrationAdmin) revert UnauthorizedMigration(); + + _migrate(migrator_); + } + + /* ============ View/Pure Functions ============ */ + + /// @inheritdoc IEarnerManager + function earnerStatusFor(address account_) external view returns (bool status_) { + return isEarnersListIgnored() || isInEarnersList(account_) || _isValidAdmin(_earnerDetails[account_].admin); + } + + /// @inheritdoc IEarnerManager + function earnerStatusesFor(address[] calldata accounts_) external view returns (bool[] memory statuses_) { + statuses_ = new bool[](accounts_.length); + + bool isListIgnored_ = isEarnersListIgnored(); + + for (uint256 index_; index_ < accounts_.length; ++index_) { + if (isListIgnored_) { + statuses_[index_] = true; + continue; + } + + address account_ = accounts_[index_]; + + if (isInEarnersList(account_)) { + statuses_[index_] = true; + continue; + } + + statuses_[index_] = _isValidAdmin(_earnerDetails[account_].admin); + } + } + + /// @inheritdoc IEarnerManager + function isEarnersListIgnored() public view returns (bool isIgnored_) { + return IRegistrarLike(registrar).get(EARNERS_LIST_IGNORED_KEY) != bytes32(0); + } + + /// @inheritdoc IEarnerManager + function isInEarnersList(address account_) public view returns (bool isInList_) { + return IRegistrarLike(registrar).listContains(EARNERS_LIST_NAME, account_); + } + + /// @inheritdoc IEarnerManager + function getEarnerDetails(address account_) external view returns (bool status_, uint16 feeRate_, address admin_) { + if (isEarnersListIgnored() || isInEarnersList(account_)) return (true, 0, address(0)); + + EarnerDetails storage details_ = _earnerDetails[account_]; + + return _isValidAdmin(details_.admin) ? (true, details_.feeRate, details_.admin) : (false, 0, address(0)); + } + + /// @inheritdoc IEarnerManager + function getEarnerDetails( + address[] calldata accounts_ + ) external view returns (bool[] memory statuses_, uint16[] memory feeRates_, address[] memory admins_) { + statuses_ = new bool[](accounts_.length); + feeRates_ = new uint16[](accounts_.length); + admins_ = new address[](accounts_.length); + + bool isEarnersListIgnored_ = isEarnersListIgnored(); + + for (uint256 index_; index_ < accounts_.length; ++index_) { + if (isEarnersListIgnored_) { + statuses_[index_] = true; + continue; + } + + address account_ = accounts_[index_]; + + if (isInEarnersList(account_)) { + statuses_[index_] = true; + continue; + } + + EarnerDetails storage details_ = _earnerDetails[account_]; + + if (!_isValidAdmin(details_.admin)) continue; + + statuses_[index_] = true; + feeRates_[index_] = details_.feeRate; + admins_[index_] = details_.admin; + } + } + + /// @inheritdoc IEarnerManager + function isAdmin(address account_) public view returns (bool isAdmin_) { + // TODO: Consider transient storage for memoizing this check. + return IRegistrarLike(registrar).listContains(ADMINS_LIST_NAME, account_); + } + + /* ============ Internal Interactive Functions ============ */ + + /** + * @dev Sets the earner details for `account_`. + * @param account_ The account under which yield could generate. + * @param status_ Whether the account is an earner, according to the admin. + * @param admin_ The admin who set the details and who will collect the fee. + * @param feeRate_ The fee rate to be taken from the yield. + */ + function _setDetails(address account_, bool status_, address admin_, uint16 feeRate_) internal { + if (account_ == address(0)) revert ZeroAccount(); + if (!status_ && (feeRate_ != 0)) revert InvalidDetails(); + if (status_ == (admin_ == address(0))) revert InvalidDetails(); + if (feeRate_ > MAX_FEE_RATE) revert FeeRateTooHigh(); + if (isInEarnersList(account_)) revert AlreadyInEarnersList(account_); + + address currentAdmin_ = _earnerDetails[account_].admin; + + // Revert if the details have already been set by an admin that is not `admin_` and is still an admin. + if ((currentAdmin_ != address(0)) && (currentAdmin_ != admin_) && isAdmin(currentAdmin_)) { + revert EarnerDetailsAlreadySet(account_); + } + + _earnerDetails[account_] = EarnerDetails(admin_, feeRate_); + + emit EarnerDetailsSet(account_, status_, admin_, feeRate_); + } + + /** + * @dev Reverts if the caller is not an admin. + */ + function _revertIfNotAdmin() internal view { + if (!isAdmin(msg.sender)) revert NotAdmin(); + } + + /* ============ Internal View/Pure Functions ============ */ + + /// @dev Returns the address of the contract to use as a migrator, if any. + function _getMigrator() internal view override returns (address migrator_) { + return + address( + uint160( + // NOTE: A subsequent implementation should use a unique migrator prefix. + uint256(IRegistrarLike(registrar).get(keccak256(abi.encode(MIGRATOR_KEY_PREFIX, address(this))))) + ) + ); + } + + /** + * @dev Returns whether `admin_` is a valid current admin. + * @param admin_ The admin to check. + * @return isValidAdmin_ True if `admin_` is a valid admin (non-zero and an admin according to the Registrar). + */ + function _isValidAdmin(address admin_) internal view returns (bool isValidAdmin_) { + return (admin_ != address(0)) && isAdmin(admin_); + } +} diff --git a/src/WrappedMToken.sol b/src/WrappedMToken.sol index 1086dca..4e9e1cb 100644 --- a/src/WrappedMToken.sol +++ b/src/WrappedMToken.sol @@ -9,6 +9,7 @@ import { ERC20Extended } from "../lib/common/src/ERC20Extended.sol"; import { IndexingMath } from "./libs/IndexingMath.sol"; +import { IEarnerManager } from "./interfaces/IEarnerManager.sol"; import { IMTokenLike } from "./interfaces/IMTokenLike.sol"; import { IRegistrarLike } from "./interfaces/IRegistrarLike.sol"; import { IWrappedMToken } from "./interfaces/IWrappedMToken.sol"; @@ -31,10 +32,22 @@ import { Migratable } from "./Migratable.sol"; * @author M^0 Labs */ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { + /* ============ Structs ============ */ + + /** + * @dev Struct to represent an account's balance and yield earning details + * @param isEarning Whether the account is actively earning yield. + * @param balance The present amount of tokens held by the account. + * @param lastIndex The index of the last interaction for the account (0 for non-earning accounts). + * @param hasEarnerDetails Whether the account has additional details for earning yield. + */ struct Account { + // First Slot bool isEarning; uint240 balance; + // Second slot uint128 lastIndex; + bool hasEarnerDetails; } /* ============ Variables ============ */ @@ -51,6 +64,9 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { /// @inheritdoc IWrappedMToken bytes32 public constant MIGRATOR_KEY_PREFIX = "wm_migrator_v2"; + /// @inheritdoc IWrappedMToken + address public immutable earnerManager; + /// @inheritdoc IWrappedMToken address public immutable migrationAdmin; @@ -83,18 +99,22 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { /** * @dev Constructs the contract given an M Token address and migration admin. * Note that a proxy will not need to initialize since there are no mutable storage values affected. - * @param mToken_ The address of an M Token. - * @param registrar_ The address of a Registrar. - * @param migrationAdmin_ The address of a migration admin. + * @param mToken_ The address of an M Token. + * @param registrar_ The address of a Registrar. + * @param earnerManager_ The address of an Earner Manager. + * @param excessDestination_ The address of an excess destination. + * @param migrationAdmin_ The address of a migration admin. */ constructor( address mToken_, address registrar_, + address earnerManager_, address excessDestination_, address migrationAdmin_ ) ERC20Extended("Smart M by M^0", "MSMART", 6) { if ((mToken = mToken_) == address(0)) revert ZeroMToken(); if ((registrar = registrar_) == address(0)) revert ZeroRegistrar(); + if ((earnerManager = earnerManager_) == address(0)) revert ZeroEarnerManager(); if ((excessDestination = excessDestination_) == address(0)) revert ZeroExcessDestination(); if ((migrationAdmin = migrationAdmin_) == address(0)) revert ZeroMigrationAdmin(); } @@ -135,7 +155,10 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { /// @inheritdoc IWrappedMToken function enableEarning() external { - _revertIfNotApprovedEarner(address(this)); + if ( + IRegistrarLike(registrar).get(EARNERS_LIST_IGNORED_KEY) == bytes32(0) && + !IRegistrarLike(registrar).listContains(EARNERS_LIST_NAME, address(this)) + ) revert NotApprovedEarner(address(this)); if (isEarningEnabled()) revert EarningIsEnabled(); @@ -154,7 +177,10 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { /// @inheritdoc IWrappedMToken function disableEarning() external { - _revertIfApprovedEarner(address(this)); + if ( + IRegistrarLike(registrar).get(EARNERS_LIST_IGNORED_KEY) != bytes32(0) || + IRegistrarLike(registrar).listContains(EARNERS_LIST_NAME, address(this)) + ) revert IsApprovedEarner(address(this)); if (!isEarningEnabled()) revert EarningIsDisabled(); @@ -443,7 +469,19 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { emit Claimed(account_, claimRecipient_, yield_); emit Transfer(address(0), account_, yield_); - if (claimRecipient_ != account_) { + if (accountInfo_.hasEarnerDetails) { + (, uint16 feeRate_, address admin_) = IEarnerManager(earnerManager).getEarnerDetails(account_); + + feeRate_ = feeRate_ > 10_000 ? 10_000 : feeRate_; // Ensure fee rate is capped at 100%. + uint240 fee_ = (feeRate_ * yield_) / 10_000; + + if (fee_ != 0) { + _transfer(account_, admin_, fee_, currentIndex_); + yield_ -= fee_; + } + } + + if ((claimRecipient_ != account_) && (fee_ != 0)) { // NOTE: Watch out for a long chain of earning claim override recipients. _transfer(account_, claimRecipient_, yield_, currentIndex_); } @@ -466,6 +504,8 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { emit Transfer(sender_, recipient_, amount_); + if (amount_ == 0) return; + Account storage senderAccountInfo_ = _accounts[sender_]; Account storage recipientAccountInfo_ = _accounts[recipient_]; @@ -588,7 +628,9 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { * @param currentIndex_ The current index. */ function _startEarningFor(address account_, uint128 currentIndex_) internal { - _revertIfNotApprovedEarner(account_); + (bool isEarner_, uint16 feeRate_, ) = IEarnerManager(earnerManager).getEarnerDetails(account_); + + if (!isEarner_) revert NotApprovedEarner(account_); Account storage accountInfo_ = _accounts[account_]; @@ -596,6 +638,7 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { accountInfo_.isEarning = true; accountInfo_.lastIndex = currentIndex_; + accountInfo_.hasEarnerDetails = feeRate_ != 0; uint240 balance_ = accountInfo_.balance; @@ -614,7 +657,9 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { * @param currentIndex_ The current index. */ function _stopEarningFor(address account_, uint128 currentIndex_) internal { - _revertIfApprovedEarner(account_); + (bool isEarner_, , ) = IEarnerManager(earnerManager).getEarnerDetails(account_); + + if (isEarner_) revert IsApprovedEarner(account_); _claim(account_, currentIndex_); @@ -624,6 +669,7 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { delete accountInfo_.isEarning; delete accountInfo_.lastIndex; + delete accountInfo_.hasEarnerDetails; uint240 balance_ = accountInfo_.balance; @@ -698,17 +744,6 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { ); } - /** - * @dev Returns whether `account_` is a Registrar-approved earner. - * @param account_ The account being queried. - * @return isApproved_ True if the account_ is a Registrar-approved earner, false otherwise. - */ - function _isApprovedEarner(address account_) internal view returns (bool isApproved_) { - return - IRegistrarLike(registrar).get(EARNERS_LIST_IGNORED_KEY) != bytes32(0) || - IRegistrarLike(registrar).listContains(EARNERS_LIST_NAME, account_); - } - /** * @dev Returns the projected total earning supply if all accrued yield was claimed at this moment. * @param currentIndex_ The current index. @@ -734,22 +769,6 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { if (recipient_ == address(0)) revert InvalidRecipient(recipient_); } - /** - * @dev Reverts if `account_` is an approved earner. - * @param account_ Address of an account. - */ - function _revertIfApprovedEarner(address account_) internal view { - if (_isApprovedEarner(account_)) revert IsApprovedEarner(account_); - } - - /** - * @dev Reverts if `account_` is not an approved earner. - * @param account_ Address of an account. - */ - function _revertIfNotApprovedEarner(address account_) internal view { - if (!_isApprovedEarner(account_)) revert NotApprovedEarner(account_); - } - /** * @dev Reads the uint128 value at some index of an array of uint128 values whose storage pointer is given, * assuming the index is valid, without wasting gas checking for out-of-bounds errors. diff --git a/src/MigratorV1.sol b/src/WrappedMTokenMigratorV1.sol similarity index 95% rename from src/MigratorV1.sol rename to src/WrappedMTokenMigratorV1.sol index b148337..eb0cf22 100644 --- a/src/MigratorV1.sol +++ b/src/WrappedMTokenMigratorV1.sol @@ -6,7 +6,7 @@ pragma solidity 0.8.26; * @title Migrator contract for migrating a WrappedMToken contract from V1 to V2. * @author M^0 Labs */ -contract MigratorV1 { +contract WrappedMTokenMigratorV1 { /// @dev Storage slot with the address of the current factory. `keccak256('eip1967.proxy.implementation') - 1`. uint256 private constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; diff --git a/src/interfaces/IEarnerManager.sol b/src/interfaces/IEarnerManager.sol new file mode 100644 index 0000000..d5ada0c --- /dev/null +++ b/src/interfaces/IEarnerManager.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity 0.8.26; + +import { IMigratable } from "./IMigratable.sol"; + +/** + * @title Earner Status Manager interface for setting and returning earner status for Wrapped M Token accounts. + * @author M^0 Labs + */ +interface IEarnerManager is IMigratable { + /* ============ Events ============ */ + + /** + * @notice Emitted when the earner for `account` is set to `status`. + * @param account The account under which yield could generate. + * @param status Whether the account is set an earner, according to the admin. + * @param admin The admin who set the details and who will collect the fee. + * @param feeRate The fee rate to be taken from the yield. + */ + event EarnerDetailsSet(address indexed account, bool indexed status, address indexed admin, uint16 feeRate); + + /* ============ Custom Errors ============ */ + + /// @notice Emitted when `account` is is already in the earners list, so it cannot be added by an admin. + error AlreadyInEarnersList(address account); + + /// @notice Emitted when the lengths of input arrays do not match. + error ArrayLengthMismatch(); + + /// @notice Emitted when the length of an input array is 0. + error ArrayLengthZero(); + + /// @notice Emitted when the earner details have already be set by an existing and active admin. + error EarnerDetailsAlreadySet(address account); + + /// @notice Emitted when the earners list is ignored, thus not requiring admin to define earners. + error EarnersListIgnored(); + + /// @notice Emitted when the fee rate provided is to high (higher than 100% in basis points). + error FeeRateTooHigh(); + + /// @notice Emitted when setting details (i.e. fee rate) while setting status to false. + error InvalidDetails(); + + /// @notice Emitted when the caller is not an admin. + error NotAdmin(); + + /// @notice Emitted when the non-governance migrate function is called by a account other than the migration admin. + error UnauthorizedMigration(); + + /// @notice Emitted when an account (whose status is being set) is 0x0. + error ZeroAccount(); + + /// @notice Emitted in constructor if Migration Admin is 0x0. + error ZeroMigrationAdmin(); + + /// @notice Emitted in constructor if Registrar is 0x0. + error ZeroRegistrar(); + + /* ============ Interactive Functions ============ */ + + /** + * @notice Sets the status for `account` to `status`. + * @param account The account under which yield could generate. + * @param status Whether the account is an earner, according to the admin. + * @param feeRate The fee rate to be taken from the yield. + */ + function setEarnerDetails(address account, bool status, uint16 feeRate) external; + + /** + * @notice Sets the status for multiple accounts. + * @param accounts The accounts under which yield could generate. + * @param statuses Whether each account is an earner, respectively, according to the admin. + * @param feeRates The fee rates to be taken from the yield, respectively. + */ + function setEarnerDetails( + address[] calldata accounts, + bool[] calldata statuses, + uint16[] calldata feeRates + ) external; + + /* ============ Temporary Admin Migration ============ */ + + /** + * @notice Performs an arbitrarily defined migration. + * @param migrator The address of a migrator contract. + */ + function migrate(address migrator) external; + + /* ============ View/Pure Functions ============ */ + + /// @notice Maximum fee rate that can be set (1005 in basis points). + function MAX_FEE_RATE() external pure returns (uint16 maxFeeRate); + + /// @notice Registrar name of admins list. + function ADMINS_LIST_NAME() external pure returns (bytes32 adminsListName); + + /// @notice Registrar key holding value of whether the earners list can be ignored or not. + function EARNERS_LIST_IGNORED_KEY() external pure returns (bytes32 earnersListIgnoredKey); + + /// @notice Registrar name of earners list. + function EARNERS_LIST_NAME() external pure returns (bytes32 earnersListName); + + /// @notice Registrar key prefix to determine the migrator contract. + function MIGRATOR_KEY_PREFIX() external pure returns (bytes32 migratorKeyPrefix); + + /** + * @notice Returns the earner status for `account`. + * @param account The account being queried. + * @return status Whether the account is an earner. + */ + function earnerStatusFor(address account) external view returns (bool status); + + /** + * @notice Returns the statuses for multiple accounts. + * @param accounts The accounts being queried. + * @return statuses Whether each account is an earner, respectively. + */ + function earnerStatusesFor(address[] calldata accounts) external view returns (bool[] memory statuses); + + /** + * @notice Returns whether the list of earners can be ignored (thus making all accounts earners). + * @return isIgnored Whether the list of earners can be ignored. + */ + function isEarnersListIgnored() external view returns (bool isIgnored); + + /** + * @notice Returns whether `account` is a Registrar-approved earner. + * @param account The account being queried. + * @return isInList Whether the account is a Registrar-approved earner. + */ + function isInEarnersList(address account) external view returns (bool isInList); + + /** + * @notice Returns the earner details for `account`. + * @param account The account being queried. + * @return status Whether the account is an earner. + * @return feeRate The fee rate to be taken from the yield. + * @return admin The admin who set the details and who will collect the fee. + */ + function getEarnerDetails(address account) external view returns (bool status, uint16 feeRate, address admin); + + /** + * @notice Returns the earner details for multiple accounts, according to an admin. + * @param accounts The accounts being queried. + * @return statuses Whether each account is an earner, respectively. + * @return feeRates The fee rates to be taken from the yield, respectively. + * @return admins The admin who set the details and who will collect the fee, respectively. + */ + function getEarnerDetails( + address[] calldata accounts + ) external view returns (bool[] memory statuses, uint16[] memory feeRates, address[] memory admins); + + /** + * @notice Returns whether `account` is an admin. + * @param account The address of an account. + * @return isAdmin Whether the account is an admin. + */ + function isAdmin(address account) external view returns (bool isAdmin); + + /// @notice The account that can bypass the Registrar and call the `migrate(address migrator)` function. + function migrationAdmin() external view returns (address migrationAdmin); + + /// @notice Returns the address of the Registrar. + function registrar() external view returns (address); +} diff --git a/src/interfaces/IWrappedMToken.sol b/src/interfaces/IWrappedMToken.sol index 8e8fbbc..e00f6e7 100644 --- a/src/interfaces/IWrappedMToken.sol +++ b/src/interfaces/IWrappedMToken.sol @@ -85,6 +85,9 @@ interface IWrappedMToken is IMigratable, IERC20Extended { /// @notice Emitted when the non-governance migrate function is called by a account other than the migration admin. error UnauthorizedMigration(); + /// @notice Emitted in constructor if Earner Manager is 0x0. + error ZeroEarnerManager(); + /// @notice Emitted in constructor if Excess Destination is 0x0. error ZeroExcessDestination(); @@ -250,6 +253,9 @@ interface IWrappedMToken is IMigratable, IERC20Extended { /// @notice The address of the Registrar. function registrar() external view returns (address registrar); + /// @notice The address of the Earner Manager. + function earnerManager() external view returns (address earnerManager); + /// @notice The portion of total supply that is not earning yield. function totalNonEarningSupply() external view returns (uint240 totalSupply); diff --git a/test/integration/Deploy.t.sol b/test/integration/Deploy.t.sol index c6c172e..335b2a4 100644 --- a/test/integration/Deploy.t.sol +++ b/test/integration/Deploy.t.sol @@ -4,8 +4,8 @@ pragma solidity 0.8.26; import { Test } from "../../lib/forge-std/src/Test.sol"; +import { IEarnerManager } from "../../src/interfaces/IEarnerManager.sol"; import { IWrappedMToken } from "../../src/interfaces/IWrappedMToken.sol"; -import { IRegistrarLike } from "../../src/interfaces/IRegistrarLike.sol"; import { DeployBase } from "../../script/DeployBase.sol"; @@ -21,21 +21,36 @@ contract Deploy is Test, DeployBase { vm.setNonce(_DEPLOYER, uint64(_DEPLOYER_NONCE)); vm.startPrank(_DEPLOYER); - (address implementation_, address proxy_) = deploy(_M_TOKEN, _REGISTRAR, _EXCESS_DESTINATION, _MIGRATION_ADMIN); + ( + address earnerManagerImplementation_, + address earnerManagerProxy_, + address wrappedMTokenImplementation_, + address wrappedMTokenProxy_ + ) = deploy(_M_TOKEN, _REGISTRAR, _EXCESS_DESTINATION, _MIGRATION_ADMIN); vm.stopPrank(); + // Earner Manager Implementation assertions + assertEq(IEarnerManager(earnerManagerImplementation_).registrar(), _REGISTRAR); + + // Earner Manager Proxy assertions + assertEq(IEarnerManager(earnerManagerProxy_).registrar(), _REGISTRAR); + assertEq(IEarnerManager(earnerManagerProxy_).implementation(), earnerManagerImplementation_); + // Wrapped M Token Implementation assertions - assertEq(implementation_, getExpectedWrappedMTokenImplementation(_DEPLOYER, _DEPLOYER_NONCE)); - assertEq(IWrappedMToken(implementation_).migrationAdmin(), _MIGRATION_ADMIN); - assertEq(IWrappedMToken(implementation_).mToken(), _M_TOKEN); - assertEq(IWrappedMToken(implementation_).registrar(), _REGISTRAR); - assertEq(IWrappedMToken(implementation_).excessDestination(), _EXCESS_DESTINATION); + assertEq(wrappedMTokenImplementation_, getExpectedWrappedMTokenImplementation(_DEPLOYER, _DEPLOYER_NONCE)); + assertEq(IWrappedMToken(wrappedMTokenImplementation_).earnerManager(), earnerManagerProxy_); + assertEq(IWrappedMToken(wrappedMTokenImplementation_).migrationAdmin(), _MIGRATION_ADMIN); + assertEq(IWrappedMToken(wrappedMTokenImplementation_).mToken(), _M_TOKEN); + assertEq(IWrappedMToken(wrappedMTokenImplementation_).registrar(), _REGISTRAR); + assertEq(IWrappedMToken(wrappedMTokenImplementation_).excessDestination(), _EXCESS_DESTINATION); // Wrapped M Token Proxy assertions - assertEq(proxy_, getExpectedWrappedMTokenProxy(_DEPLOYER, _DEPLOYER_NONCE)); - assertEq(IWrappedMToken(proxy_).migrationAdmin(), _MIGRATION_ADMIN); - assertEq(IWrappedMToken(proxy_).mToken(), _M_TOKEN); - assertEq(IWrappedMToken(proxy_).registrar(), _REGISTRAR); - assertEq(IWrappedMToken(proxy_).excessDestination(), _EXCESS_DESTINATION); + assertEq(wrappedMTokenProxy_, getExpectedWrappedMTokenProxy(_DEPLOYER, _DEPLOYER_NONCE)); + assertEq(IWrappedMToken(wrappedMTokenProxy_).earnerManager(), earnerManagerProxy_); + assertEq(IWrappedMToken(wrappedMTokenProxy_).migrationAdmin(), _MIGRATION_ADMIN); + assertEq(IWrappedMToken(wrappedMTokenProxy_).mToken(), _M_TOKEN); + assertEq(IWrappedMToken(wrappedMTokenProxy_).registrar(), _REGISTRAR); + assertEq(IWrappedMToken(wrappedMTokenProxy_).excessDestination(), _EXCESS_DESTINATION); + assertEq(IWrappedMToken(wrappedMTokenProxy_).implementation(), wrappedMTokenImplementation_); } } diff --git a/test/integration/TestBase.sol b/test/integration/TestBase.sol index efa4be5..db8ab7c 100644 --- a/test/integration/TestBase.sol +++ b/test/integration/TestBase.sol @@ -7,8 +7,10 @@ import { Test } from "../../lib/forge-std/src/Test.sol"; import { IWrappedMToken } from "../../src/interfaces/IWrappedMToken.sol"; +import { EarnerManager } from "../../src/EarnerManager.sol"; +import { Proxy } from "../../src/Proxy.sol"; import { WrappedMToken } from "../../src/WrappedMToken.sol"; -import { MigratorV1 } from "../../src/MigratorV1.sol"; +import { WrappedMTokenMigratorV1 } from "../../src/WrappedMTokenMigratorV1.sol"; import { IMTokenLike, IRegistrarLike } from "./vendor/protocol/Interfaces.sol"; @@ -56,8 +58,10 @@ contract TestBase is Test { address[] internal _accounts = [_alice, _bob, _carol, _dave, _eric, _frank, _grace, _henry, _ivan, _judy]; - address internal _implementationV2; - address internal _migratorV1; + address internal _earnerManagerImplementation; + address internal _earnerManager; + address internal _wrappedMTokenImplementationV2; + address internal _wrappedMTokenMigratorV1; function _getSource(address token_) internal pure returns (address source_) { if (token_ == _USDC) return _USDC_SOURCE; @@ -142,16 +146,18 @@ contract TestBase is Test { } function _deployV2Components() internal { - _implementationV2 = address( - new WrappedMToken(address(_mToken), _registrar, _excessDestination, _migrationAdmin) + _earnerManagerImplementation = address(new EarnerManager(_registrar, _migrationAdmin)); + _earnerManager = address(new Proxy(_earnerManagerImplementation)); + _wrappedMTokenImplementationV2 = address( + new WrappedMToken(address(_mToken), _registrar, _earnerManager, _excessDestination, _migrationAdmin) ); - _migratorV1 = address(new MigratorV1(_implementationV2)); + _wrappedMTokenMigratorV1 = address(new WrappedMTokenMigratorV1(_wrappedMTokenImplementationV2)); } function _migrate() internal { _set( keccak256(abi.encode(_MIGRATOR_V1_PREFIX, address(_wrappedMToken))), - bytes32(uint256(uint160(_migratorV1))) + bytes32(uint256(uint160(_wrappedMTokenMigratorV1))) ); _wrappedMToken.migrate(); @@ -159,6 +165,6 @@ contract TestBase is Test { function _migrateFromAdmin() internal { vm.prank(_migrationAdmin); - _wrappedMToken.migrate(_migratorV1); + _wrappedMToken.migrate(_wrappedMTokenMigratorV1); } } diff --git a/test/unit/EarnerManager.sol b/test/unit/EarnerManager.sol new file mode 100644 index 0000000..870ebd3 --- /dev/null +++ b/test/unit/EarnerManager.sol @@ -0,0 +1,557 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.26; + +import { Test } from "../../lib/forge-std/src/Test.sol"; + +import { IEarnerManager } from "../../src/interfaces/IEarnerManager.sol"; + +import { MockRegistrar } from "./../utils/Mocks.sol"; +import { EarnerManagerHarness } from "../utils/EarnerManagerHarness.sol"; + +contract EarnerStatusManagerTests is Test { + bytes32 internal constant _EARNERS_LIST_IGNORED_KEY = "earners_list_ignored"; + bytes32 internal constant _EARNERS_LIST_NAME = "earners"; + bytes32 internal constant _ADMINS_LIST_NAME = "em_admins"; + + address internal _admin1 = makeAddr("admin1"); + address internal _admin2 = makeAddr("admin2"); + + address internal _alice = makeAddr("alice"); + address internal _bob = makeAddr("bob"); + address internal _carol = makeAddr("carol"); + address internal _dave = makeAddr("dave"); + address internal _frank = makeAddr("frank"); + + address internal _migrationAdmin = makeAddr("migrationAdmin"); + + MockRegistrar internal _registrar; + EarnerManagerHarness internal _earnerManager; + + function setUp() external { + _registrar = new MockRegistrar(); + _earnerManager = new EarnerManagerHarness(address(_registrar), _migrationAdmin); + + _registrar.setListContains(_ADMINS_LIST_NAME, _admin1, true); + _registrar.setListContains(_ADMINS_LIST_NAME, _admin2, true); + } + + /* ============ initial state ============ */ + function test_initialState() external view { + assertEq(_earnerManager.registrar(), address(_registrar)); + } + + /* ============ constructor ============ */ + function test_constructor_zeroRegistrar() external { + vm.expectRevert(IEarnerManager.ZeroRegistrar.selector); + new EarnerManagerHarness(address(0), address(0)); + } + function test_constructor_zeroMigrationAdmin() external { + vm.expectRevert(IEarnerManager.ZeroMigrationAdmin.selector); + new EarnerManagerHarness(address(_registrar), address(0)); + } + + /* ============ _setDetails ============ */ + function test_setDetails_zeroAccount() external { + vm.expectRevert(IEarnerManager.ZeroAccount.selector); + + _earnerManager.setDetails(address(0), false, address(0), 0); + } + + function test_setDetails_invalidDetails() external { + vm.expectRevert(IEarnerManager.InvalidDetails.selector); + + _earnerManager.setDetails(_alice, false, address(0), 1); + + vm.expectRevert(IEarnerManager.InvalidDetails.selector); + + _earnerManager.setDetails(_alice, false, _admin1, 0); + + vm.expectRevert(IEarnerManager.InvalidDetails.selector); + + _earnerManager.setDetails(_alice, true, address(0), 0); + } + + function test_setDetails_feeRateTooHigh() external { + vm.expectRevert(IEarnerManager.FeeRateTooHigh.selector); + + _earnerManager.setDetails(_alice, true, _admin1, 10_001); + } + + function test_setDetails_alreadyInEarnersList() external { + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + + vm.expectRevert(abi.encodeWithSelector(IEarnerManager.AlreadyInEarnersList.selector, _alice)); + + _earnerManager.setDetails(_alice, false, address(0), 0); + } + + function test_setDetails_earnerDetailsAlreadySet() external { + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 1); + + vm.expectRevert(abi.encodeWithSelector(IEarnerManager.EarnerDetailsAlreadySet.selector, _alice)); + + _earnerManager.setDetails(_alice, true, _admin2, 2); + } + + function test_setDetails() external { + _earnerManager.setDetails(_alice, true, _admin1, 1); + + (bool status_, uint16 feeRate_, address admin_) = _earnerManager.getEarnerDetails(_alice); + + assertTrue(status_); + assertEq(feeRate_, 1); + assertEq(admin_, _admin1); + } + + /* ============ setEarnerDetails ============ */ + function test_setEarnerDetails_notAdmin() external { + vm.expectRevert(IEarnerManager.NotAdmin.selector); + + vm.prank(_bob); + _earnerManager.setEarnerDetails(_alice, true, 0); + } + + function test_setEarnerDetails_earnersListIgnored() external { + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + + vm.expectRevert(IEarnerManager.EarnersListIgnored.selector); + + vm.prank(_admin1); + _earnerManager.setEarnerDetails(_alice, true, 0); + } + + function test_setEarnerDetails() external { + vm.expectEmit(); + emit IEarnerManager.EarnerDetailsSet(_alice, true, _admin1, 10_000); + + vm.prank(_admin1); + _earnerManager.setEarnerDetails(_alice, true, 10_000); + + (bool status_, uint16 feeRate_, address admin_) = _earnerManager.getEarnerDetails(_alice); + + assertTrue(status_); + assertEq(feeRate_, 10_000); + assertEq(admin_, _admin1); + } + + /* ============ setEarnerDetails batch ============ */ + function test_setEarnerDetails_batch_notAdmin() external { + vm.expectRevert(IEarnerManager.NotAdmin.selector); + + vm.prank(_alice); + _earnerManager.setEarnerDetails(new address[](0), new bool[](0), new uint16[](0)); + } + + function test_setEarnerDetails_batch_arrayLengthZero() external { + vm.expectRevert(IEarnerManager.ArrayLengthZero.selector); + + vm.prank(_admin1); + _earnerManager.setEarnerDetails(new address[](0), new bool[](2), new uint16[](2)); + } + + function test_setEarnerDetails_batch_arrayLengthMismatch() external { + vm.expectRevert(IEarnerManager.ArrayLengthMismatch.selector); + + vm.prank(_admin1); + _earnerManager.setEarnerDetails(new address[](1), new bool[](2), new uint16[](2)); + + vm.expectRevert(IEarnerManager.ArrayLengthMismatch.selector); + + vm.prank(_admin1); + _earnerManager.setEarnerDetails(new address[](2), new bool[](1), new uint16[](2)); + + vm.expectRevert(IEarnerManager.ArrayLengthMismatch.selector); + + vm.prank(_admin1); + _earnerManager.setEarnerDetails(new address[](2), new bool[](2), new uint16[](1)); + } + + function test_setEarnerDetails_batch_earnersListIgnored() external { + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + + vm.expectRevert(IEarnerManager.EarnersListIgnored.selector); + + vm.prank(_admin1); + _earnerManager.setEarnerDetails(new address[](2), new bool[](2), new uint16[](2)); + } + + function test_setEarnerDetails_batch() external { + address[] memory accounts_ = new address[](2); + accounts_[0] = _alice; + accounts_[1] = _bob; + + bool[] memory statuses_ = new bool[](2); + statuses_[0] = true; + statuses_[1] = true; + + uint16[] memory feeRates = new uint16[](2); + feeRates[0] = 1; + feeRates[1] = 10_000; + + vm.expectEmit(); + emit IEarnerManager.EarnerDetailsSet(_alice, true, _admin1, 1); + + vm.expectEmit(); + emit IEarnerManager.EarnerDetailsSet(_bob, true, _admin1, 10_000); + + vm.prank(_admin1); + _earnerManager.setEarnerDetails(accounts_, statuses_, feeRates); + + (bool status_, uint16 feeRate_, address admin_) = _earnerManager.getEarnerDetails(_alice); + + assertTrue(status_); + assertEq(feeRate_, 1); + assertEq(admin_, _admin1); + + (status_, feeRate_, admin_) = _earnerManager.getEarnerDetails(_bob); + + assertTrue(status_); + assertEq(feeRate_, 10_000); + assertEq(admin_, _admin1); + } + + /* ============ earnerStatusFor ============ */ + function test_earnerStatusFor_earnersListIgnored() external { + assertFalse(_earnerManager.earnerStatusFor(_alice)); + + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + + assertTrue(_earnerManager.earnerStatusFor(_alice)); + } + + function test_earnerStatusFor_inEarnersList() external { + assertFalse(_earnerManager.earnerStatusFor(_alice)); + + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + + assertTrue(_earnerManager.earnerStatusFor(_alice)); + } + + function test_earnerStatusFor_setByAdmin() external { + assertFalse(_earnerManager.earnerStatusFor(_alice)); + + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 0); + + assertTrue(_earnerManager.earnerStatusFor(_alice)); + } + + function test_earnerStatusFor_earnersListIgnoredAndInEarnersList() external { + assertFalse(_earnerManager.earnerStatusFor(_alice)); + + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + + assertTrue(_earnerManager.earnerStatusFor(_alice)); + } + + function test_earnerStatusFor_inEarnersListAndSetByAdmin() external { + assertFalse(_earnerManager.earnerStatusFor(_alice)); + + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 0); + + assertTrue(_earnerManager.earnerStatusFor(_alice)); + } + + function test_earnerStatusFor_earnersListIgnoredAndSetByAdmin() external { + assertFalse(_earnerManager.earnerStatusFor(_alice)); + + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 0); + + assertTrue(_earnerManager.earnerStatusFor(_alice)); + } + + function test_earnerStatusFor_earnersListIgnoredAndInEarnersListAndSetByAdmin() external { + assertFalse(_earnerManager.earnerStatusFor(_alice)); + + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 0); + + assertTrue(_earnerManager.earnerStatusFor(_alice)); + } + + /* ============ earnerStatusesFor ============ */ + function test_earnerStatusesFor_earnersListIgnored() external { + address[] memory accounts_ = new address[](3); + accounts_[0] = _alice; + accounts_[1] = _bob; + accounts_[2] = _carol; + + bool[] memory statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertFalse(statuses_[0]); + assertFalse(statuses_[1]); + assertFalse(statuses_[2]); + + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + + statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertTrue(statuses_[0]); + assertTrue(statuses_[1]); + assertTrue(statuses_[2]); + } + + function test_earnerStatusesFor_inEarnersList() external { + address[] memory accounts_ = new address[](3); + accounts_[0] = _alice; + accounts_[1] = _bob; + accounts_[2] = _carol; + + bool[] memory statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertFalse(statuses_[0]); + assertFalse(statuses_[1]); + assertFalse(statuses_[2]); + + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + + statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertTrue(statuses_[0]); + assertFalse(statuses_[1]); + assertFalse(statuses_[2]); + } + + function test_earnerStatusesFor_setByAdmin() external { + address[] memory accounts_ = new address[](3); + accounts_[0] = _alice; + accounts_[1] = _bob; + accounts_[2] = _carol; + + bool[] memory statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertFalse(statuses_[0]); + assertFalse(statuses_[1]); + assertFalse(statuses_[2]); + + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 0); + _earnerManager.setInternalEarnerDetails(_bob, _admin2, 0); + + statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertTrue(statuses_[0]); + assertTrue(statuses_[1]); + assertFalse(statuses_[2]); + } + + function test_earnerStatusesFor_earnersListIgnoredAndInEarnersList() external { + address[] memory accounts_ = new address[](3); + accounts_[0] = _alice; + accounts_[1] = _bob; + accounts_[2] = _carol; + + bool[] memory statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertFalse(statuses_[0]); + assertFalse(statuses_[1]); + assertFalse(statuses_[2]); + + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + + statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertTrue(statuses_[0]); + assertTrue(statuses_[1]); + assertTrue(statuses_[2]); + } + + function test_earnerStatusesFor_inEarnersListAndSetByAdmin() external { + address[] memory accounts_ = new address[](3); + accounts_[0] = _alice; + accounts_[1] = _bob; + accounts_[2] = _carol; + + bool[] memory statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertFalse(statuses_[0]); + assertFalse(statuses_[1]); + assertFalse(statuses_[2]); + + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + _registrar.setListContains(_EARNERS_LIST_NAME, _carol, true); + _earnerManager.setInternalEarnerDetails(_bob, _admin1, 0); + _earnerManager.setInternalEarnerDetails(_carol, _admin2, 0); + + statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertTrue(statuses_[0]); + assertTrue(statuses_[1]); + assertTrue(statuses_[2]); + } + + function test_earnerStatusesFor_earnersListIgnoredAndSetByAdmin() external { + address[] memory accounts_ = new address[](3); + accounts_[0] = _alice; + accounts_[1] = _bob; + accounts_[2] = _carol; + + bool[] memory statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertFalse(statuses_[0]); + assertFalse(statuses_[1]); + assertFalse(statuses_[2]); + + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 0); + _earnerManager.setInternalEarnerDetails(_bob, _admin2, 0); + + statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertTrue(statuses_[0]); + assertTrue(statuses_[1]); + assertTrue(statuses_[2]); + } + + function test_earnerStatusesFor_earnersListIgnoredAndInEarnersListAndSetByAdmin() external { + address[] memory accounts_ = new address[](3); + accounts_[0] = _alice; + accounts_[1] = _bob; + accounts_[2] = _carol; + + bool[] memory statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertFalse(statuses_[0]); + assertFalse(statuses_[1]); + assertFalse(statuses_[2]); + + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + _registrar.setListContains(_EARNERS_LIST_NAME, _carol, true); + _earnerManager.setInternalEarnerDetails(_bob, _admin1, 0); + _earnerManager.setInternalEarnerDetails(_carol, _admin2, 0); + + statuses_ = _earnerManager.earnerStatusesFor(accounts_); + + assertTrue(statuses_[0]); + assertTrue(statuses_[1]); + assertTrue(statuses_[2]); + } + + /* ============ isEarnersListIgnored ============ */ + function test_isEarnersListIgnored() external { + assertFalse(_earnerManager.isEarnersListIgnored()); + + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + + assertTrue(_earnerManager.isEarnersListIgnored()); + } + + /* ============ isInEarnersList ============ */ + function test_isInEarnersList() external { + assertFalse(_earnerManager.isInEarnersList(_alice)); + + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + + assertTrue(_earnerManager.isInEarnersList(_alice)); + } + + /* ============ getEarnerDetails ============ */ + + function test_getEarnerDetails_earnersListIgnored() external { + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 1); + + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + + (bool status_, uint16 feeRate_, address admin_) = _earnerManager.getEarnerDetails(_alice); + + assertTrue(status_); + assertEq(feeRate_, 0); + assertEq(admin_, address(0)); + } + + function test_getEarnerDetails_inEarnersList() external { + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 1); + + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + + (bool status_, uint16 feeRate_, address admin_) = _earnerManager.getEarnerDetails(_alice); + + assertTrue(status_); + assertEq(feeRate_, 0); + assertEq(admin_, address(0)); + } + + function test_getEarnerDetails_invalidAdmin() external { + _earnerManager.setInternalEarnerDetails(_alice, _bob, 1); + + (bool status_, uint16 feeRate_, address admin_) = _earnerManager.getEarnerDetails(_alice); + + assertFalse(status_); + assertEq(feeRate_, 0); + assertEq(admin_, address(0)); + } + + function test_getEarnerDetails() external { + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 1); + + (bool status_, uint16 feeRate_, address admin_) = _earnerManager.getEarnerDetails(_alice); + + assertTrue(status_); + assertEq(feeRate_, 1); + assertEq(admin_, _admin1); + } + + /* ============ getEarnerDetails batch ============ */ + function test_getEarnerDetails_batch_earnersListIgnored() external { + _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); + + address[] memory accounts_ = new address[](2); + accounts_[0] = _alice; + accounts_[1] = _bob; + + (bool[] memory statuses_, uint16[] memory feeRates_, address[] memory admins_) = _earnerManager + .getEarnerDetails(accounts_); + + assertTrue(statuses_[0]); + assertEq(feeRates_[0], 0); + assertEq(admins_[0], address(0)); + + assertTrue(statuses_[1]); + assertEq(feeRates_[1], 0); + assertEq(admins_[1], address(0)); + } + + function test_getEarnerDetails_batch() external { + _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); + + _earnerManager.setInternalEarnerDetails(_bob, _admin1, 1); + _earnerManager.setInternalEarnerDetails(_carol, _frank, 2); // Invalid admin + + address[] memory accounts_ = new address[](4); + accounts_[0] = _alice; + accounts_[1] = _bob; + accounts_[2] = _carol; + accounts_[3] = _dave; + + (bool[] memory statuses_, uint16[] memory feeRates_, address[] memory admins_) = _earnerManager + .getEarnerDetails(accounts_); + + assertTrue(statuses_[0]); + assertEq(feeRates_[0], 0); + assertEq(admins_[0], address(0)); + + assertTrue(statuses_[1]); + assertEq(feeRates_[1], 1); + assertEq(admins_[1], _admin1); + + assertFalse(statuses_[2]); + assertEq(feeRates_[2], 0); + assertEq(admins_[2], address(0)); + + assertFalse(statuses_[3]); + assertEq(feeRates_[3], 0); + assertEq(admins_[3], address(0)); + } + + /* ============ isAdmin ============ */ + function test_isAdmin() external view { + assertFalse(_earnerManager.isAdmin(_alice)); + assertTrue(_earnerManager.isAdmin(_admin1)); + assertTrue(_earnerManager.isAdmin(_admin2)); + } +} diff --git a/test/unit/Migration.t.sol b/test/unit/Migration.t.sol deleted file mode 100644 index aaa5d75..0000000 --- a/test/unit/Migration.t.sol +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity 0.8.26; - -import { Test } from "../../lib/forge-std/src/Test.sol"; - -import { IWrappedMToken } from "../../src/interfaces/IWrappedMToken.sol"; - -import { WrappedMToken } from "../../src/WrappedMToken.sol"; -import { Proxy } from "../../src/Proxy.sol"; - -import { MockM, MockRegistrar } from "./../utils/Mocks.sol"; - -contract WrappedMTokenV3 { - function foo() external pure returns (uint256) { - return 1; - } -} - -contract WrappedMTokenMigratorV2 { - uint256 private constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; - - address public immutable implementationV2; - - constructor(address implementationV3_) { - implementationV2 = implementationV3_; - } - - fallback() external virtual { - address implementationV3_ = implementationV2; - - assembly { - sstore(_IMPLEMENTATION_SLOT, implementationV3_) - } - } -} - -contract MigrationTests is Test { - uint56 internal constant _EXP_SCALED_ONE = 1e12; - - bytes32 internal constant _MIGRATOR_V2_PREFIX = "wm_migrator_v2"; - - address internal _alice = makeAddr("alice"); - address internal _bob = makeAddr("bob"); - address internal _carol = makeAddr("carol"); - address internal _dave = makeAddr("dave"); - - address internal _excessDestination = makeAddr("excessDestination"); - address internal _migrationAdmin = makeAddr("migrationAdmin"); - - MockM internal _mToken; - MockRegistrar internal _registrar; - WrappedMToken internal _implementation; - IWrappedMToken internal _wrappedMToken; - - function setUp() external { - _registrar = new MockRegistrar(); - - _mToken = new MockM(); - _mToken.setCurrentIndex(_EXP_SCALED_ONE); - - _implementation = new WrappedMToken(address(_mToken), address(_registrar), _excessDestination, _migrationAdmin); - - _wrappedMToken = IWrappedMToken(address(new Proxy(address(_implementation)))); - } - - function test_migration() external { - WrappedMTokenV3 implementationV3_ = new WrappedMTokenV3(); - address migrator_ = address(new WrappedMTokenMigratorV2(address(implementationV3_))); - - _registrar.set( - keccak256(abi.encode(_MIGRATOR_V2_PREFIX, address(_wrappedMToken))), - bytes32(uint256(uint160(migrator_))) - ); - - vm.expectRevert(); - WrappedMTokenV3(address(_wrappedMToken)).foo(); - - _wrappedMToken.migrate(); - - assertEq(WrappedMTokenV3(address(_wrappedMToken)).foo(), 1); - } - - function test_migration_fromAdmin() external { - WrappedMTokenV3 implementationV3_ = new WrappedMTokenV3(); - address migrator_ = address(new WrappedMTokenMigratorV2(address(implementationV3_))); - - vm.expectRevert(); - WrappedMTokenV3(address(_wrappedMToken)).foo(); - - vm.prank(_migrationAdmin); - _wrappedMToken.migrate(migrator_); - - assertEq(WrappedMTokenV3(address(_wrappedMToken)).foo(), 1); - } -} diff --git a/test/unit/Migrations.t.sol b/test/unit/Migrations.t.sol new file mode 100644 index 0000000..4534316 --- /dev/null +++ b/test/unit/Migrations.t.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.26; + +import { Test } from "../../lib/forge-std/src/Test.sol"; + +import { IEarnerManager } from "../../src/interfaces/IEarnerManager.sol"; +import { IWrappedMToken } from "../../src/interfaces/IWrappedMToken.sol"; + +import { EarnerManager } from "../../src/EarnerManager.sol"; +import { WrappedMToken } from "../../src/WrappedMToken.sol"; +import { Proxy } from "../../src/Proxy.sol"; + +import { MockM, MockRegistrar } from "./../utils/Mocks.sol"; + +contract Foo { + function bar() external pure returns (uint256) { + return 1; + } +} + +contract Migrator { + uint256 private constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + address public immutable implementationV2; + + constructor(address implementation_) { + implementationV2 = implementation_; + } + + fallback() external virtual { + address implementation_ = implementationV2; + + assembly { + sstore(_IMPLEMENTATION_SLOT, implementation_) + } + } +} + +contract MigrationTests is Test { + uint56 internal constant _EXP_SCALED_ONE = 1e12; + + bytes32 internal constant _WM_MIGRATOR_KEY_PREFIX = "wm_migrator_v2"; + bytes32 internal constant _EM_MIGRATOR_KEY_PREFIX = "em_migrator_v1"; + + address internal _alice = makeAddr("alice"); + address internal _bob = makeAddr("bob"); + address internal _carol = makeAddr("carol"); + address internal _dave = makeAddr("dave"); + + address internal _earnerManager = makeAddr("earnerManager"); + address internal _excessDestination = makeAddr("excessDestination"); + address internal _migrationAdmin = makeAddr("migrationAdmin"); + + function test_wrappedMToken_migration() external { + MockRegistrar registrar_ = new MockRegistrar(); + address mToken_ = makeAddr("mToken"); + + address implementation_ = address( + new WrappedMToken( + address(mToken_), + address(registrar_), + _earnerManager, + _excessDestination, + _migrationAdmin + ) + ); + + address proxy_ = address(new Proxy(address(implementation_))); + address migrator_ = address(new Migrator(address(new Foo()))); + + registrar_.set(keccak256(abi.encode(_WM_MIGRATOR_KEY_PREFIX, proxy_)), bytes32(uint256(uint160(migrator_)))); + + vm.expectRevert(); + Foo(proxy_).bar(); + + IWrappedMToken(proxy_).migrate(); + + assertEq(Foo(proxy_).bar(), 1); + } + + function test_wrappedMToken_migration_fromAdmin() external { + MockRegistrar registrar_ = new MockRegistrar(); + address mToken_ = makeAddr("mToken"); + + address implementation_ = address( + new WrappedMToken( + address(mToken_), + address(registrar_), + _earnerManager, + _excessDestination, + _migrationAdmin + ) + ); + + address proxy_ = address(new Proxy(address(implementation_))); + address migrator_ = address(new Migrator(address(new Foo()))); + + vm.expectRevert(); + Foo(proxy_).bar(); + + vm.prank(_migrationAdmin); + IWrappedMToken(proxy_).migrate(migrator_); + + assertEq(Foo(proxy_).bar(), 1); + } + + function test_earnerManager_migration() external { + MockRegistrar registrar_ = new MockRegistrar(); + + address implementation_ = address(new EarnerManager(address(registrar_), _migrationAdmin)); + address proxy_ = address(new Proxy(address(implementation_))); + address migrator_ = address(new Migrator(address(new Foo()))); + + registrar_.set(keccak256(abi.encode(_EM_MIGRATOR_KEY_PREFIX, proxy_)), bytes32(uint256(uint160(migrator_)))); + + vm.expectRevert(); + Foo(proxy_).bar(); + + IWrappedMToken(proxy_).migrate(); + + assertEq(Foo(proxy_).bar(), 1); + } + + function test_earnerManager_migration_fromAdmin() external { + MockRegistrar registrar_ = new MockRegistrar(); + + address implementation_ = address(new EarnerManager(address(registrar_), _migrationAdmin)); + address proxy_ = address(new Proxy(address(implementation_))); + address migrator_ = address(new Migrator(address(new Foo()))); + + vm.expectRevert(); + Foo(proxy_).bar(); + + vm.prank(_migrationAdmin); + IEarnerManager(proxy_).migrate(migrator_); + + assertEq(Foo(proxy_).bar(), 1); + } +} diff --git a/test/unit/Stories.t.sol b/test/unit/Stories.t.sol index adffb6a..23d882d 100644 --- a/test/unit/Stories.t.sol +++ b/test/unit/Stories.t.sol @@ -9,7 +9,7 @@ import { IWrappedMToken } from "../../src/interfaces/IWrappedMToken.sol"; import { WrappedMToken } from "../../src/WrappedMToken.sol"; import { Proxy } from "../../src/Proxy.sol"; -import { MockM, MockRegistrar } from "../utils/Mocks.sol"; +import { MockEarnerManager, MockM, MockRegistrar } from "../utils/Mocks.sol"; contract Tests is Test { uint56 internal constant _EXP_SCALED_ONE = 1e12; @@ -24,6 +24,9 @@ contract Tests is Test { address internal _excessDestination = makeAddr("excessDestination"); address internal _migrationAdmin = makeAddr("migrationAdmin"); + address internal _vault = makeAddr("vault"); + + MockEarnerManager internal _earnerManager; MockM internal _mToken; MockRegistrar internal _registrar; WrappedMToken internal _implementation; @@ -35,14 +38,22 @@ contract Tests is Test { _mToken = new MockM(); _mToken.setCurrentIndex(_EXP_SCALED_ONE); - _implementation = new WrappedMToken(address(_mToken), address(_registrar), _excessDestination, _migrationAdmin); + _earnerManager = new MockEarnerManager(); + + _implementation = new WrappedMToken( + address(_mToken), + address(_registrar), + address(_earnerManager), + _excessDestination, + _migrationAdmin + ); _wrappedMToken = IWrappedMToken(address(new Proxy(address(_implementation)))); } function test_story() external { - _registrar.setListContains(_EARNERS_LIST, _alice, true); - _registrar.setListContains(_EARNERS_LIST, _bob, true); + _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); + _earnerManager.setEarnerDetails(_bob, true, 0, address(0)); _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); _wrappedMToken.enableEarning(); @@ -239,7 +250,7 @@ contract Tests is Test { assertEq(_wrappedMToken.totalAccruedYield(), 133_333336); assertEq(_wrappedMToken.excess(), 416_666664); - _registrar.setListContains(_EARNERS_LIST, _alice, false); + _earnerManager.setEarnerDetails(_alice, false, 0, address(0)); _wrappedMToken.stopEarningFor(_alice); @@ -254,7 +265,7 @@ contract Tests is Test { assertEq(_wrappedMToken.totalAccruedYield(), 66_666672); assertEq(_wrappedMToken.excess(), 416_666664); - _registrar.setListContains(_EARNERS_LIST, _carol, true); + _earnerManager.setEarnerDetails(_carol, true, 0, address(0)); _wrappedMToken.startEarningFor(_carol); @@ -362,8 +373,8 @@ contract Tests is Test { } function test_noExcessCreep() external { - _registrar.setListContains(_EARNERS_LIST, _alice, true); - _registrar.setListContains(_EARNERS_LIST, _bob, true); + _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); + _earnerManager.setEarnerDetails(_bob, true, 0, address(0)); _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); _mToken.setCurrentIndex(_EXP_SCALED_ONE + 3e11 - 1); @@ -397,8 +408,8 @@ contract Tests is Test { } function test_dustWrapping() external { - _registrar.setListContains(_EARNERS_LIST, _alice, true); - _registrar.setListContains(_EARNERS_LIST, _bob, true); + _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); + _earnerManager.setEarnerDetails(_bob, true, 0, address(0)); _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); _mToken.setCurrentIndex(_EXP_SCALED_ONE + 1); diff --git a/test/unit/WrappedMToken.t.sol b/test/unit/WrappedMToken.t.sol index 46776fb..32239be 100644 --- a/test/unit/WrappedMToken.t.sol +++ b/test/unit/WrappedMToken.t.sol @@ -12,7 +12,7 @@ import { IndexingMath } from "../../src/libs/IndexingMath.sol"; import { Proxy } from "../../src/Proxy.sol"; -import { MockM, MockRegistrar } from "../utils/Mocks.sol"; +import { MockEarnerManager, MockM, MockRegistrar } from "../utils/Mocks.sol"; import { WrappedMTokenHarness } from "../utils/WrappedMTokenHarness.sol"; // TODO: Test for `totalAccruedYield()`. @@ -36,6 +36,7 @@ contract WrappedMTokenTests is Test { uint128 internal _currentIndex; + MockEarnerManager internal _earnerManager; MockM internal _mToken; MockRegistrar internal _registrar; WrappedMTokenHarness internal _implementation; @@ -47,9 +48,12 @@ contract WrappedMTokenTests is Test { _mToken = new MockM(); _mToken.setCurrentIndex(_EXP_SCALED_ONE); + _earnerManager = new MockEarnerManager(); + _implementation = new WrappedMTokenHarness( address(_mToken), address(_registrar), + address(_earnerManager), _excessDestination, _migrationAdmin ); @@ -73,22 +77,39 @@ contract WrappedMTokenTests is Test { function test_constructor_zeroMToken() external { vm.expectRevert(IWrappedMToken.ZeroMToken.selector); - new WrappedMTokenHarness(address(0), address(0), address(0), address(0)); + new WrappedMTokenHarness(address(0), address(0), address(0), address(0), address(0)); } function test_constructor_zeroRegistrar() external { vm.expectRevert(IWrappedMToken.ZeroRegistrar.selector); - new WrappedMTokenHarness(address(_mToken), address(0), address(0), address(0)); + new WrappedMTokenHarness(address(_mToken), address(0), address(0), address(0), address(0)); + } + + function test_constructor_zeroEarnerManager() external { + vm.expectRevert(IWrappedMToken.ZeroEarnerManager.selector); + new WrappedMTokenHarness(address(_mToken), address(_registrar), address(0), address(0), address(0)); } function test_constructor_zeroExcessDestination() external { vm.expectRevert(IWrappedMToken.ZeroExcessDestination.selector); - new WrappedMTokenHarness(address(_mToken), address(_registrar), address(0), address(0)); + new WrappedMTokenHarness( + address(_mToken), + address(_registrar), + address(_earnerManager), + address(0), + address(0) + ); } function test_constructor_zeroMigrationAdmin() external { vm.expectRevert(IWrappedMToken.ZeroMigrationAdmin.selector); - new WrappedMTokenHarness(address(_mToken), address(_registrar), _excessDestination, address(0)); + new WrappedMTokenHarness( + address(_mToken), + address(_registrar), + address(_earnerManager), + _excessDestination, + address(0) + ); } function test_constructor_zeroImplementation() external { @@ -138,7 +159,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.enableEarning(); - _wrappedMToken.setAccountOf(_alice, 0, _EXP_SCALED_ONE); + _wrappedMToken.setAccountOf(_alice, 0, _EXP_SCALED_ONE, false); _mToken.setBalanceOf(_alice, 1_002); @@ -190,7 +211,7 @@ contract WrappedMTokenTests is Test { balance_ = uint240(bound(balance_, 0, _getMaxAmount(accountIndex_))); if (accountEarning_) { - _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_); + _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_, false); _wrappedMToken.setTotalEarningSupply(balance_); _wrappedMToken.setPrincipalOfTotalEarningSupply( @@ -245,7 +266,7 @@ contract WrappedMTokenTests is Test { balance_ = uint240(bound(balance_, 0, _getMaxAmount(accountIndex_))); if (accountEarning_) { - _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_); + _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_, false); _wrappedMToken.setTotalEarningSupply(balance_); _wrappedMToken.setPrincipalOfTotalEarningSupply( @@ -301,7 +322,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.enableEarning(); - _wrappedMToken.setAccountOf(_alice, 999, _currentIndex); + _wrappedMToken.setAccountOf(_alice, 999, _currentIndex, false); vm.expectRevert(abi.encodeWithSelector(IWrappedMToken.InsufficientBalance.selector, _alice, 999, 1_000)); vm.prank(_alice); @@ -340,7 +361,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setPrincipalOfTotalEarningSupply(909); _wrappedMToken.setTotalEarningSupply(1_000); - _wrappedMToken.setAccountOf(_alice, 1_000, _currentIndex); + _wrappedMToken.setAccountOf(_alice, 1_000, _currentIndex, false); _mToken.setBalanceOf(address(_wrappedMToken), 1_000); @@ -381,7 +402,7 @@ contract WrappedMTokenTests is Test { balance_ = uint240(bound(balance_, 0, _getMaxAmount(accountIndex_))); if (accountEarning_) { - _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_); + _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_, false); _wrappedMToken.setTotalEarningSupply(balance_); _wrappedMToken.setPrincipalOfTotalEarningSupply( @@ -445,7 +466,7 @@ contract WrappedMTokenTests is Test { balance_ = uint240(bound(balance_, 0, _getMaxAmount(accountIndex_))); if (accountEarning_) { - _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_); + _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_, false); _wrappedMToken.setTotalEarningSupply(balance_); _wrappedMToken.setPrincipalOfTotalEarningSupply( @@ -493,7 +514,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.enableEarning(); - _wrappedMToken.setAccountOf(_alice, 1_000, _EXP_SCALED_ONE); + _wrappedMToken.setAccountOf(_alice, 1_000, _EXP_SCALED_ONE, false); assertEq(_wrappedMToken.balanceOf(_alice), 1_000); @@ -513,7 +534,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setTotalEarningSupply(balance_); - _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_); + _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_, false); _mToken.setCurrentIndex(index_); @@ -581,7 +602,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.enableEarning(); - _wrappedMToken.setAccountOf(_alice, 999, _currentIndex); + _wrappedMToken.setAccountOf(_alice, 999, _currentIndex, false); vm.expectRevert(abi.encodeWithSelector(IWrappedMToken.InsufficientBalance.selector, _alice, 999, 1_000)); vm.prank(_alice); @@ -642,7 +663,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setTotalNonEarningSupply(500); - _wrappedMToken.setAccountOf(_alice, 1_000, _currentIndex); + _wrappedMToken.setAccountOf(_alice, 1_000, _currentIndex, false); _wrappedMToken.setAccountOf(_bob, 500); vm.prank(_alice); @@ -679,7 +700,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setTotalNonEarningSupply(1_000); _wrappedMToken.setAccountOf(_alice, 1_000); - _wrappedMToken.setAccountOf(_bob, 500, _currentIndex); + _wrappedMToken.setAccountOf(_bob, 500, _currentIndex, false); vm.prank(_alice); _wrappedMToken.transfer(_bob, 500); @@ -701,8 +722,8 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setPrincipalOfTotalEarningSupply(1_363); _wrappedMToken.setTotalEarningSupply(1_500); - _wrappedMToken.setAccountOf(_alice, 1_000, _currentIndex); - _wrappedMToken.setAccountOf(_bob, 500, _currentIndex); + _wrappedMToken.setAccountOf(_alice, 1_000, _currentIndex, false); + _wrappedMToken.setAccountOf(_bob, 500, _currentIndex, false); vm.prank(_alice); _wrappedMToken.transfer(_bob, 500); @@ -740,7 +761,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setPrincipalOfTotalEarningSupply(909); _wrappedMToken.setTotalEarningSupply(1_000); - _wrappedMToken.setAccountOf(_alice, 1_000, _currentIndex); + _wrappedMToken.setAccountOf(_alice, 1_000, _currentIndex, false); _mToken.setCurrentIndex((_currentIndex * 5) / 3); // 1_833333447838 @@ -776,7 +797,7 @@ contract WrappedMTokenTests is Test { aliceBalance_ = uint240(bound(aliceBalance_, 0, _getMaxAmount(aliceIndex_) / 4)); if (aliceEarning_) { - _wrappedMToken.setAccountOf(_alice, aliceBalance_, aliceIndex_); + _wrappedMToken.setAccountOf(_alice, aliceBalance_, aliceIndex_, false); _wrappedMToken.setTotalEarningSupply(aliceBalance_); _wrappedMToken.setPrincipalOfTotalEarningSupply( @@ -791,7 +812,7 @@ contract WrappedMTokenTests is Test { bobBalance_ = uint240(bound(bobBalance_, 0, _getMaxAmount(bobIndex_) / 4)); if (bobEarning_) { - _wrappedMToken.setAccountOf(_bob, bobBalance_, bobIndex_); + _wrappedMToken.setAccountOf(_bob, bobBalance_, bobIndex_, false); _wrappedMToken.setTotalEarningSupply(_wrappedMToken.totalEarningSupply() + bobBalance_); _wrappedMToken.setPrincipalOfTotalEarningSupply( @@ -878,7 +899,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setAccountOf(_alice, 1_000); - _registrar.setListContains(_EARNERS_LIST, _alice, true); + _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); vm.expectEmit(); emit IWrappedMToken.StartedEarning(_alice); @@ -906,7 +927,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setAccountOf(_alice, aliceBalance_); - _registrar.setListContains(_EARNERS_LIST, _alice, true); + _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); vm.expectRevert(UIntMath.InvalidUInt112.selector); _wrappedMToken.startEarningFor(_alice); @@ -924,7 +945,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setAccountOf(_alice, balance_); - _registrar.setListContains(_EARNERS_LIST, _alice, true); + _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); _mToken.setCurrentIndex(index_); @@ -946,7 +967,7 @@ contract WrappedMTokenTests is Test { function test_startEarningFor_batch_notApprovedEarner() external { _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); - _registrar.setListContains(_EARNERS_LIST, _alice, true); + _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); _wrappedMToken.enableEarning(); @@ -960,8 +981,8 @@ contract WrappedMTokenTests is Test { function test_startEarningFor_batch() external { _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); - _registrar.setListContains(_EARNERS_LIST, _alice, true); - _registrar.setListContains(_EARNERS_LIST, _bob, true); + _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); + _earnerManager.setEarnerDetails(_bob, true, 0, address(0)); _wrappedMToken.enableEarning(); @@ -980,7 +1001,7 @@ contract WrappedMTokenTests is Test { /* ============ stopEarningFor ============ */ function test_stopEarningFor_isApprovedEarner() external { - _registrar.setListContains(_EARNERS_LIST, _alice, true); + _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); vm.expectRevert(abi.encodeWithSelector(IWrappedMToken.IsApprovedEarner.selector, _alice)); _wrappedMToken.stopEarningFor(_alice); @@ -994,7 +1015,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setPrincipalOfTotalEarningSupply(909); _wrappedMToken.setTotalEarningSupply(1_000); - _wrappedMToken.setAccountOf(_alice, 999, _currentIndex); + _wrappedMToken.setAccountOf(_alice, 999, _currentIndex, false); vm.expectEmit(); emit IWrappedMToken.StoppedEarning(_alice); @@ -1019,7 +1040,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setTotalEarningSupply(balance_); - _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_); + _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_, false); _mToken.setCurrentIndex(index_); @@ -1036,7 +1057,7 @@ contract WrappedMTokenTests is Test { /* ============ stopEarningFor batch ============ */ function test_stopEarningFor_batch_isApprovedEarner() external { - _registrar.setListContains(_EARNERS_LIST, _bob, true); + _earnerManager.setEarnerDetails(_bob, true, 0, address(0)); address[] memory accounts_ = new address[](2); accounts_[0] = _alice; @@ -1051,8 +1072,8 @@ contract WrappedMTokenTests is Test { _wrappedMToken.enableEarning(); - _wrappedMToken.setAccountOf(_alice, 0, _currentIndex); - _wrappedMToken.setAccountOf(_bob, 0, _currentIndex); + _wrappedMToken.setAccountOf(_alice, 0, _currentIndex, false); + _wrappedMToken.setAccountOf(_bob, 0, _currentIndex, false); address[] memory accounts_ = new address[](2); accounts_[0] = _alice; @@ -1150,7 +1171,7 @@ contract WrappedMTokenTests is Test { _wrappedMToken.enableEarning(); - _wrappedMToken.setAccountOf(_alice, 500, _EXP_SCALED_ONE); + _wrappedMToken.setAccountOf(_alice, 500, _EXP_SCALED_ONE, false); assertEq(_wrappedMToken.balanceOf(_alice), 500); @@ -1239,8 +1260,8 @@ contract WrappedMTokenTests is Test { uint128 index_ ) external { _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); - _registrar.setListContains(_EARNERS_LIST, _alice, true); - _registrar.setListContains(_EARNERS_LIST, _bob, true); + _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); + _earnerManager.setEarnerDetails(_bob, true, 0, address(0)); _wrappedMToken.enableEarning(); diff --git a/test/utils/EarnerManagerHarness.sol b/test/utils/EarnerManagerHarness.sol new file mode 100644 index 0000000..e24ed5c --- /dev/null +++ b/test/utils/EarnerManagerHarness.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity 0.8.26; + +import { EarnerManager } from "../../src/EarnerManager.sol"; + +contract EarnerManagerHarness is EarnerManager { + constructor(address registrar_, address migrationAdmin_) EarnerManager(registrar_, migrationAdmin_) {} + + function setInternalEarnerDetails(address account_, address admin_, uint16 feeRate_) external { + _earnerDetails[account_] = EarnerDetails(admin_, feeRate_); + } + + function setDetails(address account_, bool status_, address admin_, uint16 feeRate_) external { + _setDetails(account_, status_, admin_, feeRate_); + } +} diff --git a/test/utils/Mocks.sol b/test/utils/Mocks.sol index 805eb7b..2e11d89 100644 --- a/test/utils/Mocks.sol +++ b/test/utils/Mocks.sol @@ -52,3 +52,23 @@ contract MockRegistrar { listContains[list_][account_] = contains_; } } + +contract MockEarnerManager { + struct EarnerDetails { + bool status; + uint16 feeRate; + address admin; + } + + mapping(address account => EarnerDetails earnerDetails) internal _earnerDetails; + + function setEarnerDetails(address account_, bool status_, uint16 feeRate_, address admin_) external { + _earnerDetails[account_] = EarnerDetails(status_, feeRate_, admin_); + } + + function getEarnerDetails(address account_) external view returns (bool status_, uint16 feeRate_, address admin_) { + EarnerDetails storage earnerDetails_ = _earnerDetails[account_]; + + return (earnerDetails_.status, earnerDetails_.feeRate, earnerDetails_.admin); + } +} diff --git a/test/utils/WrappedMTokenHarness.sol b/test/utils/WrappedMTokenHarness.sol index b285c83..796a76c 100644 --- a/test/utils/WrappedMTokenHarness.sol +++ b/test/utils/WrappedMTokenHarness.sol @@ -8,9 +8,10 @@ contract WrappedMTokenHarness is WrappedMToken { constructor( address mToken_, address registrar_, + address earnerManager_, address excessDestination_, address migrationAdmin_ - ) WrappedMToken(mToken_, registrar_, excessDestination_, migrationAdmin_) {} + ) WrappedMToken(mToken_, registrar_, earnerManager_, excessDestination_, migrationAdmin_) {} function setIsEarningOf(address account_, bool isEarning_) external { _accounts[account_].isEarning = isEarning_; @@ -20,12 +21,12 @@ contract WrappedMTokenHarness is WrappedMToken { _accounts[account_].lastIndex = uint128(index_); } - function setAccountOf(address account_, uint256 balance_, uint256 index_) external { - _accounts[account_] = Account(true, uint240(balance_), uint128(index_)); + function setAccountOf(address account_, uint256 balance_, uint256 index_, bool hasEarnerDetails_) external { + _accounts[account_] = Account(true, uint240(balance_), uint128(index_), hasEarnerDetails_); } function setAccountOf(address account_, uint256 balance_) external { - _accounts[account_] = Account(false, uint240(balance_), 0); + _accounts[account_] = Account(false, uint240(balance_), 0, false); } function setTotalNonEarningSupply(uint256 totalNonEarningSupply_) external { From e22f3fbad6d84e923fe2aeadf2a668ae05eb94e4 Mon Sep 17 00:00:00 2001 From: Michael De Luca Date: Thu, 24 Oct 2024 14:54:03 -0400 Subject: [PATCH 2/3] fix: PR Review --- script/DeployBase.sol | 12 +- src/EarnerManager.sol | 62 +++--- src/WrappedMToken.sol | 60 +++--- src/interfaces/IEarnerManager.sol | 29 +-- src/interfaces/IWrappedMToken.sol | 3 + test/unit/EarnerManager.sol | 95 ++++++--- test/unit/WrappedMToken.t.sol | 296 +++++++++++++++++++++++++--- test/utils/EarnerManagerHarness.sol | 4 +- 8 files changed, 440 insertions(+), 121 deletions(-) diff --git a/script/DeployBase.sol b/script/DeployBase.sol index 9885381..fb6d014 100644 --- a/script/DeployBase.sol +++ b/script/DeployBase.sol @@ -11,10 +11,10 @@ import { Proxy } from "../src/Proxy.sol"; contract DeployBase { /** * @dev Deploys Wrapped M Token. - * @param mToken_ The address the M Token contract. - * @param registrar_ The address the Registrar contract. - * @param excessDestination_ The address the excess destination. - * @param migrationAdmin_ The address the Migration Admin. + * @param mToken_ The address of the M Token contract. + * @param registrar_ The address of the Registrar contract. + * @param excessDestination_ The address of the excess destination. + * @param migrationAdmin_ The address of the Migration Admin. * @return earnerManagerImplementation_ The address of the deployed Earner Manager implementation. * @return earnerManagerProxy_ The address of the deployed Earner Manager proxy. * @return wrappedMTokenImplementation_ The address of the deployed Wrapped M Token implementation. @@ -95,7 +95,9 @@ contract DeployBase { return _getExpectedWrappedMTokenProxy(deployer_, deployerNonce_); } - function getDeployerNonceAfterProtocolDeployment(uint256 deployerNonce_) public pure virtual returns (uint256) { + function getDeployerNonceAfterWrappedMTokenDeployment( + uint256 deployerNonce_ + ) public pure virtual returns (uint256) { return deployerNonce_ + 4; } } diff --git a/src/EarnerManager.sol b/src/EarnerManager.sol index 3afde5e..706aad9 100644 --- a/src/EarnerManager.sol +++ b/src/EarnerManager.sol @@ -68,9 +68,9 @@ contract EarnerManager is IEarnerManager, Migratable { /// @inheritdoc IEarnerManager function setEarnerDetails(address account_, bool status_, uint16 feeRate_) external onlyAdmin { - if (isEarnersListIgnored()) revert EarnersListIgnored(); + if (earnersListsIgnored()) revert EarnersListsIgnored(); - _setDetails(account_, status_, msg.sender, feeRate_); + _setDetails(account_, status_, feeRate_); } /// @inheritdoc IEarnerManager @@ -82,13 +82,13 @@ contract EarnerManager is IEarnerManager, Migratable { if (accounts_.length == 0) revert ArrayLengthZero(); if (accounts_.length != statuses_.length) revert ArrayLengthMismatch(); if (accounts_.length != feeRates_.length) revert ArrayLengthMismatch(); - if (isEarnersListIgnored()) revert EarnersListIgnored(); + if (earnersListsIgnored()) revert EarnersListsIgnored(); for (uint256 index_; index_ < accounts_.length; ++index_) { // NOTE: The `isAdmin` check in `_setDetails` will make this costly to re-set details for multiple accounts // that have already been set by the same admin, due to the redundant queries to the registrar. // Consider transient storage in `isAdmin` to memoize admins. - _setDetails(accounts_[index_], statuses_[index_], msg.sender, feeRates_[index_]); + _setDetails(accounts_[index_], statuses_[index_], feeRates_[index_]); } } @@ -105,48 +105,54 @@ contract EarnerManager is IEarnerManager, Migratable { /// @inheritdoc IEarnerManager function earnerStatusFor(address account_) external view returns (bool status_) { - return isEarnersListIgnored() || isInEarnersList(account_) || _isValidAdmin(_earnerDetails[account_].admin); + return earnersListsIgnored() || isInRegistrarEarnersList(account_) || isInAdministratedEarnersList(account_); } /// @inheritdoc IEarnerManager function earnerStatusesFor(address[] calldata accounts_) external view returns (bool[] memory statuses_) { statuses_ = new bool[](accounts_.length); - bool isListIgnored_ = isEarnersListIgnored(); + bool earnersListsIgnored_ = earnersListsIgnored(); for (uint256 index_; index_ < accounts_.length; ++index_) { - if (isListIgnored_) { + if (earnersListsIgnored_) { statuses_[index_] = true; continue; } address account_ = accounts_[index_]; - if (isInEarnersList(account_)) { + if (isInRegistrarEarnersList(account_)) { statuses_[index_] = true; continue; } - statuses_[index_] = _isValidAdmin(_earnerDetails[account_].admin); + statuses_[index_] = isInAdministratedEarnersList(account_); } } /// @inheritdoc IEarnerManager - function isEarnersListIgnored() public view returns (bool isIgnored_) { + function earnersListsIgnored() public view returns (bool isIgnored_) { return IRegistrarLike(registrar).get(EARNERS_LIST_IGNORED_KEY) != bytes32(0); } /// @inheritdoc IEarnerManager - function isInEarnersList(address account_) public view returns (bool isInList_) { + function isInRegistrarEarnersList(address account_) public view returns (bool isInList_) { return IRegistrarLike(registrar).listContains(EARNERS_LIST_NAME, account_); } + /// @inheritdoc IEarnerManager + function isInAdministratedEarnersList(address account_) public view returns (bool isInList_) { + return _isValidAdmin(_earnerDetails[account_].admin); + } + /// @inheritdoc IEarnerManager function getEarnerDetails(address account_) external view returns (bool status_, uint16 feeRate_, address admin_) { - if (isEarnersListIgnored() || isInEarnersList(account_)) return (true, 0, address(0)); + if (earnersListsIgnored() || isInRegistrarEarnersList(account_)) return (true, 0, address(0)); EarnerDetails storage details_ = _earnerDetails[account_]; + // NOTE: Not using `isInAdministratedEarnersList(account_)` here to avoid redundant storage reads. return _isValidAdmin(details_.admin) ? (true, details_.feeRate, details_.admin) : (false, 0, address(0)); } @@ -158,23 +164,24 @@ contract EarnerManager is IEarnerManager, Migratable { feeRates_ = new uint16[](accounts_.length); admins_ = new address[](accounts_.length); - bool isEarnersListIgnored_ = isEarnersListIgnored(); + bool earnersListsIgnored_ = earnersListsIgnored(); for (uint256 index_; index_ < accounts_.length; ++index_) { - if (isEarnersListIgnored_) { + if (earnersListsIgnored_) { statuses_[index_] = true; continue; } address account_ = accounts_[index_]; - if (isInEarnersList(account_)) { + if (isInRegistrarEarnersList(account_)) { statuses_[index_] = true; continue; } EarnerDetails storage details_ = _earnerDetails[account_]; + // NOTE: Not using `isInAdministratedEarnersList(account_)` here to avoid redundant storage reads. if (!_isValidAdmin(details_.admin)) continue; statuses_[index_] = true; @@ -192,29 +199,32 @@ contract EarnerManager is IEarnerManager, Migratable { /* ============ Internal Interactive Functions ============ */ /** - * @dev Sets the earner details for `account_`. + * @dev Sets the earner details for `account_`, assuming `msg.sender` is the calling admin. * @param account_ The account under which yield could generate. * @param status_ Whether the account is an earner, according to the admin. - * @param admin_ The admin who set the details and who will collect the fee. * @param feeRate_ The fee rate to be taken from the yield. */ - function _setDetails(address account_, bool status_, address admin_, uint16 feeRate_) internal { + function _setDetails(address account_, bool status_, uint16 feeRate_) internal { if (account_ == address(0)) revert ZeroAccount(); - if (!status_ && (feeRate_ != 0)) revert InvalidDetails(); - if (status_ == (admin_ == address(0))) revert InvalidDetails(); + if (!status_ && (feeRate_ != 0)) revert InvalidDetails(); // Fee rate must be zero if status is false. if (feeRate_ > MAX_FEE_RATE) revert FeeRateTooHigh(); - if (isInEarnersList(account_)) revert AlreadyInEarnersList(account_); + if (isInRegistrarEarnersList(account_)) revert AlreadyInRegistrarEarnersList(account_); - address currentAdmin_ = _earnerDetails[account_].admin; + address admin_ = _earnerDetails[account_].admin; - // Revert if the details have already been set by an admin that is not `admin_` and is still an admin. - if ((currentAdmin_ != address(0)) && (currentAdmin_ != admin_) && isAdmin(currentAdmin_)) { + // Revert if the details have already been set by an admin that is not `msg.sender`, and is still an admin. + // NOTE: No `_isValidAdmin` here to avoid unnecessary contract call and storage reads if `admin_ == msg.sender`. + if ((admin_ != address(0)) && (admin_ != msg.sender) && isAdmin(admin_)) { revert EarnerDetailsAlreadySet(account_); } - _earnerDetails[account_] = EarnerDetails(admin_, feeRate_); + if (status_) { + _earnerDetails[account_] = EarnerDetails(msg.sender, feeRate_); + } else { + delete _earnerDetails[account_]; + } - emit EarnerDetailsSet(account_, status_, admin_, feeRate_); + emit EarnerDetailsSet(account_, status_, msg.sender, feeRate_); } /** diff --git a/src/WrappedMToken.sol b/src/WrappedMToken.sol index 4e9e1cb..12710cc 100644 --- a/src/WrappedMToken.sol +++ b/src/WrappedMToken.sol @@ -52,6 +52,9 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { /* ============ Variables ============ */ + /// @inheritdoc IWrappedMToken + uint16 public constant HUNDRED_PERCENT = 10_000; + /// @inheritdoc IWrappedMToken bytes32 public constant EARNERS_LIST_IGNORED_KEY = "earners_list_ignored"; @@ -155,11 +158,7 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { /// @inheritdoc IWrappedMToken function enableEarning() external { - if ( - IRegistrarLike(registrar).get(EARNERS_LIST_IGNORED_KEY) == bytes32(0) && - !IRegistrarLike(registrar).listContains(EARNERS_LIST_NAME, address(this)) - ) revert NotApprovedEarner(address(this)); - + if (!_isThisApprovedEarner()) revert NotApprovedEarner(address(this)); if (isEarningEnabled()) revert EarningIsEnabled(); // NOTE: This is a temporary measure to prevent re-enabling earning after it has been disabled. @@ -177,11 +176,7 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { /// @inheritdoc IWrappedMToken function disableEarning() external { - if ( - IRegistrarLike(registrar).get(EARNERS_LIST_IGNORED_KEY) != bytes32(0) || - IRegistrarLike(registrar).listContains(EARNERS_LIST_NAME, address(this)) - ) revert IsApprovedEarner(address(this)); - + if (_isThisApprovedEarner()) revert IsApprovedEarner(address(this)); if (!isEarningEnabled()) revert EarningIsDisabled(); uint128 currentMIndex_ = _currentMIndex(); @@ -241,9 +236,8 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { function accruedYieldOf(address account_) public view returns (uint240 yield_) { Account storage accountInfo_ = _accounts[account_]; - if (!accountInfo_.isEarning) return 0; - - return _getAccruedYield(accountInfo_.balance, accountInfo_.lastIndex, currentIndex()); + return + accountInfo_.isEarning ? _getAccruedYield(accountInfo_.balance, accountInfo_.lastIndex, currentIndex()) : 0; } /// @inheritdoc IERC20 @@ -308,10 +302,10 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { /// @inheritdoc IWrappedMToken function totalAccruedYield() external view returns (uint240 yield_) { - uint240 projectedEarningSupply_ = _projectedEarningSupply(currentIndex()); - uint240 earningSupply_ = totalEarningSupply; - unchecked { + uint240 projectedEarningSupply_ = _projectedEarningSupply(currentIndex()); + uint240 earningSupply_ = totalEarningSupply; + return projectedEarningSupply_ <= earningSupply_ ? 0 : projectedEarningSupply_ - earningSupply_; } } @@ -453,9 +447,9 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { accountInfo_.lastIndex = currentIndex_; - unchecked { - if (yield_ == 0) return 0; + if (yield_ == 0) return 0; + unchecked { accountInfo_.balance = startingBalance_ + yield_; // Update the total earning supply to account for the yield, but the principal has not changed. @@ -469,21 +463,26 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { emit Claimed(account_, claimRecipient_, yield_); emit Transfer(address(0), account_, yield_); + uint240 yieldNetOfFees_ = yield_; + if (accountInfo_.hasEarnerDetails) { (, uint16 feeRate_, address admin_) = IEarnerManager(earnerManager).getEarnerDetails(account_); - feeRate_ = feeRate_ > 10_000 ? 10_000 : feeRate_; // Ensure fee rate is capped at 100%. - uint240 fee_ = (feeRate_ * yield_) / 10_000; + feeRate_ = feeRate_ > HUNDRED_PERCENT ? HUNDRED_PERCENT : feeRate_; // Ensure fee rate is capped at 100%. + + unchecked { + uint240 fee_ = (feeRate_ * yield_) / HUNDRED_PERCENT; - if (fee_ != 0) { - _transfer(account_, admin_, fee_, currentIndex_); - yield_ -= fee_; + if (fee_ != 0) { + yieldNetOfFees_ -= fee_; + _transfer(account_, admin_, fee_, currentIndex_); + } } } - if ((claimRecipient_ != account_) && (fee_ != 0)) { + if ((claimRecipient_ != account_) && (yieldNetOfFees_ != 0)) { // NOTE: Watch out for a long chain of earning claim override recipients. - _transfer(account_, claimRecipient_, yield_, currentIndex_); + _transfer(account_, claimRecipient_, yieldNetOfFees_, currentIndex_); } } @@ -628,7 +627,7 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { * @param currentIndex_ The current index. */ function _startEarningFor(address account_, uint128 currentIndex_) internal { - (bool isEarner_, uint16 feeRate_, ) = IEarnerManager(earnerManager).getEarnerDetails(account_); + (bool isEarner_, , address admin_) = IEarnerManager(earnerManager).getEarnerDetails(account_); if (!isEarner_) revert NotApprovedEarner(account_); @@ -638,7 +637,7 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { accountInfo_.isEarning = true; accountInfo_.lastIndex = currentIndex_; - accountInfo_.hasEarnerDetails = feeRate_ != 0; + accountInfo_.hasEarnerDetails = admin_ != address(0); // Has earner details if an admin exists for this account. uint240 balance_ = accountInfo_.balance; @@ -689,6 +688,13 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { return IMTokenLike(mToken).currentIndex(); } + /// @dev Returns whether this contract is a Registrar-approved earner. + function _isThisApprovedEarner() internal view returns (bool) { + return + IRegistrarLike(registrar).get(EARNERS_LIST_IGNORED_KEY) != bytes32(0) || + IRegistrarLike(registrar).listContains(EARNERS_LIST_NAME, address(this)); + } + /// @dev Returns the earning index from the last `disableEarning` call. function _lastDisableEarningIndex() internal view returns (uint128 index_) { return wasEarningEnabled() ? _unsafeAccess(_enableDisableEarningIndices, 1) : 0; diff --git a/src/interfaces/IEarnerManager.sol b/src/interfaces/IEarnerManager.sol index d5ada0c..ab5189d 100644 --- a/src/interfaces/IEarnerManager.sol +++ b/src/interfaces/IEarnerManager.sol @@ -14,7 +14,7 @@ interface IEarnerManager is IMigratable { /** * @notice Emitted when the earner for `account` is set to `status`. * @param account The account under which yield could generate. - * @param status Whether the account is set an earner, according to the admin. + * @param status Whether the account is set as an earner, according to the admin. * @param admin The admin who set the details and who will collect the fee. * @param feeRate The fee rate to be taken from the yield. */ @@ -22,8 +22,8 @@ interface IEarnerManager is IMigratable { /* ============ Custom Errors ============ */ - /// @notice Emitted when `account` is is already in the earners list, so it cannot be added by an admin. - error AlreadyInEarnersList(address account); + /// @notice Emitted when `account` is already in the earners list, so it cannot be added by an admin. + error AlreadyInRegistrarEarnersList(address account); /// @notice Emitted when the lengths of input arrays do not match. error ArrayLengthMismatch(); @@ -34,13 +34,13 @@ interface IEarnerManager is IMigratable { /// @notice Emitted when the earner details have already be set by an existing and active admin. error EarnerDetailsAlreadySet(address account); - /// @notice Emitted when the earners list is ignored, thus not requiring admin to define earners. - error EarnersListIgnored(); + /// @notice Emitted when the earners lists are ignored, thus not requiring admin to define earners. + error EarnersListsIgnored(); /// @notice Emitted when the fee rate provided is to high (higher than 100% in basis points). error FeeRateTooHigh(); - /// @notice Emitted when setting details (i.e. fee rate) while setting status to false. + /// @notice Emitted when setting fee rate to a nonzero value while setting status to false. error InvalidDetails(); /// @notice Emitted when the caller is not an admin. @@ -90,7 +90,7 @@ interface IEarnerManager is IMigratable { /* ============ View/Pure Functions ============ */ - /// @notice Maximum fee rate that can be set (1005 in basis points). + /// @notice Maximum fee rate that can be set (100% in basis points). function MAX_FEE_RATE() external pure returns (uint16 maxFeeRate); /// @notice Registrar name of admins list. @@ -120,17 +120,24 @@ interface IEarnerManager is IMigratable { function earnerStatusesFor(address[] calldata accounts) external view returns (bool[] memory statuses); /** - * @notice Returns whether the list of earners can be ignored (thus making all accounts earners). - * @return isIgnored Whether the list of earners can be ignored. + * @notice Returns whether the lists of earners can be ignored (thus making all accounts earners). + * @return ignored Whether the lists of earners can be ignored. */ - function isEarnersListIgnored() external view returns (bool isIgnored); + function earnersListsIgnored() external view returns (bool ignored); /** * @notice Returns whether `account` is a Registrar-approved earner. * @param account The account being queried. * @return isInList Whether the account is a Registrar-approved earner. */ - function isInEarnersList(address account) external view returns (bool isInList); + function isInRegistrarEarnersList(address account) external view returns (bool isInList); + + /** + * @notice Returns whether `account` is an Admin-approved earner. + * @param account The account being queried. + * @return isInList Whether the account is an Admin-approved earner. + */ + function isInAdministratedEarnersList(address account) external view returns (bool isInList); /** * @notice Returns the earner details for `account`. diff --git a/src/interfaces/IWrappedMToken.sol b/src/interfaces/IWrappedMToken.sol index e00f6e7..24ffe30 100644 --- a/src/interfaces/IWrappedMToken.sol +++ b/src/interfaces/IWrappedMToken.sol @@ -185,6 +185,9 @@ interface IWrappedMToken is IMigratable, IERC20Extended { /* ============ View/Pure Functions ============ */ + /// @notice 100% in basis points. + function HUNDRED_PERCENT() external pure returns (uint16 hundredPercent); + /// @notice Registrar key holding value of whether the earners list can be ignored or not. function EARNERS_LIST_IGNORED_KEY() external pure returns (bytes32 earnersListIgnoredKey); diff --git a/test/unit/EarnerManager.sol b/test/unit/EarnerManager.sol index 870ebd3..7dd86c1 100644 --- a/test/unit/EarnerManager.sol +++ b/test/unit/EarnerManager.sol @@ -46,6 +46,7 @@ contract EarnerStatusManagerTests is Test { vm.expectRevert(IEarnerManager.ZeroRegistrar.selector); new EarnerManagerHarness(address(0), address(0)); } + function test_constructor_zeroMigrationAdmin() external { vm.expectRevert(IEarnerManager.ZeroMigrationAdmin.selector); new EarnerManagerHarness(address(_registrar), address(0)); @@ -55,35 +56,27 @@ contract EarnerStatusManagerTests is Test { function test_setDetails_zeroAccount() external { vm.expectRevert(IEarnerManager.ZeroAccount.selector); - _earnerManager.setDetails(address(0), false, address(0), 0); + _earnerManager.setDetails(address(0), false, 0); } function test_setDetails_invalidDetails() external { vm.expectRevert(IEarnerManager.InvalidDetails.selector); - _earnerManager.setDetails(_alice, false, address(0), 1); - - vm.expectRevert(IEarnerManager.InvalidDetails.selector); - - _earnerManager.setDetails(_alice, false, _admin1, 0); - - vm.expectRevert(IEarnerManager.InvalidDetails.selector); - - _earnerManager.setDetails(_alice, true, address(0), 0); + _earnerManager.setDetails(_alice, false, 1); } function test_setDetails_feeRateTooHigh() external { vm.expectRevert(IEarnerManager.FeeRateTooHigh.selector); - _earnerManager.setDetails(_alice, true, _admin1, 10_001); + _earnerManager.setDetails(_alice, true, 10_001); } - function test_setDetails_alreadyInEarnersList() external { + function test_setDetails_alreadyInRegistrarEarnersList() external { _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); - vm.expectRevert(abi.encodeWithSelector(IEarnerManager.AlreadyInEarnersList.selector, _alice)); + vm.expectRevert(abi.encodeWithSelector(IEarnerManager.AlreadyInRegistrarEarnersList.selector, _alice)); - _earnerManager.setDetails(_alice, false, address(0), 0); + _earnerManager.setDetails(_alice, true, 0); } function test_setDetails_earnerDetailsAlreadySet() external { @@ -91,11 +84,13 @@ contract EarnerStatusManagerTests is Test { vm.expectRevert(abi.encodeWithSelector(IEarnerManager.EarnerDetailsAlreadySet.selector, _alice)); - _earnerManager.setDetails(_alice, true, _admin2, 2); + vm.prank(_admin2); + _earnerManager.setDetails(_alice, true, 2); } function test_setDetails() external { - _earnerManager.setDetails(_alice, true, _admin1, 1); + vm.prank(_admin1); + _earnerManager.setDetails(_alice, true, 1); (bool status_, uint16 feeRate_, address admin_) = _earnerManager.getEarnerDetails(_alice); @@ -104,6 +99,44 @@ contract EarnerStatusManagerTests is Test { assertEq(admin_, _admin1); } + function test_setDetails_changeFeeRate() external { + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 1); + + (bool status_, uint16 feeRate_, address admin_) = _earnerManager.getEarnerDetails(_alice); + + assertTrue(status_); + assertEq(feeRate_, 1); + assertEq(admin_, _admin1); + + vm.prank(_admin1); + _earnerManager.setDetails(_alice, true, 2); + + (status_, feeRate_, admin_) = _earnerManager.getEarnerDetails(_alice); + + assertTrue(status_); + assertEq(feeRate_, 2); + assertEq(admin_, _admin1); + } + + function test_setDetails_remove() external { + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 1); + + (bool status_, uint16 feeRate_, address admin_) = _earnerManager.getEarnerDetails(_alice); + + assertTrue(status_); + assertEq(feeRate_, 1); + assertEq(admin_, _admin1); + + vm.prank(_admin1); + _earnerManager.setDetails(_alice, false, 0); + + (status_, feeRate_, admin_) = _earnerManager.getEarnerDetails(_alice); + + assertFalse(status_); + assertEq(feeRate_, 0); + assertEq(admin_, address(0)); + } + /* ============ setEarnerDetails ============ */ function test_setEarnerDetails_notAdmin() external { vm.expectRevert(IEarnerManager.NotAdmin.selector); @@ -115,7 +148,7 @@ contract EarnerStatusManagerTests is Test { function test_setEarnerDetails_earnersListIgnored() external { _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); - vm.expectRevert(IEarnerManager.EarnersListIgnored.selector); + vm.expectRevert(IEarnerManager.EarnersListsIgnored.selector); vm.prank(_admin1); _earnerManager.setEarnerDetails(_alice, true, 0); @@ -170,7 +203,7 @@ contract EarnerStatusManagerTests is Test { function test_setEarnerDetails_batch_earnersListIgnored() external { _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); - vm.expectRevert(IEarnerManager.EarnersListIgnored.selector); + vm.expectRevert(IEarnerManager.EarnersListsIgnored.selector); vm.prank(_admin1); _earnerManager.setEarnerDetails(new address[](2), new bool[](2), new uint16[](2)); @@ -432,26 +465,34 @@ contract EarnerStatusManagerTests is Test { assertTrue(statuses_[2]); } - /* ============ isEarnersListIgnored ============ */ - function test_isEarnersListIgnored() external { - assertFalse(_earnerManager.isEarnersListIgnored()); + /* ============ earnersListsIgnored ============ */ + function test_earnersListsIgnored() external { + assertFalse(_earnerManager.earnersListsIgnored()); _registrar.set(_EARNERS_LIST_IGNORED_KEY, bytes32(uint256(1))); - assertTrue(_earnerManager.isEarnersListIgnored()); + assertTrue(_earnerManager.earnersListsIgnored()); } - /* ============ isInEarnersList ============ */ - function test_isInEarnersList() external { - assertFalse(_earnerManager.isInEarnersList(_alice)); + /* ============ isInRegistrarEarnersList ============ */ + function test_isInRegistrarEarnersList() external { + assertFalse(_earnerManager.isInRegistrarEarnersList(_alice)); _registrar.setListContains(_EARNERS_LIST_NAME, _alice, true); - assertTrue(_earnerManager.isInEarnersList(_alice)); + assertTrue(_earnerManager.isInRegistrarEarnersList(_alice)); } - /* ============ getEarnerDetails ============ */ + /* ============ isInAdministratedEarnersList ============ */ + function test_isInAdministratedEarnersList() external { + assertFalse(_earnerManager.isInAdministratedEarnersList(_alice)); + _earnerManager.setInternalEarnerDetails(_alice, _admin1, 0); + + assertTrue(_earnerManager.isInAdministratedEarnersList(_alice)); + } + + /* ============ getEarnerDetails ============ */ function test_getEarnerDetails_earnersListIgnored() external { _earnerManager.setInternalEarnerDetails(_alice, _admin1, 1); diff --git a/test/unit/WrappedMToken.t.sol b/test/unit/WrappedMToken.t.sol index 32239be..ca74568 100644 --- a/test/unit/WrappedMToken.t.sol +++ b/test/unit/WrappedMToken.t.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.26; import { Test, console2 } from "../../lib/forge-std/src/Test.sol"; +import { IERC20 } from "../../lib/common/src/interfaces/IERC20.sol"; import { IERC20Extended } from "../../lib/common/src/interfaces/IERC20Extended.sol"; import { UIntMath } from "../../lib/common/src/libs/UIntMath.sol"; @@ -16,11 +17,13 @@ import { MockEarnerManager, MockM, MockRegistrar } from "../utils/Mocks.sol"; import { WrappedMTokenHarness } from "../utils/WrappedMTokenHarness.sol"; // TODO: Test for `totalAccruedYield()`. -// TODO: All operations involving earners should include demonstration of accrued yield being added t their balance. +// TODO: All operations involving earners should include demonstration of accrued yield being added to their balance. // TODO: Add relevant unit tests while earning enabled/disabled. contract WrappedMTokenTests is Test { uint56 internal constant _EXP_SCALED_ONE = 1e12; + uint56 internal constant _ONE_HUNDRED_PERCENT = 10_000; + bytes32 internal constant _CLAIM_OVERRIDE_RECIPIENT_KEY_PREFIX = "wm_claim_override_recipient"; bytes32 internal constant _EARNERS_LIST = "earners"; @@ -145,6 +148,9 @@ contract WrappedMTokenTests is Test { function test_wrap_toNonEarner() external { _mToken.setBalanceOf(_alice, 1_000); + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, 1_000); + vm.prank(_alice); assertEq(_wrappedMToken.wrap(_alice, 1_000), 1_000); @@ -163,6 +169,9 @@ contract WrappedMTokenTests is Test { _mToken.setBalanceOf(_alice, 1_002); + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, 999); + vm.prank(_alice); assertEq(_wrappedMToken.wrap(_alice, 999), 999); @@ -172,6 +181,9 @@ contract WrappedMTokenTests is Test { assertEq(_wrappedMToken.principalOfTotalEarningSupply(), 909); assertEq(_wrappedMToken.totalEarningSupply(), 999); + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, 1); + vm.prank(_alice); assertEq(_wrappedMToken.wrap(_alice, 1), 1); @@ -182,6 +194,9 @@ contract WrappedMTokenTests is Test { assertEq(_wrappedMToken.principalOfTotalEarningSupply(), 910); assertEq(_wrappedMToken.totalEarningSupply(), 1_000); + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, 2); + vm.prank(_alice); assertEq(_wrappedMToken.wrap(_alice, 2), 2); @@ -232,6 +247,9 @@ contract WrappedMTokenTests is Test { if (wrapAmount_ == 0) { vm.expectRevert(abi.encodeWithSelector(IERC20Extended.InsufficientAmount.selector, (0))); + } else { + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, wrapAmount_); } vm.startPrank(_alice); @@ -287,6 +305,9 @@ contract WrappedMTokenTests is Test { if (wrapAmount_ == 0) { vm.expectRevert(abi.encodeWithSelector(IERC20Extended.InsufficientAmount.selector, (0))); + } else { + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, wrapAmount_); } vm.startPrank(_alice); @@ -336,6 +357,9 @@ contract WrappedMTokenTests is Test { _mToken.setBalanceOf(address(_wrappedMToken), 1_000); + vm.expectEmit(); + emit IERC20.Transfer(_alice, address(0), 500); + vm.prank(_alice); assertEq(_wrappedMToken.unwrap(_alice, 500), 500); @@ -344,6 +368,9 @@ contract WrappedMTokenTests is Test { assertEq(_wrappedMToken.totalEarningSupply(), 0); assertEq(_wrappedMToken.principalOfTotalEarningSupply(), 0); + vm.expectEmit(); + emit IERC20.Transfer(_alice, address(0), 500); + vm.prank(_alice); assertEq(_wrappedMToken.unwrap(_alice, 500), 500); @@ -365,6 +392,9 @@ contract WrappedMTokenTests is Test { _mToken.setBalanceOf(address(_wrappedMToken), 1_000); + vm.expectEmit(); + emit IERC20.Transfer(_alice, address(0), 1); + vm.prank(_alice); assertEq(_wrappedMToken.unwrap(_alice, 1), 0); @@ -374,6 +404,9 @@ contract WrappedMTokenTests is Test { assertEq(_wrappedMToken.totalNonEarningSupply(), 0); assertEq(_wrappedMToken.totalEarningSupply(), 999); + vm.expectEmit(); + emit IERC20.Transfer(_alice, address(0), 999); + vm.prank(_alice); assertEq(_wrappedMToken.unwrap(_alice, 999), 998); @@ -433,6 +466,9 @@ contract WrappedMTokenTests is Test { unwrapAmount_ ) ); + } else { + vm.expectEmit(); + emit IERC20.Transfer(_alice, address(0), unwrapAmount_); } vm.startPrank(_alice); @@ -487,6 +523,9 @@ contract WrappedMTokenTests is Test { if (balance_ + accruedYield_ == 0) { vm.expectRevert(abi.encodeWithSelector(IERC20Extended.InsufficientAmount.selector, (0))); + } else { + vm.expectEmit(); + emit IERC20.Transfer(_alice, address(0), balance_ + accruedYield_); } vm.startPrank(_alice); @@ -518,31 +557,194 @@ contract WrappedMTokenTests is Test { assertEq(_wrappedMToken.balanceOf(_alice), 1_000); + vm.expectEmit(); + emit IWrappedMToken.Claimed(_alice, _alice, 100); + + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, 100); + assertEq(_wrappedMToken.claimFor(_alice), 100); assertEq(_wrappedMToken.balanceOf(_alice), 1_100); } - function testFuzz_claimFor(uint240 balance_, uint128 accountIndex_, uint128 index_) external { + function test_claimFor_earner_withOverrideRecipient() external { + _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); + + _registrar.set( + keccak256(abi.encode(_CLAIM_OVERRIDE_RECIPIENT_KEY_PREFIX, _alice)), + bytes32(uint256(uint160(_bob))) + ); + + _wrappedMToken.enableEarning(); + + _wrappedMToken.setAccountOf(_alice, 1_000, _EXP_SCALED_ONE, false); + + assertEq(_wrappedMToken.balanceOf(_alice), 1_000); + + vm.expectEmit(); + emit IWrappedMToken.Claimed(_alice, _bob, 100); + + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, 100); + + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, 100); + + assertEq(_wrappedMToken.claimFor(_alice), 100); + + assertEq(_wrappedMToken.balanceOf(_alice), 1_000); + assertEq(_wrappedMToken.balanceOf(_bob), 100); + } + + function test_claimFor_earner_withFee() external { + _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); + + _wrappedMToken.enableEarning(); + + _wrappedMToken.setAccountOf(_alice, 1_000, _EXP_SCALED_ONE, true); + + _earnerManager.setEarnerDetails(_alice, true, 1_500, _bob); + + assertEq(_wrappedMToken.balanceOf(_alice), 1_000); + + vm.expectEmit(); + emit IWrappedMToken.Claimed(_alice, _alice, 100); + + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, 100); + + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, 15); + + assertEq(_wrappedMToken.claimFor(_alice), 100); + + assertEq(_wrappedMToken.balanceOf(_alice), 1_085); + assertEq(_wrappedMToken.balanceOf(_bob), 15); + } + + function test_claimFor_earner_withFeeAboveOneHundredPercent() external { + _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); + + _wrappedMToken.enableEarning(); + + _wrappedMToken.setAccountOf(_alice, 1_000, _EXP_SCALED_ONE, true); + + _earnerManager.setEarnerDetails(_alice, true, type(uint16).max, _bob); + + assertEq(_wrappedMToken.balanceOf(_alice), 1_000); + + vm.expectEmit(); + emit IWrappedMToken.Claimed(_alice, _alice, 100); + + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, 100); + + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, 100); + + assertEq(_wrappedMToken.claimFor(_alice), 100); + + assertEq(_wrappedMToken.balanceOf(_alice), 1_000); + assertEq(_wrappedMToken.balanceOf(_bob), 100); + } + + function test_claimFor_earner_withOverrideRecipientAndFee() external { + _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); + + _registrar.set( + keccak256(abi.encode(_CLAIM_OVERRIDE_RECIPIENT_KEY_PREFIX, _alice)), + bytes32(uint256(uint160(_charlie))) + ); + + _wrappedMToken.enableEarning(); + + _wrappedMToken.setAccountOf(_alice, 1_000, _EXP_SCALED_ONE, true); + + _earnerManager.setEarnerDetails(_alice, true, 1_500, _bob); + + assertEq(_wrappedMToken.balanceOf(_alice), 1_000); + + vm.expectEmit(); + emit IWrappedMToken.Claimed(_alice, _charlie, 100); + + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, 100); + + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, 15); + + vm.expectEmit(); + emit IERC20.Transfer(_alice, _charlie, 85); + + assertEq(_wrappedMToken.claimFor(_alice), 100); + + assertEq(_wrappedMToken.balanceOf(_alice), 1_000); + assertEq(_wrappedMToken.balanceOf(_bob), 15); + assertEq(_wrappedMToken.balanceOf(_charlie), 85); + } + + function testFuzz_claimFor( + uint240 balance_, + uint128 accountIndex_, + uint128 index_, + bool claimOverride_, + uint16 feeRate_ + ) external { accountIndex_ = uint128(bound(index_, _EXP_SCALED_ONE, 10 * _EXP_SCALED_ONE)); balance_ = uint240(bound(balance_, 0, _getMaxAmount(accountIndex_))); index_ = uint128(bound(index_, accountIndex_, 10 * _EXP_SCALED_ONE)); _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); + if (claimOverride_) { + _registrar.set( + keccak256(abi.encode(_CLAIM_OVERRIDE_RECIPIENT_KEY_PREFIX, _alice)), + bytes32(uint256(uint160(_charlie))) + ); + } + _wrappedMToken.enableEarning(); _wrappedMToken.setTotalEarningSupply(balance_); - _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_, false); + _wrappedMToken.setAccountOf(_alice, balance_, accountIndex_, feeRate_ != 0); + + if (feeRate_ != 0) { + _earnerManager.setEarnerDetails(_alice, true, feeRate_, _bob); + } _mToken.setCurrentIndex(index_); uint240 accruedYield_ = _wrappedMToken.accruedYieldOf(_alice); + if (accruedYield_ != 0) { + vm.expectEmit(); + emit IWrappedMToken.Claimed(_alice, claimOverride_ ? _charlie : _alice, accruedYield_); + + vm.expectEmit(); + emit IERC20.Transfer(address(0), _alice, accruedYield_); + } + + uint240 fee_ = (accruedYield_ * (feeRate_ > _ONE_HUNDRED_PERCENT ? _ONE_HUNDRED_PERCENT : feeRate_)) / + _ONE_HUNDRED_PERCENT; + + if (fee_ != 0) { + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, fee_); + } + + if (claimOverride_ && (accruedYield_ - fee_ != 0)) { + vm.expectEmit(); + emit IERC20.Transfer(_alice, _charlie, accruedYield_ - fee_); + } + assertEq(_wrappedMToken.claimFor(_alice), accruedYield_); - assertEq(_wrappedMToken.totalEarningSupply(), _wrappedMToken.balanceOf(_alice)); + assertEq( + _wrappedMToken.totalSupply(), + _wrappedMToken.balanceOf(_alice) + _wrappedMToken.balanceOf(_bob) + _wrappedMToken.balanceOf(_charlie) + ); } /* ============ claimExcess ============ */ @@ -575,6 +777,9 @@ contract WrappedMTokenTests is Test { abi.encodeCall(_mToken.transfer, (_wrappedMToken.excessDestination(), expectedExcess_)) ); + vm.expectEmit(); + emit IWrappedMToken.ExcessClaimed(expectedExcess_); + assertEq(_wrappedMToken.claimExcess(), expectedExcess_); assertEq(_wrappedMToken.excess(), 0); } @@ -615,6 +820,9 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setAccountOf(_alice, 1_000); _wrappedMToken.setAccountOf(_bob, 500); + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, 500); + vm.prank(_alice); _wrappedMToken.transfer(_bob, 500); @@ -642,6 +850,9 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setAccountOf(_alice, aliceBalance_); _wrappedMToken.setAccountOf(_bob, bobBalance); + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, transferAmount_); + vm.prank(_alice); _wrappedMToken.transfer(_bob, transferAmount_); @@ -666,6 +877,9 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setAccountOf(_alice, 1_000, _currentIndex, false); _wrappedMToken.setAccountOf(_bob, 500); + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, 500); + vm.prank(_alice); _wrappedMToken.transfer(_bob, 500); @@ -677,6 +891,9 @@ contract WrappedMTokenTests is Test { assertEq(_wrappedMToken.totalNonEarningSupply(), 1_000); assertEq(_wrappedMToken.totalEarningSupply(), 500); + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, 1); + vm.prank(_alice); _wrappedMToken.transfer(_bob, 1); @@ -702,6 +919,9 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setAccountOf(_alice, 1_000); _wrappedMToken.setAccountOf(_bob, 500, _currentIndex, false); + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, 500); + vm.prank(_alice); _wrappedMToken.transfer(_bob, 500); @@ -725,6 +945,9 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setAccountOf(_alice, 1_000, _currentIndex, false); _wrappedMToken.setAccountOf(_bob, 500, _currentIndex, false); + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, 500); + vm.prank(_alice); _wrappedMToken.transfer(_bob, 500); @@ -743,6 +966,9 @@ contract WrappedMTokenTests is Test { _wrappedMToken.setAccountOf(_alice, 1_000); + vm.expectEmit(); + emit IERC20.Transfer(_alice, _alice, 500); + vm.prank(_alice); _wrappedMToken.transfer(_alice, 500); @@ -768,6 +994,9 @@ contract WrappedMTokenTests is Test { assertEq(_wrappedMToken.balanceOf(_alice), 1_000); assertEq(_wrappedMToken.accruedYieldOf(_alice), 666); + vm.expectEmit(); + emit IERC20.Transfer(_alice, _alice, 500); + vm.prank(_alice); _wrappedMToken.transfer(_alice, 500); @@ -846,6 +1075,9 @@ contract WrappedMTokenTests is Test { amount_ ) ); + } else { + vm.expectEmit(); + emit IERC20.Transfer(_alice, _bob, amount_); } vm.prank(_alice); @@ -890,6 +1122,25 @@ contract WrappedMTokenTests is Test { _wrappedMToken.startEarningFor(_alice); } + function test_startEarning_overflow() external { + _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); + + _wrappedMToken.enableEarning(); + + uint256 aliceBalance_ = uint256(type(uint112).max) + 20; + + _mToken.setCurrentIndex(_currentIndex = _EXP_SCALED_ONE); + + _wrappedMToken.setTotalNonEarningSupply(aliceBalance_); + + _wrappedMToken.setAccountOf(_alice, aliceBalance_); + + _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); + + vm.expectRevert(UIntMath.InvalidUInt112.selector); + _wrappedMToken.startEarningFor(_alice); + } + function test_startEarningFor() external { _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); @@ -914,25 +1165,6 @@ contract WrappedMTokenTests is Test { assertEq(_wrappedMToken.totalEarningSupply(), 1_000); } - function test_startEarning_overflow() external { - _registrar.setListContains(_EARNERS_LIST, address(_wrappedMToken), true); - - _wrappedMToken.enableEarning(); - - uint256 aliceBalance_ = uint256(type(uint112).max) + 20; - - _mToken.setCurrentIndex(_currentIndex = _EXP_SCALED_ONE); - - _wrappedMToken.setTotalNonEarningSupply(aliceBalance_); - - _wrappedMToken.setAccountOf(_alice, aliceBalance_); - - _earnerManager.setEarnerDetails(_alice, true, 0, address(0)); - - vm.expectRevert(UIntMath.InvalidUInt112.selector); - _wrappedMToken.startEarningFor(_alice); - } - function testFuzz_startEarningFor(uint240 balance_, uint128 index_) external { balance_ = uint240(bound(balance_, 0, _getMaxAmount(_currentIndex))); index_ = uint128(bound(index_, _currentIndex, 10 * _EXP_SCALED_ONE)); @@ -949,6 +1181,9 @@ contract WrappedMTokenTests is Test { _mToken.setCurrentIndex(index_); + vm.expectEmit(); + emit IWrappedMToken.StartedEarning(_alice); + _wrappedMToken.startEarningFor(_alice); assertEq(_wrappedMToken.isEarning(_alice), true); @@ -1046,6 +1281,9 @@ contract WrappedMTokenTests is Test { uint240 accruedYield_ = _wrappedMToken.accruedYieldOf(_alice); + vm.expectEmit(); + emit IWrappedMToken.StoppedEarning(_alice); + _wrappedMToken.stopEarningFor(_alice); assertEq(_wrappedMToken.balanceOf(_alice), balance_ + accruedYield_); @@ -1184,6 +1422,18 @@ contract WrappedMTokenTests is Test { assertEq(_wrappedMToken.balanceOf(_alice), 1_000); } + /* ============ claimOverrideRecipientFor ============ */ + function test_claimOverrideRecipientFor() external { + assertEq(_wrappedMToken.claimOverrideRecipientFor(_alice), address(0)); + + _registrar.set( + keccak256(abi.encode(_CLAIM_OVERRIDE_RECIPIENT_KEY_PREFIX, _alice)), + bytes32(uint256(uint160(_bob))) + ); + + assertEq(_wrappedMToken.claimOverrideRecipientFor(_alice), _bob); + } + /* ============ totalSupply ============ */ function test_totalSupply_onlyTotalNonEarningSupply() external { _wrappedMToken.setTotalNonEarningSupply(500); diff --git a/test/utils/EarnerManagerHarness.sol b/test/utils/EarnerManagerHarness.sol index e24ed5c..fb2e798 100644 --- a/test/utils/EarnerManagerHarness.sol +++ b/test/utils/EarnerManagerHarness.sol @@ -11,7 +11,7 @@ contract EarnerManagerHarness is EarnerManager { _earnerDetails[account_] = EarnerDetails(admin_, feeRate_); } - function setDetails(address account_, bool status_, address admin_, uint16 feeRate_) external { - _setDetails(account_, status_, admin_, feeRate_); + function setDetails(address account_, bool status_, uint16 feeRate_) external { + _setDetails(account_, status_, feeRate_); } } From 596cd79bc39db40cc886725bf79978da71d10526 Mon Sep 17 00:00:00 2001 From: Michael De Luca Date: Wed, 30 Oct 2024 12:34:42 -0400 Subject: [PATCH 3/3] fix: `_handleEarnerDetails` and `hasEarnerDetails` update --- src/WrappedMToken.sol | 44 +++++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/WrappedMToken.sol b/src/WrappedMToken.sol index 12710cc..adec802 100644 --- a/src/WrappedMToken.sol +++ b/src/WrappedMToken.sol @@ -466,17 +466,8 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { uint240 yieldNetOfFees_ = yield_; if (accountInfo_.hasEarnerDetails) { - (, uint16 feeRate_, address admin_) = IEarnerManager(earnerManager).getEarnerDetails(account_); - - feeRate_ = feeRate_ > HUNDRED_PERCENT ? HUNDRED_PERCENT : feeRate_; // Ensure fee rate is capped at 100%. - unchecked { - uint240 fee_ = (feeRate_ * yield_) / HUNDRED_PERCENT; - - if (fee_ != 0) { - yieldNetOfFees_ -= fee_; - _transfer(account_, admin_, fee_, currentIndex_); - } + yieldNetOfFees_ -= _handleEarnerDetails(account_, yield_, currentIndex_); } } @@ -486,6 +477,39 @@ contract WrappedMToken is IWrappedMToken, Migratable, ERC20Extended { } } + /** + * @dev Handles the computation and transfer of fees to the admin of an account with earner details. + * @param account_ The address of the account to handle earner details for. + * @param yield_ The yield accrued by the account. + * @param currentIndex_ The current index to use to compute the principal amount. + * @return fee_ The fee amount that was transferred to the admin. + */ + function _handleEarnerDetails( + address account_, + uint240 yield_, + uint128 currentIndex_ + ) internal returns (uint240 fee_) { + (, uint16 feeRate_, address admin_) = IEarnerManager(earnerManager).getEarnerDetails(account_); + + if (admin_ == address(0)) { + // Prevent transferring to address(0) and remove `hasEarnerDetails` property going forward. + _accounts[account_].hasEarnerDetails = false; + return 0; + } + + if (feeRate_ == 0) return 0; + + feeRate_ = feeRate_ > HUNDRED_PERCENT ? HUNDRED_PERCENT : feeRate_; // Ensure fee rate is capped at 100%. + + unchecked { + fee_ = (feeRate_ * yield_) / HUNDRED_PERCENT; + } + + if (fee_ == 0) return 0; + + _transfer(account_, admin_, fee_, currentIndex_); + } + /** * @dev Transfers `amount_` tokens from `sender_` to `recipient_` given some current index. * @param sender_ The sender's address.