Reach out for an audit or to learn more about Macro
or Message on Telegram

Heroglyphs A-1

Security Audit

September 19, 2024

Version 1.0.0

Presented by 0xMacro

Table of Contents

Introduction

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.

Overall Assessment

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.

Specification

Our understanding of the specification was based on the following sources:

Trust Model, Assumptions, and Accepted Risks (TMAAR)

Actors

Assumptions

Source Code

The following source code was reviewed during the audit:

Specifically, we audited the following contracts within this repository:

Source Code SHA256
./src/GuessOurBlockReceiver.sol

71d69cd93ba024a327ee8721f188cd9d9c84a47ba4dfa204dcb3c106d2b0ddec

./src/GuessOurBlockSender.sol

712d6b7b3796033d07276335432d833c2ca21679a668fc7599bbb8b927969496

./src/IGuessOurBlock.sol

543173692119eb3c58c16b505db91333dd6f209ca0a634edc6ead639bc0a826a

./src/dripVaults/BaseDripVault.sol

9947d094f003f659ed13f71077ffd3c230d663c6330badd1be47589ec6f6be66

./src/dripVaults/IDripVault.sol

81bee6b248cda22298e082d27c175c2a24d0ea5477f6b3d2eeca9a1dfa45ca5c

./src/dripVaults/implementations/AaveVault.sol

31ddee0eacd6a073af60f666199a66302f09816fb3ebec97ccf36d2390ea9f76

./src/dripVaults/implementations/apxETHVault.sol

c46cb2a7bb654052f5d7a2ff24e2169246cc09796276ef8c7ad8a2bcbd72227e

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.

Issue Descriptions and Recommendations

Click on an issue to jump to it, or scroll down to see them all.

Security Level Reference

We quantify issues in three parts:

  1. The high/medium/low/spec-breaking impact of the issue:
    • How bad things can get (for a vulnerability)
    • The significance of an improvement (for a code quality issue)
    • The amount of gas saved (for a gas optimization)
  2. The high/medium/low likelihood of the issue:
    • How likely is the issue to occur (for a vulnerability)
  3. The overall critical/high/medium/low severity of the issue.

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.

Issue Details

H-1

apxETHVault does not consider redemption fees and uses maximum amounts on each withdrawal

Topic
Protocol Integration
Status
Impact
High
Likelihood
Medium

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:

  • If a validator and treasury fee are set, each withdrawal will pay for the 0,03% of the total.
  • Each time a user claims a different winning bet, the vault will pay a 0,03% fee on the total assets held by the Vault, including different block and unallocated assets.

Additionally, depending on how much yield has been accrued between deposits and withdrawals:

  • If the yield accrued exceeds the equivalent fee, the rewards will cover the fee, and the Heroglyph interest will be lower than expected.
  • If the yield is insufficient to cover the fees, the contract will revert as the 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:

  • Only redeem the desired amount and verify the received pxETH.
  • Use and transfer apxETH and allow the end receiver to handle redeem or withdrawal fees.
Response by Heroglyphs

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.

H-2

Aave vault can not be migrated

Topic
Protocol Integration
Status
Impact
High
Likelihood
N/A

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.

M-1

Vault migration mid-state could render the receiver app to always revert

Topic
Migration
Status
Impact
Medium
Likelihood
Low

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.

M-2

lzEndpointReceiverId

Topic
Best practices
Status
Impact
Medium
Likelihood
N/A

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.

L-1

Including the same ticker multiple times will cross-chain repeated messages

Topic
Grief
Status
Impact
Low
Likelihood
Medium

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:

L-2

Potential owner misconfigurations

Topic
Configuration
Status
Impact
Medium
Likelihood
Low

Two potential owner configuration setters could cause undesired revert scenarios, breaking the contract:

Configuring minimumBlockAge to zero:

  • This would allow an actor to front-run the LZ message delivery with the won block and place bets that will instantly win. Rendering the bet odds and guess unfair.

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.

Q-1

fullWeightCost can be immutable

Topic
Best Practice
Status
Quality Impact
Low

In GuessOurBlock sender, the variable fullWeightCost can be declared immutable since it’s only set in the constructor.

Response by Heroglyphs

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.

Q-2

Replace magic numbers with constants

Topic
Best Practice
Status
Quality Impact
Low

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.

Q-3

Using safeTransfer()

Topic
Best Practice
Status
Quality Impact
Medium

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.

Q-4

Unnecessary receive() function

Topic
Code Quality
Status
Quality Impact
Medium

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.

Q-5

No treasury address setter

Topic
Configuration
Status
Quality Impact
Medium

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.

Disclaimer

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.