Skip to content

Conversation

@Architsharma7
Copy link
Collaborator

@Architsharma7 Architsharma7 commented Oct 22, 2025

This PR adds a Groth16 verifier contract with input validation logic, comprehensive utility functions, and scripts for full proof generation and on-chain verification, including key generation, setup and deployment and proving proofs onchain scripts.

Relevant Information:

  1. 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.
  • Witness Data Generator – Creates complete witness data for the Groth16 circuit (Merkle membership, signatures, and valid signer tracking)
  1. Scripts

keygen.sh

  • Generates validator key pairs and computes the Merkle root.
  • Uses parameters n and maxK for the number of validator key pairs you want to generate and total number of validators set by the circuit
  • Note: Since in the circuit the depth of the merkle tree is set 6, the maxK cannot be more than 64 or the proof will fail. 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 a keys.json with public/private key pairs and merkle_root.txt with merkle root.
  • Since, the maxK is required for the circuit, if n < maxK, all the rest maxK - n keys are zeroes, but are used to build the merkle tree.

setup_and_deploy_sepolia.sh

  • Compiles the cicuit and build the Proving Key (PK) and Verifying Key (VK)
    • The contract uses a template verifier contract that validates proofs against the verifying key.
  • Extends the verifier with input validation logic:
    • Ensures the threshold constraint is respected.
    • Asserts the Merkle root matches the expected validator set.
  • Provides a script for generating:
    • The Proving Key (PK) and Verifying Key (VK) from the circuit.
    • Hardcodes the VK inside the smart contract template for reproducible deployment
  • The setup and deploy script does the setup + deployment with the threshold and merkle root as constructor values.
  • Note that 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 template_sol.go template as it uses the input[N] (where N is the number of public inputs) in functions publicInputMSM, verifyCompressedProof and verifyProof. Since, the circuit implementation in this case does not need to change the public inputs often or ever, it is highly unlikely to require a change to template contract.
  • Outputs circuit.r1cs: compiled form of the circuit and multischnorr.g16.pk and multischnorr.g16.vk

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 verifyproof function on the verifier contract.
  • Ouputs the proof.json with a flattened version of proof and public inputs (public witness) required by the contract to verfiy the proof.
  1. Verifier Contract
  • Automates hardcoding verifying key (VK) for reproducible deployments
  • Input validation logic:
    • Enforces threshold constraints
    • Verifies that the provided Merkle root matches the stored validator set

Workflow:

Step 1 - Generate Keys & Merkle Root

  • num_validators: number of validator keypairs you want to generate
  • maxK: should be < 64 and be a power of 2 for a perfect binary tree (assumption made in cirucit)
cd gnark/multi-schnorr
bash ./keygen.sh --num-validators {n} --maxK {k}

Step 2 - Setup & Deploy Verifier

  • threshold: threshold of validator signatures you want to set in the contract for proof verification against sufficent signatures
bash ./setup_and_deploy_sepolia.sh \
  --private-key 0xYOUR_DEPLOYER_PK \
  --rpc-url {sepolia_rpc_url} \
  --threshold {t} \

Step 3 - Generate & Verify Proof On-Chain

  • msg: mesage you want to sign. Should be in string format(""), eg. "hello zk world"
  • signers: List of signer indices for which the signatures are created and sumValid is calculated. Should be in string format("") and seperated by spaces, eg. "0 1 2 3 4 5". To verify the proof sucessfully, len(signers) > threshold.
bash ./prove.sh \
  --rpc-url {sepolia_rpc_url} \
  --private-key 0xYOUR_DEPLOYER_PK \
  --verifier 0xVerifierAddress \
  --msg {msg} \
  --maxK {k} \
  --signers {signers}

@Architsharma7 Architsharma7 marked this pull request as draft October 22, 2025 00:28
@Architsharma7 Architsharma7 changed the title added verifier contract and setup scripts added verifier contract and keygen, setup, prove utils and scripts Oct 27, 2025
@Architsharma7 Architsharma7 marked this pull request as ready for review October 27, 2025 15:23
Copy link

