Security Audit
September 19, 2024
Version 1.0.0
Presented by 0xMacro
This document includes the results of the security audit for Heroglyphs's smart contract code as found in the section titled ‘Source Code’. The security audit was performed by the Macro security team from September 11 to 17, 2024.
The purpose of this audit is to review the source code of certain Heroglyphs Solidity contracts, and provide feedback on the design, architecture, and quality of the source code with an emphasis on validating the correctness and security of the software in its entirety.
Disclaimer: While Macro’s review is comprehensive and has surfaced some changes that should be made to the source code, this audit should not solely be relied upon for security, as no single audit is guaranteed to catch all possible bugs.
The following is an aggregation of issues found by the Macro Audit team:
Severity | Count | Acknowledged | Won't Do | Addressed |
---|---|---|---|---|
High | 2 | - | - | 2 |
Medium | 2 | - | - | 2 |
Low | 2 | - | - | 2 |
Code Quality | 5 | - | - | 5 |
Heroglyphs was quick to respond to these issues.
Our understanding of the specification was based on the following sources:
owner
: Multi-signature protocol account (3/5 sigs required).owner
address on deployment. (Receiver and Sender)feeBps
, setting the treasury
, validator
, and nextRound
fees. All values combined can not exceed 10_000
BPS, representing 100% of the lot. (Receiver)minimumnBlockAge
, which represents the minimum age of the block guess. (Receiver)groupSize
, which represents the size of each block guessed. Initially set to 10
blocks. (Receiver)permanentlySetDripVault
to prevent further vault migrations. (Receiver)lzGasLimit
used in lzSend()
cross-chain message. (Sender)gob
address, authorizing the GOB contract to deposit and withdraw from the vault. (Vault)delegate
: Set it to the same address as the owner
during initialization.lzEndpoint
: LayerZero entry and exit point contract for cross-chain messages. It will receive the request sent by the GuessOurBlockSender
contract and process and execute the lzReceive
in GuessOurBlockReceiver
through a valid executor.relay
: Protocol contract that processes and triggers each valid Herohlyph block with their proper graffiti data and executes tickers contracts.onValidatorTriggered()
function.deposit
and withdraw
interactions.GuessOurBlockReceiver
fees will be set to a fair value, and the owner is entrusted with not setting these maliciously, as the treasury
tax can be set to 100% of the value.The following source code was reviewed during the audit:
d359658e2f5b9929b51968a6395c197b93c44e74
Specifically, we audited the following contracts within this repository:
Source Code | SHA256 |
---|---|
./src/GuessOurBlockReceiver.sol |
|
./src/GuessOurBlockSender.sol |
|
./src/IGuessOurBlock.sol |
|
./src/dripVaults/BaseDripVault.sol |
|
./src/dripVaults/IDripVault.sol |
|
./src/dripVaults/implementations/AaveVault.sol |
|
./src/dripVaults/implementations/apxETHVault.sol |
|
Note: This document contains an audit solely of the Solidity contracts listed above. Specifically, the audit pertains only to the contracts themselves, and does not pertain to any other programs or scripts, including deployment scripts.
Click on an issue to jump to it, or scroll down to see them all.
apxETHVault
does not consider redemption fees and uses maximum amounts on each withdrawal
lzEndpointReceiverId
fullWeightCost
can be immutable
safeTransfer()
receive()
function
treasury
address setter
We quantify issues in three parts:
This third part – the severity level – is a summary of how much consideration the client should give to fixing the issue. We assign severity according to the table of guidelines below:
Severity | Description |
---|---|
(C-x) Critical |
We recommend the client must fix the issue, no matter what, because not fixing would mean significant funds/assets WILL be lost. |
(H-x) High |
We recommend the client must address the issue, no matter what, because not fixing would be very bad, or some funds/assets will be lost, or the code’s behavior is against the provided spec. |
(M-x) Medium |
We recommend the client to seriously consider fixing the issue, as the implications of not fixing the issue are severe enough to impact the project significantly, albiet not in an existential manner. |
(L-x) Low |
The risk is small, unlikely, or may not relevant to the project in a meaningful way. Whether or not the project wants to develop a fix is up to the goals and needs of the project. |
(Q-x) Code Quality |
The issue identified does not pose any obvious risk, but fixing could improve overall code quality, on-chain composability, developer ergonomics, or even certain aspects of protocol design. |
(I-x) Informational |
Warnings and things to keep in mind when operating the protocol. No immediate action required. |
(G-x) Gas Optimizations |
The presented optimization suggestion would save an amount of gas significant enough, in our opinion, to be worth the development cost of implementing it. |
apxETHVault
does not consider redemption fees and uses maximum amounts on each withdrawal
The apxETHVault.sol
contract, a variant of a drip vault integrating with Dinero Pirex ETH, uses the native asset deposits with the auto compounding flag true to accrue yield while holding the GOB lot until a winning block, with a previously set guess to be mined by a Heroglyphs validator.
function _afterDeposit(uint256 _amount) internal override {
pirexEth.deposit{ value: _amount }(address(this), true);
}
Reference: apxETHVault.sol#L37-39
When a winning block is selected, the vault is migrated, or a user claims a winning guess, the GOB contract will withdraw from the vault, which in turn executes the _beforeWithdrawal()
logic in the apxETHVault contract:
function _beforeWithdrawal(address _to, uint256 _amount) internal override {
**uint128 exitedPx = uint128(apxETH.redeem(apxETH.maxRedeem(address(this)), address(this), address(this)));**
uint256 interestInPx;
uint256 cachedTotalDeposit = getTotalDeposit();
**uint256 amountInPx = apxETH.convertToShares(_amount);**
uint256 exitedInETH = apxETH.convertToAssets(exitedPx);
//Shares scales down, in full exit, we might find less than the total deposit
if (exitedInETH > cachedTotalDeposit) {
interestInPx = apxETH.convertToShares(exitedInETH - cachedTotalDeposit);
}
_transfer(address(pxETH), rateReceiver, interestInPx);
_transfer(address(pxETH), _to, amountInPx);
if (cachedTotalDeposit - _amount != 0) {
apxETH.deposit(pxETH.balanceOf(address(this)), address(this));
} else {
// Transfer the remaining balance of pxETH to the rateReceiver, left over from shares conversion
_transfer(address(pxETH), rateReceiver, pxETH.balanceOf(address(this)));
}
}
Reference: apxETHVault.sol#L41-63
However, the logic does not consider the apxETH → pxETH
redeem fees, currently set at 0,03% (but can be changed by the Pirex ETH owner). This causes some undesired outcomes:
As the Vault redeems the maximum balance (maxRedeem()
) on each withdrawal:
validator
and treasury
fee are set, each withdrawal will pay for the 0,03% of the total.Additionally, depending on how much yield has been accrued between deposits and withdrawals:
amountInPx
is calculated using _amount
and not the balance received after redeeming.Note that not accounting for the actual received balances could cause rounding issues to deny withdrawals. For example, the current test_onRun_thenFlowWorks()
test in GOBPxETHE2E.t.sol
currently reverts when updating the vault since the asset-to-share conversion of the total deposits has a 1
wei less discrepancy with the redeemed balance.
Remediations to Consider:
pxETH
.apxETH
and allow the end receiver to handle redeem or withdrawal fees.After verifying the contract & docs, there is no fee on exit (redeeming) from apxETH vault since it returns in pxETH. pxETH is the one with the withdrawal fee (pxETH → ETH) https://dinero.xyz/docs/fees
But for:
- Efficiency & cleaner code-base: less operation to do.
- Better UX: user are still inside the yield pool.
- Security: withdrawal fee can be modified by owner.
We are sending apxETH back to the user instead of pxETH.
When a Vault migration is initiated through the updateVaultDrip()
ownable function, all the current deposits are withdrawn to the treasury address to properly handle withdrawal delays and move the assets to the new vault:
function updateDripVault(address _dripVault) external onlyOwner {
...
uint256 totalDeposit = dripVault.getTotalDeposit();
dripVault.withdraw(treasury, totalDeposit);
...
}
Reference: GuessOurBlockReceiver.sol#L209-221
In AaveVault.sol
, the _beforeWithdrawal()
function withdraws the maximum withdrawal value from the pool to calculate the interest and then re-supply the difference between the total deposits and the amount.
function _beforeWithdrawal(address _to, uint256 _amount) internal override {
**uint128 exited = uint128(aaveV3Pool.withdraw(address(weth), type(uint256).max, address(this)));**
uint256 cachedTotalDeposit = getTotalDeposit();
uint256 interest = exited - cachedTotalDeposit;
**aaveV3Pool.supply(address(weth), cachedTotalDeposit - _amount, address(this), 0);**
weth.withdraw(_amount + interest);
_transfer(address(0), rateReceiver, interest);
_transfer(address(0), _to, _amount);
}
Reference: AaveVault.sol#L40-52
Since Aave V3 pools require the amount to be different from zero in the supply()
validation logic, the contract will revert with error code “26,” representing an invalid amount. If the initial vault is set to use the AaveVault
implementation, any attempt to update the vault will result in a revert.
Remediations to Consider:
Verify if the total amount was used in withdraw()
and skip the supply()
call if so.
When calling the updateDripVault()
ownable function, the total deposits corresponding to the lot will be withdrawn to the treasury
address, as mentioned in the comments, due to some vault drips' withdrawal delays.
function updateDripVault(address _dripVault) external onlyOwner {
if (permanentlySetDripVault) revert CanNoLongerUpdateDripVault();
if (_dripVault == address(0)) revert DripVaultCannotBeZero();
uint256 totalDeposit = dripVault.getTotalDeposit();
dripVault.withdraw(treasury, totalDeposit);
dripVault = IDripVault(_dripVault);
isMigratingDripVault = true;
emit DripVaultUpdated(_dripVault);
emit DripVaultMigrationStarted();
}
Reference: GuessOurBlockReceiver.sol#L209-221
This function will set the isMigratingDripVault
bool to true. However, no checks in _lzReceive()
consider this variable, potentially causing the newly updated vault to revert if a block wins and the contract attempts to withdraw fees or pay the winner.
Remediations to Consider
Consider checking the isMigratingDripVault
and returning early if it’s set to true.
lzEndpointReceiverId
As per the LayerZero V2 documentation, the endpoint IDs should not be hard coded, and access control setters should be used to prevent potential integrator changes from breaking the application.
Remediations to Consider:
Consider implementing an onlyOwner
setter for the lzEndpointReceiverId
.
In GuessOurBlockSender
the latestMintedBlock
is checked against the passed _blockNumber
and returns early if higher.
function onValidatorTriggered(uint32, uint32 _blockNumber, address _validatorWithdrawer, uint128 _heroglyphFee)
external
override
onlyRelay
{
// return instead a revert for gas optimization on Heroglyph side.
if (latestMintedBlock > _blockNumber) return;
...
}
Reference: GuessOurBlockSender.sol#L30-47
However, since each Heroglyph validated block can include in the graffiti the same ticker multiple times, it could cause the onValidatorTriggered()
function to be called multiple times with the same block, sending a cross-chain message that will return early as the block will be completed on the first call.
Remediations to Consider:
Two potential owner configuration setters could cause undesired revert scenarios, breaking the contract:
Configuring minimumBlockAge
to zero:
Configuring groupSize
variable to zero will:
_lzReceive()
to revert in #114:
uint32 blockNumberTail = blockNumber - (blockNumber % groupSize);
_guess()
to revert on the _isValidTailBlockNumber()
check in line #98:
return _tailBlockNumber % groupSize == 0;
Remediations to Consider:
Consider having minimum bound values for these configuration setters.
fullWeightCost
can be immutable
In GuessOurBlock
sender, the variable fullWeightCost
can be declared immutable since it’s only set in the constructor.
We are aware that changing this value might bring unfair situation, but to be safe that the price stays affordable, we going to make it editable by owner.
The GuessOurBlockReceiver
contract logic has the guess weight unit value hardcoded as 1e18
when calculating the guessWeight
and reducedLot
:
uint128 guessWeight = uint128(Math.mulDiv(_nativeSent, **1e18**, fullWeightCost));
Reference: GuessOurBlockReceiver.sol#L88
if (blockMetadata.totalGuessWeight < **1e18**) {
uint128 reducedLot = uint128(Math.mulDiv(winningLot, blockMetadata.totalGuessWeight, **1e18**));
Reference: GuessOurBlockReceiver.sol#L133-134
Consider declaring a constant variable for this to increase readability and make its usage straightforward.
safeTransfer()
In BaseDripVault.sol
contract, the _transfer()
internal function is used to transfer native assets or ERC20 tokens depending on the _asset
address input:
function _transfer(address _asset, address _to, uint256 _amount) internal {
if (_amount == 0) return;
if (_asset == address(0)) {
(bool success,) = _to.call{ value: _amount }("");
if (!success) revert FailedToSendETH();
} else {
IERC20(_asset).transfer(_to, _amount);
}
}
Reference: BaseDripVault.sol#L39-48
This function uses the standard IERC20.transfer()
. Consider using safeTransfer()
instead to handle potential non-standard transfers, such as token transfers that do not revert to failed transfers.
receive()
function
In GuessOurBlockReceiver.sol
contract, the receive()
function allows the dripVault
to send native assets back again to the contract.
receive() external payable {
if (msg.sender != address(dripVault)) revert InvalidSender();
}
However, the logic will always withdraw to the proper end without going to the GuessOurBlockReceiver
. The claim()
function will send the assets directly to the user. When executing the lzReceive()
logic or claiming, they are sent to other addresses (validator, treasury, or claimants), not back to this contract. Consider removing this logic, as it’s unnecessary with the current vaults and logic.
treasury
address setter
In GuessOurBlockReceiver.sol
contract, the treasury
address variable is only set in the constructor. Although not explicitly immutable, this address can not be changed by any method. Consider having an onlyOwner
function that allows this address to be updated in case of a potential misconfiguration or migration of treasury or marking this address as immutable.
Macro makes no warranties, either express, implied, statutory, or otherwise, with respect to the services or deliverables provided in this report, and Macro specifically disclaims all implied warranties of merchantability, fitness for a particular purpose, noninfringement and those arising from a course of dealing, usage or trade with respect thereto, and all such warranties are hereby excluded to the fullest extent permitted by law.
Macro will not be liable for any lost profits, business, contracts, revenue, goodwill, production, anticipated savings, loss of data, or costs of procurement of substitute goods or services or for any claim or demand by any other party. In no event will Macro be liable for consequential, incidental, special, indirect, or exemplary damages arising out of this agreement or any work statement, however caused and (to the fullest extent permitted by law) under any theory of liability (including negligence), even if Macro has been advised of the possibility of such damages.
The scope of this report and review is limited to a review of only the code presented by the Heroglyphs team and only the source code Macro notes as being within the scope of Macro’s review within this report. This report does not include an audit of the deployment scripts used to deploy the Solidity contracts in the repository corresponding to this audit. Specifically, for the avoidance of doubt, this report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. In this report you may through hypertext or other computer links, gain access to websites operated by persons other than Macro. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such websites’ owners. You agree that Macro is not responsible for the content or operation of such websites, and that Macro shall have no liability to your or any other person or entity for the use of third party websites. Macro assumes no responsibility for the use of third party software and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.