OctoronRift - Main Mint - ERC721a NFT smart contract
The contract for our main mint has not yet been deployed, but it’s short enough to post here:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;
import "./ERC721A.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/finance/PaymentSplitter.sol";
import '@openzeppelin/contracts/utils/cryptography/ECDSA.sol';
contract OctoronRift is ERC721A, Ownable, PaymentSplitter, ReentrancyGuard {
using ECDSA for bytes32;
uint256 public maxSupply = 8888;
string public baseURI;
bool public mintingEnabled = false;
mapping(address => uint) public claimedTokens;
address private _signer;
constructor(
string memory _initBaseURI,
address[] memory _sharesAddresses,
uint[] memory _sharesEquity,
address signer
)
ERC721A("OctoronRift", "OCT")
PaymentSplitter(_sharesAddresses, _sharesEquity){
setURI(_initBaseURI);
_signer = signer;
}
function _baseURI() internal view virtual override returns (string memory) {
return baseURI;
}
function setURI(string memory _newBaseURI) public onlyOwner {
baseURI = _newBaseURI;
}
function updateSigner(address signer) external onlyOwner {
_signer = signer;
}
function _hash(address _address, uint amount, uint allowedAmount, uint cost) internal view returns (bytes32){
return keccak256(abi.encode(address(this), _address, amount, allowedAmount, cost));
}
function _verify(bytes32 hash, uint8 v, bytes32 r, bytes32 s) internal view returns (bool){
return (ecrecover(hash, v, r, s) == _signer);
}
function setMintState(bool _mintingEnabled) public onlyOwner {
mintingEnabled = _mintingEnabled;
}
function mint(uint8 v, bytes32 r, bytes32 s, uint256 amount, uint256 allowedAmount) public payable {
require(mintingEnabled, "CONTRACT ERROR: minting has not been enabled");
require(claimedTokens[msg.sender] + amount <= allowedAmount, "CONTRACT ERROR: Address has already claimed max amount");
require(totalSupply() + amount <= maxSupply, "CONTRACT ERROR: not enough remaining in supply to support desired mint amount");
require(_verify(_hash(msg.sender, amount, allowedAmount, msg.value), v, r, s), 'CONTRACT ERROR: Invalid signature');
_safeMint(msg.sender, amount);
claimedTokens[msg.sender] += amount;
}
}
Lets step through it:
Imports
import "./ERC721A.sol";
Our contract imports the popular ERC721a contract.
Originally developed by the Azuki team, this contract boasts heavily reduced gas prices - especially for multiple mints, and therefore a lot of projects have been using this recently. For example, on a ERC721a based contract I’ve recently deployed, we had a user pay $8.22 USD in gas to mint x3 NFTs with gwei at 40.9 & eth at $2,733.05.
You can read more about ERC721a here: https://www.azuki.com/erc721a
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/finance/PaymentSplitter.sol";
import '@openzeppelin/contracts/utils/cryptography/ECDSA.sol';
These 4 imports are from the popular OpenZeppelin contracts.
These include well tested & audited features. It’s rare to not see OpenZeppelin usage in NFT smart contracts.
You can learn more here: https://openzeppelin.com/contracts/
Ownable:
- this provides an easy interface so that we can add permissions to methods so that only the owner of the contract can use (us), for example our update metadata & signer methods.
- https://docs.openzeppelin.com/contracts/2.x/api/ownership#Ownable
ReentrancyGuard:
- provides a layer of security, mainly to be used with methods like withdraws, so that our code isn’t vulnerable to reentrancy attacks; an example of this is if we withdrew funds to a malicious account / contract. This should not be an issue regardless because the only accounts we’ll withdraw are owned by team members, but better to be safe!
- https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard
PaymentSplitter:
- This is how we pay our team! All addresses & their respective shares will be entered at time of contract deployment. Our staff can then “claim” their % whenever they’d like post mint.
- https://docs.openzeppelin.com/contracts/2.x/api/payment#PaymentSplitter
ECDSA:
- Used to verify that a message was signed by the owner of a given address
- https://docs.openzeppelin.com/contracts/2.x/api/cryptography#ECDSA
Our contract uses ECDSA signatures when minting.
Basically, this means that when you press mint on our site, we bundle things like the following into a message:
- your eth address
- your eth price to mint
- number of tokens you want to mint
- number of tokens you’re allowed to mint
- our smart contract’s address
We then take that message, generated off-chain by our website, and sign it using our eth wallet. This signature & the message is then passed through to our smart contract.
The contract will validate that:
- the message matches the signature
- the signature was indeed signed by our eth wallet
You can read more about ECDSA signatures here: https://docs.openzeppelin.com/contracts/2.x/utilities#checking_signatures_on_chain
This means that our smart contract is prepared for the following :
- variable mint prices per wallet - so OG’s can claim their free mints + any other giveaways we may have, including the possibility of reduced mints and during the presale so we do not need to have separate mint windows or transactions per user
- variable mint allowances per wallet - so we can increase / decrease wallet limits per user, or per stage of mint.
- off-chain presale lists - we can add users to our presale list even while a mint is live, without needing to update the smart contract or pay gas. For example if a user changes their address last minute, and we get it, this happens more than you’d think!
So, the following methods are all part of this signing process
function updateSigner // this sets the address of the account we sign messages with
...
function _hash // a solidity hasher function to reproduce the exact same hash as we do off-chain
...
function _verify // this is where we verify the message was indeed signed by an account we own
...
Something else to note is that our implementation has been specifically designed to prevent replay attacks.
A replay attack is when a signed message is reused to claim authorization for a second action.
An example:
Our minting method looks like this:
function mint(uint8 v, bytes32 r, bytes32 s, uint256 amount, uint256 allowedAmount)
where v, r & s = the signature
and where we hash
- your address
- the contract’s address
- the amount you’re minting
- the amount you’re allowed to mint
- the amount you’re expected to pay
and verify that against the signature.
But if we instead wrote our minting method like this (we’ve scarily seen this a fair bit in recent weeks!):
function mint(uint8 v, bytes32 r, bytes32 s, uint256 amount, uint256 allowedAmount, bytes32 hash)
and instead did not regenerate the hash, and just verified that provided hash against the provided signature, we’d be vulnerable to a replay attack.
The same user, or another user, could provide the same signature & hash.
At the very least, they’d be able to mint without being on an allow list (WL).
Worst case, they’d be able to change amount, allowedAmount or even potentially the amount of ether payable to whatever they want - we’ve seen it happen! Depends on what other checks the developer added to their minting method.
Public & private variables
uint256 public maxSupply = 8888;
string public baseURI;
bool public mintingEnabled = false;
mapping(address => uint) public claimedTokens;
address private _signer;
This is where we set up our variables. Some of which remain constant (like max supply), while others we can change (like baseURI)
Lets use baseURI as an example.
OpenSea gets the image & metadata by querying a method of the ERC721a called tokenURI
.
tokenURI
is created by simply appending the token ID to the end of _baseURI()
which returns baseURI
.
For example, if we call setURI("https://octoronrift.com/api/metadata/")
and OpenSea queries tokenURI(10)
the result returned would be https://octoronrift.com/api/metadata/10
which is a URL that would return a JSON file with the metadata & a URL to the image / animation.
BaseURI can be web2 hosted, i.e. our own API, or utilizing a decentralised platform like IPFS.
Mint method
function mint(uint8 v, bytes32 r, bytes32 s, uint256 amount, uint256 allowedAmount) public payable {
require(mintingEnabled, "CONTRACT ERROR: minting has not been enabled");
require(claimedTokens[msg.sender] + amount <= allowedAmount, "CONTRACT ERROR: Address has already claimed max amount");
require(totalSupply() + amount <= maxSupply, "CONTRACT ERROR: not enough remaining in supply to support desired mint amount");
require(_verify(_hash(msg.sender, amount, allowedAmount, msg.value), v, r, s), 'CONTRACT ERROR: Invalid signature');
_safeMint(msg.sender, amount);
claimedTokens[msg.sender] += amount;
}
We’ve touched on our mint method in the ECDSA section above, but we’ll continue through the rest.
One thing to highlight is that we do not have separate methods for presale (WL) vs dev mints vs public mints - this is all controllable via our web2 backend, thanks to the signing technique.
The first 4 lines featuring require
are checks. If these fail, the transaction will revert & fail.
- minting must be turned on in the contract - this gives us the ability to fully turn off all mints in case of an issue
- checks that the user hasn’t minted more than they’re allowed. While a touch more expensive, we store in the contract’s storage all wallets that have minted. This is to ensure we are vulnerable to users who transfer to another wallet to try mint more than they have been allocated.
- checks that the total supply hasn’t fully been minted yet
- check the signature is correct
The we call _safeMint
, a method of the ERC721a import which performs the mint. Followed finally by updating the mapping that stores how many tokens every wallet has minted.
And that’s it for our NFT contact.
For any questions or concerns, please create a ticket within our Discord and tag @Cube
Every line of code always has the potential to either have an issue or the opportunity for optimization so we appreciate any and all feedback, criticisms or issues raised.
Thank you!