Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "gnark/multi-schnorr/contract/lib/openzeppelin-contracts"]
path = gnark/multi-schnorr/contract/lib/openzeppelin-contracts
url = https://github.com/OpenZeppelin/openzeppelin-contracts
9 changes: 9 additions & 0 deletions gnark/multi-schnorr/.devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "Multi-Schnorr ZK Prover",
"image": "mcr.microsoft.com/devcontainers/go:2-1.24-bookworm",
"remoteUser": "vscode",
"postCreateCommand": "bash -lc \"[ -f go.mod ] && go mod download || true; sudo apt-get update -y && sudo apt-get install -y --no-install-recommends ca-certificates curl git; curl -L https://foundry.paradigm.xyz | bash; export PATH=\\\"$HOME/.foundry/bin:$PATH\\\"; $HOME/.foundry/bin/foundryup; $HOME/.foundry/bin/forge --version && $HOME/.foundry/bin/cast --version\"",
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
]
}
3 changes: 3 additions & 0 deletions gnark/multi-schnorr/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
proof.json
*.r1cs
*.pprof
103 changes: 103 additions & 0 deletions gnark/multi-schnorr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Multi-Schnorr ZK Prover

Gnark groth16 circuit and tooling for verifying multiple Schnorr signatures against a Merkle-committed validator set.
Uses babyJubJub over BN254 Fr and MiMC hash function for optimal proving time and on-chain verification compatability.

### Circuit Overview

- **Validator set:** `S` of size `MaxK = 2^Depth`, with `depth` as the depth of merkle tree of validator pubic key hashes (currently set as 6) and inactive entries gated by `IsIgnore = 1`.
- **Merkle binding:** One-time MiMC tree built from `S` (`leaf = MiMC(Ax, Ay)`), less computation complexity than verifying each membership proof if k is large (eg. 2/3).
- **Membership:** Membership (per candidate): enforce that `(Ax,Ay)` matches exactly one leaf in `S`
- **Verification:** For each active entry, enforce `[S]G = R + [e]A`, with `e = MiMC(Rx, Ry, Ax, Ay, Message)`.
- **Counting:** `SumValid` accumulates all active, valid signatures, that can be compared against threshold in verifying smart contract

### Utility Functions

- **Key Generation:** Generates padded key pairs and persists them in keys.json.
- **Merkle Root Builder:** Builds the validator set Merkle root from generated public keys.
- **Candidate Builder:** Prepares candidate structures for proof creation.
- **Prepare Witness:** Creates complete witness data for the Groth16 circuit (Merkle membership, signatures, and valid signer tracking)

### Scripts

#### Setup

The `keygen.sh` and `setup_and_deploy_sepolia.sh` scripts together form the setup phase of the proving system and only need to be executed once for each circuit version.

They bind the validator set’s `Merkle root` and `threshold` to the `MultischnorrVerifier` contract and the `verifying key (VK)`to the `Verifier` contract, ensuring proofs from the matching `proving key (PK)` are valid only for that configuration.

- If the Merkle `depth` or circuit logic change, `setup_and_deploy_sepolia.sh` must be rerun and contracts redeployed, since the `VK` is hardcoded in the `Verifier` contract.
- If the validator `keys`, merkle `root` or `threshold` changes, the existing contracts can be used with the merkle root and threshold can be updated in the `MultischnorrVerifier` contract.

`keygen.sh`

- Generates validator key pairs and computes the Merkle root.
- Note: Since in the circuit, the depth of the merkle tree is set to 6, 64 (2^6) validator keys are generated. Because the circuit needs to be static and have fixed size at compile time, if in future, more validators are added, just update the ciruit.
Ouputs: `keys.json` with public/private key pairs and `merkle_root.txt` with merkle root.

`setup_and_deploy_sepolia.sh`

- Compiles the cicuit and build the Proving Key (PK) and Verifying Key (VK), both of which can be made public.
- Generates a `Verifier` contract that hardcodes the `VK` inside the smart contract for reproducible deployment.
- The setup and deploy script does the setup + deployment with the threshold and merkle root as constructor values.
- Note: The setup and deployment is required everytime the circuit changes and the keys change with each setup.
- Note: If the number of public inputs change, it will be needed to update the `MultischnorrVerifier` contract as it uses circuit specific inputs.
Outputs: `circuit.r1cs`: compiled form of the circuit and `multischnorr.g16.pk` and `multischnorr.g16.vk` and `deployment.json` containing the addresses of `Verifier` and `MultischnorrVerifier` contracts.

#### Proof Generation & Verification

`prove.sh`

