Skip to main content

From a Stylus function to a private circuit

This tutorial takes an ordinary Stylus function, marks the parameters you want to hide with #[zk_private], and lets the exporter generate the halo2 circuit, its descriptor, an abi.json schema, and a privacy-transformed contract — in one command. By the end you will understand every file it produces and the honest on-chain model behind the generated contract.

You'll use: zerostyl-export. Time: ~10 minutes.

1. Annotate the source

Write the function you want to make private and annotate the hidden parameters. Here a deposit whose collateral is committed with Poseidon, proven in 0..1_000_000, and proven >= threshold:

contract_source.rs
pub fn deposit(
#[zk_private(
commit = "poseidon",
range = "0..1000000",
constraint = "value >= threshold"
)]
collateral: u64,
threshold: u64,
) -> bool {
let _ = (collateral, threshold);
true
}

The attribute keys map to gadgets: commit = "poseidon" → a Poseidon commitment exposed as a public input; range = "a..b" → a bounded range proof; constraint = "value >= other" → a comparison against another parameter. Multiple attributes on one parameter compose as a logical AND. Every #[zk_private] parameter must carry a commit — it is what ties the private value to a public input the verifier checks.

2. Transform

zerostyl-export transform --contract contract_source.rs --output-dir generated
transformed circuit 'deposit' -> generated
generated/circuit.rs
generated/descriptor.rs
generated/contract_transformed.rs
generated/abi.json

3. What you get

abi.json — the language-neutral schema

{
"abi_version": 1,
"circuit": {
"name": "deposit",
"default_k": 10,
"num_public_inputs": 1,
"num_private_witnesses": 3
},
"witness": {
"fields": [
{ "name": "collateral", "kind": { "type": "u64" }, "visibility": "private" },
{ "name": "collateral_nonce", "kind": { "type": "fp" }, "visibility": "private" },
{ "name": "threshold", "kind": { "type": "u64" }, "visibility": "private" }
]
},
"public_inputs": {
"fields": [ { "name": "collateral_commitment", "kind": { "type": "fp" } } ]
},
"proof": { "format_version": 1, "proving_system": "halo2_kzg" }
}

Note the exporter added collateral_nonce (the Poseidon randomness) as a witness, and exposed collateral_commitment as the single public input. This same abi.json is what the TypeScript and Python SDKs consume — see abi.json → typed bindings.

circuit.rs — the halo2 circuit

A Circuit<Fr> that wires PoseidonCommitmentChip, RangeProofChip, and ComparisonChip. The value is loaded once and the same cell flows into the commitment, the range check, and the comparison (copy_advice ties them together), so the range/comparison statements are provably about the committed value — not a free witness.

descriptor.rs — the CircuitDescriptor

Implements prove / verify / mock_prove / inspect and exposes pub fn descriptor() -> &'static dyn CircuitDescriptor, so the circuit plugs into the CLI, the debugger, and the SDK exactly like the built-ins.

contract_transformed.rs — the privacy-safe ABI

Each #[zk_private] parameter becomes a B256 commitment, plus a trailing proof: Bytes:

pub fn deposit(
host: &mut impl DepositHost,
collateral_commitment: B256,
threshold: u64,
proof: Bytes,
) -> bool { /* guards → nullifier registry → standardized event */ }

The generated module ships a DepositHost trait (nullifier storage, clock, event sink, and a verify_proof hook that fails closed by default), a public_inputs() helper, a per-commitment derive_nullifier, and the ZeroStylPrivacyTransaction event constants — plus a reference Stylus embedding behind a feature flag.

Honest on-chain model

The generated contract does not verify the SNARK on-chain: Arbitrum Stylus caps deployable contracts at 24 KB Brotli-compressed while a halo2 verifier exceeds 90 KB. It implements a hash-guard flow (proof hash + one-shot nullifier + standardized event) with verify_proof as the extension point for real verification. See Events & Fingerprinting and the deployability tutorial.

Next steps