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

Zora A-1

Security Audit

Sept 6, 2024

Version 1.0.0

Presented by 0xMacro

Table of Contents

Introduction

This document includes the results of the security audit for Zora's smart contract code as found in the section titled ‘Source Code’. The security audit was performed by the Macro security team from May 22nd, 2024 to May 28th, 2024.

The purpose of this audit is to review the source code of certain Zora 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
Medium 2 1 - 1
Low 1 1 - -
Code Quality 1 1 - -
Gas Optimization 1 1 - -

Zora 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)

The Mints token contract itself is non-upgradeable, and assuming the set redeem handler for a token is immutable, users should always be able to receive the same or an expected amount of ETH or Tokens from redeeming relative to what they paid to mint.

The Mints Manager is upgradable has the permission create new types of Mints tokens, as well as set the mints token that is available to be minted for a specific payment type. The current default ETH Mints token is currently used to by integrated Zora collections to determine the protocol fees for each Mint, which can be changed.

The owner of the Mints Manager has the ability to upgrade it, as well as create and set default tokens, adjusting the price to purchase mints for any payment type, as well as set the protocol fee to mint on associated collections. The owner is trusted to set the cost of of Mints tokens to be reasonable to not adversely effect Zora collections, as well as to only upgrade the Mints Manager to benefit the protocol and/or its users.

Source Code

The following source code was reviewed during the audit:

Specifically, we audited the following contracts within this repository.

Contract SHA256
packages/mints/src/BaseRedeemHandler.sol

c3e1c9f211903aaafcd26beed434c3dfc3042ab08121712dcf5cce0d3b75f418

packages/mints/src/ICollectWithZoraMints.sol

c81a9c28c39f71149e9aa75df0fa7793d05c8be9b7f23304e5dc2469a7442cd6

packages/mints/src/IMintWithMints.sol

ba816ffa0994145980a098df6d150e01fadd2b13d64b2ace7a33516a2ecdf40d

packages/mints/src/MintsManagerStorageBase.sol

6eab9a2c0c578ba9b07799576385dd15b62d044577e79095a21f9d1e5c456125

packages/mints/src/MintsStorageBase.sol

7852877fb6d80a1352ac4cf5d02611d49c21c1a2d5bfda7a9846aa78ceec5c21

packages/mints/src/ZoraMints1155.sol

198d87871978d2c8ede370485ef15b74ab7393b4e17e276cf2f92f88291fcfde

packages/mints/src/ZoraMintsManager.sol

e27877c4c285233f85baecf639a04b29f1166d016176957d98859fc96707bd99

packages/mints/src/ZoraMintsManagerImpl.sol

70fb73ab1eac6f674dd37d2670041815fb1fbebd950af406d3a5f7f8ded25f02

packages/mints/src/ZoraMintsTypes.sol

660399506c4d9f974abe0e6fbc22b10af8b5f013b324a623f575af7a333fb569

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

M-1

ETH sent to MintsEthUnwrapperAndCaller can be stolen if incorrect call is made

Topic
Input Validation
Status
Acknowledged
Impact
High
Likelihood
Low

MintsEthUnwrapperAndCaller.sol has the specific purpose of allowing a user to redeem their mint tokens for ETH, and supply their own additional ETH as necessary, to make an arbitrary call using this ETH. This call could be to do anything, but a specific use case is to bridge the ETH received, using Relay, and mint Zora NFTs on another chain.

How it does this is a little indirect, where a user signs a permit that allows transferring of their mints, as well as having additional data that is passed into onERC1155Received or onERC1155BatchReceived hook, which the received mints tokens are redeemed for ETH and an arbratrary call is made, using the ETH initially sent as well as the amount redeemed via the mints tokens. Any excess ETH remaining in the contact is then sent back to the user.

function permitWithAdditionalValue(IZoraMints1155Managed.PermitBatch calldata permit, bytes calldata signature) external payable {
    IZoraMints1155Managed(address(zoraMints1155)).permitSafeTransferBatch(permit, signature);
}

Reference: MintsEthUnwrapperAndCaller.sol#L42-L44

