Building the Surge Passport NFT smart contracts
By ExplorerGeek, edits by lonilush and Juliette Chevalier.
Ever wondered how NFT projects are able to create such beautiful profile pictures (PFPs) and get them deployed? How do teams make engineering decisions? Why do they decide to choose one standard over another?
There are many great tutorials out there, but the space needs more real-world examples of what works and what doesn’t. It’s important to know why a project chose certain methods and what were the tradeoffs. Also, it's important to share where we got the information in the first place.
At Surge, open-source knowledge is one of our core beliefs. It’s why we want to share what we’ve learned through creating a smart contract for the Surge Passport NFT in the hopes that it can serve more projects in the future.
NOTE: This article is meant to be slightly technical and will focus on the smart contracts we implemented. You don’t need to be deep into Solidity, but an understanding of contracts and software development is expected.
The Team
When we first started working together, we made sure to organize ourselves based on our strengths.
We had part of the team working on the minting site, aka the dApp portion. We built this using React, connecting to the contracts through ethers.js.
Others worked on the infrastructure side, making sure the minting site could handle an influx of demand we were expecting during mint day.
Others worked on the generative art algorithm, making sure every image and layer generated the beautiful PFPs we were expecting.
And finally, we had part of the team working specifically on building the smart contracts.
Even though our team was working on different parts of the project, we were still in communication the entire time since all sections depended upon one another.
In this article, we’ll dive deep only into the smart contract section of our project.
Why the ERC721A Standard
When we started developing the smart contracts, we opted for using the OpenZeppelin ERC721 standard, which is quite popular and widely used among unique NFT projects.
But we knew our community would include many first-time minters, which meant keeping gas low was a key priority. As the mint date got closer, we kept reading about ways to optimize our code, which is how we first stumbled upon Azuki’s ERC721A in a Telegram group for NFT standards.
ERC721A is optimized for batch minting. With the classic ERC721 standard, a wallet address is saved to each tokenId, which is then stored on the blockchain. This means that we have information written into the blockchain every time we perform a mint of a token, which requires a lot of gas - especially if people are minting more than one NFT.
On the other hand, the ERC721A ensures that during minting, a wallet address is saved to the first tokenId in the batch and the rest are left empty. This means we only write once to the blockchain and set the owner to many NFTs at the same time. With less information to store, users pay less for gas.
Since the Surge Passport NFT is a utility token with many perks attached to it, we were envisioning many of our members would batch mint, which is why we decided to implement the ERC721A instead of the ERC721. There are many more features and tradeoffs between each of these two contracts, but for our community, this decision made the most sense.
Deeper into the gas optimization
We further optimized gas improvements by using a SaleStatus enum. This allowed us to set the status of the sale without having to use separate get() and set() methods for public sale and presale. We could then check inside of each minting method if the correct sale was active or not.
// Sale Status enum SaleStatus { Paused, Presale, PublicSale, SoldOut }
Our Minting Functions
There are three different minting functions in our contract and each of them minimize gas, We did not have to use Reentrancy protection because of how we wrote the code inside the methods.
The presale and public sale methods set the minted amount of the address before actually minting the tokens which helped save us gas. Let’s dive deep into each.
Batch minting
We knew we wanted to keep 200 NFTs for our treasury, but couldn’t use our regular mint function because it had a modifier that ensured a user couldn’t mint more than 5 NFTs.
In order to avoid that, we created a custom method called batchMinting() that allows the owner of the contract to mint NFTs for itself, regardless of the state of the sale. It accepts the number of tokens being passed into it and uses the _safeMint() method from the ERC721A contract.
The other key thing we did here is that we allowed the owner of the contract to change the price dynamically so that we could do a batch minting of the treasury at 0 cost, paying only for gas, then change the price for open mint. This saved us hundreds of dollars on minting, considering we ended up paying only 120$ on gas for minting over 200 NFTs.
// @notice Allows the owner to mint for the organization's treasury // @param _amountOfTokens Amount of tokens to mint function batchMinting(uint256 _amountOfTokens) external payable nonReentrant onlyOwner verifyMaxSupply(_amountOfTokens) isEnoughEth(_amountOfTokens) { _safeMint(msg.sender, _amountOfTokens); }
Verifying our presale list
We realized passing in all the wallet addresses from our presale list to a function would be unfeasible and cost too much gas. We would have had to iterate and map each address to a boolean so could know which address was in fact in our presale list. This meant a lot of storage would be needed and the initial looping would have been extremely expensive.
To avoid that, we decided to use a Merkle tree. Although the topic of Merkle trees falls beyond the scope of this article, you can read more about them here. Essentially, they are a binary tree that automatically generates a hash (better known as a MerkleRoot) through which users can verify certain information exists inside of that tree by using the root hash and the location of the leaf.
We were then able to check if the signer address calling the presaleMint(_amountOfTokens, merkleProof) method was in our presale list based on the root that was sorted in our contract and the proof we got by using the address of the caller, which we passed in from the front end.
// @notice Presale minting verifies callers address is in Merkle Root // @param _amountOfTokens Amount of tokens to mint // @param _merkleProof Hash of the callers address used to verify the location of the address in the Merkle Root function presaleMint(uint256 _amountOfTokens, bytes32[] calldata _merkleProof) external payable verifyMaxPerUser(msg.sender, _amountOfTokens) verifyMaxSupply(_amountOfTokens) isEnoughEth(_amountOfTokens) { require(status == SaleStatus.Presale, "Presale not active"); bytes32 leaf = keccak256(abi.encodePacked(msg.sender)); require(MerkleProof.verify(_merkleProof, merkleRoot, leaf), "Not in presale list"); _mintedAmount[msg.sender] += _amountOfTokens; _safeMint(msg.sender, _amountOfTokens); }
If you want to dive deeper into Merkle trees:
Our public sale minting function
We started off with two different minting functions because we wanted to allow people to pay with credit cards on our minting site. This meant that we needed one function where we passed in the address of the person we would mint to (to allow for the Crossmint credit card payment processor), as well as the traditional minting function where the NFT would be given directly to the signer of the transaction.
However, we quickly realized that we could add the parameter “to” to our minting function and use it for both Crossmint as well as traditional minting by passing the address directly from the front-end.
// @notice Public Sale minting // @param to Address that will receive minted token // @param _amountOfTokens Amount of tokens to mint function mint(address to, uint256 _amountOfTokens) external payable verifyMaxPerUser(to, _amountOfTokens) verifyMaxSupply(_amountOfTokens) isEnoughEth(_amountOfTokens) { require(status == SaleStatus.PublicSale, "Sale not active"); _mintedAmount[to] += amountOfTokens; _safeMint(to, _amountOfTokens); }
The coolest thing about this was it also allowed us the opportunity to have a feature on our minting site where our community could “gift” mint an NFT to a friend. Our community was able to mint directly into a friend's wallet. We did this by updating our minting site front-end code to allow someone to pass in another address for minting if they wanted.
The Capital and the Royalties
The next part was designing functions that would handle the funds, including the royalties.
Contract withdrawal
In order to withdraw the funds generated from the contract, we created a withdrawAll() method similar to what we found in the Hashlips MasterClass Video to withdraw all the funds from the contract.
However, based on stories from previous creators, this was not enough. The withdrawall() function withdrew funds in ETH, but if someone sent other tokens by mistake we would be unable to withdraw them because no withdrawal function existed for those tokens.
That’s why we also added the withdrawTokens(token) method to be able to withdraw any funds that might have been sent to our contract outside of what was expected.
// @notice Release contract funds to contract owner function withdrawAll() public payable onlyOwner nonReentrant { (bool sucess, ) = payable(msg.sender).call{vallue: address(this).balance}(""); require(success, "Unsuccessful withdraw"); } // @notice Release any ERC20 tokens to the contract // @param token ERC20 token sent to contract function withdrawTokens(IERC20 token) public onlyOwner { uint256 balance = token.balanceOf(address(this)); SafeERC20.safeTransfer(token, msg.sender, balance); }
We also added some extra protection for our community within our modifier. We heard of some issues with other projects having to track down refunds due to community members overpaying. So, we wanted to make sure that our community would only be able to pay us the exact minting price.
To make sure they would only pay the exact amount of the minting price, we added a modifier statement checking that a person had enough ETH in their wallet. This was resolved by making the amounts equal instead of greater than or equal.
// @notice Verifies the address minting has enough ETH in their wallet // @param _amountOfTokens Amount of tokens to be minted modifier isEnoughEth(uint256 _amountOfTokens) { require(msg.value == _amountOfTokens * price, "Not enough ETH"); _; }
The royalties
Royalties were a little tricky to implement, and we want to thank Patrick Collins and @dd0sxx for helping us better understand how this should be implemented!
When it comes to funds, these had to be distributed in a specific way:
The funds from the minting process would stay in the contract, until we called the withdrawAll() to pass them into our organization’s account.
Then, we needed the royalties to be divided between two accounts: the project’s and the artist’s.
This was complicated since most standards only allow for the royalties to be sent to one address. However, we needed to make sure we paid the artist as well as the organization whenever royalties from secondary sales were generated.
In order to do this, we had to create an entirely separate contract only for the royalties. So we created an Escrow contract using the ERC2981 Standard, which we passed in our Surge contract constructor to make sure all royalties went to the contract itself as the receiver of the funds.
// Surge.sol constructor( string memory _name, string memory _symbol, string memory _baseTokenURI, uint128 _price, address _receiver, uint256 _royalties ) payable ERC721A(_name, _symbol) { setBaseURI(_baseTokenURI); setPrice(_price); setRoyalties(_receiver, _royalties); } // @notice Allows to set the royalties on the contract // @param value Updated royalties (between 0 and 10000) function setRoyalties(address recipient, uint256 value) public onlyOwner { _setRoyalties(recipient, value); }
Then, the Escrow contract (which is a direct copy of the PaymentSplitter contract from OpenZeppelin) divided up the royalties between the two addresses, using the percentages we passed into the Escrow contract constructor.
Keep in mind, though: since OpenSea does not recognize ERC2981 yet, we had to go into our collection and set the royalties manually.
The Reveal
We knew we wanted to do a delayed reveal for the NFTs. This means all users would receive the same image when they minted the NFT, but then the actual, unique image they owned was revealed a few days later.
When talking with @dd0sxx about it, he suggested we merely update the value of the baseURI to do so. That way, we would not need to store the IPFS hashes in variables on the blockchain (which costs gas), but instead, update the baseURI using a single method.
We did this by:
Setting the original baseURI of the collection in the constructor by passing an IPFS hash pointing to the metadata of 5000 objects all leading towards the same image.
Then, when we wanted to reveal, we called the setBaseURI() with the updated IPFS hash, whose metadata pointed to the 5000 unique images.
Then, we ask our community to refresh their metadata on OpenSea.
// @notice Set metadata base URI // @param _baseTokenURI New base URI function setBaseURI(string memory _baseTokenURI) public onlyOwner { baseTokenURI = _baseTokenURI; }
TLDR
Our main goal with this contract was to deliver the smoothest minting experience possible for our community, whom we knew was comprised of many first-time minters. It was important to customize our contract and optimize for gas and simplicity.
Choosing to use the ERC721A, the merkleRoot, and the SaleState enum contributed directly to our community paying an average of $13 USD in gas fees throughout the entire minting window (6 days).
Updating our primary minting function allowed us to integrate Crossmint, so our community members without crypto wallets could pay with a credit card. This also gave our community the ability to mint NFTs directly into the wallets of friends and family, which was a core part of our project’s mission.
We learned so much during this entire process, we are thankful to all the people who took the time to give us feedback and love. Skills forged in fire!
We hope that this serves as a jumping board for your own projects.
Having that said, if you have any questions, please feel free to find the Surge team in our Discord or on Twitter. We are happy to support anyone looking to build their crypto dreams.