Deep dive inside the JellyBots smart contract

For the sake of transparency, we found it important, in addition to publicly publishing the source code of the JellyBots smart contract, to be able to explain line by line what it does.
Our contract is pretty standard in appearance as it follows the ERC721 standard (ERC721A to be more precise) but we did one thing very differently than other collections: we have an evolutive price that starts at 0.0001 ETH and that increases by 0.0001 ETH on every mint. It's not the point of this article to explain why we did it that way (especially since we already did it in a previous article) but it's important to understand that this single peculiarity actually drove the whole way the contract was written.
If you've never seen a Solidity smart contract in your entire life, I recommend reading our previous article where we explain how to write a very simple smart contract for an NFT collection, then come back here.
So, first of all, what do we want our contract to do?
- We want people to be able to mint one or more JellyBots.
- We want to be able to airdrop JellyBots to people of our choice (for many different reasons).
That's it, we don't want our contract to do more than that. Unfortunately, having an evolutive price caused us some problems that we had to think about and to which we had to find elegant solutions.

For the minting process, we already have a problem. Imagine two people wanting to mint exactly at the same time. If say 13 JellyBots have already been minted, they will both be presented with a price of 0.0014 ETH to mint. The thing is, one transaction will work and the other will fail. The reason for that is that one will inevitably come before the other and the second one will fail because the price of 0.0014 ETH that is sent to the contract will not be considered enough. It would expect to receive 0.0015 ETH in that case because 14 JellyBots have already been minted at that point and not 13 anymore. Needless to say that the problem would be even worse if people tried to mint more than one JellyBot at a time. This would definitely lead to situations where most transactions fail, leading to a lot of frustration.
To counter these problems, we did three different things. First of all, we made it impossible in the contract to mint more than one JellyBot at a time. You can mint as many JellyBots as you want in total but you have to mint them one by one (one transaction per JellyBot). This can be annoying if you really like the collection and you want to mint 100 JellyBots, but we'll come back to that later.
The second thing we did is to constantly listen to the totalSupply
method of the contract on the mint page of our website. If we constantly know how many JellyBots have already been minted, we can make sure we always display the latest price you have to pay to mint a JellyBot and send it to the mint
function of the contract if you decide to mint. This was made very easy thanks to the watch
parameter of wagmi's useContractRead
.
const { data } = useContractRead({
addressOrName: CONTRACT_ADDRESS,
contractInterface: jellyBotsABI,
functionName: "totalSupply",
watch: true,
});
But that's still not enough. If two people click at the exact same time on the mint button, one unlucky person will still encounter a failure. So, the last thing we did is to allow people (but not forcing them) to add what we call a "tip" of 0.0005 ETH when they mint. This is not really a tip though, it's really made to avoid failed transactions. With that 0.0005 ETH tip, you're almost sure that your transaction will succeed because it would only fail if more than 5 people mint at the exact same time, and we don't think that will happen (and if that happens, it won't be very often). At the time of writing, 0.0005 ETH is about $0.6 so it won't financially kill you while pretty much guaranteeing that you won't pay gas fees for a failed transaction.

OK, we solved that first problem, but as we already said, we created another one. What if you really like the collection and you want to mint 100, 200, or 500 JellyBots? We still want to have that possibility, so we created the mintMultiple
method in our contract, but we made it kind of exclusive, the same way a SaaS product would have an enterprise plan. If we get approached for an important mint (of more than 50 JellyBots), we will make it possible by pausing the contract for regular mint (so it doesn't mess with it) and allowing people to call mintMultiple
in a specific time window. It will also be required to be whitelisted to be able to call the mintMultiple
method (which is a different use of a whitelist than what is usually done). We will always give at least a 3-hour notice before pausing the contract to allow someone to use the mintMultiple
method (it will be announced on our Twitter) and the pause will never last longer than 30 minutes.
Here is how the mint
and mintMultiple
methods look in the contract:
// ...
contract JellyBots is ERC721A, Ownable, ReentrancyGuard {
// ...
uint256 private constant MAX_SUPPLY = 10000;
uint256 private constant INCREMENT = 0.0001 ether;
// ...
function mint() external payable whenNotPaused nonReentrant {
address addr = msg.sender;
uint256 price = (totalSupply() + 1) * INCREMENT;
require(currentStep == Step.Sale, "Public sale is not active");
require(totalSupply() + 1 <= MAX_SUPPLY, "Maximum supply exceeded");
require(msg.value >= price, "Not enough funds");
_safeMint(addr, 1);
}
function mintMultiple(bytes32[] calldata merkleProof, uint256 _quantity)
external
payable
whenPaused
nonReentrant
{
address addr = msg.sender;
uint256 price = (totalSupply() *
_quantity +
(_quantity * (_quantity + 1)) /
2) * INCREMENT;
require(merkleRoot != "", "Merkle root is not set");
require(
MerkleProof.verify(
merkleProof,
merkleRoot,
keccak256(abi.encodePacked(addr))
),
"Invalid Merkle proof"
);
require(currentStep == Step.Sale, "Public sale is not active");
require(
totalSupply() + _quantity <= MAX_SUPPLY,
"Maximum supply exceeded"
);
require(msg.value >= price, "Not enough funds");
_safeMint(addr, _quantity);
}
// ...
}
As you can see, our mint
method is pretty standard. We calculate the price with a simple formula, then we check that the minting is currently enabled (via currentStep
), that we did not exceed the max supply of 10,000 yet, and that we sent enough funds to the method to be able to mint.
The mintMultiple
is a bit more complex because the formula to calculate the price is a bit harder to read (but it will look familiar if you didn't sleep during math class, see below) and because of all the processes with the Merkle tree to implement the whitelist. I won't go into the details of implementing a whitelist with a Merkle tree, because others have done it 100 times before me, but just remember that it's a very efficient way to manage a whitelist, without having to store all of the whitelisted wallet addresses in the contract (which is very expensive).

Notice the nonReentrant
, whenNotPaused
and whenPaused
modifiers on these two methods. The nonReentrant
modifier comes from the ReentrancyGuard from OpenZeppelin and is there to avoid the reentrancy attack. whenNotPaused
and whenPaused
are modifiers we implemented ourselves (even though we could have used Pausable from OpenZeppelin) to indicate that a method can only be called when a contract is not paused or when a contract is paused.
This is how we implemented it:
// ...
contract JellyBots is ERC721A, Ownable, ReentrancyGuard {
// ...
bool private paused = false;
// ...
modifier whenNotPaused() {
require(!paused, "Contract is currently paused");
_;
}
modifier whenPaused() {
require(paused, "Contract is not currently paused");
_;
}
// ...
function pause() external onlyOwner {
paused = true;
}
function unpause() external onlyOwner {
paused = false;
}
function isPaused() external view returns (bool) {
return paused;
}
// ...
}
That's it for the mint, but as we said at the beginning of the article, we also want to be able to airdrop JellyBots, in case we have contests or things like that. We want to have two kinds of airdrops: a regular one (airdrop to one person) called airdrop
and a bulk one (airdrop to multiple people at the same time) called bulkAirdrop
. Note that these two methods will only be callable when the contract is paused, with the whenPaused
modifier.
This is how we implemented these two methods:
// ...
contract JellyBots is ERC721A, Ownable, ReentrancyGuard {
// ...
uint256 private constant MAX_SUPPLY = 10000;
// ...
function airdrop(address _addr, uint256 _quantity)
external
onlyOwner
whenPaused
nonReentrant
{
require(
totalSupply() + _quantity <= MAX_SUPPLY,
"Maximum supply exceeded"
);
_safeMint(_addr, _quantity);
}
function bulkAirdrop(address[] memory _addrs, uint256 _quantity)
external
onlyOwner
whenPaused
nonReentrant
{
require(
totalSupply() + _quantity * _addrs.length <= MAX_SUPPLY,
"Maximum supply exceeded"
);
for (uint256 i = 0; i < _addrs.length; i++) {
_safeMint(_addrs[i], _quantity);
}
}
// ...
}
Another interesting thing we did is to make sure we can only set the baseURI
once, so you can be assured that we cannot change the token URIs afterward. This is a good practice all NFT smart contracts should implement, but very few do.
// ...
contract JellyBots is ERC721A, Ownable, ReentrancyGuard {
// ...
string private baseURI;
// ...
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 {
require(
bytes(baseURI).length == 0,
"You can only set the base URI once"
);
baseURI = _baseURI;
}
function getBaseURI() external view returns (string memory) {
return baseURI;
}
// ...
}
The rest of the contract is pretty standard and very similar (if not exactly the same) to what we did for the Stupid Faces collection. If you want to see it in its entirety, the contract is available on Etherscan.
Now let's talk about the metadata...
The URI to a metadata JSON file is what you actually own when you purchase an NFT, so it's important to understand how it works.
To know the URI of the metadata JSON file of your NFT, you just have to call the tokenURI
method of the contract by passing the id of the token. Ideally, the JSON files are placed on a decentralized, durable, and immutable storage solution like IPFS or Arweave so you're sure your metadata is never tampered with.
In JellyBots' case, we went with Arweave, and the URI to the metadata JSON file will look like this: ar://abc/1.json
. As this URI is not exploitable directly (by a web browser or anything else), you need to go through a gateway to be able to access the file. For Arweave, the official gateway is https://arweave.net
so ar://abc/1.json
becomes https://arweave.net/abc/1.json
and now you can see the content of the file:
{
"description": "Fully-equipped bots, 100% made of organic gelatin. Ready to replace your real self while you're in the metaverse.",
"external_url": "https://jellybots.rocks/art/1",
"image": "ar://xyz/1.png",
"name": "Henry Shoemaker",
"attributes": [{
"trait_type": "Background",
"value": "Classic Turquoise"
}, {
"trait_type": "Shadow",
"value": "Shadow"
}, {
"trait_type": "Skin",
"value": "Monster Skin"
}, {
"trait_type": "Mouth",
"value": "Short Purple"
}, {
"trait_type": "Clothes",
"value": "Sport Red"
}, {
"trait_type": "Beard",
"value": "Captain Blue"
}, {
"trait_type": "Noz",
"value": "Noz"
}, {
"trait_type": "Hair",
"value": "Bumby Red"
}, {
"trait_type": "Eyes",
"value": "Regular Red"
}, {
"trait_type": "Ear",
"value": "Millenial"
}, {
"display_type": "date",
"trait_type": "Birthday",
"value": 313267455
}, {
"trait_type": "Strength",
"value": 42,
"max_value": 100
}, {
"trait_type": "Constitution",
"value": 22,
"max_value": 100
}, {
"trait_type": "Dexterity",
"value": 36,
"max_value": 100
}, {
"trait_type": "Comeliness",
"value": 49,
"max_value": 100
}, {
"trait_type": "Intelligence",
"value": 93,
"max_value": 100
}, {
"trait_type": "Wisdom",
"value": 94,
"max_value": 100
}, {
"trait_type": "Charisma",
"value": 20,
"max_value": 100
}, {
"trait_type": "Luck",
"value": 34,
"max_value": 100
}, {
"trait_type": "Insanity",
"value": 45,
"max_value": 100
}, {
"trait_type": "Computing Power",
"value": 94,
"max_value": 1000
}]
}
The most important information here is image_url
which is the URI to the PNG file for the JellyBot. Like the metadata JSON file, the file should ideally be on IPFS or Arweave. With the Arweave gateway, ar://xyz/1.png
translates to https://arweave.net/xyz/1.png
and that's how you can access the image for the NFT (but you could use any other gateway).
The other fields in the JSON file are less crucial but still very important:
name
is the name of the JellyBot. We decided to give a unique name to each of our JellyBots.description
is a description of the JellyBot. In this case, we decided to put the same description for all the JellyBots, because we didn't have time (and motivation) to write 10,000 individual descriptions (I hope you understand).external_url
is an individual URL for the JellyBot. We created something on our website for that (for example https://jellybots.rocks/art/1) but most collections use the same URL for all their tokens there (they usually put the URL of their website).attributes
is where the fun happens. An NFT usually has different traits to describe how it looks (background, hair, nose, ears, mouth, etc.), some traits being rarer than others. That's whatBackground
,Shadow
,Skin
,Mouth
,Clothes
,Beard
,Noz
,Hair
,Eyes
, andEar
are there for: describe the physical aspect of the JellyBot. Then we have theBirthday
trait because we want all of our JellyBots to have a birth date. And finally, we have what we call the ability scores, inspired by games like Dungeons & Dragons:Strength
,Constitution
,Dexterity
,Comeliness
,Intelligence
,Wisdom
,Charisma
,Luck
,Insanity
, andComputing Power
. These ability scores are completely useless at the moment, but we wanted them there just in case we want to create a game or something else at some point. As a reminder, regardless of the previously mentioned traits, all the JellyBots will have the same power: giving you access for life to all the products 0x3 Studio makes.

Note that we'll also have 10 super rare tokens (aka 1:1) that look completely different from the others. These super rare tokens will have ability scores like regular tokens but only one physical trait called Signature Series
that will have a single unique value. These super rare tokens will not have more utility than the others (at least for now), we just made them because it's fun, and we like fun.

Finally, you should know that we won't have a reveal phase and hide the metadata for the tokens not yet minted like most collections do. You'll be able to see what's coming in advance, with the 10 super rare tokens having ids that are multiple of 1,000. We do it that way because to implement a reveal or to hide metadata for a certain time, we'd need a proxy URL instead of using IPFS or Arweave URIs directly, and that means our metadata would be centralized until our reveal and sell out. It's fine for collections that expect to sell out quickly, but that's not the case for us due to our special pricing and utility. Our plan from the beginning is to sell out within three years. We're not here for the quick money, we're here for the long term.
Oh, and by the way, if you want to mint a JellyBot, you can do it here: https://jellybots.rocks/mint
Thanks for reading!