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

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 themint
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 theaddAdmin
andremoveAdmin
functions. - We have a
burn
function that we'll call from the other contract. You can obviously only burn your own tokens, thus therequire
to make that check. Note that we compare the address passed to the function totx.origin
and not tomsg.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!