From 07f3530090c3f0e87de70fd2e5775e455dc5448f Mon Sep 17 00:00:00 2001 From: Michael De Luca Date: Mon, 27 May 2024 09:41:28 -0400 Subject: [PATCH] feat: claimable --- .gitmodules | 3 + .prettierrc | 7 +- .solhint.json | 28 +- foundry.toml | 2 +- lib/common | 1 + script/Foo.s.sol | 17 -- src/Foo.sol | 9 - src/WrappedM.sol | 424 +++++++++++++++++++++++++++ src/interfaces/IMTokenLike.sol | 19 ++ src/interfaces/ITTGRegistrarLike.sol | 9 + src/interfaces/IWrappedM.sol | 39 +++ test/Foo.t.sol | 41 --- 12 files changed, 522 insertions(+), 77 deletions(-) create mode 160000 lib/common delete mode 100644 script/Foo.s.sol delete mode 100644 src/Foo.sol create mode 100644 src/WrappedM.sol create mode 100644 src/interfaces/IMTokenLike.sol create mode 100644 src/interfaces/ITTGRegistrarLike.sol create mode 100644 src/interfaces/IWrappedM.sol delete mode 100644 test/Foo.t.sol diff --git a/.gitmodules b/.gitmodules index 482a2f9..83abd65 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ path = lib/forge-std url = https://github.com/foundry-rs/forge-std branch = v1 +[submodule "lib/common"] + path = lib/common + url = git@github.com:MZero-Labs/common.git diff --git a/.prettierrc b/.prettierrc index a0c7ac8..954e185 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,11 +1,13 @@ { - "plugins": ["prettier-plugin-solidity"], + "plugins": [ + "prettier-plugin-solidity" + ], "overrides": [ { "files": "*.sol", "options": { "bracketSpacing": true, - "compiler": "0.8.25", + "compiler": "0.8.23", "parser": "solidity-parse", "printWidth": 120, "tabWidth": 4, @@ -13,4 +15,3 @@ } ] } - diff --git a/.solhint.json b/.solhint.json index af97b6e..3c7c11c 100644 --- a/.solhint.json +++ b/.solhint.json @@ -1,10 +1,20 @@ { - "extends": ["solhint:recommended"], - "plugins": ["prettier"], + "extends": [ + "solhint:recommended" + ], + "plugins": [ + "prettier" + ], "rules": { "prettier/prettier": "error", - "code-complexity": ["warn", 10], - "compiler-version": ["error", "0.8.25"], + "code-complexity": [ + "warn", + 10 + ], + "compiler-version": [ + "error", + "0.8.23" + ], "comprehensive-interface": "off", "const-name-snakecase": "off", "func-name-mixedcase": "off", @@ -14,10 +24,16 @@ "ignoreConstructors": true } ], - "function-max-lines": ["warn", 100], + "function-max-lines": [ + "warn", + 100 + ], "immutable-vars-naming": "off", "imports-on-top": "error", - "max-line-length": ["warn", 120], + "max-line-length": [ + "warn", + 120 + ], "no-empty-blocks": "off", "no-inline-assembly": "off", "not-rely-on-time": "off", diff --git a/foundry.toml b/foundry.toml index ee59ba1..a302004 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,7 +3,7 @@ gas_reports = ["*"] gas_reports_ignore = [] ignored_error_codes = [] optimizer = false -solc_version = "0.8.25" +solc_version = "0.8.23" verbosity = 3 [profile.production] diff --git a/lib/common b/lib/common new file mode 160000 index 0000000..e809402 --- /dev/null +++ b/lib/common @@ -0,0 +1 @@ +Subproject commit e809402c4cc21f1fa8291f17ee0aee859f3b0d29 diff --git a/script/Foo.s.sol b/script/Foo.s.sol deleted file mode 100644 index f11a782..0000000 --- a/script/Foo.s.sol +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity 0.8.25; - -import { Script } from "forge-std/Script.sol"; -import { Foo } from "../src/Foo.sol"; - -/// @dev See the Solidity Scripting tutorial: https://book.getfoundry.sh/tutorials/solidity-scripting -contract FooScript is Script { - Foo internal foo; - - function run() public { - vm.startBroadcast(); - foo = new Foo(); - vm.stopBroadcast(); - } -} diff --git a/src/Foo.sol b/src/Foo.sol deleted file mode 100644 index be90d28..0000000 --- a/src/Foo.sol +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity 0.8.25; - -contract Foo { - function getFoo() external pure returns (string memory) { - return "Foo"; - } -} diff --git a/src/WrappedM.sol b/src/WrappedM.sol new file mode 100644 index 0000000..4eb824a --- /dev/null +++ b/src/WrappedM.sol @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.23; + +import { UIntMath } from "../lib/common/src/libs/UIntMath.sol"; + +import { ERC20Extended } from "../lib/common/src/ERC20Extended.sol"; + +import { IMTokenLike } from "./interfaces/IMTokenLike.sol"; +import { IWrappedM } from "./interfaces/IWrappedM.sol"; +import { ITTGRegistrarLike } from "./interfaces/ITTGRegistrarLike.sol"; + +// TODO: Allow willing accounts to block the activate of claim delegates for this account. + +contract WrappedM is IWrappedM, ERC20Extended { + type Balance is uint256; + + /* ============ Variables ============ */ + + uint56 internal constant _EXP_SCALED_ONE = 1e12; + + bytes32 internal constant _ALLOCATORS_LIST = "wm_allocators"; + bytes32 internal constant _EARNERS_LIST = "earners"; + bytes32 internal constant _EARNING_DELEGATE_PREFIX = "earning_delegate"; + + address public immutable mToken; + address public immutable ttgRegistrar; + + uint240 public totalEarningSupply; + uint112 public principalOfTotalNonEarningSupply; + uint128 public indexOfNonEarningSupply = _EXP_SCALED_ONE; + + mapping(address account => Balance balance) internal _balances; + + mapping(address account => address claimDelegate) public claimDelegateOf; + + /* ============ Modifiers ============ */ + + /* ============ Constructor ============ */ + + constructor(address mToken_, address ttgRegistrar_) ERC20Extended("WrappedM by M^0", "wM", 6) { + mToken = mToken_; + ttgRegistrar = ttgRegistrar_; + } + + /* ============ Interactive Functions ============ */ + + function activateClaimDelegate(address account_) external { + claimDelegateOf[account_] = _getEaringDelegate(account_); + } + + function claim() external returns (uint256 yield_) { + ( bool isEarning_, , ) = _unwrap(_balances[msg.sender]); + + if (!isEarning_) revert NotEarning(); + + return _claimForEarner(msg.sender, currentMIndex()); + } + + // TODO: Should anyone be allowed to do this? No harm, but legal implications? + function claimForTotalNonEarningSupply() external returns (uint256 yield_) { + return _claimForTotalNonEarningSupply(currentMIndex()); + } + + function deposit(address destination_, uint256 amount_) external { + IMTokenLike(mToken).transferFrom(msg.sender, address(this), amount_); + + _mint(destination_, UIntMath.safe240(amount_)); + } + + function withdraw(address destination_, uint256 amount_) external { + IMTokenLike(mToken).transfer(msg.sender, amount_); + + _burn(destination_, UIntMath.safe240(amount_)); + } + + // TODO: Could be replace with a transferFrom override, but transferFrom is not virtual. + function allocate(address recipient_, uint256 amount_) external { + if (!_isApprovedAllocator(msg.sender)) revert NotAllocator(); + + _transfer(address(this), recipient_, amount_); + } + + function startEarning() external { + if (!_isApprovedEarner(msg.sender)) revert NotApprovedEarner(); + + _startEarning(msg.sender); + } + + function startEarning(address account_) external { + if (_getEaringDelegate(account_) != msg.sender) revert NotEarningDelegate(); + + if (!_isApprovedEarner(account_)) revert NotApprovedEarner(); + + _startEarning(account_); + } + + function stopEarning() external { + _stopEarning(msg.sender); + } + + function stopEarning(address account_) external { + if (_isApprovedEarner(account_)) revert IsApprovedEarner(); + + _stopEarning(account_); + } + + /* ============ View/Pure Functions ============ */ + + function accruedYieldOf(address account_) public view returns (uint256 yield_) { + return _accruedYield(_balances[account_], currentMIndex()); + } + + function accruedYieldOfEarningSupply() public view returns (uint256 yield_) { + uint256 mBalance_ = IMTokenLike(mToken).balanceOf(address(this)); + + return mBalance_ - totalSupply() - accruedYieldOfNonEarningSupply(); + } + + function accruedYieldOfNonEarningSupply() public view returns (uint256 yield_) { + return _accruedYield(principalOfTotalNonEarningSupply, indexOfNonEarningSupply, currentMIndex()); + } + + function balanceOf(address account) external view returns (uint256 balance_) { + ( bool isEarning_, uint128 index_, uint256 rawBalance_ ) = _unwrap(_balances[account]); + + return isEarning_ ? _getPresentAmountRoundedDown(uint112(rawBalance_), index_) : rawBalance_; + } + + function totalSupply() public view returns (uint256 totalSupply_) { + return + totalEarningSupply + + _getPresentAmountRoundedDown(principalOfTotalNonEarningSupply, indexOfNonEarningSupply); + } + + function currentMIndex() public view returns (uint128 index_) { + return IMTokenLike(mToken).currentIndex(); + } + + /* ============ Internal Interactive Functions ============ */ + + function _claimForEarner(address account_, uint128 currentIndex_) internal returns (uint240 yield_) { + ( , uint128 index_, uint256 rawBalance_) = _unwrap(_balances[account_]); + + yield_ = _accruedYield(uint112(rawBalance_), index_, currentIndex_); + + if (yield_ == 0) return 0; + + address claimDelegate_ = claimDelegateOf[account_]; + address recipient_ = claimDelegate_ == address(0) ? account_ : claimDelegate_; + + emit Claim(recipient_, yield_); + emit Transfer(address(0), account_, yield_); + + _balances[recipient_] = _wrap(true, currentIndex_, rawBalance_); + + totalEarningSupply += yield_; + } + + function _claimForTotalNonEarningSupply(uint128 currentIndex_) internal returns (uint240 yield_) { + yield_ = _accruedYield(principalOfTotalNonEarningSupply, indexOfNonEarningSupply, currentIndex_); + + if (yield_ == 0) return 0; + + emit Claim(address(this), yield_); + emit Transfer(address(0), address(this), yield_); + + ( , , uint256 rawBalance_ ) = _unwrap(_balances[address(this)]); + + _balances[address(this)] = _wrap(false, 0, rawBalance_ + yield_); + indexOfNonEarningSupply = currentIndex_; + } + + function _burn(address account_, uint240 amount_) internal { + ( bool isEarning_, , ) = _unwrap(_balances[account_]); + + uint128 currentIndex_ = currentMIndex(); + + if (isEarning_) { + _claimForEarner(account_, currentIndex_); + _burnEarningAmount(account_, amount_, currentIndex_); + } else { + _claimForTotalNonEarningSupply(currentIndex_); + _burnNonEarningAmount(account_, amount_, currentIndex_); + } + + emit Transfer(account_, address(0), amount_); + } + + /// @dev Should only be called if indexOfNonEarningSupply == currentIndex_. + function _burnNonEarningAmount(address account_, uint240 amount_, uint128 currentIndex_) internal { + ( , , uint256 rawBalance_ ) = _unwrap(_balances[account_]); + + _balances[account_] = _wrap(false, 0, rawBalance_ - amount_); + principalOfTotalNonEarningSupply -= _getPrincipalAmountRoundedDown(amount_, currentIndex_); + } + + /// @dev Should only be called if _balances[account_].index is currentIndex_. + function _burnEarningAmount(address account_, uint240 amount_, uint128 currentIndex_) internal { + ( , , uint256 rawBalance_ ) = _unwrap(_balances[account_]); + + _balances[account_] = _wrap( + true, + currentIndex_, + rawBalance_ - _getPrincipalAmountRoundedUp(amount_, currentIndex_) + ); + + totalEarningSupply -= amount_; + } + + function _mint(address recipient_, uint240 amount_) internal { + ( bool isEarning_, , ) = _unwrap(_balances[recipient_]); + + uint128 currentIndex_ = currentMIndex(); + + if (isEarning_) { + _claimForEarner(recipient_, currentIndex_); + _mintEarningAmount(recipient_, amount_, currentIndex_); + } else { + _claimForTotalNonEarningSupply(currentIndex_); + _mintNonEarningAmount(recipient_, amount_, currentIndex_); + } + + emit Transfer(address(0), recipient_, amount_); + } + + /// @dev Should only be called if indexOfNonEarningSupply == currentIndex_. + function _mintNonEarningAmount(address recipient_, uint240 amount_, uint128 currentIndex_) internal { + ( , , uint256 rawBalance_ ) = _unwrap(_balances[recipient_]); + + _balances[recipient_] = _wrap(false, 0, rawBalance_ + amount_); + principalOfTotalNonEarningSupply += _getPrincipalAmountRoundedDown(amount_, currentIndex_); + } + + /// @dev Should only be called if _balances[recipient_].index == currentIndex_. + function _mintEarningAmount(address recipient_, uint240 amount_, uint128 currentIndex_) internal { + ( , , uint256 rawBalance_ ) = _unwrap(_balances[recipient_]); + + uint112 principalAmount_ = _getPrincipalAmountRoundedDown(amount_, currentIndex_); + + _balances[recipient_] = _wrap(true, currentIndex_, rawBalance_ + principalAmount_); + + totalEarningSupply += amount_; + } + + function _startEarning(address account_) internal { + ( bool isEarning_, , uint256 rawBalance_) = _unwrap(_balances[account_]); + + if (isEarning_) return; + + emit StartEarning(account_); + + uint128 currentIndex_ = currentMIndex(); + + _balances[account_] = _wrap( + true, + currentIndex_, + _getPrincipalAmountRoundedDown(uint240(rawBalance_), currentIndex_) + ); + } + + function _stopEarning(address account_) internal { + ( bool isEarning_, , ) = _unwrap(_balances[account_]); + + if (!isEarning_) return; + + _claimForEarner(account_, currentMIndex()); + + emit StopEarning(account_); + + ( , uint128 index_, uint256 rawBalance_) = _unwrap(_balances[account_]); + + _balances[account_] = _wrap( + false, + 0, + _getPresentAmountRoundedDown(uint112(rawBalance_), index_) + ); + } + + function _transfer(address sender_, address recipient_, uint256 amount_) internal override { + emit Transfer(sender_, recipient_, amount_); + + uint240 safeAmount_ = UIntMath.safe240(amount_); + + ( bool senderIsEarning_, , uint256 senderRawBalance_ ) = _unwrap(_balances[sender_]); + ( bool recipientIsEarning_, , uint256 recipientRawBalance_ ) = _unwrap(_balances[recipient_]); + + if (!senderIsEarning_ && !recipientIsEarning_) { + _balances[sender_] = _wrap(false, 0, senderRawBalance_ - safeAmount_); + _balances[recipient_] = _wrap(false, 0, recipientRawBalance_ + safeAmount_); + + return; + } + + uint128 currentIndex_ = currentMIndex(); + + if (senderIsEarning_ && recipientIsEarning_) { + _claimForEarner(sender_, currentIndex_); + _claimForEarner(recipient_, currentIndex_); + + ( , , senderRawBalance_ ) = _unwrap(_balances[sender_]); + ( , , recipientRawBalance_ ) = _unwrap(_balances[recipient_]); + + uint112 principalAmount_ = _getPrincipalAmountRoundedDown(safeAmount_, currentIndex_); + + _balances[sender_] = _wrap(true, currentIndex_, senderRawBalance_ - principalAmount_); + _balances[recipient_] = _wrap(true, currentIndex_, recipientRawBalance_ + principalAmount_); + } else if (senderIsEarning_) { + _claimForEarner(sender_, currentIndex_); + _claimForTotalNonEarningSupply(currentIndex_); + _burnEarningAmount(sender_, safeAmount_, currentIndex_); + _mintNonEarningAmount(recipient_, safeAmount_, currentIndex_); + } else { + _claimForEarner(recipient_, currentIndex_); + _claimForTotalNonEarningSupply(currentIndex_); + _burnNonEarningAmount(sender_, safeAmount_, currentIndex_); + _mintEarningAmount(recipient_, safeAmount_, currentIndex_); + } + } + + /* ============ Internal View/Pure Functions ============ */ + + function _accruedYield(Balance balance_, uint128 currentIndex_) internal pure returns (uint240 yield_) { + ( bool isEarning_, uint128 index_, uint256 rawBalance_) = _unwrap(balance_); + + return isEarning_ ? _accruedYield(uint112(rawBalance_), index_, currentIndex_) : 0; + } + + function _accruedYield( + uint112 principalAmount_, + uint128 index_, + uint128 currentIndex_ + ) internal pure returns (uint240 yield_) { + // TODO: Compare with `_getPresentAmountRoundedDown(principalAmount_, currentIndex_) - _getPresentAmountRoundedDown(principalAmount_, index_)` + return _getPresentAmountRoundedDown(principalAmount_, currentIndex_ - index_); + } + + function _isApprovedEarner(address account_) internal view returns (bool isApproved_) { + // TODO: Toggle boolean? + // TODO: Separate list? + return (account_ != address(this)) && ITTGRegistrarLike(ttgRegistrar).listContains(_EARNERS_LIST, account_); + } + + function _isApprovedAllocator(address account_) internal view returns (bool isApproved_) { + return ITTGRegistrarLike(ttgRegistrar).listContains(_ALLOCATORS_LIST, account_); + } + + function _getEaringDelegate(address account_) internal view returns (address earningDelegate_) { + return + address( + uint160( + uint256( + ITTGRegistrarLike(ttgRegistrar).get(keccak256(abi.encode(_EARNING_DELEGATE_PREFIX, account_))) + ) + ) + ); + } + + function _multiplyDown(uint112 x_, uint128 index_) internal pure returns (uint240 z) { + unchecked { + return uint240((uint256(x_) * index_) / _EXP_SCALED_ONE); + } + } + + function _divideDown(uint240 x_, uint128 index_) internal pure returns (uint112 z) { + if (index_ == 0) revert DivisionByZero(); + + unchecked { + return UIntMath.safe112((uint256(x_) * _EXP_SCALED_ONE) / index_); + } + } + + function _divideUp(uint240 x, uint128 index) internal pure returns (uint112 z) { + if (index == 0) revert DivisionByZero(); + + unchecked { + return UIntMath.safe112(((uint256(x) * _EXP_SCALED_ONE) + index - 1) / index); + } + } + + function _getPresentAmountRoundedDown( + uint112 principalAmount_, + uint128 index_ + ) internal pure returns (uint240 presentAmount_) { + return _multiplyDown(principalAmount_, index_); + } + + function _getPrincipalAmountRoundedDown( + uint240 presentAmount_, + uint128 index_ + ) internal pure returns (uint112 principalAmount_) { + return _divideDown(presentAmount_, index_); + } + + function _getPrincipalAmountRoundedUp( + uint240 presentAmount_, + uint128 index_ + ) internal pure returns (uint112 principalAmount_) { + return _divideUp(presentAmount_, index_); + } + + function _unwrap(Balance balance_) internal pure returns (bool isEarning_, uint128 index_, uint256 rawBalance_) { + uint256 unwrapped_ = Balance.unwrap(balance_); + + isEarning_ = (unwrapped_ >> 248) != 0; + + if (isEarning_) { + index_ = uint128((unwrapped_ << 8) >> 136); + + if (index_ == 0) { + index_ = _EXP_SCALED_ONE; + } + + rawBalance_ = (unwrapped_ << 136) >> 136; + } else { + index_ = 0; + rawBalance_ = (unwrapped_ << 8) >> 8; + } + } + + function _wrap( bool isEarning_, uint256 index_, uint256 amount_) internal pure returns (Balance balance_) { + return isEarning_ ? Balance.wrap(1 << 248 | index_ << 136 | amount_) : Balance.wrap(amount_); + } +} diff --git a/src/interfaces/IMTokenLike.sol b/src/interfaces/IMTokenLike.sol new file mode 100644 index 0000000..881000c --- /dev/null +++ b/src/interfaces/IMTokenLike.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.23; + +interface IMTokenLike { + /* ============ Interactive Functions ============ */ + + function transfer(address recipient, uint256 amount) external; + + function transferFrom(address sender, address recipient, uint256 amount) external; + + /* ============ View/Pure Functions ============ */ + + function balanceOf(address account) external view returns (uint256); + + function currentIndex() external view returns (uint128); + + function isEarning(address account) external view returns (bool); +} diff --git a/src/interfaces/ITTGRegistrarLike.sol b/src/interfaces/ITTGRegistrarLike.sol new file mode 100644 index 0000000..80dec7b --- /dev/null +++ b/src/interfaces/ITTGRegistrarLike.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.23; + +interface ITTGRegistrarLike { + function get(bytes32 key) external view returns (bytes32 value); + + function listContains(bytes32 list, address account) external view returns (bool); +} diff --git a/src/interfaces/IWrappedM.sol b/src/interfaces/IWrappedM.sol new file mode 100644 index 0000000..687c017 --- /dev/null +++ b/src/interfaces/IWrappedM.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.23; + +import { IERC20Extended } from "../../lib/common/src/interfaces/IERC20Extended.sol"; + +interface IWrappedM is IERC20Extended { + /* ============ Events ============ */ + + event Claim(address indexed account, uint256 yield); + + event StartEarning(address indexed account); + + event StopEarning(address indexed account); + + /* ============ Custom Errors ============ */ + + error NotApprovedEarner(); + + error IsApprovedEarner(); + + error DivisionByZero(); + + error NotEarning(); + + error NotAllocator(); + + error NotEarningDelegate(); + + /* ============ Interactive Functions ============ */ + + function deposit(address destination, uint256 amount) external; + + function withdraw(address destination, uint256 amount) external; + + /* ============ View/Pure Functions ============ */ + + function mToken() external view returns (address); +} diff --git a/test/Foo.t.sol b/test/Foo.t.sol deleted file mode 100644 index 8e6f3e8..0000000 --- a/test/Foo.t.sol +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity 0.8.25; - -import "forge-std/Test.sol"; - -import { Foo } from "../src/Foo.sol"; - -interface IERC20 { - function balanceOf(address account) external view returns (uint256); -} - -/// @dev See the "Writing Tests" section in the Foundry Book if this is your first time with Forge. -/// https://book.getfoundry.sh/forge/writing-tests -contract FooTest is Test { - uint256 public mainnetFork; - - Foo public fooContract = new Foo(); - - function setUp() public { - mainnetFork = vm.createFork(vm.rpcUrl("mainnet"), 16_428_000); - } - - /// @dev Simple test. Run Forge with `-vvvv` to see stack traces. - function test() external { - string memory foo = fooContract.getFoo(); - - assertEq(foo, "Foo"); - } - - /// @dev Test that runs against a fork of Ethereum Mainnet. You need to set `MAINNET_RPC_URL` in your `.env` - function testFork() external { - vm.selectFork(mainnetFork); - - address usdc = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - address holder = 0x7713974908Be4BEd47172370115e8b1219F4A5f0; - uint256 actualBalance = IERC20(usdc).balanceOf(holder); - uint256 expectedBalance = 196_307_713.810457e6; - assertEq(actualBalance, expectedBalance); - } -}