Date: 18.04.24
Produced by Kirill Fedoseev (telegram: kfedoseev, twitter: @k1rill_fedoseev)
An independent security review of the M^0 Protocol was conducted by kfedoseev from 26.03.24 to 27.03.24. The fixes review was conducted on 18.04.24. The following methods were used for conducting a security review:
- Manual source code review
No security review can guarantee or verify the absence of vulnerabilities. This security review is a time-bound process where I tried to identify as many potential issues and vulnerabilities as possible, using my personal expertise in the smart contract development and review.
The M^0 NFT custody is an opt-in solution for wrapping rebasing POWER tokens minted via the core M^0 protocol. POWER holders not willing to deal with rebasing tokens due to legal or other reasons may use the wrapped NFT to transitively control their POWER balance though an NFT ownership.
- M^0 NFT custody is designed to support only the POWER ERC20 token issued by the deployed M^0 protocol. All other assets, including other tokens and native ETH, sent to the NFT custody contracts will be irrecoverably lost.
Severity | Impact: High | Impact: Medium | Impact: Low |
---|---|---|---|
Likelihood: High | Critical | High | Medium |
Likelihood: Medium | High | Medium | Low |
Likelihood: Low | Medium | Low | Low |
Impact - the economic, technical, reputation or other damage to the protocol implied from a successful exploit.
Likelihood - the probability that a particular finding or vulnerability gets exploited.
Severity - the overall criticality of the particular finding.
Reviewed commits:
Reviewed fixes commits:
Reviewed contracts:
src/interfaces/**
src/Proxy.sol
src/TokenHolder.sol
src/TokenWrapper.sol
ID | Title | Severity | Status |
---|---|---|---|
[H-01] | Wrapped POWER balances are lost during reset |
High | Fixed |
[L-01] | Address manipulation of deployed proxies | Low | Fixed |
[I-01] | More-efficient ERC1167 proxies for clone deployments | Informational | Fixed |
[I-02] | Redundant _tokenHolders storage mapping |
Informational | Fixed |
[I-03] | Avoid use of low-level calls | Informational | Fixed |
[I-04] | Unused imports | Informational | Fixed |
Core M^0 protocol has a reset functionality that redeploys the POWER token contract to a new address and re-distributes its initial supply to prior POWER holders.
TokenWrapper
, however, does not provide a way for NFT owner to access their redeployed POWER balance, while their old
POWER balance will be effectively rendered worthless.
Reset proposals submitted to ZeroGovernor
during epoch N
, can be voted for and executed during epochs N
and N+1
.
If the threshold is reached and proposal is also executed during epoch N
, new power token will use balances snapshot
from epoch N-1
for its bootstrap. In this case, the NFT owner won't have any time to react to the submitted reset
proposal and transfer tokens out of the custody contract.
Allow NFT owner to access arbitrary ERC20 tokens by adding an extra argument in the ITokenHolder
interface:
-function transfer(address recipient, uint256 amount) external returns (bool);
+function transfer(address token, address recipient, uint256 amount) external returns (bool);
-function delegate(address delegatee) external;
+function delegate(address token, address delegatee) external;
TokenWrapper
uses CREATE
to deploy TokenHolder
proxies. This allows front-running of wrap
transactions, which
will impact the assigned NFT ownership rights. This is problematic in the following scenario:
- Alice wraps 1 POWER token to test the system and observes the NFT with id
N1
being minted to her. - Bob frontruns the Alice's transaction in a chain reorg, resulting in
N1
ownership being re-assigned to Bob, while Alice receives a newN2
NFT. - Alice, while still thinking that
N1
belongs to her, transfers the rest of her POWER tokens to theTokenHolder
controlled byN1
. - Bob takes control over the Alice's POWER as he is the current owner of the
N1
NFT.
Use CREATE2
to deploy the proxies at deterministic addresses:
-function _wrap(uint256 amount_, address to_, address delegatee_) internal returns (uint256 tokenId_) {
+function _wrap(uint256 amount_, address to_, address delegatee_, bytes32 salt_) internal returns (uint256 tokenId_) {
// ...
- address tokenHolder_ = address(new Proxy(tokenHolderImplementation));
+ bytes32 salt = keccak256(abi.encode(to_, salt_));
+ address tokenHolder_ = address(new Proxy{salt: salt}(tokenHolderImplementation));
// ...
}
TokenWrapper
uses a custom implementation for immutable proxies deployment inspired by ERC1967 standard. However, as
the deployed proxies are designed to be immutable, a more efficient proxy standard exists - ERC1167.
ERC1167 is much cheaper gas-wise both during the proxy deployment and during proxied calls, as less storage is required for storing contract bytecode or implementation addresses.
Consider deploying ERC1167 proxies instead to save on gas, e.g. by using a Clones library.
-address tokenHolder_ = address(new Proxy(tokenHolderImplementation));
+address tokenHolder_ = Clones.clone(tokenHolderImplementation);
+// OR `Clones.cloneDeterministic(tokenHolderImplementation, salt)`, according to [L-01]
Storage mapping tokenHolders
in TokenWrapper
is effectively used to record key-value
pairs tokenHolder => tokenHolder
, since _getTokenId
does not change the low-level value of its argument and returns
the input value unchanged.
Consider removing _tokenHolders
mapping completely and changing getTokenHolder
in the following way:
function getTokenHolder(uint256 tokenId_) external view returns (address) {
_requireOwned(tokenId_);
- return _tokenHolders[tokenId_];
+ return address(uint160(tokenId_));
}
TokenWrapper
implementation of _wrap
uses a low-level call that shadows the original revert reason coming from
the delegate
implementation (e.g. error VoteEpoch()
). This seems unnecessary, consider making a direct Solidity call
instead.
-(bool success_, ) = tokenHolder_.call(abi.encodeWithSelector(ITokenHolder.delegate.selector, delegatee_));
-
-if (!success_) revert DelegationFailed();
+ITokenHolder(tokenHolder_).delegate(delegatee_);
The following imports are unused and can be removed:
In TokenHolder.sol
:
-import { SignatureChecker } from "../lib/common/src/libs/SignatureChecker.sol";
In TokenWrapper.sol
:
-import { Base64 } from "../lib/openzeppelin-contracts/contracts/utils/Base64.sol";
-import { Strings } from "../lib/openzeppelin-contracts/contracts/utils/Strings.sol";
// ...
-import { IERC20Like } from "./interfaces/IERC20Like.sol";