Hyperware DAO
Cantina Security Report
Organization
- @Hyperware
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
High Risk
3 findings
3 fixed
0 acknowledged
Medium Risk
9 findings
8 fixed
1 acknowledged
Low Risk
11 findings
11 fixed
0 acknowledged
Informational
23 findings
21 fixed
2 acknowledged
Gas Optimizations
14 findings
12 fixed
2 acknowledged
High Risk3 findings
_validateSignature() returns wrong value
State
Severity
- Severity: High
Submitted by
Gerard Persoon
Description
_validateSignature()
returns 0 in case invalid parameters are passed. However this meansSIG_VALIDATION_SUCCEEDED
, which means the multisig can be circumvented.Recommendation
Change the code to:
if (signature.length == 0 || signature.length % SIGNATURE_LENGTH != 0) {- return 0;+ return SIG_VALIDATION_FAILED;}
ECDSA.recover() reverts on invalid signatures
State
Severity
- Severity: High
Submitted by
Gerard Persoon
Description
In
HyperAccountMultisig::extractSigners()
, the call toECDSA.recover()
reverts on invalid signatures and because only one of the hashes can be valid per signature, a revert will always occur. So execution will never be allowed.The same issue is present in
HyperAccountMinterUpgradable::_validateSignature()
.Recommendation
Change
ECDSA.recover()
toECDSA.tryRecover()
orECDSA.tryRecoverCalldata()
in both locations.Hyperware
Fixed in:
Voting Power Delegation From Expired Lock Incorrectly Increases Delegatee's Power
Severity
- Severity: High
≈
Likelihood: High×
Impact: High Submitted by
slowfi
Summary
Delegation from an expired lock improperly inflates voting power on the delegatee account due to stale multipliers not being updated.
Finding Description
When a user with a fully decayed or expired lock delegates their voting power to another user, the voting power transferred does not properly reflect the lock’s decay. Instead, the
TokenRegistry
allows delegation ofgHYPR
tokens using outdated or non-zero multipliers, effectively granting the recipient more voting power than they should receive.This breaks a key invariant: voting power must reflect locked duration. Allowing expired locks to still influence governance, even via delegation, violates the intent of the voting weight mechanism and enables disproportionate control.
The bug stems from early returns in the delegation logic that skip updating multipliers when the delegator’s lock duration is zero.
Impact Explanation
This issue allows expired or soon-to-expire tokens to be used to artificially increase another user's voting power. In a low-participation or tightly contested governance vote, this could be exploited to tip the result. It undermines the principle that governance influence should decrease over time and expire entirely when tokens become liquid.
This breaks the governance accounting guarantee that:
Likelihood Explanation
High likelihood in production environments, especially with long-term lockers or DAOs relying on periodic delegation. The bug was confirmed through a unit test showing a mismatch in the total system voting power before and after delegation from an expired lock.
Proof of Concept
The following test demonstrates that delegating voting power from an expired lock incorrectly increases the total system voting power. It compares two scenarios:
- Two users each lock 10 ETH: user1 locks for
MIN_LOCK_DURATION
, and user2 locks forMAX_LOCK_DURATION
. - Voting power for both users is measured.
- A snapshot is created, and time advances by one year. Voting power is measured again after the lock decay.
- The snapshot is restored, and this time user1 delegates their voting power to user2.
- Voting power is measured again (before and after advancing one year).
- The test then compares the total voting power in both scenarios to ensure they are consistent.
Despite no changes in lock duration or amount, the final assertion fails:
├─ [0] VM::assertEq(269999998650000000000 [2.699e20], 269999998650000000000 [2.699e20]) [staticcall] │ └─ ← [Return] ├─ [0] VM::assertEq(249999998750000000000 [2.499e20], 259999998700000000000 [2.599e20]) [staticcall] │ └─ ← [Revert] assertion failed: 249999998750000000000 != 259999998700000000000 └─ ← [Revert] assertion failed: 249999998750000000000 != 259999998700000000000
Code of the test:
function test_votingPowerTwoAccounts_OneYearPass_DelegatingVoting() public { vm.warp(block.timestamp + 365 days); // Avoid timestamp 0 edge case // Scenario setup vm.startPrank(user1); hyper.approve(address(registry), type(uint256).max); registry.manageLock(10 ether, MIN_LOCK_DURATION); vm.stopPrank(); vm.startPrank(user2); hyper.approve(address(registry), type(uint256).max); registry.manageLock(10 ether, MAX_LOCK_DURATION); vm.stopPrank(); // Scenario 1 — no delegation uint256 votesUser1_before = gHyper.getVotes(user1); uint256 votesUser2_before = gHyper.getVotes(user2); uint256 snapshotId = vm.snapshot(); vm.warp(block.timestamp + 365 days); // Advance one year uint256 votesUser1_after = gHyper.getVotes(user1); uint256 votesUser2_after = gHyper.getVotes(user2); // Scenario 2 — with delegation vm.revertTo(snapshotId); vm.startPrank(user1); gHyper.delegate(user2); vm.stopPrank(); uint256 votesUser1_before_scenario2 = gHyper.getVotes(user1); uint256 votesUser2_before_scenario2 = gHyper.getVotes(user2); vm.warp(block.timestamp + 365 days); uint256 votesUser1_after_scenario2 = gHyper.getVotes(user1); uint256 votesUser2_after_scenario2 = gHyper.getVotes(user2); assertEq( votesUser1_before + votesUser2_before, votesUser1_before_scenario2 + votesUser2_before_scenario2 ); assertEq( votesUser1_after + votesUser2_after, votesUser1_after_scenario2 + votesUser2_after_scenario2 );}
This test fails because after one year, user1's lock has expired and their voting power should be zero. Delegating voting power from that expired state should not increase user2’s power. However, due to the
TokenRegistry
not properly updating or invalidating expired voting multipliers during delegation, the system incorrectly allows user2 to gain more voting power than was present in scenario 1. This reveals a flaw in how the registry updates normalized weight during delegation, breaking the conservation of total voting power.Recommendation
Ensure that delegation always updates the multiplier, even for expired locks. Also consider:
- Validating that expired locks yield zero voting power, even if delegated
- Adding an invariant test to ensure conservation of voting power across delegation
Hyperware: Fixed in commit ID db3d810aacbf3392565884888b7170aaa57ade45.
To trace this bug it was added some logging in a not-for-merge branch on Pull 105
The issue was that we were returning early and not updating all multipliers when delegating. The total token amount changes (i.e. the actual gHYPR tokens) but we were not renormalizing anything after duration == 0, so those values were larger than expected
Cantina Managed: Fix verified.
Medium Risk9 findings
NFT Transfer Can Fail and Block Seller Payment
State
Severity
- Severity: Medium
Submitted by
slowfi
Description
In
TLZAuction.sol
at line 564, the auction finalization logic attempts to deliver the NFT to the auction winner usingsafeTransferFrom
:IERC721(address(_hypermap)).safeTransferFrom(address(this), winner, mintedNode);
This call will revert if the
winner
address is a contract that does not implementIERC721Receiver.onERC721Received()
, such as a minimal proxy or non-upgradeable logic contract. When this happens:- The NFT remains stuck in the auction contract.
- The seller’s payment logic, which follows immediately after the transfer, is never executed.
- Neither party can recover their assets through standard flows.
This is a critical liveness issue.
Recommendation
Consider addressing the edge case with one or more of the following design improvements:
-
Wrap the
safeTransferFrom
in atry/catch
, and on failure:- Mark the NFT as pending
- Enable a retry via a
claimNFT()
function
-
Use
transferFrom
instead ofsafeTransferFrom
, though this skips important ERC721 compliance checks -
Decouple the NFT delivery logic from
claimTlz
, similar to the seller's pull-based payment -
Add a pre-bid check to ensure the bidder is a contract that implements
onERC721Received
Hypermap
Fixed in
Incorrect calculation of getUserUnlockStamp()
State
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
Found by the project. Protocol delegation bug with the next scenario:
- User A locks 2 HYPR for 2 years
- User B locks 3 HYPR for 1 year
- User C locks 7 HYPR for 3 years
- User A delegates to B
- User B delegates to C
When B delegates to C, when transferring voting power via
userUnlockStamp()
the unlock stamp for B is computed incorrectly:The function looks at the max of
unlock time
anddelegated unlock time
: the duration will incorrectly be returned as 2 years instead of 1 yearRecommendation
Consider changing the code to:
function getUserUnlockStamp(address _account) external view returns (uint256) { ...- return _max($._userUnlockStamps[_account], $._delegatedUnlockStamps[_account]);+ return $._userUnlockStamps[_account];}
Also adding a function
getUserOrDelegatedUnlockStamp()
that returns the max value ofunlock time
anddelegated unlock time
. This should be used in function_getUserVotesAt()
.Detection of last month is not working
State
- Fixed
PR #100
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
The function
_processMonthlyVotes()
and_processMonthlyVotesTransfer()
do a loop overTOTAL_MONTHS
. This loop should end when_unlockStamp
is reached.However the end isn't correctly detected due to
duration = _max(duration, MIN_REGISTRATION_DURATION)
.This means action are taken after the last month which is inefficient and make the administration incorrect.
Recommendation
Detect when
_unlockStamp
is reached.Hyperware
Solved in :
Optimization in _updateMultiplier() not always correct
State
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
Function
_updateMultiplier()
has some optimization in case(currentMultiplier == 0 && _votesBefore == 0)
. However this isn't correct when_op == _subtract
, because the logic is different than in theelse
part.This could lead to errors in the calculations.
Recommendation
Consider removing the optimization:
-if (currentMultiplier == 0 && _votesBefore == 0) {- uint256 newMultiplier = _movedMonthlyVotes * MULTIPLIER_PRECISION / _votesAfter;- _setMultiplier(_account, _timepoint, newMultiplier);-} else { uint256 monthlyVotes = currentMultiplier * _votesBefore / MULTIPLIER_PRECISION; uint256 newMultiplier = _op(monthlyVotes, _movedMonthlyVotes) * MULTIPLIER_PRECISION / _votesAfter; _setMultiplier(_account, _timepoint, newMultiplier);-}
Function state() doesn't cover all changes
State
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
According to eip-6551, an account should have a function
state()
that is modified each time the account changes state.The current implementation is only used in relation to account abstraction, in other situations it isn't updated.
The reason for this function is to detect last minute changes when selling an NFT, where these changes could make the NFT worth less.
Recommendation
Consider updating
state()
for every change on the token bound account:- the number of tokens bound to the TBA as well as the duratation (might require additional administration)
- additional childeren minted (or childeren of childeren)
- updates of notes (or updates of notes of childeren or childeren of childeren)
- upgrade of any TBA self or of the contracts of childeren
- usage of
_exec()
- receival of funds in
receive()
Hyperware
Partly fixed. Unfortunately we cannot do a full-featured solution here as far as I can tell, but we can do better than nothing:
- do not update
state()
on things that are not owner actions. Reasoning here is that it becomes a griefing vector (Eve the spammer can send a spam token to changestate()
and kill a sale). Receiving tokens is not something we're trying to prevent anyways; - do update state() on owner actions: this is the
_exec()
increment. So, e.g., if owner sends a token that belongs to the TBA, that can potentially kill sale.
Unfortunately it is still not foolproof: if Evan is the owner, he can have previously approved a
transferFrom()
and then, after thestate()
is recorded, transfer tokens out.Documented the known weaknesses with this implementation. For a trustless marketplace we'll implement an offchain commitment scheme of some kind that doesn't rely on
state()
.Last minute changes to TBA before end of auction
State
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
Last minute changes could potentially be made to a TBA, just before an auction end. Then the buyer would buy a TBA that might be worth less.
eip-6551 has defined an approach to mitigate that, via the function
state()
that is modified each time the account changes state.Recommendation
Consider making use of the function
state()
. For example by storing thestate
at the start of the auction, checking it at all bids and checking it atclaimTlz()
.If the
state
has changed, then return the tokens to the bidder.Auctioned TBAs could still be managed
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
Auctioned TBAs are transfered to the
TLZAuction
contract to prevent furter actions on the TBA. However, depending on the type of TBA some other parties might still be able to manage the TBA.For example when the
HyperAccountMultisig
is used. There is no guarantee that these parties will transfer their rights to the new owner.Recommendation
Consider enforcing that additional rights are transferred. This might require additional functions in the TBA to verify this.
Hyperware
Acknowledged. Out-of-scope for this contract since it is scoped for use by us to auction brand-new TLZs.
Last member can be removed from multisig
State
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
Function
removeMember()
allows removing the last member. This would make the multisig unsuable.Additionally
initialize()
also doesn't allow 0 members.Recommendation
Consider enforcing that at least one member is present.
Rounding Error Prevented Voting Power Decay for Long Locks
Severity
- Severity: Medium
Submitted by
slowfi
Description
Found by the project. Protocol delegation bug.
The constant
C
used in the linear voting power calculation causedduration / C
to round down early on, especially forMAX_LOCK_DURATION
. This resulted in voting power not decaying for some months, breaking expected behavior.Recommendation
Use a smaller
C
value to ensure monthly voting power decay occurs even for long-term locks, as implemented in the referenced fix.Hyperware: Fixed on commit ID ea73e9fd9693999efcc76e72ba01fec438bd2f22.
Cantina Managed: Fix verified.
Low Risk11 findings
When is lock expired?
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The situation where
$._userUnlockStamps[_account] == block.timestamp
is interpreted in different ways: According toisLockExpired()
, the lock is not expired.However the other functions
manageLock()
,calculateNewLockDuration()
andtransferRegistration()
do consider it expired.This is inconsistent.
Recommendation
Doublecheck the desired behaviour and adapt the code.
updateDelegationMultipliers() has no nonReentrant modifier
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Several functions in this contract have a
nonReentrant
modifier, for examplewithdraw()
. Suppose there is a reentrancy possibility withinwithdraw()
, then this could be used to callHyperGovernorToken::delegate()
which callsupdateDelegationMultipliers()
.This could interfere with
withdraw()
.Recommendation
Although we see no realistic reentrancy possibility, this is a low cost safety precaution.
Consider adding a
nonReentrant
modifier toupdateDelegationMultipliers()
.Same value bids are possible
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
If the initial
highestBid
is (very)low, thenincreaseAmount
can be rounded down to 0. This allows same value bids to be done, which is unwanted in an auction.Additionally if
_bidIncreasePercentage
is set to 0,increaseAmount
will also be 0, with the same result.Recommendation
Consider doing the following:
- require a minimum value for bids
- disallow
_bidIncreasePercentage
to be 0 - for safety doublecheck
increaseAmount
isn't 0
Bids of 0 tokens allowed
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The
minPrice
for an auction could be 0, after aresetAuction()
. Assuming_validateBidAmount()
is updated to allow bids equalminPrice
, then a_newBidAmount
could be 0.Most tokens allow a transfer of 0 tokens, however some tokens might not allow this, see weird-erc20.
Also allowing a bid of 0 is not logical in an auction.
Recommendation
Consider requiring a minimum amount for a bid. This should be checked in both
createAuction()
andresetAuction()
.Auctions can be monopolized
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Someone who is determined to win an auction can use two accounts and quickly bid with alternate accounts until
MAX_AUCTION_EXTENSIONS
is reached and thus monopolize the auction.Note: depending on
bidIncreasePercentage
this might be economically infeasible.Recommendation
Consider doing one of the following:
- use blind auctions
- still allow bidding even after the time can't be extended any more. Note: this leads to possibility of last-minute bidding, but this is more-or-less the same situation as last-minute bidding to be the final extension.
Two definitions for auction has ended
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Edge case: when
block.timestamp == auction.endAt
, then according to_checkAuctionActive()
itsAuctionExpired
, howeverclaimTlz()
considers itAuctionInProgress
.Recommendation
Consider using the same defintion in all locations.
Missing Check to Restrict Multisig Members to EOAs
State
Severity
- Severity: Low
Submitted by
slowfi
Description
In
HyperAccountMultisig.sol
at line 319, the_addMember
function adds a new signer to the multisig:function _addMember(address _member, MainStorage storage _storage) internal {
It is implied that
_member
should be an Externally Owned Account (EOA), not a contract. However, there is currently no check enforcing that condition. This could allow contracts to be added as multisig members, which may lead to unexpected behavior or reentrancy risks depending on how those contracts behave during signature validation.Although Solidity permits contracts to appear as EOAs during construction (i.e.,
code.length == 0
), this edge case is difficult to exploit in this context.Recommendation
Consider enforcing an EOA-only policy with:
require(_member.code.length == 0, "Must be EOA");
This protects the multisig from being compromised by contracts with complex behaviors or upgradeable logic. If contract-based signers are intended in the future, this restriction can be revised or made configurable.
Hyperware: Fixed on commit ID 5c167266fce1e00453fc4163dbe643876027d381.
Cantina Managed: Fix verified.
manageLock() doesn't always check MIN_LOCK_DURATION
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
In function
manageLock()
, in the situation where_processLock()
is called, there isn't an explicit check that_duration >MIN_LOCK_DURATION
.However the comment suggest this check should be done.
Note:
_processLockExtension()
does check this via_validateExtensionDuration()
Recommendation
Consider enforcing the
MIN_LOCK_DURATION
or alternatively change the comment.Logic for DEFAULT_REGISTRATION_NAMEHASH is missing
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
A previous version of the code hade the following code, but that isn't present in the current version.
emit BindCreated(msg.sender, DEFAULT_REGISTRATION_NAMEHASH, _amount, 0);...emit BindAmountIncreased(msg.sender, DEFAULT_REGISTRATION_NAMEHASH, _amount, 0);...$._userBinds[msg.sender].add(DEFAULT_REGISTRATION_NAMEHASH);...$._userBinds[msg.sender].remove(DEFAULT_REGISTRATION_NAMEHASH);
Recommendation
Doublecheck the usefulness and the statements.
initialize(address validator) required for mint
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The function
HyperGridNamespaceMinter::_mint()
only works if theimplementation
contract (e.g. the TBA) has a functioninitialize(address validator)
. However this currently isn't checked.Recommendation
Consider checking the
implementation
contract is compatible ininitialize(address childImplementation, ...)
andsetChildImplementation()
. This can be done viasupportsInterface()
.Note:
HyperGridNamespaceMinter()
currently doesn't havesupportsInterface()
, also see findingsupportsInterface()
not implemented everywhere.totalSupply() doesn't use Votes::_getTotalSupply()
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
HyperGovernorToken::totalSupply()
usesERC20::totalSupply()
. However it more logical to useVotes::_getTotalSupply()
. This is becauseVotes::_getTotalSupply()
uses_totalCheckpoints[]
similar to_getPastTotalSupply()
/getPastTotalSupply()
.Also
ERC20::totalSupply()
returns all the tokens but this might be different from the number of votes. For example if tokens are delegated toaddress(0)
, they don't count anymore as votes, althought this is prevented in the current code, there might be other edge cases.Recommendation
Consider using
Votes::_getTotalSupply()
.
Informational23 findings
Redundant Declaration of EIP1271_MAGIC_VALUE
State
Severity
- Severity: Informational
Submitted by
slowfi
Description
The constant
EIP1271_MAGIC_VALUE
is redefined inHyperAccountMultisig.sol
at line 37, despite already being declared asEIP1271_MAGICVALUE
in the inheritedMech
contract.Inheritance path:
HyperAccountMultisig → HyperAccountMinterUpgradable → ERC721TokenBoundMech → TokenBoundMech → Mech
This duplication adds unnecessary code and increases the risk of inconsistency if one constant is modified independently in the future.
Recommendation
Consider removing the local declaration in
HyperAccountMultisig.sol
and relying on the inheritedEIP1271_MAGICVALUE
constant fromMech.sol
.Hyperware: Fixed on commit ID f13dab24ba788d359edd73e9336dfab70c9aa40e.
Cantina Managed: Fix verified.
Hardcoded Minimum Auction Bid Period
State
Severity
- Severity: Informational
Submitted by
slowfi
Description
In
TLZAuction.sol
at line 587, the condition uses a hardcoded duration:if (_defaultAuctionBidPeriod < 1 days) {
This check enforces a minimum bid period, but the
1 days
value is not declared as a named constant, making it less consistent with the rest of the codebase and harder to maintain or audit.Recommendation
Consider defining
1 days
as a named constantMIN_AUCTION_BID_PERIOD
to improve clarity and maintainability. This aligns with best practices for parameter configuration.Hyperware: Fixed on commit ID f4a0b488d7aa303bb0a0ce74c4b713cbee8dd470.
Cantina managed: Fix verified.
Misleading Error Name: AuctionInProgress Used After Finalization
State
Severity
- Severity: Informational
Submitted by
slowfi
In
TLZAuction.sol
at line 372 (and similarly inresetAuction
), the errorAuctionInProgress
is triggered whenauction.highestBidder != address(0)
:if (auction.highestBidder != address(0)) { revert AuctionInProgress(_nameHash);}
However, this condition may also be true after an auction has already been finalized, making the error message misleading. The name
AuctionInProgress
implies that bidding is currently allowed, which is not necessarily the case.Recommendation
Consider renaming the error to better reflect its actual purpose (e.g.,
AuctionAlreadyExists
,AuctionAlreadyFinalized
, orAuctionLocked
). Alternatively, differentiate between "active" and "finalized" states more explicitly in the error logic.Hyperware: Fixed on commit ID 7107ee08ea73fe21d74191adc4a6a14f5bcbb7a6
Cantina Managed: Fix verified.
getRawVotes() can be called with address(0)
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
manageLock()
callsgetRawVotes()
withaddress(0)
, if its the first call by amsg.sender
. However its more logical to check thegetRawVotes()
of themsg.sender
.In practice this results in the same value because
address(0)
can't receivegHyper
tokens and can't be delegated to. However its more clear to use the correctdelegatee
.Recommendation
Consider changing the code to:
-uint256 votesReceiverBefore = $.gHypr.getRawVotes(delegatee);if (delegatee == address(0)) { delegatee = msg.sender;}+uint256 votesReceiverBefore = $.gHypr.getRawVotes(delegatee);
updateDelegationMultipliers() could use _max()
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Some code in
updateDelegationMultipliers()
can be simplified.Recommendation
Consider changing the code to:
$._delegatedUnlockStamps[_dst] =- $._delegatedUnlockStamps[_dst] > _unlockStamp ? $._delegatedUnlockStamps[_dst] : _unlockStamp;+ _max($._delegatedUnlockStamps[_dst], _unlockStamp);
Sybils can circumvent sublinear approach
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
An inherent issue of the sublinear approach is token owners can split their balance over multiple accounts (also known as sybils) and then lock multiple times. This reduces the penalty.
Recommendation
Just important to be aware of this.
_timepoint should always be month start
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The parameter
_timepoint
of_updateMultiplier()
should always be a month start to be able to work with the rest of the code. This can made clear by renaming the variable.Recommendation
Consider renaming
_timepoint
to_monthStart
._updateMultiplier() could use getMultiplier()
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The calculation of
currentMultiplier
is equivalent to a part of the code ofgetMultiplier()
. This part could be moved to a seperate function to hide implementation details and make it more in line with the rest of code.Recommendation
Consider moving the code to a seperate function and also use that from
getMultiplier()
:function _getMultiplier(address _account, uint256 _timepoint) internal view returns (uint256) { TokenRegistryStorageLayout storage $ = _getTokenRegistryStorage(); return _account == address(0) ? $._totalMultipliers[_timepoint] : $._userMultipliers[_account][_timepoint];}
Function _getRemainingDuration() can be simplified
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
_getRemainingDuration()
can be simplified.Recommendation
Consider using
_subtract()
.Redundant check for type(IAccessControl).interfaceId
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The check for
type(IAccessControl).interfaceId
insupportsInterface()
is not necessary for contracts that inherit from AccessControlUpgradeable or AccessControl.Recommendation
Consider removing the check.
Hyperware
Solved in:
tokenRegistry can be immutable
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
tokenRegistry
can beimmutable
because its never updated.Recommendation
Consider changing
tokenRegistry
toimmutable
.Function _update() not necessary
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
_update()
doesn't alter the functionality ofsuper._update()
so could be removed.Recommendation
Consider removing
_update()
fromHyperGovernorToken
.Same nameHash can't be auctioned twice
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The same
nameHash
can't be auctioned twice becauseauction.completed
is set totrue
and this is checked increateAuction()
.This is useful immediately after the auction to prevent accidental bid to a potentially new auction.
However after soms time has passed it might be useful to be able to auction again.
Recommendation
Consider the usefulness of being able to auction again. If useful consider allowing this after some time has passed.
Bid of minPrice is not accepted
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
A bid of
minPrice
is not accepted which isn't logical based on the variable name.Recommendation
Consider changing the code to:
-if (_auction.minPrice >= _bidAmount) {+if (_auction.minPrice > _bidAmount) {
Old comment about assembly
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
_validateBidAmount()
contains a comment about assembly that isn't relevant (any more).Recommendation
Consider removing the comment.
Repeated bids are inefficient
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
If someone want to do repeated bids, he has to pay for all the bids, or first
withdraw()
the payments for previous bids, which is inefficient.Recommendation
Consider re-using the amount in
pendingWithdrawals[msg.sender]
and onlysafeTransferFrom()
the extra amount.Minting gHYPR Without Enforcing HYPR Max Supply Cap
State
Severity
- Severity: Informational
Submitted by
slowfi
Description
In
HyperGovernorToken.sol
at line 69, themint()
function allowsTokenRegistry
to mint governance tokens (gHYPR
) to an account:function mint(address account, uint256 amount) external override onlyTokenRegistry notZeroAddress(account) { _ensureDelegateSet(account); _mint(account, amount);}
However, this function does not check that the resulting
totalSupply()
ofgHYPR
stays within theMAX_SUPPLY()
constraint of the underlyingHYPR
token. Whilemint()
is restricted to be callable only by theTokenRegistry
, the lack of a hard cap opens the door for accidental or inconsistent inflation if logic inTokenRegistry
ever diverges fromHYPR
's total supply tracking.Recommendation
Consider adding a check to enforce that the amount of governance token can not exceed the total supply of
HYPR
token.Hyperware: Fixed on commit ID c03c95ccfc0229b5ebabc2e4a15b0047cbde1343.
Cantina Managed: Fix verified.
Unnecessary Exposure of approve() Function in Non-Transferable Token
State
Severity
- Severity: Informational
Submitted by
slowfi
Description
In
HyperGovernorToken.sol
at line 23, the contract inherits fromERC20
,ERC20Permit
, andERC20Votes
, which expose ERC20 standard functions such asapprove()
:contract HyperGovernorToken is IHyperGovernorToken, AccessControl, ERC20Permit, ERC20Votes
Although the token is explicitly non-transferable (with
transfer()
andtransferFrom()
disabled), theapprove()
function remains exposed. This can cause confusion for integrators or tooling that assumes approvals imply transferability.Recommendation
Consider overriding and disabling the
approve()
function explicitly, for example:function approve(address, uint256) public pure override returns (bool) { revert("gHYPR is non-transferable");}
This will make the non-transferable nature of the token clearer and reduce the potential for integration bugs or false assumptions.
Hyperware: Fixed on commit ID 679dfbe52cfd63e4be43ad8df89a30875de7de85.
Cantina Managed: Fix verified.
HyperAccountMinterUpgradable initialize() function
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
HyperAccountMinterUpgradable
is an abstract contract but has its owninitialize()
function.However the contracts that inherit from
HyperAccountMinterUpgradable
have their owninitialize()
function, sometimes with a different number of parameters.If the parameters are different then two instances of
initialize()
are present and of these should happen:- both
initialize()
are called, but this is prevented with theinitializer
modifier - the
initialize()
function of the main contract should do everything as theinitialize()
of the inherited contract. However this is not always the case (HyperAccountPaidMinter).
Recommendation
Consider creating a function
HyperAccountMinterUpgradable_init()
comparable to the OZ abstract contracts.Call this function from all the main contracts:
- HyperAccountAccessControlMinter
- HyperAccountMinter
- HyperAccountPermissionedMinter
- HyperAccountPaidMinter
- HyperAccount9CharCommitMinter
- HyperAccountMultisig
- HyperGridNamespaceMinter
According to the OZ pattern, the library functions are called from the
initialize()
on the main contract:- __UUPSUpgradeable_init();
- __EIP712_init("HyperAccountMinterUpgradable", "1");
This means
HyperAccountMinterUpgradable_init()
will be an empty function.supportsInterface() not implemented everywhere
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
supportsInterface()
isn't overridden inHyperGridNamespaceMinter()
andHyperAccountMultisig()
, unlike the other TBAs.Recommendation
Consider adding
supportsInterface()
inHyperGridNamespaceMinter()
andHyperAccountMultisig()
.HyperGridNamespaceMinter has two parts
State
- Fixed
PR #107
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The contract
HyperGridNamespaceMinter
has two different parts that can be deployed seperately and reference each other. This can also be seen by the two differentinitialize()
functions.However this makes the code more difficult to understand.
Recommendation
Consider splitting the contract into two parts that can be deployed seperately.
Unused error NotSupportedYet()
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The error
NotSupportedYet()
is not used.Recommendation
Consider removing the error.
Voting Power Decay Does Not Trigger Until Day 56 for Max Duration Locks
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
During testing, it was observed that when four users stake their full balance for
MAX_LOCK_DURATION
, their voting power remains constant through the first 55 days. Decay only begins on day 56.This behavior is due to rounding in the duration term used in voting power calculation. Although the expectation was that decay would begin after the first month, the current precision and divisor cause the system to round the duration term such that the voting power remains unchanged for nearly two months.
This is not a critical bug but an important nuance in how vote decay is implemented.
Recommendation
No immediate fix is required. However, consider documenting this behavior or refining the duration divisor to ensure that decay begins closer to the expected one-month mark if stronger time resolution is desired.
Hyperware: Acknowledged. It is not ideal, but It is ok due to rounding issues.
Cantina Managed: Acknowledged by Hyperware team.
Gas Optimizations14 findings
Unnecessary Inheritance from ERC20Permit in Non-Transferable Token
State
Severity
- Severity: Gas optimization
Submitted by
slowfi
Description
The
HyperGovernorToken
contract inherits fromERC20Permit
(line 23), which provides off-chain signature-based approvals (EIP-2612) for spending allowances.However, if this token is not transferable, permit functionality has no practical effect. It also exposes unused functionality that could confuse integrators or increase the attack surface.
Recommendation
Consider removing the inheritance from
ERC20Permit
unless permit-based delegation or future transferability is explicitly planned and justified.Hyperware: Fixed on commit ID 057459fc9f05b3e0987018e0f1dc89c3eab94e96.
Cantina Managed: Fix verified.
Unused AccesControl Inheritance
State
Severity
- Severity: Gas optimization
Submitted by
slowfi
Description
In
HyperGovernorToken.sol
at line 54, the contract grantsDEFAULT_ADMIN_ROLE
to theadmin
address:_grantRole(DEFAULT_ADMIN_ROLE, admin);
However, the contract does not appear to use
AccessControl
roles for any permission checks or role-restricted functionality. This role assignment is therefore unused and may be misleading to auditors or future contributors.Recommendation
Consider removing the
AccessControl
inheritance and the unusedDEFAULT_ADMIN_ROLE
assignment unless you plan to enforce role-based access control in the future.Hyperware: Fixed on commit ID f189f1696c20cb6a79c756f6e3197ac2cf6f3a51
Cantina Managed: Fix verified.
Redundant Check on extensionTime > MAX_SINGLE_EXTENSION
State
Severity
- Severity: Gas optimization
Submitted by
slowfi
Description
In
TLZAuction.sol
at line 497, the code includes a conditional cap:if (extensionTime > MAX_SINGLE_EXTENSION) { extensionTime = MAX_SINGLE_EXTENSION;}
However, this condition is already enforced in the
_setAuctionIncreaseTime()
function when the value is initially set. Assuming the implementation behind the proxy does not change, this check is effectively unreachable and redundant.Recommendation
Consider removing the redundant check to simplify the code, reduce bytecode size, and eliminate confusion. If upgradability is a concern, document the assumption or enforce safety through external validation instead.
Hyperware: Fixed on commit ID 71d92869ffbd5f738fce5deb88ff050d3b6b3c45.
Cantina Managed: Fix verified.
_revokeRole() not necessary
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
The function
TLZAuction::initialize()
calls_revokeRole()
formsg.sender
. However this isn't necessary becausemsg.sender
hasn't been added to the admin role.Recommendation
Consider removing the call to
_revokeRole()
.hypermap could be made immutable in TLZAuction
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Function
TLZAuction:initialize()
stores_hypermap
in a storage variable. As the address of the Hypermap isn't expected to change (because it is upgradable) and assuming it is available before the deployment ofTLZAuction
, it can be madeimmutable
.Recommendation
Consider making
hypermap
animmutable
variable and configure is in the constructor ofTLZAuction
.Code can be simplified in cancelAuction() and resetAuction()
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Both the function
cancelAuction()
andresetAuction()
enforceauction.highestBidder == address(0)
.If that is the case then
auction.endAt
is also 0, because both are set at the same time inmakeBid()
. So the code can be simplified to make it more readable and save some gas.Recommendation
Consider changing the code to:
function cancelAuction(bytes32 _nameHash) external nonReentrant { ...- if (auction.endAt == 0 && block.timestamp < auction.startAt + RESET_COOLDOWN_PERIOD) {+ if (block.timestamp < auction.startAt + RESET_COOLDOWN_PERIOD) { revert InCoolDownPeriod(_nameHash); } ...}
function resetAuction(bytes32 _nameHash, uint256 _minPrice) external { ...- if (auction.endAt != 0 && block.timestamp < auction.endAt) {- revert AuctionInProgress(_nameHash);- }- uint256 cooldownStart = auction.endAt == 0 ? auction.startAt : auction.endAt;- if (block.timestamp < cooldownStart + RESET_COOLDOWN_PERIOD) {+ if (block.timestamp < auction.startAt + RESET_COOLDOWN_PERIOD) { revert InCoolDownPeriod(_nameHash); } ...}
Redundant check in HyperAccountMultisig::initialize()
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Function
initialize()
does some checks on_requiredSignatures
, however these checks are also done by_changeRequiredSignatures()
, so the checks are redundant.Recommendation
Consider removing the checks from
initialize()
.ECDSA.recoverCalldata() can be used
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Function
extractSigners()
uses thememory
version ofECDSA.recover
. Howevercalldata
specific versions also exist which save some gas.Recommendation
Consider changing the code to:
- bytes memory singleSig = _signature[offset:offset + SIGNATURE_LENGTH];+ bytes calldata singleSig = _signature[offset:offset + SIGNATURE_LENGTH]; ...- signers[...] = ECDSA.recover(..., singleSig);+ signers[...] = ECDSA.recoverCalldata (..., singleSig);
_getSender() no longer necessary
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Function
_getSender()
was implemented to support_mintBySignature()
. However because_mintBySignature()
is removed, it is no longer necessary.Recommendation
Consider removing the function
_getSender()
.ReentrancyGuardTransientUpgradeable could be used
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Contract
HyperAccountMultisig
importsReentrancyGuardUpgradeable
. However a more gas efficient version also exists.Recommendation
Consider using
ReentrancyGuardTransientUpgradeable
.Extra inheritance level in HyperGridNamespaceMinter
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Contract
HyperGridNamespaceMinter
inherits fromHyperAccountMinter
,which inherits fromHyperAccountMinterUpgradable
. However most other contracts directly inherit fromHyperAccountMinterUpgradable
.Recommendation
Consider directly inheriting from
HyperAccountMinterUpgradable
.Unused nonce in HyperAccountMinterUpgradable
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
In contract
HyperAccountMinterUpgradable
thenonce
mapping is no longer used.Recommendation
Consider removeing the
nonce
mapping.Note: be aware of potential old values of the
nonce
mapping when new variables are added inHyperAccountMinterUpgradableStorage
in the future in combination with upgrades.TimelockController could be used
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
The constructor of
TimelockControllerUpgradeableWithInit
effectely converts this into the nonupgradable version: TimelockController.Recommendation
Consider directly using
TimelockController
.struct Auction can be packed more efficient
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
The
struct Auction
can be packed more efficientRecommendation
Consider moving the
bool completed
directly after an address.