Minting with a pass: an alternative to whitelists for NFT smart contracts

cool person using a pass to access a beautiful red building, illustrated
Image generated by DALL·E 2

One thing we had to do recently was to implement an NFT smart contract where the mint (or at least the initial phase of the mint) was only possible if you owned an NFT from another collection. It could be seen as an alternative to a whitelist in some way, but there are certainly plenty of other fascinating use cases for this.

In the end, it was not too complex to implement in itself, but it was actually not that easy to find documentation about how to implement it. That's why I'm writing this tutorial today, to make life easier for those who'll want to do this in the future.

So, let's imagine we have an NFT collection A deployed somewhere and we only want to allow people who own an NFT from that collection A to be able to mint an NFT from another collection, collection B. This collection A could be based on an ERC-721 smart contract but for the purpose of this article, collection A will be based on an ERC-1155 smart contract, and we'll explain why in a second.

Our use case is the following: collection A has only one token–a pass–than can be airdropped to several people by admins of the collection (thus ERC-1155). We'll call it Pass. Collection B is a more classic ERC-721 NFT collection with 10,000 unique tokens. As it will represent punks that are bored, we'll call it BoredPunks (any resemblance to already existing stuff is purely coincidental). Interesting twist: we also want the Pass NFT to be burned when the BoredPunks NFT is minted, so you can't mint an infinite number of BoredPunks NFTs with the same Pass NFT.

Here's what the contract for Pass would look like:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract Pass is ERC1155, Ownable {
    mapping(address => bool) private admins;

    modifier onlyAdmins() {
        require(
            admins[msg.sender],
            "Only administrators are allowed to mint invitations"
        );
        _;
    }

    constructor()
        ERC1155(
            "ipfs://QmdisZFELMHdn68en1fQKzYF5CAGNqW7hV8AgPe56bi6E3/{id}.json"
        )
    {}

    function addAdmin(address _user) external onlyOwner {
        admins[_user] = true;
    }

    function removeAdmin(address _user) external onlyOwner {
        delete admins[_user];
    }

    function setURI(string memory _newURI) external onlyOwner {
        _setURI(_newURI);
    }

    function mint(address _addr, uint256 _amount) external onlyAdmins {
        _mint(_addr, 0, _amount, "0x");
    }

    function burn(address _addr, uint256 _amount) external {
        require(tx.origin == _addr, "You can only burn your own invitations");
        _burn(_addr, 0, _amount);
    }
}

The most interesting things to pinpoint in this contract are:

  • We created our own modifier onlyAdmins so the airdrop (via the mint function) can be done by multiple different people and not only by the sole owner of the contract. It's possible for the owner to add or remove admins via the addAdmin and removeAdmin functions.
  • We have a burn function that we'll call from the other contract. You can obviously only burn your own tokens, thus the require to make that check. Note that we compare the address passed to the function to tx.origin and not to msg.sender as it's usually the case because, when called by a contract, msg.sender would be the address of the contract and not the address of the account that initiated the request.

Now, let's have a look at our BoredPunks contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "erc721a/contracts/ERC721A.sol";

interface IPass {
    function balanceOf(address account, uint256 id)
        external
        view
        returns (uint256);

    function burn(address addr, uint256 amount) external;
}

contract BoredPunks is ERC721A, Ownable {
    enum Step {
        Prologue,
        PrivateSale,
        PublicSale,
        Epilogue
    }

    Step private sellingStep;
    string private baseURI;
    uint256 private publicPrice = 0.01 ether;
    address private passContractAddress;

    constructor() ERC721A("BoredPunks", "BRP") {}

    // Mint

    function mintWithPass() external {
        address addr = msg.sender;
        require(sellingStep == Step.PrivateSale, "Private sale is not active");
        IPass pass = IPass(
            passContractAddress
        );
        require(
            pass.balanceOf(addr, 0) >= 1,
            "You need at least one pass to mint"
        );
        pass.burn(addr, 1);
        _safeMint(addr, 1);
    }

    function mint() external payable {
        address addr = msg.sender;
        uint256 price = publicPrice;
        require(price != 0, "Price is 0");
        require(sellingStep == Step.PublicSale, "Public sale is not active");
        require(msg.value >= price, "Not enough funds");
        _safeMint(addr, 1);
    }

    // Utils

    function tokenURI(uint256 _tokenId)
        public
        view
        virtual
        override
        returns (string memory)
    {
        require(_exists(_tokenId), "URI query for nonexistent token");
        return
            string(
                abi.encodePacked(baseURI, Strings.toString(_tokenId), ".json")
            );
    }

    function setBaseURI(string memory _baseURI) external onlyOwner {
        baseURI = _baseURI;
    }

    function getBaseURI() external view returns (string memory) {
        return baseURI;
    }

    function setPublicPrice(uint256 _publicPrice) external onlyOwner {
        publicPrice = _publicPrice;
    }

    function getPublicPrice() external view returns (uint256) {
        return publicPrice;
    }

    function setStep(uint256 _step) external onlyOwner {
        sellingStep = Step(_step);
    }

    function getStep() external view returns (uint256) {
        return uint256(sellingStep);
    }

    function setPassContractAddress(address _passContractAddress)
        external
        onlyOwner
    {
        passContractAddress = _passContractAddress;
    }

    function getPassContractAddress() external view returns (address) {
        return passContractAddress;
    }

    // Withdraw

    function withdraw() external payable onlyOwner {
        payable(owner()).transfer(address(this).balance);
    }
}

At the very beginning of the file, you can see that we define an interface IPass for our Pass contract with the only two functions that we'll need to call in the BoredPunks contract: burn and balanceOf. The latter is not in our Pass contract directly but is native to ERC-1155 so is present in the ERC1155.sol contract we import from OpenZeppelin.

The real magic happens in the mintWithPass function where we instantiate our Pass contract via the interface, then we check the balance with pass.balanceOf(addr, 0) >= 1 before burning the pass with pass.burn(addr, 1) and allowing the mint with _safeMint(addr, 1).

And that's it! The rest of the contract is very standard and has already been described in detail in two previous articles that I invite you to (re-)read:

I hope you enjoyed this little article!