Introduction
This is my first time reviewing an NFT smart contract from front to back and while I'm not an expert I'm confident in my ability to explain the concepts within this project in a concise and digestible way. With all that being said let us begin:
Disclaimer: YOU MUST have even a basic understanding of solidity to follow along.
Solidity version
pragma solidity ^0.8.0;
This line of code is the version of solidity the team used to write this smart contract.
Import statements
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./ERC721A.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
These import statements allow the team to use functions from the contracts that are being imported.
import "@openzeppelin/contracts/access/Ownable.sol";
This is a contract that tells who has access to perform certain tasks. The access control of your contract may govern who can mint tokens, vote on proposals, freeze transfers, and many other things it basically gives the creator of the contract administrative powers.
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
This contract protects against reentrancy attacks. A reetrancy attack is essentially when function makes an external call to an outside contract that in turn makes a recursive call back to drain funds from the first function.
import "./ERC721A.sol";
This is a special contract written by the AZUKI team to reduce gas costs during the minting process. You can read more about it here ERC721A
import "@openzeppelin/contracts/utils/Strings.sol";
This contract helps with any extra utility in this instance handling string conversion.
contract Azuki is Ownable, ERC721A, ReentrancyGuard
This line of code shows us that the azuki contract is inheriting from Ownable, ERC721A and ReentrancyGuard, this means Azuki will be able to use functions written in those 3 functions without writing a single line of code. This essentially saves time.
Some of the state variables
uint256 public immutable maxPerAddressDuringMint;
uint256 public immutable amountForDevs;
uint256 public immutable amountForAuctionAndDev;
These are state variables that can be both used externally and internally, immutable means that once the value is set it can't be changed, It also saves on gas because you wont be able to change its value.
uint256 public immutable maxPerAddressDuringMint;
This variable represents the maximum amount of NFTs each address can get during the minting process.
uint256 public immutable amountForDevs;
This represents the amount of NFTs set aside for the dev team I'm assuming.
uint256 public immutable amountForAuctionAndDev;
This represents the amount of NFTs allocated for the auction and the dev team.
Struct: SaleConfig
struct SaleConfig {
struct SaleConfig {
uint32 auctionSaleStartTime;
uint32 publicSaleStartTime;
uint64 mintlistPrice;
uint64 publicPrice;
uint32 publicSaleKey;
}
SaleConfig public saleConfig;
This struct allows the team to create some details about the selling process. Structs allow us to create our own data types in the form of structures. This struct will hold the auction start time, public sale time, the minting list price and the public price. The bottom piece of code is the declaration of the struct.
Mapping: allowlist
mapping(address => uint256) public allowlist;
This is a mapping of the whitelisted addresses for the NFTs.
Constructor
constructor(
uint256 maxBatchSize_,
uint256 collectionSize_,
uint256 amountForAuctionAndDev_,
uint256 amountForDevs_
) ERC721A("Azuki", "AZUKI", maxBatchSize_, collectionSize_) {
maxPerAddressDuringMint = maxBatchSize_;
amountForAuctionAndDev = amountForAuctionAndDev_;
amountForDevs = amountForDevs_;
require(
amountForAuctionAndDev_ <= collectionSize_,
"larger collection size needed"
);
}
This constructor is essentially initializing these state variables. Its also gives the NFTs a name and a symbol.
require(
amountForAuctionAndDev_ <= collectionSize_,
"larger collection size needed"
);
This require statement is basically telling us that as long as the collection size of the NFTs is greater than the NFTs set aside for auction and the developer team it will run fine.
Modifier: callerisUser
modifier callerIsUser() {
require(tx.origin == msg.sender, "The caller is another contract");
_;
}
Modifiers allow us to change the behaviour of a function. This modifier is checking if the caller is the user before minting.
Function 1: auctionMint()
function auctionMint(uint256 quantity) external payable callerIsUser {
uint256 _saleStartTime = uint256(saleConfig.auctionSaleStartTime);
require(
_saleStartTime != 0 && block.timestamp >= _saleStartTime,
"sale has not started yet"
);
require(
totalSupply() + quantity <= amountForAuctionAndDev,
);
require(
numberMinted(msg.sender) + quantity <= maxPerAddressDuringMint,
"can not mint this many"
);
uint256 totalCost = getAuctionPrice(_saleStartTime) * quantity;
_safeMint(msg.sender, quantity);
refundIfOver(totalCost);
}
This is the mint function used for the auction
uint256 _saleStartTime = uint256(saleConfig.auctionSaleStartTime);
This variable represents the Auction sale start time.
require(
_saleStartTime != 0 && block.timestamp >= _saleStartTime,
"sale has not started yet"
);
This require statement checks if the auction sale has started.
require(
totalSupply() + quantity <= amountForAuctionAndDev,
"not enough remaining reserved for auction to support desired mint amount"
);
This require statement is ensuring that the amount minted so far and the quantity the user wants is less than the allocated amount.
require(
numberMinted(msg.sender) + quantity <= maxPerAddressDuringMint,
"can not mint this many"
);
This require statement is ensuring that the number of nfts that the wallet that's the calling the function owns and the quantity they want is less than the amount of NFTs allowed per address during the minting process.
uint256 totalCost = getAuctionPrice(_saleStartTime) * quantity;
The total cost is the current price of the auction(I'll dissect this function later in the article), the parameter that it takes is ensuring that the auction has indeed started multiplied by the amount the user is requesting.
_safeMint(msg.sender, quantity);
refundIfOver(totalCost);
The first line is minting the amount of NFTs the caller inputs and the address to send it to. The second line just refunds a users ether if they sent more than what is required.
Function 2: allowlistMint()
function allowlistMint() external payable callerIsUser {
uint256 price = uint256(saleConfig.mintlistPrice);
require(price != 0, "allowlist sale has not begun yet");
require(allowlist[msg.sender] > 0, "not eligible for allowlist mint");
require(totalSupply() + 1 <= collectionSize, "reached max supply");
allowlist[msg.sender]--;
_safeMint(msg.sender, 1);
refundIfOver(price);
}
This function is used to mint NFTs that are whitelisted. The function is payable meaning it can send and receive ether, it's external meaning that it can only be interacted with via outside contracts but not internally and it's using the callerisUser modifier.
uint256 price = uint256(saleConfig.mintlistPrice);
This variable represents the pricing of the NFTs via the whitelist
require(price != 0, "allowlist sale has not begun yet");
This is essentially saying that if the price is at 0 then the auction hasn't started as yet.
require(allowlist[msg.sender] > 0, "not eligible for allowlist mint");
This require statement is basically a safety precaution to ensure that only people that are on the white list can receive an NFT.
require(totalSupply() + 1 <= collectionSize, "reached max supply");
This is saying that if the total amount of NFTs minted so far plus 1 is greater than or equal to the collection they cant mint anymore because it's finished.
allowlist[msg.sender]--;
For this piece of code, I'm assuming It subtracts an address from the mapping allowlist so that people can't be nefarious and continuously mint new NFTs.
_safeMint(msg.sender, 1);
refundIfOver(price);
The first line is minting one NFT to an address while the second line is a refunding function.
Function 3: publicSaleMint()
function publicSaleMint(uint256 quantity, uint256 callerPublicSaleKey)
external
payable
callerIsUser
{
SaleConfig memory config = saleConfig;
uint256 publicSaleKey = uint256(config.publicSaleKey);
uint256 publicPrice = uint256(config.publicPrice);
uint256 publicSaleStartTime = uint256(config.publicSaleStartTime);
require(
publicSaleKey == callerPublicSaleKey,
"called with incorrect public sale key"
);
require(
isPublicSaleOn(publicPrice, publicSaleKey, publicSaleStartTime),
"public sale has not begun yet"
);
require(totalSupply() + quantity <= collectionSize, "reached max supply");
require(
numberMinted(msg.sender) + quantity <= maxPerAddressDuringMint,
"can not mint this many"
);
_safeMint(msg.sender, quantity);
refundIfOver(publicPrice * quantity);
}
This function handles the minting process for the public sale
SaleConfig memory config = saleConfig;
uint256 publicSaleKey = uint256(config.publicSaleKey);
uint256 publicPrice = uint256(config.publicPrice);
uint256 publicSaleStartTime = uint256(config.publicSaleStartTime);
From what I know about gas optimization my guess here is that the solidity team is wrapping the items stored in the saleConfig into local variables this is a pretty common trick to save on gas consumption because state variables are more expensive to process. Here we have the public sale key which I think is a key to some sort of wallet, the public price for the NFT sale and the start time for said sale.
require(
publicSaleKey == callerPublicSaleKey,
"called with incorrect public sale key"
);
You won't be able to continue if you have the wrong key, this is a safety precaution.
require(
isPublicSaleOn(publicPrice, publicSaleKey, publicSaleStartTime),
"public sale has not begun yet"
);
This checks if the public sale has started as yet.
require(totalSupply() + quantity <= collectionSize, "reached max supply");
This require statement is checking if the amount of NFTs minted for the public sale plus the quantity the user is requesting is greater than the amount allocated for the sale.
require(
numberMinted(msg.sender) + quantity <= maxPerAddressDuringMint,
"can not mint this many"
);
This is ensuring that the amount of NFTs in the wallet of this caller plus the amount they're requesting is less than the number of NFTs allowed per address during the minting process.
_safeMint(msg.sender, quantity);
refundIfOver(publicPrice * quantity);
I already explained these two above
Function 4: refundIfOver()
function refundIfOver(uint256 price) private {
require(msg.value >= price, "Need to send more ETH.");
if (msg.value > price) {
payable(msg.sender).transfer(msg.value - price);
}
}
This function allows for refunds if you send more Eth than is required for purchases.
require(msg.value >= price, "Need to send more ETH.");
This require statement is checking if the price parameter is less than the amount the user sent. This is a security measure to ensure that people cant run scams on the function, if this statement is true then we can continue to get back our money.
if (msg.value > price) {
payable(msg.sender).transfer(msg.value - price);
}
This if statement is saying that if the users ether is more than the price subtract the price from the amount of ether they sent and send it back to their address.
Function 5: isPublicSaleOn()
function isPublicSaleOn(
uint256 publicPriceWei,
uint256 publicSaleKey,
uint256 publicSaleStartTime
) public view returns (bool) {
return
publicPriceWei != 0 &&
publicSaleKey != 0 &&
block.timestamp >= publicSaleStartTime;
}
This function checks if the public sale for the NFT is on or not
State variables for the Dutch Auction
uint256 public constant AUCTION_START_PRICE = 1 ether;
uint256 public constant AUCTION_END_PRICE = 0.15 ether;
uint256 public constant AUCTION_PRICE_CURVE_LENGTH = 340 minutes;
uint256 public constant AUCTION_DROP_INTERVAL = 20 minutes;
uint256 public constant AUCTION_DROP_PER_STEP =
(AUCTION_START_PRICE - AUCTION_END_PRICE) /
(AUCTION_PRICE_CURVE_LENGTH / AUCTION_DROP_INTERVAL);
These are some variables for the auction sale. Azuki is using the Dutch Auction style which starts at the highest price point and then drops down in intervals until a general price is reached, you can read more about it here Dutch-Auction. These variables represent the starting price, ending price, the duration of the auction(This is my assumption, in economics, curve length represents the distance between two points so I'm assuming this AUCTION_PRICE_CURVE_LENGTH represents the distance from the start price and end price in minutes format), the time between intervals which is 20 minutes and I'm assuming the last variable is the price of the NFT after every drop.
Function 6: getAuctionPrice()
function getAuctionPrice(uint256 _saleStartTime)
public
view
returns (uint256)
{
if (block.timestamp < _saleStartTime) {
return AUCTION_START_PRICE;
}
if (block.timestamp - _saleStartTime >= AUCTION_PRICE_CURVE_LENGTH) {
return AUCTION_END_PRICE;
} else {
uint256 steps = (block.timestamp - _saleStartTime) /
AUCTION_DROP_INTERVAL;
return AUCTION_START_PRICE - (steps * AUCTION_DROP_PER_STEP);
}
}
This function tells us the current price of the auction.
if (block.timestamp < _saleStartTime) {
return AUCTION_START_PRICE;
}
This if statement returns the current price if the auction hasn't started.
if (block.timestamp - _saleStartTime >= AUCTION_PRICE_CURVE_LENGTH) {
return AUCTION_END_PRICE;
This if statement returns the end price of the auction if the current time minus the auction starting time is greater than the overall time of the auction.
else {
uint256 steps = (block.timestamp - _saleStartTime) /
AUCTION_DROP_INTERVAL;
return AUCTION_START_PRICE - (steps * AUCTION_DROP_PER_STEP);
}
This else statement is saying that if the two conditions aren't met then return the starting price subtracted by the current time which is multiplied by the price per drop. Which gives us the current price.
Function 7: endAuctionAndSetupNonAuctionSaleInfo()
function endAuctionAndSetupNonAuctionSaleInfo(
uint64 mintlistPriceWei,
uint64 publicPriceWei,
uint32 publicSaleStartTime
) external onlyOwner {
saleConfig = SaleConfig(
0,
publicSaleStartTime,
mintlistPriceWei,
publicPriceWei,
saleConfig.publicSaleKey
);
}
This function ends the auction and gives us some details on the said auction.
Function 8: setAuctionSaleStartTime()
function setAuctionSaleStartTime(uint32 timestamp) external onlyOwner {
saleConfig.auctionSaleStartTime = timestamp;
}
This function sets the start time for the auction.
Function 9: setPublicSaleKey()
function setPublicSaleKey(uint32 key) external onlyOwner {
saleConfig.publicSaleKey = key;
}
This function sets the Public key for the wallet the azuki team used for the auction I'm assuming.
Function 10: seedAllowlist()
function seedAllowlist(address[] memory addresses, uint256[] memory numSlots)
external
onlyOwner
{
require(
addresses.length == numSlots.length,
"addresses does not match numSlots length"
);
for (uint256 i = 0; i < addresses.length; i++) {
allowlist[addresses[i]] = numSlots[i];
}
}
This function sets the whitelisted addresses and the amount of NFTs each account can mint
Function 11: devMint()
function devMint(uint256 quantity) external onlyOwner {
amount for dev.
require(
totalSupply() + quantity <= amountForDevs,
);
require(
quantity % maxBatchSize == 0,
"can only mint a multiple of the maxBatchSize"
);
uint256 numChunks = quantity / maxBatchSize;
for (uint256 i = 0; i < numChunks; i++) {
_safeMint(msg.sender, maxBatchSize);
}
}
This function I'm assuming mints the NFTs allocated for the developer team.
require(
totalSupply() + quantity <= amountForDevs,
"too many already minted before dev mint"
);
This require statement is saying that the amount of NFTs already minted plus the amount the user is requesting should be less than the amount allocated for the dev team
require(
quantity % maxBatchSize == 0,
"can only mint a multiple of the maxBatchSize"
);
I'm not exactly sure why they used this require statement but it's essentially saying that the quantity of NFTs the user requests must be a multiple of the amount of NFTs allowed per address during the minting cycle.
uint256 numChunks = quantity / maxBatchSize;
for (uint256 i = 0; i < numChunks; i++) {
_safeMint(msg.sender, maxBatchSize);
}
This is saying that the numChunks variable is equal to the amount the caller wants, divided by how much they are allowed to mint during the minting process. Looping in solidity is expensive because you get charged gas per iteration so to solve something like this we wrap state variables inside a local variable and then loop which is the cheaper option. This for loop will continue to print the maximum amount of NFTs allowed per address until the condition isn't true anymore
Meta Data
Function 12: _baseURI()
Function 13: setBaseURI()
string private _baseTokenURI;
function _baseURI() internal view virtual override returns (string memory) {
return _baseTokenURI;
}
function setBaseURI(string calldata baseURI) external onlyOwner {
_baseTokenURI = baseURI;
}
This string and two functions handle the contracts URI. URIs in smart contracts are essentially something that's unique like IPFS hash or something along those lines, this unique identifier aligns with NFTs nonfungible nature.
Function 14: withdrawMoney()
function withdrawMoney() external onlyOwner nonReentrant {
(bool success, ) = msg.sender.call{value: address(this).balance}("");
require(success, "Transfer failed.");
}
This function allows the owner to withdraw money but for the keen eyes among you, you may be wondering why we have the onlyOwner modifier and nonReentrant modifier. This is essentially proving to its users that the owners of the smart contract cannot cheat. In practicality, the owner can only withdraw once because the whole contract is being sent to them anyways but in theory, a user could repeatedly call the function which will lead to waste of gas and a deeper call stack which can lead to vector attacks.
Function 15: setOwnersExplicit()
function setOwnersExplicit(uint256 quantity) external onlyOwner nonReentrant {
_setOwnersExplicit(quantity);
}
This function I'm not exactly sure what it does but I'm leaning to think that it handles the amount of NFTs a user can request from a function I just don't know how(if anyone knows the answer hmu Axis )
Function 16: numberMinted()
function numberMinted(address owner) public view returns (uint256) {
return _numberMinted(owner);
}
I explained this one already but it essentially returns the amount of NFTs in a user's wallet
Function 17: getOwnershipData()
function getOwnershipData(uint256 tokenId)
external
view
returns (TokenOwnership memory)
{
return ownershipOf(tokenId);
}
}
And FINALLY, this function returns the owner of a particular NFT.
Conclusion
In this article, I tried my best to break down the azuki smart contract line by line in digestible pieces so that anyone with a little solidity knowledge will be able to understand it. I said before I'm no expert but I put a lot of thought and research into this project so if you guys like the content show me some love by giving the article a thumbs up or a heart and if you spot anything that isn't accurate feel free to hit me up Axis. You can find the full code here