diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e4da1c6 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +ALCHEMY_KEY= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 762a296..86e1ce4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,3 +43,5 @@ jobs: run: | forge test -vvv id: test + env: + ALCHEMY_KEY: ${{ secrets.ALCHEMY_KEY }} diff --git a/README.md b/README.md index 9265b45..20ac75b 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,42 @@ -## Foundry +# Morpho Token -**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** +This repository contains the latest version of the Morpho protocol’s ERC20 token, designed to enhance functionality, security, and compatibility within the Morpho ecosystem. This new version introduces upgradability and onchain delegation features, allowing for greater flexibility and adaptability over time. Additionally, it includes a wrapper contract to facilitate a seamless migration from the previous token version, enabling users to transition their assets with minimal friction. -Foundry consists of: +## Upgradability -- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). -- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. -- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. -- **Chisel**: Fast, utilitarian, and verbose solidity REPL. +The Morpho Token leverages the eip-1967 to enable upgrade of the logic. This will allow new features to be added in the future. -## Documentation +## Delegation -https://book.getfoundry.sh/ +The Morpho Token enables onchain voting power delegation. The contract keeps track of all the addresses current voting power, which allows onchain votes thanks to storage proofs (on specific voting contracts). -## Usage - -### Build - -```shell -$ forge build -``` - -### Test - -```shell -$ forge test -``` +## Migration -### Format +### Wrapper Contract -```shell -$ forge fmt -``` +The `Wrapper` contract is designed to facilitate the migration of legacy tokens to the new token version at a 1:1 ratio. By implementing `depositFor` and `withdrawTo` functions, this contract ensures compliance with `ERC20WrapperBundler` from the [Morpho bundler](https://github.com/morpho-org/morpho-blue-bundlers) contracts, enabling one-click migrations that simplify the transition process. +The `Wrapper` contract will hold the migrated legacy tokens. -### Gas Snapshots +### Migration Flow -```shell -$ forge snapshot -``` +N.B. The `Wrapper` contract must be deployed before the new token's initialization. -### Anvil +At the token's initialization, 1B tokens will be minted for the `Wrapper` contract, which will initially hold the entire supply. -```shell -$ anvil -``` +Any legacy token holder will then be able to migrate their tokens by calling the `depositFor` function of the `Wrapper` contract (Having previously approved the migration amount to the wrapper). -### Deploy +Migrated legacy tokens can be recovered thanks to the `withdrawTo`, that allows to revert a migration. -```shell -$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key -``` +## Usage -### Cast +### Install dependencies ```shell -$ cast +$ forge install ``` -### Help +### Test ```shell -$ forge --help -$ anvil --help -$ cast --help +$ forge test ``` diff --git a/foundry.toml b/foundry.toml index 25b918f..cb8bdf4 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,4 +3,11 @@ src = "src" out = "out" libs = ["lib"] +[profile.default.fuzz] +runs = 32 + + +[profile.default.rpc_endpoints] +ethereum = "https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" + # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/src/ERC20DelegatesUpgradeable.sol b/src/ERC20DelegatesUpgradeable.sol new file mode 100644 index 0000000..ae553b9 --- /dev/null +++ b/src/ERC20DelegatesUpgradeable.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import {IDelegates} from "./interfaces/IDelegates.sol"; + +import {ERC20Upgradeable} from "lib/openzeppelin-contracts-upgradeable/contracts/token/ERC20/ERC20Upgradeable.sol"; +import {ECDSA} from + "lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; +import {NoncesUpgradeable} from "lib/openzeppelin-contracts-upgradeable/contracts/utils/NoncesUpgradeable.sol"; +import {EIP712Upgradeable} from + "lib/openzeppelin-contracts-upgradeable/contracts/utils/cryptography/EIP712Upgradeable.sol"; +import {Initializable} from "lib/openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; + +/// @title ERC20DelegatesUpgradeable +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @dev Extension of ERC20 to support token delegation. +/// +/// This extension keeps track of each account's vote power. Vote power can be delegated either by calling the +/// {delegate} function directly, or by providing a signature to be used with {delegateBySig}. Voting power can be +/// queried through the public accessor {getVotes}. +/// +/// By default, token balance does not account for voting power. This makes transfers cheaper. The downside is that it +/// requires users to delegate to themselves in order to activate their voting power. +abstract contract ERC20DelegatesUpgradeable is + Initializable, + ERC20Upgradeable, + EIP712Upgradeable, + NoncesUpgradeable, + IDelegates +{ + /* CONSTANTS */ + + bytes32 private constant DELEGATION_TYPEHASH = + keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); + + // keccak256(abi.encode(uint256(keccak256("morpho.storage.ERC20Delegates")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ERC20DelegatesStorageLocation = + 0x1dc92b2c6e971ab6e08dfd7dcec0e9496d223ced663ba2a06543451548549500; + + /* STRUCTS */ + + /// @custom:storage-location erc7201:morpho.storage.ERC20Delegates + struct ERC20DelegatesStorage { + mapping(address account => address) _delegatee; + mapping(address delegatee => uint256) _votingPower; + uint256 _totalVotingPower; + } + + /* PUBLIC */ + + /// @dev Returns the current amount of votes that `account` has. + function getVotes(address account) public view returns (uint256) { + ERC20DelegatesStorage storage $ = _getERC20DelegatesStorage(); + return $._votingPower[account]; + } + + /// @dev Returns the delegate that `account` has chosen. + function delegates(address account) public view returns (address) { + ERC20DelegatesStorage storage $ = _getERC20DelegatesStorage(); + return $._delegatee[account]; + } + + /// @dev Delegates votes from the sender to `delegatee`. + function delegate(address delegatee) public { + address account = _msgSender(); + _delegate(account, delegatee); + } + + /// @dev Delegates votes from signer to `delegatee`. + function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) public { + if (block.timestamp > expiry) { + revert DelegatesExpiredSignature(expiry); + } + address signer = ECDSA.recover( + _hashTypedDataV4(keccak256(abi.encode(DELEGATION_TYPEHASH, delegatee, nonce, expiry))), v, r, s + ); + _useCheckedNonce(signer, nonce); + _delegate(signer, delegatee); + } + + /* INTERNAL */ + + /// @dev Delegates all of `account`'s voting units to `delegatee`. + /// @dev Emits events {IDelegates-DelegateChanged} and {IDelegates-DelegateVotesChanged}. + function _delegate(address account, address delegatee) internal { + ERC20DelegatesStorage storage $ = _getERC20DelegatesStorage(); + address oldDelegate = delegates(account); + $._delegatee[account] = delegatee; + + emit DelegateChanged(account, oldDelegate, delegatee); + _moveDelegateVotes(oldDelegate, delegatee, _getVotingUnits(account)); + } + + /// @dev Transfers, mints, or burns voting units. To register a mint, `from` should be zero. To register a burn, `to` + /// should be zero. Total supply of voting units will be adjusted with mints and burns. + function _transferVotingUnits(address from, address to, uint256 amount) internal { + ERC20DelegatesStorage storage $ = _getERC20DelegatesStorage(); + if (from == address(0)) { + $._totalVotingPower += amount; + } + if (to == address(0)) { + $._totalVotingPower -= amount; + } + _moveDelegateVotes(delegates(from), delegates(to), amount); + } + + /// @dev Must return the voting units held by an account. + function _getVotingUnits(address account) internal view returns (uint256) { + return balanceOf(account); + } + + /// @dev Moves voting power when tokens are transferred. + /// @dev Emits a {IDelegates-DelegateVotesChanged} event. + function _update(address from, address to, uint256 value) internal virtual override { + super._update(from, to, value); + // No check of supply cap here like in OZ implementation as MORPHO has a 1B total supply cap. + _transferVotingUnits(from, to, value); + } + + /* PRIVATE */ + + /// @dev Moves delegated votes from one delegate to another. + function _moveDelegateVotes(address from, address to, uint256 amount) private { + ERC20DelegatesStorage storage $ = _getERC20DelegatesStorage(); + if (from != to && amount > 0) { + if (from != address(0)) { + uint256 oldValue = $._votingPower[from]; + uint256 newValue = oldValue - amount; + $._votingPower[from] = newValue; + emit DelegateVotesChanged(from, oldValue, newValue); + } + if (to != address(0)) { + uint256 oldValue = $._votingPower[to]; + uint256 newValue = oldValue + amount; + $._votingPower[to] = newValue; + emit DelegateVotesChanged(to, oldValue, newValue); + } + } + } + + /// @dev Returns the ERC20DelegatesStorage struct. + function _getERC20DelegatesStorage() private pure returns (ERC20DelegatesStorage storage $) { + assembly { + $.slot := ERC20DelegatesStorageLocation + } + } +} diff --git a/src/MorphoToken.sol b/src/MorphoToken.sol index 91f8101..6f8fcc4 100644 --- a/src/MorphoToken.sol +++ b/src/MorphoToken.sol @@ -1,28 +1,27 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.13; +pragma solidity 0.8.27; import {ERC20Upgradeable} from "lib/openzeppelin-contracts-upgradeable/contracts/token/ERC20/ERC20Upgradeable.sol"; import {Ownable2StepUpgradeable} from "lib/openzeppelin-contracts-upgradeable/contracts/access/Ownable2StepUpgradeable.sol"; -import {ERC20VotesUpgradeable} from - "lib/openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC20VotesUpgradeable.sol"; +import {ERC20DelegatesUpgradeable} from "./ERC20DelegatesUpgradeable.sol"; import { ERC20PermitUpgradeable, NoncesUpgradeable } from "lib/openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; import {UUPSUpgradeable} from "lib/openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; -// TODO: -// - add natspecs -// - add events? -// - add error messages -contract MorphoToken is ERC20VotesUpgradeable, ERC20PermitUpgradeable, Ownable2StepUpgradeable, UUPSUpgradeable { +/// @title MorphoToken +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice The MORPHO Token contract. +contract MorphoToken is ERC20DelegatesUpgradeable, ERC20PermitUpgradeable, Ownable2StepUpgradeable, UUPSUpgradeable { /* CONSTANTS */ - /// @dev the name of the token. + /// @dev The name of the token. string internal constant NAME = "Morpho Token"; - /// @dev the symbol of the token. + /// @dev The symbol of the token. string internal constant SYMBOL = "MORPHO"; /* ERRORS */ @@ -32,11 +31,13 @@ contract MorphoToken is ERC20VotesUpgradeable, ERC20PermitUpgradeable, Ownable2S /* PUBLIC */ + /// @notice Initializes the contract. + /// @param dao The DAO address. + /// @param wrapper The wrapper contract address to migrate legacy MORPHO tokens to the new one. function initialize(address dao, address wrapper) public initializer { require(dao != address(0), ZeroAddress()); require(wrapper != address(0), ZeroAddress()); - ERC20VotesUpgradeable.__ERC20Votes_init(); ERC20Upgradeable.__ERC20_init(NAME, SYMBOL); Ownable2StepUpgradeable.__Ownable2Step_init(); ERC20PermitUpgradeable.__ERC20Permit_init(NAME); @@ -45,17 +46,19 @@ contract MorphoToken is ERC20VotesUpgradeable, ERC20PermitUpgradeable, Ownable2S _mint(wrapper, 1_000_000_000e18); // Mint 1B to the wrapper contract. } + /// @inheritdoc ERC20PermitUpgradeable function nonces(address owner) public view override(ERC20PermitUpgradeable, NoncesUpgradeable) returns (uint256) { return ERC20PermitUpgradeable.nonces(owner); } /* INTERNAL */ + /// @inheritdoc ERC20DelegatesUpgradeable function _update(address from, address to, uint256 value) internal - override(ERC20Upgradeable, ERC20VotesUpgradeable) + override(ERC20Upgradeable, ERC20DelegatesUpgradeable) { - ERC20VotesUpgradeable._update(from, to, value); + ERC20DelegatesUpgradeable._update(from, to, value); } /// @inheritdoc UUPSUpgradeable diff --git a/src/Wrapper.sol b/src/Wrapper.sol index 886fbb6..b1f7a12 100644 --- a/src/Wrapper.sol +++ b/src/Wrapper.sol @@ -1,21 +1,21 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.13; +pragma solidity 0.8.27; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -// TODO: -// - add natspecs -// - add events? -// - add error messages +/// @title MorphoToken +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice The MORPHO Token contract. contract Wrapper { /* CONSTANTS */ - /// @dev The address of the legacy morpho token. + /// @dev The address of the legacy MORPHO token. address public constant LEGACY_MORPHO = address(0x9994E35Db50125E0DF82e4c2dde62496CE330999); /* IMMUTABLES */ - /// @dev The address of the new morpho token. + /// @dev The address of the new MORPHO token. address public immutable NEW_MORPHO; /* ERRORS */ @@ -38,12 +38,22 @@ contract Wrapper { /* PUBLIC */ /// @dev Compliant to `ERC20Wrapper` contract from OZ for convenience. - function depositFor(address account, uint256 amount) public returns (bool) { + function depositFor(address account, uint256 value) public returns (bool) { require(account != address(0), ZeroAddress()); require(account != address(this), SelfAddress()); - IERC20(LEGACY_MORPHO).transferFrom(msg.sender, address(this), amount); - IERC20(NEW_MORPHO).transfer(account, amount); + IERC20(LEGACY_MORPHO).transferFrom(msg.sender, address(this), value); + IERC20(NEW_MORPHO).transfer(account, value); + return true; + } + + /// @dev Compliant to `ERC20Wrapper` contract from OZ for convenience. + function withdrawTo(address account, uint256 value) public returns (bool) { + require(account != address(0), ZeroAddress()); + require(account != address(this), SelfAddress()); + + IERC20(NEW_MORPHO).transferFrom(msg.sender, address(this), value); + IERC20(LEGACY_MORPHO).transfer(account, value); return true; } diff --git a/src/interfaces/IDelegates.sol b/src/interfaces/IDelegates.sol new file mode 100644 index 0000000..c08cefb --- /dev/null +++ b/src/interfaces/IDelegates.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title IDelegates +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice The Delegates interface. +interface IDelegates { + // @dev The signature used has expired. + error DelegatesExpiredSignature(uint256 expiry); + + // @dev Emitted when an account changes their delegate. + event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate); + + // @dev Emitted when a token transfer or delegate change results in changes to a delegate's number of voting units. + event DelegateVotesChanged(address indexed delegate, uint256 previousVotes, uint256 newVotes); + + // @dev Returns the current amount of votes that `account` has. + function getVotes(address account) external view returns (uint256); + + // @dev Returns the delegate that `account` has chosen. + function delegates(address account) external view returns (address); + + // @dev Delegates votes from the sender to `delegatee`. + function delegate(address delegatee) external; + + // @dev Delegates votes from signer to `delegatee`. + function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) external; +} diff --git a/test/MorphoToken.t.sol b/test/MorphoToken.t.sol index 5d288c6..2649bbb 100644 --- a/test/MorphoToken.t.sol +++ b/test/MorphoToken.t.sol @@ -1,18 +1,204 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; -import {Test, console} from "forge-std/Test.sol"; +import {BaseTest} from "./helpers/BaseTest.sol"; +import {SigUtils} from "./helpers/SigUtils.sol"; import {MorphoToken} from "../src/MorphoToken.sol"; +import {ERC1967Proxy} from + "lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -// TODO: Test the following: -// - Test every paths -// - Test migration flow -// - Test bundler wrapping -// - Test access control -// - Test voting -// - Test delegation -contract MorphoTokenTest is Test { - MorphoToken public token; - - function setUp() public {} +contract MorphoTokenTest is BaseTest { + function testInitilizeZeroAddress(address randomAddress) public { + vm.assume(randomAddress != address(0)); + + address proxy = address(new ERC1967Proxy(address(tokenImplem), hex"")); + + vm.expectRevert(); + MorphoToken(proxy).initialize(address(0), randomAddress); + + vm.expectRevert(); + MorphoToken(proxy).initialize(randomAddress, address(0)); + } + + function testUpgradeNotOwner(address updater) public { + vm.assume(updater != address(0)); + vm.assume(updater != MORPHO_DAO); + + address newImplem = address(new MorphoToken()); + + vm.expectRevert(); + newMorpho.upgradeToAndCall(newImplem, hex""); + } + + function testUpgrade() public { + address newImplem = address(new MorphoToken()); + + vm.prank(MORPHO_DAO); + newMorpho.upgradeToAndCall(newImplem, hex""); + } + + function testOwnDelegation(address delegator, uint256 amount) public { + vm.assume(delegator != address(0)); + vm.assume(delegator != MORPHO_DAO); + amount = bound(amount, MIN_TEST_AMOUNT, MAX_TEST_AMOUNT); + + deal(address(newMorpho), delegator, amount); + + vm.prank(delegator); + newMorpho.delegate(delegator); + + assertEq(newMorpho.delegates(delegator), delegator); + assertEq(newMorpho.getVotes(delegator), amount); + } + + function testDelegate(address delegator, address delegatee, uint256 amount) public { + address[] memory addresses = new address[](2); + addresses[0] = delegator; + addresses[1] = delegatee; + _validateAddresses(addresses); + amount = bound(amount, MIN_TEST_AMOUNT, MAX_TEST_AMOUNT); + + deal(address(newMorpho), delegator, amount); + + vm.prank(delegator); + newMorpho.delegate(delegatee); + + assertEq(newMorpho.delegates(delegator), delegatee); + assertEq(newMorpho.getVotes(delegator), 0); + assertEq(newMorpho.getVotes(delegatee), amount); + } + + function testDelegateBySigExpired(SigUtils.Delegation memory delegation, uint256 privateKey, uint256 expiry) + public + { + expiry = bound(expiry, MAX_TEST_AMOUNT, MAX_TEST_AMOUNT); + privateKey = bound(privateKey, 1, type(uint32).max); + address delegator = vm.addr(privateKey); + + address[] memory addresses = new address[](2); + addresses[0] = delegator; + addresses[1] = delegation.delegatee; + _validateAddresses(addresses); + + delegation.expiry = expiry; + delegation.nonce = 0; + + Signature memory sig; + bytes32 digest = SigUtils.getTypedDataHash(delegation, address(newMorpho)); + (sig.v, sig.r, sig.s) = vm.sign(privateKey, digest); + + vm.warp(expiry + 1); + + vm.expectRevert(); + newMorpho.delegateBySig(delegation.delegatee, delegation.nonce, delegation.expiry, sig.v, sig.r, sig.s); + } + + function testDelegateBySigWrongNonce(SigUtils.Delegation memory delegation, uint256 privateKey, uint256 nounce) + public + { + vm.assume(nounce != 0); + privateKey = bound(privateKey, 1, type(uint32).max); + address delegator = vm.addr(privateKey); + + address[] memory addresses = new address[](2); + addresses[0] = delegator; + addresses[1] = delegation.delegatee; + _validateAddresses(addresses); + + delegation.expiry = bound(delegation.expiry, block.timestamp, type(uint256).max); + delegation.nonce = nounce; + + Signature memory sig; + bytes32 digest = SigUtils.getTypedDataHash(delegation, address(newMorpho)); + (sig.v, sig.r, sig.s) = vm.sign(privateKey, digest); + + vm.expectRevert(); + newMorpho.delegateBySig(delegation.delegatee, delegation.nonce, delegation.expiry, sig.v, sig.r, sig.s); + } + + function testDelegateBySig(SigUtils.Delegation memory delegation, uint256 privateKey, uint256 amount) public { + privateKey = bound(privateKey, 1, type(uint32).max); + address delegator = vm.addr(privateKey); + + address[] memory addresses = new address[](2); + addresses[0] = delegator; + addresses[1] = delegation.delegatee; + _validateAddresses(addresses); + vm.assume(newMorpho.nonces(delegator) == 0); + + delegation.expiry = bound(delegation.expiry, block.timestamp, type(uint256).max); + delegation.nonce = 0; + + amount = bound(amount, MIN_TEST_AMOUNT, MAX_TEST_AMOUNT); + deal(address(newMorpho), delegator, amount); + + Signature memory sig; + bytes32 digest = SigUtils.getTypedDataHash(delegation, address(newMorpho)); + (sig.v, sig.r, sig.s) = vm.sign(privateKey, digest); + + newMorpho.delegateBySig(delegation.delegatee, delegation.nonce, delegation.expiry, sig.v, sig.r, sig.s); + + assertEq(newMorpho.delegates(delegator), delegation.delegatee); + assertEq(newMorpho.getVotes(delegator), 0); + assertEq(newMorpho.getVotes(delegation.delegatee), amount); + assertEq(newMorpho.nonces(delegator), 1); + } + + function testMultipleDelegations( + address delegator1, + address delegator2, + address delegatee, + uint256 amount1, + uint256 amount2 + ) public { + address[] memory addresses = new address[](3); + addresses[0] = delegator1; + addresses[1] = delegator2; + addresses[2] = delegatee; + _validateAddresses(addresses); + amount1 = bound(amount1, MIN_TEST_AMOUNT, MAX_TEST_AMOUNT); + amount2 = bound(amount2, MIN_TEST_AMOUNT, MAX_TEST_AMOUNT); + + deal(address(newMorpho), delegator1, amount1); + deal(address(newMorpho), delegator2, amount2); + + vm.prank(delegator1); + newMorpho.delegate(delegatee); + + vm.prank(delegator2); + newMorpho.delegate(delegatee); + + assertEq(newMorpho.getVotes(delegatee), amount1 + amount2); + } + + function testTransferVotingPower( + address delegator1, + address delegator2, + address delegatee1, + address delegatee2, + uint256 initialAmount, + uint256 transferredAmount + ) public { + address[] memory addresses = new address[](4); + addresses[0] = delegator1; + addresses[1] = delegator2; + addresses[2] = delegatee1; + addresses[3] = delegatee2; + _validateAddresses(addresses); + initialAmount = bound(initialAmount, MIN_TEST_AMOUNT, MAX_TEST_AMOUNT); + transferredAmount = bound(transferredAmount, MIN_TEST_AMOUNT, initialAmount); + + deal(address(newMorpho), delegator1, initialAmount); + + vm.prank(delegator2); + newMorpho.delegate(delegatee2); + + vm.startPrank(delegator1); + newMorpho.delegate(delegatee1); + newMorpho.transfer(delegator2, transferredAmount); + vm.stopPrank(); + + assertEq(newMorpho.getVotes(delegatee1), initialAmount - transferredAmount); + assertEq(newMorpho.getVotes(delegatee2), transferredAmount); + } } diff --git a/test/MorphoTokenMigration.t.sol b/test/MorphoTokenMigration.t.sol new file mode 100644 index 0000000..4c64339 --- /dev/null +++ b/test/MorphoTokenMigration.t.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {console} from "lib/forge-std/src/Test.sol"; +import {BaseTest} from "./helpers/BaseTest.sol"; +import {Wrapper} from "../src/Wrapper.sol"; +import {IMulticall} from "./helpers/interfaces/IMulticall.sol"; +import {EncodeLib} from "./helpers/libraries/EncodeLib.sol"; +import {IERC20} from + "lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; + +contract MorphoTokenMigrationTest is BaseTest { + address internal constant BUNDLER_ADDRESS = 0x4095F064B8d3c3548A3bebfd0Bbfd04750E30077; + address internal constant LEGACY_MORPHO = 0x9994E35Db50125E0DF82e4c2dde62496CE330999; + + IMulticall internal bundler; + IERC20 internal legacyMorpho; + + uint256 internal forkId; + + bytes[] internal bundle; + + function setUp() public virtual override { + _fork(); + + vm.startPrank(MORPHO_DAO); + RolesAuthority(LEGACY_MORPHO).setPublicCapability(0x23b872dd, true); + RolesAuthority(LEGACY_MORPHO).setPublicCapability(0xa9059cbb, true); + vm.stopPrank(); + + bundler = IMulticall(BUNDLER_ADDRESS); + legacyMorpho = IERC20(LEGACY_MORPHO); + + super.setUp(); + } + + function _fork() internal virtual { + string memory rpcUrl = vm.rpcUrl("ethereum"); + uint256 forkBlockNumber = 20969715; + + forkId = vm.createSelectFork(rpcUrl, forkBlockNumber); + vm.chainId(1); + } + + function testDeployWrapperZeroAddress() public { + vm.expectRevert(); + new Wrapper(address(0)); + } + + function testTotalSupply() public { + assertEq(newMorpho.totalSupply(), 1_000_000_000e18); + } + + function testInitialWrapperBalances() public { + assertEq(legacyMorpho.balanceOf(address(wrapper)), 0); + assertEq(newMorpho.balanceOf(address(wrapper)), 1_000_000_000e18); + } + + function testDepositForZeroAddress(uint256 amount) public { + vm.assume(amount != 0); + + vm.expectRevert(); + wrapper.depositFor(address(0), amount); + } + + function testDepositForSelfAddress(uint256 amount) public { + vm.assume(amount != 0); + + vm.expectRevert(); + wrapper.depositFor(address(wrapper), amount); + } + + function testWithdrawToZeroAddress(uint256 amount) public { + vm.assume(amount != 0); + + vm.expectRevert(); + wrapper.withdrawTo(address(0), amount); + } + + function testWithdrawToSelfAddress(uint256 amount) public { + vm.assume(amount != 0); + + vm.expectRevert(); + wrapper.withdrawTo(address(wrapper), amount); + } + + function testDAOMigration() public { + uint256 daoTokenAmount = legacyMorpho.balanceOf(MORPHO_DAO); + + bundle.push(EncodeLib._erc20TransferFrom(LEGACY_MORPHO, daoTokenAmount)); + bundle.push(EncodeLib._erc20WrapperDepositFor(address(wrapper), daoTokenAmount)); + + vm.startPrank(MORPHO_DAO); + legacyMorpho.approve(address(bundler), daoTokenAmount); + bundler.multicall(bundle); + vm.stopPrank(); + + assertEq(legacyMorpho.balanceOf(MORPHO_DAO), 0, "legacyMorpho.balanceOf(MORPHO_DAO)"); + assertEq(legacyMorpho.balanceOf(address(wrapper)), daoTokenAmount, "legacyMorpho.balanceOf(wrapper)"); + assertEq(newMorpho.balanceOf(MORPHO_DAO), daoTokenAmount, "newMorpho.balanceOf(MORPHO_DAO)"); + } + + function testMigration(address migrater, uint256 amount) public { + vm.assume(migrater != address(0)); + vm.assume(migrater != MORPHO_DAO); + amount = bound(amount, MIN_TEST_AMOUNT, 1_000_000_000e18); + + deal(LEGACY_MORPHO, migrater, amount); + + bundle.push(EncodeLib._erc20TransferFrom(LEGACY_MORPHO, amount)); + bundle.push(EncodeLib._erc20WrapperDepositFor(address(wrapper), amount)); + + vm.startPrank(migrater); + legacyMorpho.approve(address(bundler), amount); + bundler.multicall(bundle); + vm.stopPrank(); + + assertEq(legacyMorpho.balanceOf(migrater), 0, "legacyMorpho.balanceOf(migrater)"); + assertEq(legacyMorpho.balanceOf(address(wrapper)), amount, "legacyMorpho.balanceOf(wrapper)"); + assertEq(newMorpho.balanceOf(address(wrapper)), 1_000_000_000e18 - amount, "newMorpho.balanceOf(wrapper)"); + assertEq(newMorpho.balanceOf(migrater), amount, "newMorpho.balanceOf(migrater)"); + } + + function testRevertMigration(address migrater, uint256 migratedAmount, uint256 revertedAmount) public { + vm.assume(migrater != address(0)); + vm.assume(migrater != MORPHO_DAO); + migratedAmount = bound(migratedAmount, MIN_TEST_AMOUNT, 1_000_000_000e18); + revertedAmount = bound(revertedAmount, MIN_TEST_AMOUNT, migratedAmount); + + deal(LEGACY_MORPHO, migrater, migratedAmount); + + bundle.push(EncodeLib._erc20TransferFrom(LEGACY_MORPHO, migratedAmount)); + bundle.push(EncodeLib._erc20WrapperDepositFor(address(wrapper), migratedAmount)); + + vm.startPrank(migrater); + legacyMorpho.approve(address(bundler), migratedAmount); + bundler.multicall(bundle); + vm.stopPrank(); + + vm.startPrank(migrater); + newMorpho.approve(address(wrapper), revertedAmount); + wrapper.withdrawTo(migrater, revertedAmount); + vm.stopPrank(); + + assertEq(legacyMorpho.balanceOf(migrater), revertedAmount, "legacyMorpho.balanceOf(migrater)"); + assertEq( + legacyMorpho.balanceOf(address(wrapper)), migratedAmount - revertedAmount, "legacyMorpho.balanceOf(wrapper)" + ); + assertEq( + newMorpho.balanceOf(address(wrapper)), + 1_000_000_000e18 - migratedAmount + revertedAmount, + "newMorpho.balanceOf(wrapper)" + ); + assertEq(newMorpho.balanceOf(migrater), migratedAmount - revertedAmount, "newMorpho.balanceOf(migrater)"); + } +} + +interface RolesAuthority { + function setPublicCapability(bytes4 functionSig, bool enabled) external; +} diff --git a/test/helpers/BaseTest.sol b/test/helpers/BaseTest.sol new file mode 100644 index 0000000..2714e21 --- /dev/null +++ b/test/helpers/BaseTest.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "lib/forge-std/src/Test.sol"; +import {MorphoToken} from "../../src/MorphoToken.sol"; +import {Wrapper} from "../../src/Wrapper.sol"; +import {ERC1967Proxy} from + "lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {UUPSUpgradeable} from "lib/openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; + +// TODO: Test the following: +// - Test every paths +// - Test migration flow +// - Test bundler wrapping +// - Test access control +// - Test voting +// - Test delegation +contract BaseTest is Test { + address public constant MORPHO_DAO = 0xcBa28b38103307Ec8dA98377ffF9816C164f9AFa; + + MorphoToken public tokenImplem; + MorphoToken public newMorpho; + ERC1967Proxy public tokenProxy; + Wrapper public wrapper; + + uint256 internal constant MIN_TEST_AMOUNT = 100; + uint256 internal constant MAX_TEST_AMOUNT = 1e28; + + function setUp() public virtual { + // DEPLOYMENTS + tokenImplem = new MorphoToken(); + tokenProxy = new ERC1967Proxy(address(tokenImplem), hex""); + wrapper = new Wrapper(address(tokenProxy)); + + newMorpho = MorphoToken(payable(address(tokenProxy))); + newMorpho.initialize(MORPHO_DAO, address(wrapper)); + } + + function _validateAddresses(address[] memory addresses) internal pure { + for (uint256 i = 0; i < addresses.length; i++) { + vm.assume(addresses[i] != address(0)); + vm.assume(addresses[i] != MORPHO_DAO); + for (uint256 j = i + 1; j < addresses.length; j++) { + vm.assume(addresses[i] != addresses[j]); + } + } + } + + struct Signature { + uint8 v; + bytes32 r; + bytes32 s; + } +} diff --git a/test/helpers/SigUtils.sol b/test/helpers/SigUtils.sol new file mode 100644 index 0000000..cf43e7e --- /dev/null +++ b/test/helpers/SigUtils.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {IERC5267} from + "lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/interfaces/IERC5267.sol"; + +library SigUtils { + struct Delegation { + address delegatee; + uint256 nonce; + uint256 expiry; + } + + bytes32 private constant DELEGATION_TYPEHASH = + keccak256("Delegation(address delegatee,uint256 nonce,uint256 expiry)"); + + bytes32 private constant TYPE_HASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + // bytes32 private constant DOMAIN_SEPARATOR = 0xebe7cdc854ed987c1fb2e9e58acbe8b1afdc4375c51e160b9a8de75014baa36b; + + /// @dev Computes the hash of the EIP-712 encoded data. + function getTypedDataHash(Delegation memory delegation, address contractAddress) public view returns (bytes32) { + (, string memory name, string memory version,,,,) = IERC5267(contractAddress).eip712Domain(); + return + keccak256(bytes.concat("\x19\x01", domainSeparator(contractAddress, name, version), hashStruct(delegation))); + } + + function hashStruct(Delegation memory delegation) internal pure returns (bytes32) { + return keccak256(abi.encode(DELEGATION_TYPEHASH, delegation.delegatee, delegation.nonce, delegation.expiry)); + } + + function domainSeparator(address contractAddress, string memory name, string memory version) + public + view + returns (bytes32) + { + return keccak256( + abi.encode(TYPE_HASH, keccak256(bytes(name)), keccak256(bytes(version)), block.chainid, contractAddress) + ); + } +} diff --git a/test/helpers/interfaces/IERC20WrapperBundler.sol b/test/helpers/interfaces/IERC20WrapperBundler.sol new file mode 100644 index 0000000..6a25dfa --- /dev/null +++ b/test/helpers/interfaces/IERC20WrapperBundler.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +interface IERC20WrapperBundler { + function erc20WrapperDepositFor(address wrapper, uint256 amount) external; +} diff --git a/test/helpers/interfaces/IMulticall.sol b/test/helpers/interfaces/IMulticall.sol new file mode 100644 index 0000000..f498cf4 --- /dev/null +++ b/test/helpers/interfaces/IMulticall.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title IMulticall +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Interface of Multicall. +interface IMulticall { + /// @notice Executes an ordered batch of delegatecalls to this contract. + /// @param data The ordered array of calldata to execute. + function multicall(bytes[] calldata data) external payable; +} diff --git a/test/helpers/interfaces/ITransferBundler.sol b/test/helpers/interfaces/ITransferBundler.sol new file mode 100644 index 0000000..83b2138 --- /dev/null +++ b/test/helpers/interfaces/ITransferBundler.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +interface ITransferBundler { + function erc20TransferFrom(address asset, uint256 amount) external; +} diff --git a/test/helpers/libraries/EncodeLib.sol b/test/helpers/libraries/EncodeLib.sol new file mode 100644 index 0000000..c9f4a71 --- /dev/null +++ b/test/helpers/libraries/EncodeLib.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {IERC20WrapperBundler} from "../interfaces/IERC20WrapperBundler.sol"; +import {ITransferBundler} from "../interfaces/ITransferBundler.sol"; + +library EncodeLib { + function _erc20WrapperDepositFor(address asset, uint256 amount) internal pure returns (bytes memory) { + return abi.encodeCall(IERC20WrapperBundler.erc20WrapperDepositFor, (asset, amount)); + } + + function _erc20TransferFrom(address asset, uint256 amount) internal pure returns (bytes memory) { + return abi.encodeCall(ITransferBundler.erc20TransferFrom, (asset, amount)); + } +}