- Generates a Groth16 proof, converts it to a Solidity-compatible format, and verifies it on-chain by sending a transaction via `cast send`
- Builds the witness including the signatures for indices that signed, message and calculate the `sumValid`, which is the number of valid signatures that the circuit doesn't ignore.
- Sends a transaction with the proof to call the `verify` function on the `MultischnorrVerifier` contract.
Ouputs: `proof.json` with a flattened version of proof and public inputs (public witness) required by the contract to verfiy the proof.

### Contracts

- `Verifier`: Auto-generated Groth16 verifier with the Verifying Key (VK) hardcoded as constants
- `MultiSchnorrVerifier`: Ownable wrapper around the verifier. It performs: validation of `threshold` against `sumValid`, validation of `merkle root` provided as input against `root` stored in contract by `owner`. Delegates to the `Verifier` with public inputs and proof data to verify the proof and if successful, emits a `ProofVerified` event.

### Running

The scripts can be run locally as well as in a devcontainer.

To run the scripts locally, make sure [golang](https://go.dev/doc/install) and [foundry](https://getfoundry.sh/introduction/installation/) are installed in the system.

To run the scripts using the devcontainer, make sure VScode and [Docker](https://www.docker.com/get-started/) is installed and running.

Running a Devcontainer: VS Code → Command Palette → “Dev Containers: Rebuild and Reopen in Container”

0. If not in the right directory (for running locally only)

```
cd gnark/multi-schnorr
```

1. Generate keys & Merkle root

```
bash ./keygen.sh
```

2. Compile, setup Groth16, and deploy contracts

```
bash ./setup_and_deploy_sepolia.sh \
--private-key 0xYOUR_DEPLOYER_PK \
--rpc-url https://sepolia.rpc.url \
--threshold <uint256> \
--etherscan-api-key ETHERSCAN_API_KEY
```

3. Generate a proof and submit on-chain

```
bash ./prove.sh \
--rpc-url <URL> \
--private-key <0xPK> \
--msg "<message string or hex>" \
--signers "space separated indices"
```

Note: The message here can be a string as well as a hex that can be formed using something like `abi.encode`.
35 changes: 2 additions & 33 deletions gnark/multi-schnorr/circuit.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ func (c *Circuit) Define(api frontend.API) error {
return err
}
G := twistededwards.Point{X: params.Base[0], Y: params.Base[1]}
identity := twistededwards.Point{X: 0, Y: 1}

h, err := mimc.NewMiMC(api)
if err != nil {
Expand Down Expand Up @@ -111,35 +110,6 @@ func (c *Circuit) Define(api frontend.API) error {
rhsR := api.Add(1, dx2y2R)
api.AssertIsEqual(api.Mul(active, api.Sub(lhsR, rhsR)), 0)

// gated subgroup checks: [order]*P == identity
tA := E.ScalarMul(A, params.Order)
api.AssertIsEqual(api.Mul(active, api.Sub(tA.X, identity.X)), 0)
api.AssertIsEqual(api.Mul(active, api.Sub(tA.Y, identity.Y)), 0)

tR := E.ScalarMul(R, params.Order)
api.AssertIsEqual(api.Mul(active, api.Sub(tR.X, identity.X)), 0)
api.AssertIsEqual(api.Mul(active, api.Sub(tR.Y, identity.Y)), 0)

// Merkle membership check
// Hash the current candidate's pubkey
h.Reset()
h.Write(wi.Ax, wi.Ay)
candidateLeaf := h.Sum()

// For each active signature, check membership by finding matching leaf
var merkleMatches frontend.Variable = 0 // Will be 1 if pubkey found in tree

// Check if this leaf appears anywhere in our tree
// (This is the membership test - pubkey must be in validator set)
for j := 0; j < MaxK; j++ {
leafMatch := api.IsZero(api.Sub(candidateLeaf, leaves[j]))
merkleMatches = api.Add(merkleMatches, leafMatch)
}

//enforces only a single match
api.AssertIsEqual(api.Mul(active, api.Sub(merkleMatches, 1)), 0)
merkleOK := api.IsZero(api.Sub(merkleMatches, 1))

// Schnorr challenge e = H(Rx, Ry, Ax, Ay, msg)
h.Reset()
h.Write(R.X, R.Y, A.X, A.Y, c.Message)
Expand All @@ -154,9 +124,8 @@ func (c *Circuit) Define(api frontend.API) error {
okX := api.IsZero(api.Sub(sG.X, rhsP.X))
okY := api.IsZero(api.Sub(sG.Y, rhsP.Y))

// valid_i = active ∧ merkleOK ∧ okX ∧ okY (AND via multiplication)
valid := api.Mul(active, merkleOK)
valid = api.Mul(valid, okX)
// valid = active ∧ okX ∧ okY (AND via multiplication)
valid := api.Mul(active, okX)
valid = api.Mul(valid, okY)

sumValid = api.Add(sumValid, valid)
Expand Down
6 changes: 6 additions & 0 deletions gnark/multi-schnorr/contract/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Compiler files
cache/
out/
lib/
build/
src/Verifier.sol
66 changes: 66 additions & 0 deletions gnark/multi-schnorr/contract/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
## Foundry

**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.**

Foundry consists of:

- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools).
- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data.
- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network.
- **Chisel**: Fast, utilitarian, and verbose solidity REPL.

## Documentation

https://book.getfoundry.sh/

## Usage

### Build

```shell
$ forge build
```

### Test

```shell
$ forge test
```

### Format

```shell
$ forge fmt
```

### Gas Snapshots

```shell
$ forge snapshot
```

### Anvil

```shell
$ anvil
```

### Deploy

```shell
$ forge script script/Counter.s.sol:CounterScript --rpc-url <your_rpc_url> --private-key <your_private_key>
```

### Cast

```shell
$ cast <subcommand>
```

### Help

```shell
$ forge --help
$ anvil --help
$ cast --help
```

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions gnark/multi-schnorr/contract/foundry.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"lib/openzeppelin-contracts": {
"tag": {
"name": "v5.4.0",
"rev": "c64a1edb67b6e3f4a15cca8909c9482ad33a02b0"
}
}
}
10 changes: 10 additions & 0 deletions gnark/multi-schnorr/contract/foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
evm_version = "paris"
remappings = [
"@openzeppelin/=lib/openzeppelin-contracts/"
]

# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Script.sol";
import {MultiSchnorrVerifier} from "../src/MultiSchnorrVerifier.sol";
import {Verifier} from "../src/Verifier.sol";

contract DeployMultischnorr is Script {
function run(uint256 threshold, uint256 merkleRoot) external {
vm.startBroadcast();
address owner = tx.origin;
Verifier ver = new Verifier();
MultiSchnorrVerifier verifier = new MultiSchnorrVerifier(
ver,
threshold,
merkleRoot,
owner
);
vm.stopBroadcast();

console2.log("Verifier deployed at:", address(ver));
console2.log("MultischnorrVerifier deployed at:", address(verifier));
console2.log("owner:", owner);
}
}
90 changes: 90 additions & 0 deletions gnark/multi-schnorr/contract/src/MultiSchnorrVerifier.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/access/Ownable.sol";
import {Verifier} from "./Verifier.sol";

contract MultiSchnorrVerifier is Ownable {
Verifier public verifier;
uint256 public threshold;
uint256 public merkleRoot;

event VerifierUpdated(
address indexed oldVerifier,
address indexed newVerifier
);
event ThresholdUpdated(uint256 oldThreshold, uint256 newThreshold);
event MerkleRootUpdated(uint256 oldRoot, uint256 newRoot);
event ProofVerified(
bytes message,
uint256 merkleRoot,
uint256 messageFr,
uint256 sumValid
);

error InvalidMerkleRoot();
error InsufficientSignatures();

constructor(
Verifier _verifier,
uint256 _threshold,
uint256 _root,
address _owner
) Ownable(_owner) {
require(address(_verifier) != address(0), "verifier=0");
require(_threshold > 0, "threshold=0");
verifier = _verifier;
threshold = _threshold;
merkleRoot = _root;
}

uint256 constant R =
0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001;

function updateVerifier(Verifier newVerifier) external onlyOwner {
require(address(newVerifier) != address(0), "verifier=0");
address old = address(verifier);
verifier = newVerifier;
emit VerifierUpdated(old, address(newVerifier));
}

function updateThreshold(uint256 t) external onlyOwner {
require(t > 0, "threshold=0");
uint256 old = threshold;
threshold = t;
emit ThresholdUpdated(old, t);
}

function updateMerkleRoot(uint256 r) external onlyOwner {
require(r != 0, "root=0");
uint256 old = merkleRoot;
merkleRoot = r;
emit MerkleRootUpdated(old, r);
}

function keccakToFr(bytes memory m) internal pure returns (uint256) {
return uint256(keccak256(m)) % R;
}

/// @notice Verify proof binds {merkleRoot, hashToFr(message), sumValid}
/// and emits the original message.
function verify(
uint256[8] calldata proof, // if compressed: change to uint256[4]
bytes calldata message,
uint256 _merkleRoot,
uint256 sumValid
) external {
if (sumValid < threshold) {
revert InsufficientSignatures();
}
if (_merkleRoot != merkleRoot) {
revert InvalidMerkleRoot();
}
uint256 messageFr = keccakToFr(message);
uint256[3] memory input = [merkleRoot, messageFr, sumValid];

verifier.verifyProof(proof, input);

emit ProofVerified(message, merkleRoot, messageFr, sumValid);
}
}
Loading