Platform: Code4rena
Start Date: 03/07/2023
Pot Size: $100,000 USDC
Total HM: 4
Participants: 36
Period: 10 days
Judge: gzeon
Total Solo HM: 3
Id: 257
League: ETH
Rank: 15/36
Findings: 2
Award: $274.10
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: c3phas
Also found by: 0xAnah, Aymen0909, JCN, K42, MohammedRizwan, Raihan, SAQ, SM3_SS, dharma09, flutter_developer, hunter_w3b, klau5, naman1778, petrichor
55.3038 USDC - $55.30
General Optimization =
external
can be more gas efficient than public
functions because they read directly from calldata
, whereas public
functions copy data to memory
, which costs more gas. For example, the castVoteBySig
function could be made external if it's not called from within the contract. This could save around 600 gas
per call.Possible Optimization 1 =
proposal
storage variable are made. Each storage
load operation consumes 800 gas
. We can reduce the gas consumption by loading the proposal into memory once and reusing it.Here is the optimized code snippet:
function state(uint256 proposalId) public view returns (ProposalState) { require(proposalCount >= proposalId, 'NounsDAO::state: invalid proposal id'); Proposal storage proposal = _proposals[proposalId]; Proposal memory _proposal = proposal; // load proposal into memory if (_proposal.vetoed) { return ProposalState.Vetoed; } else if (_proposal.canceled) { return ProposalState.Canceled; } else if (block.number <= _proposal.startBlock) { return ProposalState.Pending; } else if (block.number <= _proposal.endBlock) { return ProposalState.Active; } else if (_proposal.forVotes <= _proposal.againstVotes || _proposal.forVotes < quorumVotes(_proposal.id)) { return ProposalState.Defeated; } else if (_proposal.eta == 0) { return ProposalState.Succeeded; } else if (_proposal.executed) { return ProposalState.Executed; } else if (block.timestamp >= _proposal.eta + timelock.GRACE_PERIOD()) { return ProposalState.Expired; } else { return ProposalState.Queued; } }
6400 gas
per call to the state function. The exact amount of gas saved will depend on the frequency of calls to this function. If this function is called frequently, the gas savings could be significant.Possible Optimization 2 =
In the _setPendingAdmin function, the condition msg.sender == admin
is checked. This is redundant because the AdminOnly
modifier already checks this condition. Removing this could save around 1000 gas.
Estimated gas saved = 1000 gas.
Possible Optimization 1 =
-- In the constructor, the admin
state variable is set twice. The first assignment to msg.sender
is unnecessary and can be removed to save gas. Here is the optimized code
constructor( address timelock_, address nouns_, address vetoer_, address admin_, address implementation_, uint256 votingPeriod_, uint256 votingDelay_, uint256 proposalThresholdBPS_, uint256 quorumVotesBPS_ ) { delegateTo( implementation_, abi.encodeWithSignature( 'initialize(address,address,address,uint256,uint256,uint256,uint256)', timelock_, nouns_, vetoer_, votingPeriod_, votingDelay_, proposalThresholdBPS_, quorumVotesBPS_ ) ); _setImplementation(implementation_); admin = admin_; }
20000 gas
for the constructor
call.Possible Optimization 2 =
In the _setImplementation function, the condition implementation_ != address(0)
is checked. This check is redundant because the delegateTo
function called within the constructor will fail if the implementation address is zero. So removing this could save around 800 gas per call.
Estimated gas saved = 800 gas
per call to the _setImplementation function.
Possible Optimization 1 =
Constant
Functions MIN_PROPOSAL_THRESHOLD_BPS, MAX_PROPOSAL_THRESHOLD_BPS, MIN_VOTING_PERIOD, MAX_VOTING_PERIOD, MIN_VOTING_DELAY, MAX_VOTING_DELAY, proposalMaxOperations all return constants these can be replaced with public constant variables. This will save the gas cost of function calls.For example:
function MIN_PROPOSAL_THRESHOLD_BPS() public pure returns (uint256) { return NounsDAOV3Admin.MIN_PROPOSAL_THRESHOLD_BPS; }
Change to:
uint256 public constant MIN_PROPOSAL_THRESHOLD_BPS = NounsDAOV3Admin.MIN_PROPOSAL_THRESHOLD_BPS;
You can the same for the other functions mentioned above.
Possible Optimization 2 =
function minQuorumVotes() public view returns (uint256) { return ds.minQuorumVotes(ds.adjustedTotalSupply()); }
function maxQuorumVotes() public view returns (uint256) { return ds.maxQuorumVotes(ds.adjustedTotalSupply()); }
After:
function minQuorumVotes() public view returns (uint256) { uint256 adjustedTotalSupply = ds.adjustedTotalSupply(); return ds.minQuorumVotes(adjustedTotalSupply); }
and
function maxQuorumVotes() public view returns (uint256) { uint256 adjustedTotalSupply = ds.adjustedTotalSupply(); return ds.maxQuorumVotes(adjustedTotalSupply); }
Possible Optimization =
VoteCast
event emission from the castRefundableVoteInternal function and modifying the castVoteInternal function to return both the number of votes and the voter's address. This way, we can emit the VoteCast
event only once and save gas.Here is how you modify the code:
function castRefundableVoteInternal( NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId, uint8 support, string memory reason ) internal { uint256 startGas = gasleft(); (uint96 votes, address voter) = castVoteInternal(ds, msg.sender, proposalId, support); if (votes > 0) { _refundGas(startGas); } }
and
function castVoteInternal( NounsDAOStorageV3.StorageV3 storage ds, address voter, uint256 proposalId, uint8 support ) internal returns (uint96, address) { NounsDAOStorageV3.ProposalState proposalState = ds.stateInternal(proposalId); uint96 votes; if (proposalState == NounsDAOStorageV3.ProposalState.Active) { votes = castVoteDuringVotingPeriodInternal(ds, proposalId, voter, support); } else if (proposalState == NounsDAOStorageV3.ProposalState.ObjectionPeriod) { if (support != 0) revert CanOnlyVoteAgainstDuringObjectionPeriod(); votes = castObjectionInternal(ds, proposalId, voter); } else { revert('NounsDAO::castVoteInternal: voting is closed'); } emit VoteCast(voter, proposalId, support, votes, ''); return (votes, voter); }
castRefundableVote
call due to the elimination of one event emission.Possible Optimization 2 =
min
function calls with ternary operators to save some gas.Before:
function _refundGas(uint256 startGas) internal { unchecked { uint256 balance = address(this).balance; if (balance == 0) { return; } uint256 basefee = min(block.basefee, MAX_REFUND_BASE_FEE); uint256 gasPrice = min(tx.gasprice, basefee + MAX_REFUND_PRIORITY_FEE); uint256 gasUsed = min(startGas - gasleft() + REFUND_BASE_GAS, MAX_REFUND_GAS_USED); uint256 refundAmount = min(gasPrice * gasUsed, balance); (bool refundSent, ) = msg.sender.call{ value: refundAmount }(''); emit RefundableVote(msg.sender, refundAmount, refundSent); } }
After Optimization:
function _refundGas(uint256 startGas) internal { unchecked { uint256 balance = address(this).balance; if (balance == 0) { return; } uint256 basefee = block.basefee < MAX_REFUND_BASE_FEE ? block.basefee : MAX_REFUND_BASE_FEE; uint256 gasPrice = tx.gasprice < (basefee + MAX_REFUND_PRIORITY_FEE) ? tx.gasprice : (basefee + MAX_REFUND_PRIORITY_FEE); uint256 gasUsed = (startGas - gasleft() + REFUND_BASE_GAS) < MAX_REFUND_GAS_USED ? (startGas - gasleft() + REFUND_BASE_GAS) : MAX_REFUND_GAS_USED; uint256 refundAmount = gasPrice * gasUsed < balance ? gasPrice * gasUsed : balance; (bool refundSent, ) = msg.sender.call{ value: refundAmount }(''); emit RefundableVote(msg.sender, refundAmount, refundSent); } }
Possible Optimization =
erc20tokens
array. This results in a time complexity of O(n^2)
, which can be expensive in terms of gas cost when the array size is large. We can optimize this by using a mapping
to check for duplicates, which reduces the time complexity to O(n)
In the original function, for each pair of addresses, we perform an SLOAD
operation to load the addresses from storage. In the optimized function, we perform an SLOAD
operation once for each address, and an SSTORE
operation to store the address in the seen mapping.Before:
function checkForDuplicates(address[] calldata erc20tokens) internal pure { if (erc20tokens.length == 0) return; for (uint256 i = 0; i < erc20tokens.length - 1; i++) { for (uint256 j = i + 1; j < erc20tokens.length; j++) { if (erc20tokens[i] == erc20tokens[j]) revert DuplicateTokenAddress(); } }
After Optimization:
function checkForDuplicates(address[] calldata erc20tokens) internal pure { if (erc20tokens.length == 0) return; mapping(address => bool) memory seen; for (uint256 i = 0; i < erc20tokens.length; i++) { if (seen[erc20tokens[i]]) revert DuplicateTokenAddress(); seen[erc20tokens[i]] = true; } }
SLOAD
operations, while the optimized function performs n SLOAD
operations and n SSTORE
operations. Since SSTORE
is more expensive than SLOAD
, the gas saved is approximately (n^2 - 2n) * gas cost of SLOAD
.CREATE2
: I put this in depth in my advanced-anaylsis report as it is an implementation change.Possible Optimization =
keccak256
function to generate a transaction hash twice, once at the beginning of the function and once in the emit ExecuteTransaction statement. This is redundant and increases gas costs. We can optimize this by storing the transaction hash in a variable and reusing it.Before Optimization:
function executeTransaction( address target, uint256 value, string memory signature, bytes memory data, uint256 eta ) public returns (bytes memory) { require(msg.sender == admin, 'NounsDAOExecutor::executeTransaction: Call must come from admin.'); bytes32 txHash = keccak256(abi.encode(target, value, signature, data, eta)); require(queuedTransactions[txHash], "NounsDAOExecutor::executeTransaction: Transaction hasn't been queued."); require( getBlockTimestamp() >= eta, "NounsDAOExecutor::executeTransaction: Transaction hasn't surpassed time lock." ); require( getBlockTimestamp() <= eta + GRACE_PERIOD, 'NounsDAOExecutor::executeTransaction: Transaction is stale.' ); queuedTransactions[txHash] = false; bytes memory callData; if (bytes(signature).length == 0) { callData = data; } else { callData = abi.encodePacked(bytes4(keccak256(bytes(signature))), data); } // solium-disable-next-line security/no-call-value (bool success, bytes memory returnData) = target.call{ value: value }(callData); require(success, 'NounsDAOExecutor::executeTransaction: Transaction execution reverted.'); emit ExecuteTransaction(keccak256(abi.encode(target, value, signature, data, eta)), target, value, signature, data, eta); return returnData; }
After Optimization:
function executeTransaction( address target, uint256 value, string memory signature, bytes memory data, uint256 eta ) public returns (bytes memory) { require(msg.sender == admin, 'NounsDAOExecutor::executeTransaction: Call must come from admin.'); bytes32 txHash = keccak256(abi.encode(target, value, signature, data, eta)); require(queuedTransactions[txHash], "NounsDAOExecutor::executeTransaction: Transaction hasn't been queued."); require( getBlockTimestamp() >= eta, "NounsDAOExecutor::executeTransaction: Transaction hasn't surpassed time lock." ); require( getBlockTimestamp() <= eta + GRACE_PERIOD, 'NounsDAOExecutor::executeTransaction: Transaction is stale.' ); queuedTransactions[txHash] = false; bytes memory callData; if (bytes(signature).length == 0) { callData = data; } else { callData = abi.encodePacked(bytes4(keccak256(bytes(signature))), data); } // solium-disable-next-line security/no-call-value (bool success, bytes memory returnData) = target.call{ value: value }(callData); require(success, 'NounsDAOExecutor::executeTransaction: Transaction execution reverted.'); emit ExecuteTransaction(txHash, target, value, signature, data, eta); return returnData; }
Possible Optimization = Reducing redundant storage reads:
block.number
is read twice. Reading from storage is expensive in terms of gas, so it's better to read once and store the value in memory.Here is the optimized function:
function createNewProposal( NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId, uint256 proposalThreshold_, uint256 adjustedTotalSupply, ProposalTxs memory txs ) internal returns (NounsDAOStorageV3.Proposal storage newProposal) { uint64 currentBlock = SafeCast.toUint64(block.number); uint64 updatePeriodEndBlock = currentBlock + ds.proposalUpdatablePeriodInBlocks; uint256 startBlock = updatePeriodEndBlock + ds.votingDelay; uint256 endBlock = startBlock + ds.votingPeriod; newProposal = ds._proposals[proposalId]; newProposal.id = proposalId; newProposal.proposer = msg.sender; newProposal.proposalThreshold = proposalThreshold_; newProposal.targets = txs.targets; newProposal.values = txs.values; newProposal.signatures = txs.signatures; newProposal.calldatas = txs.calldatas; newProposal.startBlock = startBlock; newProposal.endBlock = endBlock; newProposal.totalSupply = adjustedTotalSupply; newProposal.creationBlock = currentBlock; newProposal.updatePeriodEndBlock = updatePeriodEndBlock; }
Possible Optimization = Reducing redundant storage reads:
proposal.totalSupply
is read twice from storage. By storing the value in a memory variable proposalTotalSupply
after the first read, we can eliminate the second storage read, saving gas.Here is the optimized function:
function quorumVotes(NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId) internal view returns (uint256) { NounsDAOStorageV3.Proposal storage proposal = ds._proposals[proposalId]; uint256 proposalTotalSupply = proposal.totalSupply; if (proposalTotalSupply == 0) { return proposal.quorumVotes; } return dynamicQuorumVotes( proposal.againstVotes, proposalTotalSupply, getDynamicQuorumParamsAt(ds, proposal.creationBlock) ); }
Possible Optimization = Remove redundant currentOwnerOf
checks:
currentOwnerOf
a token is the expected owner before transferring it. However, the transferFrom
function in the ERC721
standard will automatically revert if the msg.sender
is not the owner or an approved operator of the token, making these checks redundant.You can remove these checks to save some gas:
function returnTokensToOwner(address owner, uint256[] calldata tokenIds) external onlyDAO { for (uint256 i = 0; i < tokenIds.length; i++) { nounsToken.transferFrom(address(this), owner, tokenIds[i]); escrowedTokensByForkId[forkId][tokenIds[i]] = address(0); } numTokensInEscrow -= tokenIds.length; }
and
function withdrawTokens(uint256[] calldata tokenIds, address to) external onlyDAO { for (uint256 i = 0; i < tokenIds.length; i++) { nounsToken.transferFrom(address(this), to, tokenIds[i]); } }
currentOwnerOf
check will save the gas cost of a storage read, which is 800 gas. So if you have n tokens, you will save 800 * n gas.Possible Optimization = Use calldata
instead of memory
for function parameters:
signature
and data
parameters are declared as memory
. However, these parameters are not modified within the function, so they can be declared as calldata
to save some gas.For queueTransaction:
string calldata signature, bytes calldata data,
and the same
for executeTransaction
memory
to calldata
saves 200 gas per parameter. So for each call to these functions, you will save 200 * 2 = 400 gas.Possible Optimization =
Auction
struct in memory, updating the fields in memory, and then storing the entire struct in storage at once.Here is the optimized function:
function _settleAuction() internal { INounsAuctionHouse.Auction memory _auction = auction; require(_auction.startTime != 0, "Auction hasn't begun"); require(!_auction.settled, 'Auction has already been settled'); require(block.timestamp >= _auction.endTime, "Auction hasn't completed"); _auction.settled = true; if (_auction.bidder == address(0)) { nouns.burn(_auction.nounId); } else { nouns.transferFrom(address(this), _auction.bidder, _auction.nounId); } if (_auction.amount > 0) { _safeTransferETHWithFallback(owner(), _auction.amount); } auction = _auction; emit AuctionSettled(_auction.nounId, _auction.bidder, _auction.amount); }
Possible Optimization = Reduce the number of SSTORE operations:
Before Optimization:
Proposal storage newProposal = _proposals[proposalCount]; newProposal.id = proposalCount; newProposal.proposer = msg.sender; newProposal.proposalThreshold = temp.proposalThreshold; newProposal.quorumVotes = bps2Uint(quorumVotesBPS, temp.totalSupply); newProposal.eta = 0; newProposal.targets = targets; newProposal.values = values; newProposal.signatures = signatures; newProposal.calldatas = calldatas; newProposal.startBlock = temp.startBlock; newProposal.endBlock = temp.endBlock; newProposal.forVotes = 0; newProposal.againstVotes = 0; newProposal.abstainVotes = 0; newProposal.canceled = false; newProposal.executed = false; newProposal.creationBlock = block.number;
After Optimization:
// Create a memory instance of the Proposal struct and set its fields Proposal memory newProposal = Proposal({ id: proposalCount, proposer: msg.sender, proposalThreshold: temp.proposalThreshold, quorumVotes: bps2Uint(quorumVotesBPS, temp.totalSupply), eta: 0, targets: targets, values: values, signatures: signatures, calldatas: calldatas, startBlock: temp.startBlock, endBlock: temp.endBlock, forVotes: 0, againstVotes: 0, abstainVotes: 0, canceled: false, executed: false, creationBlock: block.number }); // Store the entire struct in one operation _proposals[proposalCount] = newProposal;
Possible Optimization =
seed
in a local variable before setting it in the mapping and emitting the event. This would reduce the number of SSTORE operations from two to one.Before Optimization:
function _mintWithOriginalSeed(address to, uint256 nounId) internal { (uint48 background, uint48 body, uint48 accessory, uint48 head, uint48 glasses) = NounsTokenFork( address(escrow.nounsToken()) ).seeds(nounId); INounsSeeder.Seed memory seed = INounsSeeder.Seed(background, body, accessory, head, glasses); seeds[nounId] = seed; _mint(to, nounId); emit NounCreated(nounId, seed); }
After Optimization:
function _mintWithOriginalSeed(address to, uint256 nounId) internal { (uint48 background, uint48 body, uint48 accessory, uint48 head, uint48 glasses) = NounsTokenFork( address(escrow.nounsToken()) ).seeds(nounId); INounsSeeder.Seed memory seed = INounsSeeder.Seed(background, body, accessory, head, glasses); // Store the seed in a local variable INounsSeeder.Seed memory localSeed = seed; // Use the local variable instead of the mapping seeds[nounId] = localSeed; _mint(to, nounId); emit NounCreated(nounId, seed); }
#0 - c4-judge
2023-07-25T10:15:25Z
gzeon-c4 marked the issue as grade-b
🌟 Selected for report: 0xnev
Also found by: 0xSmartContract, K42, Matin, ihtishamsudo, shark
I have suggested optimization, which I left out of my gas report due to it being a structural change, therefore I recommended it here instead, see below:
Possible Optimization in ForkDAODeployer.sol
In the deployForkDAO function, the current implementation deploys four new contracts (token, auction, governor, treasury) using the ERC1967Proxy contract. Each deployment of a contract involves a CREATE opcode which is quite expensive in terms of gas. We can optimize this by using a CREATE2 opcode which allows us to deploy a contract at a deterministic address. This would reduce the gas cost as CREATE2 is cheaper than CREATE and also provides additional benefits like being able to compute the address of the contract before it's deployed.
Before:
token = address(new ERC1967Proxy(tokenImpl, '')); address auction = address(new ERC1967Proxy(auctionImpl, '')); address governor = address(new ERC1967Proxy(governorImpl, '')); treasury = address(new ERC1967Proxy(treasuryImpl, ''));
After Optimization:
bytes32 salt = keccak256(abi.encodePacked(tokenImpl, auctionImpl, governorImpl, treasuryImpl)); token = deployContract(tokenImpl, salt); auction = deployContract(auctionImpl, salt); governor = deployContract(governorImpl, salt); treasury = deployContract(treasuryImpl, salt);
function deployContract(address implementation, bytes32 salt) internal returns (address) { bytes20 targetBytes = bytes20(implementation); address result; // solhint-disable-next-line no-inline-assembly assembly { let clone := mload(0x40) mstore(clone, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000) mstore(add(clone, 0x14), targetBytes) mstore(add(clone, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000) result := create2(0, clone, 0x37, salt) } return result; }
Estimated gas saved = The exact gas savings would depend on the size of the contracts being deployed. However, as a rough estimate, the CREATE opcode costs 32000 gas, while the CREATE2 opcode costs 20000 gas. Therefore, for each contract deployment, we save approximately 12000 gas. Since we are deploying four contracts, the total gas saved is approximately 48000 gas.
The codebase of NounsDAO is well-structured and follows best practices for smart contract development. It makes use of well-tested libraries and frameworks such as OpenZeppelin for implementing standard functionalities. The code is modular, which makes it easier to understand and maintain. It also has extensive comments, which aids in understanding the functionality of different parts of the code.
However, the complexity of the system also poses potential risks. With so many interacting components, there is a higher chance of unexpected behaviour or vulnerabilities. Therefore, thorough future testing and auditing are crucial to ensure the security of the system.
Consider a even more modular architecture to improve maintainability and ease of adding new features.
Ensure a fair and wider distribution of governance tokens to mitigate centralization risks.
Regularly review and update the auction mechanism to ensure fairness and transparency.
Explore solutions for potential scalability issues and high transaction fees on the Ethereum network.
40 hours
#0 - c4-judge
2023-07-25T10:24:20Z
gzeon-c4 marked the issue as grade-b