SealHub to revolutionize ECDSA ZK proofs

Attention! This article describes SealHub — an experiment we did around five months ago before starting to work on ketl. Also keep in mind that this was us riffing on Personae Labs' efficient-zk-ecdsa solution.

G'day Internet! Meet SealHub — our long-awaited tool to decrease the constraints for ECDSA signature verification in Circom from 500,000 to a mere 10,000. The proof size? It goes down from ~500,000 bytes to ~27 bytes. SealHub makes it feasible to generate a zero-knowledge proof of ownership of an Ethereum address verifiable on-chain! Of course, with one smaaaaaaaaaal drawback, but more on this below.

What's the problem with the current approach?

ZK works in strange but straightforward ways. Check out my other article on the technical primer if you aren't familiar with ZK circuits. We can add private inputs to an "equation" with public outputs and verify on-chain that the inputs were correct (without revealing them) for the equation and the specified results.

One of the use cases is membership proofs. Imagine having a list of Ethereum addresses allowed to perform an action on-chain (for instance, mint a new meme token before the public sale goes up). Now, this is as simple as putting all the addresses in a Merkle Tree and providing a Merkle Proof that whoever is calling the mint function (usually, msg.sender) is a part of the tree.

You don't have to be a math genius to understand Merkle Trees. Ultimately, it's a way to structure a list of items so that proving an item is inside the list takes log(n) steps. For the uninitiated, log(n) is considered to be fast (in fact, exponentially fast). In practice, if we had a list of 10,000,000 Ethereum addresses, a Merkle Proof (given that we've added the addresses to a Merkle Tree) would contain ~24 numbers. Bumping up the numbers to a mere 27 would allow for 130,000,000+ addresses.

We could do this without ZK — simply require addresses to provide Merkle Proof of them being inside the list with 24 numbers. However, the Ethereum blockchain is public by default; everyone would know who claimed the airdrop. This is where the power of ZK kicks in! We can verify that whoever claims an airdrop owns an Ethereum address within a Merkle Tree. To do so, we need to prove two points:

  1. The Ethereum address in question is contained within the allowed addresses in Merkle Tree.
  2. The user owns this Ethereum address.

Now, proving the first part is pretty trivial nowadays — simply use zk-kit! Unfortunately, the second part is where the tricky things start. We usually prove ownership of an Ethereum address by knowledge of an arbitrary signature. If one can sign a message with the private key corresponding to a public key, chances are they know the private key (or stole the signature — which is where the "arbitrary" part of proving comes in).

The issue is that Ethereum uses the secp256k1 elliptic curve, which is expensive to verify in ZK applications. Currently, the two most advanced ways to verify an Ethereum signature in ZK are efficient-zk-ecdsa and spartan-ecdsa. Unfortunately, the former is too heavy to compute every single time a ZK proof is needed, and the latter cannot be verified permissionlessly on-chain.

Obviously, we want to have a decentralized and permissionless system — so spartan-ecdsa doesn't work. However, what if we could verify ownership of an Ethereum address once by submitting some type of commitment on-chain and then use it in ZK dapps later? Enter SealHub, where we did exactly that!

How does SealHub work?

The overall process is pretty straightforward:

  1. Ask a user to sign a specific message. This signature becomes the user's password (as only the user can generate the signature specific to them).
  2. Verify the signature with ZK and record the hash of the signature on-chain.
  3. In the future, ZK dapps will simply check the knowledge of the signature. Only the owner of the address would know the signature.

Now, there are a few issues with this approach that we are not going to discuss in this article:

  1. What if a malicious actor steals the signature? Then they can pose as the victim.
  2. If a signature is compromised, how can one invalidate it and replace it with another one?
  3. Essentially, we're verifying knowledge of the signature as a proxy to the knowledge of the private key. How safe is this?

SealHub is live as an alpha on the test network as a proof of concept. Hence, the issues above exist but are not critical at the moment. You can connect your wallet, put a commitment on-chain, and then use it to access any other ZK dapps on the same testnet. Later in this article, I'll explore how one can create a ZK airdrop using SealHub!

Let's explore every step of the process separately.

Ask a user to sign a specific message

When using SealHub, you will notice that we ask you to sign the following message:

SealHub verification specific to your address

⚠️ Never sign this message at the websites you don't trust! The signature is your password, do not share the signature with anyone! ⚠️

We then take the resulting signature and generate a valid commitment to be put on-chain:

export function getCommitmentFromSignature(signature: string, message: string) {
  const precomputes = getUAndSFromSignature(signature, message)
  const address = utils.verifyMessage(message, signature)
  return getCommitmentFromPrecommitment({ ...precomputes, address })
}

As you can see, the commitment is a simple hash of the address, plus the s, U, and T components of the signature. I'll tell you more about the signature components in a bit, for now, just bear with me.

Then we check if the commitment has already been recorded on-chain (because if it has, then there is no need to re-record it). This is as simple as reading mapping(uint256 => bool) public commitmentMap on the SealHub contract on Goerli. We can also use seal-hub-kit that we've built to simplify the integration of SealHub in future ZK dapps. Here's how we do this on the SealHub front end.

Verify the signature with ZK and record the hash of the signature on-chain

Let's deal with T and U. Please, refer to another one of our blog posts for the math details. Still, essentially the approach efficient-zk-ecdsa takes is to precompute as many things as possible outside of the ZK circuit and then verify some stuff on-chain in Solidity smart contracts instead. Unfortunately, this results in humongous proof sizes that are unfeasible to be verified on-chain.

So we took it one step further and decided to extract U verification to a separate ZK circuit altogether. Here's how we check U in a separate circuit, here's how we check the rest of the signature components, and here's how we verify that the two ZK proofs are made for the same signature. This allows us to keep the Powers of Tau at 19, making it feasible to generate ECDSA ZK proof even on a mobile device. The size of the proof also became manageable to be submitted to an EVM-compatible chain. This helps a lot because we're also subsidizing SealHub transactions with OpenGSN.

However, not everybody has a good enough mobile device, so we also provide a way to generate the ECDSA ZK proof on a centralized server. Users can either use the SealHub official server or run their own prover. Of course, if their device permits, the proofs can also be generated locally in the web browser.

Now, having both the ECDSA proof and the U precomputes proof, the client combines them in one transaction to put the commitment on-chain:

function createCommitment(
  ECDSAProof memory _ecdsaProof,
  UPrecomputesProof memory _uPrecomputesProof
) public {
  // Check the proof
  require(
    ICompleteECDSACheckerVerifier(verifierContract).verifyProofs(
      _ecdsaProof,
      _uPrecomputesProof
    ),
    "Invalid ZK proof"
  );
  // Add the commitment
  uint256 commitment = _ecdsaProof.input[0];
  commitmentMap[commitment] = true;
  commitments.push(commitment);
  numberOfCommitments.increment();
  // Add to Merkle Tree
  tree.insert(commitment);
  bytes32 merkleRoot = bytes32(tree.root);
  merkleRoots.push(merkleRoot);
  merkleRootMap[merkleRoot] = true;
  emit CommitmentCreated(commitment, merkleRoot);
}

You can notice that the commitment isn't only added to the chain but also to the Merkle Tree with all the commitments. Why so?

ZK dapps will simply check the knowledge of the signature

We're dealing with ZK here, so we must protect user privacy. Remember the membership proofs? You might have a sense of what's coming now! The truly powerful thing in this scheme is that you can verify the validity of a commitment within a Merkle Tree and get the user's address inside the ZK circuit! All of this without disclosing either the commitment or the address!

We will use seal-hub-kit to use the SealHub type of commitment (it encapsulates many steps and makes the process streamlined). Here's what you need to do:

  1. Obtain a signature of the specified message from the user locally.
  2. Generate the commitment from the signature locally.
  3. Check if the commitment is registered with SealHub. If not, direct the user toward SealHub; continue if the commitment is registered.
  4. Get all the ZK inputs for the SealHubValidator Circom template (you only need to provide the signature and the message).
  5. Use the SealHubValidator template in your ZK circuits (this will give you the user's address which you can use).
  6. In your smart contracts, check if the ZK proof is valid and that the Merkle Root SealHubValidator produces is valid.

And this is it! We've built seal-hub-verifier-template to show you an example of how this could work.

🎉
12,022 constraints are all you need to verify the SealHub commitment, which you can use as a proxy of an Ethereum address ownership!

Let's airdrop!

So SealHub is awesome, but how would one use it? Let's see how we can build a simple airdrop website that utilizes SealHub and creates a ZK proof of ownership for an Ethereum address! We will be building the SealHub | memorabilia token website — go ahead, give it a spin. The website has one button to connect a wallet and one button to mint a memorabilia token (it looks like this).

The website (and smart contracts) won't allow you to mint the token if you haven't registered a SealHub commitment. We will be using seal-hub-kit it extensively to simplify the process — the package contains a lot of stuff that you don't have to worry about. We'll start with the smart contracts — here's the full repository for your reference.

When building a somewhat complex codebase, I love to work backward from top to bottom — first working on the end interface and then figuring out the details. In this case, eventually, we should have a smart contract on an EVM-compatible chain (in our case, this is the Goerli testnet) that checks the SealHub commitment and mints a token. As a bonus, we can create a ZK-nullifier that acts as a guard against the same address minting multiple tokens.

A ZK-nullifier is a "number" that looks random enough not to be traced back to the wallet that creates it, but that is always the same for the same wallet. You can see how it can be used to prevent "double-spending!" I'm using seal-hub-verifier-template and creating the repository that I've mentioned above.

This is how the resulting repository looks like

I've changed some metadata, but the two main parts here are contracts/SealHubMemorabiliaToken.sol and circuits/NullifierCreator.circom. The first one is the token that we'll be writing right now, and the second one is the ZK circuit we'll be working on next. Let's have a look at the token code!

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@big-whale-labs/seal-hub-kit/contracts/SealHubChecker.sol";
import "@big-whale-labs/versioned-contract/contracts/Versioned.sol";
import "./NullifierCreatorVerifier.sol";

contract SealHubMemorabiliaToken is ERC1155, SealHubChecker, Versioned {
  // State
  mapping(uint => bool) public nullifiers;
  NullifierCreatorVerifier private nullifierCreatorVerifier;

  constructor(
    string memory _version,
    string memory _uri,
    address _nullifierCreatorVerifier,
    address _sealHub
  )
    Versioned(_version)
    ERC1155(_uri)
    SealHubChecker(_sealHub)
  {
    nullifierCreatorVerifier = NullifierCreatorVerifier(
      _nullifierCreatorVerifier
    );
  }

  function mint(
    uint[2] calldata _pA,
    uint[2][2] calldata _pB,
    uint[2] calldata _pC,
    uint[2] calldata _pubSignals
  ) public {
    // Check the nullifier
    require(!nullifiers[_pubSignals[1]], "Nullifier has already been used");
    // Check the SealHub commitment
    _checkSealHub(_pubSignals[0]);
    // Check the ZK proof
    require(
      nullifierCreatorVerifier.verifyProof(_pA, _pB, _pC, _pubSignals),
      "Invalid proof"
    );
    // Mint the token
    _mint(msg.sender, 0, 1, "");
    // Add the nullifier
    nullifiers[_pubSignals[1]] = true;
  }
}

Here are the main points about this contract:

  • This is an ERC1155 token inherited from OpenZeppelin contracts.
  • The contract also inherits SealHubChecker from seal-hub-kit which has just one internal function _checkSealHub that checks if the commitment provided inside the ZK circuit is registered on SealHub.
  • The contract also inherits Versioned from versioned-contract by Big Whale Labs (that's us!) to have a set version constant (helps to identify contracts on-chain).
  • Nullifiers, in this case, are simple uint numbers, so we can simply create a mapping like nullifiers to remember which ones were used.
  • The nullifierCreatorVerifier will be the resulting verifier from the ZK circuit we'll be writing shortly — it will verify the ZK proof of an Ethereum address ownership.
  • The constructor here simply sets the version of the contract, the URI for the NFT metadata readable by marketplaces, the addresses for the verifier, and the SealHub address. You can see where we get these values from the scripts/deploy.ts file.

Now, the mint function is a bit more complicated. After all, it has to do all these small checks to verify that whoever is trying to mint a token can do so! Here's what's going on:

  • First, we check if the nullifier from the ZK proof has already been used — if so, YEET.
  • Then, we check if the ZK proof is valid — if not, YEET.
  • Both checks are done, so we can safely mint the token and remember the nullifier for future YEETing.

It wasn't that complicated, was it? When someone describes things in an easy-to-follow, top-to-bottom, left-to-right manner, programming is helluva simple. Now, the circuit might be a bit more complicated:

pragma circom 2.0.4;

include "./templates/SealHubValidator.circom";
include "../circomlib/circuits/mimcsponge.circom";

template NullifierCreator() {
  var k = 4; // ECDSA verification number of components per number
  var levels = 30; // Depth of the commitment Merkle tree
  // Private inputs, *never* export them publicly
  signal input s[k]; // Pre-commitment signature component
  signal input U[2][k]; // Pre-commitment signature component
  signal input address; // Pre-commitment address
  signal input pathIndices[levels]; // Merkle proof that commitment is a part of the Merkle tree
  signal input siblings[levels]; // Merkle proof that commitment is a part of the Merkle tree
  // Verify SealHub commitment
  component sealHubValidator = SealHubValidator();
  for (var i = 0; i < k; i++) {
    sealHubValidator.s[i] <== s[i];
    sealHubValidator.U[0][i] <== U[0][i];
    sealHubValidator.U[1][i] <== U[1][i];
  }
  sealHubValidator.address <== address;
  for (var i = 0; i < levels; i++) {
    sealHubValidator.pathIndices[i] <== pathIndices[i];
    sealHubValidator.siblings[i] <== siblings[i];
  }
  // Export Merkle root
  signal output merkleRoot <== sealHubValidator.merkleRoot;

  // !! By now, we have verified that the user:
  // !! 1. Knows the signature r, U with the address
  // !! 2. Commitment derived from r, U and the address are in the Merkle tree
  // !! We can now use r, U, address to create a nullifier that will be deterministic for this r, U and address

  // Compute nullifier
  component nullifierMimc = MiMCSponge(3 * k + 3, 220, 1);
  nullifierMimc.k <== 0;
  // Fill in pre-commitment
  for (var i = 0; i < k; i++) {
    nullifierMimc.ins[i] <== s[i];
    nullifierMimc.ins[k + i] <== U[0][i];
    nullifierMimc.ins[2 * k + i] <== U[1][i];
  }
  nullifierMimc.ins[3 * k] <== address;
  // Add extra numbers specific to our application (just to scramble the hash)
  nullifierMimc.ins[3 * k + 1] <== 69;
  nullifierMimc.ins[3 * k + 2] <== 420;
  // Export nullifier
  signal output nullifierHash <== nullifierMimc.outs[0];

  // !! We are now sure that the user who generates this ZKP
  // !! knows the signature s, U signed with private key corresponding
  // !! to the address. We can use this address anyway we want
  // !! e.g. proving that it's a part of a merkle tree of Cryptopunk holders and exporting
  // !! the merkle root
  // !! But we *should not* export it as a public output
}

component main = NullifierCreator();

I'll admit it: I'm lazy. This is why I didn't do nothing but remove the debug log statement exporting the public key. I even used the existing constants from the template for the nullifier creation — the 69 and 420 you see in the code (nice)! Here's what's going on:

  • We use the template from SealHubValidator.circom to simplify the logic here.
  • SealHubValidator template consumes the SealHub signature, the resulting address (it's part of the commitment), and the Merkle proof of the fact that the commitment is registered on SealHub.
  • Then we compute the nullifier by taking some parts of the pre-commitment (the things that we use to generate the commitment) and some random numbers 69 and 420 (nice).
  • The only thing that we export publicly is the Merkle root of the commitment Merkle tree on SealHub. This is what allows us to verify that the commitment we have here was registered (it's verified in the smart contract above with SealHubChecker)
  • In a real-life scenario, you would also provide Merkle proof that this address is part of an allow-set to mint the token, for instance. This would allow you to both check the ownership of the address and that the address is a part of a specific set!

Feeling shaky? Read my article "Zero-knowledge is easy or the ultimate how-to article" to refresh your knowledge on the ZK tech stack!

Now, prepare all your big oof gifs to react to me struggling with a stupid transition from nice OOP to a fully functional hook-oriented structure of the wagmi library. Why do await sign("message") if you could do const { data, isLoading, signMessage } = useSignMessage(), am I right?

I've seen this shift to a fully functional paradigm in mobile, then in web, and now in web3? Seems like everywhere I go, "brilliant" developers rediscover the functional paradigm and try to "fix" what's not broken, resulting in tens of thousands of devs writing incomprehensible code. Instead of having a nice top-to-bottom, left-to-right code flow, we get... well, you'll see this crap below, I don't have to explain this to you.

Refer to the repository for the frontend of the SealHub | memorabilia token website. As promised, it's just one large button to connect a wallet and then mint the token.

I won't bore you with details on how to set up RainbowKit to connect the wallets — you can find numerous tutorials on the Internet and even read the official documentation. I will also not touch on the setup of the preact project (preact is a lighter version of react that we use everywhere). The only place we'll look at will be the MintingFlow component that is shown when the wallet has already been connected.

First, let's talk about the state management in react with wagmi hooks. It is stupid and cumbersome. I had to set up the state machine with the state and the switch around that state:

enum Step {
  empty = '',
  awaitingSignature = 'Awaiting signature...',
  checkingCommitment = 'Checking commitment...',
  commitmentNotRegistered = 'Commitment not registered',
  checkingBalance = 'Checking balance...',
  zk = 'Generating a ZK proof...',
  minting = 'Minting...',
  minted = 'Minted!',
  error = 'Error',
}

<...>

switch (step) {
  case Step.empty:
    setGlobalLoading(false)
    setCommitmentRegistered(null)
    break

<...>

{step === Step.minted && (
  <p>
    Congratulations! You've minted the token!

<...>

Instead of having a simple asynchronous function that is called by UI and does all the steps one by one in a simple try/catch block, I have to jump through many hoops and move my eyes constantly up and down to follow the code. This pattern reminds me of the good old callback and promise hells. But you'll see it when you look at the code — this article isn't for me to rant about "these young kids ruining my favorite design patterns."

Let's dissect the most crucial functions of interest here:

async function checkCommitment() {
  try {
    const commitment = await getCommitmentFromSignature(
      signatureData!,
      getMessage()
    )
    setCommitmentRegistered(
      await isCommitmentRegistered(
        commitment,
        new AlchemyProvider('goerli', env.VITE_ALCHEMY_KEY)
      )
    )
  } catch (error) {
    console.error(error)
    setStep(Step.error)
  }
}

After we get the user to sign the getMessage() from seal-hub-kit, we feed it into the getCommitmentFromSignature from the same package and then check if it's registered with SealHub. As you can see, we've encapsulated most things in seal-hub-kit, and you only need the hex signature to use it!

async function checkBalance() {
  if (!address) {
    setStep(Step.error)
    return
  }
  const balance = await tokenContract.balanceOf(address, 0)
  if (balance.eq(0)) {
    setStep(Step.zk)
  } else {
    setStep(Step.minted)
  }
}

Back to the rant on state management in the functional paradigm. Functional programming does not handle state good enough! As the meme goes: "It's in the name!" This is why we must check prerequisites, like address at the beginning of every function here. Cumbersome? Yes. Do I hate the new kids ruining my favorite design patterns? I'll leave it to you to answer.

Here we simply check if the token has been minted already from the connected address. The smart contract won't allow this to be minted again — but having a nice UI reaction is always a nice touch.

async function generateZKProof() {
  if (!signatureData) {
    setStep(Step.error)
    return
  }
  try {
    const inputs = await getSealHubValidatorInputs(
      signatureData,
      getMessage(),
      new AlchemyProvider('goerli', env.VITE_ALCHEMY_KEY)
    )
    const proof = (await snarkjs.groth16.fullProve(
      inputs,
      '/NullifierCreator.wasm',
      '/NullifierCreator_final.zkey'
    )) as ProofResult
    setProof(makeTransaction(proof))
    setStep(Step.minting)
  } catch (error) {
    console.error(error)
    setStep(Step.error)
  }
}

Yes, finally, some zero-knowledge! But this is quite... underwhelming. Again, everything complicated is encapsulated in the seal-hub-kit level. You give getSealHubValidatorInputs the signature, and this is basically it. Then you use snarkjs to generate the proof. This is all we'll need to mint the token — the proof is here!

async function mint() {
  if (!proof || !signer) {
    setStep(Step.error)
    return
  }
  try {
    const tx = await tokenContract
      .connect(signer)
      .mint(proof._pA, proof._pB, proof._pC, proof._pubSignals)
    await tx.wait()
    setTxHash(tx.hash)
    setStep(Step.minted)
  } catch (error) {
    console.error(error)
    setStep(Step.error)
  }
}

Now, the actual mint function is unremarkable. We take the proof and feed it to the mint function. Then we save the transaction hash to show it in the UI.

OMG, this is all you need to know to use SealHub. You don't need a computer science degree to understand how all of it works (looking at you, Docker, why the hell do I need to dig through hundreds of pages of documentation to understand how to share volumes?)

Drawbacks

There are a few reasons why SealHub ended up only an experiment. There is still a bit of uncertainty in some technical details that will require more time investment to figure out — and we're all hands on ketl at the moment.

Also, go try ketl! It's the first-of-a-kind, fully decentralized pseudonymous zero-knowledge social network on EVM! Anyway, here are some issues with SealHub that still need to be addressed:

  • Currently, we use efficient-zk-ecdsa signature verification, and whereas it is great at verifying ownership, tech-savvy users can generate multiple signatures for the same message from the same private key. This makes it difficult to have a deterministic nullifier scheme; if users can generate multiple signatures for the same message, they can virtually generate an infinite number of nullifiers. We rely on the crypto wallets to generate the same type of signatures for the same message for now.
  • When using SealHub, one still needs to generate a full efficient-zk-ecdsa proof at least once. This can be costly in terms of processing power — and unfeasible on low-end devices. We solved this by providing users with a centralized prover — and with a simple way to set up their own centralized provers so that they don't have to trust BWL with their private inputs. ECDSA ownership verification must be improved in ZK, but this should still be verifiable on-chain.
  • If a malicious website steals a user's signature for the SealHub message, then the website can impersonate the user. This is true for most applications in Web3, though. Don't sign random messages on websites you don't trust! However, we had a mechanism to "roll" the commitments in the workings in case a signature gets compromised. This must be completed for the system to be production-ready.
  • A ZK proof of knowledge of a specific signature can be a good enough proxy of knowledge of the private key. However, a way better solution would allow the use of unique signatures per action as a proxy of knowledge of the private key. Unfortunately, a ZK proof of knowledge of a specific signature has to do.
  • Currently, we are using the Groth16 proving scheme that requires a trusted setup. Before going to production, it wouldn't be that difficult to switch to a proving scheme that doesn't need a trusted setup.

As you can see, the drawbacks above have prevented us from releasing the mainnet version of SealHub as it stands right now. As noted multiple times, this was an experiment we did as a company to try to advance the ZK field forward. I believe we did a way better job at ketl. I mean, it's so good it's almost mainnet-ready!

Conclusion

We spent a good chunk of 2022 and 2023 building up the tech for SealHub — and now it serves as a proof of concept that ZK userflow can — and must! — be improved. With a few tricks here and there, users can now generate ECDSA ownership proofs on virtually any device. Just check out the resulting SealHub | memorabilia token website and see for yourselves!

After all this effort, we finally got to the point when we were comfortable enough to launch ketl — our next experiment with zero-knowledge and decentralized social. Hell, that's what we've been doing at Big Whale Labs since the very beginning!

I hope you found this article useful — or at least checked out what VCs and founders talk about at ketl. Cheers!