@anxolin anxolin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you by any chance forgot to commit the src dir. I can't find in your PR the verifier contract

image

Copy link

@fleupold fleupold left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great proof of concept!

A few thoughts

  • I think the repo can be public
  • Could you add a devcontainer + readme with sample commands (to avoid setup difficulties)
  • I think we should differentiate more clearly between setup (doesn't have to happen every time) and proof generation (has to happen every time)

--private-key) PK="$2"; shift 2 ;;
--verifier) VERIFIER="$2"; shift 2 ;;
--msg) MSG="$2"; shift 2 ;;
--maxK) MAXK="$2"; shift 2 ;;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed here? Shouldn't this be part of the circuit and not variable?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is just left in the case, we want to change the depth in the circuit any day, but agree, can be hardcoded somewhere and then change when you change the depth.

Comment on lines 19 to 20
--num-validators) NUM_VALIDATORS="$2"; shift 2 ;;
--maxK) MAXK="$2"; shift 2 ;;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the depth of the tree is hardcoded, I think we should not have to specify these values here. MaxK should always be 2**depth and num-validators 2/3 of K.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for this was modularity. If we want to increase the maxK in the circuit any day, it can be done easily.
The num-validators necessarily don't need to be 2/3 of MaxK as it is the number of validator keys you want to generate, not the number of validators who sign. So, if a system only have 56 validators right now, for MaxK = 64, you can have 56 validator keys (rest are just padded keys, that are being used for building the merkle root) and some out of them sign the bids, which is then maybe 2/3 of the validators being present in the system (i.e threshold, which you can change in the contract anytime validators enter/exit)

--rpc-url) RPC_URL="$2"; shift 2 ;;
--private-key) PK="$2"; shift 2 ;;
--verifier) VERIFIER="$2"; shift 2 ;;
--msg) MSG="$2"; shift 2 ;;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Message should probably an arbitrary long message (not a single point). I would like to see how the hashing/reduction to zk input works in the verifier contract as well.

At the end of the day we want to verify an ethereum transaction (not an integer).

Copy link
Collaborator Author

@Architsharma7 Architsharma7 Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added a MiMC library which performs the hashing and reduction from bytes to points and so now, you can pass the message as bytes (abi.encode can be used) to the verifier contract along with the proof and other public inputs and the message can be decoded later. There is also an event that emits this message once the proof is verified for the same. cc @fedgiac

--verifier) VERIFIER="$2"; shift 2 ;;
--msg) MSG="$2"; shift 2 ;;
--maxK) MAXK="$2"; shift 2 ;;
--signers) SIGNERS_STR="$2"; shift 2 ;;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this supposed to be used? Does the script hardcode private/public keys? Can you add sample commands in the readme?

@Architsharma7
Copy link
Collaborator Author

Have you by any chance forgot to commit the src dir. I can't find in your PR the verifier contract

image

No, since the verification key needs to be set in the smart contract and uses constant. There were two way:

  • make them updatable and update them with the vk
  • use the smart contract template in setup/template_sol.go to deploy the contract with replaced constants for verification key
    The second way was choosen to save more on time and gas cost to update these values, since they remain constant until the circuit changes.

So, when you call the setup script, the MultischnorrVerifier contract is added in src dir automatically with the vk values hardcoded.
@anxolin were you trying to do forge build in the contract folder?

Copy link

@fedgiac fedgiac left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice proof of concept!
I didn't read the code in detail, I just tried to run the script and fill the gaps by checking what I needed from the code. I couldn't verify the final proof (see comment) and I'm not sure why, maybe you have a better idea on what's going wrong?

Something I think would be helpful is using the message in some way in Solidity. When executing a transaction I couldn't recover the actual message from the user input, so I wonder how a contract is going to use it. What I'd like to see is having the autogenerated contract used as a library: for example, there could be another contract Verifier.sol (representing CoW exchange) that imports the autogenerated one, has an emitSignedMessage function and emits the original message if the verification through the autogenerated code succeeds. I imagine the actual message to be either decoded or compared with the proof input for validation.

