I’m Xuannü, the WITCH of WILL and keeper of the coven at Crypto Coven.
As some of you know, I worked on crafting the ERC-721 contract for the initial series of WITCHES for the project. It was the first smart contract I had ever written—about a week or two into knowing what a smart contract was at all. An experience better not repeated, decidedly, but an edifying one nonetheless.
The cryptic art of spellcasting in Solidity can be inscrutable at the surface, but we were able to draw from a collective pool of wisdom that deepens daily… and so we bring our own to you, young arcanists. Here is the story of a contract cast into the ether to bring to life a coven, as well as the trials encountered along the way—the curse of the void witch, and their untimely return.
It was a few weeks before the Halloween launch of Crypto Coven and, thus, the date at which we were meant to deploy the smart contract for our collection of WITCHES.
The catch? There was no smart contract; and, frankly, we weren’t yet sure what exactly a smart contract was.
We had begun working on the project in mid-September, and there was a seemingly infinite amount to do—drawing art, sculpting a generator, weaving lore, creating a website, and so on. As arcanists with keen competencies in web development, the generator and the website were easy (and, granted, crucial) places to focus our energies. They were already enough work on their own that we had been pulling four a.m. nights for weeks. The smart contract, by contrast, was uncharted territory. We regarded it with no slight amount of trepidation, and so we procrastinated on it until we had simply no other recourse but to dust off the sacred tomes and, well, learn what it meant to write code on the blockchain.
Of course, we could have just used a platform like OpenSea or Zora rather than develop a contract from scratch—but we wanted to write our own. For one, a major part of why we had undertaken this project at all was to explore and experiment with the blockchain as a medium. Intrigued by the possibilities that emerged out of permissionless code and decentralized data, we sought to understand the technologies underpinning them more deeply for ourselves. More than that, we weren’t just dropping a collection of WITCHES—we were crafting a world around them. Having our own contract would enable us to design the entire experience around the summoning of a WITCH, setting the tone for the community we hoped to manifest by offering an early glimpse into the weird wilds.
With that aim, I first set out to get a technical base of the problem space, starting with just one Saturday spent perusing the entirety of the Ethereum Foundation docs on the subject.
What is a smart contract? It’s a misnomer, really. A smart contract is a program that lives and stores its data on the blockchain, typically with APIs to interact with its contents. Poking around contracts on Etherscan makes that much clearer; you can see the entirety of the code for a verified contract, as well as call read and write functions on them.
What, then, is a non-fungible token (NFT)? It’s a record of ownership created and stored within a smart contract that follows a particular standard (ERC-721), and it can point to anything—an image, a song, an HTML page, and so on. Unlike cryptocurrencies, where tokens are interchangeable (fungible), NFTs reference unique objects.
Even as competent engineers, we initially struggled with the specifics of how NFTs work—in part because it’s so simple that it’s counterintuitive. It was hard to believe that that was it. Most NFTs are just pointers: a blockchain-based database entry that points to content somewhere else on the internet. On-chain NFTs are the exception; the content itself lives on the blockchain, not just a pointer to it, but that also means that the content is limited to formats that can feasibly be stored, given the size limitations of the blockchain (i.e., vector or pixel art rather than high-res raster art).
But for our project, for instance, there is absolutely nothing in the smart contract that defines what makes a WITCH—there are just arbitrary IDs (1 through 9,999). Everything about a particular witch exists at the URI at which the smart contract points. That meant that the surrounding infrastructure we needed to write was more fragile and more complex, but the work of writing the contract was relatively straightforward.
After developing a high-level understanding of smart contracts and NFTs, I started looking through existing contracts, from older, well-known projects like Cryptopunks, Bored Ape Yacht Club, and Blitmaps to projects that were just launching at the time, like Doodles. Seeing real code bridged a lot of the gaps in my mental model. Nyx and I had worked to sketch out the functionality we wanted in our smart contract; browsing other projects’ contracts on Etherscan helped to see countless examples of different implementations and feature sets.
As much as we gleaned from all this prior work, perhaps one takeaway was even more salient: not every contract is well-written, and in fact even common practices can be anti-patterns. This space is still early, and many of the people deploying to mainnet are, like us, new to developing on Ethereum—which means that it’s important to never assume that even a well-known project necessarily has good code. Understanding the fundamentals rather than replicating code can be essential to ensure that you aren’t copying inefficient, incorrect, or (worst of all) insecure code.
We had a few aims with minting: we wanted to support both a community sale (limited to a list of addresses) and a public sale, with a three-WITCH-per-wallet limit across both.
Figuring out the public sale and the per-wallet limit were trivial. Figuring out how to limit minting for the community sale to a list of addresses, though, posed a more complicated question. Few of the contracts I read included logic for limiting minting to a list of addresses.
But when I tweeted a different question, asking how people usually implemented airdrops, indreams.eth shared Mirror’s DroppableEditionsLogic
contract. I scrutinized it with acolytes Oizys and Enthea, breaking down each function to understand how it worked. We focused on purchaseWithProof()
, learning what a Merkle tree was. But even after we understood what it was, we still didn’t understand why this seemingly convoluted logic was necessary. Without any reference points, we kept wondering: why not just write this code, the obvious solution?
function setCommunityList(address[] calldata addresses) external onlyOwner {
_communityList = addresses;
}
function mintCommunitySale(...) external payable {
require(_communityList.includes(msg.sender)); // this `includes()` method does not exist!
...
}
After some experimenting with code, the reason quickly became clear: gas. The cost of storing so much data on-chain was astronomical. It was perhaps the first revelation we had about how writing Solidity differed from most of the code we’d written in the past. Storing a whole list of addresses just to check whether a given address should be able to mint was a really inefficient use of on-chain storage. Instead, we could use a Merkle tree and only store the root of the tree.
With gifting, we wanted to be able to support airdrops and claim drops. In a “true” airdrop, the project takes on the cost of gas and directly sends tokens to recipients. In a claim drop, the recipient covers gas.
We wanted the flexibility of these different options because, around the time the project was launching, gas was particularly high. On one hand, we were super low on funds and weren’t sure if we could feasibly take on the cost of airdropping gifts to people. On the other hand, many of the people we wanted to gift were new to NFTs, and, in an environment where a few hundred dollars to mint an NFT wasn’t out of the question, making the experience as frictionless as possible was important to us.
In retrospect, we probably spent too much time optimizing for this question—it was a relatively minor feature in the grand scheme of everything, and we overcomplicated the contract by deploying it with multiple gifting implementations. With smart contracts, simplicity can be key, especially when it comes to functionality as core as minting, but more on that later.
One recurring question I had as I browsed examples of ERC-721 contracts was: where are all the royalties? None of the contracts I reviewed had any mention of royalties written into them. Because of that, it was difficult to even learn what percentage royalties a given project received.
We realized that the state of royalties at the time that we were writing our contract wasn’t what we had expected—marketplaces like OpenSea had off-chain implementations, and the one on-chain royalty standard (EIP-2891) that existed was strictly opt-in. In other words, royalties (an oft-touted benefit of NFTs for artists!) only existed at the discretion of third-party implementations that decided to respect them.
Still, a standard for on-chain royalties was certainly preferable to the alternative, so we decided to implement it.
Because we were writing and deploying our own contract, by default, people wouldn’t be able to list their WITCHES on OpenSea without paying gas. But we had noticed in their docs, sparse as they were about the specifics and why any of it was necessary in the first place, that we had the option to lower this friction by allowing gasless listings. Oizys was able to piece together what we needed to do to enable the functionality: override the isApprovedForAll()
function to allowlist the OpenSea proxy registry address.
We were never expecting an instant sellout for the project, nor was that our aim, which meant that we needed some way to slowly unveil new WITCHES and ensure that minters received a random WITCH (rather than competing to snipe the most desirable WITCHES).
That would be simple enough if we were hosting our metadata on our own servers. But we had decided to use IPFS, a distributed, peer-to-peer file storage system. That complicated matters to some extent. IPFS works by using content-addressed storage—in other words, all files that live on IPFS are hashed, producing a unique identifier based on the specific content of that file, and that determines its “address” on IPFS.
In our case, each time we wanted to make changes to the metadata (i.e., by replacing placeholder WITCHES with revealed ones), we would need to upload a new directory containing all of our metadata files—which meant that the IPFS address would change, which then meant that the contract would need to point to the new one. Because of that, we had to be able to update the base URI rather than have it hardcoded into the contract.
Our acolyte Enthea wrote a script to programmatically reveal batches of WITCHES, reading the number of WITCHES already minted to the contract and then populating the directory of minted witches with the correct metadata, creating placeholders for all the others. We then uploaded a new directory to IPFS each time and updated the base URI in the contract to reflect the new address for the metadata.
Lowering gas usage was a high priority for us, given that gas fees had become so onerous around the time that we were launching. At the time, gas hovered solidly at hundreds of gwei, often even spiking to the thousands.
There were a few different ways we attempted to tackle the issue—compiling the contract with Hardhat using gas optimization for 100,000 runs, minimizing data storage with the Merkle tree for the community sale, and looking into best practices for airdrops (specifically, where we covered gas) to make them more financially viable.
For this last case, we spent a huge amount of time implementing different methods for airdrops and comparing gas usage between them, to see if there was a way to get the best of both worlds. We spent days experimenting with different options, ranging from exploring Cargo’s batch transfer EIP to... just minting on a for
loop. Underwhelmingly, sequentially minting with a for
loop turned out to be the most gas-efficient option.
Oizys, meticulous arcanist that they are, brought up testing. We needed to be able to easily write tests for the contract to ensure that we didn’t break anything as we made changes.
What that meant for the contract was moving some of the variables we had hardcoded (maxWitches
, maxCommunitySaleWitches
, and maxGiftedWitches
) into the constructor. The tradeoff was that it made it harder to read these values at a glance, but it became easier and faster to write tests checking key logic, like what should happen when we hit each of these limits. And, in fact, we did catch several off-by-one bugs this way before we shipped the contract. Tests! They work.
And, finally, transparency. We wanted the limits we’d set for ourselves to be clearly encoded in the contract (i.e., the total number of WITCHES we could ever gift). We hadn’t seen that in any of the contracts we read; the owners of contracts could typically reserve an arbitrary number of tokens for themselves—in BAYC’s case, an infinite number of tokens because the owner-only function wasn’t subject to the max total tokens check.
Similarly, once all the WITCHES were revealed, we wanted to add a verification hash for all the images (which are currently hosted on S3) so that anyone could confirm in the future that they hadn’t been modified.
Late on the night before Halloween, we finally had a contract that had been heavily tested locally and on testnet. It was time to deploy.
We opened the community sale, to no small amount of chaos—there were a few bugs in the minting UI (notably, that the addresses for the community sale were case-sensitive), but people were finally able to begin summoning their WITCHES.
Oizys and I stayed awake until five a.m. that morning, debugging issues and pushing fixes, but at that level of fatigue, we were breaking more than we were fixing. At some point, everyone started experiencing issues with minting. We decided to figure it out in the morning.
The next day, after a few hours of sleep, I quickly realized what had been wrong—we had reverted to an incorrect Merkle tree at some point the previous night, which is why all minting was failing rather than just some of it. I recreated the tree and added the correct root to the contract, and after we redeployed to the website, we announced that minting was fixed. All was well.
We had decided to do a few batch reveals that same day (rather than have people wait) to commemorate the occasion. The first went smoothly on our end—I updated the metadata and set a new base URI pointing to the new IPFS directory without any issues. But it was taking an unsettlingly long amount of time to update on OpenSea.
We kept pressing the refresh metadata button, to no avail, and we were searching for what might be wrong when we came across a (spoiler: incorrect) writeup about how IPFS files couldn’t include file extensions. Then, when we tested the token URI with OpenSea’s validator, it threw an exception, unable to pull the metadata. The .json
extension was written directly into our tokenURI()
function. Did we make an error that would prevent the WITCHES from ever rendering?
Luckily, it was just OpenSea’s slow queueing to process metadata refreshes, and within the hour, the unveiled WITCHES began to appear.
In retrospect, this panic didn’t make any sense—the metadata for the placeholder was working perfectly, for one. But we were exhausted and paranoid, and our first WITCH unveiling had stalled. It was our first post-deployment panic over a bug in immutable code; it wouldn’t be the last.
As people unveiled their WITCHES, the tension and stress of the day dissipated. The reception was glowing, and I was floating on the high, dizzy from the whiplash of emotions. The remainder of the day passed in a blur, with each subsequent reveal unfolding smoothly. I decided to go to bed early that evening but first wanted to send out the WITCHES we had been planning to gift to the friends of the coven on launch day.
I went to Etherscan to call the giftWitches()
function in our contract, but when I attempted to pass it the full list of addresses (eighteen), I was hit with an impossibly high gas fee and an ambiguous error. Tired, I opted to deal with it in the morning.
The next morning, I woke, ready to figure out the gifting at last. I still had no idea what could possibly be wrong; I wondered vaguely if there might be an issue with one of the addresses or even the number of addresses in the list. I started testing the function with different combinations of addresses, with differing outcomes. With just the first half of the addresses, everything looked fine. Swapping some of the addresses worked as well. Without a definitive answer, I decided to mint to the addresses that looked fine, the first half. All good.
I checked the transaction to see if it had been successful. It had, but upon looking more closely, I noticed that two of the WITCHES hadn’t been gifted after all. We had skipped over two IDs entirely.
What happened? Skimming the function, I remembered that we’d implemented a per-wallet limit for gifting as well as for minting in the community and public sales; two of the people we had intended to gift had already minted three WITCHES in the community sale in the short time that it had been open. Disappointing, but not the end of the world.
I turned back to the second half of the addresses. Except, this time, none of them worked—not a single one could be minted without triggering the astronomical gas fee.
With a growing sense of doom, I decided to call giftWitches()
from the command line to see if that would work. Immediately, there, I saw the real error that using Etherscan and Metamask had buried:
ProviderError: execution reverted: ERC721: token already minted
That wasn’t what I had been expecting to see, at all. Suddenly I had a flash of understanding and rushed back to the contract code to reread the function, line by line. How could we be minting a token that had already been minted?
As I read, the situation became clearer, but only to our detriment.
Here was the function I had called:
function giftWitches(address[] calldata addresses) ... {
uint256 ts = totalSupply();
uint256 numToGift = addresses.length;
...
for (uint256 i = 0; i < numToGift; i++) {
if (balanceOf(addresses[i]) < MAX_WITCHES_PER_WALLET) {
_safeMint(addresses[i], ts + i + 1);
}
}
numGiftedWitches += numToGift;
}
There was one crucial error in this code and then one design choice that made it irreparable.
First, we included a check for the per-wallet limit in a for
loop and relied on the iterator to assign IDs—which meant that IDs could be skipped, not a desirable behavior under any circumstance.
Second, we were using totalSupply()
to assign token IDs because we had assumed that the total supply would always be equal to the last token ID we had minted.
But now we were stuck in a situation where we had a total supply of 135 WITCHES, but the last token ID that had been minted was 137. We had skipped 130 and 135, and there was already a token with the ID 136, which the contract considered the “next” token ID. It was an infuriatingly simple bug, one that could have been easily fixed if we had thought to test that edge case.
But we hadn’t, and the contract was trapped in this state, unable to ever mint again—the crisis we had been so relieved to avert just the prior day had come to pass.
So it had happened—our contract had hit a snag that would force us to redeploy after the community had already summoned WITCHES. What now?
The high witches flew into action. Nyx posted a pronouncement notifying the community that minting was on pause as we investigated a flaw in our gifting logic, and we started reaching out to all the people we knew who might be able to help us figure out a fix.
Oizys and I analyzed our options to see if there was any way at all that we could circumvent the bug within the contract. Could we burn all the tokens after the first gap in IDs to reset it to a pre-broken state? Could we deploy a new contract but have it delegate back to the v1 contract for tokens that had already been minted? Redeploying the contract and re-minting all the tokens that had already been minted felt prohibitively expensive—it could exhaust all the funds we had earned from the mint thus far.
After we announced that minting was paused, one Discord user named mersenne inquired about the nature of the bug. They had noticed another potential issue in the contract upon reading it on Etherscan: namely, that the community sale could be drained if a malicious user who had added their address to our community list minted three WITCHES, transferred them to another wallet, and then repeated the process.
In a frankly implausible stroke of luck, this random person who had only just wandered into the Discord an hour earlier turned out to be Matthew Di Ferrante, an experienced Solidity auditor and developer who had been contributing to the Ethereum ecosystem for years. He offered to help us figure out what to do in exchange for a custom WITCH; we agreed, gladly.
With someone with, well, more than a few weeks of experience with writing code on Ethereum, the necessary course of action quickly became clear. We would need to redeploy and roll over all the WITCHES that had already been minted. Outside of the contract, then, we would need to replace the art for the tokens on the v1 contract and (ideally) integrate it into the lore for the world we were building.
Nyx went to work on writing a post explaining the situation to the community. Aletheia started developing the art for what would become the soul vessels—decadent bejeweled vials containing the souls of the WITCHES lost to the ether. Aradia delved into the world-building around the soul vessels, while Keridwen put her pen to weaving the lore behind the so-called VOID WITCH who had snatched these lost WITCHES.
Once we had solidified a plan, Nyx shared out the update to everyone affected, along with what they should expect: that they would be granted new copies of their WITCHES as tokens on another contract, and the WITCHES they had would be turned into soul vessels to commemorate minting at launch. We were terrified of how people might respond to the news.
But perhaps we didn’t need to be. The replies were overwhelmingly kind; not a single person expressed so much as frustration, and, in fact, the community told us to take our time and not rush ourselves. The relief I felt upon seeing that was—indescribable.
With the pressure of immediately redeploying a fixed contract alleviated, Oizys and I turned our attention to the new contract. We now had an opportunity to improve it, not just push out the fixes for the two issues we had discovered.
So we had our order of business: patch the bugs, roll over the mints from the v1 contract, do a proper audit, make any additional improvements we wanted, and then redeploy.
The first problem we needed to fix was the giftWitches()
function. Here was the original glitchy code:
function giftWitches(address[] calldata addresses) ... {
uint256 ts = totalSupply();
uint256 numToGift = addresses.length;
...
for (uint256 i = 0; i < numToGift; i++) {
if (balanceOf(addresses[i]) < MAX_WITCHES_PER_WALLET) {
_safeMint(addresses[i], ts + i + 1);
}
}
numGiftedWitches += numToGift;
}
We addressed the issue in two ways. First, we chose to remove the per-wallet check—it didn’t make sense to us that we would no longer be able to gift a WITCH to someone if they had already minted three on their own.
function giftWitches(address[] calldata addresses) ... {
uint256 numToGift = addresses.length;
numGiftedWitches += numToGift;
for (uint256 i = 0; i < numToGift; i++) {
_safeMint(addresses[i], nextTokenId());
}
}
Just removing the logic around the three-token limit eliminated this specific bug.
But the higher-level problem was how we were assigning token IDs—using totalSupply()
made it unnecessarily fragile. Beyond that, Daniel McCartney (a skilled arcanist and friend upon whom we had called to review the code) observed that we were calling _safeMint()
in five different places, calculating the correct token ID in each place. He suggested extracting the logic into a nextTokenId()
method we could call instead.
We decided to switch to using OpenZeppelin’s Counters
library and wrote a new nextTokenId()
function (as used above) that would ensure that we would never be able to get stuck in a state where the next token ID pointed to an existing token again.
Counters.Counter private tokenCounter;
function nextTokenId() private returns (uint256) {
tokenCounter.increment();
return tokenCounter.current();
}
The next problem was the potential community sale exploit that Mersenne had flagged. The tweak for that was simple; instead of just requiring that msg.sender
had fewer than three WITCHES in their wallet at the time, I added a mapping that tracked community sale mints by address and checked against that count.
mapping(address => uint256) public communityMintCounts;
function mintCommunitySale(
uint8 numberOfTokens,
bytes32[] calldata merkleProof
) ... {
uint256 numAlreadyMinted = communityMintCounts[msg.sender];
require(
numAlreadyMinted + numberOfTokens <= MAX_WITCHES_PER_WALLET,
"Max witches to mint in community sale is three"
);
...
communityMintCounts[msg.sender] = numAlreadyMinted + numberOfTokens;
for (uint256 i = 0; i < numberOfTokens; i++) {
_safeMint(msg.sender, nextTokenId());
}
}
We needed a way to replicate the mints from the previous contract, which was straightforward enough. I wrote a function that took an array of addresses and minted them in order, along with a script that created and formatted the list by talking to the v1 contract.
function rollOverWitches(address[] calldata addresses) ... {
require(
tokenCounter.current() + addresses.length <= 128,
"All witches are already rolled over"
);
for (uint256 i = 0; i < addresses.length; i++) {
communityMintCounts[addresses[i]] += 1;
_mint(addresses[i], nextTokenId());
}
}
All of these mints would count toward the community mint counts. It wasn’t a perfect mapping because a handful of these WITCHES had been gifts, but we didn’t want to further complicate the logic by attempting to differentiate between the two.
Now, the interesting bit. What could we do to improve the contract? With Mersenne, we suddenly had access to a depth of experience with Solidity. As we made changes to the contract, he reviewed them and suggested others. In the end, the v2 contract included five key improvements over v1.
We had made an effort to optimize for gas in v1 of the contract—but we didn’t realize that one choice we had made increased gas usage by ~30,000 per mint and per transfer. Nearly every single ERC-721 contract we read used ERC721Enumerable
, the extension that supplied the totalSupply()
function we had been using and, more importantly, maintained a mapping of which tokens belonged to a particular address.
We had added it without thinking too deeply about it; the value-add was ambiguous to us, but everyone else was using it, so we assumed it wouldn’t hurt to have the functionality. Without a clear mental model of what kinds of computations cost more or less gas, it didn’t occur to us that the data structures that the extension added could make the two key features of our contract (mints and transfers) as much as 50% more expensive.
With that knowledge, we decided to cut ERC721Enumerable
in favor of off-chain implementations that didn’t require witches to pay a premium on every mint or transfer forever.
In writing the original contract, we considered reentrancy as an attack vector but incorrectly believed that our contract wasn’t susceptible because we weren’t calling arbitrary contracts. It turned out that we had missed a call inside OpenZeppelin’s implementation of _safeMint()
—there was a function that did call a function on the receiver address if it was a contract. That meant that it was possible for contracts to call back into ours, sidestepping the checks we had put in place.
Because of that, we had to restructure how a few of our functions were written to update the state we were tracking in the contract before minting to ensure that they couldn’t be exploited with reentrancy.
For example, here was the original claim()
function that allowed people to claim a WITCH for the cost of gas only:
function claim(bytes32[] calldata merkleProof) ... {
uint256 ts = totalSupply();
...
require(!claimed[msg.sender], "Witch already claimed by this wallet");
...
_safeMint(msg.sender, ts + 1);
claimed[msg.sender] = true;
numGiftedWitches += 1;
}
Logically, this code made sense—only once the mint was successful did we consider it claimed by the address and added to the total count of gifted WITCHES.
But because _safeMint()
allowed reentrant calls back to the function, a malicious contract with a valid Merkle proof could keep calling back into claim()
to mint more WITCHES, before we had been able to update our state.
Updating the state before performing actions like minting handled the issue.
function claim(bytes32[] calldata merkleProof) ... {
require(!claimed[msg.sender], "Witch already claimed by this wallet");
claimed[msg.sender] = true;
numGiftedWitches += 1;
_safeMint(msg.sender, nextTokenId());
}
On top of that, we opted to use OpenZeppelin’s ReentrancyGuard
module, which offers a nonReentrant
modifier that prevents reentrant calls to functions. We took care to add guards even to onlyOwner
functions that only we could potentially exploit, as a measure to increase trust.
And, speaking of modifiers, they were the next improvement that Mersenne offered. We hadn’t been familiar with the concept in Solidity, but I had felt the pain of our oft-duplicated require
calls across our functions.
Function modifiers are a declarative way to “modify” functions, with code that runs before or after a function call. They’re typically used to wrap checks, both for readability and reusability.
Here’s one example of how our mint()
function changed. We started out with this code:
function mint(uint256 numberOfTokens) external payable {
uint256 ts = totalSupply();
require(isPublicSaleActive, "Public sale is not open");
require(
balanceOf(msg.sender) + numberOfTokens <= MAX_WITCHES_PER_WALLET,
"Max witches already minted to this wallet"
);
require(
ts + numberOfTokens <= maxWitches - maxGiftedWitches,
"Not enough witches remaining to mint"
);
require(
PUBLIC_SALE_PRICE * numberOfTokens <= msg.value,
"Incorrect ETH value sent"
);
for (uint256 i = 0; i < numberOfTokens; i++) {
_safeMint(msg.sender, ts + i + 1);
}
}
Not the worst, but also not easy to parse at a glance. Especially since several of those require
calls were the same ones we needed in other functions like mintCommunitySale()
or giftWitches()
, pulling them out into modifiers vastly improved the reading experience and made the code less error-prone.
With modifiers, the content of the mint()
function became trivial—three lines of code, with an easy-to-understand list of modifiers that applied to it.
modifier publicSaleActive() {
require(isPublicSaleActive, "Public sale is not open");
_;
}
modifier maxWitchesPerWallet(uint256 numberOfTokens) {
require(
balanceOf(msg.sender) + numberOfTokens <= MAX_WITCHES_PER_WALLET,
"Max witches to mint is three"
);
_;
}
modifier canMintWitches(uint256 numberOfTokens) {
require(
tokenCounter.current() + numberOfTokens <=
maxWitches - maxGiftedWitches,
"Not enough witches remaining to mint"
);
_;
}
modifier isCorrectPayment(uint256 price, uint256 numberOfTokens) {
require(
price * numberOfTokens == msg.value,
"Incorrect ETH value sent"
);
_;
}
function mint(uint256 numberOfTokens)
external
payable
nonReentrant
isCorrectPayment(PUBLIC_SALE_PRICE, numberOfTokens)
publicSaleActive
canMintWitches(numberOfTokens)
maxWitchesPerWallet(numberOfTokens)
{
for (uint256 i = 0; i < numberOfTokens; i++) {
_safeMint(msg.sender, nextTokenId());
}
}
We had a withdraw()
function in v1 of the contract to withdraw the ETH from the mint, but Mersenne added a function to withdraw ERC-20 tokens as well, so that we were able to pull out any accidentally sent to our contract address.
Initially I thought that it was nice to have this function, just in case, but also the odds weren’t super high that we’d need it. I was wrong! EIP-2981, the standard for on-chain royalties, explicitly does not specify a currency or token, and, in fact, some major marketplaces like LooksRare that have implemented on-chain royalties pay them in WETH, not ETH. Without this function, we wouldn’t have been able to withdraw these royalties; they would be trapped in the contract forever.
Finally, as we wrapped work on the contract and readied for redeployment, Mersenne noted that the OpenSea proxy registry approval we had enabled for gasless listings could be a vulnerability for the contract, in the event that OpenSea were ever compromised. If we thought there was a chance that Crypto Coven could become a truly long-term project, it would be wise to add a toggle that allowed us to disable access.
bool private isOpenSeaProxyActive = true;
function setIsOpenSeaProxyActive(bool _isOpenSeaProxyActive) ... {
isOpenSeaProxyActive = _isOpenSeaProxyActive;
}
function isApprovedForAll(address owner, address operator) ... {
...
if (
isOpenSeaProxyActive &&
address(proxyRegistry.proxies(owner)) == operator
) {
return true;
}
...
}
It was a helpful reminder that, in a world of code as long-lived (and an environment as adversarial) as Ethereum, it can be important to take the long view. Companies come and go; the blockchain is forever.
At last, on November 3, we were ready to redeploy. The whole process, as long and arduous as it had felt, had taken just two days.
I deployed the v2 contract, ran the script to generate the most up-to-date list of current owners of tokens from the v1 contract, and called the rollover function. Just like that, minting could resume. The next day, we updated the base URI for the original contract, replacing the WITCHES with the soul vessels. The transition was complete.
In the coming weeks, the state of the project would transform utterly. The coven would start to grow—slowly at first, and then all at once. Soon, countless people we admired, in and out of web3, would start to assume the visage of a WITCH. The first sprouts of conceptual expansions of our world would start to emerge out of the community. And, underneath it all, the contract (its own subject of no little praise) hummed, surely and steadily, holding the coven fast.
I close this imparting of learnings with perhaps the most important one of all: no smart contract is perfect. The same is true for ours. The Crypto Coven contract that now lives on mainnet contains two known bugs, one of which spelled the return of the void witch. Both are detailed in this earlier post, and fortunately neither was severe enough that we had to take more drastic action again.
Even outside of these flaws, with the knowledge we’ve accrued since this first attempt at conjuring artifacts out of the ether, there are further refinements I’d make—for better on-chain composability, for even lower gas usage, for less fragile batch reveals, and so on. This domain is still just emerging; day by day, week by week, the collective wisdom evolves in novel and intriguing directions.
And, finally: writing immutable code that deals in large sums of coin is emotionally taxing. The sob emoji became the most frequently used on my keyboard; I’ve cursed the very existence of smart contracts more times than I can remember.
But, more earnestly, it is good to face shipping Solidity with apprehension. That’s how it should be. It’s not a responsibility to take lightly. Doing high-stakes work has high-stakes consequences, and once the die is cast, it cannot be undone. The choices you make, knowingly or unknowingly, have gravity.
There will be errors, because code is code, and constructing an incantation to perfectly mirror your wishes requires more than just caution or care. But, as arcanists, the most we can endeavor to do is our finest work—and no less.