function onERC1155Received(address, address from, uint256 id, uint256 value, bytes calldata data) external onlyMints returns (bytes4) {
    // temporarily enable receiving eth
    expectReceive = true;
    // redeem the MINTs - all eth will be sent to this contract
    Redemption memory redemption = zoraMints1155.redeem(id, value, address(this));
    // disable receiving ETH
    expectReceive = false;

    // if any redemption is erc20, revert
    if (redemption.tokenAddress != address(0)) {
        revert ERC20NotSupported(0);
    }

    // forward eth balance redeemed to the desired receiver, calling it with the data and desired
    // value to forward.
    // refund the remaining eth to the original owner of the MINTs
    _sendToReceiverAndRefundExcess(data, from);

    return ON_ERC1155_RECEIVED_HASH;
}

Reference: MintsEthUnwrapperAndCaller.sol#L46-L65

function _sendToReceiverAndRefundExcess(bytes calldata data, address refundRecipient) internal {
    bytes4 action = bytes4(data[:4]);

    if (action != IUnwrapAndForwardAction.callWithEth.selector) {
        revert UnknownUserAction();
    }

    // decode the call: get address to forward eth to, encoded function to call on it, and value to forward
    (address receiverAddress, bytes memory call, uint256 valueToSend) = abi.decode(data[4:], (address, bytes, uint256));

    (bool success, bytes memory callResponseData) = receiverAddress.call{value: valueToSend}(call);
    if (!success) {
        revert CallFailed(callResponseData);
    }

    // if theres any remaining eth, refund it to the original owner of the MINTs
    if (address(this).balance > 0) {
        Address.sendValue(payable(refundRecipient), address(this).balance);
    }
}

Reference: MintsEthUnwrapperAndCaller.sol#L95-L114

However, this assumes that the call made to permitSafeTransferBatch() transfers the mints tokens to this unwrapper contract which triggers the hooks and continue the execution flow. If any other address is provided in the PermitBatch’s to parameter, then the expected execution flow stops and any ETH sent with the call to permitWithAdditionalValue() remains in the contract. In the case where this occurs, the ETH left in the contract can be easily scooped up by anyone, either maliciously or accidentally, as the next call that properly executes as expected will have all the excess ETH sent to the refundRecipient.

Remediations to Consider

Ensure that the permit’s to parameter is set to address(this) in permitWithAdditionalValue(), to make sure that the call executes as expected and no ETH is lost due to incorrect inputs.

Response by Zora

while this is a risk, this contract call is setup by our front-end, and not meant to be used directly by third parties, so the risk is low.

M-2

Cannot pre-mint paid NFTs using mints

Topic
Protocol Design
Status
Impact
Medium
Likelihood
Medium

Mints tokens are ERC1155 tokens that are intended to be used to cover fees to mint NFTs on the Zora protocol, where each mint token can be redeemed to mint at a 1:1 rate for normal free mints, and additional ETH can be sent used to cover any additional fees a collection may require.

Typically users can mint from a Zora collection that integrates with mints, via a direct call to mintWithMints(), sending any additional ETH with the call to cover additional fees for paid mints, if required.

There is an alternative method that allows for users to use Mints without having to make an additional approval transaction to the collection. This is done my calling transferBatchToManagerAndCall() on the ZoraMints1155.sol contract or transferring the Mints tokens to the Zora Mints Manager contract with additional data relating to the call. Going through the mint manager allows 2 calls to be made, Collect(), which effectively calls mintWithMints() on the desired collection, and collectPremintV2():

function collectPremintV2(
    ContractCreationConfig calldata contractConfig,
    PremintConfigV2 calldata premintConfig,
    bytes calldata signature,
    MintArguments calldata mintArguments,
    address signerContract
) external payable override onlyThis returns (PremintResult memory result) {
    MintArguments memory emptyArguments;
    TransferredMints memory transferredMints = _getTransferredMints();
    address firstMinter = transferredMints.from;
    // call premint with mints on the premint executor, which will get or create the contract,
    // get or create a token for the uid.
    // quantity to mint is 0, meaning that this step will just get or create the contract and token
    result = premintExecutor.premintV2WithSignerContract{value: msg.value}(
        contractConfig,
        premintConfig,
        signature,
        0,
        // these arent used in the premint when quantity to mint is 0, so we can pass empty arguments
        emptyArguments,
        firstMinter,
        signerContract
    );

    // collect tokens from the creator contract using MINTs
    _collect(
        IMintWithMints(result.contractAddress),
        IMinter1155(premintConfig.tokenConfig.fixedPriceMinter),
        result.tokenId,
        mintArguments.mintRewardsRecipients,
        abi.encode(mintArguments.mintRecipient, ""),
        mintArguments.mintComment
    );
}

