Hyperware
Cantina Security Report
Organization
- @Hyperware
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Medium Risk
5 findings
5 fixed
0 acknowledged
Low Risk
23 findings
21 fixed
2 acknowledged
Informational
23 findings
22 fixed
1 acknowledged
Gas Optimizations
11 findings
11 fixed
0 acknowledged
Medium Risk5 findings
vHYPR tokens received after vestingEndTimestamp might not be claimable
State
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
Function
claim()
removessHYPR
tokens onevestingEndTimestamp
has been reached.However after that time, new
sHYPR
tokens can still be received viatransfer()
or by claiming a merkledrop.In that situation
userTotalClaimed_[]
still has its value which means the newsHYPR
tokens can't be claimed.Recommendation
Consider resetting
userTotalClaimed_[]
to 0 whensHYPR
tokens are removed.Note: resetting
userTotalClaimed_[]
to 0 could possibly enable transfers that were earlier considered not possible (because of userTotalClaimed_ > requiredClaimedAmount). However not an issues because at moment vesting has ended anyway.mint doesn't account for claimed tokens
State
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
Function
claim()
transfersHYPR
tokens out (after the cliff). In that situation the test of L73 doesn't work as expected any more and too few tokens will be able to minted.Recommendation
Consider changing the code to:
-if (totalMinted + amount > _balance) {+if (totalMinted + amount > _balance + totalClaimed) { revert NotEnoughHyprSupplyToMint();}
newDuration can be lower than lock.duration
State
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
Function
manageLock()
usesunchecked
, however this can be abused to change thenewDuration
to a value that is lower thanlock.duration
. This would allow unlocked tokens earlier than expected.Proof of Concept
Here is POC to show the issue:
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;import "hardhat/console.sol"; contract test { uint256 public constant MAX_LOCK_DURATION = 4 * 365 days; uint256 public constant MIN_LOCK_DURATION = 7 days; function _min(uint256 a, uint256 b) internal pure returns (uint256) { unchecked { return a < b ? a : b; } } function _validateDuration(uint256 _duration) internal pure { if (_duration < MIN_LOCK_DURATION || _duration > MAX_LOCK_DURATION) { revert (); } } constructor() { uint _duration = type(uint256).max - MAX_LOCK_DURATION + MIN_LOCK_DURATION+1; uint lock_duration = MAX_LOCK_DURATION; if (_duration > 0) { // Increase lock duration uint256 newDuration; unchecked { newDuration = lock_duration + _duration; } _validateDuration(newDuration); console.log("orginal duration %d days",lock_duration / 1 days); // 1460 console.log("new duration %d days",newDuration / 1 days); // 7 } } }
Recommendation
Remove the
unchecked
for the calculation ofnewDuration
.Entrypoint version mismatch
State
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
Contract
Account
includesBaseAccount
fromentryPoint
version 0.7, while the constant_entryPoint
uses version 0.6. As these are incompatible, the Account Abstraction logic will not work.Also a newer version of the
entryPoint
is released.Recommendation
Consider using entryPoint version 0.8 in both the include and the constant.
Several issues with the creation of the _DOMAINSEPARATOR
State
Severity
- Severity: Medium
Submitted by
Gerard Persoon
Description
There are several issues with the creation of the
_DOMAINSEPARATOR
:- EIP712Domain contains an extra space rights before
bytes32 salt
, this will lead to a different hash; - there is no
salt
added inabi.encode()
; address(this)
references the address of the template contract and not of the proxy.
Recommendation
Consider using EIP712Upgradeable.
This uses a
TYPE_HASH
without asalt
, but that is fine because thesalt
isn't used.bytes32 private constant TYPE_HASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
Low Risk23 findings
Potential infinite loop in constructor of HyperToken
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
In the
constructor
ofHyperToken
, acontinue
is done if(to == address(0) || amount == 0)
. In that situation the loop counter isn't incremented and the loop would continue until it is out of gas.Recommendation
Move the
i++
inside of thefor
statement. Theunchecked
isn't necessary any more since solidity version 0.8.22.The functions transfer() and transferFrom() can return false which isn't best practice
State
- Fixed
PR #77
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The functions
transfer()
andtransferFrom()
can returnfalse
. However this isn't best practice because it can be overlooked by users of the contract.Although the use of
SafeERC20
/safeTransfer()
/safeTransferFrom()
fixes this, it is safer to use the best practice.Also see weird-erc20.
For example function
claim()
usestransfer()
without usingsafeTransfer()
.Recommendation
Consider using
revert()
on failure oftransfer()
andtransferFrom()
.Function claim() uses transfer()
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The function
claim()
usestransfer()
to transfer HyperTokens. However if that tokens would returnfalse
like this contract does, then potentially errors would not be detected.Recommendation
Consider using
safeTransfer()
.Not all init functions are called
State
- Fixed
PR #77
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Several init functions of (underlying) upgradable contracts are not called. Currently they are all empty, so the risk is low. However future version might have code.
Recommendation
Consider also calling:
In
MerkleDistributor::initialize()
:- __ReentrancyGuard_init
- __ERC165_init
- __Context_init
In
TokenRegistry::initialize()
:- __ERC165_init()
- __Context_init()
In
Hypermap::initialize()
:- __UUPSUpgradeable_init()
- __ERC165_init()
- __Context_init()
Multiple the same merkleRoots could be added accidentally
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Function
create()
allow to accidentally add the samemerkleRoot
multiple times. This could be for the same token or for different tokens.It also allows adding multiple different
merkleRoot
s for the same token.Recommendation
Consider disallowing to add multiple the same
merkleRoot
s are added. Also consider only allowing onemerkleRoot
per token.Indexes larger than or equal to count can be used
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Functions claim() doen't check that
_dIndex < count
. In theory this could allow claiming tokens in the case that the root is0
. However its very unlikely that a root of 0 will be valid.Function
setAlias()
also doesn't check that_dIndex < count
. This could allow setting an alias for futureroot
s.For consistency it good to check for this.
Recommendation
Consider disallowing
_dIndex >= count
in functionsclaim()
andsetAlias()
.Incorrect use of _validateDuration() in transferRegistration()
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Function
transferRegistration()
checks_duration
for validity but in the situation ofdstBind.amount ! = 0
,_duration
indicates a duration extention which is added todstBind.endTime
.This way it is not possible to extend the
duration
with an amount smaller thanMIN_LOCK_DURATION
(7 days)Recommendation
Consider changing the code to something like:
function transferRegistration(...) ... { ...- if (_duration > 0) {- _validateDuration(_duration);- } ...}function _transferRegistration( ... if (dstBind.amount == 0) { ...+ _validateDuration(_duration); ... } else { ... if (_duration > 0) {+ _validateDuration(dstBind.endTime + _duration - block.timestamp); unchecked { dstBind.endTime = _min(_lockEndTime, dstBind.endTime + _duration); } } ... }}
Function withdraw() doesn't always remove processed entries
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Function
withdraw()
processes the first 20namehash
s and remove them from the list when processed so the next call towithdraw()
will process the next 20.However in the edge case that
bind.amount == 0
, the entries are not removed, which could potentially lead to not being able to process all entries.This situation shouldn't happen but it still safer to always remove the processed entries.
Recommendation
Consider always doing
_userBinds[].remove(...);
.Function manageLock() doesn't emit BindCreated
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Function
_transferRegistration()
emitsBindCreated
for all destionations when_binds[][].amount
is set.However function
manageLock()
doesn't do this when_binds[][DEFAULT_REGISTRATION_NAMEHASH].amount
is set.Recommendation
Consider doing an
emit BindCreated()
in functionmanageLock()
.Difference between emit LockIncreased() and emit BindAmountIncreased()
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Function
manageLock()
emits the final value (e.g.lock.amount
) inemit LockIncreased()
. However function_transferRegistration()
emits the increase (e.g._maxAmount
) inemit BindAmountIncreased()
. This is inconsistent.Recommendation
Consider changing the code to:
-emit BindAmountIncreased(_user, _dstNamehash, _maxAmount, dstBind.endTime);+emit BindAmountIncreased(_user, _dstNamehash, dstBind.amount, dstBind.endTime);
Not all upgradable contracts have _disableInitializers()
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Not all upgradable contracts have
_disableInitializers()
. This is recommended to use on the template contracts to prevent aunautorized parties to initialize the contracts and potential selfdestruct them on chains where this is still possible.Recommendation
Consider adding the following to
HyperAccountMinterUpgradable
,HyperAccountUpgradable
,Hypermap
.constructor() { _disableInitializers();}
Check in function _isValidLabel() isn't robust
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The check in function
_isValidLabel()
isn't robust. Whenlen == 0
andoffset == 1
then thisif
won't trigger.In the current code this isn't an issue, but it potentially could be with future changes. It could be made more robust.
Recommendation
Consider changing the code to:
-if (len == offset) revert LabelTooShort();+if (offset >= len) revert LabelTooShort();
Function supportsInterface() doesn't include functions of current contract
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Function
supportsInterface()
ofHypermap
doesn't include the functions of the current contract.Recommendation
Consider changing the code to:
-return super.supportsInterface(interfaceId);+return super.supportsInterface(interfaceId) || interfaceId == type(IHypermap).interfaceId;
msg.sender in CommitHash() and Account Abstraction / mintBySignature()
State
- Fixed
PR #77
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The
_getCommitHash()
could be guessed when using Account Abstraction because themsg.sender
is always the same and the name could be a well known name. That way the commit could be front run.Additional in case of Account Abstraction and because the
msg.sender
is always the same, an NFT can be minted from someone else.On the other hand when using
mintBySignature()
, themsg.sender
could be any party so might not match the commit.Recommendation
For Account Abstractions consider storing
userOp.sender
inAccount::_validateSignature()
in transient storage and use that as thesender
when verifying the commit.For
mintBySignature()
use thesigner
as thesender
when verifying the commit.Hyperware
Solved for
_mintBySignature()
and acknowledged for Account Abstraction.msg.sender of withdraw() might be unexpected
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
In function
withdraw()
funds are send to themsg.sender
, however themsg.sender
could have an unexpected value.In case of
account abstraction
themsg.sender
is theEntryPoint
.If the funds are send there then the orignal sender doesn't receive them. Although the funds can be retrieved via exec() and
EntryPoint::withdrawStake()
.Recommendation
Consider doing one of the following:
- specify a destination
- when
msg.sender == entrypoint
: revert and retrieve if viaexec()
- when
msg.sender == entrypoint
: send it to the owner of the NFT - store
userOp.sender
inAccount::_validateSignature()
in transient storage and use that as destination
msg.sender of HyperAccountPermissionedMinter::_mint() might be unexpected
State
- Fixed
PR #77
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
In function
HyperAccountPermissionedMinter::_mint()
actions are taken based on themsg.sender
, however themsg.sender
could have an unexpected value.In case of Account Abstraction the
msg.sender
is theentryPoint
. In case of_mintBySignature()
thenmsg.sender
can be any user.Recommendation
For Account Abstraction consider storing
userOp.sender
inAccount::_validateSignature()
in transient storage and use that as the parameter for the_allowances[]
.For
mintBySignature()
use thesigner
as the parameter for the_allowances[]
.Hyperware
Solved for
_mintBySignature()
and acknowledged for Account Abstraction.HyperAccountPaidMinter::_mint() won't work with mintBySignature
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
HyperAccountPaidMinter::_mint()
isn'tpayable
so it won't work withmintBySignature
.However its unlikely that someone else will pay, unless it is the
entrypoint
.Recommendation
Doublecheck to usefulness of
mintBySignature
in combination withHyperAccountPaidMinter::_mint()
. If this is relevant make the functionmintBySignature
payable
.Hyperware
Acknowledged. This combination will not be used.
_initialized is overwritten
State
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
The variable
_initialized
ofHyperAccountProxy
is located onstorage
location 0. So an implementation contract, which is used viadelegatecall
, is very likely to overwrite this.For example
HyperAccountMinterUpgradable
will overwrite this with_nonce[][]
that is also located onstorage
location 0.Due to the other checks in
initialize()
its unlikely this will cause issues, however if still safer to solve this.Recommendation
Consider doing one of the following:
- eliminate the use of
_initialized
, this can be done because the checks withmsg.sender == HYPERMAP
and_implementation() == address(0)
are sufficient; - use a ERC-7201 namespace.
supportsInterface() not implemented everywhere
State
- Fixed
PR #77
Severity
- Severity: Low
Submitted by
Gerard Persoon
Description
Several contracts don't extend the
supportsInterface()
function with their owninterfaceId
.Recommendation
Consider also extending
supportsInterface()
in the attached contracts.Flawed encoding due to dynamic types allow an attacker to pass signature verification using malicious values
State
Severity
- Severity: Low
Submitted by
hash
Description
The message hash is currently constructed as
keccak256(abi.encodePacked(to, address(this), _nonce[signer], name, initialization, implementation, signer));
Since both
name
andinitialization
are of dynamic type, the same final encoded bytes can be attributed to multiple combinationsEg:
bytes name1 = "hello"; bytes initialization1 = " world"; bytes name2 = "he"; bytes initialization2 = "llo world"; constructor() { // both results in `hello world` console.logBytes32(keccak256(abi.encodePacked(name1, initialization1))); // 0x471732... console.logBytes32(keccak256(abi.encodePacked(name2, initialization2))); // 0x471732... }
Hence this allows an attacker to pass the signature verification using values different from what was signed
Recommendation
Use hashes of
name
andinitialization
insteadkeccak256(abi.encodePacked(to, address(this), _nonce[signer], keccak256(name), keccak256(initialization), implementation, signer))
Lack of protection against gene updation can cause some user's to pay unwillingly
State
- Acknowledged
Severity
- Severity: Low
Submitted by
hash
Description
In
HyperAccountPaidMinter
, users are supposed to pay in-order to mint. And in case the parent doesn't have any gene set, they expect theTBA
to be created with the passed inimplementation
.function _mint(address to, bytes calldata name, bytes calldata initialization, address implementation) internal override returns (address tba) { if (msg.value != currentPrice) revert IncorrectPayment(); return _HYPERMAP.mint(to, name, initialization, implementation); }}
But if the parent sets a gene in between an user viewing the current state and the execution of the user's transaction, it will cause the
TBA
to be created with thegene
instead of the passed inimplementation
causing users to unwillingly pay.function mint(address to, bytes calldata label, bytes calldata initialization, address implementation) external isName(label) returns (address tba) { ....... if (map[parent].gene != address(0)) { implementation = map[parent].gene; setGene = true; }
Recommendation
In case the
implementation
andgene
are both non-zero and is not the same, instead of overwriting the implementation revert the execution.Hyperware
Acknowledged. The timing is indeed an undesirable edge-case, but the behavior as it exists is clearly specified throughout our discussion of the feature (i.e. that an implementation given by the minter will be overridden).
Approved transfers can be DOS'd since 3rd party claiming is allowed
State
Severity
- Severity: Low
Submitted by
hash
Description
In order to perform an approved transfer, the user's total claimed amount must equal the approved
requiredClaimedAmount
function transfer(address to, uint256 amount) public override(IERC20, ERC20) returns (bool) { => if (params.used || params.to != to || params.amount != amount || userTotalClaimed_[msg.sender] != params.requiredClaimedAmount) { return false; }
This is flawed as any user can claim on behalf of another user increasing their
totalClaimed[]
. This will make the user unable to perform the approved transfersfunction claim(address _user) external { uint256 _balance = balanceOf(_user); uint256 _claimed = userTotalClaimed_[_user]; uint256 _available = _availableToClaim(_balance, _claimed); if (_available != 0) {=> userTotalClaimed_[_user] += _available; totalClaimed += _available;
Another way a transfer can get disabled (without the owner themselves making claims) is via an inbound transfer which increases the
userTotalClaimed_[]
. But this should be rareRecommendation
Either disallow claiming on behalf of others or remove the
requiredClaimedAmount
condition for transfersRequired preconditions in _transferRegistration are not suitable for pure duration extensions
State
Severity
- Severity: Low
Submitted by
hash
Description
In order to extend the duration of a binding, the user has to make use of the
_transferRegistration
function. But the kept preconditions are that the source binding should be expired (or be DEFAULT_REGISTRATION_NAMEHASH) and have non-zero balancefunction _transferRegistration( address _user, uint256 _lockEndTime, bytes32 _srcNamehash, bytes32 _dstNamehash, uint256 _maxAmount, uint256 _duration ) internal { Bind storage srcBind = _binds[_user][_srcNamehash]; if (_srcNamehash != DEFAULT_REGISTRATION_NAMEHASH && srcBind.endTime > block.timestamp) { revert SourceNotExpired(_srcNamehash, srcBind.endTime); } uint256 availableBalance = srcBind.amount; if (availableBalance == 0) revert InsufficientBindAmount(_srcNamehash, 0, _maxAmount);
This disallows extending the duration of a binding in case there are no other expired binding (or DEFAULT_REGISTRATION_NAMEHASH binding) with non-zero balance
Recommendation
Add a separate function that handles just the duration extension
Informational23 findings
_totalSupply could be replaced with totalSupply()
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The constructor of
HyperToken
tracks the total number of tokens minted via_totalSupply
. However the ERC20 contract also tracks this intotalSupply()
, so this could be used instead. This would simplify the code.Recommendation
Consider replacing
_totalSupply
withtotalSupply()
, in the following way:constructor(...) ... { ...- uint256 _totalSupply; ... for (uint256 i = 0; i < len; ) { ...- unchecked {- _totalSupply += amount;- } ... }- if (_totalSupply != MAX_SUPPLY) {+ if (totalSupply() != MAX_SUPPLY) { revert InvalidTotalSupply(); }}
Limiting transfers doesn't fully stop selling vHYPR
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function
transfer()
tries to limit the the sell ofvHYPR
tokens by limiting the transfer. However there are others ways to make thevHYPR tokens
that originate from an merkle drop liquid/transferable.The
vHYPR
tokens can beclaim()
ed into a seperate contract that is an ERC20/ERC4626/ERC6551 contract, and then ownership of these contracts, or shares can be transferred.Recommendation
Consider adding a comment to the code for some who reuses the code and isn't aware of this.
Entries in transferRoleParams[] can be deleted after use
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
HyperVestingToken
flag that a version oftransferRoleParams[]
is used by settingparams.used = true
. However the params are not used afterward so could also be deleted.Recommendation
Consider removing
transferRoleParams[]
once it is used.Checks on transferRoleParams[] could be more thorough
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The values of
transferRoleParams[]
are compared to the supplied parameters. However it isn't explicitly checked the enty isn't empty.Due to this a transfer with
to==0
andamount==0
could be accepted, alhough it doesn't do a meaningful action.Currently the check for
to==0
in_transfer()
prevents this, but its safer to an explicit check.Recommendation
Consider adding
|| to == 0
to the check:-if (... || params.to != to || ... ) ...+if (... || params.to != to || to == 0 || ... ) ...
Function transfer() could divide by 0
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
If the
balanceOf(msg.sender)
would be 0, and then L115 would revert due to a divsion by zero without a clear error message. Although this situation is unlikely it could be detected and reverted with a better error message.Recommendation
Consider detecting the situation where
balanceOf(msg.sender) == 0
.Two functions named claim()
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
There are two functions named
claim()
, one inMerkleDistributor
and one inHyperVestingToken
.A user should first do
claim()
on theMerkleDistributor
and then lateronclaim()
on theHyperVestingToken
. This might be confusing to documentRecommendation
Consider changing the name in
HyperVestingToken
to something likeconvert()
. Also consider renaming all other references toclaim
in that contract.Use of super not necessary
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The function
claim()
usessuper
, but it doesn'toverride
the function, sosuper
isn't necessary.Recommendation
Consider changing the code to:
-super._burn(_user, _balance);+_burn(_user, _balance);
Edge case when block.timestamp == vestingCliffTimestamp == vestingEndTimestamp
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Edge case: when
block.timestamp == vestingCliffTimestamp == vestingEndTimestamp
then function_totalVestedTokens()
will return 0. However in other situations whereblock.timestamp == vestingEndTimestamp
it will return_vestingTokenBalance
.This will most likely be negligible because the full amount will be present in the next call, after one second.
Recommendation
If you do want to change this, consider checking for
vestingEndTimestamp
first.Error message in function claim() not accurate
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
An error message in function
claim()
indicates all tokens are claimed. However the situation only indicates there are insufficient tokens.Recommendation
Consider changing the code to:
-revert AllClaimed(_dIndex, root.totalAmount);+revert NotEnoughTokensLeft(_dIndex, root.totalAmount);
Not immediately clear why unchecked calculation is safe
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Function does an unchecked calculation on
_binds[][].amount
. It is not immediately clear why this is safe.Recommendation
Consider add a comment that
_amount
won't be so large to induce an overflow because that would revert insafeTransferFrom()
.Solidity style guide not followed
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Functions in Hypermap are not ordered in the recommended way by the Solidity style guide.
This makes the code more difficult to read.
Recommendation
Consider changing the order of the functions.
Events don't show the hash
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Several events show the unhashed version of a variable twice, instead of showing both the hashed and the unhashed version.
Recommendation
Consider changing the events to:
-event <eventname>(..., bytes indexed labelhash, ...);+event <eventname>(..., bytes32 indexed namehash, ...);
And hash the values that are put in the emit.
genes and upgradeable contracts
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
Genes are meant to enforce the same implementation contract for the current TBA/leaf and all TBA/leafs below it.
However some contracts allow upgrades and thus should not be used in combination with genes:
- contracts that have
_authorizeUpgrade()
- contracts that have
delegatecall
Recommendation
Document that contracts that enable upgrades are not used in combination with gene.
Duplicate code for _exec()
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The function
_exec()
is duplicated only to have a differentmodifier
. Code duplication makes the code more difficult to maintain, which is especially important for a core function like_exec()
.Recommendation
Consider having a function that is used in a
modifier
that can be overriden to prevent code duplicationSignature with v==0
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
In several other implementation of signature checks, they allow for values of 0, 1 of v as valid values. If such a value would be present then the signature would not be interpreted correctly.
Recommendation
Consider using a different signal value for
v
. Check EIP 155 for possible values.Hyperware
Acknowledged. No action at this time.
Checks on _minCommitAge and _maxCommitAge
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The constructor of
HyperAccountCommitMinter
doesn't do any checks on_minCommitAge
and_maxCommitAge
.However there should be a reasonable time between
_minCommitAge
and_maxCommitAge
to be able to mint at all.Recommendation
Consider checking something like:
_maxCommitAge - _minCommitAge > 1 days
.Use storage variables from address 0 and use of __gaps
State
- Fixed
PR #77
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The upgradable contracts use
storage
starting from slot 0 and use__gaps
. This is cumbersome and allows for storage collisions. Some contract are upgradeable but don't have__gaps
, these are:HyperAccountCommitMinter
andHyperAccountMinterUpgradable
.Most recent contracts use the EIP-7201 storage pattern. This also removes the need for
__gaps
.Recommendation
Consider using the EIP-7201 storage pattern for all the contracts the use upgradability, including the
HyperAccountProxy
.mintBySignature() and reverting _mint()
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
In function
mintBySignature()
, when_mint()
reverts, the_nonce[]
increase is also undone. If the_mint()
always reverts, then no further_mint()
s can be done until a newsignature
is made using the samenonce
._mint()
could revert in different ways, see the attached code.Recommendation
Consider documenting how a reverting
_mint()
should be handled by the account usingmintBySignature()
.Unused imports
State
Severity
- Severity: Informational
Submitted by
Gerard Persoon
Description
The import of
IERC721
isn't used inMech
.Recommendation
Consider removing the unused imports.
No-op inside initialize function
State
Severity
- Severity: Informational
Submitted by
hash
Description
Since the
DEFAULT_ADMIN_ROLE
is never granted to msg.sender,_revokeRole(DEFAULT_ADMIN_ROLE, msg.sender);
is a no-op.Recommendation
Remove the redundant line
- _revokeRole(DEFAULT_ADMIN_ROLE, msg.sender);
BindCreated/BindAmountIncreased events are not emitted inside manageLock
State
Severity
- Severity: Informational
Submitted by
hash
Description
The
BindCreated/BindAmountIncreased
events are not emitted inside manageLock when the default binding is created for the first time or amount added to itRecommendation
Emit the events or be consistent and not emit events for default binding always
Bind removal doesn't emit event
State
Severity
- Severity: Informational
Submitted by
hash
Description
When a bind is removed, currently no event is emitted
Recommendation
Emit an event
Custom error doesn't have named parameters
State
Severity
- Severity: Informational
Submitted by
hash
Description
The
InsufficientLockAmount
error doesn't have named parametersRecommendation
Add named parameters
Gas Optimizations11 findings
More gas efficient function can be used in claim()
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Function claim() uses
MerkleProof.verify()
A more gas efficient function of this function is also present.Recommendation
Consider using
verifyCalldata()
:-if (!MerkleProof.verify(...)) { ... }+if (!MerkleProof.verifyCalldata(...)) { ... }
A more gass efficient version of ReentrancyGuardUpgradeable can be used
State
- Fixed
PR #77
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
There is a more gass efficient version of
ReentrancyGuardUpgradeable
which can be used.Recommendation
Consider using ReentrancyGuardTransientUpgradeable.
Redundant call to contains()
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
In function
manageLock()
callscontains()
before doingadd()
. Howeveradd()
also does this, so the check is redundant.Recommendation
Consider changing the code to:
- if (!_userBinds[msg.sender].contains(DEFAULT_REGISTRATION_NAMEHASH)) { _userBinds[msg.sender].add(DEFAULT_REGISTRATION_NAMEHASH);-}
Function withdraw() updates the storage variable defaultBind.amount` in a loop
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Function
withdraw() updates the storage variable
defaultBind.amount` in a loop, which is relatively expensive.Recommendation
Considure using a temporaty variable, for example in the following way.
function withdraw() external nonReentrant { ... Bind storage defaultBind = _binds[msg.sender][DEFAULT_REGISTRATION_NAMEHASH];+ uint256 amount = defaultBind.amount; ... for (uint256 i = 0; i < workingSize; ++i) { ... - defaultBind.amount += bindAmount;+ amount += bindAmount; ... }- uint256 amount = defaultBind.amount; ... // use amount defaultBind.amount = 0; ...}
entry.data always empty in get() of a name
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
If the
namehash
is aname
then the third return value (entry.data) is always empty. So this function could return an empty bytes array. This makes the code easier to understand and also saves some gas.Recommendation
Consider changing the code to:
-return (entry.tba, ownerOf(uint256(namehash)), entry.data);+return (entry.tba, ownerOf(uint256(namehash)), bytes(""));
Assembly blocks don't use "memory-safe"
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
The assembly blocks don't use "memory-safe". Having these allows the compiler to generate more efficient code.
Recommendation
Consider using the following:
-assembly { ... }+assembly ("memory-safe") { ... }
Function leaf() can be optimized
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
There are several ways to optimize the assembly code in
leaf()
:- the extra
add(..., 0x20)
on L263 doesn't seem necessary; - the second
keccak256
can use thescratch space
of address0x00..0x39
; - because
label
is limited to maximum 64 bytes, they can also fit in thescratch space
.
Recommendation
The three optimizations can be implemented in the following way:
function leaf(bytes32 parenthash, bytes calldata label) public pure returns (bytes32 namehash) { if (label.length >= 64) revert(); // important to prevent overwrite of address 0x40 return _leaf(parenthash,label);) // internal version without the length check, which can be called from mint, fact, notefunction _leaf(bytes32 parenthash, bytes calldata label) internal pure returns (bytes32 namehash) { assembly ("memory-safe") { calldatacopy(0x00, label.offset, label.length) let labelhash := keccak256(0x00, label.length) mstore(0x00, parenthash) mstore(0x20, labelhash) namehash := keccak256(0x00, 0x40) }}
Function _isOperatorOrDelegatee() can be simplified
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Function
get()
always returns an emptyowner
for anode
, so function_isOperatorOrDelegatee()
can be simplified.Recommendation
Consider changing the code to:
-(, address owner, bytes memory data) = _HYPERMAP.get(alLeaf);+(,, bytes memory data) = _HYPERMAP.get(alLeaf);...-if (owner == address(0)) { owner = IERC721(tokenContract).ownerOf(tokenId);-}
Duplication of the last element can be done before the loop
State
- Fixed
PR #77
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
The duplication of the last element can also be done before the loop (although execution speed difference is probably neclectible)
Recommendation
Consider changing the code to something like:
while len(level) > 1: next_level = []+ # If there's an odd number of elements, duplicate the last one+ if len(level) % 2 == 1:+ level.append(level[-1]) for i in range(0, len(level), 2): left = level[i]- # If there's an odd number of elements, duplicate the last one- right = level[i + 1] if i + 1 < len(level) else left+ right = level[i + 1] parent = standard_node_hash(left, right) next_level.append(parent) level = next_level
Also consider doing this in
generate_proof()
Function generate_proof() can be simplified
State
Severity
- Severity: Gas optimization
Submitted by
Gerard Persoon
Description
Function
generate_proof()
uses the arrayindexes[]
which makes the code more difficult to understand.Also the calculation of
target_index
can be simplified.Recommendation
Consider changing the code to something like:
while len(level) > 1: next_level = [] for i in range(0, len(level), 2): left = level[i] right = level[i + 1] if i + 1 < len(level) else left # Add to the next level next_level.append(standard_node_hash(left, right)) # If this is the target, add the sibling to the proof if i == target_index: proof.append(right) elif i + 1 < len(level) and i + 1 == target_index: proof.append(left) target_index //= 2 level = next_levelreturn proof
Extra condition added to always allow default binding can be removed
State
Severity
- Severity: Gas optimization
Submitted by
hash
Description
Since
DEFAULT_REGISTRATION_NAMEHASH
binding will always have endTime == 0 (which will be < block.timestamp), the single condition ofsrcBind.endTime > block.timestamp
will disallow expired bindings while always allowing the default binding. Hence the extra condition added to explicitly allow the default binding is unnecessaryRecommendation
Remove
_srcNamehash != DEFAULT_REGISTRATION_NAMEHASH