Conversation
| let nullifier: [u8; 32] = hasher.finalize().into(); | ||
|
|
||
| // Populate SHA-256 circuit | ||
| self.hasher.populate_len_bytes(witness, scope.len() + 32); |
There was a problem hiding this comment.
There appears to be a length mismatch in the SHA-256 circuit population. The circuit is initialized with total_len = scope_len_bytes + 32 (lines 243-244), but when populating the witness, scope.len() + 32 is used (line 279).
These values can differ because scope_len_bytes represents the padded length used for circuit allocation, while scope.len() is the actual data length. The message vector is padded to wire boundaries (scope_wires = (scope_len_bytes + 7) / 8), creating scope_wires * 8 bytes of space.
To ensure consistency, consider using the same length value in both places:
// Either use scope_len_bytes in both places
self.hasher.populate_len_bytes(witness, scope_len_bytes + 32);
// Or adjust the circuit initialization to use the actual length
let len_bytes = builder.add_witness(); // Make length dynamicThis inconsistency could lead to constraint verification failures or incorrect hash computations.
| self.hasher.populate_len_bytes(witness, scope.len() + 32); | |
| self.hasher.populate_len_bytes(witness, scope_len_bytes + 32); |
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
ecc58f8 to
81b8963
Compare
| let nullifier: [u8; 32] = hasher.finalize().into(); | ||
|
|
||
| // Populate Keccak circuit | ||
| self.hasher.populate_len_bytes(witness, scope.len() + 32); |
There was a problem hiding this comment.
Length Inconsistency in Keccak Circuit Initialization
There's a potential bug in the length parameter passed to populate_len_bytes(). The circuit is initialized with total_len = scope_len_bytes + 32 (lines 239-241), but when populating the witness, scope.len() + 32 is used instead.
When the actual scope is shorter than scope_len_bytes, this creates a length mismatch that will cause constraint verification to fail. To maintain consistency:
// Change this:
self.hasher.populate_len_bytes(witness, scope.len() + 32);
// To this:
self.hasher.populate_len_bytes(witness, self.scope_len_bytes + 32);This ensures the length parameter matches what was used during circuit initialization.
| self.hasher.populate_len_bytes(witness, scope.len() + 32); | |
| self.hasher.populate_len_bytes(witness, self.scope_len_bytes + 32); |
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
81b8963 to
d922cb9
Compare
| // Create Keccak circuit with fixed 64-byte length like IdentityCommitmentKeccak | ||
| let len_bytes = builder.add_constant_64(64); | ||
| let hasher = Keccak::new(builder, len_bytes, nullifier, message.clone()); |
There was a problem hiding this comment.
Wire conflict bug: The Keccak circuit is created with message.clone() as the message parameter, but these message wires are created as add_witness() wires in the NullifierGeneratorKeccak constructor. However, the Keccak::new() constructor expects to own and manage these message wires internally. This creates a conflict where the same wires are being managed by both the parent circuit and the Keccak circuit, leading to potential witness population conflicts and constraint violations. The fix is to let Keccak::new() create its own message wires internally, or use a different approach that doesn't share wire ownership.
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
d922cb9 to
bb46cc5
Compare
703a7fe to
feff0f2
Compare
| [[example]] | ||
| name = "semaphore" | ||
| path = "examples/semaphore.rs" |
There was a problem hiding this comment.
The example configuration in Cargo.toml has a mismatch between the declared name and path. The entry specifies path = "examples/semaphore.rs", but the actual file added is examples/semaphore_ecdsa.rs. To ensure the example can be properly built and run, the path should be updated to match the actual file:
[[example]]
name = "semaphore"
path = "examples/semaphore_ecdsa.rs"Alternatively, the file could be renamed to match the declared path.
| [[example]] | |
| name = "semaphore" | |
| path = "examples/semaphore.rs" | |
| [[example]] | |
| name = "semaphore" | |
| path = "examples/semaphore_ecdsa.rs" | |
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
960a10d to
a90a61d
Compare
|
|
||
| // This does NOT match what the example produces: [82, 27, 68, 224, 77, 37, 233, 239, ...] | ||
| // So there's a discrepancy between frontend crate vs examples crate | ||
| } |
There was a problem hiding this comment.
The identity commitment calculation differs between the frontend and examples crates, producing inconsistent hash values. This discrepancy should be resolved to ensure deterministic behavior across the codebase. Consider standardizing the commitment generation logic in a shared utility function that both crates can reference, or at minimum documenting the expected behavior and why the implementations differ.
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
a90a61d to
5df2182
Compare
| assert!(!message.is_empty(), "Keccak message wires cannot be empty"); | ||
| assert!(max_len_bytes > 0, "Keccak max message length must be > 0, got {} bytes from {} wires", max_len_bytes, message.len()); | ||
|
|
There was a problem hiding this comment.
There appears to be confusion in the message length validation logic. The assertion on line 57 checks max_len_bytes > 0, but this value is calculated as message.len() << 3 on line 52. Since line 56 already asserts !message.is_empty(), the second assertion becomes redundant.
Additionally, the error message is misleading - it refers to "bytes" when max_len_bytes actually represents bits (since it's calculated as message.len() * 8).
Consider simplifying to a single assertion or clarifying the error message to accurately reflect that max_len_bytes represents the bit length, not byte length.
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
e2d891a to
64f50c6
Compare
| @@ -0,0 +1,19 @@ | |||
| { | |||
64f50c6 to
3f094e8
Compare
4d01b36 to
87fa501
Compare
87fa501 to
c457285
Compare
| public_key: Secp256k1Affine, | ||
| commitment: [Wire; KECCAK_DIGEST_LIMBS], | ||
| commitment_message_wires: Vec<Wire>, | ||
| hasher: Keccak, |
There was a problem hiding this comment.
The test identity generation uses predictable patterns like [((i + 42) as u8); 32] which creates weak ECDSA keys (arrays filled with the same byte value). While this is acceptable for testing, it's worth noting that in production code, proper cryptographically secure random generation should be used. Consider adding a comment indicating this is test-only code or using a more realistic key derivation approach even in tests to better simulate real-world conditions.
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
0670a31 to
130f794
Compare
| // Let's see if this matches what the frontend crate produces | ||
| } |
There was a problem hiding this comment.
The Common rule requires that commented out code must be flagged with an exception when it's clearly marked with a reason why that particular piece of commented code is included. The comment // Let's see if this matches what the frontend crate produces appears to be commented out code or a TODO without a clear explanation of why it's included in the codebase. This should either be removed or have a clear explanation of why it's being kept.
| // Let's see if this matches what the frontend crate produces | |
| } | |
| } |
Spotted by Diamond (based on custom rule: Irreducible Rust and Cargo)
Is this helpful? React 👍 or 👎 to let us know.
130f794 to
56077ed
Compare
56077ed to
c7bbbe5
Compare
|
I find this interface confusing: cargo run --example semaphore_ecdsa --release -- --bench 10 i'd rather have a dedicated bench with criterion etc; no point in re-runnign the example like this IMO |
|
agree, do you want to create one the remove this code? this can go into the category of benchmarking work. |
Merge activity
|
| // Build tree bottom-up | ||
| while level.len() > 1 { | ||
| let mut next_level = Vec::new(); | ||
| for i in (0..level.len()).step_by(2) { |
There was a problem hiding this comment.
nit: level.chunks(2) seems more natural here
| siblings.push(sibling); | ||
|
|
||
| // Compute next level | ||
| let mut next_level = Vec::new(); |
There was a problem hiding this comment.
this replicates the logic from root; wouldn't it be simpler to compute internal nodes in the constructor and cache them? It only requires twice the memory of the leaf hashes.
| } | ||
| } | ||
|
|
||
| #[test] |
There was a problem hiding this comment.
these aren't really tests, they always pass and don't assert anything; they also pollute the test binary stdout
| }; | ||
|
|
||
| let generator = Secp256k1Affine::generator(builder); | ||
| let public_key = scalar_mul_naive(builder, curve, SCALAR_BITS, &scalar_biguint, generator); |
There was a problem hiding this comment.
Use scalar_mul - it leverages secp256k1 endomorphism and is thus twice more efficient
| let mut index = leaf_index; | ||
|
|
||
| for sibling in &siblings { | ||
| let is_even = builder.bnot(builder.band(index, builder.add_constant_64(1))); |
There was a problem hiding this comment.
nit: instead of threading index value through the circuit, you could've just shifted the bit in question to the MSB
|
|
||
| // Constrain message wires to match multiplexer output | ||
| for i in 0..SCALAR_LIMBS { | ||
| builder.assert_eq(format!("merkle_message_left[{}]", i), message_wires[i], left[i]); |
There was a problem hiding this comment.
why constrain instead of just using left and right wires as-is?
| if global_byte_idx >= scope_len_bytes { | ||
| let byte_val = builder.shr(wire, (byte_offset * BITS_PER_BYTE) as u32); | ||
| let byte_masked = builder.band(byte_val, builder.add_constant_64(0xFF)); | ||
| builder.assert_eq("scope_padding_zero", byte_masked, zero); |
There was a problem hiding this comment.
nit: this could've been a single AND constraint, with one of 0xFF..0xFFFFFF_FFFFFFFFFF masks. See how Keccak / Blake2s / etc. handle this. But given that this is done only once per nullifier that doesn't have much effect on circuit size.
|
|
||
| // Create witness wires for Keccak message | ||
| let total_message_wires = scope_wires + SCALAR_LIMBS; | ||
| let nullifier_message_wires: Vec<Wire> = (0..total_message_wires) |
There was a problem hiding this comment.
nit: this impl assumes that scope is always padded to the nearest multiple of 64 bits in size, whereas reference impl can work with scopes of arbitrary length
| for byte_offset in 0..BYTES_PER_WIRE { | ||
| let global_byte_idx = wire_start_byte + byte_offset; | ||
| if global_byte_idx >= message_len_bytes { | ||
| let byte_val = builder.shr(wire, (byte_offset * BITS_PER_BYTE) as u32); |
There was a problem hiding this comment.
nit: may be useful to reuse this logic between scope & message computations
| ); | ||
|
|
||
| Self { | ||
| message, |
There was a problem hiding this comment.
How exactly is message used? It does not feed anywhere? Is there any attempt to replicate "dummy square" functionality of the original Semaphore?
There was a problem hiding this comment.
Indeed the message is not used anywhere, just passed forward. I will look at the 'dummy square'
There was a problem hiding this comment.
Discussed with Ben and Jim. The way this works in binius64 is to 'observe' the message in the transcript. So in our case, we should not even have the message in the circuit/witness. I will remove it.
There was a problem hiding this comment.
indeed. solution: completely remove it from the circuit, and observe into the transcript
| struct NullifierGeneratorECDSA { | ||
| scope: Vec<Wire>, | ||
| secret_scalar_wires: [Wire; SCALAR_LIMBS], | ||
| #[allow(dead_code)] |
There was a problem hiding this comment.
Is identity_secret_scalar still dead code?
| let scalar_biguint = BigUint { | ||
| limbs: secret_scalar.to_vec(), | ||
| }; | ||
|
|
There was a problem hiding this comment.
Should scalar_biguint be constrained to be non-zero? There is an assert! in compute_public_key_coords in populate_witness.

Add Semaphore ECDSA Circuit Implementation
This PR implements a Semaphore anonymous group membership proof system using ECDSA key derivation on secp256k1.
Circuit Hierarchy
SemaphoreProofECDSA
├─ IN: message (Vec) - Public message being signed
├─ IN: scope (Vec) - Public scope/context for nullifier
├─ IN: merkle_root ([u8; 32]) - Public Merkle tree root
├─ OUT: nullifier ([u8; 32]) - Public nullifier (prevents double-spending)
├─ AUX: secret_scalar ([u8; 32]) - Private ECDSA key (witness)
├─ AUX: merkle_siblings (Vec<[u8; 32]>) - Merkle proof path (witness)
├─ AUX: leaf_index (usize) - Position in tree (witness)
└─ Internal Components:
├─ Secp256k1ScalarMult: secret_scalar → (pubkey_x, pubkey_y)
├─ Keccak256: (pubkey_x || pubkey_y) → commitment
├─ Keccak256: (scope || secret_scalar) → nullifier
└─ MerkleProof: (commitment, siblings, index) → root_verification
Example Usage
The
semaphore_ecdsaexample demonstrates the circuit with configurable parameters:Circuit Parameters
--tree-height <N>(default: 2) - Merkle tree height, determines max group size (2^N members)--message-len-bytes <N>(default: 32) - Maximum message length in bytes--scope-len-bytes <N>(default: 24) - Maximum scope length in bytesInstance Parameters
--group-size <N>(default: 4) - Number of group members to create--prover-index <N>(default: 1) - Index of member generating the proof (0-based)--message <STRING>(default: "I vote YES on proposal [field_buffer] FieldSlice and FieldSliceMut to own single elements #42") - Message to include in proof--scope <STRING>(default: "dao_vote_2024_q1") - Scope for nullifier generationExample Commands
Scaling
Circuit complexity scales with tree height but is independent of message size: