Applied ZK or how we built a ZK product and why it took us 18 repositories

A practical dive into how we built SealCred — a zero-knowledge social capital system

Applied ZK or how we built a ZK product and why it took us 18 repositories
SealCred

Hi Internet! Over the last several months, we've been building SealCred — a tool to create a pseudonymous identity with ZK badges. It's finally semi-live, and you can obtain the instructions and the credentials to use it on our official Discord server. Keep in mind that it is a pre-pre-pre-pre-pre-alpha at this moment. It has bugs, can be down for a bit, and unexpectedly update with breaking changes.

Please, note, that anything in the following article can change without notice: links, approaches, all of it can suddenly become outdated. We're actively developing and reimagining our products daily. However, as an example of how one can build a ZK solution we find the article being comprehensive enough.

In this article, I will dive deeply into how we built it, what tools we used and in what manner, go over some of the code bits, and maybe give you a bit of an excurse into what ZK is, in general, being applied to the problem we're solving. You can find all the repositories mentioned on our GitHub page. So dissect the code; we'll be grateful!

"ZK"? What's that?

I'm sure you're familiar with the "Zero-Knowledge" concept because you landed on this blog post. If not, here's the gist:

  • ZK means that we prove the knowledge of an information piece without revealing the piece (here's the best explanation IMO)
  • This information piece can be a solution to an arbitrary math function (e.g. an x satisfying x^2 = 25)
  • Or it can be more complicated, like an Ethereum private key
  • Yes, you can prove that you own an Ethereum address without revealing the private key

So what? You can sign a message with a private key, and anyone can verify that the message was signed by the private key that corresponds to the Ethereum address without the need to obtain the private key.

What if I told you that you don't even need to provide a signature when approaching it in the ZK way?

You can simply provide a Zero Knowledge Proof (ZKP) that you checked the validity of the signature on your end in a pre-defined manner. It's like the meme "source: trust me, dude," but on steroids and without the need for trust.

On its own, this doesn't sound like a huge deal. You can prove that you know a private key without revealing it. But in ZK, you also don't reveal the Ethereum address or the signature. Everyone knows at least one private key, so what's the use? Things really accelerate when you augment this idea with the fact that you can also prove that the private key corresponds to an Ethereum address which is a verified member of a list of Ethereum addresses within the same proof.

Do you see where this road leads us to? We can prove that we own an Ethereum address within a set without revealing what address that is 🤯 Technically, we can prove that we own a Cryptopunk or a Tiny Dino without revealing which ones. How can this be useful?

  • Anonymous access to gated communities
  • Anonymous airdrops
  • Fractionalizing identities (Horcruxes lol 🪄)
  • Anonymous voting
  • Pseudonymous social capital transfer
  • So, so many other use cases I can't even think of yet

I won't mention the use case of creating a twink Twitter account with a ZK badge certifying that you own another account with 1M followers without revealing which one. This became a cliché shortly after I stopped pitching this use case to our investors in February 2022.

Instead, imagine if whistleblowers could prove that they are employees of a specific organization without revealing their identities. Then, journalists will be 100% certain they are talking to the legit source without ever learning their name. All thanks to cryptography.

Or what if someone could participate in the discussion with the badge "I own 5000 BTC" without revealing their address? That's crazy.

I discussed one more use case for ZK with Jason (my BWL cofounder) that people don't see for some reason. Imagine if you could fetch all your credit data locally, use a formula provided by a credit score agency resulting in a credit score, and send the credit score with the ZK proof to the credit agency? The credit agency knows none of your credit data but only your score.

It gets even better: the only thing the credit agency needs to post is the verifier function verifying that you used the correct formula when calculating a particular credit score. This eliminates the need for a credit score company almost entirely — anyone can verify your credit score and the fact that you used the correct formula offline.

This just took a turn. Yes, all the ZK stuff can happen offline — for some reason, people forget about it or just don't mention it. You can generate ZK proofs and verify them offline, just like you can create and verify the cryptographic signatures offline. Maybe this is the way to go when humans settle Mars and have an eight-minute delay communicating between planets?

If you want to learn more, jump into the "Awesome zero-knowledge proofs (zkp)" rabbit hole. It will be boring, tedious, and challenging at times, but you'll become a new person coming out the other end. If you're lacking the basics of cryptography, pick up the book "Understanding Cryptography: A Textbook for Students and Practitioners" from a local library.

After the quick primer on ZK and its capabilities, let's get down to the business: what does SealCred do, and how exactly is it built?

SealCred

Actually, I lied at the very beginning of this article. It took us 20 repositories to build the demo, not 18. Unfortunately, there are two repositories we haven't open-sourced yet, and we are still debating whether it's feasible to make them public or not. But worry not! The code from these two repositories is irrelevant to our demo today. In fact, I'll show you the most relevant piece of code from them down below.

So what's SealCred? The premise is quite simple: if you own a ERC721 token (virtually any NFT from OpenSea, for instance), you can mint a SCERC721Derivative token. You can mint it to any Ethereum address you own without linking to the original ERC721 token owner address. The ownership of a SCERC721Derivative by an Ethereum address cryptographically certifies that whoever owns this address also owns an address that has the original ERC721 token.

Simply said: if you own a CryptoPunk (derivative), then you also own a CryptoPunk in one of your addresses (not necessarily the address holding the derivative). You can probably already see how this can be used. In fact, our tech demo allows users to unlock the following content with the derivatives:

Wait, what's Dosu? It's a pseudonymous social network we're working on. These are the two repositories that we haven't open-sourced yet. In fact, here're the two files checking the ownership of the Dosu Invites (derivative) token:

I won't get too much into details as the code is pretty straightforward. First, we have a map that keeps track of all the Dosu Invites (derivative) token IDs and their owners on the left. Next, you can see the function checking whether the address is an owner on the right. The rest is irrelevant to this article — middlewares, signature check (instead of JWT), etc.

But what's that on the very first line? What's that "factory" import? The TypeScript interface to the ethers.js smart contract bindings lets us interact with the smart contract from Node, React, or anything written in JavaScript or TypeScript. Next, let's discuss the second-most dull part of the article (the most boring being the web2 part) — the Ethereum smart contracts.

Quick interlude on our contract tech stack

Most of our code is written in three languages:

  • Solidity (smart contracts)
  • TypeScript (frontends, backends, various scripts)
  • Circom (proof circuits)

The reasoning behind picking this stack is beyond the scope of this article. We'll only focus on the infrastructure required to make everything work together well.

First, we use Hardhat to test and deploy the contracts. It neatly exports ethers.js, so we don't have to install it separately. The main parts of our contract repositories are:

  • GitHub configuration files (PR template, GitHub Actions to check PRs, etc.)
  • Node, TypeScript and Solidity configuration files (npm, linter, prettier, yarn, etc.)
  • VSCode configuration files (settings, rules, etc.)
  • Hardhat configuration files
  • Documentation (readme, license, etc.)
  • Test cases for the contracts
  • Contracts

This allows us to have a seamless development experience. For instance, we don't have to worry about the indentations or formatting. We have the "Format on save" option turned on by default. Also, when reviewing PRs, we don't need to check if the code is valid. Instead, GitHub Actions check it for us.

And then we also export the ethers.js-compatible TypeScript interface for the rest of the repositories as an NPM package! This means that we can, for example, do yarn add @big-whale-labs/dosu-invites-contract and boom, we're in business, have all the types ready to be used and the contracts ready to be interacted with.

Not to mention that we just do yarn deploy and lay back when we need to re-deploy the contracts! In fact, why don't we dissect one of our deploy.ts scripts? They all look alike across all of our repositories.

import { ethers, run } from 'hardhat'

async function deployContract() {
  // Get the contract owner
  const [deployer] = await ethers.getSigners()
  // Deploy the contract
  console.log('Deploying contracts with the account:', deployer.address)
  console.log('Account balance:', (await deployer.getBalance()).toString())
  const provider = ethers.provider
  const { chainId } = await provider.getNetwork()
  const chains = {
    1: 'mainnet',
    3: 'ropsten',
    4: 'rinkeby',
  } as { [chainId: number]: string }
  const chainName = chains[chainId]
  const DosuInvites = await ethers.getContractFactory('DosuInvites')
  const dosuInvites = await DosuInvites.deploy()
  console.log('Deploy tx gas price:', dosuInvites.deployTransaction.gasPrice)
  console.log('Deploy tx gas limit:', dosuInvites.deployTransaction.gasLimit)
  await dosuInvites.deployed()
  const address = dosuInvites.address
  console.log('Contract deployed to:', address)
  console.log('Wait for 1 minute to make sure blockchain is updated')
  await new Promise((resolve) => setTimeout(resolve, 60 * 1000))
  // Try to verify the contract on Etherscan
  console.log('Verifying  contract on Etherscan')
  try {
    await run('verify:verify', {
      address,
    })
  } catch (err) {
    console.log('Error verifiying contract on Etherscan:', err)
  }
  // Print out the information
  console.log('DosuInvites deployed and verified on Etherscan!')
  console.log('Contract address:', address)
  console.log(
    'Etherscan URL:',
    `https://${
      chainName !== 'mainnet' ? `${chainName}.` : ''
    }etherscan.io/address/${address}`
  )
}

deployContract().catch((error) => {
  console.error('Error deploying the contract:', error)
  process.exitCode = 1
})
BigWhaleLabs/dosu-invites-contract/scripts/deploy.ts

Well, well, well, what do we have here? The code can be all over the place, and I apologize if you find it messy. Let's go step-by-step on what's going on here:

  1. We get the deployer from the hardhat.config.ts which gives us a "window" into the blockchain providing the API to interact with the Ethereum network
  2. We log some data about the deployer account and get the name of the chain for our convenience
  3. We create a contract factory that will be used to deploy the contract to the network; it corresponds to a contract from the Solidity file
  4. We deploy the contract from #3 with the account from #2 and log a bunch of stuff like the gas price and the resulting contract address
  5. We wait a minute just in case Etherscan is too slow today to recognize that the contract got deployed (maybe unnecessary, but we don't deploy too often for this to hurt)
  6. We verify the contract address on Etherscan so that we can interact with the contract there (and so that it looks neat on Etherscan); this is wrapped in a try/catch block because we don't consider "contract already verified" an error
  7. And in the end, we log the Etherscan URL for the convenience sake
  8. We also catch any errors on the deployContract function call

Again, this is pretty consistent across the repositories. The only difference is maybe the contract deployment arguments. If you wonder what the config from #1 looks like, here's an example. It's also pretty consistent across the repositories:

import * as dotenv from 'dotenv'
import { cleanEnv, str } from 'envalid'
import { HardhatUserConfig } from 'hardhat/config'
import { ETH_RPC as FALLBACK_ETH_RPC } from '@big-whale-labs/constants'
import '@nomiclabs/hardhat-etherscan'
import '@nomiclabs/hardhat-waffle'
import '@typechain/hardhat'
import 'hardhat-gas-reporter'
import 'solidity-coverage'

dotenv.config()

const { CONTRACT_OWNER_PRIVATE_KEY, ETH_RPC, ETHERSCAN_API_KEY } = cleanEnv(
  process.env,
  {
    CONTRACT_OWNER_PRIVATE_KEY: str(),
    ETH_RPC: str({ default: FALLBACK_ETH_RPC }),
    ETHERSCAN_API_KEY: str(),
  }
)

const config: HardhatUserConfig = {
  solidity: '0.8.4',
  networks: {
    deploy: {
      url: ETH_RPC,
      accounts: [CONTRACT_OWNER_PRIVATE_KEY],
    },
  },
  gasReporter: {
    enabled: true,
    currency: 'ETH',
  },
  etherscan: {
    apiKey: ETHERSCAN_API_KEY,
  },
  typechain: {
    outDir: 'typechain',
  },
}

export default config
BigWhaleLabs/dosu-invites-contract/hardhat.config.ts

Nothing out of the ordinary except maybe for the environment variables. We do like our env vars tidy and type-checked, so we are using envalid to verify if all the required variables in .env are present and are of the correct type.

You can also notice that the ETH_RPC variable has a default value from @big-whale-labs/constants. We did it so that we don't need to change the variables across all the servers and repositories every time we deploy a new contract (doing this every time got old really quick). We use the same approach across the repositories with envalid and other env variables like SCLEDGER_CONTRACT_ADDRESS and DOSU_INVITES_CONTRACT_ADDRESS.

You can also notice that we still use the now deprecated Rinkeby network. Even though we launched our own Sepolia Ethereum node, Collab.Land only supports Rinkeby, and it's one of the most critical parts of our demo. And Rinkeby is slooooooooooooow.

Rinkeby getLogs operation in the wild

We have already submitted a ticket with Collab.Land to switch to Sepolia, but I personally don't think we'll get it anytime soon 😭

Also, yes, we launched EC2 instances with our own Ethereum nodes. We initially used Digital Ocean for this, but Mr. Bezos gave us enough credits to last through the testing phase. Launching geth as a systemd service is trivial, and I won't cover it in this article.

However, keep in mind that there is no ethereum package for the latest Ubuntu, you have to install ethereum-unstable, this will save you some time.

Contracts

We have four of them. Each one performs a specific function necessary for the whole demo to work:

  • DosuInvites
  • SealCredLedger
  • SCERC721Derivative
  • Verifier

This section will only cover the contracts and their quirks. Most of the backend/frontend will be discussed in a later section.

Dosu Invites contract

Dosu Invites is a plain old ERC721 provided by OpenZeppelin. The only thing that stands out is the allowlistMerkleRoot variable that can be modified by the contract owner. It is used to limit the addresses that can mint Dosu Invites. See, we're very selective in who can see what at this stage, and that's why only a select few can experience Dosu for now.

We only use Dosu Invites NFTs to gate the access to Dosu. In fact, you need to generate a Dosu Invite (derivative) at SealCred to gain the full access to Dosu.

Why do we use Merkle trees here instead of plain arrays of user addresses? The simple answer is that it's cheaper. Storing and modifying data on a blockchain is expensive. And I mean expensive. Verifying a Merkle proof calculated off-chain is relatively inexpensive, though. Read more on how Merkle trees work here.

This is the first time we have used Merkle trees in this blog post out of two times. In this case, we use the Merkle proof generation and verification kindly provided by OpenZepplin (at least we used their test code as an example). In this instance, we use keccak256 hashing algorithm.

When users are trying to mint an invite on invites.dosu.io, the browser calculates the Merkle proof and sends it to the mint function of the smart contract.

/**
 * @dev Mints an invite
 * @param merkleProof Merkle tree hex proof of the sender being a part of the allowlist
 */
function mint(bytes32[] calldata merkleProof) public {
  // Check preconditions
  require(balanceOf(msg.sender) == 0, "Sender already has an invite");
  bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
  require(
    MerkleProof.verify(merkleProof, allowlistMerkleRoot, leaf),
    "Merkle proof verification failed"
  );
  require(
    mintedTokensCount.current() < MAX_INVITES_SUPPLY,
    "Already minted all the allowed invites"
  );
  // Mint the token
  mintedTokensCount.increment();
  uint256 tokenId = mintedTokensCount.current();
  _safeMint(msg.sender, tokenId);
  // Emit the mint event
  emit Mint(msg.sender, tokenId);
}
The mint function of the DosuInvites.sol contract

As you can see, the code of the mint function is actually pretty trivial:

  1. We check that the minter hasn't minted an invite before
  2. We generate the leaf for the proof from the minter address
  3. We check that the Merkle proof is correct and corresponds to the leaf from #2
  4. We check if the supply hasn't ended yet
  5. We increment the token count and get the tokenId that will be minted
  6. We mint the token
  7. We emit the Mint event from the ERC721 specification
export default async function generateMerkleProof(ethAddress: string) {
  const addresses = await getAllowlist()

  const leaf = utils.keccak256(ethAddress)
  const leafNodes = addresses.map((address: string) => utils.keccak256(address))
  const merkleTree = new MerkleTree(leafNodes, utils.keccak256, {
    sortPairs: true,
  })

  const claimingIndex = leafNodes.findIndex((address) => address === leaf)

  if (claimingIndex < 0) throw new Error(ErrorList.invalidProof)

  const claimingAddress = leafNodes[claimingIndex]
  const hexProof = merkleTree.getHexProof(claimingAddress)

  return hexProof
}
The generateMerkleProof function of dosu-invites-frontend

Generating the Merkle proof on the frontend is also as trivial as it gets:

  1. We get the allowlist from allowlist.dosu.io/allowlist
  2. We generate the Merkle tree with merkletreejs (note the sortPairs parameter, it's essential)
  3. We get the hex Merkle proof for @openzeppelin/contracts/utils/cryptography/MerkleProof.sol
  4. And we pass it to the mint function provided by @big-whale-labs/dosu-invites-contract generated with hardhat and ethers.js, as simple as that!

Quick interlude on how we host our frontends

Our frontends are written in preact with a bunch of cool stuff on top, like tailwindcss. All in all, its sub-15kb in the base state but libraries like ethers.js bloat the bundle size a lot.

We host our frontend servers either on EC2 of Amazon (thanks to Mr. Bezos for the free credits) or Digital Ocean VPSs. Except for some of the static websites hosted by GitHub Pages. Our DNS, CDNs and caching are handled by Cloudflare. It's a simple preact SPA — so technically, we can bundle it and even send it to users over pigeon mail on floppy disks.

The fact that it's a simple SPA gives us more options for serving it, including IPFS! In fact, this is how the frontends "leaked" to the community at sealcred.eth.limo, invites.dosu.eth.limo and dosu.eth.limo, even though the centralized servers are login-protected. We use ipfs-sync on a centralized server with the ipfs daemon to serve the frontends (and ci-ninja to update whenever we push to the main branch). We also use IPNS to use more user-friendly addresses.

SealCredLedger contract

SealCredLedger is the heart of our protocol. We deliberately kept it as short as possible yet had to submit to spawning a few helper contracts to support all the functions we needed the ledger to perform. In fact, both the SCERC721Derivative and the Verifier contracts can be considered sub-contracts of the SealCredLedger, even though they can be used entirely independently.

Two primary mappings worth considering are:

  • mapping(address => bytes32) private ledger
  • mapping(address => address) private tokenToDerivative

The first maps original ERC721 contracts to the Merkle roots of the addresses that own this ERC721. Later on, we'll use these Merkle roots to prove that the address is the owner of one of these tokens. The second maps the original ERC721 contracts to their SCERC721Derivative counterparts. This will be used externally as the source of truth by whoever needs to find a derivative for a specific ERC721 token.

Most of the contract consists of the accessors and editors for these variables. Worth noting that to minimize the number of transactions, we batch the edits and deletions and can update multiple Merkle roots for multiple ERC721 contracts within the same transaction.

The only complicated function in this contract is addRoots. It doesn't just add Merkle roots to the ledger but also spawns new SCERC721Derivative contracts. This function should be used only once for every supported ERC721 contract to initialize the membership of this contract in the ledger and allow users to mint the derivatives.

function addRoots(Root[] memory roots) external onlyOwner {
    for (uint256 i = 0; i < roots.length; i++) {
      Root memory _currentRoot = roots[i];

      if (ledger[_currentRoot.tokenAddress] != 0) {
        continue;
      }
      IERC721Metadata metadata = IERC721Metadata(_currentRoot.tokenAddress);
      SCERC721Derivative derivative = new SCERC721Derivative(
        _currentRoot.tokenAddress,
        address(this),
        string(bytes.concat(bytes(metadata.name()), bytes(" (derivative)"))),
        string(bytes.concat(bytes(metadata.symbol()), bytes("-d"))),
        verifier
      );

      ledger[_currentRoot.tokenAddress] = _currentRoot.merkleRoot;
      tokenToDerivative[_currentRoot.tokenAddress] = address(derivative);
      emit CreateDerivative(
        address(derivative),
        _currentRoot.tokenAddress,
        address(this),
        string(bytes.concat(bytes(metadata.name()), bytes(" (derivative)"))),
        string(bytes.concat(bytes(metadata.symbol()), bytes("-d"))),
        verifier
      );
      emit SetMerkleRoot(_currentRoot.tokenAddress, _currentRoot.merkleRoot);
    }
  }
SealCredLedger.sol excerpt from seal-cred-ledger-contract

Let's go step-by-step on what we're doing here:

  1. We go over all the roots within the batch to apply each one of them sequentially (each root addition consists of tokenAddress and merkleRoot)
  2. We halt the loop and proceed to the next one if the ERC721 contract has already been added
  3. We fetch the original metadata from the ERC721 contract
  4. We spawn a new SCERC721Derivative with the slightly modified metadata, original ERC721 contract address, StreetCredLedger address and the Verifier address
  5. We set the ledger Merkle root and the derivative address for this ERC721 contract
  6. We emit the events for the future convenience

See? I told you we tried to keep it as uncomplicated as possible! Now, what are these SCERC721Derivative and Verifier contracts I've been rambling about?

SCERC721Derivative contract

SCERC721Derivative contract is actually in the same repo as the SealCredLedger contract 🥲 I'm not sure if we needed to extract it, so we just kept it there. All in all, it's no different from a vanilla ERC721 provided by OpenZeppelin except for one critical difference in the mint function:

function mint(
  uint256[2] memory a,
  uint256[2][2] memory b,
  uint256[2] memory c,
  uint256[2] memory input
) external {
  require(
    bytes32(input[1]) == sealCred.getRoot(sealCredMapAddress),
    "Merkle Root does not match the contract"
  );
  require(IVerifier(verifier).verifyProof(a, b, c, input), "Invalid Proof");
  uint256 _tokenId = tokenId.current();
  _safeMint(msg.sender, _tokenId);
  tokenId.increment();
}
mint function of SCERC721Derivative.sol

Hey, is that it? Do we only check if the public output of the ZK proof matches the current StreetCredLedger Merkle root of the original ERC721 contract, then check if the ZK proof is valid with the Verifier contract and mint the derivative?

Yes, that's it. We, again, deliberately decided not to deviate from the ERC721 standard that much for tools like Collab.Land to use the derivatives seamlessly. See, most of the gating software nowadays can work with ERC721 tokens. So one of our goals was to ensure that the derivatives could be used there. In fact, you can see how it all works and try it following the instructions on our Discord server!

Verifier contract

Verifier contract is where the magic happens! In fact, this repo also includes our proof circuits because these are used to generate the Verifier contract with snarkjs! We actually take no credit for the Verifier contract as it was purely generated from our proof circuit written in circom. If you dissect the contract, you'll notice a variety of number constants — these are the only things that change depending on the proof circuit you write.

This contract has literally one use: verifying that the ZK proof you submit is indeed a correct one. If not, it'll throw an error. That's it.

Bonus contract: SimpleERC721

SimpleERC721 is just an ERC721 contract from OpenZeppelin without limits on who can mint and how many tokens can be issued. The cool thing is in the deploy script, it's modified a bit to deploy 7 SimpleERC721 contracts that can be minted at BWL Minting Experience.

The only reason we have this is to give testers a bit more ERC721 contracts to play with on the testnet.

See the list of the SimpleERC721 tokens I own on the left (and the DosuInvites token because I'm elite)

As you see, our smart contracts are as simple as they get. But, again, we kept them simple deliberately to decrease the risks of making an error somewhere. This concludes the second-most boring section of this article. Now, head to the proof circuits where the heat's at!

Proof circuits

We went with the groth16 setup with circom partially because there is circom-ecdsa that allows us to verify ECDSA Ethereum signatures in a ZK way. It has its own disadvantages, but it has the best documentation in the space. For instance, the verify function requires ~1.5M constraints, which takes a lot of RAM to generate the witness and then the proof. You probably guessed that this can't be used in a browser.

So we built a centralized server and beefed it up to make sure it could handle proof generation. People have to queue on SealCred to get their proofs generated. Even though the server is centralized, anyone can launch their own node from our repo and connect to it. It also takes ~8 minutes to generate a proof in the current setup.

The struggle of waiting in the queue is real

On the other hand, thanks to our team's superior coding skills, users at least don't have to wait on the page for the proofs to finish generating. Instead, they can come back later, and the frontend will check if the proof is ready. Let's examine the proof circuit code!

I'm not going to paste all the code here because the guys from StealthDrop explained the ECDSA portion better, and the Tornado Cash Merkle proof verifier circuit is so short it's self-explanatory. So instead, we'll focus on the signals:

signal input root;
signal input leaf;
signal input pathIndices[nLevels];
signal input siblings[nLevels];

signal input r[k];
signal input s[k];
signal input msghash[k];
signal input pubkey[2][k];

The first four are the Merkle proof elements, and the next four are the ECDSA signature elements. The only public input signal is root which is the Merkle tree root. There is also a public output signal, but I don't think we use them, and we probably added it to resolve a random circom error, so I won't mention it.

This circuit is basically the formula I talked about when explaining ZK. It verifies that given a public input Merkle root, we know the private key corresponding to an address which is a member of that Merkle tree. snarkjs generates a few artifacts from the proof circuit, but we're interested in the proof generator function and the proof verifier function.

The proof generator function runs on our beefed-up server, takes all the input signals as an input and returns the ZK proof corresponding to these signals. Finally, the proof verifier function is written in Solidity and is precisely what our Verifier contract is. It simply verifies that a given ZK proof is valid.

Now, what do we mean by valid? How can we say (or prove) that a particular ZK proof is valid? What do we prove after all? Simply speaking, the verifier function cryptographically checks if we followed the formula correctly and ended up with a correct solution that satisfies f(x) = y, where x is our inputs, y is our outputs and f is our proof circuit! Again, we don't have to expose neither the x's nor the y's, but the proof generator will generate a valid cryptographic proof that we indeed followed all the rules (or constraints) — and the verifier function checks if this proof is valid!

Worth noting that in this case we use a different hashing algorithm for the Merkle trees — Poseidon Hash. In fact, we even extracted an implementation of it from circomlibjs to our own package @big-whale-labs/poseidon.

As simple as that, we can now verify that we own an address from the list of ERC721 token owners without revealing any information except for the publicly known Merkle root of the owner list!

But in the ZK world, there are always a couple of catches:

  • If the Merkle root of the original ERC721 token changes, the proof becomes invalid. Gonna generate the proofs fast!
  • ZK proofs are public, and we haven't added a guard against reusing the proofs yet. So go ahead, shame us, and generate a bunch of derivatives of the tokens that you don't own. We deserve it.

We're working on resolving them promptly. Nothing to see here. Please, proceed to the most boring web2 section of this article. I know you're tired of web2, but I'm really proud of what our team has achieved, and I want to share the journey we took with you.

The most boring part (aka the web2 part)

So far, we have covered like 6 or 7 repositories, but what are the other dozen-or-so repositories we spawned in the feverish dream called "zk and web3 meet the cutting-edge frontend and backend libs"?

Let's start with the backend, as it's less diverse than our frontend solutions. We use Node.js with TypeScript on the backend so that our code is nicely typed and can run on any potato of a computer. We don't care if Go servers are like 100 times faster (they aren't). We care about the quality of the code and how easy it is to follow the codebase. In fact, anyone can open our repositories and start contributing proper code within 10 minutes of looking at them. Could you do this with Go? Didn't think so.

We value the shortness of the code. If something can be implicitly derived from the code — it should not be written by us. That's why we use koa with amala, instead of famous express. See for yourselves:

import { Controller, Get } from 'amala'
import sealCredLedger from '@/helpers/sealCredLedger'

@Controller('/')
export default class RootController {
  @Get('/contract-address')
  getContractAddress() {
    return { address: sealCredLedger.address }
  }
}

This is an excerpt from one of our repositories. It is really this simple to build REST API in this manner. Of course, one could argue that flask can do a similar thing. But I'll leave teaching the devs multiple languages and spending time switching between them constantly and manually managing the types and docs to the one who argues. I want none of it.

If something can be implied from our code, it should. We built machines to serve us, not to be served by us. I expect tiny pieces of silicon to be smarter than me and check me every time I write a line of code. Overall, here're all the centralized repositories with their corresponding functions:

It listens for the StreetCredLedger's original ERC721 contracts Transfer events. When a Transfer event occurs, it notes that change and saves the contract address to have its owners' Merkle root checked.

Every 5 minutes, it checks if there are mismatches about the actual current Merkle roots and the ones saved in StreetCredLedger for the noted changed ERC721 contracts. If there are any mismatches, it updates the Merkle roots.

This is a centralized oracle. However, it doesn't mean you have to trust it. At any moment, one can get the list of owners for the ERC721 tokens from the blockchain and verify the Merkle roots.

In fact, this is precisely what our seal-cred-admin-frontend does, it checks the roots!

This is a simple REST API server with three methods:

  • Return the StreetCredLedger contract address
  • Add addresses to the ledger (spawning SCERC721Derivative contracts)
  • Remove addresses from the ledger

We can get rid of the backend altogether a bit later as the only thing the backend does here is centrally call the smart contract with the owner's private key. Instead, we will add web3 calls straight to the frontend. The same thing applies to the rest of the backends that use the owner's private key, so I won't repeat this point further.

This is also a simple REST API server with the following methods:

  • Post proof job
  • Get proof job status

You give it the inputs, it creates the job in the database, the jobs get consequently completed, you request the job status — and get the ZK proof or the position if the proof is scheduled to be generated.

As simple as that. However, notice how we used typegoose to simplify the database code as much as possible. I know that this is unnecessary in web3 and even for the function of ZK proof generation for this product, but I still wanted to give the typegoose team a shout-out!

Initially, it was a backend for the Dosu Invites minting website. However, we figured out that we didn't need a backend for that quite rapidly. So then this app just became an IPFS shared folder for the Dosu Invites NFT metadata and pics updater. And whenever there is a new Dosu Invite minted, it uploads the metadata and the pic of the new NFT to IPFS.

Another REST API that will probably get deprecated in favour of using web3 libraries on the frontend quite soon. It serves the allowlist for Dosu Invites and allows us to modify the allowlist with the Merkle root in the Dosu Invites contract. The following methods are supported:

  • Return the Dosu Invites contract address
  • Get the allowlist
  • Add an address to the allowlist
  • Check if Merkle root needs to be changed (and if so, change it)

Having a central place for the allowlist simplified the process quite a lot. I'm surprised that there are no services to hold allowlists and generate Merkle trees from them. It seems like such a typical task that should be automated.


And that's basically it for the centralized backend infrastructure. Now to the more valuable part of the excursion — the frontends! Unlike the backends that can be replaced by blockchain, the frontend apps are the places we use to actually interact with the blockchain contracts.

Nothing ground-breaking here. This SPA fetches the StreetCredLedger, in particular, the list of supported ERC721 tokens, filters out non-SimpleERC721 tokens and allows users to connect a wallet and mint test NFTs. It's served by GitHub pages, which brings the server cost to $0!

This is the landing page for bwl.gg. Nothing special but the awesomeness and craziness of the design and the implementation. Also served by GitHub Pages. I know! Economical!

This frontend is as dull as it gets, pure web2 from the old age. Simply connects to the backend, displays the list of the allowed addresses and the visualization of the Merkle tree — and allows admins to add more addresses to the allowlist.

Ok, this frontend is a bit not dull, I admit. In particular, it allows users to generate the Merkle trees seamlessly and invisibly and mint Dosu Invite NFTs. Also, it fetches and shows the NFT token images from IPFS! This is the place people come to when they want to mint a Dosu Invite NFT.

This frontend shouldn't have been this boring. It could've acted without a backend at all! But for now, it simply communicates to the backend (and a bit to the blockchain when the data fetch is required) and allows the admins to add or remove ERC721 tokens to and from the StreetCredLedger contract. You can also see a screenshot of this webpage above.

The crown jewel of our collection, the place without a backend, the true one product where everything comes together. If, before looking at this article, you thought that we had a variety of unconnected repositories, here you see how all the puzzle pieces connect. BWL repositories, assemble!

  • BWL website leads users to SealCred
  • Backend utils support the necessary backends
  • SimpleERC721 tokens are minted for demo purposes
  • The minting experience allows users to mint SimpleERC721 tokens
  • Constants repo holds all the required info like addresses and public credentials
  • Poseidon enables ZK-friendly hashing across the repos
  • Dosu Invites contract is used to demo the derivative minting as well
  • Dosu Invites allowlist backend and frontend allow admins to limit who can mind Dosu Invites
  • Dosu Invites frontend allows users to mint Dosu Invites tokens
  • Dosu Invites backend uploads Dosu Invite NFT metadata to IPFS
  • SealCredLedger contract holds the Merkle roots of NFT owners and the links to the derivative contracts
  • SCERC721Derivative contracts, when minted, verify that a user owns an NFT without revealing which one
  • Verifier contract allows SCERC721Derivative to check the ownership in a ZK way before minting
  • SealCred admin backend and frontend allow admins to add and remove ERC721 tokens to and from the SealCredLedger
  • SealCredLedger contract maintainer makes sure that SealCredLedger always has updated Merkle roots
  • ZK proof generator generates ZK proofs suitable for the Verifier contract

And then the SealCred website comes in as our Captain America or Nick Fury to save the day, assemble all the heroes and maybe have shawarma together.

Things still left to do

There are a few obvious conceptual problems we are tackling right now that prevent us from launching on the mainnet. However, there are naive and complicated solutions to each of the problems, and we're deciding between them, testing and trying them out.

  • What about the double-spending? Should a user be able to mint multiple derivatives of the same token? If not, how not to dox the user with the fact that they have already minted?
  • How to prevent users from using other users' ZK proofs? ZK proofs are public on the blockchain, so anyone can potentially snatch a proof. Should we introduce accumulators, nullifiers or extra hashes? What are the implications of the user not being able to reuse a proof?
  • When we will be able to generate ZK proofs in the browser? What does it take? Should we switch to PLONK? Should we wait until the fantastic team from circom-ecdsa makes proof generation feasible?
  • Can we make ZK proofs... crosschain? Can we deploy the Verifier contract to the blockchains other than Ethereum?

Conclusion

Thank you for reading the whole article! I truly appreciate the time you invested in reading my sometimes incoherent rambling. Whether you're a seasoned ZK pro or just starting, I hope you found at least a portion of this article valuable.

Feel free to share your thoughts on this article on Twitter or our Discord server.

Acknowledgements

  • 0xparc community — for excellent materials on ZK and active support of the bleeding-edge tech, without which SealCred wouldn't be possible
  • Wei Jie — for precious advice on how to approach the technical problems and for advancing the ZK space further
  • Stealthdrop team — for basically hitting all the potholes in front of us and building a similar solution that solved the majority of the technical problems
  • zk-kit team — for building incredible JS/TS tooling
  • circom team — for making proof circuit writing a tad bit easier
  • Awesome ZKP list maintainers — for giving the basis for the ZK research to the uninitiated
  • Tornado Cash team — for open-sourcing the Merkle proof-verification circuit