Minting NFTs on zkSync | Europa Labs

This is a technical overview of the airdrop process for 'The Discovery of Galaxy Joa' by Jupiter Group

Not your average NFT drop!

The technologies involved make this drop pretty much the first of its kind. Using zero-knowledge proofs (zkSync) for token minting, Chainlink VRF for raffle drawing, and Storj for decentralized storage - there's not much more we could've done to make this more web3. And putting it all together in under 4 days is a personal feat of mine :sunglasses:

I'd like to expand on each component of this procedure to highlight some technical features and hopefully inspire others to try minting their NFTs in this fashion.

Chaink VRF | A deterministic, fair raffle

The conditions for the drop were that we would take a snapshot of all Boonji tokens and their OpenSea listings on the night of December 13th. Out of these owners, we would choose 33 random accounts. As long as the account didn't have their token listed or it was listed for at least 3.3 eth - they would qualify for the drop.

There's a myriad of ways to do this, but we wanted a process that was non-biased, cheap, and simple enough that anyone else could replicate it and generate the same list of winners that we did, provided the same input. For entropy in Solidity, developers can turn to Chainlink VRF(Verifiable Random Function). The idea is that for every request, the VRF contract generates the random number and a cryptographic proof of how that number was determined. The only trick is the request/callback nature of this procedure, but we don't have to worry about that for this feature. If you need more info on integrating with your solidity contract, read here.

To take advantage of this verifiable randomness locally without deploying a contract, we can simply uses ethers.js to query the blockchain for the log that the ChainlinkVRF contract emits upon completing a request; each event object contains the random number generated that we can use.

// instantiate the contract object using mainnet address/abi
const ChainlinkVRF = await new ethers.Contract(CHAINLINK_VRF_MAINNET, abi, ethersProvider);

// query events from a generic block range
const eventFilter = vrf.filters.RandomnessRequestFulfilled();
const events = await vrf.queryFilter(eventFilter, fromBlock, toBlock);

The random number generated is of type BigNumber, but we can parse it to fit our needs.

// guarantee the same random numbers each time by doing rand % limitNumber
const modVal = ethers.BigNumber.from(TOTAL_TOKEN_HOLDERS.toString());

const _getRandomNumberFromEvent = (event) => {
  const b = ethers.utils.formatEther(event.args.output); // actually just a BigNumber
  const [massiveInteger] = b.split('.');
  return parseInt(ethers.BigNumber.from(massiveInteger).mod(modVal).toNumber());

Now it's just a matter of applying a simple algorithm to deterministically choose the winners. We use the word deterministically because this process should produce the same output every time, given the same input (the snapshot of winners).

for each number in `randomNumbers`:
 1. retrieve the owner at index in `tokenOwners`
  - if owner has listed the token for less than 3.3 ETH => retrieve the next owner (idx+1) until false
 2. flag owner as a weiner
 3. remove the account from the array of `tokenOwners` for the next draw not to include

Storj | Decentralized storage of assets and metadata

The protocol of choice for hosting token metadata is IPFS, although you would still need to use a pinning service like Pinata.

However, at Jupiter Group we use Storj to host and serve our content. It was a successful integration for the Boonji drop (see case study) and so we're running with it for future projects. It's secure, highly available, and open source - everything we need for a web3 project.

Uploading content using the uplink cli and then sharing it can be done via a bash script

uplink cp ./scripts/storj/assets/1.png sj://boonji-joa/assets/1.png
uplink share sj://boonji-joa/assets --url --not-after=none

Naturally, we want to automate as much of the process as possible. So the steps required to prep the NFT metadata are:

  1. have all NFT assets in a local directory
  2. rename the files to be deterministic based on tokenId
  3. upload and get the public read-access URL
  4. prepare all token metadata JSON files with the apppropriate values for name, image, etc
  5. upload all metadata JSON files and get the public read-access URL

As long as filenames are deterministic based on tokenId, the only thing we need to provide the ERC721 contract is the baseURI, which in this case will resolve to something like (tip: providing the /raw/ flag will automatically download the file from the browser)

zkSync | Minting NFTs

Here is the meat of the project: minting the tokens on a zk-rollup and enabling withdrawals to L1. We highly respect the values behind zkSync and the team at Matter Labs. Furthermore, their developer resources are top notch; I can say that with confidence because I hacked this entire drop together in under 4 days without even having looked at their docs prior!

There's many layers to understanding zk-rollups, the UX/transactions, etc. If you want a deep dive, feel free to check out their Medium page. For our purposes, we're just highlight the features of zkSync and move on to the steps to minting:

  • extremely low transaction fees, as long as you stay on l2 :)
  • funds are cryptographically secure
  • users are always in control of their funds

All the resources used: their guide for NFTs, Javascript SDK docs, and environment for deployed contracts.

At a high-level, these are the things we have to do before minting on zkSync

  1. instantiate an ethers wallet to sign transactions on zkSync
  2. activate our account
  3. guarantee that our tokens can be withdrawn to L1
  4. mint tokens on behalf of other accounts

Step three is in bold because it's the most important one - any tokens we mint using the zkSync SDK only live on the L2, and withdrawal to L1 must be supported. From their docs, there's 3 components to withdrawals to L1:

