Platform: Code4rena
Start Date: 03/08/2023
Pot Size: $90,500 USDC
Total HM: 6
Participants: 36
Period: 7 days
Judge: 0xean
Total Solo HM: 1
Id: 273
League: ETH
Rank: 7/36
Findings: 3
Award: $2,457.24
π Selected for report: 1
π Solo Findings: 0
1793.8212 USDC - $1,793.82
https://github.com/ArbitrumFoundation/governance/blob/c18de53820c505fc459f766c1b224810eaeaabc5/src/security-council-mgmt/governors/SecurityCouncilMemberElectionGovernor.sol#L103-L110 https://github.com/ArbitrumFoundation/governance/blob/c18de53820c505fc459f766c1b224810eaeaabc5/src/security-council-mgmt/governors/modules/SecurityCouncilMemberElectionGovernorCountingUpgradeable.sol#L77-L84
In SecurityCouncilMemberElectionGovernor
contract : relay
function enables the contract owner from making calls to any contract address.
And in SecurityCouncilMemberElectionGovernorCountingUpgradeable
contract: setFullWeightDuration
can be accessed only by invoking it from SecurityCouncilMemberElectionGovernor
which is possible only via relay
function.
So the owner can set a new value for fullWeightDuration
that is used to determine the deadline after which the voting weight will linearly decrease.
But when setting it; there's no check if there's a current active proposal.
This makes the voting unfair and the results unreliable as the owner can control the voting power during the election; as increasing the voting power of late voters if fullWeightDuration
is set to a higher value during active election.
Code:
SecurityCouncilMemberElectionGovernor contract/relay function
File:governance/src/security-council-mgmt/governors/SecurityCouncilMemberElectionGovernor.sol Line 103-110: function relay(address target, uint256 value, bytes calldata data) external virtual override onlyOwner { AddressUpgradeable.functionCallWithValue(target, data, value); }
SecurityCouncilMemberElectionGovernorCountingUpgradeable contract/setFullWeightDuration function
File: governance/src/security-council-mgmt/governors/modules/SecurityCouncilMemberElectionGovernorCountingUpgradeable.sol Line 77-84: function setFullWeightDuration(uint256 newFullWeightDuration) public onlyGovernance { if (newFullWeightDuration > votingPeriod()) { revert FullWeightDurationGreaterThanVotingPeriod(newFullWeightDuration, votingPeriod()); } fullWeightDuration = newFullWeightDuration; emit FullWeightDurationSet(newFullWeightDuration); }
Foundry PoC:
testSetVotingPeriodDuringActiveProposal()
test is added to SecurityCouncilMemberElectionGovernorTest.t.sol
file; where the relay function is invoked by the contract owner to change the fullWeightDuration
during an active proposal:
function testSetVotingPeriodDuringActiveProposal() public { //1. initiate a proposal _propose(0); //2. change fullWeightDuration while the proposal is still active assertEq(governor.votingPeriod(), initParams.votingPeriod); vm.prank(initParams.owner); governor.relay( address(governor), 0, abi.encodeWithSelector(governor.setVotingPeriod.selector, 121_212) ); assertEq(governor.votingPeriod(), 121_212); }
Test result:
$ forge test --match-test testSetVotingPeriodDuringActiveProposal [PASS] testSetVotingPeriodDuringActiveProposal() (gas: 118129) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.51ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual Testing & Foundry.
Enable setting a new value for fullWeightDuration
only if there's no active election.
Governance
#0 - 0xSorryNotSorry
2023-08-12T10:02:22Z
#1 - c4-pre-sort
2023-08-12T10:02:25Z
0xSorryNotSorry marked the issue as low quality report
#2 - c4-judge
2023-08-18T13:40:52Z
0xean marked the issue as duplicate of #52
#3 - c4-judge
2023-08-18T23:54:44Z
0xean marked the issue as satisfactory
#4 - c4-judge
2023-08-18T23:55:07Z
0xean marked the issue as selected for report
π Selected for report: MiloTruck
Also found by: 0xbepresent, 0xnev, 0xprinc, HE1M, Mirror, Sathish9098, Udsen, arialblack14, berlin-101, eierina, hals, ktg, nobody2018
36.1616 USDC - $36.16
ID | Title | Severity |
---|---|---|
L-01 | SecurityCouncilNomineeElectionGovernor : No Check On nomineeVettingDuration | Low |
L-02 | SecurityCouncilManager : No Check for The cohortSize When Initialized | Low |
L-03 | L2SecurityCouncilMgmtFactory::deploy : No Check on The Address of The Voting Token | Low |
L-04 | L2SecurityCouncilMgmtFactory::deploy : No Check If The Cohort Members Are Assigned to The Correct Cohort | Low |
L-05 | SecurityCouncilManager : Cohorts Vacancies Can't Be Filled Via Election | Low |
L-06 | SecurityCouncilMemberElectionGovernor : Setting fullWeightDuration Equal to votingPeriod() Will Break The Voting | Low |
NC-01 | SecurityCouncilManager::_swapMembers : No Check If The Two Swapped Addresses Are The Same | Non Critical |
SecurityCouncilNomineeElectionGovernor
: No Check On nomineeVettingDuration
<a id="l-01" ></a>SecurityCouncilNomineeElectionGovernor
contract : the duration for nominees vetting/compliance checking is set upon contract initialization.The Foundation will be given 14 days to vet the prospective nominees.
excludeNominee
); this will make the time window for these operations either narrow (if it's set to a lower vetting period than intended by design) or wide (if it's set to a higher vetting period than intended by design).initialize function/Line 109-111
File: governance/src/security-council-mgmt/governors/SecurityCouncilNomineeElectionGovernor.sol Line 109-111: __SecurityCouncilNomineeElectionGovernorTiming_init( params.firstNominationStartDate, params.nomineeVettingDuration );
SecurityCouncilNomineeElectionGovernorTiming contract/Line 65
File: governance/src/security-council-mgmt/governors/modules/SecurityCouncilNomineeElectionGovernorTiming.sol Line 65: nomineeVettingDuration = _nomineeVettingDuration;
Manual Testing.
In SecurityCouncilNomineeElectionGovernor
: check that params.nomineeVettingDuration
complies with the designed duration (14 days) before initializing it.
SecurityCouncilManager
: No Check for The cohortSize
When Initialized <a id="l-02" ></a>In SecurityCouncilManager
contract : each time this contract is re-deployed; it's initialized with the current first and second security council cohort members.
The checks that are made on the cohorts sizes :
L2SecurityCouncilMgmtFactory::deploy
upon contract deployment: if the sum of their sizes equals to the number of security council current cohort members:if (owners.length != (dp.firstCohort.length + dp.secondCohort.length)) { revert InvalidCohortsSize(owners.length, dp.firstCohort.length, dp.secondCohort.length); }
SecurityCouncilManager::initialize
upon contract initialization: if their sizes are equal:if (_firstCohort.length != _secondCohort.length) { revert CohortLengthMismatch(_firstCohort, _secondCohort); }
Then the cohortSize
will be set equal to _firstCohort.length
; and there's noway to reset this value again; unless a new governor contract is deployed; and this will require all governor contracts to be re-deployed again as well.
And as per constitution; the cohort size is 6 members.
Since this check is not done upon initialization; then cohortSize
could be assigned any value less than 6.
The scenario of setting the cohortSize
to a lower value than designed is very likely to happen; as the previous cohorts members might be less than 6 members due to the possibility of any cohort member to be removed and their vacancy is not filled (the two cohorts must be equl to initialize the contract).
This implementation deviates from the design; as the next security council cohorts sizes will be controlled by the wrong value of cohortSize
, and it will never be possible to elect/add 6 cohort members.
And this will make the next elections to fill 5 members only, as the number of selected top nominees depend on the cohortSize
.
Also as mentioned in the constitution: the security council cohort members are responsible of voting on emergency actions (with 9 out of 12 members approval) and non emergency actions (with 7 out of 12 members approval); so for example: if the security council cohorts were 3 members each upon initialization; then any proposed actions will never be executed as the number falls below voting threshold.
Code:
L2SecurityCouncilMgmtFactory contract/deploy function/Line 107-109
File: governance/src/security-council-mgmt/factories/L2SecurityCouncilMgmtFactory.sol Line 107-109: if (owners.length != (dp.firstCohort.length + dp.secondCohort.length)) { revert InvalidCohortsSize(owners.length, dp.firstCohort.length, dp.secondCohort.length); }
SecurityCouncilManager contract/initialize function/Line 97-102
File: governance/src/security-council-mgmt/SecurityCouncilManager.sol Line 97-102: if (_firstCohort.length != _secondCohort.length) { revert CohortLengthMismatch(_firstCohort, _secondCohort); } firstCohort = _firstCohort; secondCohort = _secondCohort; cohortSize = _firstCohort.length;
Foundry PoC:
The following modifications are done to the setup
function & testInitialization
test in SecurityCouncilManagerTest
, to test if the contract can be initialized with firstCohort & secondCohort with less than 6 members, test set-up as follows:
modify the lengths of both cohorts to be 5 instead of 6:
- address[] firstCohort = new address[](6); + address[] firstCohort = new address[](5); - address[] secondCohort = new address[](6); + address[] secondCohort = new address[](5);
in setUp
: five members only are added for each cohort instead of 6
function setUp() public { //...... some code - for (uint256 i = 0; i < 6; i++) { + for (uint256 i = 0; i < 5; i++) { secondCohort[i] = _secondCohort[i]; firstCohort[i] = _firstCohort[i]; bothCohorts.push(_firstCohort[i]); bothCohorts.push(_secondCohort[i]); newCohort[i] = _newCohort[i]; newCohortWithADup[i] = _newCohortWithADup[i]; }
in testInitialization
: assert that the cohortSize can be initialized with less than 6 members
function testInitialization() public { //...... some code + assertEq(scm.getFirstCohort().length, 5); + assertEq(scm.getFirstCohort().length, scm.cohortSize());
Test result:
$ forge test --match-test testInitialization [PASS] testInitialization() (gas: 200199) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.65ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual Testing.
When initializing the SecurityCouncilManager
contract: check that cohorts sizes comply with the designed value (6 members for each cohort),and if it's deemed acceptable to have less than 6 members/cohort (assuming that some cohort members were removed and their vacancies were not filled); then set cohortSize to a constant value of 6:
File: governance/src/security-council-mgmt/SecurityCouncilManager.sol function initialize( address[] memory _firstCohort, address[] memory _secondCohort, SecurityCouncilData[] memory _securityCouncils, SecurityCouncilManagerRoles memory _roles, address payable _l2CoreGovTimelock, UpgradeExecRouteBuilder _router ) external initializer { //... some code -102: cohortSize = _firstCohort.length; +102: cohortSize = 6; //... some code
L2SecurityCouncilMgmtFactory::deploy
: No Check on The Address of The Voting Token <a id="l-03"></a>In L2SecurityCouncilMgmtFactory
contract: is a factory contract to deploy and initialize election government contracts and security manager contract on the governance chain via deploy
function.
When initializing these contract: the address of the voting token is supposed to be the address of $ARB token on Arbitrum chain; but there's no check made on the address of the assigned token if it's the address of $ARB token.
File: governance/src/security-council-mgmt/factories/L2SecurityCouncilMgmtFactory.sol Line 200: _token: IVotesUpgradeable(dp.arbToken), Line 225: token: IVotesUpgradeable(dp.arbToken), Line 235: _token: IVotesUpgradeable(dp.arbToken),
Manual Testing.
Check that the address of the voting token is the address of the $ARB token before contracts initialization.
L2SecurityCouncilMgmtFactory::deploy
: No Check If The Cohort Members Are Assigned to The Correct Cohort <a id="l-04" ></a>In L2SecurityCouncilMgmtFactory
contract: when the security manager contract is deployed and initialized via deploy
function; the only check made on the assigned cohort members is whether they are members of the security council or not; but there's no check made if each member belongs correctly to the assigned cohort (might be in cohort one and assigned to cohort two).
The impact is low as this wrong assignment of cohort members can be fixed in SecurityCouncilManager
contract.
File: governance/src/security-council-mgmt/factories/L2SecurityCouncilMgmtFactory.sol Line 111-121: for (uint256 i = 0; i < dp.firstCohort.length; i++) { if (!govChainEmergencySCSafe.isOwner(dp.firstCohort[i])) { revert AddressNotInCouncil(owners, dp.firstCohort[i]); } } for (uint256 i = 0; i < dp.secondCohort.length; i++) { if (!govChainEmergencySCSafe.isOwner(dp.secondCohort[i])) { revert AddressNotInCouncil(owners, dp.secondCohort[i]); } }
Manual Testing.
Add a mechanism to check if each cohort member belongs to the correct cohort before initializing SecurityCouncilManager
contract
SecurityCouncilManager
: Cohorts Vacancies Can't Be Filled Via Election <a id="l-05" ></a>When removing a security council cohort member: as per constitution: the vacancy can be filled either directly by the govChainEmergencySecurityCouncil or by the next election.
If it's decided to fill the vacancy through an election (which is going to be started as proposal in SecurityCouncilNomineeElectionGovernor
contract); it will not be possible to start an election before 6-months passed from the last election, which makes it impossible to fill that vacancy via election.
Another scenario: if two members from the second cohorts were removed, and the next election is going to be for the first cohort; so the vacancies of the removed second cohort members will never be filled by election, even after 6-months passed from the last election.
Code:
L2SecurityCouncilMgmtFactory contract/Line 152
```solidity File: governance/src/security-council-mgmt/factories/L2SecurityCouncilMgmtFactory.sol Line 152: memberAdder: dp.govChainEmergencySecurityCouncil, ```
SecurityCouncilManager contract/Line 176
```solidity File: governance/src/security-council-mgmt/SecurityCouncilManager.sol Line 176: function addMember(address _newMember, Cohort _cohort) external onlyRole(MEMBER_ADDER_ROLE) { ```
SecurityCouncilNomineeElectionGovernor contract/createElection function
```solidity File: Breadcrumbsgovernance/src/security-council-mgmt/governors/SecurityCouncilNomineeElectionGovernor.sol Line 166-167: uint256 thisElectionStartTs = electionToTimestamp(electionCount); if (block.timestamp < thisElectionStartTs) { revert CreateTooEarly(block.timestamp, thisElectionStartTs);// @audit : this will revert when a new election is being started before 6-months from the last election } ```
Foundry PoC:
testRemovedCohortMembersCantBeAddedByElection()
test is added to SecurityCouncilNomineeElectionGovernorTest.t.sol
file:
function testRemovedCohortMembersCantBeAddedByElection() public { //1. first an election proposal to replace SECOND cohort members is created and executed: (copied from testExecutedProposalIdState test and updated to demonstrate the vulnerability) uint256 proposalId = _propose(); uint256 electionIndex = governor.electionCount() - 1; //2. nomineeVetter adds 6 nominees: vm.roll(governor.proposalVettingDeadline(proposalId) + 1); vm.startPrank(initParams.nomineeVetter); for (uint8 i = 0; i < cohortSize; i++) { _mockCohortIncludes(Cohort.SECOND, _contender(i), false); governor.includeNominee(proposalId, _contender(i)); } vm.stopPrank(); //3. execute: vm.mockCall( address(initParams.securityCouncilMemberElectionGovernor), "", abi.encode(proposalId) ); vm.expectCall( address(initParams.securityCouncilMemberElectionGovernor), abi.encodeWithSelector( initParams .securityCouncilMemberElectionGovernor .proposeFromNomineeElectionGovernor .selector, electionIndex ) ); _execute(electionIndex, ""); assertEq(uint256(governor.currentCohort()), uint256(Cohort.FIRST)); assertEq(uint256(governor.otherCohort()), uint256(Cohort.SECOND)); //4. now assume after 3-months the MEMBER_REMOVER_ROLE in SecurityCouncilManager contract removes two members from the second cohort; and these members are going to be added via an election, but nominee election proposal wont be createdL vm.warp(7889400); //represents 3-months in seconds uint256 expectedStartTimestamp = _datePlusMonthsToTimestamp( initParams.firstNominationStartDate, 6 ); vm.expectRevert( abi.encodeWithSelector( SecurityCouncilNomineeElectionGovernor.CreateTooEarly.selector, block.timestamp, expectedStartTimestamp ) ); governor.createElection(); }
Test result:
$ forge test --match-test testRemovedCohortMembersCantBeAddedByElection [PASS] testRemovedCohortMembersCantBeAddedByElection() (gas: 545760) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.64ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual Testing & Foundry.
Add a mechanism to enable creating elections for cohort vacancies between the main elections (which occurs each 6-months).
SecurityCouncilMemberElectionGovernor
: Setting fullWeightDuration
Equal to votingPeriod()
Will Break The Voting<a id="l-06" ></a>SecurityCouncilMemberElectionGovernor
contract inherits the counting contract SecurityCouncilMemberElectionGovernorCountingUpgradeable
; where the logic of voting and voting weights is set.SecurityCouncilMemberElectionGovernor
is initialized; it sets the value of the fullWeightDuration that is used to calculate votes weight with time & votingPeriod of the proposals._fullWeightDuration > _votingPeriod
.votesToWeight
function will always revert due to division by zero (as the decreasingWeightDuration will be zero).This will make votesToWeight
and all the functions invoking it temporarily inaccessible (break the voting); unless the owner sets a new fullWeightDuration value.
SecurityCouncilMemberElectionGovernor/initialize function
File: governance/src/security-council-mgmt/governors/SecurityCouncilMemberElectionGovernor.sol Line 56-58: if (_fullWeightDuration > _votingPeriod) { revert InvalidDurations(_fullWeightDuration, _votingPeriod); }
SecurityCouncilMemberElectionGovernorCountingUpgradeable/votesToWeight function
File: governance/src/security-council-mgmt/governors/modules/SecurityCouncilMemberElectionGovernorCountingUpgradeable.sol Line 242-253: uint256 fullWeightVotingDeadline_ = fullWeightVotingDeadline(proposalId); if (blockNumber <= fullWeightVotingDeadline_) { return _downCast(votes); } // Between the fullWeightVotingDeadline and the proposalDeadline each vote will have weight linearly decreased by time since fullWeightVotingDeadline // slope denominator uint256 decreasingWeightDuration = endBlock - fullWeightVotingDeadline_; // slope numerator is -votes, slope denominator is decreasingWeightDuration, delta x is blockNumber - fullWeightVotingDeadline_ // y intercept is votes uint256 decreaseAmount = votes * (blockNumber - fullWeightVotingDeadline_) / decreasingWeightDuration;
Manual Testing.
Update SecurityCouncilMemberElectionGovernor/initialize function
to revert if the two values are equal:
- if (_fullWeightDuration > _votingPeriod) { + if (_fullWeightDuration >= _votingPeriod) { revert InvalidDurations(_fullWeightDuration, _votingPeriod); }
SecurityCouncilManager::_swapMembers
: No Check If The Two Swapped Addresses Are The Same <a id="nc-01" ></a>SecurityCouncilManager
contract/ _swapMembers
function: there's no check if the two swapped addresses are the same (this check is not done in the functions where this function is called as well)._swapMembers function/Line 218-229
File: governance/src/security-council-mgmt/SecurityCouncilManager.sol Line 218-229: function _swapMembers(address _addressToRemove, address _addressToAdd) internal returns (Cohort) { if (_addressToRemove == address(0) || _addressToAdd == address(0)) { revert ZeroAddress(); } Cohort cohort = _removeMemberFromCohortArray(_addressToRemove); _addMemberToCohortArray(_addressToAdd, cohort); _scheduleUpdate(); return cohort; }
Manual Testing.
Update swapMemebrs
function to revert on similar addresses:
function _swapMembers(address _addressToRemove, address _addressToAdd) internal returns (Cohort) { if (_addressToRemove == address(0) || _addressToAdd == address(0)) { revert ZeroAddress(); } + require(_addressToRemove != _addressToAdd,"duplicate addresses"); Cohort cohort = _removeMemberFromCohortArray(_addressToRemove); _addMemberToCohortArray(_addressToAdd, cohort); _scheduleUpdate(); return cohort; }
#0 - c4-judge
2023-08-18T23:26:30Z
0xean marked the issue as grade-b
π Selected for report: catellatech
Also found by: 0xSmartContract, 0xnev, K42, MSK, Sathish9098, berlin-101, hals, kodyvim, yixxas
The Arbitrum DAO incorporates a security council that can take certain emergency and non-emergency actions, and the scope of the audit was to review the codebase related to the election process of the security council members. The audited codebase consits of appx. 2184 nSLoC distributed over 20 contracts.
From reviewing the Arbitrum election related codebase,I was able to achieve the following:
The codebase was very distinguished in terms of:
The in-scope main contracts can be divided into three groups:
SecurityCouncilManager
contract:
where all the security council members management operations are done; replace cohorts,add,remove & swap members.
SecurityCouncilNomineeElectionGovernor
contract:
where the election process for a new cohort members starts;it has the logic of adding contenders,excluding and adding nominees within the vetting period, and starting the next phase (member election) once deadline has passed and a minimum number of nominees has been met.
SecurityCouncilMemberElectionGovernor
contract:
where the voting process for a new cohort members is continued; it has the logic of voting on nominees, then after the deadline of the member election process has passed; a new cohort members are set.
SecurityCouncilMemberRemovalGovernor
contract:
where a proposal is set to initiate the voting process to remove a cohort members.
L2SecurityCouncilMgmtFactory
contract:
where security council management contracts are deployed and initialized on the governance chain (Arbitrum).GovernanceChainSCMgmtActivationAction
,L1SCMgmtActivationAction
& NonGovernanceChainSCMgmtActivationAction
contracts:
where elections are activated on the three chains; Arbitrum,Ethereum & Nova.UpgradeExecRouteBuilder
contract:
where it builds routes to target the upgrade executors on each of the three chain.Deployment:
in L2SecurityCouncilMgmtFactory
: it deploys the four security governors contracts and syncs the relations between these contracts: as it sets the cohort replacer role in the SecurityCouncilManager
to the address of SecurityCouncilMemberElectionGovernor
which makes it less prone to any mis-alignmnet that might occure if each of these contracts are deployed individually.
Election flow:
in nomineeElection contract β createElection β 0-7 days to add contenders β from day 7 to day 21 (14 days) nominees are vetted: added/excluded β then anyone can execute the proposal to be sent to the membersElection contract β from day21 + 21 days are added for voting on the nominees β then the top 6 nominees are added to the security council while the old cohort members are removed:
Timelock:
after the security council members election ends, users are given some time to withdraw their asset if they don't agree with the proposed change.
SecurityCouncilMemberElectionGovernor
contract:The owner of this contract can use relay
function to call any function in any contract that's accessible only by the address of SecurityCouncilMemberElectionGovernor
contract; this gives the owner the ability to replace the members of any cohort any time without election.
Also the owner can change the fullWeightDuration
of the memberElection contract even if there's a running/active proposal; which makes the owner in control of the result of members voting by increaing and decreasing the weight in favor of some members.
SecurityCouncilMemberRemovalGovernor
contract:The owner can change the voteSuccessNumerator
of the memberRemoval contract even if there's a running/active proposal; which makes a malicious owner controls the result of member removing voting by increaing or decreasing the voteSuccessNumerator
in favor of/against this member.
The owner of this contract can use relay
function to remove any cohort member any time without election.
SecurityCouncilNomineeElectionGovernor
, no checks are made on nomineeVettingDuration if it complies with the design value or not (14 days).fullWeightDuration
and votingPeriod()
equal in SecurityCouncilMemberElectionGovernor
will break the voting as votesToWeight
will always revert (due to dividion by zero).MEMBER_ADDER_ROLE
role which is assigned to the govChainEmergencySecurityCouncil.L2SecurityCouncilMgmtFactory
contract; but these contracts can be deployed by anyone maliciously and used to trick users; so it must be ensured that the deployer of these contrats is the L2SecurityCouncilMgmtFactory
contract address.relay
function can't send native tokens to the target address when called; as it's non-payable.In SecurityCouncilManager
: check that cohorts sizes comply with the designed value (6 members for each cohort) upon contract initialization, and if it's deemed acceptable to have less than 6 members/cohort at any time (assuming that some cohort members were removed and their vacancies were not filled); then set cohortSize to a constant value of 6.
In SecurityCouncilNomineeElectionGovernor
: assign the designed nomineeVettingDuration
upon initialization.
In SecurityCouncilNomineeElectionGovernor
: add a mechanism to enable creating elections for cohort vacancies between the main elections (which occurs each 6-months).
Approximately 35 hours ; divided between manually reviewing the codebase, reading documentation, foundry testing, and documenting my findings.
35 hours
#0 - c4-judge
2023-08-18T23:49:13Z
0xean marked the issue as grade-a