From 4eb34a2b37c2932fc165a970503162db1d328925 Mon Sep 17 00:00:00 2001 From: Michael De Luca Date: Mon, 27 May 2024 09:41:28 -0400 Subject: [PATCH] feat: nft yield --- .gitmodules | 6 ++ .prettierrc | 7 +- .solhint.json | 28 ++++-- foundry.toml | 2 +- lib/common | 1 + lib/openzeppelin-contracts | 1 + script/Foo.s.sol | 17 ---- src/Foo.sol | 9 -- src/MTokenYield.sol | 155 ++++++++++++++++++++++++++++++++ src/WrappedM.sol | 84 +++++++++++++++++ src/interfaces/IMTokenLike.sol | 11 +++ src/interfaces/IMTokenYield.sol | 35 ++++++++ src/interfaces/IWrappedM.sol | 27 ++++++ test/Foo.t.sol | 41 --------- 14 files changed, 347 insertions(+), 77 deletions(-) create mode 160000 lib/common create mode 160000 lib/openzeppelin-contracts delete mode 100644 script/Foo.s.sol delete mode 100644 src/Foo.sol create mode 100644 src/MTokenYield.sol create mode 100644 src/WrappedM.sol create mode 100644 src/interfaces/IMTokenLike.sol create mode 100644 src/interfaces/IMTokenYield.sol create mode 100644 src/interfaces/IWrappedM.sol delete mode 100644 test/Foo.t.sol diff --git a/.gitmodules b/.gitmodules index 482a2f9..7d901f8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,9 @@ 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 +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = git@github.com:OpenZeppelin/openzeppelin-contracts.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/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..a241f09 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit a241f099054953be8e30bbca5f47c9a79ed24c69 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/MTokenYield.sol b/src/MTokenYield.sol new file mode 100644 index 0000000..89548ab --- /dev/null +++ b/src/MTokenYield.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.23; + +import { UIntMath } from "../lib/common/src/libs/UIntMath.sol"; + +import { ERC721 } from "../lib/openzeppelin-contracts/contracts/token/ERC721/ERC721.sol"; + +import { IMTokenLike } from "./interfaces/IMTokenLike.sol"; +import { IMTokenYield } from "./interfaces/IMTokenYield.sol"; +import { IWrappedM } from "./interfaces/IWrappedM.sol"; + +contract MTokenYield is IMTokenYield, ERC721 { + // TODO: Might be a way to make this a uint112 and uint128 for one slot. + struct YieldBase { + uint240 amount; + uint128 index; + } + + /* ============ Variables ============ */ + + uint56 internal constant _EXP_SCALED_ONE = 1e12; + + address public immutable mToken; + address public immutable wrappedM; + + uint256 internal _tokenCount; + + mapping(uint256 tokenId => YieldBase yieldBase) internal _yieldBases; + + /* ============ Modifiers ============ */ + + modifier onlyWrappedM() { + if (msg.sender != wrappedM) revert NotWrappedM(); + + _; + } + + /* ============ Constructor ============ */ + + constructor(address mToken_, address wrappedM_) ERC721("MYield by M^0", "ysM") { + mToken = mToken_; + wrappedM = wrappedM_; + } + + /* ============ Interactive Functions ============ */ + + function mint(address account_, uint256 amount_) external onlyWrappedM returns (uint256 tokenId_) { + tokenId_ = ++_tokenCount; + + _yieldBases[tokenId_] = YieldBase({ + amount: UIntMath.safe240(amount_), + index: IMTokenLike(mToken).currentIndex() + }); + + _mint(account_, tokenId_); + } + + function burn( + address account_, + uint256 tokenId_ + ) external onlyWrappedM returns (uint256 baseAmount_, uint256 yield_) { + if (ownerOf(tokenId_) != account_) revert NotOwner(); + + _burn(tokenId_); + + YieldBase storage yieldBase_ = _yieldBases[tokenId_]; + + baseAmount_ = yieldBase_.amount; + + yield_ = _multiplyDown(yieldBase_.amount, yieldBase_.index - IMTokenLike(mToken).currentIndex()); + + delete _yieldBases[tokenId_]; + } + + function claim(address account_, uint256 tokenId_) external returns (uint256 yield_) { + if (ownerOf(tokenId_) != msg.sender) revert NotOwner(); + + YieldBase storage yieldBase_ = _yieldBases[tokenId_]; + + uint128 currentIndex_ = IMTokenLike(mToken).currentIndex(); + + yield_ = _multiplyDown(yieldBase_.amount, yieldBase_.index - currentIndex_); + + yieldBase_.index = currentIndex_; + + IWrappedM(wrappedM).extract(account_, yield_); + } + + function reshape( + address account_, + uint256[] calldata tokenIds_, + uint256[] calldata amounts_ + ) external returns (uint256[] memory newTokenIds_, uint256 yield_) { + if (tokenIds_.length != amounts_.length) revert LengthMismatch(); + + uint128 currentIndex_ = IMTokenLike(mToken).currentIndex(); + + uint240 total_; + + for (uint256 index_; index_ < tokenIds_.length; ++index_) { + uint256 tokenId_ = tokenIds_[index_]; + + if (ownerOf(tokenId_) != msg.sender) revert NotOwner(); + + _burn(tokenId_); + + YieldBase storage yieldBase_ = _yieldBases[tokenId_]; + + total_ += yieldBase_.amount; + yield_ += _multiplyDown(yieldBase_.amount, yieldBase_.index - currentIndex_); + + delete _yieldBases[tokenId_]; + } + + newTokenIds_ = new uint256[](tokenIds_.length); + + for (uint256 index_; index_ < newTokenIds_.length; ++index_) { + uint256 tokenId_ = newTokenIds_[index_] = ++_tokenCount; + uint240 amount_ = UIntMath.safe240(amounts_[index_]); + + _yieldBases[tokenId_] = YieldBase({ + amount: amount_, + index: currentIndex_ + }); + + total_ -= amount_; + + _mint(account_, tokenId_); + } + + if (total_ > 0) revert ExcessAmount(); + + IWrappedM(wrappedM).extract(account_, yield_); + } + + /* ============ View/Pure Functions ============ */ + + function getYieldBase(uint256 tokenId_) external view returns (uint240 amount_, uint128 index_) { + YieldBase storage yieldBase_ = _yieldBases[tokenId_]; + + amount_ = yieldBase_.amount; + index_ = yieldBase_.index; + } + + /* ============ Internal Interactive Functions ============ */ + + /* ============ Internal View/Pure Functions ============ */ + + function _multiplyDown(uint240 x_, uint128 index_) internal pure returns (uint240) { + unchecked { + return uint240((uint256(x_) * index_) / _EXP_SCALED_ONE); + } + } +} diff --git a/src/WrappedM.sol b/src/WrappedM.sol new file mode 100644 index 0000000..20d46fc --- /dev/null +++ b/src/WrappedM.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.23; + +import { UIntMath } from "../lib/common/src/libs/UIntMath.sol"; + +import { IERC20 } from "../lib/common/src/interfaces/IERC20.sol"; + +import { ERC20Extended } from "../lib/common/src/ERC20Extended.sol"; + +import { IMTokenLike } from "./interfaces/IMTokenLike.sol"; +import { IMTokenYield } from "./interfaces/IMTokenYield.sol"; +import { IWrappedM } from "./interfaces/IWrappedM.sol"; + +contract WrappedM is IWrappedM, ERC20Extended { + /* ============ Variables ============ */ + + address public immutable mToken; + address public immutable mYield; + + uint256 public totalSupply; + + mapping(address account => uint256 balance) public balanceOf; + + /* ============ Modifiers ============ */ + + modifier onlyEarner() { + if (!IMTokenLike(mToken).isEarning(msg.sender)) revert NotEarner(); + + _; + } + + modifier onlyMYield() { + if (msg.sender != mYield) revert NotMYield(); + + _; + } + + /* ============ Constructor ============ */ + + constructor(address mToken_, address mYield_) ERC20Extended("WrappedM by M^0", "wM", 6) { + mToken = mToken_; + mYield = mYield_; + } + + /* ============ Interactive Functions ============ */ + + function deposit(address account_, uint256 amount_) external onlyEarner returns (uint256 mYieldTokenId_) { + emit Transfer(address(0), account_, amount_); + + balanceOf[account_] += amount_; + totalSupply += amount_; + + mYieldTokenId_ = IMTokenYield(mYield).mint(account_, amount_); + + IERC20(mToken).transferFrom(msg.sender, address(this), amount_); + } + + function withdraw(address account_, uint256 mYieldTokenId_) external returns (uint256 baseAmount_, uint256 yield_) { + (baseAmount_, yield_) = IMTokenYield(mYield).burn(msg.sender, mYieldTokenId_); + + balanceOf[account_] -= baseAmount_; + totalSupply -= baseAmount_; + + IERC20(mToken).transfer(account_, baseAmount_ + yield_); + } + + function extract(address account_, uint256 amount_) external onlyMYield { + IERC20(mToken).transfer(account_, amount_); + } + + /* ============ View/Pure Functions ============ */ + + /* ============ Internal Interactive Functions ============ */ + + function _transfer(address sender_, address recipient_, uint256 amount_) internal override { + emit Transfer(sender_, recipient_, amount_); + + balanceOf[sender_] -= amount_; + balanceOf[recipient_] += amount_; + } + + /* ============ Internal View/Pure Functions ============ */ +} diff --git a/src/interfaces/IMTokenLike.sol b/src/interfaces/IMTokenLike.sol new file mode 100644 index 0000000..e77f7e0 --- /dev/null +++ b/src/interfaces/IMTokenLike.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.23; + +interface IMTokenLike { + /* ============ View/Pure Functions ============ */ + + function currentIndex() external view returns (uint128); + + function isEarning(address account) external view returns (bool); +} diff --git a/src/interfaces/IMTokenYield.sol b/src/interfaces/IMTokenYield.sol new file mode 100644 index 0000000..1f60d6a --- /dev/null +++ b/src/interfaces/IMTokenYield.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.23; + +import { IERC721Metadata } from "../../lib/openzeppelin-contracts/contracts/token/ERC721/extensions/IERC721Metadata.sol"; + +interface IMTokenYield is IERC721Metadata { + /* ============ Events ============ */ + + /* ============ Custom Errors ============ */ + + error NotWrappedM(); + + error NotOwner(); + + error LengthMismatch(); + + error ExcessAmount(); + + /* ============ Interactive Functions ============ */ + + function mint(address account, uint256 amount) external returns (uint256 tokenId); + + function burn(address account, uint256 tokenId) external returns (uint256 baseAmount, uint256 yield); + + function claim(address account_, uint256 tokenId) external returns (uint256 yield); + + function reshape( + address account, + uint256[] calldata tokenIds, + uint256[] calldata amounts + ) external returns (uint256[] memory newTokenIds, uint256 yield); + + /* ============ View/Pure Functions ============ */ +} diff --git a/src/interfaces/IWrappedM.sol b/src/interfaces/IWrappedM.sol new file mode 100644 index 0000000..afe86bd --- /dev/null +++ b/src/interfaces/IWrappedM.sol @@ -0,0 +1,27 @@ +// 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 ============ */ + + /* ============ Custom Errors ============ */ + + error NotEarner(); + + error NotMYield(); + + /* ============ Interactive Functions ============ */ + + function deposit(address account, uint256 amount) external returns (uint256 mYieldTokenId); + + function withdraw(address account_, uint256 mYieldTokenId) external returns (uint256 baseAmount, uint256 yield); + + function extract(address account, 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); - } -}