- Factory: L1 contract that can mint L1 NFT tokens
- Creator: user which mints NFT on L2
- NFTOwner: user which owns NFT on L2

Withdrawals are essentially an atomic transaction where the token on L2 is burned, and the token on L1 is minted. zkSync provides a default "factory" contract on L1 to mint tokens, but a developer can implement their own "factory" contract to handle the mint on L1. The only two requirements are (1) the contract must implement a specific mint function and (2) the contract must be registered with the L1 Governance contract via specific function call.

In our case, we needed to implement a custom factory contract because the default contract assumes each token's contentHash is an IPFS CID, which it then resolves as ipfs://${contentHash} for each tokenURI. Since we're using Storj, we have to construct the tokenURI differently.

Our BoonjixJoaFactory contract implements ERC721 and the required mintNFTFromZkSync() function, which does the minting of a given token from l2. One thing to note is that we do not reuse the provided tokenId and instead rely on the token id recovered from contentHash. We do use a counter, though, but only to keep track of the current number of tokes minted (see totalSupply()).

To satisfy the first requirement - the contract must implement a specific mint function. Here's our mintNFTFromZkSync() function signature. This function is called from the ZkSync smart contract when a user goes through the withdrawal process on L2, and takes care of minting on L1 (like calling _safeMint()):

/// @dev mints a token from zksync l2
/// @notice only the zksync contract can call
/// @param creator original minter on l2
/// @param recipient account to receive token on l1
/// @param creatorAccountId creator account id on l2
/// @param serialId enumerable id of tokens minted by the creator
/// @param contentHash bytes32 hash of token uri
/// @param tokenId the token id (from l2)
function mintNFTFromZkSync(
  address creator,
  address recipient,
  uint32 creatorAccountId,
  uint32 serialId,
  bytes32 contentHash,
  uint256 tokenId
) external override onlyZkSync {}

To satisfy the second requirement - the contract must be registered with the L1 Governance contract. We have to include in our smart contract the following:

/// @dev registers a creator with the zksync Governance contract to bridge tokens from l2
/// @param governance the zksync Governance contract
/// @param _creatorAccountId the creator account id on zksync
/// @param creator a whitelisted creator
/// @param signature payload signed by creator
function registerFactory(
  address governance,
  uint32 _creatorAccountId,
  address creator,
  bytes calldata signature
) external onlyOwner {
  IGovernance(governance).registerNFTFactoryCreator(_creatorAccountId, creator, signature);

To generate the required signature, check out their docs, but to make things easier on folks following along, here's my hacky script. It formats all inputs as expected in the signature - you can confirm this by checking out the implementation of the Governance contract on the zkSync repo

const factory = await _getFactoryContract(ethWallet);
const _accountId = await syncWallet.getAccountId();
const _accountIdHex = hre.ethers.utils.hexValue(_accountId);
const accountId = `000${_accountIdHex.split('0x')[1]}`;
const creatorAddress = address.split('0x')[1].toLowerCase();
const factoryAddress = factory.address.split('0x')[1].toLowerCase();
const msg = `\nCreator's account ID in zkSync: ${accountId}\nCreator: ${creatorAddress}\nFactory: ${factoryAddress}`;

const signature = await ethWallet.signMessage(msg);
const tx = await factory.registerFactory(GOVERNANCE_MAINNET, _accountId, address, signature);

Now that our factory contract is registered, we can guarantee that token owners can safely withdraw their NFTs to L1, and a custom factory will be ready handle the minting.

How do we mint? It's pretty straight-forward, actually. There's just a few gotchas along the way.

Here's a js function that will mint an NFT on zkSync with a given contentHash

const mintNFT = async (syncProvider, syncWallet, recipient, contentHash) => {
  const fee = await _logTxFeeSync(syncProvider, syncWallet);
  const nft = await syncWallet.mintNFT({
    feeToken: 'ETH',
  await nft.awaitReceipt();

Here, recipient is the address to receive the freshly minted NFT. However, if the recipient never activated their account on zkSync, you'll get the following error:

ZKSyncTxError: zkSync transaction failed: Recipient account not found

One way around this is to first transfer a little bit of ETH to this address. From the docs:

Users can transfer NFTs to existing accounts and transfer to addresses that have not yet registered a zkSync account. TRANSFER and TRANSFER_TO_NEW opcodes will work the same

We can either mint NFT to ourselves and then transfer, or transfer a little bit of ETH to the recipient to trigger that TRANSFER_TO_NEW opcode.

Once the token is minted, the recipient should see it in their zk wallet: Another gotcha is that transactions on zkSync go thorough a state change from committed to verified. Finality can be achieved usually within the hour, and only then can accounts withdraw to L1.

Finally, when a token has reached finality and is in the verified state (2 green arrows in the UI), it can be withdrawn to L1 here:


Putting all components together, going through a few rinkeby dry runs, and deploying on mainnet was quite the experience; it's exhilarating to be on the cutting edge. With zkSync soon supporting smart contracts written in Solidity, it's all but guaranteed that protocols will take advantage of cheap and fast transaction finality and port their Solidity code over.

We had DefiSummer in 2020.

NFTs took over in 2021.

2022 will be the year of zk-rollups.

Consider giving me a follow:

Subscribe to carlosbeltran.eth
Receive the latest updates directly to your inbox.
This entry has been permanently stored onchain and signed by its creator.