Platform: Code4rena
Start Date: 05/10/2023
Pot Size: $33,050 USDC
Total HM: 1
Participants: 54
Period: 6 days
Judge: hansfriese
Id: 294
League: ETH
Rank: 13/54
Findings: 3
Award: $99.29
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: thekmj
Also found by: 0x3b, 33BYTEZZZ, Bauchibred, Chom, Dravee, J4X, Limbooo, Maroutis, MiloTruck, MrPotatoMagic, SBSecurity, Sathish9098, Tadev, ZanyBonzy, adam-idarrha, adriro, btk, hyh, lukejohn, nmirchev8, peakbolt, radev_sw, rvierdiiev
5.4311 USDC - $5.43
QA | Issues | Instances |
---|---|---|
[N-01] | Missing event emission for critical state changes | 4 |
[N-02] | Unnecessary typecast to uint256 | 1 |
[N-03] | Remove unnecessary code | 4 |
[N-04] | Constants should be defined rather than using magic numbers | 2 |
[L-01] | Missing getUri() function that returns baseUri appended with a user specific tokenId | 1 |
[R-01] | Consider adding a time interval field | 1 |
N = Non-Critical, L = Low-severity, R = Recommendation
There are 4 instances of this issue:
Missing event emission when new delegator instance is created.
File: ERC20MultiDelegate.sol 16: constructor(ERC20Votes _token, address _delegate) { 17: _token.approve(msg.sender, type(uint256).max); 18: _token.delegate(_delegate); 19: //@audit NC - missing event emission when new delegator instance is created 20: }
Missing event emission when token
and uri
are set. Note: ERC1155 does not emit an event when setting a uri as seen here.
File: ERC20MultiDelegate.sol 45: constructor( 46: ERC20Votes _token, 47: string memory _metadata_uri 48: ) ERC1155(_metadata_uri) { 49: token = _token; 50: //@audit NC - missing event emission for both token and uri state settings 51: }
Missing event emission when internal function finishes execution.
File: ERC20MultiDelegate.sol 60: function delegateMulti( 61: uint256[] calldata sources, 62: uint256[] calldata targets, 63: uint256[] calldata amounts 64: ) external { 65: _delegateMulti(sources, targets, amounts); 66: }
File: ERC20MultiDelegate.sol 160: function setUri(string memory uri) external onlyOwner { 161: _setURI(uri); 162: //@audit NC - missing event emission 163: }
There is 1 instance of this issue:
File: ERC20MultiDelegate.sol 220: uint256(0), // salt //@audit NC - unnecessary typecast
There are 4 instances of this issue:
Although following the pattern of external functions calling internal functions is recommended, the delegateMulti() function just calls internal _delegateMulti() function with the passed parameters. This is an unnecessary step. Additionally, there are no complex interactions when calling from the external to internal function, thus making it safe to remove.
Remove this external delegateMulti() function below and make the internal function _delegateMulti() to external.
File: ERC20MultiDelegate.sol 60: function delegateMulti( 61: uint256[] calldata sources, 62: uint256[] calldata targets, 63: uint256[] calldata amounts 64: ) external { 65: _delegateMulti(sources, targets, amounts); 66: }
Since unsigned integer variables are 0 by default, there is no need to initialize them to 0.
86: uint transferIndex = 0;
Remove the parameter ERC20Votes _token from function retrieveProxyContractAddress
. The function retrieveProxyContractAddress
is called by functions _reimburse
, transferBetweenDelegators
and deployProxyDelegatorIfNeeded
. These functions always pass the state variable token
as parameter to the retrieveProxyContractAddress
. This creates extra gas that can be avoided by just using the state variable token
in the retrieveProxyContractAddress
function directly.
Instead of this:
File: ERC20MultiDelegate.sol 232: function retrieveProxyContractAddress( 233: ERC20Votes _token, 234: address _delegate 235: ) private view returns (address) { 236: bytes memory bytecode = abi.encodePacked( 237: type(ERC20ProxyDelegator).creationCode, 238: abi.encode(_token, _delegate) 239: ); 240: bytes32 hash = keccak256( 241: abi.encodePacked( 242: bytes1(0xff), 243: address(this), 244: uint256(0), // salt 245: keccak256(bytecode) 246: ) 247: ); 248: return address(uint160(uint256(hash))); 249: }
Use this: (Note: Make sure to make changes in functions _reimburse
, transferBetweenDelegators
and deployProxyDelegatorIfNeeded
when passing parameters to retrieveProxyContractAddress
)
File: ERC20MultiDelegate.sol 232: function retrieveProxyContractAddress( 233: address _delegate //@audit Now only one parameter 234: ) private view returns (address) { 235: bytes memory bytecode = abi.encodePacked( 236: type(ERC20ProxyDelegator).creationCode, 237: abi.encode(token, _delegate) //@audit directly using state variable "token" 238: ); 239: bytes32 hash = keccak256( 240: abi.encodePacked( 241: bytes1(0xff), 242: address(this), 243: uint256(0), // salt 244: keccak256(bytecode) 245: ) 246: ); 247: return address(uint160(uint256(hash))); 248: }
The return statement return proxyAddress
below can be removed and a named return can be used. This can additionally save some gas as well.
File: contracts/ERC20MultiDelegate.sol 189: return proxyAddress;
There are 2 instances of this issue:
https://github.com/code-423n4/2023-10-ens/blob/ed25379c06e42c8218eb1e80e141412496950685/contracts/ERC20MultiDelegate.sol#L186 https://github.com/code-423n4/2023-10-ens/blob/ed25379c06e42c8218eb1e80e141412496950685/contracts/ERC20MultiDelegate.sol#L210
Below are 2 instances of magic number 0 being used for salt. Use a constant variable salt
to avoid using the magic number directly. This can further increase the readability and maintainability of the code.
File: ERC20MultiDelegate.sol 224: new ERC20ProxyDelegator{salt: 0}(token, delegate); 248: uint256(0), // salt
Implementing a token type ID substitution mechanism (as mentioned by OpenZeppelin here) is a widespread practice in contracts that use the ERC1155 standard.
This is because instead of the user having to manually append their tokenId themselves, the getUri() function provides it directly when the user provides their tokenId. Additionally, a getUri() function can be helpful to read uri data for a specific tokenId directly from the state since it ensures that the data is upto date and removes the frontend from the equation to handle the appending of the tokenId. It also serves as an onchain pointer for a specific tokenId that is some metadata stored offchain in the bucket of a IPFS solution.
Small discussion between Sponsor and I which confirms that a data uri will be used to display metadata on the user frontend:
Solution:
function getUri(uint256 tokenId) external view returns(string memory) { string memory tokenID = Strings.toString(tokenId); string memory baseUri = uri(tokenID); //passing tokenID here is not necessary since it just returns base uri return string(abi.encodePacked(baseUri, tokenID)); }
The time interval field would represent the length of a proposal or the wait time before which delegation cannot be reimbursed or transferred to another delegator.
This can help prevent gaining proposal majority by disallowing a single entity from voting from multiple addresses. This interval is just an extra layer of prevention in case an external voting implementation has an error in it that allows this issue to occur. The interval can be set to 0 for external voting systems that do not require a wait-time.
#0 - 141345
2023-10-13T12:03:03Z
#1 - c4-pre-sort
2023-10-13T12:03:08Z
141345 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-24T15:46:09Z
hansfriese marked the issue as grade-b
🌟 Selected for report: windhustler
Also found by: 0xhex, 0xta, JCK, K42, MatricksDeCoder, MrPotatoMagic, SAQ, SY_S, SovaSlava, aslanbek, d3e4, danb, hunter_w3b, lukejohn
8.1878 USDC - $8.19
Gas Optimizations | Issues | Instances |
---|---|---|
[G-01] | Unnecessary function can be removed | 1 |
[G-02] | Use do-while loop instead of for-loop to save gas | 1 |
[G-03] | Remove initialization to 0 to save gas | 1 |
[G-04] | Remove parameter ERC20Votes _token from function retrieveProxyContractAddress to save gas | 1 |
[G-05] | Use if conditional statements instead of ternary operators to save gas | 1 |
[G-06] | Use gas-efficient assembly for common math operations like min and max | 3 |
[G-07] | Consider using alternatives to OpenZeppelin | 1 |
[G-08] | Remove return statement to save gas | 1 |
Although following the pattern of external functions calling internal functions is recommended, the delegateMulti() function just calls internal _delegateMulti() function with the passed parameters. This is an unnecessary step. Additionally, there are no complex interactions when calling from the external to internal function, thus making it safe to remove.
There is 1 instance of this:
Before VS After
Deployment cost: 4121917 - 4116745 = 5172 gas saved
Function execution cost: 90194 - 90140 = 54 gas saved per call
Remove this external delegateMulti() function below and make the internal function _delegateMulti() to external.
File: ERC20MultiDelegate.sol 60: function delegateMulti( 61: uint256[] calldata sources, 62: uint256[] calldata targets, 63: uint256[] calldata amounts 64: ) external { 65: _delegateMulti(sources, targets, amounts); 66: }
There is 1 instance of this:
Before VS After
Deployment cost: 4121917 - 4118209 = 3708 gas saved
Function execution cost: 90194 - 89798 = 396 gas saved per call
Instead of this:
File: ERC20MultiDelegate.sol 089: for ( 090: uint transferIndex = 0;//@audit Gas - check if initialization to 0 can be removed to save gas 091: transferIndex < Math.max(sourcesLength, targetsLength); 092: transferIndex++ //@audit Gas - ++i 093: ) { 094: address source = transferIndex < sourcesLength 095: ? address(uint160(sources[transferIndex])) 096: : address(0); 097: address target = transferIndex < targetsLength 098: ? address(uint160(targets[transferIndex])) 099: : address(0); 100: uint256 amount = amounts[transferIndex]; 101: 102: if (transferIndex < Math.min(sourcesLength, targetsLength)) { 103: // Process the delegation transfer between the current source and target delegate pair. 104: _processDelegation(source, target, amount); 105: } else if (transferIndex < sourcesLength) { 106: // Handle any remaining source amounts after the transfer process. 107: _reimburse(source, amount); 108: } else if (transferIndex < targetsLength) { 109: // Handle any remaining target amounts after the transfer process. 110: createProxyDelegatorAndTransfer(target, amount); 111: } 112: }
Use this:
File: ERC20MultiDelegate.sol 113: uint256 transferIndex; 114: do { 115: address source = transferIndex < sourcesLength 116: ? address(uint160(sources[transferIndex])) 117: : address(0); 118: address target = transferIndex < targetsLength 119: ? address(uint160(targets[transferIndex])) 120: : address(0); 121: uint256 amount = amounts[transferIndex]; 122: 123: if (transferIndex < Math.min(sourcesLength, targetsLength)) { 124: // Process the delegation transfer between the current source and target delegate pair. 125: _processDelegation(source, target, amount); 126: } else if (transferIndex < sourcesLength) { 127: // Handle any remaining source amounts after the transfer process. 128: _reimburse(source, amount); 129: } else if (transferIndex < targetsLength) { 130: // Handle any remaining target amounts after the transfer process. 131: createProxyDelegatorAndTransfer(target, amount); 132: } 133: 134: unchecked { 135: ++transferIndex; 136: } 137: } while (transferIndex < Math.max(sourcesLength, targetsLength));
Since unsigned integer variables are 0 by default, there is no need to initialize them to 0.
There is 1 instance of this issue:
Before VS After
Deployment cost: 4121917 - 4121905 = 12 gas saved
86: uint transferIndex = 0;
ERC20Votes _token
from function retrieveProxyContractAddress
to save gasThe function retrieveProxyContractAddress
is called by functions _reimburse
, transferBetweenDelegators
and deployProxyDelegatorIfNeeded
. These functions always pass the state variable token
as parameter to the retrieveProxyContractAddress
. This creates extra gas that can be avoided by just using the state variable token
in the retrieveProxyContractAddress
function directly.
There is 1 instance of this:
Before VS After
Deployment cost: 4121917 - 4098816 = 23101 gas saved
Function execution cost: 90194 - 90184 = 10 gas saved per call
Instead of this:
File: ERC20MultiDelegate.sol 232: function retrieveProxyContractAddress( 233: ERC20Votes _token, 234: address _delegate 235: ) private view returns (address) { 236: bytes memory bytecode = abi.encodePacked( 237: type(ERC20ProxyDelegator).creationCode, 238: abi.encode(_token, _delegate) 239: ); 240: bytes32 hash = keccak256( 241: abi.encodePacked( 242: bytes1(0xff), 243: address(this), 244: uint256(0), // salt 245: keccak256(bytecode) 246: ) 247: ); 248: return address(uint160(uint256(hash))); 249: }
Use this: (Note: Make sure to make changes in functions _reimburse
, transferBetweenDelegators
and deployProxyDelegatorIfNeeded
when passing parameters to retrieveProxyContractAddress
)
File: ERC20MultiDelegate.sol 232: function retrieveProxyContractAddress( 233: address _delegate //@audit Now only one parameter 234: ) private view returns (address) { 235: bytes memory bytecode = abi.encodePacked( 236: type(ERC20ProxyDelegator).creationCode, 237: abi.encode(token, _delegate) //@audit directly using state variable "token" 238: ); 239: bytes32 hash = keccak256( 240: abi.encodePacked( 241: bytes1(0xff), 242: address(this), 243: uint256(0), // salt 244: keccak256(bytecode) 245: ) 246: ); 247: return address(uint160(uint256(hash))); 248: }
There is 1 instance of this:
Before VS After
Deployment cost: 4121917 - 4118701 = 3216 gas saved
Function execution cost: 90194 - 90166 = 28 gas saved per call
Instead of this:
File: ERC20MultiDelegate.sol 94: address source = transferIndex < sourcesLength 95: ? address(uint160(sources[transferIndex])) 96: : address(0); 97: address target = transferIndex < targetsLength 98: ? address(uint160(targets[transferIndex])) 99: : address(0);
Use this:
File: ERC20MultiDelegate.sol 100: address source; 101: address target; 102: if (transferIndex < sourcesLength) source = address(uint160(sources[transferIndex])); 103: if (transferIndex < targetsLength) target = address(uint160(targets[transferIndex]));
There are 3 instances of this:
https://github.com/code-423n4/2023-10-ens/blob/ed25379c06e42c8218eb1e80e141412496950685/contracts/ERC20MultiDelegate.sol#L80 https://github.com/code-423n4/2023-10-ens/blob/ed25379c06e42c8218eb1e80e141412496950685/contracts/ERC20MultiDelegate.sol#L87 https://github.com/code-423n4/2023-10-ens/blob/ed25379c06e42c8218eb1e80e141412496950685/contracts/ERC20MultiDelegate.sol#L98C39-L98C39
These are the following min and max instances in the code.
File: contracts/ERC20MultiDelegate.sol 80: Math.max(sourcesLength, targetsLength) == amountsLength, 87: transferIndex < Math.max(sourcesLength, targetsLength); 98: if (transferIndex < Math.min(sourcesLength, targetsLength)) {
The current Math library being used by OpenZeppelin evaluates "max" using ternary operators as follows:
function max(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a : b; }
An optimized version for this in assembly would be as follows (Similar to Solady's implementation):
assembly { c := xor(a, mul(xor(a, b), gt(b, a))) }
The reason the above example is more gas efficient is because the ternary operator in the original code contains conditional jumps in the opcodes, which are more costly.
Consider using Solmate and Solady. Solmate is a library that provides a number of gas-efficient implementations of common smart contract patterns. Solady is another gas-efficient library that places a strong emphasis on using assembly. In the ERC20MultiDelegate.sol contract, ERC1155 is used frequently for batch minting and burning. Using a gas-optimized version can help reduce gas costs on the user end.
There is 1 instance of this:
File: ERC20MultiDelegate.sol 4: import {Address} from "@openzeppelin/contracts/utils/Address.sol"; 5: 6: import "@openzeppelin/contracts/access/Ownable.sol"; 7: import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; 8: import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; 9: import "@openzeppelin/contracts/utils/math/Math.sol";
There is 1 instance of this:
Before VS After
Deployment cost: 4121917 - 4120801 = 1116 gas saved
Function execution cost: 90194 - 90168 = 26 gas saved per call
Instead of this:
File: ERC20MultiDelegate.sol 211: function deployProxyDelegatorIfNeeded( 212: address delegate 213: ) internal returns (address) { 214: address proxyAddress = retrieveProxyContractAddress(token, delegate); 215: 216: // check if the proxy contract has already been deployed 217: uint bytecodeSize; 218: assembly { 219: bytecodeSize := extcodesize(proxyAddress) 220: } 221: 222: // if the proxy contract has not been deployed, deploy it 223: if (bytecodeSize == 0) { 224: new ERC20ProxyDelegator{salt: 0}(token, delegate); 225: emit ProxyDeployed(delegate, proxyAddress); 226: } 227: return proxyAddress; 228: }
Use this:
File: ERC20MultiDelegate.sol 211: function deployProxyDelegatorIfNeeded( 212: address delegate 213: ) internal returns (address proxyAddress) { 214: proxyAddress = retrieveProxyContractAddress(token, delegate); 215: 216: // check if the proxy contract has already been deployed 217: uint bytecodeSize; 218: assembly { 219: bytecodeSize := extcodesize(proxyAddress) 220: } 221: 222: // if the proxy contract has not been deployed, deploy it 223: if (bytecodeSize == 0) { 224: new ERC20ProxyDelegator{salt: 0}(token, delegate); 225: emit ProxyDeployed(delegate, proxyAddress); 226: } 227: }
#0 - c4-pre-sort
2023-10-13T14:28:46Z
141345 marked the issue as sufficient quality report
#1 - c4-judge
2023-10-24T17:00:45Z
hansfriese marked the issue as grade-b
🌟 Selected for report: polarzero
Also found by: 0x3b, 0xSmartContract, 0xnev, ABA, Bulletprime, J4X, Limbooo, MrPotatoMagic, SBSecurity, Sathish9098, asui, d3e4, hyh, ihtishamsudo, inzinko, marchev, pfapostol, radev_sw, rahul, ro1sharkm
85.6695 USDC - $85.67
This section contains my submitted issues which include Gas/QA reports and certain issues I was researching but turned out to be invalid. Here are the points I would like to point out:
Gas Report - My Gas report includes 8 gas optimizations that save 36839 gas in total. None of these gas findings affect the logic of the code, which is proved by all the tests passing. Each finding shows the deployment and function execution cost separately for the sponsor to select the ones that save the most gas without affecting code readability and maintainability.
QA Report - My QA report includs 4 non-critical issues, 1 low-severity issue and 1 recommendation. Out of these, the two most important issues are [N-03] and [L-01]. The [N-03] issue points out code that is unnecessary in the codebase and can be optimized to not only improve code readability but also save some gas. The [L-01] issue points out a missing implementation of token type ID substitution mechanism, which is used by user's to read the onchain pointer (baseUri + tokenId) to their metadata stored offchain on an IPFS solution. The other issues submitted as important as well but not as much as these two mentioned above.
Unsubmitted issues:
forge --version
to see if you have foundry installedyarn add --dev @nomicfoundation/hardhat-foundry
require("@nomicfoundation/hardhat-foundry");
to your hardhat confignpx hardhat init-foundry
testDelegateMulti.t.sol
and paste the below POCforge test --match-test testUserCanSelfDelegate -vvvvv
to run the test which proves self-delegation is possibleFile: testDelegateMulti.t.sol 01: // SPDX-License-Identifier: MIT 02: pragma solidity 0.8.19; 03: 04: import {Test, console} from "forge-std/Test.sol"; 05: import {ERC20MultiDelegate} from "../contracts/ERC20MultiDelegate.sol"; 06: import {ENSToken} from "../contracts/ENSToken.sol"; 07: import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; 08: 09: contract testDelegateMulti is Test { 10: 11: ENSToken ens; 12: ERC20MultiDelegate public emd; 13: address constant USER1 = address(0x01); 14: 15: uint256[] public _sources; 16: uint256[] public _targets; 17: uint256[] public _amounts; 18: 19: function setUp() external { 20: ens = new ENSToken(1e18, 0, 0); 21: emd = new ERC20MultiDelegate(ens, ""); 22: } 23: 24: function testUserCanSelfDelegate() public { 25: vm.warp(366 days); 26: //ens.mint(address(this), 10000); 27: ens.approve(address(emd), 10000); 28: 29: uint256 currentAddress = uint256(uint160(address(this))); 30: _targets.push(currentAddress); //UINT256 version of USER1 31: _targets.push(currentAddress); 32: _amounts.push(5000); 33: _amounts.push(5000); 34: 35: emd.delegateMulti(_sources, _targets, _amounts); 36: 37: console.log(emd.balanceOf(address(this), currentAddress)); 38: console.log(ens.balanceOf(address(this))); 39: } 40: 41: function onERC1155Received( 42: address operator, 43: address from, 44: uint256 id, 45: uint256 value, 46: bytes calldata data 47: ) external returns (bytes4) { 48: // Handle the received ERC1155 token, and return the expected function selector (bytes4(keccak256("onERC1155Received(address,address,uint256,uint256,bytes)"))). 49: 50: // Add your custom logic here. 51: 52: return this.onERC1155Received.selector; 53: } 54: 55: function onERC1155BatchReceived( 56: address operator, 57: address from, 58: uint256[] calldata ids, 59: uint256[] calldata values, 60: bytes calldata data 61: ) external returns (bytes4) { 62: // Handle the received batch of ERC1155 tokens, and return the expected function selector (bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))). 63: 64: // Add your custom logic here. 65: 66: return this.onERC1155BatchReceived.selector; 67: } 68: }
Day 1
Day 2
Day 3
locked
mapping that tracks user struct locks which includes the amount deposited in the lock and the delegate voting power is assigned to (defaulted to user - see here). The second difference is that ENS makes use of ProxyDelegators that store/lock tha ERC20Votes token while Canto uses the VotingEscrow itself to store/lock the CANTO tokens sent as msg.value (see here). The third difference is that ENS allows increasing delegate voting power through the createProxyDelegatorAndTransfer() function while Canto uses a increaseAmount() function which is more complex to understand and requires the user to increase their lock times. Overall, ENS ERC20MultiDelegate.sol is a much more simplistic and flexible contract that takes on a unique approach to delegations and gets rid of the additional complexity and some features Canto provides.The codebase is high quality due to the difficulty to break it and it's simplicity. The specific aspects of why this codebase is rock solid has been included in sections of this Analysis report such as What's unique? and How this codebase compares to others I'm familiar with.
Additionally, the contract has close to 100% test coverage, which adds an additional layer of security to ensure all functions work as intended. I would recommend the sponsor to integrate a foundry test suite with fuzz tests as well to ensure the functions work with all types of soure-target inputs.
There are no administration roles in this contract, which make it safe to use. The only centralization risk this contract could pose (external to the system) is gaining proposal majority by using the same tokens to vote using different addresses owned by the same entity. This should be mitigated by setting a wait time till the proposal ends before which delegation cannot be reimbursed or transferred to another delegator. This interval/wait-time is just an extra layer of prevention in case an external voting implementation has an error in it that allows this issue to occur. The interval can be set to 0 for external voting systems that do not require a wait-time.
The below three instances are the fundamental situations, which can be combined together depending on how inputs are provided.
This section includes answers to certain questions/attack surfaces the sponsor wanted addressed.
Check for proper permissions and roles
Ensure that the delegateMulti function handles array inputs correctly
Validate the logic for transferring between proxy delegators
15 hours
#0 - c4-pre-sort
2023-10-14T08:43:59Z
141345 marked the issue as sufficient quality report
#1 - c4-judge
2023-10-22T16:19:52Z
hansfriese marked the issue as grade-a