Reference: ZoraMintsManagerImpl.sol#L247-L280

CollectPremintV2() does the same as collect, but allows minting on a collection that may not exist yet, by calling premintV2WithSignerContract() on the set premintExecutor, creating the collection if it does not exist and the collection owner has signed the appropriate data for the new collection. However, when calling premintV2WithSignerContract() ETH is sent with the call, equal to msg.value, which is not used when no tokens are minted, since quantityToMint is set to zero there is no call to mintWithETH() on the collection:

 if (quantityToMint > 0) {
    ZoraCreator1155PremintExecutorImplLib.mintWithEth(
        IZoraCreator1155(result.contractAddress),
        premintConfig.tokenConfig.fixedPriceMinter,
        result.tokenId,
        quantityToMint,
        mintArguments
    );
}

Reference: ZoraCreator1155PremintExecutorImpl.sol#L132-L140

So any ETH sent in this call gets locked in the ZoraCreator1155PremintExecutor contract, but when the call continues to _collect() the msg.value is used again when calling mintWithMints():

...
mints1155.setApprovalForAll(address(zoraCreator1155Contract), true);
// call the Zora Creator 1155 contract to mint the creator tokens.  The creator contract will redeem the MINTs.
uint256 quantityMinted = zoraCreator1155Contract.mintWithMints{value: msg.value}(
    tokenIds,
    quantities,
    minter,
    zoraCreator1155TokenId,
    rewardsRecipients,
    // here we strip out the comment since it doesn't work properly with msg.sender changing.
    minterArguments
);
...

Reference: ZoraMintsManagerImpl.sol#L308-L319

Since the value of the call was already sent out of the contract in the call to premintV2WithSignerContract(), there likely isn’t any ETH remaining in the contract to make the second call to mintWithMints(), so the execution should revert if msg.value is non-zero, and there wasn’t any ETH in the contract prior to the call, which is likely. The only case where there is expected to extra ETH sent is where the user wants to mint a paid NFT, so in the case of attempting to pre-mint or mint a paid NFT using collectPremintV2(), the transaction will fail.

Remediations to Consider

Do not send any ETH in the call to premintV2WithSignerContract(), to allow minting paid NFTs via the collectPremintV2() path. Also consider adjusting the premintV2WithSignerContract() function to make sure that there is no value sent in if quantityToMint is zero, as any ETH sent in accidentally is currently locked.

L-1

Users may not mint the expected Mints token, potentially spending more than desired

Topic
Protocol Design
Status
Acknowledged
Impact
Medium
Likelihood
Low

Minting Mints tokens is not done directly, but rather by calling either mintWithEth() or mintWithERC20() on the Mints Manager:

 /// This will be moved to the Mints Manager
function mintWithEth(uint256 quantity, address recipient) external payable override returns (uint256 mintableTokenId) {
    MintsManagerStorage storage mintsManagerStorage = _getMintsManagerStorage();
    mintableTokenId = mintsManagerStorage.mintableEthToken;
    mintsManagerStorage.mints.mintTokenWithEth{value: msg.value}(mintableTokenId, quantity, recipient, "");
}

/// This will be moved to the Mints Manager
function mintWithERC20(address tokenAddress, uint quantity, address recipient) external returns (uint256 mintableTokenId) {
    MintsManagerStorage storage mintsManagerStorage = _getMintsManagerStorage();
    mintableTokenId = mintsManagerStorage.mintableERC20Token[tokenAddress];

    _mintTokenWithERC20(mintableTokenId, tokenAddress, quantity, recipient, "");
}

Reference: ZoraMintsManagerImpl.sol#L140-L153

However, the specific Mints token id isn’t specified, and the currently set mintable token is used depending on the payment type used. This mintable token is set by the owner of the Mints Manager via setDefaultMintable():

// note: this is to be moved to the mints manager
function setDefaultMintable(address tokenAddress, uint256 tokenId) external override onlyOwner {
    _setDefaultMintable(tokenAddress, tokenId);
}