Comment on lines 9 to 10
--num-validators <N> \
--maxK <K>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what these two are. Without knowing anything I thought that N was the total number of validators in the system and K was the threshold (size of the min validator set needed for a signature to be valid).
After reading the circuit, I believe my guess for N is correct but the threshold isn't enforced in the circuit, it's just verified that it matches the input. Then K is just an internal parameter to make the validator set fit a power of two and then it could be derived automatically instead of being user specified, right?


pushd "$CONTRACT_DIR" >/dev/null
echo ">> forge build"
forge build
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this to work I had to first run forge install OpenZeppelin/[email protected].
Probably the submodule needs to be checked in as well.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed now. It installs OZ library if it isn't present.

Comment on lines 96 to 99
outPath := filepath.Join(outDir, "MultischnorrVerifier.sol")
if _, err := os.Stat(outPath); err == nil {
return fmt.Errorf("file already exists: %s (delete or move it first)", outPath)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script threw an error because the file already exists, here maybe you could delete the existing file in advance.

writing MultischnorrVerifier contract...
2025/10/29 10:15:33 file already exists: /workspaces/zk-benchmark/gnark/multi-schnorr/contract/src/MultischnorrVerifier.sol (delete or move it first)
exit status 1

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also fixed in the new setup flow

Comment on lines +73 to +75
for i := 0; i < multischnorr.MaxK; i++ {
c := wd.Candidates[i]
assignment.S[i].Ax = c.Ax
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing the scripts with maxK = 32 this errors out with:

panic: runtime error: index out of range [32] with length 32

This is because multischnorr.MaxK is 64 regardless of user input. Where does it come from? It looks like it's an import from the GitHub mirror of this repo, can it use the local version instead?

Comment on lines 52 to 58
echo ">> Sending verifyProof tx…"
cast send \
--rpc-url "$RPC_URL" \
--private-key "$PK" \
"$VERIFIER" \
"verifyProof(uint256[8],uint256[3])" \
"$PROOF" "$INPUT"
Copy link

@fedgiac fedgiac Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This step reverts for me with execution reverted, data: "0x7fcdd1f4": ProofInvalid (I changed the code to execute it regardless, here it is).
Maybe this is related to the use of different code through the GitHub import?

What I did

$ go version
go version go1.25.3 linux/amd64
$ forge --version
forge Version: 1.4.3-stable
Commit SHA: fa9f934bdac4bcf57e694e852a61997dda90668a
Build Timestamp: 2025-10-22T05:31:43.173937269Z (1761111103)
Build Profile: maxperf
$ cd gnark/multi-schnorr/contract
$ forge install OpenZeppelin/[email protected]
$ cd ..
$ bash ./keygen.sh --num-validators 40 --maxK 64
$ rm -rf ./contract/src/MultischnorrVerifier.sol
$ bash ./setup_and_deploy_sepolia.sh --private-key "$PK" --rpc-url "$SEPOLIA_RPC_URL" --threshold 10 --merkle-root "$(cat merkle_root.txt)" --etherscan-api-key "$ETHERSCAN_API_KEY"
$ deployed_contract="$(cat contract/broadcast/DeployMultiSchnorrVerifier.s.sol/11155111/run-latest.json | jq '.transactions[0].contractAddress' --raw-output)"
$ bash ./prove.sh --rpc-url "$SEPOLIA_RPC_URL" --private-key "$PK" --verifier "$deployed_contract" --msg 'greetings' --maxK 64 --signers "$(seq 1 15)"

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cloned the repo again and executed the exact steps described above and the transaction was executed successfully! Here it is: https://sepolia.etherscan.io/tx/0x8047f3a27592486647de6a068d338732d723c0eaebc99315570a81bbecac3988
I wasn't able to understand what went wrong before. My suspicion is that there's some state that doesn't get cleared or updated correctly and so my multiple attempts were using some corrupted state.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The most probable reason for this is that the Verifier contract was deployed with the wrong (old) verifiyingKey and then the proof was generated with a new provingKey and hence the proof was failing.

@Architsharma7
Copy link
Collaborator Author

Incorporated all the changes requested, along with a Devcontainer and Readme. Ready to be reviewed again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants