Hyperware

Hyperware DAO

Cantina Security Report

Organization

@Hyperware

Engagement Type

Cantina Reviews

Period

-


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

  1. _validateSignature() returns wrong value

    Severity

    Severity: High

    Submitted by

    Gerard Persoon


    Description

    _validateSignature() returns 0 in case invalid parameters are passed. However this means SIG_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;}
  2. ECDSA.recover() reverts on invalid signatures

    Severity

    Severity: High

    Submitted by

    Gerard Persoon


    Description

    In HyperAccountMultisig::extractSigners(), the call to ECDSA.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() to ECDSA.tryRecover() or ECDSA.tryRecoverCalldata() in both locations.

    Hyperware

    Fixed in:

  3. 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 of gHYPR 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:

    votingPowerdelegateenormalized powerdelegators\text{votingPower}_{\text{delegatee}} \leq \sum \text{normalized power}_{\text{delegators}}

    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:

    1. Two users each lock 10 ETH: user1 locks for MIN_LOCK_DURATION, and user2 locks for MAX_LOCK_DURATION.
    2. Voting power for both users is measured.
    3. A snapshot is created, and time advances by one year. Voting power is measured again after the lock decay.
    4. The snapshot is restored, and this time user1 delegates their voting power to user2.
    5. Voting power is measured again (before and after advancing one year).
    6. 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

  1. NFT Transfer Can Fail and Block Seller Payment

    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 using safeTransferFrom:

    IERC721(address(_hypermap)).safeTransferFrom(address(this), winner, mintedNode);

    This call will revert if the winner address is a contract that does not implement IERC721Receiver.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 a try/catch, and on failure:

      • Mark the NFT as pending
      • Enable a retry via a claimNFT() function
    • Use transferFrom instead of safeTransferFrom, 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

  2. Incorrect calculation of getUserUnlockStamp()

    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 and delegated unlock time: the duration will incorrectly be returned as 2 years instead of 1 year

    Recommendation

    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 of unlock time and delegated unlock time. This should be used in function _getUserVotesAt().

  3. 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 over TOTAL_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 :

  4. Optimization in _updateMultiplier() not always correct

    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 the else 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);-}
  5. Function state() doesn't cover all changes

    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 change state() 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 the state() 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().

  6. Last minute changes to TBA before end of auction

    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 the state at the start of the auction, checking it at all bids and checking it at claimTlz().

    If the state has changed, then return the tokens to the bidder.

  7. 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.

  8. Last member can be removed from multisig

    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.

  9. 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 caused duration / C to round down early on, especially for MAX_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

  1. When is lock expired?

    Severity

    Severity: Low

    Submitted by

    Gerard Persoon


    Description

    The situation where $._userUnlockStamps[_account] == block.timestamp is interpreted in different ways: According to isLockExpired(), the lock is not expired.

    However the other functions manageLock(), calculateNewLockDuration() and transferRegistration() do consider it expired.

    This is inconsistent.

    Recommendation

    Doublecheck the desired behaviour and adapt the code.

  2. updateDelegationMultipliers() has no nonReentrant modifier

    Severity

    Severity: Low

    Submitted by

    Gerard Persoon


    Description

    Several functions in this contract have a nonReentrant modifier, for example withdraw(). Suppose there is a reentrancy possibility within withdraw(), then this could be used to call HyperGovernorToken::delegate() which calls updateDelegationMultipliers().

    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 to updateDelegationMultipliers().

  3. Same value bids are possible

    Severity

    Severity: Low

    Submitted by

    Gerard Persoon


    Description

    If the initial highestBid is (very)low, then increaseAmount 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
  4. Bids of 0 tokens allowed

    Severity

    Severity: Low

    Submitted by

    Gerard Persoon


    Description

    The minPrice for an auction could be 0, after a resetAuction(). Assuming _validateBidAmount() is updated to allow bids equal minPrice, 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() and resetAuction().

  5. Auctions can be monopolized

    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.
  6. Two definitions for auction has ended

    Severity

    Severity: Low

    Submitted by

    Gerard Persoon


    Description

    Edge case: when block.timestamp == auction.endAt, then according to _checkAuctionActive() its AuctionExpired, however claimTlz() considers it AuctionInProgress.

    Recommendation

    Consider using the same defintion in all locations.

  7. Missing Check to Restrict Multisig Members to EOAs

    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.

  8. manageLock() doesn't always check MIN_LOCK_DURATION

    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.

  9. Logic for DEFAULT_REGISTRATION_NAMEHASH is missing

    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.

  10. initialize(address validator) required for mint

    Severity

    Severity: Low

    Submitted by

    Gerard Persoon


    Description

    The function HyperGridNamespaceMinter::_mint() only works if the implementation contract (e.g. the TBA) has a function initialize(address validator). However this currently isn't checked.

    Recommendation

    Consider checking the implementation contract is compatible in initialize(address childImplementation, ...) and setChildImplementation(). This can be done via supportsInterface().

    Note: HyperGridNamespaceMinter() currently doesn't have supportsInterface(), also see finding supportsInterface() not implemented everywhere.

  11. totalSupply() doesn't use Votes::_getTotalSupply()

    Severity

    Severity: Low

    Submitted by

    Gerard Persoon


    Description

    HyperGovernorToken::totalSupply() uses ERC20::totalSupply(). However it more logical to use Votes::_getTotalSupply(). This is because Votes::_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 to address(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

  1. Redundant Declaration of EIP1271_MAGIC_VALUE

    Severity

    Severity: Informational

    Submitted by

    slowfi


    Description

    The constant EIP1271_MAGIC_VALUE is redefined in HyperAccountMultisig.sol at line 37, despite already being declared as EIP1271_MAGICVALUE in the inherited Mech 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 inherited EIP1271_MAGICVALUE constant from Mech.sol.

    Hyperware: Fixed on commit ID f13dab24ba788d359edd73e9336dfab70c9aa40e.

    Cantina Managed: Fix verified.

  2. Hardcoded Minimum Auction Bid Period

    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 constant MIN_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.

  3. Misleading Error Name: AuctionInProgress Used After Finalization

    Severity

    Severity: Informational

    Submitted by

    slowfi


    In TLZAuction.sol at line 372 (and similarly in resetAuction), the error AuctionInProgress is triggered when auction.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, or AuctionLocked). Alternatively, differentiate between "active" and "finalized" states more explicitly in the error logic.

    Hyperware: Fixed on commit ID 7107ee08ea73fe21d74191adc4a6a14f5bcbb7a6

    Cantina Managed: Fix verified.

  4. getRawVotes() can be called with address(0)

    Severity

    Severity: Informational

    Submitted by

    Gerard Persoon


    Description

    Function manageLock() calls getRawVotes() with address(0), if its the first call by a msg.sender. However its more logical to check the getRawVotes() of the msg.sender.

    In practice this results in the same value because address(0) can't receive gHyper tokens and can't be delegated to. However its more clear to use the correct delegatee.

    Recommendation

    Consider changing the code to:

    -uint256 votesReceiverBefore = $.gHypr.getRawVotes(delegatee);if (delegatee == address(0)) {    delegatee = msg.sender;}+uint256 votesReceiverBefore = $.gHypr.getRawVotes(delegatee);
  5. updateDelegationMultipliers() could use _max()

    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);
  6. 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.

  7. _timepoint should always be month start

    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.

  8. _updateMultiplier() could use getMultiplier()

    Severity

    Severity: Informational

    Submitted by

    Gerard Persoon


    Description

    The calculation of currentMultiplier is equivalent to a part of the code of getMultiplier(). 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];}
  9. Function _getRemainingDuration() can be simplified

    Severity

    Severity: Informational

    Submitted by

    Gerard Persoon


    Description

    Function _getRemainingDuration() can be simplified.

    Recommendation

    Consider using _subtract().

  10. Redundant check for type(IAccessControl).interfaceId

    Severity

    Severity: Informational

    Submitted by

    Gerard Persoon


    Description

    The check for type(IAccessControl).interfaceId in supportsInterface() is not necessary for contracts that inherit from AccessControlUpgradeable or AccessControl.

    Recommendation

    Consider removing the check.

    Hyperware

    Solved in:

  11. tokenRegistry can be immutable

    Severity

    Severity: Informational

    Submitted by

    Gerard Persoon


    Description

    tokenRegistry can be immutable because its never updated.

    Recommendation

    Consider changing tokenRegistry to immutable.

  12. Function _update() not necessary

    Severity

    Severity: Informational

    Submitted by

    Gerard Persoon


    Description

    Function _update() doesn't alter the functionality of super._update() so could be removed.

    Recommendation

    Consider removing _update() from HyperGovernorToken.

  13. Same nameHash can't be auctioned twice

    Severity

    Severity: Informational

    Submitted by

    Gerard Persoon


    Description

    The same nameHash can't be auctioned twice because auction.completed is set to true and this is checked in createAuction().

    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.

  14. Bid of minPrice is not accepted

    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) {
  15. Old comment about assembly

    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.

  16. Repeated bids are inefficient

    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 only safeTransferFrom() the extra amount.

  17. Minting gHYPR Without Enforcing HYPR Max Supply Cap

    Severity

    Severity: Informational

    Submitted by

    slowfi


    Description

    In HyperGovernorToken.sol at line 69, the mint() function allows TokenRegistry 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() of gHYPR stays within the MAX_SUPPLY() constraint of the underlying HYPR token. While mint() is restricted to be callable only by the TokenRegistry, the lack of a hard cap opens the door for accidental or inconsistent inflation if logic in TokenRegistry ever diverges from HYPR'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.

  18. Unnecessary Exposure of approve() Function in Non-Transferable Token

    Severity

    Severity: Informational

    Submitted by

    slowfi


    Description

    In HyperGovernorToken.sol at line 23, the contract inherits from ERC20, ERC20Permit, and ERC20Votes, which expose ERC20 standard functions such as approve():

    contract HyperGovernorToken is IHyperGovernorToken, AccessControl, ERC20Permit, ERC20Votes

    Although the token is explicitly non-transferable (with transfer() and transferFrom() disabled), the approve() 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.

  19. HyperAccountMinterUpgradable initialize() function

    Severity

    Severity: Informational

    Submitted by

    Gerard Persoon


    Description

    HyperAccountMinterUpgradable is an abstract contract but has its own initialize() function.

    However the contracts that inherit from HyperAccountMinterUpgradable have their own initialize() 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 the initializer modifier
    • the initialize() function of the main contract should do everything as the initialize() 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.

  20. supportsInterface() not implemented everywhere

    Severity

    Severity: Informational

    Submitted by

    Gerard Persoon


    Description

    Function supportsInterface() isn't overridden in HyperGridNamespaceMinter() and HyperAccountMultisig(), unlike the other TBAs.

    Recommendation

    Consider adding supportsInterface() in HyperGridNamespaceMinter() and HyperAccountMultisig().

  21. 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 different initialize() functions.

    However this makes the code more difficult to understand.

    Recommendation

    Consider splitting the contract into two parts that can be deployed seperately.

  22. Unused error NotSupportedYet()

    Severity

    Severity: Informational

    Submitted by

    Gerard Persoon


    Description

    The error NotSupportedYet() is not used.

    Recommendation

    Consider removing the error.

  23. 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

  1. Unnecessary Inheritance from ERC20Permit in Non-Transferable Token

    Severity

    Severity: Gas optimization

    Submitted by

    slowfi


    Description

    The HyperGovernorToken contract inherits from ERC20Permit (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.

  2. Unused AccesControl Inheritance

    Severity

    Severity: Gas optimization

    Submitted by

    slowfi


    Description

    In HyperGovernorToken.sol at line 54, the contract grants DEFAULT_ADMIN_ROLE to the admin 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 unused DEFAULT_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.

  3. Redundant Check on extensionTime > MAX_SINGLE_EXTENSION

    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.

  4. _revokeRole() not necessary

    Severity

    Severity: Gas optimization

    Submitted by

    Gerard Persoon


    Description

    The function TLZAuction::initialize() calls _revokeRole() for msg.sender. However this isn't necessary because msg.sender hasn't been added to the admin role.

    Recommendation

    Consider removing the call to _revokeRole().

  5. 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 of TLZAuction, it can be made immutable.

    Recommendation

    Consider making hypermap an immutable variable and configure is in the constructor of TLZAuction.

  6. Code can be simplified in cancelAuction() and resetAuction()

    Severity

    Severity: Gas optimization

    Submitted by

    Gerard Persoon


    Description

    Both the function cancelAuction() and resetAuction() enforce auction.highestBidder == address(0).

    If that is the case then auction.endAt is also 0, because both are set at the same time in makeBid(). 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);    }    ...}
  7. Redundant check in HyperAccountMultisig::initialize()

    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().

  8. ECDSA.recoverCalldata() can be used

    State

    Acknowledged

    Severity

    Severity: Gas optimization

    Submitted by

    Gerard Persoon


    Description

    Function extractSigners() uses the memory version of ECDSA.recover. However calldata 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);
  9. _getSender() no longer necessary

    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().

  10. ReentrancyGuardTransientUpgradeable could be used

    Severity

    Severity: Gas optimization

    Submitted by

    Gerard Persoon


    Description

    Contract HyperAccountMultisig imports ReentrancyGuardUpgradeable. However a more gas efficient version also exists.

    Recommendation

    Consider using ReentrancyGuardTransientUpgradeable.

  11. Extra inheritance level in HyperGridNamespaceMinter

    Severity

    Severity: Gas optimization

    Submitted by

    Gerard Persoon


    Description

    Contract HyperGridNamespaceMinter inherits from HyperAccountMinter,which inherits from HyperAccountMinterUpgradable. However most other contracts directly inherit from HyperAccountMinterUpgradable.

    Recommendation

    Consider directly inheriting from HyperAccountMinterUpgradable.

  12. Unused nonce in HyperAccountMinterUpgradable

    Severity

    Severity: Gas optimization

    Submitted by

    Gerard Persoon


    Description

    In contract HyperAccountMinterUpgradable the nonce 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 in HyperAccountMinterUpgradableStorage in the future in combination with upgrades.

  13. TimelockController could be used

    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.

  14. struct Auction can be packed more efficient

    Severity

    Severity: Gas optimization

    Submitted by

    Gerard Persoon


    Description

    The struct Auction can be packed more efficient

    Recommendation

    Consider moving the bool completed directly after an address.