Since the token id that is minted can change, if a user submits a transaction to mint, they may be expecting a certain price, or token with an accepted redeemHandler, but if the mintable token changes before they execute the transaction, they may not be happy with the result. When paying with ETH it is less likely to be an issue if the price changes as the call will likely revert, but if a user has approved the Mints Manager for a large amount of ERC20 tokens, they could have enough balance and approvals to purchase a mint for more than expected if the new token’s price is higher. In both cases a new redeemHandler that takes some of the redeemed amount may have dissuaded the user from minting had they known beforehand.

Remediations to Consider

Add a expected tokenId to both the mintWithERC20() and mintWithEth() functions to ensure the outcome for users is as expected.

Response by Zora

Won’t fix for now - adding a tokenId to this makes sense. While this is a risk, it only occurs with erc20 based mint token ids. We don’t plan on adding support for erc20 minting anytime soon. If/when we do, we will switch to taking a token id as a parameter.

G-1

Unnecessary setting and removing transferred mints

Topic
Gas Optimization
Status
Acknowledged
Gas Savings
Low

Mints tokens are transferred to the Zora Mints Manager, the tokens sent are stored to be later used in the calls made to itself to collect() or collectPremintV2(), then the store values are removed. This can occur via transfers or calls to callWithTransferTokens() by the Mints contract:

function onERC1155Received(address, address from, uint256 id, uint256 value, bytes calldata data) external onlyMints returns (bytes4) {
    (uint256[] memory ids, uint256[] memory quantities) = BatchDataHelper.asSingletonArrays(id, value);
    _setTransferredMints(from, ids, quantities);

    if (data.length != 0) {
        _handleReceivedCallAndRevertIfFails(data);
    }

    _clearTransferredMints();

    return ON_ERC1155_RECEIVED_HASH;
}

function onERC1155BatchReceived(
    address,
    address from,
    uint256[] calldata ids,
    uint256[] calldata values,
    bytes calldata data
) external onlyMints returns (bytes4) {
    _setTransferredMints(from, ids, values);

    if (data.length != 0) {
        _handleReceivedCallAndRevertIfFails(data);
    }

    _clearTransferredMints();

    return ON_ERC1155_BATCH_RECEIVED_HASH;
}

function callWithTransferTokens(
    address callFrom,
    uint256[] calldata tokenIds,
    uint256[] calldata quantities,
    bytes calldata call
) external payable onlyMints returns (bool success, bytes memory result) {
    _setTransferredMints(callFrom, tokenIds, quantities);
    (success, result) = _handleReceivedCall(call, msg.value);
    _clearTransferredMints();
}

Reference: ZoraMintsManagerImpl.sol#L358-L398

However, when the mints contract calls callWithTransferTokens() in its transferBatchToManagerAndCall() function, it first transferred the mints tokens with no data, which would trigger onERC1155BatchReceived() to set and clear the transferred mints before it is set again in the call to callWithTransferTokens():

function transferBatchToManagerAndCall(
    uint256[] calldata tokenIds,
    uint256[] calldata quantities,
    bytes calldata call
) external payable returns (bytes memory callReturn) {
    safeBatchTransferFrom(msg.sender, getManager(), tokenIds, quantities, "");

    // store the msgSender, so that the manager can get the msgSender of the call
    (bool success, bytes memory result) = ICollectWithZoraMints(getManager()).callWithTransferTokens{value: msg.value}({
        callFrom: msg.sender,
        tokenIds: tokenIds,
        quantities: quantities,
        call: call
    });
    ...

Reference: ZoraMints1155.sol#L322-L335

For the onERC1155BatchReceived and onERC1155Received hooks, the transferred tokens are only used if the data is not empty.

Remediations to Consider

Only set the transferred tokens for the 1155 hooks if the data passed in is not empty to prevent and additional set and removal when transferBatchToManagerAndCall() is used.

Response by Zora

We are going to eventually remove this logic completely for more simple functionality that enables unwrapping/

Q-1

Inaccurate comments

Topic
Code Quality
Status
Acknowledged
Quality Impact
Low

Notes to move functions to mint manager should be removed since they are currently in the mint manager:

Response by Zora

When we do the next upgrade to this contract we can fix it.

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