ZK
In the previous chapter, we learned how a simple light client operates. In this chapter, we will look into leveraging zero-knowledge cryptography to improve the onchain efficiency of our light client.
Recall how a light client verifies that a block is canonical. In the case of Ethereum, we track the sync committee and aggregate the BLS public key. The signature already comes pre-aggregated in the block.
def _aggregate_pubkeys(committee, bits):
pubkeys = []
for i, bit in enumerate(bits):
if bit:
pubkeys.append(committee[i])
return bls.Aggregate(pubkeys)
For other chains, pre-aggregation might not occur. For example, Tendermint simply has each validator signature (and vote) appended in the block. This means that to verify if the block is canonical, we have to perform a signature verification for each validator individually. Here is a pseudo-Python Tendermint block verifier (it doesn't handle voting correctly and misses some components).
import base64
import json
from typing import List, Dict, Any
import nacl.signing
def verify_tendermint_votes(block_data: Dict[Any, Any]) -> bool:
"""Verifies validator signatures for a Tendermint block."""
# Extract block ID and validator votes
block_id = block_data["block_id"]["hash"]
precommits = block_data["last_commit"]["signatures"]
validators = block_data["validators"]
# Track validation results
valid_votes = 0
total_votes = len(precommits)
for vote in precommits:
if vote["signature"] is None:
continue
validator_idx = vote["validator_address"]
validator_pubkey = validators[validator_idx]["pub_key"]["value"]
# Decode signature and public key
signature = base64.b64decode(vote["signature"])
pubkey = base64.b64decode(validator_pubkey)
# Create vote message (block ID + vote data)
vote_data = {
"type": "precommit",
"height": block_data["header"]["height"],
"round": vote["round"],
"block_id": block_id,
"timestamp": vote["timestamp"],
"validator_address": validator_idx
}
msg = json.dumps(vote_data, sort_keys=True).encode()
# ANCHOR: verify-signature
verify_key = nacl.signing.VerifyKey(pubkey)
try:
verify_key.verify(msg, signature)
valid_votes += 1
except nacl.exceptions.BadSignatureError:
pass
# ANCHOR_END: verify-signature
# Return true if 2/3+ of validators had valid signatures
return valid_votes >= (2 * total_votes // 3 + 1)
Note how for each vote, we perform:
verify_key = nacl.signing.VerifyKey(pubkey)
try:
verify_key.verify(msg, signature)
valid_votes += 1
except nacl.exceptions.BadSignatureError:
pass
Although this is just a single verify operation, computationally it is quite expensive. Doing this in Solidity would mean that we would spend about ±2 million gas per block verification. This also means that with more validators operational, we have a linear increase in computational cost. This cost translates into a higher fee for end users, making it something we want to avoid.
We can leverage zero-knowledge cryptography to have a constant computational cost, irrespective of the number of signatures we verify, as well as perform arbitrary other computation, such as vote-weight tallying.
First we will explore how to leverage Gnark to build a high performance circuit, analyzing an actual production circuit. Next we will re-implement the same logic using a zkvm.