Use native operations and gadgets together
Most applications use each primitive twice: native code prepares values outside the proof, while a gadget enforces the same relationship inside the circuit. Enable the zk feature on the relevant gadget crate and use the same BLS12-381 backend across every dependency.
Each gadget excerpt below is a complete Circuit implementation. Populate its fields with native values, then use the Groth16 or PLONK compilation and proving flow above. The linked repository tests show the full setup, witness construction, proof, and verification flow.
Hash field elements with Poseidon
Choose a domain that matches the protocol purpose. Incremental and one-shot hashing produce the same result for the same domain and input sequence.
use dusk_poseidon::{Domain, Hash};
let digest = Hash::digest(Domain::Other, &input);
let mut hasher = Hash::new(Domain::Other);
hasher.update(&input[..3]);
hasher.update(&input[3..]);
assert_eq!(digest, hasher.finalize());
// Merkle hashing uses a separate domain.
let parent = Hash::digest(Domain::Merkle4, &input[..4]);Use Poseidon inside a circuit
Append the preimage as private witnesses, hash those witnesses with HashGadget, and constrain the result to the public digest. The domain and input length are part of the circuit shape, so keep them stable between compilation and proving.
use dusk_poseidon::{Domain, HashGadget};
use dusk_zk_composer::prelude::*;
#[derive(Debug)]
struct HashCircuit<const N: usize> {
input: [BlsScalar; N],
expected: BlsScalar,
}
impl<const N: usize> Default for HashCircuit<N> {
fn default() -> Self {
Self {
input: [BlsScalar::from(0u64); N],
expected: BlsScalar::from(0u64),
}
}
}
impl<const N: usize> Circuit for HashCircuit<N> {
fn circuit<B: ComposerBackend>(
&self,
composer: &mut Composer<B>,
) -> Result<(), CircuitError> {
let mut input = [Composer::<B>::ZERO; N];
for (value, witness) in self.input.iter().zip(&mut input) {
*witness = composer.append_witness(*value);
}
let digest = HashGadget::digest(composer, Domain::Other, &input);
let expected = composer.append_public(self.expected);
composer.assert_equal(digest[0], expected);
Ok(())
}
}Construct HashCircuit with the preimage and Hash::digest(Domain::Other, &input)[0]. The verifier supplies that expected digest as the circuit's only public input. See the tested Poseidon gadget flow.
Build a sparse Merkle tree
The generic tree delegates parent calculation to Aggregate. This introductory binary tree sums children so the structure is easy to see; it is not a cryptographic commitment. The circuit example that follows switches to the arity-four poseidon-merkle tree.
use dusk_merkle::{Aggregate, Tree};
#[derive(Clone, Copy, Debug, PartialEq)]
struct U8(u8);
impl Aggregate<2> for U8 {
const EMPTY_SUBTREE: Self = Self(0);
fn aggregate(items: [&Self; 2]) -> Self {
Self(items[0].0 + items[1].0)
}
}
let mut tree = Tree::<U8, 3, 2>::new();
tree.insert(4, U8(21));
tree.insert(7, U8(21));
assert_eq!(*tree.root(), U8(42));Prove a Poseidon Merkle opening
The opening and leaf are private circuit data. opening_gadget recomputes the path root, which is then constrained to the public root expected by the verifier. Build the tree and opening natively before constructing this circuit.
use dusk_zk_composer::prelude::*;
use poseidon_merkle::zk::opening_gadget;
use poseidon_merkle::{Item, Opening, Tree};
const HEIGHT: usize = 17;
type PoseidonItem = Item<()>;
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
struct OpeningCircuit {
opening: Opening<(), HEIGHT>,
leaf: PoseidonItem,
}
impl Default for OpeningCircuit {
fn default() -> Self {
let empty = PoseidonItem::new(BlsScalar::from(0u64), ());
let mut tree: Tree<(), HEIGHT> = Tree::new();
tree.insert(0, empty);
let opening = tree.opening(0).expect("leaf exists");
Self { opening, leaf: empty }
}
}
impl Circuit for OpeningCircuit {
fn circuit<B: ComposerBackend>(
&self,
composer: &mut Composer<B>,
) -> Result<(), CircuitError> {
let leaf = composer.append_witness(self.leaf.hash);
let computed_root = opening_gadget(composer, &self.opening, leaf);
let public_root = composer.append_public(self.opening.root().hash);
composer.assert_equal(computed_root, public_root);
Ok(())
}
}Create the real witness with tree.opening(position), pair it with the leaf at that position, and let the verifier supply opening.root().hash as the single public input. See the tested Merkle opening flow.
Sign and verify on JubJub
The signature crate includes standard, double-key, variable-generator, and multisignature flows, plus gadgets for in-circuit verification.
use jubjub_schnorr::{PublicKey, SecretKey};
let secret_key = SecretKey::random(&mut rng);
let public_key = PublicKey::from(&secret_key);
let signature = secret_key.sign(&mut rng, message);
public_key.verify(&signature, message)?;Verify a Schnorr signature inside a circuit
Append the response scalar, nonce point, public key, and message as witnesses, then call verify_signature. The gadget checks point validity, derives the Poseidon challenge, and enforces the Schnorr equation.
use dusk_zk_composer::prelude::*;
use jubjub_schnorr::{gadgets, PublicKey, Signature};
#[derive(Debug, Default)]
struct SignatureCircuit {
signature: Signature,
public_key: PublicKey,
message: BlsScalar,
}
impl Circuit for SignatureCircuit {
fn circuit<B: ComposerBackend>(
&self,
composer: &mut Composer<B>,
) -> Result<(), CircuitError> {
let u = composer.append_witness(*self.signature.u());
let r = composer.append_point(*self.signature.R())?;
let public_key = composer.append_point(*self.public_key.as_ref())?;
let message = composer.append_witness(self.message);
gadgets::verify_signature(composer, u, r, public_key, message)
}
}Construct this circuit from the native signature, public_key, and message values created above. This version proves knowledge of a valid signature without revealing those values. If the verifier must know the message or public key, bind them to public inputs explicitly rather than relying on application convention. See the tested Schnorr gadget flow.
Move proofs across a WebAssembly boundary
plonkwasm accepts deserialized prover and verifier keys, returns proof bytes, and serializes public inputs as concatenated 32-byte scalar encodings.
let output = plonkwasm::prove(&prover, [9; 32], &circuit)?;
plonkwasm::verify(
&verifier,
&output.proof,
&output.public_inputs,
)?;Test the failure cases
A proof-system integration is incomplete if it only tests a valid proof. Reuse each circuit test across schemes and include:
- Valid proofs and expected public-input order.
- Invalid witnesses and modified public inputs.
- Stable shape across different valid witness values.
- Malformed, truncated, wrong-version, and incompatible serialized data.
- Feature and backend combinations supported by the root Makefile.
make test # release-mode cryptographic tests
make clippy # supported lint matrix
make no-std # bare-metal and WASM checks
make doc # crate API documentation
make solidity-test # Rust-to-Solidity verifier testsFor contract generation, proof conversion, ABI details, and the EIP-2537 deployment boundary, see the Solidity verification guide.