What does it take to build an NFT project? How does it work end-to-end, from an idea to collectibles in the user's wallet? How much does it cost?
In total, we spent more than 450 hours on this project (and an additional 200 hours waiting for the 3D renders).
Today, we open-source the Rings for Loot for anyone who wants to learn and expand on it. The 3D art, the icons, all of the website code, and the smart contract are open source and available under the CC0 license on GitHub. Builders are encouraged to learn from our work and use any parts of it in their own projects (including commercial and closed source ones)!
The Loot universe was seeded with 8,000 loot bags. Each bag has a weapon, chest, head, waist, foot and hand armor, necklace and a ring. All these items are represented as text, and it's up to the community to interpret them in any way they like. Many projects spawned that visualized various items from the loot bags.
The idea for Rings was to craft the whole category of items in beautiful 3D art. Jeremy, a super talented designer and 3D artist, came up with the original concept. He posted a few samples on Twitter and got many people interested. 0xHab and I joined the team.
We wanted to make the process of getting the rings fun and engaging. Instead of just selling the NFTs on the open market we introduced some rules: anyone can purchase a common ring (they get one of the Gold, Silver, Bronze, Platinum or Titanium rings at random), they can visit a blacksmith and turn their common rings into rare rings. People who have a Loot bag can purchase a ring matching their bag, but only if it's not sold out (or forged) yet. Our theory was that this dynamics would recognize Loot holders but also give people new to the Loot universe a chance to get a rare ring.
The front and center of this project is the 3D art. All the assets were designed and rendered in Cinema 4D, then post-processed in Photoshop and Lightroom.
On average, it took about 15m to render one frame (for a total of 200+ hours of rendering time). Jeremy had to buy a new laptop to speed things up and finish the renders before the deadline we committed to.
The final images are 2560 by 2560 pixels and take 5-6 MB each. That's more pixels than fit on 4K monitors!
There are a ton of cool effects and hidden details in the Rings. For example, each prefix (e.g. Tempest Peak) has its own icon rendered on each rare ring. There are also easter eggs hidden in the renders (can you spot them?)
We used Figma to design and discuss the website. Spending time together in a Figma doc was really fun, and we feel like it helped us make a better product.
Every NFT is baked by a smart contract. There are 2 common standards of NFTs: ERC721 and ERC1155. They define a set of functions that a contract needs to support to be considered an NFT. For example,
ownerOf(tokenId) is a standard function that needs to return the address of the current owner of a given token,
tokenURI is expected to return a URI with NFT metadata (like name, image, attributes, etc.). There are also a few functions that allow the owner of an NFT to allow a third party to transfer the tokens (used for markets like OpenSea) and a few standard events.
Most projects don't implement these from scratch. We used the most popular at this time library by OpenZeppelin for the base for our contract implementation.
ERC721 and ERC1155 don't say anything about how tokens are minted. This functionality is totally up to us to implement. And that's where the hardest and the most interesting part of Rings contract is.
Making a contract for web3 apps is a very different experience from building a backend for web2 apps — you can't ship incrementally. With traditional backends, you can deploy a small subset of features and continuously iterate. With smart contracts, you gotta make sure you get everything right from the first try.
We built our contract as ERC1155 NFT. The main difference from ERC721 is that there could be multiples of the same token. All Gold Rings are the same, and we wanted your wallet and marketplaces to reflect that.
Each NFT token has an identifier. It can be any number, but most projects use sequential ids: 1, 2, 3, and so on. For Rings, we choose to define token id as the first loot bag that has this type of ring. For example, Silver Ring has id 6 because it's the first loot bag that has a Silver Ring in it. This system is very useful because we don't need to create and maintain another mapping (saves gas for everyone) and makes the contract more composable.
In the original Loot universe, there are only 8,000 bags, with 8,000 rings. Different rings have different supplies. For example, there are 1093 Gold Rings, 20 Gold Rings of Anger, and only one "Corruption Bender" Gold Ring of Reflection +1.
We needed to make sure that the users can't mint more than exists in the universe. We could limit this functionality from the website (e.g. when "Corruption Bender" Gold Ring of Reflection +1 is minted we could disable the purchase button), however smart people could go directly to the contract and simply mint it again. The supply had to be stored on-chain.
Unfortunately, storing a large amount of data on-chain is very expensive. A naive implementation that uses a mapping from ring id to supply would consume 830 (rings) * 64 (key-value pair) = 53KB of data. Additionally, we want to store the list of all rings (we'll get to why in a second), which is an additional 26.5KB.
To save up on space, we decided to encode different types of rings differently. For the 5 common rings, we are storing it as a regular mapping. Less common rings are stored as an array of 2-byte ids and 2-byte supply. The rare rings are encoded as a simple 2-byte array of ids, where the supply is encoded by duplicating the ids.
You can see the encoding here.
Minting Common Rings
According to the rules we came up with when a user mints a common ring they should get a random one. However, there are no truly random numbers on the chain: the EVM is deterministic so you can't just do
Math.random(). We considered using a third party for entropy (like Chainlink VRF) but the additional complexity was not worth it. Instead, we went with a common pattern of generating a pseudo-random number by hashing the current block timestamp and sender address.
uint256 rand = uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender)))
This means that a very dedicated user could brute-force their way into buying a common ring of the color they need, however, the costs of doing so are higher than the cost of just buying all the rings and selling the ones they don't need. For some projects, this trade-off is not acceptable and VRF would be the way to go.
There are a few more edge cases we had to take care of, e.g. when some common rings are sold out. You can see the full source code here.
Purchasing Rings That Match Your Loot Bag
Another rule we had is that if you have a Loot bag you should be able to mint a matching ring (assuming it's not sold out). We also wanted to include mLoot and Genesis Adventure to the list of supported bags, since there are a lot of active community members.
To do that, we needed to match up the ring id to the bag id the user has. E.g. bags number 4 and 15 have Gold Rings. Fortunately, all Loot-compatible contracts have a
getRing function, so we call that and check that the rings match.
There's some additional code to figure out and decrease the supply, you can find the details here.
To even out the playfield we also gave the people who don't have a loot bag a chance to get a rare ring. In exchange, they had to burn a few of the common rings of the same color. Same as in previous cases, we need to pick a random rare ring and make sure it's not sold out.
We wanted to have a way to give our active community members a chance to mint a free ring.
There are several ways different projects implement a way to limit the people who can have access to the benefits: on-chain storage, token-gated access, Merkle trees or signed messages.
We went with signed messages as it's the cheapest and the most flexible (based on the EIP-712 example here). The idea is that contract's methods to mint rings (
purchaseMatching) accept an optional argument called
signature. If it's supplied, the code checks if the signature is valid and if it's coming from a trusted signer. The signer is a wallet that lives on our backend and is only used for signing.
This way we can check if the user that's visiting our minting page is on the giveaway list, and if they are we'll craft and send a signature to the frontend that will allow them to mint a ring for free.
To avoid race conditions, we also mark the address of the user on-chain to make sure the whitelisted person can't use their signature more than once.
In the spirit of Loot, we wanted to make use of on-chain data as much as possible. The token metadata is rendered as on-chain JSON, and we used some clever tricks to escape the
" special character.
The rings are rendered in high quality and can take up to 5MB each. Storing that on-chain is not feasible, so we uploaded all the pictures to IPFS and used Pinata to "pin" the IPFS assets (to make sure there's at least one always-online node to serve the content if no one else has cached it yet).
Other interesting contract functions
We've added support for IERC2981, which allows the contract to tell the marketplaces how much royalty the creators should get. At the time of this writing, most marketplaces still do it the old way (require the collection author to configure this in their UI) but some emerging projects like Zora already support IERC2981 out of the box.
To make trading on OpenSea more accessible, we included their code that allows access to the tokens from the proxy contract.
When people mint rings, the funds are sent to the contract address. Some projects choose to program the smart contract in a way that sends the funds to its creator on every transaction. It's convenient, but it makes every mint more expensive for the users. For Rings, we added a separate function we can call that sends out the contract's balance. We also made it possible to withdraw any ERC20-compatible token, in case someone mistakenly sends their token to our contract address.
All code can have bugs, and smart contracts are very scary for this reason. Fixing the bugs in the smart contracts after they've been deployed is really hard.
So as much as possible we've leaned on automated testing. The Rings test suite is pretty extensive. We have tests for common scenarios like purchasing a random common ring, forging, etc. We also have a set of slow tests that mint every single ring in every possible way to make sure minting won't get expensive or stuck.
Contract deployment requires a lot of gas (~6.5M in our case) and the gas prices change all the time. This means that waiting for a good gas price is very beneficial and could save you thousands of dollars.
I wrote a script that waits for a favorable gas price before deploying the contract. This script requires access to the private key of the wallet that's deploying the code. Being a very paranoid person, I used a burner wallet to deploy and transfer ownership to my main account.
The initial cost for deployment was 0.5Ξ (~$1170 at the time). An additional 0.1Ξ was spent on calling admin functions (start, pause, changing the giveaway signing key and IPFS root).
We split our launch into 2 parts: common rings drop and rare rings drop. We wanted to give people who don't have loot bags a way to get enough common rings to forge a rare ring.
The website is built with NextJS and deployed to Vercel. I've been building React apps since 2013 and love how productive this stack is.
The landing page was a little tricky to build out — it has fancy image overlaps that need to keep working on different screen sizes.
We relied on Vercel's optimized image component to compress and cache Rings images on the fly. This was super useful, as each ring image was 2560x2560 (~5MB) in size. The only gotcha was that we couldn't serve the images from the IPFS gateway fast enough, so we duplicated all the assets to the S3 bucket (only for the website, the contract still refers to IPFS).
We used Web3Modal to support browser wallets and WalletConnect. It's not perfect but it was the best option available at the time.
A cool thing about the website is that it tracks your rings inventory in real-time. So when you mint or forge rings, they show up in the panel right away. There's also a cute little animation that's shown when you mint a ring. All this functionality is powered by listening to contract events. ERC1155 doesn't have enumerable support (and adding it would make all mints more expensive due to higher gas usage), so to reconstruct the state of the user's wallet we query the Transfer events.
To test things up, we deployed a copy of Loot contracts to Rinkeby test network. Our website seamlessly supports both Rinkeby and Mainnet, which made playing with the UX cheap and easy.
There's also a small trick we had to use to reduce the number of failed transactions. When you interact with the blockchain, your wallet tries to estimate how much gas a transaction is going to consume. This is important because as a user, generally, you don't want to set the gas limit yourself.
But we have a small problem here. As I mentioned earlier, our contract has the functionality to give the minter random common or rare rings. Under the hood, the contract code can execute a little differently depending on which random number you get. This means that your wallet can underestimate the amount of gas it needs to set for the transaction, and the transaction can fail (wasting the gas fees).
To compensate for that, we do gas estimation ourselves and increase the gas limit by ~30%. If we end up overshooting, the unused gas fees will be refunded to the user by the Ethereum network automatically at the end of the transaction.
After the project has been deployed and stable, we've transferred ownership to a Gnosis safe shared between the three of us. This way if something happens to any of our wallets, we still have access to the contract functions and funds.
It's up to you! We are hoping the code and the assets we crafted will help the next generation of artists to continue building the Loot universe.
Thanks for being part of the community and reading about our journey! If you have any questions or feedback, feel free to reach out!