From 823c4227aa67892914fd7762a5344a8d2ae3a363 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 01:04:22 +0800 Subject: [PATCH 01/26] phase 0: add circuit annotation planning baseline --- docs/adr/circuit-annotation/README.md | 1819 +++++++++++++++++ .../circuit-annotation/implementation-plan.md | 58 + .../phase-0-planning-baseline.md | 79 + 3 files changed, 1956 insertions(+) create mode 100644 docs/adr/circuit-annotation/README.md create mode 100644 docs/adr/circuit-annotation/implementation-plan.md create mode 100644 docs/adr/circuit-annotation/phase-0-planning-baseline.md diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md new file mode 100644 index 0000000..b5edbb5 --- /dev/null +++ b/docs/adr/circuit-annotation/README.md @@ -0,0 +1,1819 @@ +# ADR: Annotation-Based Circuit Authoring for Java Developers + +## Status +Accepted for implementation + +## Date +2026-05-18 + +## Vision + +ZeroJ already lets Java developers define zero-knowledge circuits without +leaving the Java ecosystem. Today there are two authoring styles: + +- `CircuitBuilder#define(...)`, a functional DSL over `CircuitAPI` +- `CircuitSpec#define(SignalBuilder)`, an object-oriented DSL over `Signal` + +Both styles are powerful and reusable, but they still require developers to +think in terms of circuit variables, builder declarations, witness maps, and +manual public/private input wiring. The next step is to make circuit authoring +feel more like ordinary Java application code while keeping the generated +circuits explicit, testable, and compatible with the existing ZeroJ compiler +pipeline. + +The target developer experience is: + +```java +@ZKCircuit(name = "range-proof") +public class RangeProof { + @Secret + @UInt(bits = 64) + ZkUInt secret; + + @Public + @UInt(bits = 64) + ZkUInt lo; + + @Public + @UInt(bits = 64) + ZkUInt hi; + + @Prove + ZkBool inRange() { + return secret.gte(lo).and(secret.lte(hi)); + } +} +``` + +The generated companion class should expose the normal ZeroJ circuit API: + +```java +var circuit = RangeProofCircuit.build(); + +var witness = circuit.calculateWitness(RangeProofCircuit.inputs() + .secret(BigInteger.valueOf(42)) + .lo(BigInteger.valueOf(18)) + .hi(BigInteger.valueOf(99)) + .toWitnessMap(), CurveId.BLS12_381); + +var r1cs = circuit.compileR1CS(CurveId.BLS12_381); +var plonk = circuit.compilePlonK(CurveId.BLS12_381); +``` + +Parameterized circuits should keep the current Java-as-template advantage: + +```java +var merkle16 = MerkleMembershipCircuit.build(16, HashType.POSEIDON); +var merkle32 = MerkleMembershipCircuit.build(32, HashType.POSEIDON); +``` + +This feature should not replace `CircuitSpec` or the current DSL. It should +generate code that sits on top of them. + +## Why We Are Doing This + +ZeroJ's core direction is Java-first circuit authoring, pure Java verification, +and production proving paths over Cardano-relevant curves. The current circuit +DSL is already a major improvement over requiring circom, Go, or Rust, but there +is still ceremony around: + +- declaring public and secret variables in `CircuitBuilder` +- binding variable names in the proof body +- remembering bit-width constraints for integer-like values +- constructing `Map>` witness inputs +- keeping field names, witness names, and public input order aligned +- explaining to Java developers why `BigInteger` values in a proof body are not + ordinary runtime values + +Annotation-based authoring can reduce that ceremony while preserving the +important circuit model: + +- all values inside a circuit are symbolic +- all constraints are generated through the existing `SignalBuilder` and + `CircuitAPI` +- all circuits still compile to the existing proof-system-agnostic + `ConstraintGraph` +- all existing circuit libraries remain usable +- advanced users can drop down to `Signal` and `CircuitSpec` when needed + +## Non-Goal: Translating Arbitrary Java + +The most attractive syntax would be ordinary Java: + +```java +@ZKCircuit +public class RangeProof { + @Witness BigInteger secret; + @Public BigInteger lo; + @Public BigInteger hi; + + @Prove + boolean inRange() { + return secret.compareTo(lo) >= 0 + && secret.compareTo(hi) <= 0; + } +} +``` + +This is not a good v1 target. + +`BigInteger.compareTo` computes with concrete runtime values. A circuit proof +body must build symbolic constraints over wires. Supporting the example above +would require Java AST or bytecode translation, a restricted Java subset, and +special handling for method calls, control flow, short-circuit boolean logic, +loops, and object state. That path is possible later, but it is high-risk and +would make circuit behavior harder to reason about. + +The v1 design uses symbolic `Zk*` types instead: + +```java +@Prove +ZkBool inRange() { + return secret.gte(lo).and(secret.lte(hi)); +} +``` + +This keeps the code Java-native while making the symbolic nature of circuit +values explicit. + +## Decision + +Introduce an annotation-based circuit authoring layer built from two modules: + +```text +zeroj-circuit-annotation-api +zeroj-circuit-annotation-processor +``` + +The annotation API module contains annotations, symbolic `Zk*` value types, and +small support abstractions. The annotation processor scans annotated circuits at +compile time and generates companion classes that build normal `CircuitBuilder` +instances. + +The generated code must target the existing circuit stack: + +```text +Annotated user class + | + v +Generated companion class + | + v +CircuitBuilder + CircuitSpec + SignalBuilder + | + v +ConstraintGraph + | + v +R1CS / PlonK / Halo2 compilers and witness calculator +``` + +No new constraint representation is introduced. + +## Module Design + +### `zeroj-circuit-annotation-api` + +Purpose: public compile-time and runtime API used by application code. + +Suggested package: + +```text +com.bloxbean.cardano.zeroj.circuit.annotation +``` + +Dependencies: + +```gradle +dependencies { + api project(':zeroj-circuit-dsl') +} +``` + +This module should not depend on `zeroj-circuit-lib`. Foundational symbolic +types should stay independent of optional gadgets such as Poseidon, Merkle, +Jubjub, or Pedersen. + +Contents: + +- annotations: `@ZKCircuit`, `@Prove`, `@Public`, `@Secret`, + `@CircuitParam`, `@UInt`, `@FieldElement`, `@FixedSize`, `@Order` +- symbolic values: `ZkValue`, `ZkField`, `ZkBool`, `ZkUInt`, `ZkArray` +- deferred symbolic values: `ZkBits`, `ZkBytes` +- support abstractions: `ZkContext`, `ZkTypeAdapter`, `ZkTypeDescriptor`, + `ZkInputMap`, `ZkCircuitSchema`, `ZkSignalRef` + +### `zeroj-circuit-annotation-processor` + +Purpose: compile-time source generator. + +Suggested package: + +```text +com.bloxbean.cardano.zeroj.circuit.annotation.processor +``` + +Dependencies: + +```gradle +dependencies { + implementation project(':zeroj-circuit-annotation-api') +} +``` + +Consumer Gradle usage: + +```gradle +dependencies { + implementation 'com.bloxbean.cardano:zeroj-circuit-annotation-api' + annotationProcessor 'com.bloxbean.cardano:zeroj-circuit-annotation-processor' +} +``` + +The processor should register with Java's standard annotation processing +mechanism through: + +```text +META-INF/services/javax.annotation.processing.Processor +``` + +The processor should generate source code only. It should not compile circuits +or calculate witnesses during annotation processing. + +## Symbolic Type Foundation + +### `ZkValue` + +Base interface implemented by all symbolic values. + +```java +public interface ZkValue { + List signals(); + void assertWellFormed(); +} +``` + +`signals()` returns the flattened signals backing this value. This is used by +generated schema and witness mapping code. + +`assertWellFormed()` adds constraints required by the value type. Examples: + +- `ZkBool` asserts its signal is boolean +- `ZkUInt` asserts its signal fits the configured bit width +- `ZkArray` asserts every element is well-formed + +Public and secret factory methods add required well-formedness constraints +eagerly. `assertWellFormed()` is idempotent and exists as a defensive hook for +generated code and manually wrapped values. + +### `ZkField` + +Represents one raw field element backed by one `Signal`. + +Use cases: + +- hash inputs and outputs +- commitments +- nullifiers +- raw scalar field arithmetic + +Typical methods: + +```java +ZkField add(ZkField other); +ZkField sub(ZkField other); +ZkField mul(ZkField other); +ZkField div(ZkField other); +ZkBool isEqual(ZkField other); +void assertEqual(ZkField other); +Signal signal(); +``` + +### `ZkBool` + +Represents one constrained bit. + +Typical methods: + +```java +ZkBool and(ZkBool other); +ZkBool or(ZkBool other); +ZkBool xor(ZkBool other); +ZkBool not(); + T select(T ifTrue, T ifFalse); +void assertTrue(); +void assertFalse(); +void assertEqual(ZkBool other); +ZkField asField(); +Signal signal(); +``` + +`ZkBool` should call `Signal.assertBoolean()` when constructed from public or +secret input unless the constructor is explicitly internal and trusted. + +### `ZkUInt` + +Represents an unsigned bounded integer backed by one field element and a bit +width. + +Use cases: + +- range proofs +- ages and thresholds +- amounts and balances +- bounded counters +- indexes into fixed arrays + +Typical methods: + +```java +int bits(); +ZkUInt add(ZkUInt other); +ZkUInt sub(ZkUInt other); +ZkUInt mul(ZkUInt other); +ZkBool lt(ZkUInt other); +ZkBool lte(ZkUInt other); +ZkBool gt(ZkUInt other); +ZkBool gte(ZkUInt other); +ZkBool isEqual(ZkUInt other); +ZkBool inRange(ZkUInt lo, ZkUInt hi); +void assertInRange(); +void assertEqual(ZkUInt other); +ZkField asField(); +Signal signal(); +``` + +`ZkUInt` comparisons should use the same algorithm as the existing comparator +gadgets. To avoid a foundational dependency on `zeroj-circuit-lib`, the first +version can use `SignalBuilder.api().lessThan` directly, matching the current +`SignalComparators` implementation. + +### `ZkArray` + +Represents a fixed-size symbolic array. + +Use cases: + +- Merkle siblings +- Merkle path bits +- fixed-size credential fields +- public input vectors +- multi-input commitments + +Example: + +```java +@Secret +@FixedSize(32) +ZkArray siblings; + +@Secret +@FixedSize(32) +ZkArray pathBits; +``` + +V1 arrays must have fixed size known at circuit-generation time. The size may be +a literal, or it may reference a build-time `@CircuitParam`. Dynamic arrays +should be modeled later as `maxSize + length + selectors`. + +### Deferred: `ZkBits` + +Represents a fixed-length bit vector backed by `Signal[]`. + +Use cases: + +- bit decomposition +- bitwise operations +- fixed byte packing +- cryptographic gadgets that operate on bits + +`ZkBits` is useful but not required for the first annotation slice because most +early privacy templates can absorb field elements directly through Poseidon or +MiMC. It should be added after range, array, and hash-gadget workflows are +stable. + +### Deferred: `ZkBytes` + +Represents a fixed-length byte sequence. + +Use cases: + +- credential attributes +- messages +- serialized public keys +- serialized signatures +- off-chain data that must be committed inside a circuit + +`ZkBytes` should require explicit length. Unbounded strings or byte arrays +should not be allowed. The first implementation can prefer one `ZkUInt(8)` per +byte for clarity, with packed representations added later. + +## Annotation Set + +### `@ZKCircuit` + +Marks a class as a circuit source. + +```java +@Target(TYPE) +@Retention(SOURCE) +public @interface ZKCircuit { + String name() default ""; + String nameTemplate() default ""; +} +``` + +If `name` is empty, use the Java class name converted to a stable circuit name. +For parameterized circuits, `nameTemplate` may include build-time parameters, +for example `merkle-{depth}-{hashType}`. If `nameTemplate` is empty and the +circuit has `@CircuitParam` values, generated code must append a canonical +parameter suffix to avoid reusing one circuit identity for different constraint +systems. + +### `@Prove` + +Marks the method that defines the circuit constraints. + +```java +@Target(METHOD) +@Retention(SOURCE) +public @interface Prove {} +``` + +V1 rules: + +- exactly one `@Prove` method per circuit class +- return type must be `void` or `ZkBool` +- if return type is `ZkBool`, generated code asserts it is true +- if return type is `void`, the method is responsible for adding assertions + +The `ZkBool` return form is convenience syntax for single-statement circuits and +small predicates. Complex circuits often mix direct assertions with derived +boolean checks; for those, `void` is equally first-class and may be clearer: + +```java +@Prove +void prove(...) { + amount.assertInRange(); + newBalance.assertInRange(); + senderBalance.sub(amount).assertEqual(newBalance); + amount.assertEqual(publicAmount); +} +``` + +### `@Public` and `@Secret` + +Mark symbolic inputs. + +```java +@Target({FIELD, PARAMETER}) +@Retention(SOURCE) +public @interface Public { + String name() default ""; +} + +@Target({FIELD, PARAMETER}) +@Retention(SOURCE) +public @interface Secret { + String name() default ""; +} +``` + +Use `@Secret`, not `@Witness`, as the first-class term. ZeroJ already has an +API-level `Witness` type for opaque witness bytes, so using `@Witness` for +input visibility would create naming ambiguity. + +### `@CircuitParam` + +Marks a build-time parameter that changes the generated constraint system. + +```java +@Target({FIELD, PARAMETER}) +@Retention(SOURCE) +public @interface CircuitParam { + String value() default ""; +} +``` + +Circuit parameters are not public inputs and are not secret witnesses. They are +ordinary Java values known before the circuit is built. They control circuit +shape: Merkle depth, hash choice, Poseidon arity, credential schema, or feature +flags that select which fixed constraints are generated. + +V1 supported parameter types: + +- primitive integers and boxed integer types +- `boolean` +- `String` +- enums + +Generated companion classes should expose parameterized build methods: + +```java +var circuit = MerkleMembershipCircuit.build(32, HashType.POSEIDON); +``` + +Each unique parameter set represents a distinct circuit and normally requires a +distinct setup/VK lifecycle. + +### `@UInt` + +Defines the unsigned bit width for `ZkUInt`. + +```java +@Target({FIELD, PARAMETER}) +@Retention(SOURCE) +public @interface UInt { + int bits(); +} +``` + +Rules: + +- required for `ZkUInt` +- `bits` must be in the supported range of the underlying circuit API +- v1 should reject `bits <= 0` +- v1 should reject widths greater than the current safe decomposition limit + +### `@FieldElement` + +Optional explicit marker for `ZkField`. + +```java +@Target({FIELD, PARAMETER}) +@Retention(SOURCE) +public @interface FieldElement {} +``` + +This is mostly useful for readability and future validation. + +### `@FixedSize` + +Defines fixed length for arrays and byte values. + +```java +@Target({FIELD, PARAMETER}) +@Retention(SOURCE) +public @interface FixedSize { + int value() default -1; + String param() default ""; +} +``` + +Required for `ZkBytes` and `ZkArray`. Use `value` for a literal size and +`param` to reference a build-time `@CircuitParam`. + +Examples: + +```java +@Secret +@FixedSize(32) +ZkArray fixedSiblings; + +@Secret +@FixedSize(param = "depth") +ZkArray parametricSiblings; +``` + +Exactly one of `value` or `param` must be set. `param` must reference a visible +integer `@CircuitParam`. + +### `@Order` + +Optional explicit field ordering annotation. + +```java +@Target(FIELD) +@Retention(SOURCE) +public @interface Order { + int value(); +} +``` + +V1 naming uses `@Public(name = "...")` and `@Secret(name = "...")`; there is no +separate `@Name` annotation in v1. `@Order` is optional for field style and +exists for callers who want explicit ordering independent of source position. + +Ordering rules: + +- order public and secret groups separately +- reject negative `@Order` values +- reject duplicate `@Order` values within a visibility group +- sort ordered fields by ascending `@Order` +- append unordered fields in source declaration order after ordered fields +- fail if stable source declaration order is unavailable + +## Authoring Styles + +### Field Style + +Field style is closer to typical Java model classes. + +```java +@ZKCircuit(name = "range-proof") +public class RangeProof { + @Secret @UInt(bits = 64) + ZkUInt secret; + + @Public @UInt(bits = 64) + ZkUInt lo; + + @Public @UInt(bits = 64) + ZkUInt hi; + + @Prove + ZkBool inRange() { + return secret.gte(lo).and(secret.lte(hi)); + } +} +``` + +Generated code must instantiate the class, assign symbolic fields, then call the +proof method. + +V1 field-style rules for symbolic input fields: + +- fields cannot be `final` +- fields must be package-private, protected, or public +- private fields are rejected unless setter/constructor binding is added later +- a no-arg constructor must be visible to generated code +- exactly one visibility annotation is required per symbolic field +- field ordering is deterministic: public fields first, then secret fields; each + group sorts ordered fields by ascending non-negative `@Order`, rejects + duplicates within the group, then appends unordered fields in source + declaration order as reported by `javac`; processors that cannot recover + stable source order must fail with a diagnostic and suggest `@Order` or + parameter style + +Advantages: + +- concise for small circuits +- resembles normal Java data models +- easy to add helper methods using instance fields + +Disadvantages: + +- mutation during generated binding is less elegant +- private/final fields complicate generation +- constructor requirements must be documented clearly + +Recommendation: support in the MVP because this is the primary target syntax for +Java developers writing small and medium circuits. Generated code must avoid +reflection by emitting the companion class in the same package. + +### Parameter Style + +Parameter style avoids field injection for symbolic inputs and is the best fit +for tests, small pure functions, and circuits where the proof method should make +all symbolic dependencies explicit. + +```java +@ZKCircuit(name = "range-proof") +public class RangeProof { + @Prove + ZkBool prove( + @Secret @UInt(bits = 64) ZkUInt secret, + @Public @UInt(bits = 64) ZkUInt lo, + @Public @UInt(bits = 64) ZkUInt hi) { + return secret.gte(lo).and(secret.lte(hi)); + } +} +``` + +Generated shape: + +```java +public final class RangeProofCircuit { + public static CircuitBuilder build() { + return CircuitBuilder.create("range-proof") + .publicVar("lo") + .publicVar("hi") + .secretVar("secret") + .defineSignals(c -> { + var instance = new RangeProof(); + var secret = ZkUInt.secret(c, "secret", 64); + var lo = ZkUInt.publicInput(c, "lo", 64); + var hi = ZkUInt.publicInput(c, "hi", 64); + instance.prove(secret, lo, hi).assertTrue(); + }); + } +} +``` + +Advantages: + +- no mutable field injection for symbolic inputs +- proof method is easy to unit test directly +- easier compiler validation + +Disadvantages: + +- repeated annotations on long method signatures can get noisy +- sharing values across helper methods requires passing parameters or storing in + local variables +- instance `@Prove` methods still need a visible no-arg constructor unless the + method is static + +Recommendation: support in the MVP alongside field style. + +### Hybrid Style + +Advanced circuits may need access to the current `SignalBuilder`. + +```java +@Prove +ZkBool prove(ZkContext zk, + @Secret @UInt(bits = 64) ZkUInt amount, + @Public ZkField commitment) { + var hash = zk.poseidon().hash(amount.asField(), zk.constant(0)); + return hash.isEqual(commitment); +} +``` + +The `ZkContext` wraps `SignalBuilder` and exposes: + +- constants +- raw `SignalBuilder` access for advanced interop +- factory methods for symbolic values +- optional gadget namespaces + +V1 should include a minimal `ZkContext` because the first usable slice includes +hash and Merkle adapters. Simple range/comparison circuits can still avoid it. + +## Build-Time Parametric Circuits + +Real circuits are often templates: the same source code produces different +constraint systems for different depths, arities, hash functions, credential +schemas, or feature flags. The annotation layer must preserve the existing Java +template capability shown by `NWayMerkleCircuit(int depth, HashType hashType)`. + +Use `@CircuitParam` for values that shape the circuit at build time: + +```java +@ZKCircuit( + name = "merkle-membership", + nameTemplate = "merkle-membership-d{depth}-{hashType}") +public class MerkleMembership { + private final int depth; + private final HashType hashType; + + public MerkleMembership(@CircuitParam("depth") int depth, + @CircuitParam("hashType") HashType hashType) { + if (depth < 1) throw new IllegalArgumentException("depth must be >= 1"); + this.depth = depth; + this.hashType = hashType; + } + + @Prove + ZkBool prove( + ZkContext zk, + @Secret ZkField leaf, + @Public ZkField root, + @Secret @FixedSize(param = "depth") ZkArray siblings, + @Secret @FixedSize(param = "depth") ZkArray pathBits) { + + var computed = ZkMerkle.computeRoot(zk, leaf, siblings, pathBits, hashType); + return computed.isEqual(root); + } +} +``` + +Generated shape: + +```java +public final class MerkleMembershipCircuit { + public static CircuitBuilder build(int depth, HashType hashType) { + var instance = new MerkleMembership(depth, hashType); + return CircuitBuilder.create(circuitName(depth, hashType)) + .publicVar("root") + .secretVar("leaf") + // generated loops declare sibling_0..sibling_{depth-1} + // and pathBit_0..pathBit_{depth-1} + .defineSignals(c -> { + var zk = new ZkContext(c); + var leaf = ZkField.secret(c, "leaf"); + var root = ZkField.publicInput(c, "root"); + var siblings = ZkArray.secret(c, "sibling", depth, ZkField::secret); + var pathBits = ZkArray.secret(c, "pathBit", depth, ZkBool::secret); + // bind symbolic arrays using the concrete depth + instance.prove(zk, leaf, root, siblings, pathBits).assertTrue(); + }); + } +} +``` + +Rules: + +- a `@CircuitParam` is never a public input and never a secret witness +- changing a circuit parameter changes the circuit identity and VK lifecycle +- generated `schema()` must include parameter names and values +- generated input builders for parameterized circuits are produced from a + concrete parameter set, e.g. `MerkleMembershipCircuit.inputs(32, POSEIDON)` +- `@FixedSize(param = "...")` can reference integer circuit parameters +- parameter validation remains ordinary Java constructor validation + +## Existing Circuit Library Interop + +The annotation layer must make existing gadget libraries easy to use. + +Every symbolic type should expose its underlying signal: + +```java +Signal signal(); +``` + +For multi-signal values: + +```java +Signal[] signalsArray(); +List signals(); +``` + +This allows users and generated adapters to call existing functions when no +symbolic adapter exists yet: + +```java +var hash = SignalMiMC.hash(zk.builder(), + secret.signal(), + nullifier.signal()); + +return ZkField.wrap(zk, hash).isEqual(commitment); +``` + +This fallback is useful for escape hatches, but it should not be the main v1 +developer experience. The MVP needs symbolic adapters for the gadgets used by +the first real privacy templates: + +```java +ZkField hash = ZkPoseidon.hash(zk, left, right); +ZkMerkle.verify(zk, leaf, root, siblings, pathBits, ZkPoseidon::hash); +``` + +Proposed package for these adapters: + +```text +com.bloxbean.cardano.zeroj.circuit.lib.zk +``` + +MVP adapters: + +- `ZkMiMC` +- `ZkPoseidon` +- `ZkMerkle` for fixed-depth membership proofs + +These adapters live in `zeroj-circuit-lib`, not in +`zeroj-circuit-annotation-api`, so the dependency direction remains: + +```text +zeroj-circuit-dsl + ^ + | +zeroj-circuit-annotation-api + ^ + | +zeroj-circuit-lib symbolic adapters +``` + +### Dual API Maintenance Policy + +The symbolic layer creates a second public surface over the same constraint +logic. This is useful, but it is a real maintenance commitment. + +Policy: + +- `Zk*` gadget adapters must be thin wrappers around existing `Signal*` or + `Variable` gadgets whenever possible +- public reusable gadgets that are expected to be used from annotated circuits + should ship both a `Signal*` API and a `Zk*` adapter +- each `Zk*` adapter must have differential tests against the existing DSL +- if a gadget intentionally remains `Signal`-only, document it as an advanced + escape hatch +- new privacy-template work should budget time for both surfaces + +## Generated Artifacts + +For a source class: + +```java +com.example.RangeProof +``` + +Generate: + +```text +com.example.RangeProofCircuit +``` + +The final generated class should include: + +- `build()` +- parameterized `build(...)` when the source uses `@CircuitParam` +- `schema()` +- parameterized `schema(...)` when the source uses `@CircuitParam` +- `inputs()` +- parameterized `inputs(...)` when the source uses `@CircuitParam` +- `publicInputs(...)` helper if useful +- constants for generated input names + +Phase 4 generates only `build(...)` and constants. Phase 5 adds `schema(...)`, +`inputs(...)`, and `publicInputs(...)`. Examples at the top of this ADR describe +the final target surface after Phase 5. + +Example generated public API: + +```java +public final class RangeProofCircuit { + public static final String CIRCUIT_NAME = "range-proof"; + public static final String SECRET = "secret"; + public static final String LO = "lo"; + public static final String HI = "hi"; + + public static CircuitBuilder build(); + + public static ZkCircuitSchema schema(); + + public static Inputs inputs(); + + public static final class Inputs { + public Inputs secret(BigInteger value); + public Inputs lo(BigInteger value); + public Inputs hi(BigInteger value); + public Map> toWitnessMap(); + public List publicValues(); + } +} +``` + +Generated input builders are important because the current witness calculator +takes `Map>`. Hand-written maps are easy to mistype and +do not preserve the schema in a developer-friendly way. + +For parameterized circuits, generated helpers take the same build-time +parameters and produce schema/input builders for that concrete circuit shape: + +```java +var circuit = MerkleMembershipCircuit.build(32, HashType.POSEIDON); +var schema = MerkleMembershipCircuit.schema(32, HashType.POSEIDON); +var inputs = MerkleMembershipCircuit.inputs(32, HashType.POSEIDON); +``` + +## Input Naming and Ordering + +Stable public input order is critical because public inputs are part of proof +verification. + +Rules: + +- public inputs are declared before secret inputs in `CircuitBuilder`, matching + current wire numbering +- within each visibility group, preserve source order +- for method parameter style, source order is method parameter order +- for field style, use explicit `@Order` where provided; otherwise use source + declaration order from `javac`; ordered fields sort before unordered fields + within the same visibility group; duplicate or negative `@Order` values fail +- generated `schema()` must expose the exact order +- circuit parameters are recorded in schema metadata but are not public or + secret inputs +- generated/flattened input names must be unique within the circuit; duplicate + names fail at compile time + +Nested names should flatten with `.` or `_`. The v1 choice should be `_` to +match existing circuit variable naming patterns: + +```text +credential_age +credential_countryCode +path_0 +path_1 +``` + +## Type Adapter Design + +`ZkTypeAdapter` bridges Java host values, symbolic circuit values, and +flattened input maps. + +Conceptual interface: + +```java +public interface ZkTypeAdapter { + T bindPublic(ZkContext zk, String name, ZkTypeDescriptor descriptor); + T bindSecret(ZkContext zk, String name, ZkTypeDescriptor descriptor); + List signalNames(String name, ZkTypeDescriptor descriptor); + List encodeHostValue(Object value, ZkTypeDescriptor descriptor); +} +``` + +Built-in adapters: + +- `ZkFieldAdapter` +- `ZkBoolAdapter` +- `ZkUIntAdapter` +- `ZkArrayAdapter` + +Deferred built-in adapters: + +- `ZkBitsAdapter` +- `ZkBytesAdapter` + +Later adapters: + +- `ZkJubjubPointAdapter` +- `ZkEdDSASignatureAdapter` +- `ZkMerklePathAdapter` +- `ZkCredentialAdapter` + +Adapters let complex circuits stay readable without hardcoding every domain +type into the annotation processor. + +## Circuit Examples + +### Range Proof + +```java +@ZKCircuit(name = "range-proof") +public class RangeProof { + @Prove + ZkBool prove( + @Secret @UInt(bits = 64) ZkUInt secret, + @Public @UInt(bits = 64) ZkUInt lo, + @Public @UInt(bits = 64) ZkUInt hi) { + return secret.gte(lo).and(secret.lte(hi)); + } +} +``` + +### Age Verification + +```java +@ZKCircuit(name = "age-check") +public class AgeCheck { + @Prove + ZkBool prove( + @Secret @UInt(bits = 8) ZkUInt age, + @Public @UInt(bits = 8) ZkUInt threshold) { + return age.gte(threshold); + } +} +``` + +### Private Transfer + +```java +@ZKCircuit(name = "private-transfer") +public class PrivateTransfer { + @Prove + ZkBool prove( + @Secret @UInt(bits = 64) ZkUInt senderBalance, + @Secret @UInt(bits = 64) ZkUInt amount, + @Secret @UInt(bits = 64) ZkUInt newBalance, + @Public @UInt(bits = 64) ZkUInt publicAmount) { + + amount.assertInRange(); + newBalance.assertInRange(); + + var balanceMatches = senderBalance.sub(amount).isEqual(newBalance); + var amountMatches = amount.isEqual(publicAmount); + + return balanceMatches.and(amountMatches); + } +} +``` + +### Hash Commitment With Existing Gadget + +```java +@ZKCircuit(name = "vote-commitment") +public class VoteCommitment { + @Prove + ZkBool prove( + ZkContext zk, + @Secret ZkBool vote, + @Secret ZkField nullifier, + @Public ZkField commitment) { + + return ZkMiMC.hash(zk, vote.asField(), nullifier).isEqual(commitment); + } +} +``` + +### Merkle Proof + +```java +@ZKCircuit( + name = "membership-proof", + nameTemplate = "membership-proof-d{depth}-{hashType}") +public class MembershipProof { + private final int depth; + private final HashType hashType; + + public MembershipProof(@CircuitParam("depth") int depth, + @CircuitParam("hashType") HashType hashType) { + this.depth = depth; + this.hashType = hashType; + } + + @Prove + ZkBool prove( + ZkContext zk, + @Secret ZkField leaf, + @Public ZkField root, + @Secret @FixedSize(param = "depth") ZkArray siblings, + @Secret @FixedSize(param = "depth") ZkArray pathBits) { + + var computed = ZkMerkle.computeRoot( + zk, + leaf, + siblings, + pathBits, + hashType); + + return computed.isEqual(root); + } +} +``` + +This example depends on the MVP circuit-lib symbolic adapters. + +## Unit Testing Strategy + +Annotation-based circuits must be as easy to test as existing `CircuitSpec` +circuits. Testing should happen at nine levels. + +### 1. Symbolic Type Unit Tests + +Test the `Zk*` types directly by building tiny circuits. + +Example: + +```java +@Test +void uintGreaterOrEqualAcceptsValidWitness() { + var circuit = CircuitBuilder.create("gte") + .publicVar("ok") + .secretVar("age") + .publicVar("threshold") + .defineSignals(c -> { + var age = ZkUInt.secret(c, "age", 8); + var threshold = ZkUInt.publicInput(c, "threshold", 8); + var ok = ZkBool.publicInput(c, "ok"); + + age.gte(threshold).assertEqual(ok); + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "age", List.of(BigInteger.valueOf(25)), + "threshold", List.of(BigInteger.valueOf(18)), + "ok", List.of(BigInteger.ONE)), CurveId.BN254)); +} +``` + +These tests verify that symbolic wrappers generate the same constraints as the +current `Signal` API. + +### 2. Annotation Processor Compile Tests + +Use compile-testing style tests to compile small annotated source files and +assert generated source. + +Goals: + +- valid circuits compile +- invalid circuits fail with clear messages +- generated `build()` compiles +- generated input names are stable +- generated public/secret ordering is deterministic + +Recommended test cases: + +- one valid parameter-style range proof +- one valid field-style range proof +- one valid parameterized Merkle proof +- missing `@Prove` method fails +- two `@Prove` methods fail +- `ZkUInt` without `@UInt` fails +- `@UInt(bits = 0)` fails +- field with both `@Public` and `@Secret` fails +- unsupported `BigInteger` proof parameter fails with a message pointing to + `ZkField` or `ZkUInt` +- `@Prove boolean` fails with a message explaining symbolic `ZkBool` +- private field-style binding fails in v1 +- non-fixed `ZkArray` fails +- `@FixedSize(param = "depth")` fails if `depth` is not a known integer + `@CircuitParam` +- Phase 4 parameterized circuits generate `build(...)` methods with matching + parameter lists; Phase 5 adds `schema(...)` and `inputs(...)` + +The processor should prefer precise compiler diagnostics over runtime +exceptions. + +### 3. Generated Circuit Behavior Tests + +Use generated companion classes from test fixtures. + +Example: + +```java +@Test +void generatedRangeProofAcceptsValidWitness() { + var circuit = RangeProofCircuit.build(); + + var inputs = RangeProofCircuit.inputs() + .secret(BigInteger.valueOf(42)) + .lo(BigInteger.valueOf(18)) + .hi(BigInteger.valueOf(99)) + .toWitnessMap(); + + assertDoesNotThrow(() -> + circuit.calculateWitness(inputs, CurveId.BLS12_381)); +} + +@Test +void generatedRangeProofRejectsInvalidWitness() { + var circuit = RangeProofCircuit.build(); + + var inputs = RangeProofCircuit.inputs() + .secret(BigInteger.valueOf(12)) + .lo(BigInteger.valueOf(18)) + .hi(BigInteger.valueOf(99)) + .toWitnessMap(); + + assertThrows(ArithmeticException.class, () -> + circuit.calculateWitness(inputs, CurveId.BLS12_381)); +} +``` + +These tests confirm end-to-end behavior through the existing witness calculator. + +### 4. Backend Compilation Tests + +Every representative generated circuit should compile to each backend currently +supported by `CircuitBuilder`: + +```java +assertNotNull(circuit.compileR1CS(CurveId.BN254)); +assertNotNull(circuit.compilePlonK(CurveId.BN254)); +assertNotNull(circuit.compileHalo2(CurveId.BN254)); +``` + +For field-specific gadgets such as Poseidon over BLS12-381, tests should also +verify `requireField` behavior. + +### 5. Golden Schema Tests + +Generated schema should be stable and testable. + +Example assertions: + +```java +var schema = RangeProofCircuit.schema(); + +assertEquals("range-proof", schema.name()); +assertEquals(List.of("lo", "hi"), schema.publicInputs().names()); +assertEquals(List.of("secret"), schema.secretInputs().names()); +assertEquals(64, schema.input("secret").bits()); +``` + +This prevents accidental public input reordering. + +### 6. Negative Witness Tests + +Each circuit example should include invalid witness tests: + +- range proof below lower bound +- range proof above upper bound +- boolean input set to `2` +- `ZkUInt` value exceeding its bit width +- wrong commitment +- wrong Merkle sibling +- wrong public root + +Negative tests are essential because a circuit that accepts valid witnesses but +also accepts invalid witnesses is broken. + +### 7. Differential Tests Against Existing DSL + +For simple circuits, build the same circuit with both styles: + +- hand-written `CircuitSpec` +- generated annotation companion + +Then compare: + +- witness behavior +- public input order +- approximate constraint count +- output values + +The exact gate list may differ if wrappers add explicit well-formedness +constraints earlier, but behavior must match. + +### 8. Parametric Circuit Tests + +For circuits with `@CircuitParam`, test more than one concrete shape: + +- `MerkleMembershipCircuit.build(16, POSEIDON)` +- `MerkleMembershipCircuit.build(32, POSEIDON)` +- `MerkleMembershipCircuit.build(32, MIMC)` + +Assertions: + +- generated circuit names differ by parameter set +- schema records the concrete parameters +- array input builders generate the expected number of elements +- witness calculation succeeds for a valid proof path +- changing depth or hash type without changing inputs fails clearly + +### 9. Symbolic Gadget Adapter Tests + +For every `Zk*` adapter in `zeroj-circuit-lib`, test against the corresponding +existing gadget: + +- `ZkPoseidon` versus `SignalPoseidon` +- `ZkMiMC` versus `SignalMiMC` +- `ZkMerkle` versus `SignalMerkle` + +The adapter should add no new cryptographic logic unless it is unavoidable. + +## Validation and Error Messages + +Processor errors should be written for Java developers who may not know circuit +internals. + +Examples: + +```text +@Prove method RangeProof.inRange returns boolean. +Circuit proof methods must return ZkBool or void because circuit values are +symbolic. Use ZkBool and methods such as gte(...).and(...). +``` + +```text +Field secret has type ZkUInt but no @UInt(bits = ...). +Unsigned integer circuit values need an explicit bit width so ZeroJ can add +range constraints. +``` + +```text +Field message has type ZkBytes but no @FixedSize. +Circuit byte arrays must have a fixed length at compile time. +``` + +```text +Field x cannot be both @Public and @Secret. +Choose exactly one visibility annotation. +``` + +## Security and Soundness Requirements + +The annotation layer must not silently weaken circuits. + +Rules: + +- `ZkBool` inputs must always be boolean-constrained +- `ZkUInt` inputs must always be range-constrained +- `ZkBytes` inputs must constrain each byte to 8 bits +- `ZkArray` length must be fixed at circuit generation time +- `@CircuitParam` values must be included in circuit identity/schema metadata +- data-dependent branching must use symbolic `select`, not Java `if` +- `@Prove` returning `ZkBool` must assert true in generated code +- public input order must be deterministic and exposed in schema +- unsupported Java types must fail at compile time + +## Control Flow Rules + +Normal Java loops are allowed when they are circuit-generation loops with fixed +bounds. + +Good: + +```java +for (int i = 0; i < siblings.size(); i++) { + current = hash(current, siblings.get(i)); +} +``` + +Bad: + +```java +if (secret.gte(lo)) { + return doOneThing(); +} else { + return doAnotherThing(); +} +``` + +`secret.gte(lo)` returns `ZkBool`, not Java `boolean`. Developers must use: + +```java +condition.select(ifTrue, ifFalse); +``` + +The symbolic API should make invalid Java branching impossible by type. + +## Documentation Requirements + +The public documentation should explain: + +- circuit values are symbolic +- `ZkBool` is not Java `boolean` +- `ZkUInt` is not `BigInteger` +- when to use annotations versus `CircuitSpec` +- how public input order is derived +- how build-time `@CircuitParam` values affect circuit identity and setup +- how to test circuits +- how to use existing `zeroj-circuit-lib` gadgets +- how to inspect generated source + +Minimum docs: + +- `zeroj-circuit-annotation-api/README.md` +- examples under `zeroj-examples` +- one migration guide from `CircuitSpec` to annotations +- one advanced interop example using `SignalMiMC` or `SignalPoseidon` + +## Phased Implementation Plan + +The timeline below assumes one primary contributor and starts after this ADR is +accepted. The estimates are working-time estimates, not calendar commitments. + +### Phase 0: ADR and API Review + +Estimated time: 1 to 2 days. + +Deliverables: + +- review this ADR +- agree on module names +- agree on annotation names: `@Secret` versus `@PrivateInput` +- agree that field style and parameter style both ship in the MVP +- agree that `@CircuitParam` is required before the MVP processor is complete +- agree that `ZkMiMC`, `ZkPoseidon`, and `ZkMerkle` adapters move into the first + usable slice +- agree on the dual-API maintenance policy for future gadgets + +Exit criteria: + +- maintainers agree that the annotation layer generates `CircuitSpec` / + `CircuitBuilder` code and does not introduce a new constraint engine + +### Phase 1: Module Scaffolding + +Estimated time: 0.5 to 1 day. + +Deliverables: + +- add `zeroj-circuit-annotation-api` +- add `zeroj-circuit-annotation-processor` +- add modules to `settings.gradle` +- add BOM entries to `zeroj-bom-core` and `zeroj-bom-all` +- add basic README placeholders +- add processor service registration + +Exit criteria: + +- `./gradlew :zeroj-circuit-annotation-api:test` +- `./gradlew :zeroj-circuit-annotation-processor:test` +- all modules compile with no generated circuits yet + +### Phase 2: Foundational Symbolic Types and Parameters + +Estimated time: 5 to 7 days. + +Deliverables: + +- `ZkValue` +- `ZkField` +- `ZkBool` +- `ZkUInt` +- `ZkArray` +- minimal `ZkContext` +- `@CircuitParam` +- `@FixedSize` with literal and parameter-reference modes +- direct factory methods for public and secret inputs +- unit tests comparing behavior to hand-written `Signal` circuits + +Initial scope: + +```java +ZkUInt.secret(c, "secret", 64); +ZkUInt.publicInput(c, "lo", 64); +ZkBool.assertTrue(); +ZkUInt.gte(...); +ZkArray.secret(c, "sibling", depth, ZkField::secret); +``` + +Exit criteria: + +- a hand-written symbolic range circuit works without annotations +- a hand-written symbolic fixed-depth Merkle input shape works without + annotations +- valid witnesses pass +- invalid witnesses fail +- backend compilation works for R1CS, PlonK, and Halo2 where supported + +### Phase 3: MVP Symbolic Gadget Adapters + +Estimated time: 3 to 5 days. + +Deliverables in `zeroj-circuit-lib`: + +- `ZkMiMC` +- `ZkPoseidon` +- `ZkMerkle` for fixed-depth membership proofs +- differential tests against `SignalMiMC`, `SignalPoseidon`, and `SignalMerkle` + +Exit criteria: + +- hash commitment can be written without `.signal()` / `wrap(...)` boilerplate +- Merkle membership can be written with `ZkArray` +- adapters reuse existing `Signal` or `Variable` gadgets + +### Phase 4: MVP Annotation Processor + +Estimated time: 6 to 9 days. + +Deliverables: + +- process `@ZKCircuit` +- process field-style `@Prove` +- process parameter-style `@Prove` +- process `@CircuitParam` +- process `@FixedSize(param = "...")` +- support `@Public`, `@Secret`, `@UInt`, `@FieldElement` +- generate `*Circuit.build(...)` +- generate constants for input names +- emit useful compile-time errors +- add compile tests + +MVP supported circuits: + +- field-style range proof +- parameter-style range proof +- parameterized Merkle membership with depth and hash type + +Exit criteria: + +- generated `RangeProofCircuit.build()` compiles +- generated `MerkleMembershipCircuit.build(32, POSEIDON)` compiles +- generated circuits calculate witnesses successfully for valid inputs +- generated circuits reject invalid inputs +- processor rejects unsupported `boolean` and `BigInteger` proof methods +- processor rejects invalid `@FixedSize(param = "...")` references + +### Phase 5: Generated Input Builders and Schema + +Estimated time: 3 to 5 days. + +Deliverables: + +- `ZkCircuitSchema` +- generated `schema(...)` +- generated `inputs(...)` builder +- generated `publicInputs(...)` helper +- parameter metadata in schema +- schema tests for stable ordering + +Exit criteria: + +- users no longer need to hand-write `Map>` for simple + generated circuits +- parameterized input builders produce concrete array element methods or indexed + setters for the selected depth +- public and secret input order is exposed and tested + +### Phase 6: Examples and Documentation + +Estimated time: 2 to 4 days. + +Deliverables: + +- `zeroj-examples` field-style range proof +- parameter-style age verification +- private transfer example +- hash commitment example +- parameterized Merkle proof example +- README with authoring rules and testing examples + +Exit criteria: + +- a new Java developer can copy an annotated circuit example and run tests +- docs explain when to use annotations and when to use `CircuitSpec` +- docs explain how `@CircuitParam` affects circuit identity and VK lifecycle + +### Phase 7: Deferred Bits and Bytes + +Estimated time: 3 to 5 days. + +Deliverables: + +- `ZkBits` +- `ZkBytes` +- byte and bit input encoding +- unit tests and generated circuit tests + +Exit criteria: + +- fixed-size byte messages can be represented +- invalid byte values are rejected +- byte/bit APIs do not complicate the v1 range/hash/Merkle surface + +### Phase 8: Advanced Circuit Library Symbolic Adapters + +Estimated time: 4 to 8 days. + +Deliverables in `zeroj-circuit-lib`: + +- `ZkPedersen` +- Jubjub point/signature adapters where needed +- credential-template adapters +- additional hash arity adapters as real templates require them + +Exit criteria: + +- advanced adapters follow the dual-API maintenance policy +- privacy-template examples use symbolic adapters instead of raw `Signal` + plumbing + +### Phase 9: Integration With Proving Flows + +Estimated time: 3 to 6 days. + +Deliverables: + +- generated helpers for public input extraction +- examples feeding generated circuits into existing prover paths +- optional helper to produce `PublicInputs` +- optional helper to export witness bytes if needed by a prover path +- generated circuit ID/version metadata suitable for proof envelopes + +Exit criteria: + +- annotated circuit can go from source code to compile, witness, prove, verify + in an example + +## Suggested Release Slices + +### Slice 1: Developer Preview + +Includes: + +- modules +- `ZkField`, `ZkBool`, `ZkUInt`, `ZkArray` +- `@CircuitParam` +- field-style and parameter-style annotation processing +- generated `build(...)` +- `ZkMiMC`, `ZkPoseidon`, and basic `ZkMerkle` +- range proof, age check, hash commitment, and parameterized Merkle examples + +This is enough to validate ergonomics against realistic privacy templates, not +only trivial range proofs. + +### Slice 2: Usable MVP + +Includes: + +- generated schema +- generated input builders +- parameter metadata in schema +- better diagnostics +- negative tests +- private transfer example + +This is enough for early users to write simple and medium circuits without +hand-written witness maps. + +### Slice 3: Complex Circuit Support + +Includes: + +- `ZkBits`, `ZkBytes` +- fixed-size nested data +- advanced symbolic circuit-lib adapters +- credential-oriented examples + +This is enough for richer credential templates and byte-oriented integrations. + +### Slice 4: Production Hardening + +Includes: + +- stronger compile-testing coverage +- generated source stability tests +- native-image review +- documentation polish +- end-to-end prover examples + +## Phase 0 Decisions + +| Topic | Decision | +|-------|----------| +| Public visibility annotation | Use `@Public`. | +| Secret visibility annotation | Use `@Secret`. | +| Input renaming | Use `@Public(name = "...")` and `@Secret(name = "...")`; no separate `@Name` in v1. | +| Generated class suffix | Use `Circuit`, e.g. `RangeProofCircuit`. | +| Field style | Ship in MVP; reject private/final symbolic input fields in v1. | +| Parameter style | Ship in MVP alongside field style. | +| Field ordering | Public fields first, then secret fields; within each group sort non-negative unique `@Order` values first, then unordered fields by stable javac source order; fail if stable order is unavailable. | +| Circuit parameters | Support constructor parameters and final fields initialized from those parameters. | +| Parameterized names | Use `nameTemplate` when provided; otherwise append a canonical parameter suffix. | +| Processor output | Generate source code only. | +| First generated API slice | Phase 4 emits `build(...)` and constants; Phase 5 adds `schema(...)`, `inputs(...)`, and `publicInputs(...)`. | +| First usable gadget adapters | `ZkMiMC`, `ZkPoseidon`, and basic `ZkMerkle`. | +| BOM scope | Include annotation modules in `zeroj-bom-core` and `zeroj-bom-all` during Phase 1. | +| UInt arithmetic width | Be conservative; arithmetic methods document range behavior and callers assert output range where overflow matters. | + +## Remaining Open Questions + +None for the MVP plan. Future phases may reopen private field binding, metadata +resource generation, richer ordering policies, or byte packing strategies if a +real use case requires them. + +## Risks and Mitigations + +### Risk: Developers think `ZkBool` is Java `boolean` + +Mitigation: + +- make examples consistently use `ZkBool` +- reject `@Prove boolean` with clear diagnostics +- document symbolic control flow + +### Risk: Public input ordering changes accidentally + +Mitigation: + +- generated schema exposes ordering +- golden schema tests +- source-order preservation rules +- explicit `@Order` for field style when source order needs to be pinned + +### Risk: Annotation processor becomes too smart + +Mitigation: + +- generate straightforward Java code +- do not analyze method bodies +- require explicit symbolic types +- keep AST/bytecode translation out of v1 + +### Risk: Foundational API depends on too many gadgets + +Mitigation: + +- keep `zeroj-circuit-annotation-api` dependent only on `zeroj-circuit-dsl` +- put Poseidon, Merkle, Jubjub, and Pedersen symbolic adapters in + `zeroj-circuit-lib` + +### Risk: Symbolic adapters drift from existing gadgets + +Mitigation: + +- make `Zk*` adapters thin wrappers over `Signal*` or `Variable` gadgets +- require differential tests for every adapter +- treat `Signal*` as the implementation surface and `Zk*` as the typed + authoring surface unless a gadget has a documented exception + +### Risk: Parameterized circuits reuse the wrong VK + +Mitigation: + +- include `@CircuitParam` names and values in generated schema metadata +- include parameter values in generated circuit names or name templates +- document that each parameter set is a distinct circuit/setup/VK lifecycle +- test that `build(16, POSEIDON)` and `build(32, POSEIDON)` produce different + circuit identities + +### Risk: Range constraints are forgotten + +Mitigation: + +- `ZkUInt` construction must add range constraints automatically +- generated code must call `assertWellFormed()` for every input +- unit tests must include out-of-range negative cases + +### Risk: Generated code is hard to debug + +Mitigation: + +- generate readable Java source +- use stable variable names +- include comments in generated code sparingly +- expose `schema()` +- include generated source in compile-test assertions + +## Acceptance Criteria + +The feature should be considered successful when: + +- a range proof can be written with fewer than 20 lines of user circuit code +- generated code builds a normal `CircuitBuilder` +- parameterized circuits can generate distinct concrete circuit builders from + one Java source class +- valid and invalid witnesses are easy to test with JUnit +- public input ordering is visible and stable +- common circuit libraries can be called through `Zk*` adapters from annotated + circuits +- unsupported ordinary Java proof styles fail at compile time with clear + messages +- at least one end-to-end example compiles, calculates witness, proves, and + verifies through an existing ZeroJ proving path diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md new file mode 100644 index 0000000..09008a0 --- /dev/null +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -0,0 +1,58 @@ +# Circuit Annotation Implementation Plan + +## Status + +In progress. + +## Execution Rules + +- Implement phases in order. +- Keep one git commit per phase. +- Each phase commit includes its phase implementation doc, code, tests, and any + ADR updates needed to reflect the implemented reality. +- Before each phase commit, run the phase tests, run `git diff --check`, and + complete three independent reviews: + - API/design review + - correctness/security review + - tests/docs/ergonomics review +- Do not include unrelated dirty or untracked files in phase commits. +- Use path-limited staging and commits for every phase, for example: + `git add docs/adr/circuit-annotation && git commit -- docs/adr/circuit-annotation`. + +## Phase Status + +| Phase | Name | Status | Commit | +|-------|------|--------|--------| +| 0 | Planning baseline | In progress | Pending | +| 1 | Module scaffolding | Pending | Pending | +| 2 | Symbolic foundation | Pending | Pending | +| 3 | MVP gadget adapters | Pending | Pending | +| 4 | MVP annotation processor | Pending | Pending | +| 5 | Schema and input builders | Pending | Pending | +| 6 | Examples and documentation | Pending | Pending | +| 7 | Deferred bits and bytes | Pending | Pending | +| 8 | Advanced gadget adapters | Pending | Pending | +| 9 | Proving flow integration | Pending | Pending | + +## Defaults + +- New annotation modules are included in `zeroj-bom-core` and `zeroj-bom-all` + during Phase 1. +- `@Secret` and `@Public` are the v1 input visibility names. +- Field style and parameter style both ship in the MVP processor. +- `@CircuitParam` is required before the processor MVP is complete. +- `ZkMiMC`, `ZkPoseidon`, and basic `ZkMerkle` ship in the first usable slice. +- `ZkBits` and `ZkBytes` are deferred until after schema/input builders and + examples. + +## Review Checklist + +For each phase: + +- API changes match the ADR. +- Generated or public APIs are documented. +- Public/private input ordering is deterministic where touched. +- Circuit constraints are not weakened. +- Tests include at least one negative witness or negative compile scenario when + behavior can fail. +- Review findings are resolved or documented as non-blocking. diff --git a/docs/adr/circuit-annotation/phase-0-planning-baseline.md b/docs/adr/circuit-annotation/phase-0-planning-baseline.md new file mode 100644 index 0000000..4427b32 --- /dev/null +++ b/docs/adr/circuit-annotation/phase-0-planning-baseline.md @@ -0,0 +1,79 @@ +# Phase 0: Planning Baseline + +## Status + +Approved. + +## Goal + +Establish the accepted circuit-annotation ADR and the execution tracker used by +all subsequent phases. + +## Implemented Changes + +- Marked the circuit-annotation ADR as accepted for implementation. +- Added this phase record. +- Added `implementation-plan.md` with phase order, review gate, commit policy, + defaults, and review checklist. +- Closed Phase 0 design decisions in the main ADR: + - `@Public` / `@Secret` are the v1 visibility annotations. + - field style and parameter style both ship in the MVP. + - `@CircuitParam` is required for parameterized circuit templates. + - `ZkMiMC`, `ZkPoseidon`, and basic `ZkMerkle` ship in the first usable + slice. + - generated Phase 4 API is `build(...)` plus constants; Phase 5 adds schema + and input builders. + - field-style ordering is deterministic, with non-negative unique `@Order` + values sorted before unordered fields in stable `javac` source order. + - annotation modules are included in both BOMs starting in Phase 1. + - generated/flattened input names must be unique within a circuit. + +## Public API Changes + +None. This phase is documentation only. + +## Test Commands + +```text +rg -n "[[:blank:]]$" docs/adr/circuit-annotation +git add docs/adr/circuit-annotation +git diff --cached --check -- docs/adr/circuit-annotation +``` + +## Review Results + +Initial three-agent review found blocking planning ambiguities: + +- field-style public/secret ordering needed a deterministic v1 rule +- generated `schema()` / `inputs()` methods were described before their Phase 5 + implementation boundary +- input naming had two unresolved mechanisms +- Phase 0 decisions were still listed as open questions +- BOM inclusion timing differed between the tracker and ADR +- the phase commit must be path-limited because unrelated dirty/untracked files + exist in the worktree + +All ADR/process ambiguities above have been resolved. Re-review pending. + +Second re-review found two remaining blockers: + +- `@Order` behavior for mixed, duplicate, negative, and scoped values was not + fully specified. +- `git diff --check` did not validate untracked docs before staging. + +Both have been resolved by pinning the `@Order` policy and documenting a +staged diff check for new files. + +Final three-agent re-review approved Phase 0: + +- API/design review approved the final `@Order`, naming, phase-boundary, and + BOM decisions. +- Execution-process review approved staged/cached diff checks and path-limited + commit guidance. +- Docs/ergonomics review approved the decision-complete guidance for future + implementers. + +## Commit + +Pending at time of this record; commit will include only +`docs/adr/circuit-annotation`. From 86f122c1653d99cdb12d4c8a759e0d73456484e7 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 01:10:04 +0800 Subject: [PATCH 02/26] phase 1: scaffold circuit annotation modules --- .../circuit-annotation/implementation-plan.md | 4 +- .../phase-1-module-scaffolding.md | 63 +++++++++++++++++++ settings.gradle | 2 + zeroj-bom-all/build.gradle | 2 + zeroj-bom-core/build.gradle | 2 + zeroj-circuit-annotation-api/README.md | 20 ++++++ zeroj-circuit-annotation-api/build.gradle | 20 ++++++ .../circuit/annotation/package-info.java | 8 +++ zeroj-circuit-annotation-processor/README.md | 27 ++++++++ .../build.gradle | 20 ++++++ .../processor/CircuitAnnotationProcessor.java | 32 ++++++++++ .../annotation/processor/package-info.java | 4 ++ .../javax.annotation.processing.Processor | 1 + .../CircuitAnnotationProcessorTest.java | 20 ++++++ 14 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 docs/adr/circuit-annotation/phase-1-module-scaffolding.md create mode 100644 zeroj-circuit-annotation-api/README.md create mode 100644 zeroj-circuit-annotation-api/build.gradle create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java create mode 100644 zeroj-circuit-annotation-processor/README.md create mode 100644 zeroj-circuit-annotation-processor/build.gradle create mode 100644 zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java create mode 100644 zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/package-info.java create mode 100644 zeroj-circuit-annotation-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor create mode 100644 zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index 09008a0..4a86f61 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -23,8 +23,8 @@ In progress. | Phase | Name | Status | Commit | |-------|------|--------|--------| -| 0 | Planning baseline | In progress | Pending | -| 1 | Module scaffolding | Pending | Pending | +| 0 | Planning baseline | Completed | 823c422 | +| 1 | Module scaffolding | In progress | Pending | | 2 | Symbolic foundation | Pending | Pending | | 3 | MVP gadget adapters | Pending | Pending | | 4 | MVP annotation processor | Pending | Pending | diff --git a/docs/adr/circuit-annotation/phase-1-module-scaffolding.md b/docs/adr/circuit-annotation/phase-1-module-scaffolding.md new file mode 100644 index 0000000..03983df --- /dev/null +++ b/docs/adr/circuit-annotation/phase-1-module-scaffolding.md @@ -0,0 +1,63 @@ +# Phase 1: Module Scaffolding + +## Status + +Approved. + +## Goal + +Add compile-safe annotation API and annotation processor modules without +introducing functional circuit processing yet. + +## Implemented Changes + +- Added `zeroj-circuit-annotation-api`. +- Added `zeroj-circuit-annotation-processor`. +- Registered both modules in `settings.gradle`. +- Added both modules to `zeroj-bom-core` and `zeroj-bom-all`. +- Added README placeholders. +- Added package documentation placeholders. +- Added a service-registered no-op `CircuitAnnotationProcessor` placeholder. +- Added a processor service registration smoke test. + +## Public API Changes + +- New module artifact: `zeroj-circuit-annotation-api`. +- New module artifact: `zeroj-circuit-annotation-processor`. +- No public annotations or symbolic value classes are introduced in this phase. + +## Verification + +```text +./gradlew :zeroj-circuit-annotation-api:test NO-SOURCE / PASS +./gradlew :zeroj-circuit-annotation-processor:test PASS +./gradlew :zeroj-bom-core:build :zeroj-bom-all:build PASS +rg -n "[[:blank:]]$" docs/adr/circuit-annotation zeroj-circuit-annotation-api zeroj-circuit-annotation-processor settings.gradle zeroj-bom-core/build.gradle zeroj-bom-all/build.gradle +git diff --cached --check PASS +``` + +Gradle emitted existing restricted-native-access and deprecation warnings, but +all requested tasks completed successfully. + +The trailing-whitespace scan produced no matches. + +## Staging + +```text +git add settings.gradle zeroj-bom-core/build.gradle zeroj-bom-all/build.gradle zeroj-circuit-annotation-api zeroj-circuit-annotation-processor docs/adr/circuit-annotation +``` + +## Review Results + +Three-agent review approved Phase 1: + +- API/design review approved module naming, package names, dependencies, + placeholder processor behavior, and BOM placement. +- Build/process review approved Gradle wiring, BOM constraints, service + registration, test adequacy for scaffolding, and staged-file scope. +- Docs/ergonomics review approved with non-blocking clarity suggestions, which + were applied before commit. + +## Commit + +Pending. diff --git a/settings.gradle b/settings.gradle index 5c3eb57..854469b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,6 +15,8 @@ include 'zeroj-patterns' include 'zeroj-crypto' include 'zeroj-circuit-dsl' include 'zeroj-circuit-lib' +include 'zeroj-circuit-annotation-api' +include 'zeroj-circuit-annotation-processor' include 'zeroj-prover-spi' include 'zeroj-prover-gnark' include 'zeroj-onchain-julc' diff --git a/zeroj-bom-all/build.gradle b/zeroj-bom-all/build.gradle index 63827aa..77e7cd2 100644 --- a/zeroj-bom-all/build.gradle +++ b/zeroj-bom-all/build.gradle @@ -26,6 +26,8 @@ dependencies { api project(':zeroj-crypto') api project(':zeroj-circuit-dsl') api project(':zeroj-circuit-lib') + api project(':zeroj-circuit-annotation-api') + api project(':zeroj-circuit-annotation-processor') api project(':zeroj-prover-spi') api project(':zeroj-prover-gnark') api project(':zeroj-onchain-julc') diff --git a/zeroj-bom-core/build.gradle b/zeroj-bom-core/build.gradle index ea196c7..657de73 100644 --- a/zeroj-bom-core/build.gradle +++ b/zeroj-bom-core/build.gradle @@ -23,6 +23,8 @@ dependencies { api project(':zeroj-crypto') api project(':zeroj-circuit-dsl') api project(':zeroj-circuit-lib') + api project(':zeroj-circuit-annotation-api') + api project(':zeroj-circuit-annotation-processor') api project(':zeroj-prover-spi') api project(':zeroj-prover-gnark') api project(':zeroj-onchain-julc') diff --git a/zeroj-circuit-annotation-api/README.md b/zeroj-circuit-annotation-api/README.md new file mode 100644 index 0000000..0160337 --- /dev/null +++ b/zeroj-circuit-annotation-api/README.md @@ -0,0 +1,20 @@ +# zeroj-circuit-annotation-api + +Public API for annotation-based ZeroJ circuit authoring. + +Current Phase 1 status: this module is a compile-safe placeholder. It does not +yet expose annotations or symbolic `Zk*` types. + +This module will contain: + +- circuit annotations such as `@ZKCircuit`, `@Prove`, `@Public`, `@Secret`, + `@CircuitParam`, `@UInt`, `@FieldElement`, `@FixedSize`, and `@Order` +- symbolic circuit value types such as `ZkField`, `ZkBool`, `ZkUInt`, and + `ZkArray` +- schema and binding support used by generated circuit companions + +The API is intentionally layered on top of `zeroj-circuit-dsl`. It does not +replace `CircuitSpec`, `SignalBuilder`, or the existing circuit library. + +See [docs/adr/circuit-annotation/README.md](../docs/adr/circuit-annotation/README.md) +for the accepted implementation plan. diff --git a/zeroj-circuit-annotation-api/build.gradle b/zeroj-circuit-annotation-api/build.gradle new file mode 100644 index 0000000..1a27f5a --- /dev/null +++ b/zeroj-circuit-annotation-api/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java-library' +} + +description = 'ZeroJ Circuit Annotation API — annotations and symbolic types for Java circuit authoring' + +dependencies { + api project(':zeroj-circuit-dsl') +} + +publishing { + publications { + mavenJava(MavenPublication) { + pom { + name = 'ZeroJ Circuit Annotation API' + description = 'Annotations and symbolic type API for generating ZeroJ circuits from Java classes' + } + } + } +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java new file mode 100644 index 0000000..14927ab --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java @@ -0,0 +1,8 @@ +/** + * Annotation-based circuit authoring API for ZeroJ. + * + *

This package will expose the public annotations and symbolic value types + * used by Java classes that are compiled into {@code CircuitBuilder} / + * {@code CircuitSpec} companions by the annotation processor.

+ */ +package com.bloxbean.cardano.zeroj.circuit.annotation; diff --git a/zeroj-circuit-annotation-processor/README.md b/zeroj-circuit-annotation-processor/README.md new file mode 100644 index 0000000..38900c6 --- /dev/null +++ b/zeroj-circuit-annotation-processor/README.md @@ -0,0 +1,27 @@ +# zeroj-circuit-annotation-processor + +Compile-time annotation processor for annotation-based ZeroJ circuit authoring. + +Current Phase 1 status: this module contains a registered no-op processor +placeholder. It does not yet scan annotations or generate source. + +The processor will scan classes annotated with `@ZKCircuit` and generate +companion classes that build normal `CircuitBuilder` / `CircuitSpec` circuits. + +Current phase status: + +- the module is scaffolded +- the processor is registered with Java's service provider mechanism +- functional processing is intentionally deferred to Phase 4 of the ADR plan + +Consumer usage after the API stabilizes: + +```gradle +dependencies { + implementation 'com.bloxbean.cardano:zeroj-circuit-annotation-api' + annotationProcessor 'com.bloxbean.cardano:zeroj-circuit-annotation-processor' +} +``` + +See [docs/adr/circuit-annotation/README.md](../docs/adr/circuit-annotation/README.md) +for the accepted implementation plan. diff --git a/zeroj-circuit-annotation-processor/build.gradle b/zeroj-circuit-annotation-processor/build.gradle new file mode 100644 index 0000000..e72ef3b --- /dev/null +++ b/zeroj-circuit-annotation-processor/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java-library' +} + +description = 'ZeroJ Circuit Annotation Processor — generates CircuitBuilder companions from annotated Java circuits' + +dependencies { + implementation project(':zeroj-circuit-annotation-api') +} + +publishing { + publications { + mavenJava(MavenPublication) { + pom { + name = 'ZeroJ Circuit Annotation Processor' + description = 'Compile-time processor that generates ZeroJ CircuitBuilder companions from annotated Java circuit classes' + } + } + } +} diff --git a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java new file mode 100644 index 0000000..43d0394 --- /dev/null +++ b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java @@ -0,0 +1,32 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation.processor; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.TypeElement; +import java.util.Set; + +/** + * Service-registered annotation processor placeholder for circuit annotation + * code generation. + * + *

Functional processing is introduced in Phase 4. Until then, this processor + * deliberately claims no annotations and performs no source generation.

+ */ +public final class CircuitAnnotationProcessor extends AbstractProcessor { + + @Override + public Set getSupportedAnnotationTypes() { + return Set.of(); + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latest(); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + return false; + } +} diff --git a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/package-info.java b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/package-info.java new file mode 100644 index 0000000..4377463 --- /dev/null +++ b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/package-info.java @@ -0,0 +1,4 @@ +/** + * Compile-time source generator for ZeroJ annotation-based circuit classes. + */ +package com.bloxbean.cardano.zeroj.circuit.annotation.processor; diff --git a/zeroj-circuit-annotation-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/zeroj-circuit-annotation-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 0000000..bda55e0 --- /dev/null +++ b/zeroj-circuit-annotation-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +com.bloxbean.cardano.zeroj.circuit.annotation.processor.CircuitAnnotationProcessor diff --git a/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java new file mode 100644 index 0000000..841108e --- /dev/null +++ b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java @@ -0,0 +1,20 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation.processor; + +import org.junit.jupiter.api.Test; + +import javax.annotation.processing.Processor; +import java.util.ServiceLoader; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CircuitAnnotationProcessorTest { + + @Test + void processorIsServiceRegistered() { + var processors = ServiceLoader.load(Processor.class); + assertTrue(processors.stream() + .map(ServiceLoader.Provider::type) + .anyMatch(CircuitAnnotationProcessor.class::equals), + "CircuitAnnotationProcessor should be discoverable via ServiceLoader"); + } +} From bfe0b651d0da3f716d8b8113d1e96ef8f3dba166 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 01:25:09 +0800 Subject: [PATCH 03/26] phase 2: add symbolic circuit foundation --- docs/adr/circuit-annotation/README.md | 49 ++- .../circuit-annotation/implementation-plan.md | 4 +- .../phase-1-module-scaffolding.md | 2 +- .../phase-2-symbolic-foundation.md | 86 +++++ zeroj-circuit-annotation-api/README.md | 41 ++- .../circuit/annotation/CircuitParam.java | 15 + .../circuit/annotation/FieldElement.java | 14 + .../zeroj/circuit/annotation/FixedSize.java | 16 + .../zeroj/circuit/annotation/Order.java | 15 + .../zeroj/circuit/annotation/Prove.java | 14 + .../zeroj/circuit/annotation/Public.java | 15 + .../zeroj/circuit/annotation/Secret.java | 15 + .../zeroj/circuit/annotation/UInt.java | 15 + .../zeroj/circuit/annotation/ZKCircuit.java | 16 + .../zeroj/circuit/annotation/ZkArray.java | 101 ++++++ .../zeroj/circuit/annotation/ZkBool.java | 139 ++++++++ .../zeroj/circuit/annotation/ZkContext.java | 42 +++ .../zeroj/circuit/annotation/ZkField.java | 87 +++++ .../zeroj/circuit/annotation/ZkUInt.java | 169 ++++++++++ .../zeroj/circuit/annotation/ZkValue.java | 22 ++ .../circuit/annotation/package-info.java | 4 +- .../annotation/ZkSymbolicTypesTest.java | 298 ++++++++++++++++++ .../cardano/zeroj/circuit/CircuitAPI.java | 22 ++ .../cardano/zeroj/circuit/CircuitAPIImpl.java | 41 +++ .../cardano/zeroj/circuit/Signal.java | 5 + .../cardano/zeroj/circuit/SignalBuilder.java | 6 +- 26 files changed, 1234 insertions(+), 19 deletions(-) create mode 100644 docs/adr/circuit-annotation/phase-2-symbolic-foundation.md create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/CircuitParam.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FieldElement.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FixedSize.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Order.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Prove.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Public.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Secret.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/UInt.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZKCircuit.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkArray.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBool.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkContext.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkField.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkUInt.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkValue.java create mode 100644 zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index b5edbb5..096cc66 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -266,6 +266,17 @@ Public and secret factory methods add required well-formedness constraints eagerly. `assertWellFormed()` is idempotent and exists as a defensive hook for generated code and manually wrapped values. +Factory methods must also preserve input visibility. `ZkField.publicInput`, +`ZkBool.publicInput`, and `ZkUInt.publicInput` must bind only variables declared +as public inputs; `secret(...)` must bind only variables declared as secret +inputs. This is enforced through visibility-aware `SignalBuilder` lookup so +annotation-generated code cannot accidentally hide public inputs or expose +secret witnesses by using the wrong factory. + +`wrap(...)` methods are for existing `Signal` interop and gadget adapters. They +must reject signals created from a different `SignalBuilder`; otherwise a +symbolic wrapper could associate constraints with the wrong circuit graph. + ### `ZkField` Represents one raw field element backed by one `Signal`. @@ -300,7 +311,9 @@ ZkBool and(ZkBool other); ZkBool or(ZkBool other); ZkBool xor(ZkBool other); ZkBool not(); - T select(T ifTrue, T ifFalse); +ZkField select(ZkField ifTrue, ZkField ifFalse); +ZkBool select(ZkBool ifTrue, ZkBool ifFalse); +ZkUInt select(ZkUInt ifTrue, ZkUInt ifFalse); void assertTrue(); void assertFalse(); void assertEqual(ZkBool other); @@ -309,7 +322,10 @@ Signal signal(); ``` `ZkBool` should call `Signal.assertBoolean()` when constructed from public or -secret input unless the constructor is explicitly internal and trusted. +secret input unless the constructor is explicitly internal and trusted. Phase 2 +supports `select` for built-in single-signal symbolic values; custom +multi-signal value selection can be added with the corresponding gadget +adapters. ### `ZkUInt` @@ -348,6 +364,17 @@ gadgets. To avoid a foundational dependency on `zeroj-circuit-lib`, the first version can use `SignalBuilder.api().lessThan` directly, matching the current `SignalComparators` implementation. +The maximum range width is 253 bits, matching the existing safe decomposition +limit. Comparison is stricter and requires operands below 253 bits because +`lessThan` decomposes an `nBits + 1` intermediate. + +Arithmetic methods preserve the unsigned type invariant. Addition and +multiplication widen the output bit width when the result still fits within the +safe field bound; over-wide results fail early and should be expressed as +`ZkField` arithmetic or split into smaller limbs. Subtraction returns a bounded +result and therefore constrains the output range, which rejects underflow by +default. + ### `ZkArray` Represents a fixed-size symbolic array. @@ -376,6 +403,18 @@ V1 arrays must have fixed size known at circuit-generation time. The size may be a literal, or it may reference a build-time `@CircuitParam`. Dynamic arrays should be modeled later as `maxSize + length + selectors`. +Built-in visibility-specific helpers should be used for common element types: + +```java +ZkArray siblings = ZkArray.secretFields(c, "sibling", depth); +ZkArray pathBits = ZkArray.secretBools(c, "pathBit", depth); +ZkArray amounts = ZkArray.publicUInts(c, "amount", count, 64); +``` + +`ZkArray.bind(...)` is reserved for custom symbolic element types. Its +visibility comes from the supplied factory, so generated code should prefer the +visibility-specific helpers whenever the element type is built in. + ### Deferred: `ZkBits` Represents a fixed-length bit vector backed by `Signal[]`. @@ -793,8 +832,8 @@ public final class MerkleMembershipCircuit { var zk = new ZkContext(c); var leaf = ZkField.secret(c, "leaf"); var root = ZkField.publicInput(c, "root"); - var siblings = ZkArray.secret(c, "sibling", depth, ZkField::secret); - var pathBits = ZkArray.secret(c, "pathBit", depth, ZkBool::secret); + var siblings = ZkArray.secretFields(c, "sibling", depth); + var pathBits = ZkArray.secretBools(c, "pathBit", depth); // bind symbolic arrays using the concrete depth instance.prove(zk, leaf, root, siblings, pathBits).assertTrue(); }); @@ -1499,7 +1538,7 @@ ZkUInt.secret(c, "secret", 64); ZkUInt.publicInput(c, "lo", 64); ZkBool.assertTrue(); ZkUInt.gte(...); -ZkArray.secret(c, "sibling", depth, ZkField::secret); +ZkArray.secretFields(c, "sibling", depth); ``` Exit criteria: diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index 4a86f61..89d4b9e 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -24,8 +24,8 @@ In progress. | Phase | Name | Status | Commit | |-------|------|--------|--------| | 0 | Planning baseline | Completed | 823c422 | -| 1 | Module scaffolding | In progress | Pending | -| 2 | Symbolic foundation | Pending | Pending | +| 1 | Module scaffolding | Completed | 86f122c | +| 2 | Symbolic foundation | Completed | Pending | | 3 | MVP gadget adapters | Pending | Pending | | 4 | MVP annotation processor | Pending | Pending | | 5 | Schema and input builders | Pending | Pending | diff --git a/docs/adr/circuit-annotation/phase-1-module-scaffolding.md b/docs/adr/circuit-annotation/phase-1-module-scaffolding.md index 03983df..89f3af4 100644 --- a/docs/adr/circuit-annotation/phase-1-module-scaffolding.md +++ b/docs/adr/circuit-annotation/phase-1-module-scaffolding.md @@ -60,4 +60,4 @@ Three-agent review approved Phase 1: ## Commit -Pending. +86f122c diff --git a/docs/adr/circuit-annotation/phase-2-symbolic-foundation.md b/docs/adr/circuit-annotation/phase-2-symbolic-foundation.md new file mode 100644 index 0000000..962da18 --- /dev/null +++ b/docs/adr/circuit-annotation/phase-2-symbolic-foundation.md @@ -0,0 +1,86 @@ +# Phase 2: Symbolic Foundation + +## Status + +Approved. + +## Goal + +Introduce the foundational annotation API and symbolic `Zk*` value wrappers +used by later processor and gadget phases. + +## Implemented Changes + +- Added v1 annotations: + - `@ZKCircuit` + - `@Prove` + - `@Public` + - `@Secret` + - `@CircuitParam` + - `@UInt` + - `@FieldElement` + - `@FixedSize` + - `@Order` +- Added symbolic types: + - `ZkValue` + - `ZkContext` + - `ZkField` + - `ZkBool` + - `ZkUInt` + - `ZkArray` +- Added visibility-aware `SignalBuilder` input lookup in `zeroj-circuit-dsl` + so `publicInput(...)` and `privateInput(...)` reject mismatched declarations. +- Added focused unit tests for field arithmetic, boolean constraints, unsigned + range constraints, unsigned arithmetic bit-width behavior, subtraction + underflow, comparisons, arrays, backend compilation, and differential range + behavior against a hand-written `Signal` circuit. + +## Public API Changes + +- `ZkBool` values are constrained bits and are not Java booleans. +- `ZkUInt` values eagerly assert their configured bit width for public and + secret inputs. +- `ZkUInt.add` and `ZkUInt.mul` widen their output bit widths when safe. +- `ZkUInt.sub` constrains the result range and rejects unsigned underflow. +- `ZkArray` flattens element signals and provides visibility-specific helpers + for built-in element types plus neutral `bind(...)` for custom symbolic types. +- `wrap(...)` rejects signals from another `SignalBuilder`. +- Annotation retention is `SOURCE`; processor behavior remains deferred to + Phase 4. + +## Exit Criteria Mapping + +| ADR exit criterion | Phase 2 result | +|--------------------|----------------| +| Hand-written symbolic range circuit works without annotations | Covered by `symbolicRangeCircuitMatchesSignalCircuitBehavior`. | +| Hand-written symbolic fixed-depth Merkle input shape works without annotations | Covered by `merkleShapedInputsSupportFieldSiblingsAndBooleanPathBits`. | +| Valid witnesses pass | Covered across field, bool, uint, range, array, and Merkle-shaped tests. | +| Invalid witnesses fail | Covered for wrong arithmetic output, non-boolean inputs, false comparisons, out-of-range uints, subtraction underflow, wrong array aggregate, and non-boolean Merkle path bits. | +| Backend compilation works for R1CS, PlonK, and Halo2 | Covered by `symbolicCircuitCompilesToAllBackends`. | +| Behavior compares to hand-written `Signal` circuits | Covered by the differential symbolic-vs-`Signal` range test. | + +## Verification + +- `./gradlew :zeroj-circuit-annotation-api:test` passed. +- `./gradlew :zeroj-circuit-dsl:test` passed. +- `./gradlew :zeroj-circuit-annotation-processor:test` passed. +- `rg -n "[[:blank:]]$" docs/adr/circuit-annotation zeroj-circuit-annotation-api zeroj-circuit-annotation-processor` passed. +- `git diff --cached --check` passed. + +## Review Results + +Three-agent review approved Phase 2 after blocker fixes: + +- API/design review initially blocked on `ZkArray` visibility ambiguity. The + final review approved the visibility-specific built-in helpers and neutral + custom `bind(...)` API. +- ZK-safety review initially blocked on visibility mismatch and cross-builder + wrapping hazards. The final review approved the visibility-aware + `SignalBuilder` lookup and `ZkContext.requireSignal(...)` guard. +- Tests/docs review initially blocked on missing ADR exit-criterion coverage. + The final review approved the differential range test, Merkle-shaped input + test, expanded negative cases, and exit-criteria mapping. + +## Commit + +Pending. diff --git a/zeroj-circuit-annotation-api/README.md b/zeroj-circuit-annotation-api/README.md index 0160337..917f39c 100644 --- a/zeroj-circuit-annotation-api/README.md +++ b/zeroj-circuit-annotation-api/README.md @@ -2,19 +2,48 @@ Public API for annotation-based ZeroJ circuit authoring. -Current Phase 1 status: this module is a compile-safe placeholder. It does not -yet expose annotations or symbolic `Zk*` types. +Current Phase 2 status: this module exposes the foundational annotations and +symbolic `Zk*` types used by manual symbolic circuits and later generated +companions. -This module will contain: +This module contains: - circuit annotations such as `@ZKCircuit`, `@Prove`, `@Public`, `@Secret`, `@CircuitParam`, `@UInt`, `@FieldElement`, `@FixedSize`, and `@Order` -- symbolic circuit value types such as `ZkField`, `ZkBool`, `ZkUInt`, and - `ZkArray` -- schema and binding support used by generated circuit companions +- symbolic circuit value types: `ZkField`, `ZkBool`, `ZkUInt`, and `ZkArray` + +Schema and input binding support is planned for later phases. The API is intentionally layered on top of `zeroj-circuit-dsl`. It does not replace `CircuitSpec`, `SignalBuilder`, or the existing circuit library. +Manual symbolic circuits can be written directly against `SignalBuilder`: + +```java +var circuit = CircuitBuilder.create("range") + .publicVar("threshold") + .secretVar("age") + .defineSignals(c -> { + var age = ZkUInt.secret(c, "age", 8); + var threshold = ZkUInt.publicInput(c, "threshold", 8); + age.gte(threshold).assertTrue(); + }); +``` + +Important Phase 2 API rules: + +- `ZkBool.publicInput` / `secret` add boolean constraints eagerly. +- `ZkUInt.publicInput` / `secret` add range constraints eagerly. +- `ZkUInt` supports widths `1..253`; comparison requires widths below `253`. +- `ZkUInt.add` and `mul` widen output widths when safe; `sub` rejects + unsigned underflow by constraining the result range. +- `ZkArray.secretFields`, `secretBools`, `secretUInts`, `publicFields`, + `publicBools`, and `publicUInts` encode visibility for built-in element + types. `ZkArray.bind` is for custom symbolic types. +- `wrap(...)` rejects signals from a different `SignalBuilder`. + +Annotation processing and generated companion classes are deferred to later +phases; this module only provides the public API foundation. + See [docs/adr/circuit-annotation/README.md](../docs/adr/circuit-annotation/README.md) for the accepted implementation plan. diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/CircuitParam.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/CircuitParam.java new file mode 100644 index 0000000..35f7083 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/CircuitParam.java @@ -0,0 +1,15 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a build-time value that changes circuit shape. + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.SOURCE) +public @interface CircuitParam { + String value() default ""; +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FieldElement.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FieldElement.java new file mode 100644 index 0000000..9a72187 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FieldElement.java @@ -0,0 +1,14 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Explicit marker for a raw field-element symbolic value. + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.SOURCE) +public @interface FieldElement { +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FixedSize.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FixedSize.java new file mode 100644 index 0000000..5c91351 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FixedSize.java @@ -0,0 +1,16 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares a fixed size for symbolic arrays and byte-like values. + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.SOURCE) +public @interface FixedSize { + int value() default -1; + String param() default ""; +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Order.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Order.java new file mode 100644 index 0000000..90c2b4f --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Order.java @@ -0,0 +1,15 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Explicit field ordering override for field-style annotated circuits. + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.SOURCE) +public @interface Order { + int value(); +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Prove.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Prove.java new file mode 100644 index 0000000..9a6e63e --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Prove.java @@ -0,0 +1,14 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks the method that defines circuit constraints. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.SOURCE) +public @interface Prove { +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Public.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Public.java new file mode 100644 index 0000000..4c52b17 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Public.java @@ -0,0 +1,15 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a symbolic value as a public circuit input. + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.SOURCE) +public @interface Public { + String name() default ""; +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Secret.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Secret.java new file mode 100644 index 0000000..5f672d1 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/Secret.java @@ -0,0 +1,15 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a symbolic value as a secret circuit input. + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.SOURCE) +public @interface Secret { + String name() default ""; +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/UInt.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/UInt.java new file mode 100644 index 0000000..c9205e3 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/UInt.java @@ -0,0 +1,15 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares the bit width for an unsigned symbolic integer. + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.SOURCE) +public @interface UInt { + int bits(); +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZKCircuit.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZKCircuit.java new file mode 100644 index 0000000..b35e0e5 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZKCircuit.java @@ -0,0 +1,16 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a Java class as a ZeroJ circuit source for annotation processing. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.SOURCE) +public @interface ZKCircuit { + String name() default ""; + String nameTemplate() default ""; +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkArray.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkArray.java new file mode 100644 index 0000000..25b38f4 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkArray.java @@ -0,0 +1,101 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Fixed-size symbolic array. + */ +public final class ZkArray implements ZkValue { + private final List values; + + public ZkArray(List values) { + Objects.requireNonNull(values, "values"); + this.values = List.copyOf(values); + } + + public static ZkArray publicFields(SignalBuilder builder, String baseName, int size) { + return bindElements(builder, baseName, size, ZkField::publicInput); + } + + public static ZkArray secretFields(SignalBuilder builder, String baseName, int size) { + return bindElements(builder, baseName, size, ZkField::secret); + } + + public static ZkArray publicBools(SignalBuilder builder, String baseName, int size) { + return bindElements(builder, baseName, size, ZkBool::publicInput); + } + + public static ZkArray secretBools(SignalBuilder builder, String baseName, int size) { + return bindElements(builder, baseName, size, ZkBool::secret); + } + + public static ZkArray publicUInts(SignalBuilder builder, String baseName, int size, int bits) { + return bindElements(builder, baseName, size, (c, name) -> ZkUInt.publicInput(c, name, bits)); + } + + public static ZkArray secretUInts(SignalBuilder builder, String baseName, int size, int bits) { + return bindElements(builder, baseName, size, (c, name) -> ZkUInt.secret(c, name, bits)); + } + + /** + * Bind a custom fixed-size array. Visibility comes from the supplied + * factory; use the visibility-specific helpers for built-in symbolic types. + */ + public static ZkArray bind( + SignalBuilder builder, String baseName, int size, ElementFactory factory) { + return bindElements(builder, baseName, size, factory); + } + + private static ZkArray bindElements( + SignalBuilder builder, String baseName, int size, ElementFactory factory) { + Objects.requireNonNull(builder, "builder"); + Objects.requireNonNull(baseName, "baseName"); + Objects.requireNonNull(factory, "factory"); + if (size < 0) { + throw new IllegalArgumentException("size must be >= 0, got " + size); + } + var values = new ArrayList(size); + for (int i = 0; i < size; i++) { + values.add(factory.create(builder, baseName + "_" + i)); + } + return new ZkArray<>(values); + } + + public int size() { + return values.size(); + } + + public T get(int index) { + return values.get(index); + } + + public List values() { + return values; + } + + @Override + public List signals() { + var signals = new ArrayList(); + for (T value : values) { + signals.addAll(value.signals()); + } + return List.copyOf(signals); + } + + @Override + public void assertWellFormed() { + for (T value : values) { + value.assertWellFormed(); + } + } + + @FunctionalInterface + public interface ElementFactory { + T create(SignalBuilder builder, String name); + } +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBool.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBool.java new file mode 100644 index 0000000..ed36cfb --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBool.java @@ -0,0 +1,139 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; + +import java.util.List; +import java.util.Objects; + +/** + * Symbolic boolean backed by one constrained bit signal. + */ +public final class ZkBool implements ZkValue { + final ZkContext context; + final Signal signal; + private boolean wellFormed; + + private ZkBool(ZkContext context, Signal signal, boolean wellFormed) { + this.context = Objects.requireNonNull(context, "context"); + this.signal = Objects.requireNonNull(signal, "signal"); + context.requireSignal(signal); + this.wellFormed = wellFormed; + } + + public static ZkBool publicInput(SignalBuilder builder, String name) { + return constrained(new ZkContext(builder), builder.publicInput(name)); + } + + public static ZkBool secret(SignalBuilder builder, String name) { + return constrained(new ZkContext(builder), builder.privateInput(name)); + } + + public static ZkBool wrap(ZkContext context, Signal signal) { + return constrained(context, signal); + } + + public static ZkBool wrap(SignalBuilder builder, Signal signal) { + return constrained(new ZkContext(builder), signal); + } + + static ZkBool trusted(ZkContext context, Signal signal) { + return new ZkBool(context, signal, true); + } + + private static ZkBool constrained(ZkContext context, Signal signal) { + var value = new ZkBool(context, signal, false); + value.assertWellFormed(); + return value; + } + + public ZkBool and(ZkBool other) { + requireSameContext(other); + return trusted(context, signal.and(other.signal)); + } + + public ZkBool or(ZkBool other) { + requireSameContext(other); + return trusted(context, signal.or(other.signal)); + } + + public ZkBool xor(ZkBool other) { + requireSameContext(other); + return trusted(context, signal.xor(other.signal)); + } + + public ZkBool not() { + return trusted(context, signal.not()); + } + + public ZkField select(ZkField ifTrue, ZkField ifFalse) { + requireSameContext(ifTrue); + requireSameContext(ifFalse); + return ZkField.wrap(context, signal.select(ifTrue.signal, ifFalse.signal)); + } + + public ZkBool select(ZkBool ifTrue, ZkBool ifFalse) { + requireSameContext(ifTrue); + requireSameContext(ifFalse); + return trusted(context, signal.select(ifTrue.signal, ifFalse.signal)); + } + + public ZkUInt select(ZkUInt ifTrue, ZkUInt ifFalse) { + requireSameContext(ifTrue); + requireSameContext(ifFalse); + return ZkUInt.trusted(context, signal.select(ifTrue.signal, ifFalse.signal), + Math.max(ifTrue.bits(), ifFalse.bits())); + } + + public void assertTrue() { + context.builder().assertEqual(signal, context.builder().constant(1)); + } + + public void assertFalse() { + context.builder().assertEqual(signal, context.builder().constant(0)); + } + + public void assertEqual(ZkBool other) { + requireSameContext(other); + context.builder().assertEqual(signal, other.signal); + } + + public ZkField asField() { + return ZkField.wrap(context, signal); + } + + public Signal signal() { + return signal; + } + + @Override + public List signals() { + return List.of(signal); + } + + @Override + public void assertWellFormed() { + if (!wellFormed) { + signal.assertBoolean(); + wellFormed = true; + } + } + + private void requireSameContext(ZkBool other) { + if (context.builder() != other.context.builder()) { + throw new IllegalArgumentException("Symbolic values belong to different circuit builders"); + } + } + + private void requireSameContext(ZkField other) { + if (context.builder() != other.context.builder()) { + throw new IllegalArgumentException("Symbolic values belong to different circuit builders"); + } + } + + private void requireSameContext(ZkUInt other) { + if (context.builder() != other.context.builder()) { + throw new IllegalArgumentException("Symbolic values belong to different circuit builders"); + } + } +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkContext.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkContext.java new file mode 100644 index 0000000..211c3de --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkContext.java @@ -0,0 +1,42 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; + +import java.math.BigInteger; +import java.util.Objects; + +/** + * Minimal context wrapper around {@link SignalBuilder} for symbolic values and + * future gadget adapters. + */ +public final class ZkContext { + private final SignalBuilder builder; + + public ZkContext(SignalBuilder builder) { + this.builder = Objects.requireNonNull(builder, "builder"); + } + + public SignalBuilder builder() { + return builder; + } + + public void requireSignal(Signal signal) { + Objects.requireNonNull(signal, "signal"); + if (!signal.isFrom(builder)) { + throw new IllegalArgumentException("Signal belongs to a different circuit builder"); + } + } + + public ZkField constant(long value) { + return ZkField.wrap(this, builder.constant(value)); + } + + public ZkField constant(BigInteger value) { + return ZkField.wrap(this, builder.constant(value)); + } + + public ZkField field(Signal signal) { + return ZkField.wrap(this, signal); + } +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkField.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkField.java new file mode 100644 index 0000000..9ef508f --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkField.java @@ -0,0 +1,87 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; + +import java.util.List; +import java.util.Objects; + +/** + * Symbolic raw field element backed by one {@link Signal}. + */ +public final class ZkField implements ZkValue { + final ZkContext context; + final Signal signal; + + private ZkField(ZkContext context, Signal signal) { + this.context = Objects.requireNonNull(context, "context"); + this.signal = Objects.requireNonNull(signal, "signal"); + context.requireSignal(signal); + } + + public static ZkField publicInput(SignalBuilder builder, String name) { + return wrap(new ZkContext(builder), builder.publicInput(name)); + } + + public static ZkField secret(SignalBuilder builder, String name) { + return wrap(new ZkContext(builder), builder.privateInput(name)); + } + + public static ZkField wrap(ZkContext context, Signal signal) { + return new ZkField(context, signal); + } + + public static ZkField wrap(SignalBuilder builder, Signal signal) { + return wrap(new ZkContext(builder), signal); + } + + public ZkField add(ZkField other) { + requireSameContext(other); + return wrap(context, signal.add(other.signal)); + } + + public ZkField sub(ZkField other) { + requireSameContext(other); + return wrap(context, signal.sub(other.signal)); + } + + public ZkField mul(ZkField other) { + requireSameContext(other); + return wrap(context, signal.mul(other.signal)); + } + + public ZkField div(ZkField other) { + requireSameContext(other); + return wrap(context, signal.div(other.signal)); + } + + public ZkBool isEqual(ZkField other) { + requireSameContext(other); + return ZkBool.trusted(context, signal.isEqual(other.signal)); + } + + public void assertEqual(ZkField other) { + requireSameContext(other); + context.builder().assertEqual(signal, other.signal); + } + + public Signal signal() { + return signal; + } + + @Override + public List signals() { + return List.of(signal); + } + + @Override + public void assertWellFormed() { + // Every field element is well-formed modulo the active circuit field. + } + + void requireSameContext(ZkField other) { + if (context.builder() != other.context.builder()) { + throw new IllegalArgumentException("Symbolic values belong to different circuit builders"); + } + } +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkUInt.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkUInt.java new file mode 100644 index 0000000..b48c389 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkUInt.java @@ -0,0 +1,169 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; + +import java.util.List; +import java.util.Objects; + +/** + * Symbolic unsigned integer backed by one field element and an explicit bit + * width. + */ +public final class ZkUInt implements ZkValue { + public static final int MAX_BITS = 253; + + final ZkContext context; + final Signal signal; + private final int bits; + private boolean wellFormed; + + private ZkUInt(ZkContext context, Signal signal, int bits, boolean wellFormed) { + this.context = Objects.requireNonNull(context, "context"); + this.signal = Objects.requireNonNull(signal, "signal"); + context.requireSignal(signal); + this.bits = validateBits(bits); + this.wellFormed = wellFormed; + } + + public static ZkUInt publicInput(SignalBuilder builder, String name, int bits) { + return constrained(new ZkContext(builder), builder.publicInput(name), bits); + } + + public static ZkUInt secret(SignalBuilder builder, String name, int bits) { + return constrained(new ZkContext(builder), builder.privateInput(name), bits); + } + + public static ZkUInt wrap(ZkContext context, Signal signal, int bits) { + return constrained(context, signal, bits); + } + + public static ZkUInt wrap(SignalBuilder builder, Signal signal, int bits) { + return constrained(new ZkContext(builder), signal, bits); + } + + static ZkUInt trusted(ZkContext context, Signal signal, int bits) { + return new ZkUInt(context, signal, bits, true); + } + + private static ZkUInt constrained(ZkContext context, Signal signal, int bits) { + var value = new ZkUInt(context, signal, bits, false); + value.assertWellFormed(); + return value; + } + + public int bits() { + return bits; + } + + public ZkUInt add(ZkUInt other) { + requireSameContext(other); + int outputBits = checkedOutputBits("addition", Math.max(bits, other.bits) + 1); + // Operands are already range-constrained and the widened bound stays + // below the safe field limit, so the result cannot wrap modulo p. + return trusted(context, signal.add(other.signal), outputBits); + } + + public ZkUInt sub(ZkUInt other) { + requireSameContext(other); + return constrained(context, signal.sub(other.signal), Math.max(bits, other.bits)); + } + + public ZkUInt mul(ZkUInt other) { + requireSameContext(other); + int outputBits = checkedOutputBits("multiplication", bits + other.bits); + // Operands are already range-constrained and the product bound stays + // below the safe field limit, so no extra decomposition is needed. + return trusted(context, signal.mul(other.signal), outputBits); + } + + public ZkBool lt(ZkUInt other) { + requireSameContext(other); + int compareBits = compareBits(other); + return ZkBool.trusted(context, signal.lessThan(other.signal, compareBits)); + } + + public ZkBool lte(ZkUInt other) { + return gt(other).not(); + } + + public ZkBool gt(ZkUInt other) { + return other.lt(this); + } + + public ZkBool gte(ZkUInt other) { + return lt(other).not(); + } + + public ZkBool isEqual(ZkUInt other) { + requireSameContext(other); + return ZkBool.trusted(context, signal.isEqual(other.signal)); + } + + public ZkBool inRange(ZkUInt lo, ZkUInt hi) { + requireSameContext(lo); + requireSameContext(hi); + return gte(lo).and(lte(hi)); + } + + public void assertInRange() { + assertWellFormed(); + } + + public void assertEqual(ZkUInt other) { + requireSameContext(other); + context.builder().assertEqual(signal, other.signal); + } + + public ZkField asField() { + return ZkField.wrap(context, signal); + } + + public Signal signal() { + return signal; + } + + @Override + public List signals() { + return List.of(signal); + } + + @Override + public void assertWellFormed() { + if (!wellFormed) { + signal.assertInRange(bits); + wellFormed = true; + } + } + + private int compareBits(ZkUInt other) { + int compareBits = Math.max(bits, other.bits); + if (compareBits >= MAX_BITS) { + throw new IllegalArgumentException( + "ZkUInt comparison requires bit width < " + MAX_BITS + ", got " + compareBits); + } + return compareBits; + } + + private void requireSameContext(ZkUInt other) { + if (context.builder() != other.context.builder()) { + throw new IllegalArgumentException("Symbolic values belong to different circuit builders"); + } + } + + private static int validateBits(int bits) { + if (bits <= 0 || bits > MAX_BITS) { + throw new IllegalArgumentException("bits must be in [1, " + MAX_BITS + "], got " + bits); + } + return bits; + } + + private static int checkedOutputBits(String operation, int outputBits) { + if (outputBits > MAX_BITS) { + throw new IllegalArgumentException( + "ZkUInt " + operation + " output requires " + outputBits + + " bits, exceeding max " + MAX_BITS); + } + return outputBits; + } +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkValue.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkValue.java new file mode 100644 index 0000000..6e0f733 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkValue.java @@ -0,0 +1,22 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import com.bloxbean.cardano.zeroj.circuit.Signal; + +import java.util.List; + +/** + * Base contract for symbolic circuit values. + */ +public interface ZkValue { + + /** + * Flatten this symbolic value into the backing circuit signals. + */ + List signals(); + + /** + * Add type-specific constraints. Implementations make this idempotent where + * they add constraints eagerly during public/secret input construction. + */ + void assertWellFormed(); +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java index 14927ab..f29d438 100644 --- a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java @@ -1,8 +1,8 @@ /** * Annotation-based circuit authoring API for ZeroJ. * - *

This package will expose the public annotations and symbolic value types - * used by Java classes that are compiled into {@code CircuitBuilder} / + *

This package exposes the public annotations and symbolic value types used + * by Java classes that are compiled into {@code CircuitBuilder} / * {@code CircuitSpec} companions by the annotation processor.

*/ package com.bloxbean.cardano.zeroj.circuit.annotation; diff --git a/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java b/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java new file mode 100644 index 0000000..35d85fe --- /dev/null +++ b/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java @@ -0,0 +1,298 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; +import com.bloxbean.cardano.zeroj.circuit.Signal; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ZkSymbolicTypesTest { + + @Test + void fieldArithmeticAcceptsValidWitness() { + var circuit = CircuitBuilder.create("zk-field-mul") + .publicVar("out") + .secretVar("a") + .secretVar("b") + .defineSignals(c -> { + var a = ZkField.secret(c, "a"); + var b = ZkField.secret(c, "b"); + var out = ZkField.publicInput(c, "out"); + + a.mul(b).assertEqual(out); + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "out", List.of(BigInteger.valueOf(33)), + "a", List.of(BigInteger.valueOf(3)), + "b", List.of(BigInteger.valueOf(11))), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "out", List.of(BigInteger.valueOf(34)), + "a", List.of(BigInteger.valueOf(3)), + "b", List.of(BigInteger.valueOf(11))), CurveId.BN254)); + } + + @Test + void boolInputRejectsNonBooleanWitness() { + var circuit = CircuitBuilder.create("zk-bool") + .secretVar("flag") + .defineSignals(c -> ZkBool.secret(c, "flag").assertTrue()); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "flag", List.of(BigInteger.ONE)), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "flag", List.of(BigInteger.TWO)), CurveId.BN254)); + } + + @Test + void uintComparisonRejectsFalseClaim() { + var circuit = CircuitBuilder.create("zk-uint-gte") + .publicVar("ok") + .secretVar("age") + .publicVar("threshold") + .defineSignals(c -> { + var ok = ZkBool.publicInput(c, "ok"); + var age = ZkUInt.secret(c, "age", 8); + var threshold = ZkUInt.publicInput(c, "threshold", 8); + + age.gte(threshold).assertEqual(ok); + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "ok", List.of(BigInteger.ONE), + "age", List.of(BigInteger.valueOf(25)), + "threshold", List.of(BigInteger.valueOf(18))), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "ok", List.of(BigInteger.ONE), + "age", List.of(BigInteger.valueOf(15)), + "threshold", List.of(BigInteger.valueOf(18))), CurveId.BN254)); + } + + @Test + void uintInputRejectsOutOfRangeWitness() { + var circuit = CircuitBuilder.create("zk-uint-range") + .secretVar("value") + .defineSignals(c -> ZkUInt.secret(c, "value", 8)); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "value", List.of(BigInteger.valueOf(255))), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "value", List.of(BigInteger.valueOf(256))), CurveId.BN254)); + } + + @Test + void uintAdditionExpandsOutputBitWidth() { + var circuit = CircuitBuilder.create("zk-uint-add") + .publicVar("out") + .secretVar("a") + .secretVar("b") + .defineSignals(c -> { + var a = ZkUInt.secret(c, "a", 8); + var b = ZkUInt.secret(c, "b", 8); + var out = ZkUInt.publicInput(c, "out", 9); + var sum = a.add(b); + + if (sum.bits() != 9) { + throw new IllegalStateException("8-bit + 8-bit must produce a 9-bit ZkUInt"); + } + sum.assertEqual(out); + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "out", List.of(BigInteger.valueOf(300)), + "a", List.of(BigInteger.valueOf(200)), + "b", List.of(BigInteger.valueOf(100))), CurveId.BN254)); + } + + @Test + void uintSubtractionRejectsUnderflowByDefault() { + var circuit = CircuitBuilder.create("zk-uint-sub-underflow") + .secretVar("a") + .secretVar("b") + .defineSignals(c -> ZkUInt.secret(c, "a", 8).sub(ZkUInt.secret(c, "b", 8))); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "a", List.of(BigInteger.valueOf(10)), + "b", List.of(BigInteger.valueOf(5))), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "a", List.of(BigInteger.valueOf(5)), + "b", List.of(BigInteger.valueOf(10))), CurveId.BN254)); + } + + @Test + void symbolicRangeCircuitMatchesSignalCircuitBehavior() { + var symbolic = CircuitBuilder.create("zk-symbolic-range") + .publicVar("threshold") + .secretVar("age") + .defineSignals(c -> { + var age = ZkUInt.secret(c, "age", 8); + var threshold = ZkUInt.publicInput(c, "threshold", 8); + age.gte(threshold).assertTrue(); + }); + + var signal = CircuitBuilder.create("zk-signal-range") + .publicVar("threshold") + .secretVar("age") + .defineSignals(c -> { + Signal age = c.privateInput("age"); + Signal threshold = c.publicInput("threshold"); + age.assertInRange(8); + threshold.assertInRange(8); + c.assertEqual(age.lessThan(threshold, 8).not(), c.constant(1)); + }); + + assertEquals(signal.constraintGraph().gates().size(), symbolic.constraintGraph().gates().size()); + + var valid = Map.of( + "threshold", List.of(BigInteger.valueOf(18)), + "age", List.of(BigInteger.valueOf(25))); + assertDoesNotThrow(() -> symbolic.calculateWitness(valid, CurveId.BN254)); + assertDoesNotThrow(() -> signal.calculateWitness(valid, CurveId.BN254)); + + var invalid = Map.of( + "threshold", List.of(BigInteger.valueOf(18)), + "age", List.of(BigInteger.valueOf(15))); + assertThrows(ArithmeticException.class, () -> symbolic.calculateWitness(invalid, CurveId.BN254)); + assertThrows(ArithmeticException.class, () -> signal.calculateWitness(invalid, CurveId.BN254)); + } + + @Test + void arrayFlattensSignalsAndSupportsFactories() { + var circuit = CircuitBuilder.create("zk-array") + .publicVar("out") + .secretVar("item_0") + .secretVar("item_1") + .defineSignals(c -> { + var items = ZkArray.secretFields(c, "item", 2); + var out = ZkField.publicInput(c, "out"); + + items.get(0).add(items.get(1)).assertEqual(out); + if (items.signals().size() != 2) { + throw new IllegalStateException("array should flatten to two signals"); + } + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "out", List.of(BigInteger.valueOf(30)), + "item_0", List.of(BigInteger.TEN), + "item_1", List.of(BigInteger.valueOf(20))), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "out", List.of(BigInteger.valueOf(31)), + "item_0", List.of(BigInteger.TEN), + "item_1", List.of(BigInteger.valueOf(20))), CurveId.BN254)); + } + + @Test + void merkleShapedInputsSupportFieldSiblingsAndBooleanPathBits() { + var circuit = CircuitBuilder.create("zk-merkle-shape") + .publicVar("root") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("sibling_1") + .secretVar("pathBit_0") + .secretVar("pathBit_1") + .defineSignals(c -> { + var root = ZkField.publicInput(c, "root"); + var current = ZkField.secret(c, "leaf"); + var siblings = ZkArray.secretFields(c, "sibling", 2); + var pathBits = ZkArray.secretBools(c, "pathBit", 2); + var factor = ZkField.wrap(c, c.constant(31)); + + for (int i = 0; i < siblings.size(); i++) { + var left = pathBits.get(i).select(siblings.get(i), current); + var right = pathBits.get(i).select(current, siblings.get(i)); + current = left.mul(factor).add(right); + } + current.assertEqual(root); + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "root", List.of(BigInteger.valueOf(503)), + "leaf", List.of(BigInteger.valueOf(5)), + "sibling_0", List.of(BigInteger.valueOf(7)), + "sibling_1", List.of(BigInteger.valueOf(11)), + "pathBit_0", List.of(BigInteger.ZERO), + "pathBit_1", List.of(BigInteger.ONE)), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "root", List.of(BigInteger.valueOf(503)), + "leaf", List.of(BigInteger.valueOf(5)), + "sibling_0", List.of(BigInteger.valueOf(7)), + "sibling_1", List.of(BigInteger.valueOf(11)), + "pathBit_0", List.of(BigInteger.TWO), + "pathBit_1", List.of(BigInteger.ONE)), CurveId.BN254)); + } + + @Test + void symbolicCircuitCompilesToAllBackends() { + var circuit = CircuitBuilder.create("zk-compile") + .publicVar("ok") + .secretVar("value") + .defineSignals(c -> { + var ok = ZkBool.publicInput(c, "ok"); + var value = ZkUInt.secret(c, "value", 8); + value.gte(ZkUInt.wrap(c, c.constant(100), 8)).assertEqual(ok); + }); + + assertNotNull(circuit.compileR1CS(CurveId.BN254)); + assertNotNull(circuit.compilePlonK(CurveId.BN254)); + assertNotNull(circuit.compileHalo2(CurveId.BN254)); + } + + @Test + void visibilityFactoriesRejectMismatchedDeclarations() { + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-public-as-secret") + .secretVar("x") + .defineSignals(c -> ZkField.publicInput(c, "x"))); + + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-secret-as-public") + .publicVar("x") + .defineSignals(c -> ZkField.secret(c, "x"))); + + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-array-visibility") + .publicVar("item_0") + .defineSignals(c -> ZkArray.secretFields(c, "item", 1))); + } + + @Test + void wrappingRejectsSignalsFromOtherBuilders() { + Signal[] signalFromOtherBuilder = new Signal[1]; + CircuitBuilder.create("zk-other-builder") + .secretVar("other") + .defineSignals(c -> signalFromOtherBuilder[0] = c.privateInput("other")); + + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-wrong-context") + .secretVar("local") + .defineSignals(c -> ZkField.wrap(new ZkContext(c), signalFromOtherBuilder[0]))); + } + + @Test + void apiGuardrailsRejectInvalidShapeParameters() { + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-invalid-width") + .secretVar("value") + .defineSignals(c -> ZkUInt.secret(c, "value", 0))); + + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-invalid-array") + .defineSignals(c -> ZkArray.secretFields(c, "item", -1))); + + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-max-width-comparison") + .secretVar("a") + .secretVar("b") + .defineSignals(c -> ZkUInt.secret(c, "a", ZkUInt.MAX_BITS) + .lt(ZkUInt.secret(c, "b", ZkUInt.MAX_BITS)))); + } +} diff --git a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitAPI.java b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitAPI.java index b45ae23..2e25411 100644 --- a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitAPI.java +++ b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitAPI.java @@ -98,6 +98,28 @@ public interface CircuitAPI { /** Look up a declared variable by name. */ Variable var(String name); + /** + * Look up a declared public input by name. + * + *

Implementations that track input visibility should reject secret + * variables here. The default preserves compatibility with minimal + * implementations by falling back to {@link #var(String)}.

+ */ + default Variable publicInputVar(String name) { + return var(name); + } + + /** + * Look up a declared secret input by name. + * + *

Implementations that track input visibility should reject public + * variables here. The default preserves compatibility with minimal + * implementations by falling back to {@link #var(String)}.

+ */ + default Variable secretInputVar(String name) { + return var(name); + } + // --- Field expectation (checked at compile/witness time) --- /** diff --git a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitAPIImpl.java b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitAPIImpl.java index af7d9c2..fc274fe 100644 --- a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitAPIImpl.java +++ b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitAPIImpl.java @@ -15,6 +15,7 @@ class CircuitAPIImpl implements CircuitAPI { private final List gates = new ArrayList<>(); private final Map namedVars = new LinkedHashMap<>(); + private final Map inputVisibilities = new LinkedHashMap<>(); private final Map constantCache = new HashMap<>(); private final List publicInputs = new ArrayList<>(); private final List secretInputs = new ArrayList<>(); @@ -34,6 +35,7 @@ class CircuitAPIImpl implements CircuitAPI { var v = new Variable(nextId++, name); publicInputs.add(v); namedVars.put(name, v); + inputVisibilities.put(name, InputVisibility.PUBLIC); } // Secret inputs @@ -41,6 +43,7 @@ class CircuitAPIImpl implements CircuitAPI { var v = new Variable(nextId++, name); secretInputs.add(v); namedVars.put(name, v); + inputVisibilities.put(name, InputVisibility.SECRET); } } @@ -281,4 +284,42 @@ public Variable var(String name) { if (v == null) throw new IllegalArgumentException("Unknown variable: " + name); return v; } + + @Override + public Variable publicInputVar(String name) { + return inputVar(name, InputVisibility.PUBLIC); + } + + @Override + public Variable secretInputVar(String name) { + return inputVar(name, InputVisibility.SECRET); + } + + private Variable inputVar(String name, InputVisibility expected) { + var v = namedVars.get(name); + if (v == null) throw new IllegalArgumentException("Unknown variable: " + name); + + var actual = inputVisibilities.get(name); + if (actual != expected) { + throw new IllegalArgumentException( + "Variable " + name + " is declared as " + actual.label() + + " but was requested as " + expected.label()); + } + return v; + } + + private enum InputVisibility { + PUBLIC("public"), + SECRET("secret"); + + private final String label; + + InputVisibility(String label) { + this.label = label; + } + + String label() { + return label; + } + } } diff --git a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/Signal.java b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/Signal.java index 2ff7229..0807e7f 100644 --- a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/Signal.java +++ b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/Signal.java @@ -28,6 +28,11 @@ public final class Signal { /** The underlying wire variable. */ public Variable variable() { return variable; } + /** Return true when this signal was created from the given builder's API. */ + public boolean isFrom(SignalBuilder builder) { + return builder != null && api == builder.api(); + } + // --- Arithmetic --- /** Field addition: this + other. */ diff --git a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/SignalBuilder.java b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/SignalBuilder.java index 31e3ac3..3ed5000 100644 --- a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/SignalBuilder.java +++ b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/SignalBuilder.java @@ -29,17 +29,17 @@ public final class SignalBuilder { /** Declare a private (secret) input signal. */ public Signal privateInput(String name) { - return new Signal(api.var(name), api); + return new Signal(api.secretInputVar(name), api); } /** Declare a public input signal. */ public Signal publicInput(String name) { - return new Signal(api.var(name), api); + return new Signal(api.publicInputVar(name), api); } /** Declare a public output signal (same as publicInput in the constraint system). */ public Signal publicOutput(String name) { - return new Signal(api.var(name), api); + return new Signal(api.publicInputVar(name), api); } /** Look up a previously declared signal by name. */ From 7f494131912db1da69f82739185e013acedd20df Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 01:36:37 +0800 Subject: [PATCH 04/26] phase 3: add symbolic gadget adapters --- docs/adr/circuit-annotation/README.md | 4 + .../circuit-annotation/implementation-plan.md | 4 +- .../phase-3-mvp-gadget-adapters.md | 76 ++++ zeroj-circuit-lib/README.md | 13 + zeroj-circuit-lib/build.gradle | 1 + .../cardano/zeroj/circuit/lib/MiMC.java | 5 +- .../cardano/zeroj/circuit/lib/SignalMiMC.java | 3 + .../zeroj/circuit/lib/zk/ZkMerkle.java | 173 ++++++++ .../cardano/zeroj/circuit/lib/zk/ZkMiMC.java | 25 ++ .../zeroj/circuit/lib/zk/ZkPoseidon.java | 37 ++ .../circuit/lib/zk/ZkGadgetAdaptersTest.java | 381 ++++++++++++++++++ 11 files changed, 719 insertions(+), 3 deletions(-) create mode 100644 docs/adr/circuit-annotation/phase-3-mvp-gadget-adapters.md create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMerkle.java create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMiMC.java create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPoseidon.java create mode 100644 zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index 096cc66..fe60356 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -900,6 +900,10 @@ MVP adapters: - `ZkPoseidon` - `ZkMerkle` for fixed-depth membership proofs +`ZkMiMC` uses the existing MiMC constants and is guarded as BN254-only. +`ZkPoseidon` should expose both default and explicit-`PoseidonParams` overloads +so BLS12-381 circuits can select the matching parameter set. + These adapters live in `zeroj-circuit-lib`, not in `zeroj-circuit-annotation-api`, so the dependency direction remains: diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index 89d4b9e..0a8a4de 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -25,8 +25,8 @@ In progress. |-------|------|--------|--------| | 0 | Planning baseline | Completed | 823c422 | | 1 | Module scaffolding | Completed | 86f122c | -| 2 | Symbolic foundation | Completed | Pending | -| 3 | MVP gadget adapters | Pending | Pending | +| 2 | Symbolic foundation | Completed | bfe0b65 | +| 3 | MVP gadget adapters | Completed | Pending | | 4 | MVP annotation processor | Pending | Pending | | 5 | Schema and input builders | Pending | Pending | | 6 | Examples and documentation | Pending | Pending | diff --git a/docs/adr/circuit-annotation/phase-3-mvp-gadget-adapters.md b/docs/adr/circuit-annotation/phase-3-mvp-gadget-adapters.md new file mode 100644 index 0000000..ac90ba5 --- /dev/null +++ b/docs/adr/circuit-annotation/phase-3-mvp-gadget-adapters.md @@ -0,0 +1,76 @@ +# Phase 3: MVP Gadget Adapters + +## Status + +Approved. + +## Goal + +Add the first symbolic adapters for existing `zeroj-circuit-lib` gadgets so +annotation-authored circuits can use realistic hash and Merkle workflows +without extracting raw `Signal` values and wrapping results manually. + +## Implemented Changes + +- Added `zeroj-circuit-lib` dependency on `zeroj-circuit-annotation-api`. +- Added `com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMiMC`. +- Added `com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkPoseidon`. +- Added `com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMerkle`. +- Added `ZkMerkle.HashType` with `MIMC` and `POSEIDON` choices. +- Added `ZkMerkle.HashFn` for custom symbolic two-to-one hash functions. +- Added BN254 field guards to existing MiMC gadgets. +- Added differential tests against `SignalMiMC`, `SignalPoseidon`, and + `SignalMerkle`. +- Updated `zeroj-circuit-lib` README with the symbolic adapter surface. + +## Public API Changes + +- `ZkMiMC.hash(zk, left, right)` returns `ZkField`. +- `ZkPoseidon.hash(zk, left, right)` returns `ZkField` using the existing + default Poseidon parameters. +- `ZkPoseidon.hash(zk, params, left, right)` supports explicit + `PoseidonParams`. +- `ZkMerkle.computeRoot(...)` returns the computed symbolic root. +- `ZkMerkle.verify(...)` asserts the computed root equals the supplied root. +- `ZkMerkle.verifyProof(...)` aliases `verify(...)` for parity with + `SignalMerkle.verifyProof(...)`. +- `ZkMerkle.isMember(...)` returns a `ZkBool` membership predicate. +- All adapters validate that input signals belong to the supplied `ZkContext`. +- `ZkMiMC` is guarded as BN254-only, matching the existing MiMC constants. + +## Exit Criteria Mapping + +| ADR exit criterion | Phase 3 result | +|--------------------|----------------| +| Hash commitment can be written without `.signal()` / `wrap(...)` boilerplate | Covered by `ZkMiMC.hash(...)` and `ZkPoseidon.hash(...)`. | +| Merkle membership can be written with `ZkArray` | Covered by `ZkMerkle.computeRoot(...)`, `verify(...)`, and `isMember(...)`. | +| Adapters reuse existing `Signal` or `Variable` gadgets | `ZkMiMC` delegates to `SignalMiMC`; `ZkPoseidon` delegates to `SignalPoseidon`; `ZkMerkle` delegates to `SignalMerkle`. | +| Differential tests against existing gadgets | Covered by `ZkGadgetAdaptersTest`. | + +## Verification + +- `./gradlew :zeroj-circuit-lib:test --tests com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkGadgetAdaptersTest` passed after blocker fixes. +- `./gradlew :zeroj-circuit-lib:test` passed. +- `./gradlew :zeroj-circuit-annotation-api:test :zeroj-circuit-annotation-processor:test` passed. +- `rg -n "[[:blank:]]$" docs/adr/circuit-annotation zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk zeroj-circuit-lib/build.gradle zeroj-circuit-lib/README.md zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/MiMC.java zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/SignalMiMC.java` passed. +- `git diff --cached --check` passed. + +## Review Results + +Three-agent review approved Phase 3 after blocker fixes: + +- API/design review initially blocked on `ZkMerkle` duplicating + `SignalMerkle`, missing root ownership validation, and weak Merkle + differential behavior. The final review approved the delegation, + `verifyProof(...)` alias, root validation, and strengthened tests. +- ZK-safety review initially blocked on MiMC missing a field guard. The final + review approved the BN254 guard in `MiMC` and `SignalMiMC`, plus ownership + validation for custom Merkle hash results. +- Tests/docs review initially blocked on the Merkle reuse/differential gap. The + final review approved the stronger Merkle behavior tests, Poseidon hash-type + coverage, explicit BLS12-381 Poseidon-params test, MiMC field-guard test, and + cross-builder guard tests. + +## Commit + +Pending. diff --git a/zeroj-circuit-lib/README.md b/zeroj-circuit-lib/README.md index 1ee23bc..d48994f 100644 --- a/zeroj-circuit-lib/README.md +++ b/zeroj-circuit-lib/README.md @@ -17,6 +17,7 @@ or Jubjub-style primitives. | Binary gadgets | `Binary`, `SignalBinary`, `AliasCheck` | | Selection | `Mux` | | Signal helpers | `SignalPoseidon`, `SignalMiMC` | +| Annotation helpers | `ZkPoseidon`, `ZkMiMC`, `ZkMerkle` | | Jubjub primitives | `JubjubCurve`, `PedersenCommitment`, `EdDSAJubjub`, in-circuit variants | | Poseidon parameters | `PoseidonParams*`, `PoseidonHash`, Grain LFSR generation helpers | @@ -46,6 +47,18 @@ var circuit = CircuitBuilder.create("membership") For larger circuits, prefer the `Signal*` helper classes with `SignalBuilder` and reusable `CircuitSpec` components. +Annotation-based circuits can use symbolic adapters from +`com.bloxbean.cardano.zeroj.circuit.lib.zk`: + +```java +var hash = ZkPoseidon.hash(zk, left, right); +var root = ZkMerkle.computeRoot(zk, leaf, siblings, pathBits, ZkMiMC::hash); +``` + +These adapters delegate to the existing `Signal*` gadgets and validate that +their inputs belong to the supplied `ZkContext`. `ZkMiMC` is guarded as +BN254-only; use explicit Poseidon parameters when targeting BLS12-381. + ## Gradle ```gradle diff --git a/zeroj-circuit-lib/build.gradle b/zeroj-circuit-lib/build.gradle index eed460b..5f52a36 100644 --- a/zeroj-circuit-lib/build.gradle +++ b/zeroj-circuit-lib/build.gradle @@ -6,6 +6,7 @@ description = 'ZeroJ Circuit Library — reusable ZK circuit components (Poseido dependencies { api project(':zeroj-circuit-dsl') + api project(':zeroj-circuit-annotation-api') testImplementation project(':zeroj-test-vectors') } diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/MiMC.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/MiMC.java index 578c5be..783c138 100644 --- a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/MiMC.java +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/MiMC.java @@ -1,6 +1,7 @@ package com.bloxbean.cardano.zeroj.circuit.lib; import com.bloxbean.cardano.zeroj.circuit.CircuitAPI; +import com.bloxbean.cardano.zeroj.circuit.FieldConfig; import com.bloxbean.cardano.zeroj.circuit.Variable; import java.math.BigInteger; @@ -13,7 +14,7 @@ * *

MiMC uses x^7 S-box with 91 rounds for BN254. * Each round: state = (state + round_constant + key)^7. - * Approximately 273 constraints for one hash.

+ * Approximately 364 multiplication constraints for one hash.

*/ public final class MiMC { @@ -30,6 +31,8 @@ private MiMC() {} * @return hash output */ public static Variable hash(CircuitAPI api, Variable left, Variable right) { + api.requireField(FieldConfig.BN254); + // MiMC-7 with key = right var state = left; var key = right; diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/SignalMiMC.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/SignalMiMC.java index a1aa29d..f94202b 100644 --- a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/SignalMiMC.java +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/SignalMiMC.java @@ -2,6 +2,7 @@ import com.bloxbean.cardano.zeroj.circuit.Signal; import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; +import com.bloxbean.cardano.zeroj.circuit.FieldConfig; /** * MiMC hash using the Signal API. @@ -20,6 +21,8 @@ private SignalMiMC() {} * MiMC-7 hash of two signals. */ public static Signal hash(SignalBuilder c, Signal left, Signal right) { + c.api().requireField(FieldConfig.BN254); + Signal state = left; Signal key = right; diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMerkle.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMerkle.java new file mode 100644 index 0000000..9bd69f6 --- /dev/null +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMerkle.java @@ -0,0 +1,173 @@ +package com.bloxbean.cardano.zeroj.circuit.lib.zk; + +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.lib.SignalMerkle; + +import java.util.Objects; + +/** + * Symbolic fixed-depth Merkle helpers for annotation-based circuits. + */ +public final class ZkMerkle { + + private ZkMerkle() {} + + public enum HashType { + MIMC, + POSEIDON + } + + @FunctionalInterface + public interface HashFn { + ZkField hash(ZkContext zk, ZkField left, ZkField right); + } + + public static ZkField computeRoot( + ZkContext zk, + ZkField leaf, + ZkArray siblings, + ZkArray pathBits, + HashType hashType) { + Objects.requireNonNull(hashType, "hashType"); + return switch (hashType) { + case MIMC -> computeRoot(zk, leaf, siblings, pathBits, ZkMiMC::hash); + case POSEIDON -> computeRoot(zk, leaf, siblings, pathBits, ZkPoseidon::hash); + }; + } + + public static ZkField computeRoot( + ZkContext zk, + ZkField leaf, + ZkArray siblings, + ZkArray pathBits, + HashFn hashFn) { + validateInputs(zk, leaf, siblings, pathBits, hashFn); + + Signal root = SignalMerkle.computeRoot( + zk.builder(), + leaf.signal(), + fieldSignals(siblings), + boolSignals(pathBits), + (c, left, right) -> { + ZkField hashed = hashFn.hash(zk, ZkField.wrap(zk, left), ZkField.wrap(zk, right)); + zk.requireSignal(hashed.signal()); + return hashed.signal(); + }); + return ZkField.wrap(zk, root); + } + + public static void verify( + ZkContext zk, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits, + HashType hashType) { + requireRoot(zk, root); + computeRoot(zk, leaf, siblings, pathBits, hashType).assertEqual(root); + } + + public static void verifyProof( + ZkContext zk, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits, + HashType hashType) { + verify(zk, leaf, root, siblings, pathBits, hashType); + } + + public static void verify( + ZkContext zk, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits, + HashFn hashFn) { + requireRoot(zk, root); + computeRoot(zk, leaf, siblings, pathBits, hashFn).assertEqual(root); + } + + public static void verifyProof( + ZkContext zk, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits, + HashFn hashFn) { + verify(zk, leaf, root, siblings, pathBits, hashFn); + } + + public static ZkBool isMember( + ZkContext zk, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits, + HashType hashType) { + requireRoot(zk, root); + return computeRoot(zk, leaf, siblings, pathBits, hashType).isEqual(root); + } + + public static ZkBool isMember( + ZkContext zk, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits, + HashFn hashFn) { + requireRoot(zk, root); + return computeRoot(zk, leaf, siblings, pathBits, hashFn).isEqual(root); + } + + private static void validateInputs( + ZkContext zk, + ZkField leaf, + ZkArray siblings, + ZkArray pathBits, + HashFn hashFn) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(leaf, "leaf"); + Objects.requireNonNull(siblings, "siblings"); + Objects.requireNonNull(pathBits, "pathBits"); + Objects.requireNonNull(hashFn, "hashFn"); + + if (siblings.size() != pathBits.size()) { + throw new IllegalArgumentException("siblings and pathBits must have equal length"); + } + + zk.requireSignal(leaf.signal()); + for (ZkField sibling : siblings.values()) { + zk.requireSignal(sibling.signal()); + } + for (ZkBool pathBit : pathBits.values()) { + zk.requireSignal(pathBit.signal()); + } + } + + private static void requireRoot(ZkContext zk, ZkField root) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(root, "root"); + zk.requireSignal(root.signal()); + } + + private static Signal[] fieldSignals(ZkArray values) { + Signal[] signals = new Signal[values.size()]; + for (int i = 0; i < values.size(); i++) { + signals[i] = values.get(i).signal(); + } + return signals; + } + + private static Signal[] boolSignals(ZkArray values) { + Signal[] signals = new Signal[values.size()]; + for (int i = 0; i < values.size(); i++) { + signals[i] = values.get(i).signal(); + } + return signals; + } +} diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMiMC.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMiMC.java new file mode 100644 index 0000000..6aab1eb --- /dev/null +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMiMC.java @@ -0,0 +1,25 @@ +package com.bloxbean.cardano.zeroj.circuit.lib.zk; + +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.lib.SignalMiMC; + +import java.util.Objects; + +/** + * Symbolic MiMC adapter for annotation-based circuits. + */ +public final class ZkMiMC { + + private ZkMiMC() {} + + public static ZkField hash(ZkContext zk, ZkField left, ZkField right) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(left, "left"); + Objects.requireNonNull(right, "right"); + zk.requireSignal(left.signal()); + zk.requireSignal(right.signal()); + + return ZkField.wrap(zk, SignalMiMC.hash(zk.builder(), left.signal(), right.signal())); + } +} diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPoseidon.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPoseidon.java new file mode 100644 index 0000000..4c1c62c --- /dev/null +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPoseidon.java @@ -0,0 +1,37 @@ +package com.bloxbean.cardano.zeroj.circuit.lib.zk; + +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.lib.SignalPoseidon; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; + +import java.util.Objects; + +/** + * Symbolic Poseidon adapter for annotation-based circuits. + */ +public final class ZkPoseidon { + + private ZkPoseidon() {} + + public static ZkField hash(ZkContext zk, PoseidonParams params, ZkField left, ZkField right) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(params, "params"); + Objects.requireNonNull(left, "left"); + Objects.requireNonNull(right, "right"); + zk.requireSignal(left.signal()); + zk.requireSignal(right.signal()); + + return ZkField.wrap(zk, SignalPoseidon.hash(zk.builder(), params, left.signal(), right.signal())); + } + + public static ZkField hash(ZkContext zk, ZkField left, ZkField right) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(left, "left"); + Objects.requireNonNull(right, "right"); + zk.requireSignal(left.signal()); + zk.requireSignal(right.signal()); + + return ZkField.wrap(zk, SignalPoseidon.hash(zk.builder(), left.signal(), right.signal())); + } +} diff --git a/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java b/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java new file mode 100644 index 0000000..46f955c --- /dev/null +++ b/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java @@ -0,0 +1,381 @@ +package com.bloxbean.cardano.zeroj.circuit.lib.zk; + +import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.lib.SignalMerkle; +import com.bloxbean.cardano.zeroj.circuit.lib.SignalMiMC; +import com.bloxbean.cardano.zeroj.circuit.lib.SignalPoseidon; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBN254T3; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ZkGadgetAdaptersTest { + + @Test + void mimcAdapterMatchesSignalAdapter() { + var symbolic = CircuitBuilder.create("zk-mimc") + .secretVar("left") + .secretVar("right") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMiMC.hash(zk, ZkField.secret(c, "left"), ZkField.secret(c, "right")); + }); + + var signal = CircuitBuilder.create("signal-mimc") + .secretVar("left") + .secretVar("right") + .defineSignals(c -> SignalMiMC.hash(c, c.privateInput("left"), c.privateInput("right"))); + + var inputs = Map.of( + "left", List.of(BigInteger.valueOf(3)), + "right", List.of(BigInteger.valueOf(11))); + + assertEquals(signal.constraintGraph().gates().size(), symbolic.constraintGraph().gates().size()); + assertArrayEquals( + signal.calculateWitness(inputs, CurveId.BN254), + symbolic.calculateWitness(inputs, CurveId.BN254)); + } + + @Test + void poseidonAdapterMatchesSignalAdapter() { + var symbolic = CircuitBuilder.create("zk-poseidon") + .secretVar("left") + .secretVar("right") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkPoseidon.hash(zk, ZkField.secret(c, "left"), ZkField.secret(c, "right")); + }); + + var signal = CircuitBuilder.create("signal-poseidon") + .secretVar("left") + .secretVar("right") + .defineSignals(c -> SignalPoseidon.hash(c, c.privateInput("left"), c.privateInput("right"))); + + var inputs = Map.of( + "left", List.of(BigInteger.valueOf(5)), + "right", List.of(BigInteger.valueOf(9))); + + assertEquals(signal.constraintGraph().gates().size(), symbolic.constraintGraph().gates().size()); + assertArrayEquals( + signal.calculateWitness(inputs, CurveId.BN254), + symbolic.calculateWitness(inputs, CurveId.BN254)); + } + + @Test + void merkleComputeRootMatchesSignalAdapterRootBehavior() { + var symbolic = CircuitBuilder.create("zk-merkle") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("sibling_1") + .secretVar("pathBit_0") + .secretVar("pathBit_1") + .publicVar("root") + .defineSignals(c -> { + var zk = new ZkContext(c); + var leaf = ZkField.secret(c, "leaf"); + var siblings = ZkArray.secretFields(c, "sibling", 2); + var pathBits = ZkArray.secretBools(c, "pathBit", 2); + ZkMerkle.computeRoot(zk, leaf, siblings, pathBits, ZkMiMC::hash) + .assertEqual(ZkField.publicInput(c, "root")); + }); + + var signal = CircuitBuilder.create("signal-merkle") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("sibling_1") + .secretVar("pathBit_0") + .secretVar("pathBit_1") + .publicVar("root") + .defineSignals(c -> { + Signal leaf = c.privateInput("leaf"); + Signal[] siblings = { + c.privateInput("sibling_0"), + c.privateInput("sibling_1") + }; + Signal[] pathBits = { + c.privateInput("pathBit_0"), + c.privateInput("pathBit_1") + }; + c.assertEqual( + SignalMerkle.computeRoot(c, leaf, siblings, pathBits, SignalMiMC::hash), + c.publicInput("root")); + }); + + var inputs = Map.of( + "leaf", List.of(BigInteger.valueOf(10)), + "sibling_0", List.of(BigInteger.valueOf(20)), + "sibling_1", List.of(BigInteger.valueOf(30)), + "pathBit_0", List.of(BigInteger.ZERO), + "pathBit_1", List.of(BigInteger.ONE), + "root", List.of(expectedMiMCMerkleRoot( + BigInteger.valueOf(10), + BigInteger.valueOf(20), + BigInteger.valueOf(30), + BigInteger.ZERO, + BigInteger.ONE))); + + assertDoesNotThrow(() -> signal.calculateWitness(inputs, CurveId.BN254)); + assertDoesNotThrow(() -> symbolic.calculateWitness(inputs, CurveId.BN254)); + + var invalid = Map.of( + "leaf", List.of(BigInteger.valueOf(10)), + "sibling_0", List.of(BigInteger.valueOf(20)), + "sibling_1", List.of(BigInteger.valueOf(30)), + "pathBit_0", List.of(BigInteger.ZERO), + "pathBit_1", List.of(BigInteger.ONE), + "root", List.of(BigInteger.ONE)); + assertThrows(ArithmeticException.class, () -> symbolic.calculateWitness(invalid, CurveId.BN254)); + assertThrows(ArithmeticException.class, () -> signal.calculateWitness(invalid, CurveId.BN254)); + } + + @Test + void poseidonExplicitParamsSupportBls12381() { + var circuit = CircuitBuilder.create("zk-poseidon-bls") + .secretVar("left") + .secretVar("right") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkPoseidon.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + ZkField.secret(c, "left"), + ZkField.secret(c, "right")); + }); + + assertDoesNotThrow(() -> circuit.compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); + } + + @Test + void merkleVerifySupportsCustomHashFunction() { + var circuit = CircuitBuilder.create("zk-merkle-verify") + .publicVar("root") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("sibling_1") + .secretVar("pathBit_0") + .secretVar("pathBit_1") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMerkle.verifyProof( + zk, + ZkField.secret(c, "leaf"), + ZkField.publicInput(c, "root"), + ZkArray.secretFields(c, "sibling", 2), + ZkArray.secretBools(c, "pathBit", 2), + (context, left, right) -> left.add(right)); + }); + + var valid = Map.of( + "root", List.of(BigInteger.valueOf(23)), + "leaf", List.of(BigInteger.valueOf(5)), + "sibling_0", List.of(BigInteger.valueOf(7)), + "sibling_1", List.of(BigInteger.valueOf(11)), + "pathBit_0", List.of(BigInteger.ZERO), + "pathBit_1", List.of(BigInteger.ONE)); + assertDoesNotThrow(() -> circuit.calculateWitness(valid, CurveId.BN254)); + + var invalidRoot = Map.of( + "root", List.of(BigInteger.valueOf(24)), + "leaf", List.of(BigInteger.valueOf(5)), + "sibling_0", List.of(BigInteger.valueOf(7)), + "sibling_1", List.of(BigInteger.valueOf(11)), + "pathBit_0", List.of(BigInteger.ZERO), + "pathBit_1", List.of(BigInteger.ONE)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(invalidRoot, CurveId.BN254)); + } + + @Test + void hashTypePoseidonAndIsMemberWorkTogether() { + BigInteger expectedRoot = PoseidonHash.hash( + PoseidonParamsBN254T3.INSTANCE, + BigInteger.valueOf(5), + BigInteger.valueOf(7)); + + var circuit = CircuitBuilder.create("zk-merkle-poseidon-member") + .publicVar("root") + .publicVar("ok") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("pathBit_0") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMerkle.isMember( + zk, + ZkField.secret(c, "leaf"), + ZkField.publicInput(c, "root"), + ZkArray.secretFields(c, "sibling", 1), + ZkArray.secretBools(c, "pathBit", 1), + ZkMerkle.HashType.POSEIDON) + .assertEqual(ZkBool.publicInput(c, "ok")); + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "root", List.of(expectedRoot), + "ok", List.of(BigInteger.ONE), + "leaf", List.of(BigInteger.valueOf(5)), + "sibling_0", List.of(BigInteger.valueOf(7)), + "pathBit_0", List.of(BigInteger.ZERO)), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "root", List.of(expectedRoot), + "ok", List.of(BigInteger.ZERO), + "leaf", List.of(BigInteger.valueOf(5)), + "sibling_0", List.of(BigInteger.valueOf(7)), + "pathBit_0", List.of(BigInteger.ZERO)), CurveId.BN254)); + } + + @Test + void mimcFieldGuardRejectsNonBn254Curve() { + var circuit = CircuitBuilder.create("zk-mimc-field") + .secretVar("left") + .secretVar("right") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMiMC.hash(zk, ZkField.secret(c, "left"), ZkField.secret(c, "right")); + }); + + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.calculateWitness(Map.of( + "left", List.of(BigInteger.ONE), + "right", List.of(BigInteger.TWO)), CurveId.BLS12_381)); + } + + @Test + void merkleRejectsMismatchedSiblingAndPathLengths() { + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-merkle-length") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("pathBit_0") + .secretVar("pathBit_1") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMerkle.computeRoot( + zk, + ZkField.secret(c, "leaf"), + ZkArray.secretFields(c, "sibling", 1), + ZkArray.secretBools(c, "pathBit", 2), + ZkMerkle.HashType.MIMC); + })); + } + + @Test + void adaptersRejectSignalsFromDifferentBuilders() { + ZkField[] fieldFromOtherBuilder = new ZkField[1]; + CircuitBuilder.create("zk-adapter-other") + .secretVar("left") + .defineSignals(c -> fieldFromOtherBuilder[0] = ZkField.secret(c, "left")); + + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-adapter-local") + .secretVar("right") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMiMC.hash(zk, fieldFromOtherBuilder[0], ZkField.secret(c, "right")); + })); + } + + @Test + void merkleRejectsRootFromDifferentBuilderBeforeAddingPathConstraints() { + ZkField[] rootFromOtherBuilder = new ZkField[1]; + CircuitBuilder.create("zk-merkle-other-root") + .publicVar("root") + .defineSignals(c -> rootFromOtherBuilder[0] = ZkField.publicInput(c, "root")); + + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-merkle-local-root") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("pathBit_0") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMerkle.verify( + zk, + ZkField.secret(c, "leaf"), + rootFromOtherBuilder[0], + ZkArray.secretFields(c, "sibling", 1), + ZkArray.secretBools(c, "pathBit", 1), + ZkMiMC::hash); + })); + } + + @Test + void merkleRejectsCustomHashReturningDifferentBuilderSignal() { + ZkField[] fieldFromOtherBuilder = new ZkField[1]; + CircuitBuilder.create("zk-merkle-other-hash") + .secretVar("hash") + .defineSignals(c -> fieldFromOtherBuilder[0] = ZkField.secret(c, "hash")); + + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-merkle-local-hash") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("pathBit_0") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMerkle.computeRoot( + zk, + ZkField.secret(c, "leaf"), + ZkArray.secretFields(c, "sibling", 1), + ZkArray.secretBools(c, "pathBit", 1), + (context, left, right) -> fieldFromOtherBuilder[0]); + })); + } + + private BigInteger expectedMiMCMerkleRoot( + BigInteger leaf, + BigInteger sibling0, + BigInteger sibling1, + BigInteger pathBit0, + BigInteger pathBit1) { + BigInteger current = leaf; + current = hashOrderedByPathBit(current, sibling0, pathBit0); + current = hashOrderedByPathBit(current, sibling1, pathBit1); + return current; + } + + private BigInteger hashOrderedByPathBit(BigInteger current, BigInteger sibling, BigInteger pathBit) { + if (BigInteger.ZERO.equals(pathBit)) { + return signalMiMCOffCircuit(current, sibling); + } + return signalMiMCOffCircuit(sibling, current); + } + + private BigInteger signalMiMCOffCircuit(BigInteger left, BigInteger right) { + var circuit = CircuitBuilder.create("zk-mimc-oracle") + .publicVar("hash") + .secretVar("left") + .secretVar("right") + .defineSignals(c -> c.assertEqual( + SignalMiMC.hash(c, c.privateInput("left"), c.privateInput("right")), + c.publicInput("hash"))); + + var noAssert = CircuitBuilder.create("zk-mimc-oracle-value") + .secretVar("left") + .secretVar("right") + .defineSignals(c -> SignalMiMC.hash(c, c.privateInput("left"), c.privateInput("right"))); + + var witness = noAssert.calculateWitness(Map.of( + "left", List.of(left), + "right", List.of(right)), CurveId.BN254); + BigInteger hash = witness[witness.length - 1]; + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "hash", List.of(hash), + "left", List.of(left), + "right", List.of(right)), CurveId.BN254)); + return hash; + } + +} From b033b0317f3bc5362871522f41333aa4d64cc615 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 02:02:24 +0800 Subject: [PATCH 05/26] phase 4: generate circuit companions --- docs/adr/circuit-annotation/README.md | 7 + .../circuit-annotation/implementation-plan.md | 4 +- .../phase-4-mvp-annotation-processor.md | 92 ++ zeroj-circuit-annotation-processor/README.md | 28 +- .../build.gradle | 2 + .../processor/CircuitAnnotationProcessor.java | 804 +++++++++++++++++- .../CircuitAnnotationProcessorTest.java | 655 ++++++++++++++ 7 files changed, 1573 insertions(+), 19 deletions(-) create mode 100644 docs/adr/circuit-annotation/phase-4-mvp-annotation-processor.md diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index fe60356..7c86591 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -1603,6 +1603,13 @@ Exit criteria: - processor rejects unsupported `boolean` and `BigInteger` proof methods - processor rejects invalid `@FixedSize(param = "...")` references +Implementation status as of Phase 4: completed. The processor generates +`build(...)` companions and constants for field-style, parameter-style, and +constructor-parameterized circuits. Schema and input-builder helpers remain +Phase 5 work. Phase 4 intentionally rejects unsupported source shapes such as +private proof methods, nested circuit classes, static proof methods with +field-style inputs, and `@CircuitParam` on proof parameters. + ### Phase 5: Generated Input Builders and Schema Estimated time: 3 to 5 days. diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index 0a8a4de..a29ae06 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -26,8 +26,8 @@ In progress. | 0 | Planning baseline | Completed | 823c422 | | 1 | Module scaffolding | Completed | 86f122c | | 2 | Symbolic foundation | Completed | bfe0b65 | -| 3 | MVP gadget adapters | Completed | Pending | -| 4 | MVP annotation processor | Pending | Pending | +| 3 | MVP gadget adapters | Completed | 7f49413 | +| 4 | MVP annotation processor | Completed | Pending hash | | 5 | Schema and input builders | Pending | Pending | | 6 | Examples and documentation | Pending | Pending | | 7 | Deferred bits and bytes | Pending | Pending | diff --git a/docs/adr/circuit-annotation/phase-4-mvp-annotation-processor.md b/docs/adr/circuit-annotation/phase-4-mvp-annotation-processor.md new file mode 100644 index 0000000..2b82609 --- /dev/null +++ b/docs/adr/circuit-annotation/phase-4-mvp-annotation-processor.md @@ -0,0 +1,92 @@ +# Phase 4: MVP Annotation Processor + +## Status + +Approved and completed for the Phase 4 commit. + +## Goal + +Generate the first usable `*Circuit` companion classes from annotated Java +source while still targeting the existing `CircuitBuilder` and `SignalBuilder` +pipeline. + +## Implemented Changes + +- Replaced the no-op processor with a real `@ZKCircuit` processor. +- Generated `*Circuit` companions with: + - `CIRCUIT_NAME` + - input-name constants + - `build(...)` +- Supported field-style annotated inputs. +- Supported parameter-style annotated inputs. +- Supported `ZkContext` proof parameters. +- Supported constructor `@CircuitParam` values. +- Supported `@FixedSize(param = "...")` for `ZkArray`. +- Supported `@Public`, `@Secret`, `@UInt`, `@FieldElement`, and `@Order`. +- Generated parameterized circuit names from `nameTemplate` or a canonical + suffix such as `circuit--depth-32`. +- Added compile-time diagnostics for unsupported `boolean` and `BigInteger` + proof methods. +- Added compile-time diagnostics for invalid Phase 4 shapes: + - private `@Prove` methods + - nested `@ZKCircuit` classes + - static `@Prove` methods with field-style inputs + - `@CircuitParam` on `@Prove` parameters + - invalid or duplicate constructor `@CircuitParam` names + - invalid `nameTemplate` placeholders + - invalid `@UInt` widths + - duplicate generated input names, constants, and flattened array names +- Allocated generated local names under an internal `__zeroj*` prefix so user + input names such as `c`, `zk`, `builder`, and `instance` do not collide with + generated source. +- Added compile tests that run javac with the processor, load generated + companions, build circuits, and calculate witnesses. +- Updated the processor README. + +## Public API Changes + +- Consumers can add the processor through Java annotation processing and call + generated `ExampleCircuit.build(...)` methods. +- Phase 4 generated companions do not expose schema or input-builder helpers; + those remain Phase 5 work. +- Array input names are generated from `@Public(name = "...")` or + `@Secret(name = "...")`; otherwise field/parameter names are singularized for + arrays, for example `siblings -> sibling` and `pathBits -> pathBit`. + +## Exit Criteria Mapping + +| ADR exit criterion | Phase 4 result | +|--------------------|----------------| +| Generated `RangeProofCircuit.build()` compiles | Covered by the field-style range compile test. | +| Generated `MerkleMembershipCircuit.build(32, POSEIDON)` compiles | Covered by the parameterized Merkle compile test using a concrete depth and `ZkMerkle.HashType`. | +| Generated circuits calculate witnesses successfully for valid inputs | Covered by range, age, and Merkle tests. | +| Generated circuits reject invalid inputs | Covered by range, age, and Merkle negative witness tests. | +| Processor rejects unsupported `boolean` and `BigInteger` proof methods | Covered by negative compile tests. | +| Processor rejects invalid `@FixedSize(param = "...")` references | Covered by a negative compile test. | +| Generated source remains valid for reserved-looking user input names | Covered by a parameter-style compile test using `c`, `zk`, `builder`, and `instance`. | + +## Verification + +- `./gradlew :zeroj-circuit-annotation-processor:test --tests com.bloxbean.cardano.zeroj.circuit.annotation.processor.CircuitAnnotationProcessorTest` passed. +- `./gradlew :zeroj-circuit-annotation-processor:test` passed. +- `./gradlew :zeroj-circuit-annotation-api:test :zeroj-circuit-lib:test --tests com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkGadgetAdaptersTest` passed. +- `./gradlew :zeroj-circuit-annotation-processor:test :zeroj-circuit-annotation-api:test :zeroj-circuit-lib:test --tests com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkGadgetAdaptersTest :zeroj-circuit-dsl:test` passed. +- `rg -n "[[:blank:]]$" docs/adr/circuit-annotation zeroj-circuit-annotation-processor` found no trailing whitespace. +- `git diff --cached --check` passed. + +## Review Results + +Approved after blocker fixes by three independent review tracks: + +- API/design review: approved after Phase 4 validation rules were tightened for + field/static style combinations, proof-parameter `@CircuitParam`, private + proof methods, nested circuit classes, and `ZkContext` annotation ordering. +- Correctness/security review: approved after generated constant names, + duplicate/unsafe circuit parameters, and generated local-name collisions were + fixed. +- Tests/docs/ergonomics review: approved after regression coverage was added for + each blocker and the focused processor test class passed with 18 tests. + +## Commit + +Pending final phase commit. diff --git a/zeroj-circuit-annotation-processor/README.md b/zeroj-circuit-annotation-processor/README.md index 38900c6..7cfa313 100644 --- a/zeroj-circuit-annotation-processor/README.md +++ b/zeroj-circuit-annotation-processor/README.md @@ -2,19 +2,29 @@ Compile-time annotation processor for annotation-based ZeroJ circuit authoring. -Current Phase 1 status: this module contains a registered no-op processor -placeholder. It does not yet scan annotations or generate source. +Current Phase 4 status: this module scans `@ZKCircuit` classes and generates +`*Circuit` companions with `build(...)` methods and input-name constants. -The processor will scan classes annotated with `@ZKCircuit` and generate -companion classes that build normal `CircuitBuilder` / `CircuitSpec` circuits. +The generated companions build normal `CircuitBuilder` / `CircuitSpec` +circuits. Schema and input-builder helpers are planned for Phase 5. -Current phase status: +Supported in Phase 4: -- the module is scaffolded -- the processor is registered with Java's service provider mechanism -- functional processing is intentionally deferred to Phase 4 of the ADR plan +- field-style and parameter-style `@Prove` methods +- `ZkContext` proof parameters +- constructor `@CircuitParam` values +- `@FixedSize(param = "...")` arrays +- `@Public`, `@Secret`, `@UInt`, `@FieldElement`, and `@Order` +- compile-time diagnostics for unsupported symbolic types and proof returns -Consumer usage after the API stabilizes: +Not supported in Phase 4: + +- nested `@ZKCircuit` classes +- private `@Prove` methods +- static `@Prove` methods with field-style inputs +- `@CircuitParam` on `@Prove` parameters + +Consumer usage: ```gradle dependencies { diff --git a/zeroj-circuit-annotation-processor/build.gradle b/zeroj-circuit-annotation-processor/build.gradle index e72ef3b..5c388ea 100644 --- a/zeroj-circuit-annotation-processor/build.gradle +++ b/zeroj-circuit-annotation-processor/build.gradle @@ -6,6 +6,8 @@ description = 'ZeroJ Circuit Annotation Processor — generates CircuitBuilder c dependencies { implementation project(':zeroj-circuit-annotation-api') + + testImplementation project(':zeroj-circuit-lib') } publishing { diff --git a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java index 43d0394..488aade 100644 --- a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java +++ b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java @@ -1,32 +1,820 @@ package com.bloxbean.cardano.zeroj.circuit.annotation.processor; +import com.bloxbean.cardano.zeroj.circuit.annotation.CircuitParam; +import com.bloxbean.cardano.zeroj.circuit.annotation.FieldElement; +import com.bloxbean.cardano.zeroj.circuit.annotation.FixedSize; +import com.bloxbean.cardano.zeroj.circuit.annotation.Order; +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.UInt; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; + import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.FilerException; import javax.annotation.processing.RoundEnvironment; import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.NestingKind; +import javax.lang.model.element.PackageElement; import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; +import javax.tools.JavaFileObject; +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; /** - * Service-registered annotation processor placeholder for circuit annotation - * code generation. - * - *

Functional processing is introduced in Phase 4. Until then, this processor - * deliberately claims no annotations and performs no source generation.

+ * Annotation processor that generates Phase 4 circuit companions. */ public final class CircuitAnnotationProcessor extends AbstractProcessor { + private static final String ANNOTATION_PKG = "com.bloxbean.cardano.zeroj.circuit.annotation."; + private static final String ZK_BOOL = ANNOTATION_PKG + "ZkBool"; + private static final String ZK_FIELD = ANNOTATION_PKG + "ZkField"; + private static final String ZK_UINT = ANNOTATION_PKG + "ZkUInt"; + private static final String ZK_ARRAY = ANNOTATION_PKG + "ZkArray"; + private static final String ZK_CONTEXT = ANNOTATION_PKG + "ZkContext"; @Override public Set getSupportedAnnotationTypes() { - return Set.of(); + return Set.of(ZKCircuit.class.getCanonicalName()); } @Override public SourceVersion getSupportedSourceVersion() { - return SourceVersion.latest(); + return SourceVersion.latestSupported(); } @Override public boolean process(Set annotations, RoundEnvironment roundEnv) { - return false; + for (Element element : roundEnv.getElementsAnnotatedWith(ZKCircuit.class)) { + if (element.getKind() != ElementKind.CLASS) { + error(element, "@ZKCircuit can only be applied to classes"); + continue; + } + try { + generate((TypeElement) element); + } catch (GenerationException e) { + error(e.element(), e.getMessage()); + } catch (IOException e) { + error(element, "Failed to generate circuit companion: " + e.getMessage()); + } + } + return true; + } + + private void generate(TypeElement sourceType) throws IOException { + if (sourceType.getNestingKind() != NestingKind.TOP_LEVEL) { + throw new GenerationException(sourceType, + "Nested @ZKCircuit classes are not supported in Phase 4"); + } + + List proveMethods = sourceType.getEnclosedElements().stream() + .filter(e -> e.getKind() == ElementKind.METHOD) + .map(ExecutableElement.class::cast) + .filter(m -> m.getAnnotation(Prove.class) != null) + .toList(); + if (proveMethods.size() != 1) { + throw new GenerationException(sourceType, "@ZKCircuit classes must declare exactly one @Prove method"); + } + + ExecutableElement proveMethod = proveMethods.get(0); + if (proveMethod.getModifiers().contains(Modifier.PRIVATE)) { + throw new GenerationException(proveMethod, + "@Prove method must be visible to generated code"); + } + validateReturnType(proveMethod); + + List circuitParams = circuitParams(sourceType, proveMethod); + Map circuitParamMap = circuitParams.stream() + .collect(Collectors.toMap(CircuitParamModel::name, p -> p)); + + List inputs = inputs(sourceType, proveMethod, circuitParamMap); + validateInputNames(inputs); + + String packageName = packageName(sourceType); + String sourceSimpleName = sourceType.getSimpleName().toString(); + String generatedSimpleName = sourceSimpleName + "Circuit"; + String generatedName = packageName.isEmpty() + ? generatedSimpleName + : packageName + "." + generatedSimpleName; + + String source = render(sourceType, packageName, sourceSimpleName, generatedSimpleName, + proveMethod, circuitParams, inputs); + try { + JavaFileObject file = processingEnv.getFiler().createSourceFile(generatedName, sourceType); + try (Writer writer = file.openWriter()) { + writer.write(source); + } + } catch (FilerException ignored) { + // Another round may have already created this file. + } + } + + private List circuitParams(TypeElement sourceType, ExecutableElement proveMethod) { + List constructors = sourceType.getEnclosedElements().stream() + .filter(e -> e.getKind() == ElementKind.CONSTRUCTOR) + .map(ExecutableElement.class::cast) + .filter(c -> c.getParameters().stream().anyMatch(p -> p.getAnnotation(CircuitParam.class) != null)) + .toList(); + + if (constructors.size() > 1) { + throw new GenerationException(sourceType, + "Only one constructor with @CircuitParam parameters is supported in Phase 4"); + } + if (constructors.isEmpty()) { + if (!proveMethod.getModifiers().contains(Modifier.STATIC)) { + requireVisibleNoArgConstructor(sourceType); + } + return List.of(); + } + + ExecutableElement constructor = constructors.get(0); + if (!proveMethod.getModifiers().contains(Modifier.STATIC) + && constructor.getModifiers().contains(Modifier.PRIVATE)) { + throw new GenerationException(constructor, "@CircuitParam constructor must be visible to generated code"); + } + + List params = new ArrayList<>(); + Set names = new HashSet<>(); + for (VariableElement parameter : constructor.getParameters()) { + CircuitParam annotation = parameter.getAnnotation(CircuitParam.class); + if (annotation == null) { + throw new GenerationException(parameter, + "All parameters in a @CircuitParam constructor must be annotated with @CircuitParam"); + } + String name = annotation.value().isBlank() + ? parameter.getSimpleName().toString() + : annotation.value(); + if (!isSimpleName(name)) { + throw new GenerationException(parameter, + "@CircuitParam name must match [A-Za-z_][A-Za-z0-9_]*"); + } + if (!names.add(name)) { + throw new GenerationException(parameter, "Duplicate @CircuitParam name: " + name); + } + params.add(new CircuitParamModel(name, parameter.getSimpleName().toString(), + parameter.asType().toString(), parameter.asType().getKind() == TypeKind.INT)); + } + return params; + } + + private void requireVisibleNoArgConstructor(TypeElement sourceType) { + List constructors = sourceType.getEnclosedElements().stream() + .filter(e -> e.getKind() == ElementKind.CONSTRUCTOR) + .map(ExecutableElement.class::cast) + .toList(); + if (constructors.isEmpty()) { + return; + } + boolean hasVisibleNoArg = constructors.stream() + .anyMatch(c -> c.getParameters().isEmpty() && !c.getModifiers().contains(Modifier.PRIVATE)); + if (!hasVisibleNoArg) { + throw new GenerationException(sourceType, + "A visible no-arg constructor is required unless the circuit uses a @CircuitParam constructor"); + } + } + + private List inputs(TypeElement sourceType, ExecutableElement proveMethod, + Map circuitParams) { + List fieldInputs = sourceType.getEnclosedElements().stream() + .filter(e -> e.getKind() == ElementKind.FIELD) + .map(VariableElement.class::cast) + .filter(f -> f.getAnnotation(Public.class) != null || f.getAnnotation(Secret.class) != null) + .map(f -> fieldInput(f, circuitParams)) + .toList(); + + List parameterInputs = new ArrayList<>(); + for (VariableElement parameter : proveMethod.getParameters()) { + if (parameter.getAnnotation(CircuitParam.class) != null) { + throw new GenerationException(parameter, + "@CircuitParam is only supported on constructors in Phase 4"); + } + if (isType(parameter.asType(), ZK_CONTEXT)) { + continue; + } + if (parameter.getAnnotation(Public.class) != null || parameter.getAnnotation(Secret.class) != null) { + parameterInputs.add(parameterInput(parameter, circuitParams)); + } + } + + if (!fieldInputs.isEmpty() && proveMethod.getModifiers().contains(Modifier.STATIC)) { + throw new GenerationException(proveMethod, + "Static @Prove methods must use parameter-style symbolic inputs in Phase 4"); + } + + if (!fieldInputs.isEmpty() && !parameterInputs.isEmpty()) { + throw new GenerationException(proveMethod, + "Mixing field-style and parameter-style symbolic inputs is not supported in Phase 4"); + } + + List inputs = fieldInputs.isEmpty() ? parameterInputs : fieldInputs; + if (inputs.isEmpty()) { + throw new GenerationException(proveMethod, "@Prove method or fields must declare at least one symbolic input"); + } + return orderInputs(inputs); + } + + private InputModel fieldInput(VariableElement field, Map circuitParams) { + if (field.getModifiers().contains(Modifier.PRIVATE)) { + throw new GenerationException(field, "Private symbolic input fields are not supported in Phase 4"); + } + if (field.getModifiers().contains(Modifier.FINAL)) { + throw new GenerationException(field, "Final symbolic input fields are not supported"); + } + return input(field, true, circuitParams); + } + + private InputModel parameterInput(VariableElement parameter, Map circuitParams) { + return input(parameter, false, circuitParams); + } + + private InputModel input(VariableElement element, boolean fieldStyle, + Map circuitParams) { + Public publicAnnotation = element.getAnnotation(Public.class); + Secret secretAnnotation = element.getAnnotation(Secret.class); + if ((publicAnnotation == null) == (secretAnnotation == null)) { + throw new GenerationException(element, "Exactly one of @Public or @Secret is required"); + } + + Visibility visibility = publicAnnotation != null ? Visibility.PUBLIC : Visibility.SECRET; + String explicitName = publicAnnotation != null ? publicAnnotation.name() : secretAnnotation.name(); + String javaName = element.getSimpleName().toString(); + ValueKind valueKind = valueKind(element); + String baseName = explicitName.isBlank() + ? (valueKind == ValueKind.ARRAY ? singularize(javaName) : javaName) + : explicitName; + + UInt uint = element.getAnnotation(UInt.class); + if ((valueKind == ValueKind.UINT || isArrayOf(element.asType(), ZK_UINT)) && uint == null) { + throw new GenerationException(element, "ZkUInt symbolic inputs require @UInt(bits = ...)"); + } + if (uint != null && valueKind != ValueKind.UINT && !isArrayOf(element.asType(), ZK_UINT)) { + throw new GenerationException(element, "@UInt can only be used with ZkUInt or ZkArray"); + } + if (element.getAnnotation(FieldElement.class) != null && valueKind != ValueKind.FIELD) { + throw new GenerationException(element, "@FieldElement can only be used with ZkField"); + } + + FixedSize fixedSize = element.getAnnotation(FixedSize.class); + SizeModel size = null; + if (valueKind == ValueKind.ARRAY) { + if (fixedSize == null) { + throw new GenerationException(element, "ZkArray symbolic inputs require @FixedSize"); + } + size = sizeModel(element, fixedSize, circuitParams); + } else if (fixedSize != null) { + throw new GenerationException(element, "@FixedSize can only be used with ZkArray"); + } + + Integer order = null; + Order orderAnnotation = element.getAnnotation(Order.class); + if (orderAnnotation != null) { + if (!fieldStyle) { + throw new GenerationException(element, "@Order is only supported on fields"); + } + if (orderAnnotation.value() < 0) { + throw new GenerationException(element, "@Order must be non-negative"); + } + order = orderAnnotation.value(); + } + + int bits = uint == null ? -1 : uint.bits(); + if (bits != -1 && (bits <= 0 || bits > ZkUInt.MAX_BITS)) { + throw new GenerationException(element, + "@UInt bits must be in [1, " + ZkUInt.MAX_BITS + "]"); + } + + return new InputModel(javaName, baseName, constName(baseName), visibility, valueKind, + elementType(element.asType()), bits, size, order, fieldStyle); + } + + private SizeModel sizeModel(VariableElement element, FixedSize fixedSize, + Map circuitParams) { + boolean hasLiteral = fixedSize.value() >= 0; + boolean hasParam = !fixedSize.param().isBlank(); + if (hasLiteral == hasParam) { + throw new GenerationException(element, + "@FixedSize requires exactly one of value or param"); + } + if (hasLiteral) { + if (fixedSize.value() <= 0) { + throw new GenerationException(element, "@FixedSize value must be positive"); + } + return new SizeModel(Integer.toString(fixedSize.value()), false); + } + + CircuitParamModel param = circuitParams.get(fixedSize.param()); + if (param == null || !param.intLike()) { + throw new GenerationException(element, + "@FixedSize(param = \"" + fixedSize.param() + + "\") must reference an integer @CircuitParam"); + } + return new SizeModel(param.javaName(), true); + } + + private List orderInputs(List inputs) { + for (Visibility visibility : Visibility.values()) { + Set orders = new HashSet<>(); + for (InputModel input : inputs) { + if (input.visibility() == visibility && input.order() != null && !orders.add(input.order())) { + throw new GenerationException(null, + "Duplicate @Order value " + input.order() + " for " + visibility.label() + " inputs"); + } + } + } + + Comparator comparator = Comparator + .comparing(InputModel::visibility) + .thenComparing(i -> i.order() == null ? 1 : 0) + .thenComparing(i -> i.order() == null ? Integer.MAX_VALUE : i.order()); + return inputs.stream().sorted(comparator).toList(); + } + + private void validateInputNames(List inputs) { + Set names = new HashSet<>(); + Set constantNames = new HashSet<>(); + for (InputModel input : inputs) { + if (!names.add(input.baseName())) { + throw new GenerationException(null, "Duplicate generated input name: " + input.baseName()); + } + if (!constantNames.add(input.constantName())) { + throw new GenerationException(null, "Duplicate generated input constant name: " + input.constantName()); + } + } + + Set flattenedNames = new HashSet<>(); + for (InputModel input : inputs) { + if (input.valueKind() == ValueKind.ARRAY) { + if (!input.size().fromCircuitParam()) { + int size = Integer.parseInt(input.size().expression()); + for (int i = 0; i < size; i++) { + addFlattenedName(flattenedNames, input.baseName() + "_" + i); + } + } + } else { + addFlattenedName(flattenedNames, input.baseName()); + } + } + + for (InputModel array : inputs.stream().filter(i -> i.valueKind() == ValueKind.ARRAY).toList()) { + for (InputModel scalar : inputs.stream().filter(i -> i.valueKind() != ValueKind.ARRAY).toList()) { + if (scalar.baseName().matches(java.util.regex.Pattern.quote(array.baseName()) + "_\\d+")) { + throw new GenerationException(null, + "Duplicate flattened input name may be generated from array base " + + array.baseName() + " and scalar " + scalar.baseName()); + } + } + } + } + + private void addFlattenedName(Set flattenedNames, String name) { + if (!flattenedNames.add(name)) { + throw new GenerationException(null, "Duplicate flattened input name: " + name); + } + } + + private void validateReturnType(ExecutableElement method) { + TypeMirror returnType = method.getReturnType(); + if (returnType.getKind() == TypeKind.VOID || isType(returnType, ZK_BOOL)) { + return; + } + if (returnType.getKind() == TypeKind.BOOLEAN + || returnType.toString().equals("java.lang.Boolean") + || returnType.toString().equals("java.math.BigInteger")) { + throw new GenerationException(method, + "Circuit proof methods must return ZkBool or void because circuit values are symbolic"); + } + throw new GenerationException(method, "@Prove method must return ZkBool or void"); + } + + private String render(TypeElement sourceType, String packageName, String sourceSimpleName, + String generatedSimpleName, ExecutableElement proveMethod, + List circuitParams, List inputs) { + ZKCircuit circuit = sourceType.getAnnotation(ZKCircuit.class); + String circuitName = !circuit.name().isBlank() ? circuit.name() : sourceSimpleName; + String nameTemplate = circuit.nameTemplate(); + boolean parameterized = !circuitParams.isEmpty(); + validateNameTemplate(circuitParams, nameTemplate); + boolean hasNameTemplate = parameterized && !nameTemplate.isBlank(); + boolean needsInstance = !proveMethod.getModifiers().contains(Modifier.STATIC) + || inputs.stream().anyMatch(InputModel::fieldStyle); + Set usedLocalNames = circuitParams.stream() + .map(CircuitParamModel::javaName) + .collect(Collectors.toCollection(HashSet::new)); + String builderLocal = uniqueLocalName("__zerojBuilder", usedLocalNames); + String instanceLocal = needsInstance ? uniqueLocalName("__zerojInstance", usedLocalNames) : ""; + String signalContextLocal = uniqueLocalName("__zerojSignals", usedLocalNames); + String zkLocal = uniqueLocalName("__zeroj", usedLocalNames); + String loopLocal = uniqueLocalName("__zerojIndex", usedLocalNames); + Map inputLocals = new java.util.LinkedHashMap<>(); + for (int i = 0; i < inputs.size(); i++) { + inputLocals.put(inputs.get(i), uniqueLocalName("__zerojInput" + i, usedLocalNames)); + } + + StringBuilder out = new StringBuilder(); + if (!packageName.isEmpty()) { + out.append("package ").append(packageName).append(";\n\n"); + } + out.append("import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder;\n") + .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray;\n") + .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool;\n") + .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext;\n") + .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField;\n") + .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt;\n\n") + .append("public final class ").append(generatedSimpleName).append(" {\n") + .append(" private ").append(generatedSimpleName).append("() {}\n\n") + .append(" public static final String CIRCUIT_NAME = ") + .append(stringLiteral(circuitName)).append(";\n"); + + if (hasNameTemplate) { + out.append(" public static final String CIRCUIT_NAME_TEMPLATE = ") + .append(stringLiteral(nameTemplate)).append(";\n"); + } + + for (InputModel input : inputs) { + out.append(" public static final String ").append(input.constantName()) + .append(" = ").append(stringLiteral(input.baseName())).append(";\n"); + } + out.append("\n"); + + out.append(" public static CircuitBuilder build(") + .append(renderParamSignature(circuitParams)) + .append(") {\n"); + if (needsInstance) { + out.append(" var ").append(instanceLocal).append(" = new ").append(sourceSimpleName).append("(") + .append(circuitParams.stream().map(CircuitParamModel::javaName).collect(Collectors.joining(", "))) + .append(");\n"); + } + out.append(" var ").append(builderLocal).append(" = CircuitBuilder.create(") + .append(parameterized ? "circuitName(" + renderParamNames(circuitParams) + ")" : "CIRCUIT_NAME") + .append(");\n"); + + for (InputModel input : inputs.stream().filter(i -> i.visibility() == Visibility.PUBLIC).toList()) { + renderVarDeclaration(out, input, builderLocal, loopLocal, "publicVar"); + } + for (InputModel input : inputs.stream().filter(i -> i.visibility() == Visibility.SECRET).toList()) { + renderVarDeclaration(out, input, builderLocal, loopLocal, "secretVar"); + } + + out.append(" return ").append(builderLocal).append(".defineSignals(") + .append(signalContextLocal).append(" -> {\n") + .append(" var ").append(zkLocal).append(" = new ZkContext(") + .append(signalContextLocal).append(");\n"); + + for (InputModel input : inputs) { + String inputLocal = inputLocals.get(input); + out.append(" var ").append(inputLocal).append(" = ") + .append(factoryCall(input, signalContextLocal)).append(";\n"); + if (input.fieldStyle()) { + out.append(" ").append(instanceLocal).append(".").append(input.javaName()) + .append(" = ").append(inputLocal).append(";\n"); + } + } + + String call = proveCall(sourceType, proveMethod, inputs, zkLocal, inputLocals, instanceLocal); + if (proveMethod.getReturnType().getKind() == TypeKind.VOID) { + out.append(" ").append(call).append(";\n"); + } else { + out.append(" ").append(call).append(".assertTrue();\n"); + } + out.append(" });\n") + .append(" }\n"); + + if (parameterized) { + Set circuitNameLocals = new HashSet<>( + circuitParams.stream().map(CircuitParamModel::javaName).toList()); + String nameLocal = uniqueLocalName("__zerojName", circuitNameLocals); + out.append("\n private static String circuitName(") + .append(renderParamSignature(circuitParams)) + .append(") {\n") + .append(" String ").append(nameLocal).append(" = ") + .append(hasNameTemplate ? "CIRCUIT_NAME_TEMPLATE" : "CIRCUIT_NAME") + .append(";\n"); + if (hasNameTemplate) { + for (CircuitParamModel param : circuitParams) { + out.append(" ").append(nameLocal).append(" = ").append(nameLocal) + .append(".replace(\"{").append(param.name()).append("}\", String.valueOf(") + .append(param.javaName()).append("));\n"); + } + } else { + out.append(" ").append(nameLocal).append(" = ").append(nameLocal).append(" + \"--\";\n"); + for (int i = 0; i < circuitParams.size(); i++) { + CircuitParamModel param = circuitParams.get(i); + if (i > 0) { + out.append(" ").append(nameLocal).append(" = ").append(nameLocal).append(" + \"--\";\n"); + } + out.append(" ").append(nameLocal).append(" = ").append(nameLocal) + .append(" + \"").append(param.name()).append("-\" + String.valueOf(") + .append(param.javaName()).append(");\n"); + } + } + out.append(" return ").append(nameLocal).append(";\n") + .append(" }\n"); + } + + out.append("}\n"); + return out.toString(); + } + + private void renderVarDeclaration(StringBuilder out, InputModel input, String builderLocal, + String loopLocal, String method) { + if (input.valueKind() == ValueKind.ARRAY) { + out.append(" for (int ").append(loopLocal).append(" = 0; ") + .append(loopLocal).append(" < ").append(input.size().expression()).append("; ") + .append(loopLocal).append("++) {\n") + .append(" ").append(builderLocal).append(".").append(method).append("(") + .append(input.constantName()).append(" + \"_\" + ").append(loopLocal).append(");\n") + .append(" }\n"); + } else { + out.append(" ").append(builderLocal).append(".").append(method).append("(") + .append(input.constantName()).append(");\n"); + } + } + + private String factoryCall(InputModel input, String signalContextLocal) { + String visibilityMethod = input.visibility() == Visibility.PUBLIC ? "publicInput" : "secret"; + if (input.valueKind() == ValueKind.FIELD) { + return "ZkField." + visibilityMethod + "(" + signalContextLocal + ", " + input.constantName() + ")"; + } + if (input.valueKind() == ValueKind.BOOL) { + return "ZkBool." + visibilityMethod + "(" + signalContextLocal + ", " + input.constantName() + ")"; + } + if (input.valueKind() == ValueKind.UINT) { + return "ZkUInt." + visibilityMethod + "(" + signalContextLocal + ", " + + input.constantName() + ", " + input.bits() + ")"; + } + if (input.arrayElementType().equals(ZK_FIELD)) { + return "ZkArray." + (input.visibility() == Visibility.PUBLIC ? "publicFields" : "secretFields") + + "(" + signalContextLocal + ", " + input.constantName() + ", " + + input.size().expression() + ")"; + } + if (input.arrayElementType().equals(ZK_BOOL)) { + return "ZkArray." + (input.visibility() == Visibility.PUBLIC ? "publicBools" : "secretBools") + + "(" + signalContextLocal + ", " + input.constantName() + ", " + + input.size().expression() + ")"; + } + if (input.arrayElementType().equals(ZK_UINT)) { + return "ZkArray." + (input.visibility() == Visibility.PUBLIC ? "publicUInts" : "secretUInts") + + "(" + signalContextLocal + ", " + input.constantName() + ", " + + input.size().expression() + ", " + input.bits() + ")"; + } + throw new GenerationException(null, "Unsupported ZkArray element type: " + input.arrayElementType()); + } + + private String proveCall(TypeElement sourceType, ExecutableElement proveMethod, List inputs, + String zkLocal, Map inputLocals, String instanceLocal) { + String receiver = proveMethod.getModifiers().contains(Modifier.STATIC) + ? sourceType.getQualifiedName().toString() + : instanceLocal; + Map byJavaName = inputs.stream() + .collect(Collectors.toMap(InputModel::javaName, i -> i)); + + List args = new ArrayList<>(); + for (VariableElement parameter : proveMethod.getParameters()) { + if (parameter.getAnnotation(CircuitParam.class) != null) { + CircuitParam annotation = parameter.getAnnotation(CircuitParam.class); + args.add(annotation.value().isBlank() ? parameter.getSimpleName().toString() : annotation.value()); + } else if (isType(parameter.asType(), ZK_CONTEXT)) { + args.add(zkLocal); + } else { + InputModel input = byJavaName.get(parameter.getSimpleName().toString()); + if (input == null) { + throw new GenerationException(parameter, + "@Prove parameters must be ZkContext, @CircuitParam, or symbolic inputs"); + } + args.add(inputLocals.get(input)); + } + } + return receiver + "." + proveMethod.getSimpleName() + "(" + String.join(", ", args) + ")"; + } + + private void validateNameTemplate(List circuitParams, String nameTemplate) { + if (circuitParams.isEmpty() || nameTemplate.isBlank()) { + return; + } + + Set placeholders = new HashSet<>(); + int index = 0; + while (index < nameTemplate.length()) { + int start = nameTemplate.indexOf('{', index); + if (start < 0) { + break; + } + int end = nameTemplate.indexOf('}', start + 1); + if (end < 0) { + throw new GenerationException(null, "Unclosed nameTemplate placeholder"); + } + placeholders.add(nameTemplate.substring(start + 1, end)); + index = end + 1; + } + + Set paramNames = circuitParams.stream() + .map(CircuitParamModel::name) + .collect(Collectors.toSet()); + for (String placeholder : placeholders) { + if (!paramNames.contains(placeholder)) { + throw new GenerationException(null, + "nameTemplate references unknown @CircuitParam: " + placeholder); + } + } + for (String paramName : paramNames) { + if (!placeholders.contains(paramName)) { + throw new GenerationException(null, + "nameTemplate must include @CircuitParam {" + paramName + "}"); + } + } + } + + private ValueKind valueKind(VariableElement element) { + TypeMirror type = element.asType(); + if (isType(type, ZK_FIELD)) return ValueKind.FIELD; + if (isType(type, ZK_BOOL)) return ValueKind.BOOL; + if (isType(type, ZK_UINT)) return ValueKind.UINT; + if (isType(type, ZK_ARRAY)) return ValueKind.ARRAY; + if (type.toString().equals("java.math.BigInteger") || type.getKind() == TypeKind.BOOLEAN) { + throw new GenerationException(element, + "Symbolic inputs must use ZkField, ZkBool, ZkUInt, or ZkArray, not " + type); + } + throw new GenerationException(element, "Unsupported symbolic input type: " + type); + } + + private String elementType(TypeMirror type) { + if (!isType(type, ZK_ARRAY)) { + return ""; + } + DeclaredType declared = (DeclaredType) type; + if (declared.getTypeArguments().size() != 1) { + throw new GenerationException(null, "ZkArray must declare exactly one element type"); + } + return erasure(declared.getTypeArguments().get(0)); + } + + private boolean isArrayOf(TypeMirror type, String elementType) { + return isType(type, ZK_ARRAY) && elementType(type).equals(elementType); + } + + private boolean isType(TypeMirror type, String qualifiedName) { + return erasure(type).equals(qualifiedName); + } + + private String erasure(TypeMirror type) { + return processingEnv.getTypeUtils().erasure(type).toString(); + } + + private String renderParamSignature(List params) { + return params.stream() + .map(p -> p.type() + " " + p.javaName()) + .collect(Collectors.joining(", ")); + } + + private String renderParamNames(List params) { + return params.stream().map(CircuitParamModel::javaName).collect(Collectors.joining(", ")); + } + + private String uniqueLocalName(String base, Set usedNames) { + String candidate = base; + int suffix = 0; + while (!usedNames.add(candidate)) { + suffix++; + candidate = base + "_" + suffix; + } + return candidate; + } + + private String packageName(TypeElement sourceType) { + PackageElement packageElement = processingEnv.getElementUtils().getPackageOf(sourceType); + return packageElement == null || packageElement.isUnnamed() + ? "" + : packageElement.getQualifiedName().toString(); + } + + private String singularize(String name) { + if (name.endsWith("ies") && name.length() > 3) { + return name.substring(0, name.length() - 3) + "y"; + } + if (name.endsWith("s") && name.length() > 1) { + return name.substring(0, name.length() - 1); + } + return name; + } + + private String constName(String name) { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (Character.isUpperCase(c) && i > 0) { + out.append('_'); + } else if (!Character.isLetterOrDigit(c)) { + out.append('_'); + continue; + } + out.append(Character.toUpperCase(c)); + } + String constantName = out.toString().toUpperCase(Locale.ROOT); + if (constantName.isEmpty() + || "_".equals(constantName) + || !Character.isJavaIdentifierStart(constantName.charAt(0))) { + constantName = "INPUT_" + constantName; + } + return constantName; + } + + private String stringLiteral(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private boolean isSimpleName(String value) { + if (value.isEmpty()) { + return false; + } + char first = value.charAt(0); + if (!((first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z') || first == '_')) { + return false; + } + for (int i = 1; i < value.length(); i++) { + char c = value.charAt(i); + if (!((c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || c == '_')) { + return false; + } + } + return true; + } + + private void error(Element element, String message) { + if (element == null) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, message); + } else { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, message, element); + } + } + + private enum Visibility { + PUBLIC("public"), + SECRET("secret"); + + private final String label; + + Visibility(String label) { + this.label = label; + } + + String label() { + return label; + } + } + + private enum ValueKind { + FIELD, + BOOL, + UINT, + ARRAY + } + + private record CircuitParamModel(String name, String javaName, String type, boolean intLike) {} + + private record SizeModel(String expression, boolean fromCircuitParam) {} + + private record InputModel( + String javaName, + String baseName, + String constantName, + Visibility visibility, + ValueKind valueKind, + String arrayElementType, + int bits, + SizeModel size, + Integer order, + boolean fieldStyle) {} + + private static final class GenerationException extends RuntimeException { + private final Element element; + + private GenerationException(Element element, String message) { + super(message); + this.element = element; + } + + private Element element() { + return element; + } } } diff --git a/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java index 841108e..3bc2d40 100644 --- a/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java +++ b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java @@ -1,14 +1,42 @@ package com.bloxbean.cardano.zeroj.circuit.annotation.processor; +import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBN254T3; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMerkle; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import javax.annotation.processing.Processor; +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; +import java.math.BigInteger; +import java.net.URI; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; import java.util.ServiceLoader; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class CircuitAnnotationProcessorTest { + @TempDir + Path tempDir; + @Test void processorIsServiceRegistered() { var processors = ServiceLoader.load(Processor.class); @@ -17,4 +45,631 @@ void processorIsServiceRegistered() { .anyMatch(CircuitAnnotationProcessor.class::equals), "CircuitAnnotationProcessor should be discoverable via ServiceLoader"); } + + @Test + void fieldStyleRangeProofGeneratesBuildAndRejectsInvalidWitness() throws Exception { + var compilation = compile("test.RangeProof", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "range-proof") + public class RangeProof { + @Secret @UInt(bits = 8) + ZkUInt secret; + + @Public @UInt(bits = 8) + ZkUInt lo; + + @Public @UInt(bits = 8) + ZkUInt hi; + + @Prove + ZkBool inRange() { + return secret.gte(lo).and(secret.lte(hi)); + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + assertTrue(compilation.generatedSource("test/RangeProofCircuit.java") + .contains("public static final String SECRET = \"secret\";")); + + Class companion = compilation.load("test.RangeProofCircuit"); + assertEquals("range-proof", companion.getField("CIRCUIT_NAME").get(null)); + CircuitBuilder circuit = (CircuitBuilder) companion.getMethod("build").invoke(null); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "secret", List.of(BigInteger.valueOf(42)), + "lo", List.of(BigInteger.valueOf(18)), + "hi", List.of(BigInteger.valueOf(99))), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "secret", List.of(BigInteger.valueOf(7)), + "lo", List.of(BigInteger.valueOf(18)), + "hi", List.of(BigInteger.valueOf(99))), CurveId.BN254)); + } + + @Test + void parameterStyleRangeProofGeneratesBuild() throws Exception { + var compilation = compile("test.AgeProof", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "age-proof") + public class AgeProof { + @Prove + ZkBool prove( + @Secret @UInt(bits = 8) ZkUInt age, + @Public @UInt(bits = 8) ZkUInt threshold) { + return age.gte(threshold); + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + CircuitBuilder circuit = (CircuitBuilder) compilation.load("test.AgeProofCircuit") + .getMethod("build") + .invoke(null); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "age", List.of(BigInteger.valueOf(25)), + "threshold", List.of(BigInteger.valueOf(18))), CurveId.BN254)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "age", List.of(BigInteger.valueOf(15)), + "threshold", List.of(BigInteger.valueOf(18))), CurveId.BN254)); + } + + @Test + void parameterStyleInputNamesDoNotCollideWithGeneratedLocals() throws Exception { + var compilation = compile("test.ReservedNames", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "reserved-names") + public class ReservedNames { + public ReservedNames(@CircuitParam("depth") int __zerojBuilder) {} + + @Prove + ZkBool prove( + @Public ZkBool c, + @Secret ZkBool zk, + @Secret ZkBool builder, + @Secret ZkBool instance) { + return c.and(zk).and(builder).and(instance); + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + CircuitBuilder circuit = (CircuitBuilder) compilation.load("test.ReservedNamesCircuit") + .getMethod("build", int.class) + .invoke(null, 4); + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "c", List.of(BigInteger.ONE), + "zk", List.of(BigInteger.ONE), + "builder", List.of(BigInteger.ONE), + "instance", List.of(BigInteger.ONE)), CurveId.BN254)); + } + + @Test + void parameterizedMerkleMembershipGeneratesBuildWithCircuitParams() throws Exception { + var compilation = compile("test.MerkleMembership", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMerkle; + + @ZKCircuit(name = "membership", nameTemplate = "membership-d{depth}-{hashType}") + public class MerkleMembership { + private final int depth; + private final ZkMerkle.HashType hashType; + + public MerkleMembership( + @CircuitParam("depth") int depth, + @CircuitParam("hashType") ZkMerkle.HashType hashType) { + this.depth = depth; + this.hashType = hashType; + } + + @Prove + ZkBool prove( + ZkContext zk, + @Secret ZkField leaf, + @Public ZkField root, + @Secret @FixedSize(param = "depth") ZkArray siblings, + @Secret @FixedSize(param = "depth") ZkArray pathBits) { + return ZkMerkle.isMember(zk, leaf, root, siblings, pathBits, hashType); + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + Class companion = compilation.load("test.MerkleMembershipCircuit"); + CircuitBuilder circuit = (CircuitBuilder) companion + .getMethod("build", int.class, ZkMerkle.HashType.class) + .invoke(null, 1, ZkMerkle.HashType.POSEIDON); + assertEquals("membership-d1-POSEIDON", circuit.constraintGraph().name()); + + BigInteger root = PoseidonHash.hash( + PoseidonParamsBN254T3.INSTANCE, + BigInteger.valueOf(5), + BigInteger.valueOf(7)); + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "leaf", List.of(BigInteger.valueOf(5)), + "root", List.of(root), + "sibling_0", List.of(BigInteger.valueOf(7)), + "pathBit_0", List.of(BigInteger.ZERO)), CurveId.BN254)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "leaf", List.of(BigInteger.valueOf(5)), + "root", List.of(BigInteger.ONE), + "sibling_0", List.of(BigInteger.valueOf(7)), + "pathBit_0", List.of(BigInteger.ZERO)), CurveId.BN254)); + } + + @Test + void parameterizedCircuitWithoutNameTemplateUsesCanonicalSuffix() throws Exception { + var compilation = compile("test.ParamStatic", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "param-static") + public class ParamStatic { + private ParamStatic() {} + + public ParamStatic(@CircuitParam("depth") int depth) {} + + @Prove + static ZkBool prove(@Secret ZkBool ok) { + return ok; + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + CircuitBuilder depth2 = (CircuitBuilder) compilation.load("test.ParamStaticCircuit") + .getMethod("build", int.class) + .invoke(null, 2); + CircuitBuilder depth3 = (CircuitBuilder) compilation.load("test.ParamStaticCircuit") + .getMethod("build", int.class) + .invoke(null, 3); + assertEquals("param-static--depth-2", depth2.constraintGraph().name()); + assertEquals("param-static--depth-3", depth3.constraintGraph().name()); + } + + @Test + void staticProveDoesNotRequireVisibleNoArgConstructor() throws Exception { + var compilation = compile("test.StaticProof", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "static-proof") + public class StaticProof { + private StaticProof() {} + + @Prove + static ZkBool prove(@Public ZkBool ok) { + return ok; + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + CircuitBuilder circuit = (CircuitBuilder) compilation.load("test.StaticProofCircuit") + .getMethod("build") + .invoke(null); + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of("ok", List.of(BigInteger.ONE)), CurveId.BN254)); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(Map.of("ok", List.of(BigInteger.ZERO)), CurveId.BN254)); + } + + @Test + void rejectsStaticProveWithFieldStyleInputs() throws Exception { + var compilation = compile("test.StaticFieldStyle", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class StaticFieldStyle { + @Public + ZkBool ok; + + @Prove + static ZkBool prove() { + return null; + } + } + """); + + assertFalse(compilation.success()); + assertTrue(compilation.diagnosticsText() + .contains("Static @Prove methods must use parameter-style symbolic inputs")); + } + + @Test + void rejectsPrivateProveMethods() throws Exception { + var compilation = compile("test.PrivateProof", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class PrivateProof { + @Prove + private ZkBool prove(@Public ZkBool ok) { + return ok; + } + } + """); + + assertFalse(compilation.success()); + assertTrue(compilation.diagnosticsText().contains("@Prove method must be visible to generated code")); + } + + @Test + void rejectsNestedCircuitClassesInPhase4() throws Exception { + var compilation = compile("test.OuterCircuitHolder", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + public class OuterCircuitHolder { + @ZKCircuit + public static class InnerProof { + @Prove + ZkBool prove(@Public ZkBool ok) { + return ok; + } + } + } + """); + + assertFalse(compilation.success()); + assertTrue(compilation.diagnosticsText() + .contains("Nested @ZKCircuit classes are not supported in Phase 4")); + } + + @Test + void rejectsUnsupportedBooleanAndBigIntegerProofMethods() throws Exception { + var booleanCompilation = compile("test.BooleanProof", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class BooleanProof { + @Prove + boolean prove() { + return true; + } + } + """); + assertFalse(booleanCompilation.success()); + assertTrue(booleanCompilation.diagnosticsText().contains("must return ZkBool or void")); + + var bigIntegerCompilation = compile("test.BigIntegerProof", """ + package test; + + import java.math.BigInteger; + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class BigIntegerProof { + @Prove + BigInteger prove() { + return BigInteger.ONE; + } + } + """); + assertFalse(bigIntegerCompilation.success()); + assertTrue(bigIntegerCompilation.diagnosticsText().contains("must return ZkBool or void")); + } + + @Test + void rejectsInvalidFixedSizeParamReferences() throws Exception { + var compilation = compile("test.BadMerkle", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class BadMerkle { + @Prove + ZkBool prove( + @Secret @FixedSize(param = "depth") ZkArray siblings, + @Public ZkBool ok) { + return ok; + } + } + """); + + assertFalse(compilation.success()); + assertTrue(compilation.diagnosticsText().contains("must reference an integer @CircuitParam")); + } + + @Test + void rejectsCircuitParamOnProveParametersInPhase4() throws Exception { + var compilation = compile("test.ProveParam", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class ProveParam { + @Prove + ZkBool prove(@CircuitParam("depth") int depth, @Public ZkBool ok) { + return ok; + } + } + """); + + assertFalse(compilation.success()); + assertTrue(compilation.diagnosticsText() + .contains("@CircuitParam is only supported on constructors in Phase 4")); + + var contextCompilation = compile("test.ProveContextParam", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class ProveContextParam { + @Prove + ZkBool prove(@CircuitParam("zk") ZkContext zk, @Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(contextCompilation.success()); + assertTrue(contextCompilation.diagnosticsText() + .contains("@CircuitParam is only supported on constructors in Phase 4")); + } + + @Test + void rejectsDuplicateAndUnsafeCircuitParamNames() throws Exception { + var duplicate = compile("test.DuplicateCircuitParam", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class DuplicateCircuitParam { + public DuplicateCircuitParam( + @CircuitParam("depth") int first, + @CircuitParam("depth") int second) {} + + @Prove + ZkBool prove(@Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(duplicate.success()); + assertTrue(duplicate.diagnosticsText().contains("Duplicate @CircuitParam name: depth")); + + var unsafe = compile("test.UnsafeCircuitParam", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class UnsafeCircuitParam { + public UnsafeCircuitParam(@CircuitParam("bad-name") int depth) {} + + @Prove + ZkBool prove(@Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(unsafe.success()); + assertTrue(unsafe.diagnosticsText().contains("@CircuitParam name must match [A-Za-z_][A-Za-z0-9_]*")); + } + + @Test + void rejectsUIntWidthsAboveSymbolicLimit() throws Exception { + var compilation = compile("test.BadUInt", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class BadUInt { + @Prove + ZkBool prove(@Secret @UInt(bits = 254) ZkUInt value) { + return value.gte(value); + } + } + """); + + assertFalse(compilation.success()); + assertTrue(compilation.diagnosticsText().contains("@UInt bits must be in [1, 253]")); + } + + @Test + void rejectsDuplicateFlattenedAndConstantNames() throws Exception { + var flattened = compile("test.DuplicateFlattened", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class DuplicateFlattened { + @Prove + ZkBool prove( + @Secret @FixedSize(1) ZkArray siblings, + @Secret(name = "sibling_0") ZkField sibling0, + @Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(flattened.success()); + assertTrue(flattened.diagnosticsText().contains("Duplicate flattened input")); + + var constants = compile("test.DuplicateConstants", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class DuplicateConstants { + @Prove + ZkBool prove( + @Secret(name = "a-b") ZkField a, + @Secret(name = "a_b") ZkField b, + @Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(constants.success()); + assertTrue(constants.diagnosticsText().contains("Duplicate generated input constant name")); + } + + @Test + void sanitizesGeneratedInputConstantsThatWouldNotBeJavaIdentifiers() throws Exception { + var compilation = compile("test.OddInputNames", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class OddInputNames { + @Prove + ZkBool prove( + @Public(name = "1") ZkField one, + @Secret(name = "-") ZkField dash, + @Public ZkBool ok) { + return ok; + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + String generated = compilation.generatedSource("test/OddInputNamesCircuit.java"); + assertTrue(generated.contains("public static final String INPUT_1 = \"1\";")); + assertTrue(generated.contains("public static final String INPUT__ = \"-\";")); + + CircuitBuilder circuit = (CircuitBuilder) compilation.load("test.OddInputNamesCircuit") + .getMethod("build") + .invoke(null); + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "1", List.of(BigInteger.ONE), + "-", List.of(BigInteger.TEN), + "ok", List.of(BigInteger.ONE)), CurveId.BN254)); + } + + @Test + void rejectsNameTemplateThatOmitsOrReferencesUnknownParams() throws Exception { + var missing = compile("test.MissingTemplateParam", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(nameTemplate = "missing-{depth}") + public class MissingTemplateParam { + public MissingTemplateParam( + @CircuitParam("depth") int depth, + @CircuitParam("hashType") String hashType) {} + + @Prove + ZkBool prove(@Secret ZkBool ok) { + return ok; + } + } + """); + assertFalse(missing.success()); + assertTrue(missing.diagnosticsText().contains("must include @CircuitParam {hashType}")); + + var unknown = compile("test.UnknownTemplateParam", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(nameTemplate = "unknown-{depth}-{other}") + public class UnknownTemplateParam { + public UnknownTemplateParam(@CircuitParam("depth") int depth) {} + + @Prove + ZkBool prove(@Secret ZkBool ok) { + return ok; + } + } + """); + assertFalse(unknown.success()); + assertTrue(unknown.diagnosticsText().contains("references unknown @CircuitParam: other")); + } + + private Compilation compile(String className, String source) throws Exception { + var compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "Tests require a JDK, not a JRE"); + + Path classesDir = Files.createDirectories(tempDir.resolve(className.replace('.', '_') + "_classes")); + Path sourcesDir = Files.createDirectories(tempDir.resolve(className.replace('.', '_') + "_generated")); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager( + diagnostics, null, StandardCharsets.UTF_8)) { + List options = List.of( + "-classpath", System.getProperty("java.class.path"), + "-processor", CircuitAnnotationProcessor.class.getName(), + "-d", classesDir.toString(), + "-s", sourcesDir.toString()); + + Boolean ok = compiler.getTask( + null, + fileManager, + diagnostics, + options, + null, + List.of(new SourceFile(className, source))).call(); + return new Compilation(Boolean.TRUE.equals(ok), diagnostics, classesDir, sourcesDir); + } + } + + private record Compilation( + boolean success, + DiagnosticCollector diagnostics, + Path classesDir, + Path sourcesDir) { + + String diagnosticsText() { + return diagnostics.getDiagnostics().stream() + .map(d -> d.getKind() + ": " + d.getMessage(null)) + .reduce("", (a, b) -> a + b + "\n"); + } + + String generatedSource(String relativePath) throws Exception { + return Files.readString(sourcesDir.resolve(relativePath)); + } + + Class load(String className) throws Exception { + URLClassLoader loader = new URLClassLoader( + new java.net.URL[]{classesDir.toUri().toURL()}, + CircuitAnnotationProcessorTest.class.getClassLoader()); + return Class.forName(className, true, loader); + } + } + + private static final class SourceFile extends SimpleJavaFileObject { + private final String source; + + private SourceFile(String className, String source) { + super(URI.create("string:///" + className.replace('.', '/') + JavaFileObject.Kind.SOURCE.extension), + JavaFileObject.Kind.SOURCE); + this.source = source; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return source; + } + } + } From c7b24b81b0652dafe7df60babaa03c3e0e53686e Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 02:22:29 +0800 Subject: [PATCH 06/26] phase 5: add generated schema and input builders --- .../circuit-annotation/implementation-plan.md | 4 +- .../phase-5-schema-and-input-builders.md | 87 ++++++++ zeroj-circuit-annotation-api/README.md | 29 ++- .../circuit/annotation/ZkCircuitSchema.java | 129 +++++++++++ .../zeroj/circuit/annotation/ZkInputMap.java | 55 +++++ .../annotation/ZkSymbolicTypesTest.java | 46 ++++ zeroj-circuit-annotation-processor/README.md | 8 +- .../processor/CircuitAnnotationProcessor.java | 205 +++++++++++++++++- .../CircuitAnnotationProcessorTest.java | 189 ++++++++++++++++ 9 files changed, 731 insertions(+), 21 deletions(-) create mode 100644 docs/adr/circuit-annotation/phase-5-schema-and-input-builders.md create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index a29ae06..d81889b 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -27,8 +27,8 @@ In progress. | 1 | Module scaffolding | Completed | 86f122c | | 2 | Symbolic foundation | Completed | bfe0b65 | | 3 | MVP gadget adapters | Completed | 7f49413 | -| 4 | MVP annotation processor | Completed | Pending hash | -| 5 | Schema and input builders | Pending | Pending | +| 4 | MVP annotation processor | Completed | b033b03 | +| 5 | Schema and input builders | Completed | Pending hash | | 6 | Examples and documentation | Pending | Pending | | 7 | Deferred bits and bytes | Pending | Pending | | 8 | Advanced gadget adapters | Pending | Pending | diff --git a/docs/adr/circuit-annotation/phase-5-schema-and-input-builders.md b/docs/adr/circuit-annotation/phase-5-schema-and-input-builders.md new file mode 100644 index 0000000..247a180 --- /dev/null +++ b/docs/adr/circuit-annotation/phase-5-schema-and-input-builders.md @@ -0,0 +1,87 @@ +# Phase 5: Schema and Input Builders + +## Status + +Approved and completed for the Phase 5 commit. + +## Goal + +Add the generated metadata and witness-building helpers that make annotated +circuits practical to test and use without hand-written +`Map>` values. + +## Planned Changes + +- Added runtime schema types to `zeroj-circuit-annotation-api`. +- Added a small runtime input-map helper to preserve witness map insertion order + and public input extraction. +- Generated `schema(...)` companions for concrete circuit shapes. +- Generated `inputs(...)` builders for scalar and fixed-size array inputs. +- Generated `publicInputs(Inputs)` as a convenience wrapper over the generated + input builder. +- Recorded constructor `@CircuitParam` values in schema metadata. +- Preserved deterministic public and secret input ordering from Phase 4. +- Rejected ambiguous array base names that overlap flattened input names. +- Hardened generated string literal escaping for unusual annotation values. +- Avoided generating scalar `wait(long)` setters, which would collide with + final `Object.wait(long)`. +- Allowed `@FixedSize(param = "...")` to reference primitive `int` and boxed + `Integer` constructor `@CircuitParam` values. + +## Public Surface + +For a non-parameterized circuit: + +```java +var schema = RangeProofCircuit.schema(); +var inputs = RangeProofCircuit.inputs() + .secret(BigInteger.valueOf(42)) + .lo(BigInteger.valueOf(18)) + .hi(BigInteger.valueOf(99)); + +circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254); +inputs.publicValues(); +``` + +For a parameterized circuit: + +```java +var schema = MerkleMembershipCircuit.schema(32, ZkMerkle.HashType.POSEIDON); +var inputs = MerkleMembershipCircuit.inputs(32, ZkMerkle.HashType.POSEIDON); +``` + +## Exit Criteria + +- Generated `schema(...)` exposes stable circuit name, parameters, public input + order, secret input order, bit widths, array sizes, and flattened signal + names. +- Generated input builders produce witness maps accepted by + `CircuitBuilder.calculateWitness(...)`. +- Generated input builders expose public values in schema order. +- Parameterized circuits produce schema metadata for the concrete parameter + values. +- Processor compile tests cover scalar and array input builders. + +## Verification + +- `./gradlew :zeroj-circuit-annotation-api:test :zeroj-circuit-annotation-processor:test --tests com.bloxbean.cardano.zeroj.circuit.annotation.processor.CircuitAnnotationProcessorTest` passed. +- `./gradlew :zeroj-circuit-annotation-api:test :zeroj-circuit-annotation-processor:test :zeroj-circuit-lib:test --tests com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkGadgetAdaptersTest :zeroj-circuit-dsl:test` passed. +- `rg -n "[[:blank:]]$" docs/adr/circuit-annotation zeroj-circuit-annotation-api zeroj-circuit-annotation-processor` found no trailing whitespace. +- `git diff --cached --check` passed. + +## Review Results + +Approved after blocker fixes by three independent review tracks: + +- API/design review: approved with no blockers after schema and input helper + APIs were verified against the ADR. +- Correctness/security review: approved after fixes for generated string + literal escaping, array base-name ambiguity, exact schema lookup precedence, + and scalar `wait(long)` generation. +- Tests/docs/ergonomics review: approved after regression coverage was added + for exact schema lookup, flattened-name overlap rejection, generated array + message escaping, and generated input builders. + +## Commit + +Pending final phase commit. diff --git a/zeroj-circuit-annotation-api/README.md b/zeroj-circuit-annotation-api/README.md index 917f39c..dd5b4f9 100644 --- a/zeroj-circuit-annotation-api/README.md +++ b/zeroj-circuit-annotation-api/README.md @@ -2,8 +2,8 @@ Public API for annotation-based ZeroJ circuit authoring. -Current Phase 2 status: this module exposes the foundational annotations and -symbolic `Zk*` types used by manual symbolic circuits and later generated +Current Phase 5 status: this module exposes the foundational annotations, +symbolic `Zk*` types, and runtime schema/input helpers used by generated companions. This module contains: @@ -11,8 +11,8 @@ This module contains: - circuit annotations such as `@ZKCircuit`, `@Prove`, `@Public`, `@Secret`, `@CircuitParam`, `@UInt`, `@FieldElement`, `@FixedSize`, and `@Order` - symbolic circuit value types: `ZkField`, `ZkBool`, `ZkUInt`, and `ZkArray` - -Schema and input binding support is planned for later phases. +- generated-circuit metadata and witness helpers: `ZkCircuitSchema` and + `ZkInputMap` The API is intentionally layered on top of `zeroj-circuit-dsl`. It does not replace `CircuitSpec`, `SignalBuilder`, or the existing circuit library. @@ -30,7 +30,20 @@ var circuit = CircuitBuilder.create("range") }); ``` -Important Phase 2 API rules: +Generated companions use the schema/input helpers like this: + +```java +var schema = RangeProofCircuit.schema(); +var inputs = RangeProofCircuit.inputs() + .secret(BigInteger.valueOf(42)) + .lo(BigInteger.valueOf(18)) + .hi(BigInteger.valueOf(99)); + +var witness = circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254); +var publicValues = inputs.publicValues(); +``` + +Important API rules: - `ZkBool.publicInput` / `secret` add boolean constraints eagerly. - `ZkUInt.publicInput` / `secret` add range constraints eagerly. @@ -41,9 +54,9 @@ Important Phase 2 API rules: `publicBools`, and `publicUInts` encode visibility for built-in element types. `ZkArray.bind` is for custom symbolic types. - `wrap(...)` rejects signals from a different `SignalBuilder`. - -Annotation processing and generated companion classes are deferred to later -phases; this module only provides the public API foundation. +- `ZkCircuitSchema.publicInputs().names()` and + `ZkCircuitSchema.secretInputs().names()` expose flattened input order. +- `ZkInputMap.publicValues(schema)` extracts public values in schema order. See [docs/adr/circuit-annotation/README.md](../docs/adr/circuit-annotation/README.md) for the accepted implementation plan. diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java new file mode 100644 index 0000000..2cb8c7a --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java @@ -0,0 +1,129 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Runtime metadata for a generated annotated circuit shape. + */ +public record ZkCircuitSchema( + String name, + List parameters, + InputGroup publicInputs, + InputGroup secretInputs) { + + public ZkCircuitSchema { + Objects.requireNonNull(name, "name"); + parameters = List.copyOf(Objects.requireNonNull(parameters, "parameters")); + Objects.requireNonNull(publicInputs, "publicInputs"); + Objects.requireNonNull(secretInputs, "secretInputs"); + } + + public static ZkCircuitSchema of( + String name, + List parameters, + List publicInputs, + List secretInputs) { + return new ZkCircuitSchema( + name, + parameters, + new InputGroup(publicInputs), + new InputGroup(secretInputs)); + } + + public List inputs() { + var inputs = new ArrayList(publicInputs.inputs().size() + secretInputs.inputs().size()); + inputs.addAll(publicInputs.inputs()); + inputs.addAll(secretInputs.inputs()); + return List.copyOf(inputs); + } + + public Optional findInput(String name) { + Objects.requireNonNull(name, "name"); + var exact = inputs().stream() + .filter(input -> input.name().equals(name)) + .findFirst(); + if (exact.isPresent()) { + return exact; + } + return inputs().stream() + .filter(input -> input.signalNames().contains(name)) + .findFirst(); + } + + public Input input(String name) { + return findInput(name) + .orElseThrow(() -> new IllegalArgumentException("Unknown circuit input: " + name)); + } + + public record Parameter(String name, String type, String value) { + public Parameter { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(value, "value"); + } + } + + public record InputGroup(List inputs) { + public InputGroup { + inputs = List.copyOf(Objects.requireNonNull(inputs, "inputs")); + } + + public List names() { + return inputs.stream() + .flatMap(input -> input.signalNames().stream()) + .toList(); + } + } + + public record Input( + String name, + Visibility visibility, + Kind kind, + int bits, + int size, + boolean array, + List signalNames) { + + public Input { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(visibility, "visibility"); + Objects.requireNonNull(kind, "kind"); + if (bits < -1) { + throw new IllegalArgumentException("bits must be -1 or positive"); + } + if (size <= 0) { + throw new IllegalArgumentException("size must be positive"); + } + signalNames = List.copyOf(Objects.requireNonNull(signalNames, "signalNames")); + if (signalNames.size() != size) { + throw new IllegalArgumentException("signalNames size must match input size"); + } + } + + public static Input scalar(String name, Visibility visibility, Kind kind, int bits) { + return new Input(name, visibility, kind, bits, 1, false, List.of(name)); + } + + public static Input array(String name, Visibility visibility, Kind kind, int bits, int size) { + var names = new ArrayList(size); + for (int i = 0; i < size; i++) { + names.add(name + "_" + i); + } + return new Input(name, visibility, kind, bits, size, true, names); + } + } + + public enum Visibility { + PUBLIC, + SECRET + } + + public enum Kind { + FIELD, + BOOL, + UINT + } +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java new file mode 100644 index 0000000..1e51f92 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java @@ -0,0 +1,55 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Ordered witness input accumulator used by generated input builders. + */ +public final class ZkInputMap { + private final Map> values = new LinkedHashMap<>(); + + public ZkInputMap put(String name, BigInteger value) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(value, "value"); + values.put(name, List.of(value)); + return this; + } + + public ZkInputMap put(String name, long value) { + return put(name, BigInteger.valueOf(value)); + } + + public ZkInputMap putArray(String baseName, List inputValues) { + Objects.requireNonNull(baseName, "baseName"); + Objects.requireNonNull(inputValues, "inputValues"); + for (int i = 0; i < inputValues.size(); i++) { + put(baseName + "_" + i, inputValues.get(i)); + } + return this; + } + + public Map> toWitnessMap() { + var copy = new LinkedHashMap>(); + values.forEach((name, value) -> copy.put(name, List.copyOf(value))); + return Collections.unmodifiableMap(copy); + } + + public List publicValues(ZkCircuitSchema schema) { + Objects.requireNonNull(schema, "schema"); + var out = new ArrayList(); + for (String name : schema.publicInputs().names()) { + var value = values.get(name); + if (value == null || value.isEmpty()) { + throw new IllegalStateException("Missing public input: " + name); + } + out.add(value.getFirst()); + } + return List.copyOf(out); + } +} diff --git a/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java b/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java index 35d85fe..108bc3e 100644 --- a/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java +++ b/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java @@ -237,6 +237,52 @@ void merkleShapedInputsSupportFieldSiblingsAndBooleanPathBits() { "pathBit_1", List.of(BigInteger.ONE)), CurveId.BN254)); } + @Test + void schemaExposesStableNamesAndInputMapPublicValues() { + var schema = ZkCircuitSchema.of( + "schema-test", + List.of(new ZkCircuitSchema.Parameter("depth", "int", "2")), + List.of( + ZkCircuitSchema.Input.scalar( + "root", ZkCircuitSchema.Visibility.PUBLIC, ZkCircuitSchema.Kind.FIELD, -1), + ZkCircuitSchema.Input.array( + "pathBit", ZkCircuitSchema.Visibility.PUBLIC, ZkCircuitSchema.Kind.BOOL, 1, 2)), + List.of(ZkCircuitSchema.Input.array( + "sibling", ZkCircuitSchema.Visibility.SECRET, ZkCircuitSchema.Kind.FIELD, -1, 2))); + + assertEquals("schema-test", schema.name()); + assertEquals(List.of("root", "pathBit_0", "pathBit_1"), schema.publicInputs().names()); + assertEquals(List.of("sibling_0", "sibling_1"), schema.secretInputs().names()); + assertEquals(2, schema.input("sibling").size()); + assertEquals(ZkCircuitSchema.Kind.FIELD, schema.input("sibling_0").kind()); + assertEquals("2", schema.parameters().getFirst().value()); + + var inputs = new ZkInputMap() + .put("root", BigInteger.valueOf(10)) + .putArray("pathBit", List.of(BigInteger.ZERO, BigInteger.ONE)) + .putArray("sibling", List.of(BigInteger.valueOf(20), BigInteger.valueOf(30))); + + assertEquals(List.of(BigInteger.valueOf(10), BigInteger.ZERO, BigInteger.ONE), + inputs.publicValues(schema)); + assertEquals(BigInteger.valueOf(30), inputs.toWitnessMap().get("sibling_1").getFirst()); + } + + @Test + void schemaLookupPrefersExactInputNamesBeforeFlattenedNames() { + var schema = ZkCircuitSchema.of( + "schema-overlap", + List.of(), + List.of( + ZkCircuitSchema.Input.array( + "a", ZkCircuitSchema.Visibility.PUBLIC, ZkCircuitSchema.Kind.FIELD, -1, 2), + ZkCircuitSchema.Input.array( + "a_0", ZkCircuitSchema.Visibility.PUBLIC, ZkCircuitSchema.Kind.FIELD, -1, 1)), + List.of()); + + assertEquals("a_0", schema.input("a_0").name()); + assertEquals(List.of("a_0_0"), schema.input("a_0").signalNames()); + } + @Test void symbolicCircuitCompilesToAllBackends() { var circuit = CircuitBuilder.create("zk-compile") diff --git a/zeroj-circuit-annotation-processor/README.md b/zeroj-circuit-annotation-processor/README.md index 7cfa313..ea51c6f 100644 --- a/zeroj-circuit-annotation-processor/README.md +++ b/zeroj-circuit-annotation-processor/README.md @@ -2,11 +2,12 @@ Compile-time annotation processor for annotation-based ZeroJ circuit authoring. -Current Phase 4 status: this module scans `@ZKCircuit` classes and generates -`*Circuit` companions with `build(...)` methods and input-name constants. +Current Phase 5 status: this module scans `@ZKCircuit` classes and generates +`*Circuit` companions with `build(...)`, `schema(...)`, `inputs(...)`, +`publicInputs(...)`, and input-name constants. The generated companions build normal `CircuitBuilder` / `CircuitSpec` -circuits. Schema and input-builder helpers are planned for Phase 5. +circuits and produce ordinary witness maps for `calculateWitness(...)`. Supported in Phase 4: @@ -15,6 +16,7 @@ Supported in Phase 4: - constructor `@CircuitParam` values - `@FixedSize(param = "...")` arrays - `@Public`, `@Secret`, `@UInt`, `@FieldElement`, and `@Order` +- generated schema metadata and input builders - compile-time diagnostics for unsupported symbolic types and proof returns Not supported in Phase 4: diff --git a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java index 488aade..0cd2bfa 100644 --- a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java +++ b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java @@ -169,7 +169,7 @@ private List circuitParams(TypeElement sourceType, Executable throw new GenerationException(parameter, "Duplicate @CircuitParam name: " + name); } params.add(new CircuitParamModel(name, parameter.getSimpleName().toString(), - parameter.asType().toString(), parameter.asType().getKind() == TypeKind.INT)); + parameter.asType().toString(), isIntegerCircuitParam(parameter.asType()))); } return params; } @@ -359,34 +359,45 @@ private void validateInputNames(List inputs) { } Set flattenedNames = new HashSet<>(); + Map flattenedOwners = new java.util.HashMap<>(); for (InputModel input : inputs) { if (input.valueKind() == ValueKind.ARRAY) { if (!input.size().fromCircuitParam()) { int size = Integer.parseInt(input.size().expression()); for (int i = 0; i < size; i++) { - addFlattenedName(flattenedNames, input.baseName() + "_" + i); + addFlattenedName(flattenedNames, flattenedOwners, input, input.baseName() + "_" + i); } } } else { - addFlattenedName(flattenedNames, input.baseName()); + addFlattenedName(flattenedNames, flattenedOwners, input, input.baseName()); + } + } + + for (InputModel input : inputs) { + InputModel owner = flattenedOwners.get(input.baseName()); + if (owner != null && owner != input) { + throw new GenerationException(null, + "Input base name overlaps a flattened input name: " + input.baseName()); } } for (InputModel array : inputs.stream().filter(i -> i.valueKind() == ValueKind.ARRAY).toList()) { - for (InputModel scalar : inputs.stream().filter(i -> i.valueKind() != ValueKind.ARRAY).toList()) { - if (scalar.baseName().matches(java.util.regex.Pattern.quote(array.baseName()) + "_\\d+")) { + for (InputModel other : inputs.stream().filter(i -> i != array).toList()) { + if (other.baseName().matches(java.util.regex.Pattern.quote(array.baseName()) + "_\\d+")) { throw new GenerationException(null, "Duplicate flattened input name may be generated from array base " - + array.baseName() + " and scalar " + scalar.baseName()); + + array.baseName() + " and input " + other.baseName()); } } } } - private void addFlattenedName(Set flattenedNames, String name) { + private void addFlattenedName(Set flattenedNames, Map flattenedOwners, + InputModel input, String name) { if (!flattenedNames.add(name)) { throw new GenerationException(null, "Duplicate flattened input name: " + name); } + flattenedOwners.put(name, input); } private void validateReturnType(ExecutableElement method) { @@ -434,9 +445,14 @@ private String render(TypeElement sourceType, String packageName, String sourceS out.append("import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool;\n") + .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkCircuitSchema;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField;\n") + .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkInputMap;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt;\n\n") + .append("import java.math.BigInteger;\n") + .append("import java.util.List;\n") + .append("import java.util.Map;\n\n") .append("public final class ").append(generatedSimpleName).append(" {\n") .append(" private ").append(generatedSimpleName).append("() {}\n\n") .append(" public static final String CIRCUIT_NAME = ") @@ -496,6 +512,8 @@ private String render(TypeElement sourceType, String packageName, String sourceS out.append(" });\n") .append(" }\n"); + renderSchemaAndInputs(out, circuitParams, inputs, parameterized); + if (parameterized) { Set circuitNameLocals = new HashSet<>( circuitParams.stream().map(CircuitParamModel::javaName).toList()); @@ -532,6 +550,159 @@ private String render(TypeElement sourceType, String packageName, String sourceS return out.toString(); } + private void renderSchemaAndInputs(StringBuilder out, List circuitParams, + List inputs, boolean parameterized) { + out.append("\n public static ZkCircuitSchema schema(") + .append(renderParamSignature(circuitParams)) + .append(") {\n") + .append(" return ZkCircuitSchema.of(") + .append(parameterized ? "circuitName(" + renderParamNames(circuitParams) + ")" : "CIRCUIT_NAME") + .append(",\n") + .append(" ").append(renderParameterSchemaList(circuitParams)).append(",\n") + .append(" ").append(renderInputSchemaList(inputs, Visibility.PUBLIC)).append(",\n") + .append(" ").append(renderInputSchemaList(inputs, Visibility.SECRET)).append(");\n") + .append(" }\n\n") + .append(" public static Inputs inputs(") + .append(renderParamSignature(circuitParams)) + .append(") {\n") + .append(" return new Inputs(schema(") + .append(renderParamNames(circuitParams)) + .append("));\n") + .append(" }\n\n") + .append(" public static List publicInputs(Inputs inputs) {\n") + .append(" return inputs.publicValues();\n") + .append(" }\n"); + + renderInputsClass(out, inputs); + } + + private String renderParameterSchemaList(List circuitParams) { + if (circuitParams.isEmpty()) { + return "List.of()"; + } + return circuitParams.stream() + .map(param -> "new ZkCircuitSchema.Parameter(" + + stringLiteral(param.name()) + ", " + + stringLiteral(param.type()) + ", " + + "String.valueOf(" + param.javaName() + "))") + .collect(Collectors.joining(",\n ", "List.of(", ")")); + } + + private String renderInputSchemaList(List inputs, Visibility visibility) { + List selected = inputs.stream() + .filter(input -> input.visibility() == visibility) + .toList(); + if (selected.isEmpty()) { + return "List.of()"; + } + return selected.stream() + .map(this::renderInputSchema) + .collect(Collectors.joining(",\n ", "List.of(", ")")); + } + + private String renderInputSchema(InputModel input) { + String prefix = input.valueKind() == ValueKind.ARRAY + ? "ZkCircuitSchema.Input.array(" + : "ZkCircuitSchema.Input.scalar("; + String size = input.valueKind() == ValueKind.ARRAY ? ", " + input.size().expression() : ""; + return prefix + + input.constantName() + + ", ZkCircuitSchema.Visibility." + input.visibility().name() + + ", ZkCircuitSchema.Kind." + schemaKind(input) + + ", " + schemaBits(input) + + size + + ")"; + } + + private void renderInputsClass(StringBuilder out, List inputs) { + out.append("\n public static final class Inputs {\n") + .append(" private final ZkCircuitSchema __zerojSchema;\n") + .append(" private final ZkInputMap __zerojInputs = new ZkInputMap();\n\n") + .append(" private Inputs(ZkCircuitSchema __zerojSchema) {\n") + .append(" this.__zerojSchema = __zerojSchema;\n") + .append(" }\n\n") + .append(" public ZkCircuitSchema schema() {\n") + .append(" return __zerojSchema;\n") + .append(" }\n\n"); + + for (InputModel input : inputs) { + if (input.valueKind() == ValueKind.ARRAY) { + renderArrayInputMethods(out, input); + } else { + renderScalarInputMethods(out, input); + } + } + + out.append(" public Map> toWitnessMap() {\n") + .append(" return __zerojInputs.toWitnessMap();\n") + .append(" }\n\n") + .append(" public List publicValues() {\n") + .append(" return __zerojInputs.publicValues(__zerojSchema);\n") + .append(" }\n") + .append(" }\n"); + } + + private void renderScalarInputMethods(StringBuilder out, InputModel input) { + out.append(" public Inputs ").append(input.javaName()).append("(BigInteger value) {\n") + .append(" __zerojInputs.put(").append(input.constantName()).append(", value);\n") + .append(" return this;\n") + .append(" }\n\n"); + if (!"wait".equals(input.javaName())) { + out.append(" public Inputs ").append(input.javaName()).append("(long value) {\n") + .append(" return ").append(input.javaName()).append("(BigInteger.valueOf(value));\n") + .append(" }\n\n"); + } + } + + private void renderArrayInputMethods(StringBuilder out, InputModel input) { + String sizeExpression = "__zerojSchema.input(" + input.constantName() + ").size()"; + out.append(" public Inputs ").append(input.javaName()).append("(int index, BigInteger value) {\n") + .append(" if (index < 0 || index >= ").append(sizeExpression).append(") {\n") + .append(" throw new IllegalArgumentException(") + .append(stringLiteral("index out of bounds for " + input.baseName())).append(");\n") + .append(" }\n") + .append(" __zerojInputs.put(").append(input.constantName()) + .append(" + \"_\" + index, value);\n") + .append(" return this;\n") + .append(" }\n\n") + .append(" public Inputs ").append(input.javaName()).append("(int index, long value) {\n") + .append(" return ").append(input.javaName()) + .append("(index, BigInteger.valueOf(value));\n") + .append(" }\n\n") + .append(" public Inputs ").append(input.javaName()).append("(List values) {\n") + .append(" if (values.size() != ").append(sizeExpression).append(") {\n") + .append(" throw new IllegalArgumentException(") + .append(stringLiteral(input.baseName() + " expects ")).append(" + ").append(sizeExpression) + .append(" + \" values\");\n") + .append(" }\n") + .append(" __zerojInputs.putArray(").append(input.constantName()).append(", values);\n") + .append(" return this;\n") + .append(" }\n\n"); + } + + private String schemaKind(InputModel input) { + if (input.valueKind() == ValueKind.FIELD || input.arrayElementType().equals(ZK_FIELD)) { + return "FIELD"; + } + if (input.valueKind() == ValueKind.BOOL || input.arrayElementType().equals(ZK_BOOL)) { + return "BOOL"; + } + if (input.valueKind() == ValueKind.UINT || input.arrayElementType().equals(ZK_UINT)) { + return "UINT"; + } + throw new GenerationException(null, "Unsupported schema input type: " + input.valueKind()); + } + + private int schemaBits(InputModel input) { + if (input.valueKind() == ValueKind.UINT || input.arrayElementType().equals(ZK_UINT)) { + return input.bits(); + } + if (input.valueKind() == ValueKind.BOOL || input.arrayElementType().equals(ZK_BOOL)) { + return 1; + } + return -1; + } + private void renderVarDeclaration(StringBuilder out, InputModel input, String builderLocal, String loopLocal, String method) { if (input.valueKind() == ValueKind.ARRAY) { @@ -673,6 +844,10 @@ private boolean isType(TypeMirror type, String qualifiedName) { return erasure(type).equals(qualifiedName); } + private boolean isIntegerCircuitParam(TypeMirror type) { + return type.getKind() == TypeKind.INT || erasure(type).equals("java.lang.Integer"); + } + private String erasure(TypeMirror type) { return processingEnv.getTypeUtils().erasure(type).toString(); } @@ -736,7 +911,21 @@ private String constName(String name) { } private String stringLiteral(String value) { - return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + StringBuilder out = new StringBuilder("\""); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '\\' -> out.append("\\\\"); + case '"' -> out.append("\\\""); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + default -> out.append(c); + } + } + return out.append("\"").toString(); } private boolean isSimpleName(String value) { diff --git a/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java index 3bc2d40..33987bc 100644 --- a/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java +++ b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java @@ -2,6 +2,7 @@ import com.bloxbean.cardano.zeroj.api.CurveId; import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkCircuitSchema; import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBN254T3; import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMerkle; @@ -78,6 +79,11 @@ ZkBool inRange() { Class companion = compilation.load("test.RangeProofCircuit"); assertEquals("range-proof", companion.getField("CIRCUIT_NAME").get(null)); CircuitBuilder circuit = (CircuitBuilder) companion.getMethod("build").invoke(null); + ZkCircuitSchema schema = (ZkCircuitSchema) companion.getMethod("schema").invoke(null); + assertEquals("range-proof", schema.name()); + assertEquals(List.of("lo", "hi"), schema.publicInputs().names()); + assertEquals(List.of("secret"), schema.secretInputs().names()); + assertEquals(8, schema.input("secret").bits()); assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( "secret", List.of(BigInteger.valueOf(42)), @@ -88,6 +94,17 @@ ZkBool inRange() { "secret", List.of(BigInteger.valueOf(7)), "lo", List.of(BigInteger.valueOf(18)), "hi", List.of(BigInteger.valueOf(99))), CurveId.BN254)); + + Object inputs = companion.getMethod("inputs").invoke(null); + inputs.getClass().getMethod("secret", BigInteger.class).invoke(inputs, BigInteger.valueOf(42)); + inputs.getClass().getMethod("lo", long.class).invoke(inputs, 18L); + inputs.getClass().getMethod("hi", BigInteger.class).invoke(inputs, BigInteger.valueOf(99)); + @SuppressWarnings("unchecked") + Map> witnessMap = + (Map>) inputs.getClass().getMethod("toWitnessMap").invoke(inputs); + assertDoesNotThrow(() -> circuit.calculateWitness(witnessMap, CurveId.BN254)); + assertEquals(List.of(BigInteger.valueOf(18), BigInteger.valueOf(99)), + inputs.getClass().getMethod("publicValues").invoke(inputs)); } @Test @@ -154,6 +171,37 @@ ZkBool prove( "instance", List.of(BigInteger.ONE)), CurveId.BN254)); } + @Test + void scalarInputNamedWaitDoesNotGenerateFinalObjectWaitOverride() throws Exception { + var compilation = compile("test.WaitInput", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "wait-input") + public class WaitInput { + @Prove + ZkBool prove(@Public ZkBool wait) { + return wait; + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + String generated = compilation.generatedSource("test/WaitInputCircuit.java"); + assertTrue(generated.contains("public Inputs wait(BigInteger value)")); + assertFalse(generated.contains("public Inputs wait(long value)")); + + Class companion = compilation.load("test.WaitInputCircuit"); + Object inputs = companion.getMethod("inputs").invoke(null); + inputs.getClass().getMethod("wait", BigInteger.class).invoke(inputs, BigInteger.ONE); + CircuitBuilder circuit = (CircuitBuilder) companion.getMethod("build").invoke(null); + @SuppressWarnings("unchecked") + Map> witness = + (Map>) inputs.getClass().getMethod("toWitnessMap").invoke(inputs); + assertDoesNotThrow(() -> circuit.calculateWitness(witness, CurveId.BN254)); + } + @Test void parameterizedMerkleMembershipGeneratesBuildWithCircuitParams() throws Exception { var compilation = compile("test.MerkleMembership", """ @@ -192,6 +240,17 @@ ZkBool prove( .getMethod("build", int.class, ZkMerkle.HashType.class) .invoke(null, 1, ZkMerkle.HashType.POSEIDON); assertEquals("membership-d1-POSEIDON", circuit.constraintGraph().name()); + ZkCircuitSchema schema = (ZkCircuitSchema) companion + .getMethod("schema", int.class, ZkMerkle.HashType.class) + .invoke(null, 1, ZkMerkle.HashType.POSEIDON); + assertEquals("membership-d1-POSEIDON", schema.name()); + assertEquals("depth", schema.parameters().get(0).name()); + assertEquals("1", schema.parameters().get(0).value()); + assertEquals("POSEIDON", schema.parameters().get(1).value()); + assertEquals(List.of("root"), schema.publicInputs().names()); + assertEquals(List.of("leaf", "sibling_0", "pathBit_0"), schema.secretInputs().names()); + assertEquals(1, schema.input("sibling").size()); + assertTrue(schema.input("pathBit_0").array()); BigInteger root = PoseidonHash.hash( PoseidonParamsBN254T3.INSTANCE, @@ -207,6 +266,65 @@ ZkBool prove( "root", List.of(BigInteger.ONE), "sibling_0", List.of(BigInteger.valueOf(7)), "pathBit_0", List.of(BigInteger.ZERO)), CurveId.BN254)); + + Object inputs = companion + .getMethod("inputs", int.class, ZkMerkle.HashType.class) + .invoke(null, 1, ZkMerkle.HashType.POSEIDON); + inputs.getClass().getMethod("leaf", BigInteger.class).invoke(inputs, BigInteger.valueOf(5)); + inputs.getClass().getMethod("root", BigInteger.class).invoke(inputs, root); + inputs.getClass().getMethod("siblings", List.class).invoke(inputs, List.of(BigInteger.valueOf(7))); + inputs.getClass().getMethod("pathBits", int.class, long.class).invoke(inputs, 0, 0L); + @SuppressWarnings("unchecked") + Map> generatedWitness = + (Map>) inputs.getClass().getMethod("toWitnessMap").invoke(inputs); + assertDoesNotThrow(() -> circuit.calculateWitness(generatedWitness, CurveId.BN254)); + assertEquals(List.of(root), companion.getMethod("publicInputs", inputs.getClass()).invoke(null, inputs)); + + Object badInputs = companion + .getMethod("inputs", int.class, ZkMerkle.HashType.class) + .invoke(null, 1, ZkMerkle.HashType.POSEIDON); + var ex = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> badInputs.getClass().getMethod("siblings", List.class) + .invoke(badInputs, List.of(BigInteger.valueOf(7), BigInteger.valueOf(8)))); + assertTrue(ex.getCause() instanceof IllegalArgumentException); + } + + @Test + void fixedSizeParamCanReferenceBoxedIntegerCircuitParam() throws Exception { + var compilation = compile("test.BoxedDepth", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "boxed-depth") + public class BoxedDepth { + public BoxedDepth(@CircuitParam("depth") Integer depth) {} + + @Prove + ZkBool prove( + @Secret @FixedSize(param = "depth") ZkArray siblings, + @Public ZkBool ok) { + return ok; + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + Class companion = compilation.load("test.BoxedDepthCircuit"); + ZkCircuitSchema schema = (ZkCircuitSchema) companion + .getMethod("schema", Integer.class) + .invoke(null, 2); + assertEquals(List.of("sibling_0", "sibling_1"), schema.secretInputs().names()); + + Object inputs = companion.getMethod("inputs", Integer.class).invoke(null, 2); + inputs.getClass().getMethod("siblings", List.class) + .invoke(inputs, List.of(BigInteger.ONE, BigInteger.TWO)); + inputs.getClass().getMethod("ok", long.class).invoke(inputs, 1L); + @SuppressWarnings("unchecked") + Map> witness = + (Map>) inputs.getClass().getMethod("toWitnessMap").invoke(inputs); + CircuitBuilder circuit = (CircuitBuilder) companion.getMethod("build", Integer.class).invoke(null, 2); + assertDoesNotThrow(() -> circuit.calculateWitness(witness, CurveId.BN254)); } @Test @@ -532,6 +650,49 @@ ZkBool prove( assertTrue(constants.diagnosticsText().contains("Duplicate generated input constant name")); } + @Test + void rejectsArrayBaseNamesThatOverlapFlattenedInputNames() throws Exception { + var literal = compile("test.OverlappingArrayBase", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class OverlappingArrayBase { + @Prove + ZkBool prove( + @Secret(name = "item") @FixedSize(2) ZkArray items, + @Secret(name = "item_0") @FixedSize(1) ZkArray itemZero, + @Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(literal.success()); + assertTrue(literal.diagnosticsText().contains("Input base name overlaps a flattened input name")); + + var parameterized = compile("test.OverlappingParamArrayBase", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class OverlappingParamArrayBase { + public OverlappingParamArrayBase(@CircuitParam("depth") int depth) {} + + @Prove + ZkBool prove( + @Secret(name = "sibling") @FixedSize(param = "depth") ZkArray siblings, + @Secret(name = "sibling_0") @FixedSize(1) ZkArray siblingZero, + @Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(parameterized.success()); + assertTrue(parameterized.diagnosticsText().contains("Duplicate flattened input name may be generated")); + } + @Test void sanitizesGeneratedInputConstantsThatWouldNotBeJavaIdentifiers() throws Exception { var compilation = compile("test.OddInputNames", """ @@ -565,6 +726,34 @@ ZkBool prove( "ok", List.of(BigInteger.ONE)), CurveId.BN254)); } + @Test + void generatedArrayInputBuilderEscapesInputNamesInMessages() throws Exception { + var compilation = compile("test.OddArrayNames", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class OddArrayNames { + @Prove + ZkBool prove( + @Secret(name = "item\\\"\\n") @FixedSize(1) ZkArray items, + @Public ZkBool ok) { + return ok; + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + Object inputs = compilation.load("test.OddArrayNamesCircuit") + .getMethod("inputs") + .invoke(null); + var ex = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> inputs.getClass().getMethod("items", List.class) + .invoke(inputs, List.of(BigInteger.ONE, BigInteger.TWO))); + assertTrue(ex.getCause().getMessage().contains("item\"\n expects 1 values")); + } + @Test void rejectsNameTemplateThatOmitsOrReferencesUnknownParams() throws Exception { var missing = compile("test.MissingTemplateParam", """ From d655fee645996c05b47064802f5c146a04a36c51 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 02:34:30 +0800 Subject: [PATCH 07/26] phase 6: add annotation examples and guide --- docs/adr/circuit-annotation/README.md | 4 + .../circuit-annotation/implementation-plan.md | 4 +- .../phase-6-examples-and-documentation.md | 53 ++++++ docs/circuit-annotation-user-guide.md | 134 ++++++++++++++ zeroj-examples/README.md | 9 + zeroj-examples/build.gradle | 2 + .../annotation/AnnotatedAgeVerification.java | 19 ++ .../annotation/AnnotatedHashCommitment.java | 22 +++ .../annotation/AnnotatedMerkleMembership.java | 34 ++++ .../annotation/AnnotatedPrivateTransfer.java | 22 +++ .../annotation/AnnotatedRangeProof.java | 29 +++ .../AnnotatedCircuitExamplesTest.java | 169 ++++++++++++++++++ 12 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 docs/adr/circuit-annotation/phase-6-examples-and-documentation.md create mode 100644 docs/circuit-annotation-user-guide.md create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAgeVerification.java create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedHashCommitment.java create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMerkleMembership.java create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedPrivateTransfer.java create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedRangeProof.java create mode 100644 zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index 7c86591..d273f48 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -1650,6 +1650,10 @@ Exit criteria: - docs explain when to use annotations and when to use `CircuitSpec` - docs explain how `@CircuitParam` affects circuit identity and VK lifecycle +Implementation status as of Phase 6: completed in `zeroj-examples` with +field-style, parameter-style, transfer, hash commitment, and parameterized +Merkle examples. The user-facing guide is `docs/circuit-annotation-user-guide.md`. + ### Phase 7: Deferred Bits and Bytes Estimated time: 3 to 5 days. diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index d81889b..4671048 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -28,8 +28,8 @@ In progress. | 2 | Symbolic foundation | Completed | bfe0b65 | | 3 | MVP gadget adapters | Completed | 7f49413 | | 4 | MVP annotation processor | Completed | b033b03 | -| 5 | Schema and input builders | Completed | Pending hash | -| 6 | Examples and documentation | Pending | Pending | +| 5 | Schema and input builders | Completed | c7b24b8 | +| 6 | Examples and documentation | Completed | Pending hash | | 7 | Deferred bits and bytes | Pending | Pending | | 8 | Advanced gadget adapters | Pending | Pending | | 9 | Proving flow integration | Pending | Pending | diff --git a/docs/adr/circuit-annotation/phase-6-examples-and-documentation.md b/docs/adr/circuit-annotation/phase-6-examples-and-documentation.md new file mode 100644 index 0000000..76dfb3e --- /dev/null +++ b/docs/adr/circuit-annotation/phase-6-examples-and-documentation.md @@ -0,0 +1,53 @@ +# Phase 6: Examples and Documentation + +## Status + +Approved and completed for the Phase 6 commit. + +## Goal + +Make annotation-based circuit authoring understandable from working examples, +not only from the ADR and processor tests. + +## Implemented Changes + +- Added annotated example circuits in `zeroj-examples`: + - field-style range proof + - parameter-style age verification + - private transfer conservation proof + - MiMC hash commitment + - parameterized Merkle membership +- Added example tests that use generated companions through: + - `build(...)` + - `schema(...)` + - `inputs(...)` + - `publicValues()` + - `toWitnessMap()` +- Added the annotation API and processor to the examples module. +- Added a user guide for authoring and testing annotated circuits. +- Updated the examples README with the annotation examples. + +## Verification + +- `./gradlew :zeroj-examples:test --tests com.bloxbean.cardano.zeroj.examples.annotation.AnnotatedCircuitExamplesTest` passed. +- `./gradlew :zeroj-examples:test --tests com.bloxbean.cardano.zeroj.examples.annotation.AnnotatedCircuitExamplesTest :zeroj-circuit-annotation-api:test :zeroj-circuit-annotation-processor:test :zeroj-circuit-lib:test --tests com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkGadgetAdaptersTest :zeroj-circuit-dsl:test` passed. +- `./gradlew :zeroj-examples:clean :zeroj-examples:test --tests com.bloxbean.cardano.zeroj.examples.annotation.AnnotatedCircuitExamplesTest` passed in review, confirming generated companions rebuild cleanly. +- `rg -n "[[:blank:]]$" docs/adr/circuit-annotation docs/circuit-annotation-user-guide.md zeroj-examples zeroj-circuit-annotation-api zeroj-circuit-annotation-processor` found no trailing whitespace. +- `git diff --cached --check` passed. + +## Review Results + +Approved after three independent review tracks: + +- API/design review: approved after the guide was corrected to say + `@CircuitParam` annotates constructor parameters, not constructors. +- Correctness/security review: approved after a clean examples rebuild and all + five annotation example tests passed. +- Tests/docs/ergonomics review: approved after scoped example tests and diff + checks passed. A full `zeroj-examples:test --rerun-tasks` run reported + pre-existing out-of-scope failures in non-annotation examples; Phase 6 + annotation tests passed. + +## Commit + +Pending final phase commit. diff --git a/docs/circuit-annotation-user-guide.md b/docs/circuit-annotation-user-guide.md new file mode 100644 index 0000000..d141ba1 --- /dev/null +++ b/docs/circuit-annotation-user-guide.md @@ -0,0 +1,134 @@ +# Circuit Annotation User Guide + +Annotation-based circuits are a Java-first authoring layer over the existing +`CircuitBuilder`, `CircuitSpec`, and `SignalBuilder` stack. They generate normal +ZeroJ circuits at compile time. + +## Minimal Range Proof + +```java +@ZKCircuit(name = "range-proof") +public class RangeProof { + @Secret @UInt(bits = 16) + ZkUInt secret; + + @Public @UInt(bits = 16) + ZkUInt lo; + + @Public @UInt(bits = 16) + ZkUInt hi; + + @Prove + ZkBool inRange() { + return secret.gte(lo).and(secret.lte(hi)); + } +} +``` + +The annotation processor generates `RangeProofCircuit` with: + +```java +var circuit = RangeProofCircuit.build(); +var schema = RangeProofCircuit.schema(); +var inputs = RangeProofCircuit.inputs() + .secret(42) + .lo(18) + .hi(99); + +circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254); +inputs.publicValues(); +``` + +## Authoring Rules + +- Use symbolic types in proof code: `ZkField`, `ZkBool`, `ZkUInt`, and + `ZkArray`. +- Do not return Java `boolean` from `@Prove`; return `ZkBool` or use explicit + assertion methods. +- Use `ZkBool.and(...)`, `or(...)`, `not(...)`, and `select(...)` instead of + Java `&&`, `||`, `!`, or `if` over secret values. +- Use `@UInt(bits = N)` for every `ZkUInt` input. The constructor adds range + constraints eagerly. +- Use `@FixedSize(...)` for every `ZkArray`. +- Put build-time circuit shape values in constructor parameters annotated with + `@CircuitParam`. +- Keep existing DSL and `Signal*` APIs for low-level or unsupported cases. + +## Field Style And Parameter Style + +Field style is concise for simple circuits: + +```java +@Secret @UInt(bits = 16) ZkUInt secret; + +@Prove +ZkBool prove() { + return secret.gte(lo).and(secret.lte(hi)); +} +``` + +Parameter style keeps proof dependencies visible in the method signature: + +```java +@Prove +ZkBool prove(@Secret @UInt(bits = 8) ZkUInt age, + @Public @UInt(bits = 8) ZkUInt threshold) { + return age.gte(threshold); +} +``` + +## Parameterized Circuits + +Parameterized circuits keep Java's template-like circuit generation: + +```java +@ZKCircuit(name = "merkle", nameTemplate = "merkle-d{depth}-{hashType}") +public class MerkleMembership { + private final ZkMerkle.HashType hashType; + + public MerkleMembership(@CircuitParam("depth") int depth, + @CircuitParam("hashType") ZkMerkle.HashType hashType) { + this.hashType = hashType; + } + + @Prove + ZkBool prove(ZkContext zk, + @Secret ZkField leaf, + @Public ZkField root, + @Secret @FixedSize(param = "depth") ZkArray siblings, + @Secret @FixedSize(param = "depth") ZkArray pathBits) { + return ZkMerkle.isMember(zk, leaf, root, siblings, pathBits, hashType); + } +} +``` + +```java +var circuit = MerkleMembershipCircuit.build(32, ZkMerkle.HashType.MIMC); +var inputs = MerkleMembershipCircuit.inputs(32, ZkMerkle.HashType.MIMC); +``` + +Changing a circuit parameter changes the generated circuit identity and should +be treated as a different proving/verifying key lifecycle. + +## Testing Pattern + +Every annotated circuit should have tests for: + +- generated schema ordering +- valid witness calculation +- at least one invalid witness +- public input extraction through `inputs.publicValues()` +- backend compilation for the curve and proof system you intend to use + +The examples in +`zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation` +show this pattern without requiring external prover tooling. + +## Current Limits + +- Nested `@ZKCircuit` classes are not supported. +- Private `@Prove` methods are not supported. +- Static `@Prove` methods must use parameter-style inputs. +- `@CircuitParam` belongs on constructor parameters, not proof method + parameters. +- `ZkBits` and `ZkBytes` are intentionally deferred. diff --git a/zeroj-examples/README.md b/zeroj-examples/README.md index 7bfef6c..f0a4d7a 100644 --- a/zeroj-examples/README.md +++ b/zeroj-examples/README.md @@ -14,6 +14,15 @@ End-to-end demonstrations of ZeroJ capabilities -- from Java DSL circuit definit ## Example Circuits +### 0. Annotation-Based Circuits +Write circuits as annotated Java classes and use generated companions for +`build(...)`, `schema(...)`, and witness input builders. +- **Examples**: range proof, age verification, private transfer, MiMC + commitment, parameterized Merkle membership +- **Source**: [`examples/annotation`](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation) +- **Tests**: [`AnnotatedCircuitExamplesTest.java`](src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java) +- **Guide**: [`docs/circuit-annotation-user-guide.md`](../docs/circuit-annotation-user-guide.md) + ### 1. Sealed-Bid Auction Prove your bid exceeds a reserve price without revealing the bid amount. - **Private**: bidAmount, salt diff --git a/zeroj-examples/build.gradle b/zeroj-examples/build.gradle index 171bf01..c2dd33f 100644 --- a/zeroj-examples/build.gradle +++ b/zeroj-examples/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation project(':zeroj-prover-wasm') implementation project(':zeroj-circuit-dsl') implementation project(':zeroj-circuit-lib') + implementation project(':zeroj-circuit-annotation-api') implementation project(':zeroj-crypto') implementation project(':zeroj-blst') implementation project(':zeroj-cardano') @@ -43,6 +44,7 @@ dependencies { // Julc for on-chain compilation implementation "com.bloxbean.cardano:julc-stdlib:${julcVersion}" + annotationProcessor project(':zeroj-circuit-annotation-processor') annotationProcessor "com.bloxbean.cardano:julc-annotation-processor:${julcVersion}" // Cardano Client Lib for tx building in E2E tests diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAgeVerification.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAgeVerification.java new file mode 100644 index 0000000..5875330 --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAgeVerification.java @@ -0,0 +1,19 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.UInt; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; + +@ZKCircuit(name = "annotation-age-verification") +public class AnnotatedAgeVerification { + @Prove + ZkBool prove( + @Secret @UInt(bits = 8) ZkUInt age, + @Public @UInt(bits = 8) ZkUInt threshold) { + return age.gte(threshold); + } +} diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedHashCommitment.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedHashCommitment.java new file mode 100644 index 0000000..7b6b47f --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedHashCommitment.java @@ -0,0 +1,22 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMiMC; + +@ZKCircuit(name = "annotation-hash-commitment") +public class AnnotatedHashCommitment { + @Prove + ZkBool prove( + ZkContext zk, + @Secret ZkField value, + @Secret ZkField salt, + @Public ZkField commitment) { + return ZkMiMC.hash(zk, value, salt).isEqual(commitment); + } +} diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMerkleMembership.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMerkleMembership.java new file mode 100644 index 0000000..eb5b633 --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMerkleMembership.java @@ -0,0 +1,34 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.CircuitParam; +import com.bloxbean.cardano.zeroj.circuit.annotation.FixedSize; +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMerkle; + +@ZKCircuit(name = "annotation-merkle", nameTemplate = "annotation-merkle-d{depth}-{hashType}") +public class AnnotatedMerkleMembership { + private final ZkMerkle.HashType hashType; + + public AnnotatedMerkleMembership( + @CircuitParam("depth") int depth, + @CircuitParam("hashType") ZkMerkle.HashType hashType) { + this.hashType = hashType; + } + + @Prove + ZkBool prove( + ZkContext zk, + @Secret ZkField leaf, + @Public ZkField root, + @Secret @FixedSize(param = "depth") ZkArray siblings, + @Secret @FixedSize(param = "depth") ZkArray pathBits) { + return ZkMerkle.isMember(zk, leaf, root, siblings, pathBits, hashType); + } +} diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedPrivateTransfer.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedPrivateTransfer.java new file mode 100644 index 0000000..16a838a --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedPrivateTransfer.java @@ -0,0 +1,22 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.UInt; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; + +@ZKCircuit(name = "annotation-private-transfer") +public class AnnotatedPrivateTransfer { + @Prove + ZkBool prove( + @Secret @UInt(bits = 16) ZkUInt balanceBefore, + @Secret @UInt(bits = 16) ZkUInt transferAmount, + @Public @UInt(bits = 16) ZkUInt publicAmount, + @Public @UInt(bits = 16) ZkUInt balanceAfter) { + return transferAmount.isEqual(publicAmount) + .and(balanceBefore.sub(transferAmount).isEqual(balanceAfter)); + } +} diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedRangeProof.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedRangeProof.java new file mode 100644 index 0000000..af16cab --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedRangeProof.java @@ -0,0 +1,29 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.UInt; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; + +@ZKCircuit(name = "annotation-range-proof") +public class AnnotatedRangeProof { + @Secret + @UInt(bits = 16) + ZkUInt secret; + + @Public + @UInt(bits = 16) + ZkUInt lo; + + @Public + @UInt(bits = 16) + ZkUInt hi; + + @Prove + ZkBool inRange() { + return secret.gte(lo).and(secret.lte(hi)); + } +} diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java new file mode 100644 index 0000000..264d600 --- /dev/null +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java @@ -0,0 +1,169 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.circuit.FieldConfig; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMerkle; +import com.bloxbean.cardano.zeroj.examples.dsl.common.MiMCHash; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class AnnotatedCircuitExamplesTest { + + @Test + void fieldStyleRangeProofUsesGeneratedInputBuilder() { + var circuit = AnnotatedRangeProofCircuit.build(); + var schema = AnnotatedRangeProofCircuit.schema(); + + assertEquals("annotation-range-proof", schema.name()); + assertEquals(List.of("lo", "hi"), schema.publicInputs().names()); + assertEquals(List.of("secret"), schema.secretInputs().names()); + + var inputs = AnnotatedRangeProofCircuit.inputs() + .secret(42) + .lo(18) + .hi(99); + + assertEquals(List.of(BigInteger.valueOf(18), BigInteger.valueOf(99)), inputs.publicValues()); + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); + + var invalid = AnnotatedRangeProofCircuit.inputs() + .secret(7) + .lo(18) + .hi(99); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(invalid.toWitnessMap(), CurveId.BN254)); + } + + @Test + void parameterStyleAgeVerificationUsesGeneratedSchema() { + var circuit = AnnotatedAgeVerificationCircuit.build(); + var schema = AnnotatedAgeVerificationCircuit.schema(); + + assertEquals(List.of("threshold"), schema.publicInputs().names()); + assertEquals(List.of("age"), schema.secretInputs().names()); + assertEquals(8, schema.input("age").bits()); + + var inputs = AnnotatedAgeVerificationCircuit.inputs() + .age(25) + .threshold(18); + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); + + var underAge = AnnotatedAgeVerificationCircuit.inputs() + .age(15) + .threshold(18); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(underAge.toWitnessMap(), CurveId.BN254)); + } + + @Test + void privateTransferChecksConservationAndPublicAmount() { + var circuit = AnnotatedPrivateTransferCircuit.build(); + var inputs = AnnotatedPrivateTransferCircuit.inputs() + .balanceBefore(1_000) + .transferAmount(125) + .publicAmount(125) + .balanceAfter(875); + + assertEquals(List.of(BigInteger.valueOf(125), BigInteger.valueOf(875)), inputs.publicValues()); + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); + + var wrongPublicAmount = AnnotatedPrivateTransferCircuit.inputs() + .balanceBefore(1_000) + .transferAmount(125) + .publicAmount(124) + .balanceAfter(875); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(wrongPublicAmount.toWitnessMap(), CurveId.BN254)); + + var underflow = AnnotatedPrivateTransferCircuit.inputs() + .balanceBefore(100) + .transferAmount(125) + .publicAmount(125) + .balanceAfter(0); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(underflow.toWitnessMap(), CurveId.BN254)); + } + + @Test + void hashCommitmentUsesSymbolicGadgetAdapter() { + var circuit = AnnotatedHashCommitmentCircuit.build(); + var value = BigInteger.valueOf(1234); + var salt = BigInteger.valueOf(5678); + var commitment = MiMCHash.hash(value, salt, FieldConfig.BN254.prime()); + + var inputs = AnnotatedHashCommitmentCircuit.inputs() + .value(value) + .salt(salt) + .commitment(commitment); + + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); + assertNotNull(circuit.compileR1CS(CurveId.BN254)); + + var wrong = AnnotatedHashCommitmentCircuit.inputs() + .value(value) + .salt(salt) + .commitment(BigInteger.ONE); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(wrong.toWitnessMap(), CurveId.BN254)); + } + + @Test + void parameterizedMerkleMembershipUsesDepthAndHashType() { + int depth = 2; + var hashType = ZkMerkle.HashType.MIMC; + var circuit = AnnotatedMerkleMembershipCircuit.build(depth, hashType); + var schema = AnnotatedMerkleMembershipCircuit.schema(depth, hashType); + + assertEquals("annotation-merkle-d2-MIMC", schema.name()); + assertEquals("2", schema.parameters().get(0).value()); + assertEquals("MIMC", schema.parameters().get(1).value()); + assertEquals(List.of("root"), schema.publicInputs().names()); + assertEquals(List.of("leaf", "sibling_0", "sibling_1", "pathBit_0", "pathBit_1"), + schema.secretInputs().names()); + + var leaf = BigInteger.valueOf(10); + var sibling0 = BigInteger.valueOf(20); + var sibling1 = BigInteger.valueOf(30); + var pathBit0 = BigInteger.ZERO; + var pathBit1 = BigInteger.ONE; + var root = merkleRoot(leaf, List.of(sibling0, sibling1), List.of(pathBit0, pathBit1)); + + var inputs = AnnotatedMerkleMembershipCircuit.inputs(depth, hashType) + .leaf(leaf) + .root(root) + .siblings(List.of(sibling0, sibling1)) + .pathBits(List.of(pathBit0, pathBit1)); + + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); + assertEquals(List.of(root), inputs.publicValues()); + + var invalid = AnnotatedMerkleMembershipCircuit.inputs(depth, hashType) + .leaf(leaf) + .root(BigInteger.ONE) + .siblings(List.of(sibling0, sibling1)) + .pathBits(List.of(pathBit0, pathBit1)); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(invalid.toWitnessMap(), CurveId.BN254)); + } + + private BigInteger merkleRoot( + BigInteger leaf, + List siblings, + List pathBits) { + BigInteger current = leaf; + for (int i = 0; i < siblings.size(); i++) { + BigInteger sibling = siblings.get(i); + current = BigInteger.ZERO.equals(pathBits.get(i)) + ? MiMCHash.hash(current, sibling, FieldConfig.BN254.prime()) + : MiMCHash.hash(sibling, current, FieldConfig.BN254.prime()); + } + return current; + } +} From 126ef5cff166ac98c6cea6849be71ede6f329f83 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 02:57:25 +0800 Subject: [PATCH 08/26] phase 7: add bit and byte symbolic inputs --- docs/adr/circuit-annotation/README.md | 31 ++-- .../circuit-annotation/implementation-plan.md | 4 +- .../phase-7-bit-and-byte-symbolic-inputs.md | 66 +++++++ docs/circuit-annotation-user-guide.md | 26 ++- zeroj-circuit-annotation-api/README.md | 8 +- .../zeroj/circuit/annotation/ZkBits.java | 98 ++++++++++ .../zeroj/circuit/annotation/ZkBool.java | 5 + .../zeroj/circuit/annotation/ZkBytes.java | 103 +++++++++++ .../circuit/annotation/ZkCircuitSchema.java | 37 +++- .../annotation/ZkSymbolicTypesTest.java | 69 ++++++++ zeroj-circuit-annotation-processor/README.md | 8 +- .../processor/CircuitAnnotationProcessor.java | 97 ++++++++-- .../CircuitAnnotationProcessorTest.java | 167 ++++++++++++++++++ 13 files changed, 674 insertions(+), 45 deletions(-) create mode 100644 docs/adr/circuit-annotation/phase-7-bit-and-byte-symbolic-inputs.md create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBits.java create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBytes.java diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index d273f48..245d19a 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -415,9 +415,9 @@ ZkArray amounts = ZkArray.publicUInts(c, "amount", count, 64); visibility comes from the supplied factory, so generated code should prefer the visibility-specific helpers whenever the element type is built in. -### Deferred: `ZkBits` +### `ZkBits` -Represents a fixed-length bit vector backed by `Signal[]`. +Represents a fixed-length bit vector backed by constrained `ZkBool` values. Use cases: @@ -426,12 +426,10 @@ Use cases: - fixed byte packing - cryptographic gadgets that operate on bits -`ZkBits` is useful but not required for the first annotation slice because most -early privacy templates can absorb field elements directly through Poseidon or -MiMC. It should be added after range, array, and hash-gadget workflows are -stable. +`ZkBits` requires explicit length through `@FixedSize` in generated circuits. +The Phase 7 implementation uses one constrained boolean signal per bit. -### Deferred: `ZkBytes` +### `ZkBytes` Represents a fixed-length byte sequence. @@ -443,9 +441,10 @@ Use cases: - serialized signatures - off-chain data that must be committed inside a circuit -`ZkBytes` should require explicit length. Unbounded strings or byte arrays -should not be allowed. The first implementation can prefer one `ZkUInt(8)` per -byte for clarity, with packed representations added later. +`ZkBytes` requires explicit length through `@FixedSize` in generated circuits. +The Phase 7 implementation uses one constrained 8-bit `ZkUInt` per byte for +clarity, with packed representations deferred until a real proving-flow need +appears. ## Annotation Set @@ -588,7 +587,7 @@ This is mostly useful for readability and future validation. ### `@FixedSize` -Defines fixed length for arrays and byte values. +Defines fixed length for arrays, bit vectors, and byte values. ```java @Target({FIELD, PARAMETER}) @@ -599,8 +598,8 @@ public @interface FixedSize { } ``` -Required for `ZkBytes` and `ZkArray`. Use `value` for a literal size and -`param` to reference a build-time `@CircuitParam`. +Required for `ZkArray`, `ZkBits`, and `ZkBytes`. Use `value` for a literal size +and `param` to reference a build-time `@CircuitParam`. Examples: @@ -1654,7 +1653,7 @@ Implementation status as of Phase 6: completed in `zeroj-examples` with field-style, parameter-style, transfer, hash commitment, and parameterized Merkle examples. The user-facing guide is `docs/circuit-annotation-user-guide.md`. -### Phase 7: Deferred Bits and Bytes +### Phase 7: Bit and Byte Symbolic Inputs Estimated time: 3 to 5 days. @@ -1671,6 +1670,10 @@ Exit criteria: - invalid byte values are rejected - byte/bit APIs do not complicate the v1 range/hash/Merkle surface +Implementation status as of Phase 7: completed with `ZkBits`, `ZkBytes`, +generated `@FixedSize` support, schema/input-builder support, and bit/byte +witness constraint tests. + ### Phase 8: Advanced Circuit Library Symbolic Adapters Estimated time: 4 to 8 days. diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index 4671048..8c4e4ae 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -29,8 +29,8 @@ In progress. | 3 | MVP gadget adapters | Completed | 7f49413 | | 4 | MVP annotation processor | Completed | b033b03 | | 5 | Schema and input builders | Completed | c7b24b8 | -| 6 | Examples and documentation | Completed | Pending hash | -| 7 | Deferred bits and bytes | Pending | Pending | +| 6 | Examples and documentation | Completed | d655fee | +| 7 | Bit and byte symbolic inputs | Completed | Pending final phase commit | | 8 | Advanced gadget adapters | Pending | Pending | | 9 | Proving flow integration | Pending | Pending | diff --git a/docs/adr/circuit-annotation/phase-7-bit-and-byte-symbolic-inputs.md b/docs/adr/circuit-annotation/phase-7-bit-and-byte-symbolic-inputs.md new file mode 100644 index 0000000..227ad0c --- /dev/null +++ b/docs/adr/circuit-annotation/phase-7-bit-and-byte-symbolic-inputs.md @@ -0,0 +1,66 @@ +# Phase 7: Bit and Byte Symbolic Inputs + +## Status + +Approved and completed for the Phase 7 commit. + +## Goal + +Add fixed-size bit and byte symbolic values without changing the Phase 4-6 +field/range/hash/Merkle authoring surface. + +## Implemented Changes + +- Add `ZkBits` as a fixed-size vector of constrained `ZkBool` values. +- Add `ZkBytes` as a fixed-size vector of 8-bit constrained `ZkUInt` values. +- Support `@FixedSize(...) ZkBits` and `@FixedSize(...) ZkBytes` in generated + circuit companions. +- Extend generated schemas and input builders for bit and byte vector inputs. +- Add API and processor tests for valid and invalid bit/byte witnesses. + +## Exit Criteria + +- Fixed-size byte messages can be represented. +- Invalid byte values are rejected. +- Invalid bit values are rejected. +- Byte/bit APIs do not complicate existing range/hash/Merkle examples. + +## Verification + +- `./gradlew :zeroj-circuit-annotation-api:test --tests com.bloxbean.cardano.zeroj.circuit.annotation.ZkSymbolicTypesTest` + passed. +- `./gradlew :zeroj-circuit-annotation-processor:test --tests com.bloxbean.cardano.zeroj.circuit.annotation.processor.CircuitAnnotationProcessorTest` + passed. +- `./gradlew :zeroj-circuit-dsl:test` + passed. +- `./gradlew :zeroj-circuit-lib:test --tests com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkGadgetAdaptersTest` + passed. + +Exit criteria results: + +- Fixed-size byte messages are represented by `ZkBytes`, generated as flattened + byte inputs in schemas and input builders. +- Invalid byte witnesses are rejected through 8-bit `ZkUInt` constraints. +- Invalid bit witnesses are rejected through `ZkBool` constraints. +- Existing range/hash/Merkle examples continue to use their Phase 6 APIs; bit + and byte vectors are additive. + +## Review Results + +- API/design review found that the public `ZkBytes` constructor did not enforce + the 8-bit invariant. The constructor now rejects non-8-bit `ZkUInt` values, + and tests cover the direct constructor path. +- API/design review also requested stronger `ZkCircuitSchema` invariants. Schema + metadata now rejects `bits == 0`, scalar `BITS`/`BYTES`, and mismatched bit + widths for field, bool, uint, bit-vector, and byte-vector inputs. +- Tests/docs review requested explicit negative processor tests and current + phase docs. Processor tests now cover missing/invalid `@FixedSize` declarations + for `ZkBits`/`ZkBytes`, and README/guide text reflects Phase 7. +- Correctness review found additional generated-companion guardrails: positive + `@FixedSize(param = ...)` checks, reserved generated constant names, and + rejected visibility annotations on `ZkContext` parameters. These are now fixed + and tested. + +## Commit + +Pending final phase commit. diff --git a/docs/circuit-annotation-user-guide.md b/docs/circuit-annotation-user-guide.md index d141ba1..a3621f8 100644 --- a/docs/circuit-annotation-user-guide.md +++ b/docs/circuit-annotation-user-guide.md @@ -41,15 +41,15 @@ inputs.publicValues(); ## Authoring Rules -- Use symbolic types in proof code: `ZkField`, `ZkBool`, `ZkUInt`, and - `ZkArray`. +- Use symbolic types in proof code: `ZkField`, `ZkBool`, `ZkUInt`, + `ZkArray`, `ZkBits`, and `ZkBytes`. - Do not return Java `boolean` from `@Prove`; return `ZkBool` or use explicit assertion methods. - Use `ZkBool.and(...)`, `or(...)`, `not(...)`, and `select(...)` instead of Java `&&`, `||`, `!`, or `if` over secret values. - Use `@UInt(bits = N)` for every `ZkUInt` input. The constructor adds range constraints eagerly. -- Use `@FixedSize(...)` for every `ZkArray`. +- Use `@FixedSize(...)` for every `ZkArray`, `ZkBits`, and `ZkBytes`. - Put build-time circuit shape values in constructor parameters annotated with `@CircuitParam`. - Keep existing DSL and `Signal*` APIs for low-level or unsupported cases. @@ -124,6 +124,23 @@ The examples in `zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation` show this pattern without requiring external prover tooling. +## Bit And Byte Inputs + +Use `ZkBits` for fixed-size bit vectors and `ZkBytes` for fixed-size byte +messages or serialized fields. + +```java +@Prove +ZkBool prove(@Secret @FixedSize(32) ZkBytes message, + @Public @FixedSize(32) ZkBytes expected) { + return message.isEqual(expected); +} +``` + +Each `ZkBits` element is constrained as a boolean. Each `ZkBytes` element is +constrained to 8 bits. Generated input builders accept indexed values and +`List` values. + ## Current Limits - Nested `@ZKCircuit` classes are not supported. @@ -131,4 +148,5 @@ show this pattern without requiring external prover tooling. - Static `@Prove` methods must use parameter-style inputs. - `@CircuitParam` belongs on constructor parameters, not proof method parameters. -- `ZkBits` and `ZkBytes` are intentionally deferred. +- Packed byte encodings and byte-oriented cryptographic gadgets are deferred; + Phase 7 stores one constrained field element per bit or byte. diff --git a/zeroj-circuit-annotation-api/README.md b/zeroj-circuit-annotation-api/README.md index dd5b4f9..25f77f5 100644 --- a/zeroj-circuit-annotation-api/README.md +++ b/zeroj-circuit-annotation-api/README.md @@ -2,7 +2,7 @@ Public API for annotation-based ZeroJ circuit authoring. -Current Phase 5 status: this module exposes the foundational annotations, +Current Phase 7 status: this module exposes the foundational annotations, symbolic `Zk*` types, and runtime schema/input helpers used by generated companions. @@ -10,7 +10,8 @@ This module contains: - circuit annotations such as `@ZKCircuit`, `@Prove`, `@Public`, `@Secret`, `@CircuitParam`, `@UInt`, `@FieldElement`, `@FixedSize`, and `@Order` -- symbolic circuit value types: `ZkField`, `ZkBool`, `ZkUInt`, and `ZkArray` +- symbolic circuit value types: `ZkField`, `ZkBool`, `ZkUInt`, `ZkArray`, + `ZkBits`, and `ZkBytes` - generated-circuit metadata and witness helpers: `ZkCircuitSchema` and `ZkInputMap` @@ -53,6 +54,9 @@ Important API rules: - `ZkArray.secretFields`, `secretBools`, `secretUInts`, `publicFields`, `publicBools`, and `publicUInts` encode visibility for built-in element types. `ZkArray.bind` is for custom symbolic types. +- `ZkBits` represents fixed-size bit vectors backed by constrained `ZkBool` + values. +- `ZkBytes` represents fixed-size byte vectors backed by 8-bit `ZkUInt` values. - `wrap(...)` rejects signals from a different `SignalBuilder`. - `ZkCircuitSchema.publicInputs().names()` and `ZkCircuitSchema.secretInputs().names()` expose flattened input order. diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBits.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBits.java new file mode 100644 index 0000000..420a01b --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBits.java @@ -0,0 +1,98 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Fixed-size symbolic bit vector backed by constrained {@link ZkBool} values. + */ +public final class ZkBits implements ZkValue { + private final List bits; + + public ZkBits(List bits) { + Objects.requireNonNull(bits, "bits"); + if (bits.isEmpty()) { + throw new IllegalArgumentException("bits must not be empty"); + } + this.bits = List.copyOf(bits); + } + + public static ZkBits publicInput(SignalBuilder builder, String baseName, int size) { + return bind(builder, baseName, size, ZkBool::publicInput); + } + + public static ZkBits secret(SignalBuilder builder, String baseName, int size) { + return bind(builder, baseName, size, ZkBool::secret); + } + + private static ZkBits bind(SignalBuilder builder, String baseName, int size, BitFactory factory) { + Objects.requireNonNull(builder, "builder"); + Objects.requireNonNull(baseName, "baseName"); + Objects.requireNonNull(factory, "factory"); + if (size <= 0) { + throw new IllegalArgumentException("size must be positive"); + } + var values = new ArrayList(size); + for (int i = 0; i < size; i++) { + values.add(factory.create(builder, baseName + "_" + i)); + } + return new ZkBits(values); + } + + public int size() { + return bits.size(); + } + + public ZkBool get(int index) { + return bits.get(index); + } + + public List values() { + return bits; + } + + public ZkBool isEqual(ZkBits other) { + requireSameSize(other); + ZkBool result = bits.getFirst().isEqual(other.bits.getFirst()); + for (int i = 1; i < bits.size(); i++) { + result = result.and(bits.get(i).isEqual(other.bits.get(i))); + } + return result; + } + + public void assertEqual(ZkBits other) { + isEqual(other).assertTrue(); + } + + @Override + public List signals() { + var signals = new ArrayList(bits.size()); + for (ZkBool bit : bits) { + signals.add(bit.signal()); + } + return List.copyOf(signals); + } + + @Override + public void assertWellFormed() { + for (ZkBool bit : bits) { + bit.assertWellFormed(); + } + } + + private void requireSameSize(ZkBits other) { + Objects.requireNonNull(other, "other"); + if (bits.size() != other.bits.size()) { + throw new IllegalArgumentException("bit vectors must have equal length"); + } + } + + @FunctionalInterface + private interface BitFactory { + ZkBool create(SignalBuilder builder, String name); + } +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBool.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBool.java index ed36cfb..95dc8a4 100644 --- a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBool.java +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBool.java @@ -85,6 +85,11 @@ public ZkUInt select(ZkUInt ifTrue, ZkUInt ifFalse) { Math.max(ifTrue.bits(), ifFalse.bits())); } + public ZkBool isEqual(ZkBool other) { + requireSameContext(other); + return trusted(context, signal.isEqual(other.signal)); + } + public void assertTrue() { context.builder().assertEqual(signal, context.builder().constant(1)); } diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBytes.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBytes.java new file mode 100644 index 0000000..5c3637f --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBytes.java @@ -0,0 +1,103 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Fixed-size symbolic byte vector backed by 8-bit {@link ZkUInt} values. + */ +public final class ZkBytes implements ZkValue { + private final List bytes; + + public ZkBytes(List bytes) { + Objects.requireNonNull(bytes, "bytes"); + if (bytes.isEmpty()) { + throw new IllegalArgumentException("bytes must not be empty"); + } + for (ZkUInt value : bytes) { + if (value.bits() != 8) { + throw new IllegalArgumentException("bytes must contain only 8-bit ZkUInt values"); + } + } + this.bytes = List.copyOf(bytes); + } + + public static ZkBytes publicInput(SignalBuilder builder, String baseName, int size) { + return bind(builder, baseName, size, ZkUInt::publicInput); + } + + public static ZkBytes secret(SignalBuilder builder, String baseName, int size) { + return bind(builder, baseName, size, ZkUInt::secret); + } + + private static ZkBytes bind(SignalBuilder builder, String baseName, int size, ByteFactory factory) { + Objects.requireNonNull(builder, "builder"); + Objects.requireNonNull(baseName, "baseName"); + Objects.requireNonNull(factory, "factory"); + if (size <= 0) { + throw new IllegalArgumentException("size must be positive"); + } + var values = new ArrayList(size); + for (int i = 0; i < size; i++) { + values.add(factory.create(builder, baseName + "_" + i, 8)); + } + return new ZkBytes(values); + } + + public int size() { + return bytes.size(); + } + + public ZkUInt get(int index) { + return bytes.get(index); + } + + public List values() { + return bytes; + } + + public ZkBool isEqual(ZkBytes other) { + requireSameSize(other); + ZkBool result = bytes.getFirst().isEqual(other.bytes.getFirst()); + for (int i = 1; i < bytes.size(); i++) { + result = result.and(bytes.get(i).isEqual(other.bytes.get(i))); + } + return result; + } + + public void assertEqual(ZkBytes other) { + isEqual(other).assertTrue(); + } + + @Override + public List signals() { + var signals = new ArrayList(bytes.size()); + for (ZkUInt value : bytes) { + signals.add(value.signal()); + } + return List.copyOf(signals); + } + + @Override + public void assertWellFormed() { + for (ZkUInt value : bytes) { + value.assertWellFormed(); + } + } + + private void requireSameSize(ZkBytes other) { + Objects.requireNonNull(other, "other"); + if (bytes.size() != other.bytes.size()) { + throw new IllegalArgumentException("byte vectors must have equal length"); + } + } + + @FunctionalInterface + private interface ByteFactory { + ZkUInt create(SignalBuilder builder, String name, int bits); + } +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java index 2cb8c7a..1657d88 100644 --- a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java @@ -91,12 +91,13 @@ public record Input( Objects.requireNonNull(name, "name"); Objects.requireNonNull(visibility, "visibility"); Objects.requireNonNull(kind, "kind"); - if (bits < -1) { + if (bits < -1 || bits == 0) { throw new IllegalArgumentException("bits must be -1 or positive"); } if (size <= 0) { throw new IllegalArgumentException("size must be positive"); } + validateKind(kind, bits, array); signalNames = List.copyOf(Objects.requireNonNull(signalNames, "signalNames")); if (signalNames.size() != size) { throw new IllegalArgumentException("signalNames size must match input size"); @@ -114,6 +115,36 @@ public static Input array(String name, Visibility visibility, Kind kind, int bit } return new Input(name, visibility, kind, bits, size, true, names); } + + private static void validateKind(Kind kind, int bits, boolean array) { + switch (kind) { + case FIELD -> { + if (bits != -1) { + throw new IllegalArgumentException("FIELD inputs must use bits = -1"); + } + } + case BOOL -> { + if (bits != 1) { + throw new IllegalArgumentException("BOOL inputs must use bits = 1"); + } + } + case UINT -> { + if (bits <= 0) { + throw new IllegalArgumentException("UINT inputs must use a positive bit width"); + } + } + case BITS -> { + if (!array || bits != 1) { + throw new IllegalArgumentException("BITS inputs must be arrays with bits = 1"); + } + } + case BYTES -> { + if (!array || bits != 8) { + throw new IllegalArgumentException("BYTES inputs must be arrays with bits = 8"); + } + } + } + } } public enum Visibility { @@ -124,6 +155,8 @@ public enum Visibility { public enum Kind { FIELD, BOOL, - UINT + UINT, + BITS, + BYTES } } diff --git a/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java b/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java index 108bc3e..848a610 100644 --- a/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java +++ b/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java @@ -237,6 +237,75 @@ void merkleShapedInputsSupportFieldSiblingsAndBooleanPathBits() { "pathBit_1", List.of(BigInteger.ONE)), CurveId.BN254)); } + @Test + void bitsInputRejectsNonBooleanWitnessAndSupportsEquality() { + var circuit = CircuitBuilder.create("zk-bits") + .secretVar("message_0") + .secretVar("message_1") + .publicVar("expected_0") + .publicVar("expected_1") + .defineSignals(c -> { + var message = ZkBits.secret(c, "message", 2); + var expected = ZkBits.publicInput(c, "expected", 2); + message.assertEqual(expected); + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "message_0", List.of(BigInteger.ONE), + "message_1", List.of(BigInteger.ZERO), + "expected_0", List.of(BigInteger.ONE), + "expected_1", List.of(BigInteger.ZERO)), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "message_0", List.of(BigInteger.TWO), + "message_1", List.of(BigInteger.ZERO), + "expected_0", List.of(BigInteger.ONE), + "expected_1", List.of(BigInteger.ZERO)), CurveId.BN254)); + } + + @Test + void bytesInputRejectsOutOfRangeWitnessAndSupportsEquality() { + var circuit = CircuitBuilder.create("zk-bytes") + .secretVar("message_0") + .secretVar("message_1") + .publicVar("expected_0") + .publicVar("expected_1") + .defineSignals(c -> { + var message = ZkBytes.secret(c, "message", 2); + var expected = ZkBytes.publicInput(c, "expected", 2); + message.assertEqual(expected); + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "message_0", List.of(BigInteger.valueOf(255)), + "message_1", List.of(BigInteger.ZERO), + "expected_0", List.of(BigInteger.valueOf(255)), + "expected_1", List.of(BigInteger.ZERO)), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "message_0", List.of(BigInteger.valueOf(256)), + "message_1", List.of(BigInteger.ZERO), + "expected_0", List.of(BigInteger.valueOf(255)), + "expected_1", List.of(BigInteger.ZERO)), CurveId.BN254)); + } + + @Test + void bytesConstructorRejectsNonByteUIntValues() { + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-bytes-width") + .secretVar("message_0") + .defineSignals(c -> new ZkBytes(List.of(ZkUInt.secret(c, "message_0", 16))))); + } + + @Test + void schemaRejectsInvalidBitAndByteMetadata() { + assertThrows(IllegalArgumentException.class, () -> ZkCircuitSchema.Input.scalar( + "flag", ZkCircuitSchema.Visibility.PUBLIC, ZkCircuitSchema.Kind.BOOL, 0)); + assertThrows(IllegalArgumentException.class, () -> ZkCircuitSchema.Input.scalar( + "bits", ZkCircuitSchema.Visibility.PUBLIC, ZkCircuitSchema.Kind.BITS, 1)); + assertThrows(IllegalArgumentException.class, () -> ZkCircuitSchema.Input.array( + "bytes", ZkCircuitSchema.Visibility.SECRET, ZkCircuitSchema.Kind.BYTES, 16, 2)); + } + @Test void schemaExposesStableNamesAndInputMapPublicValues() { var schema = ZkCircuitSchema.of( diff --git a/zeroj-circuit-annotation-processor/README.md b/zeroj-circuit-annotation-processor/README.md index ea51c6f..727ab5b 100644 --- a/zeroj-circuit-annotation-processor/README.md +++ b/zeroj-circuit-annotation-processor/README.md @@ -2,24 +2,24 @@ Compile-time annotation processor for annotation-based ZeroJ circuit authoring. -Current Phase 5 status: this module scans `@ZKCircuit` classes and generates +Current Phase 7 status: this module scans `@ZKCircuit` classes and generates `*Circuit` companions with `build(...)`, `schema(...)`, `inputs(...)`, `publicInputs(...)`, and input-name constants. The generated companions build normal `CircuitBuilder` / `CircuitSpec` circuits and produce ordinary witness maps for `calculateWitness(...)`. -Supported in Phase 4: +Supported: - field-style and parameter-style `@Prove` methods - `ZkContext` proof parameters - constructor `@CircuitParam` values -- `@FixedSize(param = "...")` arrays +- `@FixedSize(...)` arrays, bits, and bytes - `@Public`, `@Secret`, `@UInt`, `@FieldElement`, and `@Order` - generated schema metadata and input builders - compile-time diagnostics for unsupported symbolic types and proof returns -Not supported in Phase 4: +Not supported: - nested `@ZKCircuit` classes - private `@Prove` methods diff --git a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java index 0cd2bfa..65b2d5a 100644 --- a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java +++ b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java @@ -45,6 +45,8 @@ public final class CircuitAnnotationProcessor extends AbstractProcessor { private static final String ANNOTATION_PKG = "com.bloxbean.cardano.zeroj.circuit.annotation."; private static final String ZK_BOOL = ANNOTATION_PKG + "ZkBool"; + private static final String ZK_BITS = ANNOTATION_PKG + "ZkBits"; + private static final String ZK_BYTES = ANNOTATION_PKG + "ZkBytes"; private static final String ZK_FIELD = ANNOTATION_PKG + "ZkField"; private static final String ZK_UINT = ANNOTATION_PKG + "ZkUInt"; private static final String ZK_ARRAY = ANNOTATION_PKG + "ZkArray"; @@ -206,6 +208,9 @@ private List inputs(TypeElement sourceType, ExecutableElement proveM "@CircuitParam is only supported on constructors in Phase 4"); } if (isType(parameter.asType(), ZK_CONTEXT)) { + if (parameter.getAnnotation(Public.class) != null || parameter.getAnnotation(Secret.class) != null) { + throw new GenerationException(parameter, "ZkContext parameters cannot be @Public or @Secret"); + } continue; } if (parameter.getAnnotation(Public.class) != null || parameter.getAnnotation(Secret.class) != null) { @@ -273,13 +278,14 @@ private InputModel input(VariableElement element, boolean fieldStyle, FixedSize fixedSize = element.getAnnotation(FixedSize.class); SizeModel size = null; - if (valueKind == ValueKind.ARRAY) { + if (isFixedVector(valueKind)) { if (fixedSize == null) { - throw new GenerationException(element, "ZkArray symbolic inputs require @FixedSize"); + throw new GenerationException(element, + typeLabel(valueKind) + " symbolic inputs require @FixedSize"); } size = sizeModel(element, fixedSize, circuitParams); } else if (fixedSize != null) { - throw new GenerationException(element, "@FixedSize can only be used with ZkArray"); + throw new GenerationException(element, "@FixedSize can only be used with ZkArray, ZkBits, or ZkBytes"); } Integer order = null; @@ -316,7 +322,7 @@ private SizeModel sizeModel(VariableElement element, FixedSize fixedSize, if (fixedSize.value() <= 0) { throw new GenerationException(element, "@FixedSize value must be positive"); } - return new SizeModel(Integer.toString(fixedSize.value()), false); + return new SizeModel(Integer.toString(fixedSize.value()), false, ""); } CircuitParamModel param = circuitParams.get(fixedSize.param()); @@ -325,7 +331,7 @@ private SizeModel sizeModel(VariableElement element, FixedSize fixedSize, "@FixedSize(param = \"" + fixedSize.param() + "\") must reference an integer @CircuitParam"); } - return new SizeModel(param.javaName(), true); + return new SizeModel(param.javaName(), true, param.name()); } private List orderInputs(List inputs) { @@ -348,7 +354,7 @@ private List orderInputs(List inputs) { private void validateInputNames(List inputs) { Set names = new HashSet<>(); - Set constantNames = new HashSet<>(); + Set constantNames = new HashSet<>(Set.of("CIRCUIT_NAME", "CIRCUIT_NAME_TEMPLATE")); for (InputModel input : inputs) { if (!names.add(input.baseName())) { throw new GenerationException(null, "Duplicate generated input name: " + input.baseName()); @@ -361,7 +367,7 @@ private void validateInputNames(List inputs) { Set flattenedNames = new HashSet<>(); Map flattenedOwners = new java.util.HashMap<>(); for (InputModel input : inputs) { - if (input.valueKind() == ValueKind.ARRAY) { + if (isFixedVector(input.valueKind())) { if (!input.size().fromCircuitParam()) { int size = Integer.parseInt(input.size().expression()); for (int i = 0; i < size; i++) { @@ -381,7 +387,7 @@ private void validateInputNames(List inputs) { } } - for (InputModel array : inputs.stream().filter(i -> i.valueKind() == ValueKind.ARRAY).toList()) { + for (InputModel array : inputs.stream().filter(i -> isFixedVector(i.valueKind())).toList()) { for (InputModel other : inputs.stream().filter(i -> i != array).toList()) { if (other.baseName().matches(java.util.regex.Pattern.quote(array.baseName()) + "_\\d+")) { throw new GenerationException(null, @@ -444,7 +450,9 @@ private String render(TypeElement sourceType, String packageName, String sourceS } out.append("import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray;\n") + .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBits;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool;\n") + .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBytes;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkCircuitSchema;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField;\n") @@ -472,6 +480,7 @@ private String render(TypeElement sourceType, String packageName, String sourceS out.append(" public static CircuitBuilder build(") .append(renderParamSignature(circuitParams)) .append(") {\n"); + renderFixedSizeParamGuards(out, inputs, " "); if (needsInstance) { out.append(" var ").append(instanceLocal).append(" = new ").append(sourceSimpleName).append("(") .append(circuitParams.stream().map(CircuitParamModel::javaName).collect(Collectors.joining(", "))) @@ -554,8 +563,9 @@ private void renderSchemaAndInputs(StringBuilder out, List ci List inputs, boolean parameterized) { out.append("\n public static ZkCircuitSchema schema(") .append(renderParamSignature(circuitParams)) - .append(") {\n") - .append(" return ZkCircuitSchema.of(") + .append(") {\n"); + renderFixedSizeParamGuards(out, inputs, " "); + out.append(" return ZkCircuitSchema.of(") .append(parameterized ? "circuitName(" + renderParamNames(circuitParams) + ")" : "CIRCUIT_NAME") .append(",\n") .append(" ").append(renderParameterSchemaList(circuitParams)).append(",\n") @@ -576,6 +586,22 @@ private void renderSchemaAndInputs(StringBuilder out, List ci renderInputsClass(out, inputs); } + private void renderFixedSizeParamGuards(StringBuilder out, List inputs, String indent) { + Map sizeParams = new java.util.LinkedHashMap<>(); + for (InputModel input : inputs) { + if (input.size() != null && input.size().fromCircuitParam()) { + sizeParams.putIfAbsent(input.size().expression(), input.size().paramName()); + } + } + for (Map.Entry entry : sizeParams.entrySet()) { + out.append(indent).append("if (").append(entry.getKey()).append(" <= 0) {\n") + .append(indent).append(" throw new IllegalArgumentException(") + .append(stringLiteral("@FixedSize(param = \"" + entry.getValue() + "\") must be positive")) + .append(");\n") + .append(indent).append("}\n"); + } + } + private String renderParameterSchemaList(List circuitParams) { if (circuitParams.isEmpty()) { return "List.of()"; @@ -601,10 +627,10 @@ private String renderInputSchemaList(List inputs, Visibility visibil } private String renderInputSchema(InputModel input) { - String prefix = input.valueKind() == ValueKind.ARRAY + String prefix = isFixedVector(input.valueKind()) ? "ZkCircuitSchema.Input.array(" : "ZkCircuitSchema.Input.scalar("; - String size = input.valueKind() == ValueKind.ARRAY ? ", " + input.size().expression() : ""; + String size = isFixedVector(input.valueKind()) ? ", " + input.size().expression() : ""; return prefix + input.constantName() + ", ZkCircuitSchema.Visibility." + input.visibility().name() @@ -626,7 +652,7 @@ private void renderInputsClass(StringBuilder out, List inputs) { .append(" }\n\n"); for (InputModel input : inputs) { - if (input.valueKind() == ValueKind.ARRAY) { + if (isFixedVector(input.valueKind())) { renderArrayInputMethods(out, input); } else { renderScalarInputMethods(out, input); @@ -690,6 +716,12 @@ private String schemaKind(InputModel input) { if (input.valueKind() == ValueKind.UINT || input.arrayElementType().equals(ZK_UINT)) { return "UINT"; } + if (input.valueKind() == ValueKind.BITS) { + return "BITS"; + } + if (input.valueKind() == ValueKind.BYTES) { + return "BYTES"; + } throw new GenerationException(null, "Unsupported schema input type: " + input.valueKind()); } @@ -700,12 +732,18 @@ private int schemaBits(InputModel input) { if (input.valueKind() == ValueKind.BOOL || input.arrayElementType().equals(ZK_BOOL)) { return 1; } + if (input.valueKind() == ValueKind.BITS) { + return 1; + } + if (input.valueKind() == ValueKind.BYTES) { + return 8; + } return -1; } private void renderVarDeclaration(StringBuilder out, InputModel input, String builderLocal, String loopLocal, String method) { - if (input.valueKind() == ValueKind.ARRAY) { + if (isFixedVector(input.valueKind())) { out.append(" for (int ").append(loopLocal).append(" = 0; ") .append(loopLocal).append(" < ").append(input.size().expression()).append("; ") .append(loopLocal).append("++) {\n") @@ -730,6 +768,14 @@ private String factoryCall(InputModel input, String signalContextLocal) { return "ZkUInt." + visibilityMethod + "(" + signalContextLocal + ", " + input.constantName() + ", " + input.bits() + ")"; } + if (input.valueKind() == ValueKind.BITS) { + return "ZkBits." + visibilityMethod + "(" + signalContextLocal + ", " + + input.constantName() + ", " + input.size().expression() + ")"; + } + if (input.valueKind() == ValueKind.BYTES) { + return "ZkBytes." + visibilityMethod + "(" + signalContextLocal + ", " + + input.constantName() + ", " + input.size().expression() + ")"; + } if (input.arrayElementType().equals(ZK_FIELD)) { return "ZkArray." + (input.visibility() == Visibility.PUBLIC ? "publicFields" : "secretFields") + "(" + signalContextLocal + ", " + input.constantName() + ", " @@ -816,11 +862,13 @@ private ValueKind valueKind(VariableElement element) { TypeMirror type = element.asType(); if (isType(type, ZK_FIELD)) return ValueKind.FIELD; if (isType(type, ZK_BOOL)) return ValueKind.BOOL; + if (isType(type, ZK_BITS)) return ValueKind.BITS; + if (isType(type, ZK_BYTES)) return ValueKind.BYTES; if (isType(type, ZK_UINT)) return ValueKind.UINT; if (isType(type, ZK_ARRAY)) return ValueKind.ARRAY; if (type.toString().equals("java.math.BigInteger") || type.getKind() == TypeKind.BOOLEAN) { throw new GenerationException(element, - "Symbolic inputs must use ZkField, ZkBool, ZkUInt, or ZkArray, not " + type); + "Symbolic inputs must use ZkField, ZkBool, ZkUInt, ZkArray, ZkBits, or ZkBytes, not " + type); } throw new GenerationException(element, "Unsupported symbolic input type: " + type); } @@ -840,6 +888,19 @@ private boolean isArrayOf(TypeMirror type, String elementType) { return isType(type, ZK_ARRAY) && elementType(type).equals(elementType); } + private boolean isFixedVector(ValueKind valueKind) { + return valueKind == ValueKind.ARRAY || valueKind == ValueKind.BITS || valueKind == ValueKind.BYTES; + } + + private String typeLabel(ValueKind valueKind) { + return switch (valueKind) { + case ARRAY -> "ZkArray"; + case BITS -> "ZkBits"; + case BYTES -> "ZkBytes"; + default -> valueKind.name(); + }; + } + private boolean isType(TypeMirror type, String qualifiedName) { return erasure(type).equals(qualifiedName); } @@ -975,12 +1036,14 @@ private enum ValueKind { FIELD, BOOL, UINT, - ARRAY + ARRAY, + BITS, + BYTES } private record CircuitParamModel(String name, String javaName, String type, boolean intLike) {} - private record SizeModel(String expression, boolean fromCircuitParam) {} + private record SizeModel(String expression, boolean fromCircuitParam, String paramName) {} private record InputModel( String javaName, diff --git a/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java index 33987bc..656b761 100644 --- a/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java +++ b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java @@ -138,6 +138,131 @@ ZkBool prove( "threshold", List.of(BigInteger.valueOf(18))), CurveId.BN254)); } + @Test + void bitAndByteInputsGenerateSchemaBuildersAndConstraints() throws Exception { + var compilation = compile("test.MessageProof", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "message-proof") + public class MessageProof { + @Prove + ZkBool prove( + @Secret @FixedSize(2) ZkBytes message, + @Public @FixedSize(2) ZkBytes expected, + @Secret @FixedSize(3) ZkBits flags, + @Public ZkBool accepted) { + return message.isEqual(expected) + .and(flags.get(0)) + .and(flags.get(1).not()) + .and(flags.get(2)) + .and(accepted); + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + Class companion = compilation.load("test.MessageProofCircuit"); + ZkCircuitSchema schema = (ZkCircuitSchema) companion.getMethod("schema").invoke(null); + assertEquals(ZkCircuitSchema.Kind.BYTES, schema.input("message").kind()); + assertEquals(8, schema.input("message").bits()); + assertEquals(ZkCircuitSchema.Kind.BITS, schema.input("flags").kind()); + assertEquals(1, schema.input("flags").bits()); + assertEquals(List.of("expected_0", "expected_1", "accepted"), schema.publicInputs().names()); + assertEquals(List.of("message_0", "message_1", "flags_0", "flags_1", "flags_2"), + schema.secretInputs().names()); + + Object inputs = companion.getMethod("inputs").invoke(null); + inputs.getClass().getMethod("message", List.class) + .invoke(inputs, List.of(BigInteger.valueOf(1), BigInteger.valueOf(255))); + inputs.getClass().getMethod("expected", List.class) + .invoke(inputs, List.of(BigInteger.valueOf(1), BigInteger.valueOf(255))); + inputs.getClass().getMethod("flags", List.class) + .invoke(inputs, List.of(BigInteger.ONE, BigInteger.ZERO, BigInteger.ONE)); + inputs.getClass().getMethod("accepted", long.class).invoke(inputs, 1L); + + CircuitBuilder circuit = (CircuitBuilder) companion.getMethod("build").invoke(null); + @SuppressWarnings("unchecked") + Map> witness = + (Map>) inputs.getClass().getMethod("toWitnessMap").invoke(inputs); + assertDoesNotThrow(() -> circuit.calculateWitness(witness, CurveId.BN254)); + + var invalidByte = Map.of( + "message_0", List.of(BigInteger.valueOf(256)), + "message_1", List.of(BigInteger.valueOf(255)), + "expected_0", List.of(BigInteger.ONE), + "expected_1", List.of(BigInteger.valueOf(255)), + "flags_0", List.of(BigInteger.ONE), + "flags_1", List.of(BigInteger.ZERO), + "flags_2", List.of(BigInteger.ONE), + "accepted", List.of(BigInteger.ONE)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(invalidByte, CurveId.BN254)); + + var invalidBit = Map.of( + "message_0", List.of(BigInteger.ONE), + "message_1", List.of(BigInteger.valueOf(255)), + "expected_0", List.of(BigInteger.ONE), + "expected_1", List.of(BigInteger.valueOf(255)), + "flags_0", List.of(BigInteger.TWO), + "flags_1", List.of(BigInteger.ZERO), + "flags_2", List.of(BigInteger.ONE), + "accepted", List.of(BigInteger.ONE)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(invalidBit, CurveId.BN254)); + } + + @Test + void rejectsInvalidBitAndByteFixedSizeDeclarations() throws Exception { + var missingBitsSize = compile("test.MissingBitsSize", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class MissingBitsSize { + @Prove + ZkBool prove(@Secret ZkBits flags, @Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(missingBitsSize.success()); + assertTrue(missingBitsSize.diagnosticsText().contains("ZkBits symbolic inputs require @FixedSize")); + + var invalidByteSize = compile("test.InvalidByteSize", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class InvalidByteSize { + @Prove + ZkBool prove(@Secret @FixedSize(0) ZkBytes message, @Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(invalidByteSize.success()); + assertTrue(invalidByteSize.diagnosticsText().contains("@FixedSize value must be positive")); + + var invalidScalarSize = compile("test.InvalidScalarSize", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class InvalidScalarSize { + @Prove + ZkBool prove(@Secret @FixedSize(1) ZkBool flag) { + return flag; + } + } + """); + assertFalse(invalidScalarSize.success()); + assertTrue(invalidScalarSize.diagnosticsText() + .contains("@FixedSize can only be used with ZkArray, ZkBits, or ZkBytes")); + } + @Test void parameterStyleInputNamesDoNotCollideWithGeneratedLocals() throws Exception { var compilation = compile("test.ReservedNames", """ @@ -287,6 +412,12 @@ ZkBool prove( () -> badInputs.getClass().getMethod("siblings", List.class) .invoke(badInputs, List.of(BigInteger.valueOf(7), BigInteger.valueOf(8)))); assertTrue(ex.getCause() instanceof IllegalArgumentException); + + var badBuild = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> companion.getMethod("build", int.class, ZkMerkle.HashType.class) + .invoke(null, 0, ZkMerkle.HashType.POSEIDON)); + assertTrue(badBuild.getCause() instanceof IllegalArgumentException); + assertTrue(badBuild.getCause().getMessage().contains("@FixedSize(param = \"depth\") must be positive")); } @Test @@ -548,6 +679,26 @@ ZkBool prove(@CircuitParam("zk") ZkContext zk, @Public ZkBool ok) { .contains("@CircuitParam is only supported on constructors in Phase 4")); } + @Test + void rejectsVisibilityAnnotationsOnZkContextParameters() throws Exception { + var compilation = compile("test.ContextVisibility", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class ContextVisibility { + @Prove + ZkBool prove(@Secret ZkContext zk, @Public ZkBool ok) { + return ok; + } + } + """); + + assertFalse(compilation.success()); + assertTrue(compilation.diagnosticsText().contains("ZkContext parameters cannot be @Public or @Secret")); + } + @Test void rejectsDuplicateAndUnsafeCircuitParamNames() throws Exception { var duplicate = compile("test.DuplicateCircuitParam", """ @@ -648,6 +799,22 @@ ZkBool prove( """); assertFalse(constants.success()); assertTrue(constants.diagnosticsText().contains("Duplicate generated input constant name")); + + var reservedConstant = compile("test.ReservedInputConstant", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class ReservedInputConstant { + @Prove + ZkBool prove(@Public(name = "circuitName") ZkBool ok) { + return ok; + } + } + """); + assertFalse(reservedConstant.success()); + assertTrue(reservedConstant.diagnosticsText().contains("Duplicate generated input constant name")); } @Test From 3b873d27f30eefe2b73fa44161d489954664ea53 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 03:19:45 +0800 Subject: [PATCH 09/26] phase 8: add advanced symbolic gadget adapters --- docs/adr/circuit-annotation/README.md | 7 + .../circuit-annotation/implementation-plan.md | 4 +- .../phase-8-advanced-symbolic-adapters.md | 74 +++++ docs/circuit-annotation-user-guide.md | 32 +- zeroj-circuit-lib/README.md | 15 +- .../zeroj/circuit/lib/zk/ZkEdDSAJubjub.java | 81 +++++ .../zeroj/circuit/lib/zk/ZkJubjubPoint.java | 200 ++++++++++++ .../zeroj/circuit/lib/zk/ZkPedersen.java | 119 ++++++++ .../circuit/lib/zk/ZkGadgetAdaptersTest.java | 286 ++++++++++++++++++ zeroj-examples/README.md | 2 +- .../AnnotatedPedersenCommitment.java | 25 ++ .../AnnotatedCircuitExamplesTest.java | 26 ++ 12 files changed, 862 insertions(+), 9 deletions(-) create mode 100644 docs/adr/circuit-annotation/phase-8-advanced-symbolic-adapters.md create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkEdDSAJubjub.java create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkJubjubPoint.java create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPedersen.java create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedPedersenCommitment.java diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index 245d19a..3ce9e3f 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -1691,6 +1691,13 @@ Exit criteria: - privacy-template examples use symbolic adapters instead of raw `Signal` plumbing +Implementation status as of Phase 8: completed with symbolic wrappers for +Jubjub points, Pedersen commitments, and EdDSA-Jubjub verification in +`zeroj-circuit-lib`, plus an annotated Pedersen commitment example in +`zeroj-examples`. The advanced adapters enforce BLS12-381 usage for +Jubjub-based operations, constrain Pedersen scalars to the Jubjub subgroup +order, and reject identity EdDSA public keys in-circuit. + ### Phase 9: Integration With Proving Flows Estimated time: 3 to 6 days. diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index 8c4e4ae..c3abe71 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -30,8 +30,8 @@ In progress. | 4 | MVP annotation processor | Completed | b033b03 | | 5 | Schema and input builders | Completed | c7b24b8 | | 6 | Examples and documentation | Completed | d655fee | -| 7 | Bit and byte symbolic inputs | Completed | Pending final phase commit | -| 8 | Advanced gadget adapters | Pending | Pending | +| 7 | Bit and byte symbolic inputs | Completed | 126ef5c | +| 8 | Advanced gadget adapters | Completed | Pending final commit | | 9 | Proving flow integration | Pending | Pending | ## Defaults diff --git a/docs/adr/circuit-annotation/phase-8-advanced-symbolic-adapters.md b/docs/adr/circuit-annotation/phase-8-advanced-symbolic-adapters.md new file mode 100644 index 0000000..3d6f90b --- /dev/null +++ b/docs/adr/circuit-annotation/phase-8-advanced-symbolic-adapters.md @@ -0,0 +1,74 @@ +# Phase 8: Advanced Symbolic Gadget Adapters + +## Status + +Approved and completed for the Phase 8 commit. + +## Goal + +Expose the existing Jubjub, Pedersen, and EdDSA in-circuit gadgets through the +annotation-friendly symbolic API so annotated circuits can use realistic +credential and commitment primitives without dropping to `Variable` plumbing. + +## Implemented Changes + +- Added `ZkJubjubPoint` as an extended-coordinate symbolic point wrapper. +- Added `ZkPedersen` for symbolic Pedersen commitments over `ZkUInt` and + `ZkBits` scalar inputs. +- Added `ZkEdDSAJubjub` for symbolic EdDSA-Jubjub signature verification. +- Kept these adapters in `zeroj-circuit-lib`, not the annotation API module, + because they depend on optional circuit-library gadgets. +- Added differential and negative tests against existing off-circuit and + in-circuit Jubjub/Pedersen/EdDSA behavior. +- Added an annotated Pedersen commitment example using the generated companion + flow. + +## Exit Criteria + +- Pedersen commitments can be computed and verified from symbolic inputs. +- EdDSA-Jubjub verification can be called from `Zk*` proof code. +- Jubjub point equality/addition helpers are usable without raw `Variable` + references. +- BLS12-381 field guards remain intact for Jubjub-based gadgets. +- Tests cover valid and invalid openings/signatures and adapter guardrails. + +## Verification + +- `./gradlew :zeroj-circuit-lib:test --tests com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkGadgetAdaptersTest` + passed. +- `./gradlew :zeroj-examples:test --tests com.bloxbean.cardano.zeroj.examples.annotation.AnnotatedCircuitExamplesTest` + passed. +- `./gradlew :zeroj-circuit-annotation-api:test :zeroj-circuit-annotation-processor:test :zeroj-circuit-dsl:test` + passed. + +Exit criteria results: + +- Pedersen commitments are computed from symbolic `ZkUInt` and LSB-first + `ZkBits` inputs. +- EdDSA-Jubjub verification is callable from `Zk*` proof code. +- Jubjub point constants, affine binding, addition, equality, and affine output + assertions are available without raw `Variable` references. +- BLS12-381 field guards are inherited from existing Jubjub-based gadgets and + covered by negative tests. +- Tests cover valid and invalid Pedersen openings, LSB-first bit-vector inputs, + canonical scalar checks, standalone point field guards, valid and tampered + EdDSA signatures, identity-key rejection, scalar-width guardrails, and the + annotated Pedersen example. + +## Review Results + +Approved after three independent review tracks: + +- API/design review: approved after the point wrapper exposed only trusted + affine construction, documented the trust boundary, and kept Jubjub/Pedersen + helpers layered in `zeroj-circuit-lib`. +- Correctness/security review: approved after the adapters added BLS12-381 + field guards, canonical Pedersen scalar checks, no public extended-coordinate + constructor, and in-circuit EdDSA public-key identity rejection. +- Tests/docs/ergonomics review: approved after adding LSB-first `ZkBits` + Pedersen coverage, standalone point guard tests, the annotated Pedersen + example, and user-guide/README wording for the advanced adapters. + +## Commit + +Pending final phase commit. diff --git a/docs/circuit-annotation-user-guide.md b/docs/circuit-annotation-user-guide.md index a3621f8..68b26a2 100644 --- a/docs/circuit-annotation-user-guide.md +++ b/docs/circuit-annotation-user-guide.md @@ -141,6 +141,34 @@ Each `ZkBits` element is constrained as a boolean. Each `ZkBytes` element is constrained to 8 bits. Generated input builders accept indexed values and `List` values. +## Advanced Gadget Adapters + +`zeroj-circuit-lib` exposes symbolic adapters for the optional circuit-library +gadgets: + +```java +var commitment = ZkPedersen.commit(zk, value, blinding, 64); +commitment.assertAffineEquals(zk, expectedU, expectedV); +``` + +`ZkPedersen.commitBits(...)` accepts LSB-first `ZkBits` scalar inputs. +Pedersen scalar inputs are constrained to canonical Jubjub subgroup scalars +`< l`; range-limit any application amount separately when it has a smaller +business-domain bound. +Jubjub-based adapters use BLS12-381 and preserve the lower-level gadget +contracts: arbitrary witness points are not implicitly curve- or +subgroup-checked. Bind public keys and signature points with +`ZkJubjubPoint.fromTrustedAffine(...)` only after off-circuit curve validity, +subgroup membership, and non-identity checks. `ZkEdDSAJubjub.verify(...)` +also rejects the identity public key in-circuit. + +`ZkEdDSAJubjub.verify(...)` also needs two reduction witnesses, +`kModL` and `kQuotient`, so the circuit can prove the Poseidon challenge was +reduced modulo the Jubjub subgroup order. Compute those host values with +`ZkEdDSAJubjub.witnessComputeKReduction(signature.r(), publicKey, message)` and +bind them as secret `ZkUInt` inputs, using 252 bits for `kModL` and 4 bits for +`kQuotient`. + ## Current Limits - Nested `@ZKCircuit` classes are not supported. @@ -148,5 +176,5 @@ constrained to 8 bits. Generated input builders accept indexed values and - Static `@Prove` methods must use parameter-style inputs. - `@CircuitParam` belongs on constructor parameters, not proof method parameters. -- Packed byte encodings and byte-oriented cryptographic gadgets are deferred; - Phase 7 stores one constrained field element per bit or byte. +- Packed byte encodings are deferred; Phase 7 stores one constrained field + element per bit or byte. diff --git a/zeroj-circuit-lib/README.md b/zeroj-circuit-lib/README.md index d48994f..0d582ec 100644 --- a/zeroj-circuit-lib/README.md +++ b/zeroj-circuit-lib/README.md @@ -17,7 +17,7 @@ or Jubjub-style primitives. | Binary gadgets | `Binary`, `SignalBinary`, `AliasCheck` | | Selection | `Mux` | | Signal helpers | `SignalPoseidon`, `SignalMiMC` | -| Annotation helpers | `ZkPoseidon`, `ZkMiMC`, `ZkMerkle` | +| Annotation helpers | `ZkPoseidon`, `ZkMiMC`, `ZkMerkle`, `ZkJubjubPoint`, `ZkPedersen`, `ZkEdDSAJubjub` | | Jubjub primitives | `JubjubCurve`, `PedersenCommitment`, `EdDSAJubjub`, in-circuit variants | | Poseidon parameters | `PoseidonParams*`, `PoseidonHash`, Grain LFSR generation helpers | @@ -53,11 +53,18 @@ Annotation-based circuits can use symbolic adapters from ```java var hash = ZkPoseidon.hash(zk, left, right); var root = ZkMerkle.computeRoot(zk, leaf, siblings, pathBits, ZkMiMC::hash); +var commitment = ZkPedersen.commit(zk, value, blinding, 64); ``` -These adapters delegate to the existing `Signal*` gadgets and validate that -their inputs belong to the supplied `ZkContext`. `ZkMiMC` is guarded as -BN254-only; use explicit Poseidon parameters when targeting BLS12-381. +These adapters delegate to the existing `Signal*` and in-circuit gadgets and +validate that their inputs belong to the supplied `ZkContext`. `ZkMiMC` is +guarded as BN254-only; use explicit Poseidon parameters when targeting +BLS12-381. Jubjub, Pedersen, and EdDSA-Jubjub adapters are BLS12-381-only and +inherit the curve/subgroup-check contracts documented on the underlying +in-circuit gadgets. Use `ZkJubjubPoint.fromTrustedAffine(...)` only for points +validated off-circuit for curve membership, subgroup membership, and non-identity +where the protocol requires it. `ZkEdDSAJubjub.verify(...)` rejects identity +public keys in-circuit. ## Gradle diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkEdDSAJubjub.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkEdDSAJubjub.java new file mode 100644 index 0000000..04722de --- /dev/null +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkEdDSAJubjub.java @@ -0,0 +1,81 @@ +package com.bloxbean.cardano.zeroj.circuit.lib.zk; + +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; +import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.InCircuitEdDSAJubjub; +import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.JubjubPoint; + +import java.math.BigInteger; +import java.util.Objects; + +/** + * Symbolic EdDSA-Jubjub verification adapter for annotation-based circuits. + * + *

Public key and signature points must be curve-valid subgroup points before + * they are bound with {@link ZkJubjubPoint#fromTrustedAffine}. This verifier + * additionally rejects the identity public key in-circuit. + */ +public final class ZkEdDSAJubjub { + + private ZkEdDSAJubjub() {} + + public record KReduction(BigInteger kModL, BigInteger kQuotient) {} + + public static void verify( + ZkContext zk, + ZkJubjubPoint publicKey, + ZkField message, + ZkJubjubPoint rPoint, + ZkUInt s, + ZkUInt kModL, + ZkUInt kQuotient) { + validateInputs(zk, publicKey, message, rPoint, s, kModL, kQuotient); + publicKey.assertNotIdentity(zk); + InCircuitEdDSAJubjub.verify( + zk.builder().api(), + publicKey.asPoint(), + message.signal().variable(), + rPoint.asPoint(), + s.signal().variable(), + kModL.signal().variable(), + kQuotient.signal().variable()); + } + + public static KReduction witnessComputeKReduction( + JubjubPoint rPoint, + JubjubPoint publicKey, + BigInteger message) { + var reduction = InCircuitEdDSAJubjub.witnessComputeKReduction(rPoint, publicKey, message); + return new KReduction(reduction.kModL(), reduction.kQuotient()); + } + + private static void validateInputs( + ZkContext zk, + ZkJubjubPoint publicKey, + ZkField message, + ZkJubjubPoint rPoint, + ZkUInt s, + ZkUInt kModL, + ZkUInt kQuotient) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(publicKey, "publicKey"); + Objects.requireNonNull(message, "message"); + Objects.requireNonNull(rPoint, "rPoint"); + Objects.requireNonNull(s, "s"); + Objects.requireNonNull(kModL, "kModL"); + Objects.requireNonNull(kQuotient, "kQuotient"); + publicKey.requireSameContext(zk); + rPoint.requireSameContext(zk); + zk.requireSignal(message.signal()); + zk.requireSignal(s.signal()); + zk.requireSignal(kModL.signal()); + zk.requireSignal(kQuotient.signal()); + if (s.bits() > ZkPedersen.MAX_SCALAR_BITS || kModL.bits() > ZkPedersen.MAX_SCALAR_BITS) { + throw new IllegalArgumentException("s and kModL must use at most 252 bits"); + } + if (kQuotient.bits() > 4) { + throw new IllegalArgumentException("kQuotient must use at most 4 bits"); + } + } +} diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkJubjubPoint.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkJubjubPoint.java new file mode 100644 index 0000000..1f2f261 --- /dev/null +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkJubjubPoint.java @@ -0,0 +1,200 @@ +package com.bloxbean.cardano.zeroj.circuit.lib.zk; + +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkValue; +import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.InCircuitJubjub; +import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.JubjubPoint; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; + +import java.util.List; +import java.util.Objects; + +/** + * Symbolic Jubjub point backed by extended-coordinate field values. + * + *

This adapter is intentionally a thin wrapper over {@link InCircuitJubjub}. + * It does not add curve or subgroup checks for arbitrary witness points; callers + * should only bind points that were validated off-circuit or produced by a + * reviewed gadget. + */ +public final class ZkJubjubPoint implements ZkValue { + private final ZkField u; + private final ZkField v; + private final ZkField z; + private final ZkField t; + + private ZkJubjubPoint(ZkField u, ZkField v, ZkField z, ZkField t) { + this.u = Objects.requireNonNull(u, "u"); + this.v = Objects.requireNonNull(v, "v"); + this.z = Objects.requireNonNull(z, "z"); + this.t = Objects.requireNonNull(t, "t"); + } + + /** + * Binds an affine point whose curve validity, subgroup membership, and + * identity policy were checked off-circuit before the values entered the + * circuit. + */ + public static ZkJubjubPoint fromTrustedAffine(ZkContext zk, ZkField u, ZkField v) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(u, "u"); + Objects.requireNonNull(v, "v"); + requireBls12381(zk); + zk.requireSignal(u.signal()); + zk.requireSignal(v.signal()); + return new ZkJubjubPoint( + u, + v, + ZkField.wrap(zk, zk.builder().constant(1)), + u.mul(v)); + } + + public static ZkJubjubPoint constant(ZkContext zk, JubjubPoint point) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(point, "point"); + return wrap(zk, InCircuitJubjub.constant(zk.builder().api(), point)); + } + + static ZkJubjubPoint wrap(ZkContext zk, InCircuitJubjub.Point point) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(point, "point"); + requireBls12381(zk); + return new ZkJubjubPoint( + ZkField.wrap(zk, zk.builder().wrap(point.u())), + ZkField.wrap(zk, zk.builder().wrap(point.v())), + ZkField.wrap(zk, zk.builder().wrap(point.z())), + ZkField.wrap(zk, zk.builder().wrap(point.t()))); + } + + public ZkField u() { + return u; + } + + public ZkField v() { + return v; + } + + public ZkField z() { + return z; + } + + public ZkField t() { + return t; + } + + public ZkJubjubPoint add(ZkContext zk, ZkJubjubPoint other) { + requireSameContext(zk); + other.requireSameContext(zk); + requireBls12381(zk); + return wrap(zk, InCircuitJubjub.add(zk.builder().api(), asPoint(), other.asPoint())); + } + + public ZkJubjubPoint doubled(ZkContext zk) { + requireSameContext(zk); + requireBls12381(zk); + return wrap(zk, InCircuitJubjub.doubled(zk.builder().api(), asPoint())); + } + + public static ZkJubjubPoint select( + ZkContext zk, + ZkBool condition, + ZkJubjubPoint ifTrue, + ZkJubjubPoint ifFalse) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(condition, "condition"); + Objects.requireNonNull(ifTrue, "ifTrue"); + Objects.requireNonNull(ifFalse, "ifFalse"); + ifTrue.requireSameContext(zk); + ifFalse.requireSameContext(zk); + zk.requireSignal(condition.signal()); + requireBls12381(zk); + return wrap(zk, InCircuitJubjub.select( + zk.builder().api(), condition.signal().variable(), ifTrue.asPoint(), ifFalse.asPoint())); + } + + public void assertEqual(ZkContext zk, ZkJubjubPoint other) { + requireSameContext(zk); + other.requireSameContext(zk); + requireBls12381(zk); + var api = zk.builder().api(); + api.assertEqual(api.mul(u.signal().variable(), other.z.signal().variable()), + api.mul(other.u.signal().variable(), z.signal().variable())); + api.assertEqual(api.mul(v.signal().variable(), other.z.signal().variable()), + api.mul(other.v.signal().variable(), z.signal().variable())); + } + + public ZkBool isEqual(ZkContext zk, ZkJubjubPoint other) { + requireSameContext(zk); + other.requireSameContext(zk); + requireBls12381(zk); + var api = zk.builder().api(); + var sameU = api.isEqual( + api.mul(u.signal().variable(), other.z.signal().variable()), + api.mul(other.u.signal().variable(), z.signal().variable())); + var sameV = api.isEqual( + api.mul(v.signal().variable(), other.z.signal().variable()), + api.mul(other.v.signal().variable(), z.signal().variable())); + return ZkBool.wrap(zk, zk.builder().wrap(api.and(sameU, sameV))); + } + + public ZkBool isIdentity(ZkContext zk) { + requireSameContext(zk); + requireBls12381(zk); + var api = zk.builder().api(); + return ZkBool.wrap(zk, zk.builder().wrap(api.and( + api.isZero(u.signal().variable()), + api.isEqual(v.signal().variable(), z.signal().variable())))); + } + + public void assertNotIdentity(ZkContext zk) { + isIdentity(zk).assertFalse(); + } + + public void assertAffineEquals(ZkContext zk, ZkField affineU, ZkField affineV) { + Objects.requireNonNull(affineU, "affineU"); + Objects.requireNonNull(affineV, "affineV"); + requireSameContext(zk); + zk.requireSignal(affineU.signal()); + zk.requireSignal(affineV.signal()); + requireBls12381(zk); + var api = zk.builder().api(); + api.assertEqual(api.mul(affineU.signal().variable(), z.signal().variable()), u.signal().variable()); + api.assertEqual(api.mul(affineV.signal().variable(), z.signal().variable()), v.signal().variable()); + } + + @Override + public List signals() { + return List.of(u.signal(), v.signal(), z.signal(), t.signal()); + } + + @Override + public void assertWellFormed() { + u.assertWellFormed(); + v.assertWellFormed(); + z.assertWellFormed(); + t.assertWellFormed(); + } + + InCircuitJubjub.Point asPoint() { + return new InCircuitJubjub.Point( + u.signal().variable(), + v.signal().variable(), + z.signal().variable(), + t.signal().variable()); + } + + void requireSameContext(ZkContext zk) { + Objects.requireNonNull(zk, "zk"); + zk.requireSignal(u.signal()); + zk.requireSignal(v.signal()); + zk.requireSignal(z.signal()); + zk.requireSignal(t.signal()); + } + + private static void requireBls12381(ZkContext zk) { + zk.builder().api().requireField(PoseidonParamsBLS12_381T3.INSTANCE.field()); + } +} diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPedersen.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPedersen.java new file mode 100644 index 0000000..82d65af --- /dev/null +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPedersen.java @@ -0,0 +1,119 @@ +package com.bloxbean.cardano.zeroj.circuit.lib.zk; + +import com.bloxbean.cardano.zeroj.circuit.Variable; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBits; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; +import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.InCircuitPedersen; +import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.JubjubCurve; + +import java.util.Objects; + +/** + * Symbolic Pedersen commitment adapter for annotation-based circuits. + */ +public final class ZkPedersen { + public static final int MAX_SCALAR_BITS = 252; + + private ZkPedersen() {} + + public static ZkJubjubPoint commit(ZkContext zk, ZkUInt value, ZkUInt blinding) { + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(blinding, "blinding"); + return commit(zk, value, blinding, Math.max(value.bits(), blinding.bits())); + } + + public static ZkJubjubPoint commit(ZkContext zk, ZkUInt value, ZkUInt blinding, int scalarBits) { + validateScalarInputs(zk, value, blinding, scalarBits); + assertCanonicalScalar(zk, value.signal().variable()); + assertCanonicalScalar(zk, blinding.signal().variable()); + return ZkJubjubPoint.wrap(zk, InCircuitPedersen.commit( + zk.builder().api(), + value.signal().variable(), + blinding.signal().variable(), + scalarBits)); + } + + /** + * Commits using LSB-first scalar bit vectors. + */ + public static ZkJubjubPoint commitBits(ZkContext zk, ZkBits valueBits, ZkBits blindingBits) { + validateBitInputs(zk, valueBits, blindingBits); + Variable[] valueVariables = variables(valueBits); + Variable[] blindingVariables = variables(blindingBits); + assertCanonicalScalar(zk, zk.builder().api().fromBinary(valueVariables)); + assertCanonicalScalar(zk, zk.builder().api().fromBinary(blindingVariables)); + return ZkJubjubPoint.wrap(zk, InCircuitPedersen.commit( + zk.builder().api(), + valueVariables, + blindingVariables)); + } + + public static void verifyOpening( + ZkContext zk, + ZkJubjubPoint commitment, + ZkUInt value, + ZkUInt blinding, + int scalarBits) { + Objects.requireNonNull(commitment, "commitment"); + commitment.requireSameContext(zk); + commit(zk, value, blinding, scalarBits).assertEqual(zk, commitment); + } + + public static void verifyOpening( + ZkContext zk, + ZkJubjubPoint commitment, + ZkUInt value, + ZkUInt blinding) { + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(blinding, "blinding"); + verifyOpening(zk, commitment, value, blinding, Math.max(value.bits(), blinding.bits())); + } + + private static void validateScalarInputs(ZkContext zk, ZkUInt value, ZkUInt blinding, int scalarBits) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(blinding, "blinding"); + validateScalarBits(scalarBits); + if (value.bits() > scalarBits || blinding.bits() > scalarBits) { + throw new IllegalArgumentException("scalarBits must cover value and blinding bit widths"); + } + zk.requireSignal(value.signal()); + zk.requireSignal(blinding.signal()); + } + + private static void validateBitInputs(ZkContext zk, ZkBits valueBits, ZkBits blindingBits) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(valueBits, "valueBits"); + Objects.requireNonNull(blindingBits, "blindingBits"); + validateScalarBits(valueBits.size()); + validateScalarBits(blindingBits.size()); + for (var bit : valueBits.values()) { + zk.requireSignal(bit.signal()); + } + for (var bit : blindingBits.values()) { + zk.requireSignal(bit.signal()); + } + } + + private static void validateScalarBits(int scalarBits) { + if (scalarBits <= 0 || scalarBits > MAX_SCALAR_BITS) { + throw new IllegalArgumentException("scalarBits must be in [1, " + MAX_SCALAR_BITS + "]"); + } + } + + private static void assertCanonicalScalar(ZkContext zk, Variable scalar) { + var api = zk.builder().api(); + api.assertEqual( + api.lessThan(scalar, api.constant(JubjubCurve.SUBGROUP_ORDER), MAX_SCALAR_BITS), + api.constant(1)); + } + + private static Variable[] variables(ZkBits bits) { + Variable[] variables = new Variable[bits.size()]; + for (int i = 0; i < bits.size(); i++) { + variables[i] = bits.get(i).signal().variable(); + } + return variables; + } +} diff --git a/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java b/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java index 46f955c..cfed06f 100644 --- a/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java +++ b/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java @@ -5,11 +5,17 @@ import com.bloxbean.cardano.zeroj.circuit.Signal; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBits; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; import com.bloxbean.cardano.zeroj.circuit.lib.SignalMerkle; import com.bloxbean.cardano.zeroj.circuit.lib.SignalMiMC; import com.bloxbean.cardano.zeroj.circuit.lib.SignalPoseidon; +import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.EdDSAJubjub; +import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.JubjubCurve; +import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.JubjubPoint; +import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.PedersenCommitment; import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBN254T3; import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; @@ -25,6 +31,12 @@ import static org.junit.jupiter.api.Assertions.assertThrows; class ZkGadgetAdaptersTest { + private static final BigInteger EDDSA_SK = new BigInteger( + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", 16) + .mod(JubjubCurve.SUBGROUP_ORDER); + private static final BigInteger EDDSA_MSG = new BigInteger( + "0101010101010101010101010101010101010101010101010101010101010101", 16) + .mod(JubjubCurve.BASE_FIELD_PRIME); @Test void mimcAdapterMatchesSignalAdapter() { @@ -334,6 +346,237 @@ void merkleRejectsCustomHashReturningDifferentBuilderSignal() { })); } + @Test + void jubjubPointAdapterAddsConstants() { + JubjubPoint p = JubjubPoint.SUBGROUP_GENERATOR.scalarMul(BigInteger.valueOf(13)); + JubjubPoint q = JubjubPoint.SUBGROUP_GENERATOR.scalarMul(BigInteger.valueOf(27)); + JubjubPoint expected = p.add(q); + + var circuit = CircuitBuilder.create("zk-jubjub-add") + .publicVar("outU") + .publicVar("outV") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkJubjubPoint.constant(zk, p) + .add(zk, ZkJubjubPoint.constant(zk, q)) + .assertAffineEquals( + zk, + ZkField.publicInput(c, "outU"), + ZkField.publicInput(c, "outV")); + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "outU", List.of(expected.affineU()), + "outV", List.of(expected.affineV())), CurveId.BLS12_381)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "outU", List.of(BigInteger.ONE), + "outV", List.of(expected.affineV())), CurveId.BLS12_381)); + } + + @Test + void jubjubPointAdapterHasNoPublicExtendedCoordinateConstructorAndGuardsField() { + assertEquals(0, ZkJubjubPoint.class.getConstructors().length); + + var circuit = CircuitBuilder.create("zk-jubjub-affine-field") + .publicVar("u") + .publicVar("v") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkJubjubPoint.fromTrustedAffine( + zk, + ZkField.publicInput(c, "u"), + ZkField.publicInput(c, "v")) + .assertAffineEquals( + zk, + ZkField.publicInput(c, "u"), + ZkField.publicInput(c, "v")); + }); + + assertDoesNotThrow(() -> circuit.compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); + } + + @Test + void pedersenAdapterMatchesOffCircuitCommitment() { + BigInteger value = BigInteger.valueOf(42); + BigInteger blinding = BigInteger.valueOf(12345); + JubjubPoint expected = PedersenCommitment.commit(value, blinding); + + var circuit = CircuitBuilder.create("zk-pedersen") + .publicVar("outU") + .publicVar("outV") + .secretVar("value") + .secretVar("blinding") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkPedersen.commit( + zk, + ZkUInt.secret(c, "value", 16), + ZkUInt.secret(c, "blinding", 16), + 16) + .assertAffineEquals( + zk, + ZkField.publicInput(c, "outU"), + ZkField.publicInput(c, "outV")); + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "outU", List.of(expected.affineU()), + "outV", List.of(expected.affineV()), + "value", List.of(value), + "blinding", List.of(blinding)), CurveId.BLS12_381)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "outU", List.of(expected.affineU().add(BigInteger.ONE)), + "outV", List.of(expected.affineV()), + "value", List.of(value), + "blinding", List.of(blinding)), CurveId.BLS12_381)); + } + + @Test + void pedersenAdapterSupportsLsbFirstBitInputs() { + BigInteger value = BigInteger.valueOf(5); + BigInteger blinding = BigInteger.valueOf(3); + JubjubPoint expected = PedersenCommitment.commit(value, blinding); + + var circuit = CircuitBuilder.create("zk-pedersen-bits") + .publicVar("outU") + .publicVar("outV") + .secretVar("valueBit_0") + .secretVar("valueBit_1") + .secretVar("valueBit_2") + .secretVar("valueBit_3") + .secretVar("blindingBit_0") + .secretVar("blindingBit_1") + .secretVar("blindingBit_2") + .secretVar("blindingBit_3") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkPedersen.commitBits( + zk, + ZkBits.secret(c, "valueBit", 4), + ZkBits.secret(c, "blindingBit", 4)) + .assertAffineEquals( + zk, + ZkField.publicInput(c, "outU"), + ZkField.publicInput(c, "outV")); + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "outU", List.of(expected.affineU()), + "outV", List.of(expected.affineV()), + "valueBit_0", List.of(BigInteger.ONE), + "valueBit_1", List.of(BigInteger.ZERO), + "valueBit_2", List.of(BigInteger.ONE), + "valueBit_3", List.of(BigInteger.ZERO), + "blindingBit_0", List.of(BigInteger.ONE), + "blindingBit_1", List.of(BigInteger.ONE), + "blindingBit_2", List.of(BigInteger.ZERO), + "blindingBit_3", List.of(BigInteger.ZERO)), CurveId.BLS12_381)); + } + + @Test + void pedersenAdapterRejectsInvalidScalarWidthAndWrongCurve() { + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-pedersen-width") + .secretVar("value") + .secretVar("blinding") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkPedersen.commit( + zk, + ZkUInt.secret(c, "value", 253), + ZkUInt.secret(c, "blinding", 8)); + })); + + var circuit = CircuitBuilder.create("zk-pedersen-field") + .secretVar("value") + .secretVar("blinding") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkPedersen.commit( + zk, + ZkUInt.secret(c, "value", 8), + ZkUInt.secret(c, "blinding", 8)); + }); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); + } + + @Test + void pedersenAdapterRejectsNonCanonicalScalarWitness() { + var circuit = CircuitBuilder.create("zk-pedersen-canonical") + .publicVar("outU") + .publicVar("outV") + .secretVar("value") + .secretVar("blinding") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkPedersen.commit( + zk, + ZkUInt.secret(c, "value", 252), + ZkUInt.secret(c, "blinding", 252), + 252) + .assertAffineEquals( + zk, + ZkField.publicInput(c, "outU"), + ZkField.publicInput(c, "outV")); + }); + + var zeroCommitment = PedersenCommitment.commit(BigInteger.ZERO, BigInteger.ZERO); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "outU", List.of(zeroCommitment.affineU()), + "outV", List.of(zeroCommitment.affineV()), + "value", List.of(JubjubCurve.SUBGROUP_ORDER), + "blinding", List.of(BigInteger.ZERO)), CurveId.BLS12_381)); + } + + @Test + void eddsaJubjubAdapterAcceptsValidSignatureAndRejectsTamperedMessage() { + EdDSAJubjub.Keypair keypair = EdDSAJubjub.keypairFromSecret(EDDSA_SK); + EdDSAJubjub.Signature signature = EdDSAJubjub.sign(EDDSA_SK, EDDSA_MSG); + var circuit = buildEddsaVerifyCircuit(); + + assertDoesNotThrow(() -> circuit.calculateWitness( + eddsaWitness(keypair, signature, EDDSA_MSG), CurveId.BLS12_381)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness( + eddsaWitness(keypair, signature, EDDSA_MSG.add(BigInteger.ONE)), CurveId.BLS12_381)); + } + + @Test + void eddsaJubjubAdapterRejectsIdentityPublicKey() { + BigInteger s = BigInteger.valueOf(5); + EdDSAJubjub.Keypair identityKey = new EdDSAJubjub.Keypair(BigInteger.ONE, JubjubPoint.IDENTITY); + EdDSAJubjub.Signature forged = new EdDSAJubjub.Signature( + JubjubPoint.SUBGROUP_GENERATOR.scalarMul(s), s); + + assertThrows(ArithmeticException.class, () -> buildEddsaVerifyCircuit() + .calculateWitness(eddsaWitness(identityKey, forged, EDDSA_MSG), CurveId.BLS12_381)); + } + + @Test + void eddsaJubjubAdapterRejectsOversizedScalarMetadata() { + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-eddsa-width") + .publicVar("pkU").publicVar("pkV") + .publicVar("rU").publicVar("rV") + .publicVar("msg").publicVar("s") + .secretVar("kModL").secretVar("kQuotient") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkEdDSAJubjub.verify( + zk, + ZkJubjubPoint.fromTrustedAffine( + zk, + ZkField.publicInput(c, "pkU"), + ZkField.publicInput(c, "pkV")), + ZkField.publicInput(c, "msg"), + ZkJubjubPoint.fromTrustedAffine( + zk, + ZkField.publicInput(c, "rU"), + ZkField.publicInput(c, "rV")), + ZkUInt.publicInput(c, "s", 253), + ZkUInt.secret(c, "kModL", 252), + ZkUInt.secret(c, "kQuotient", 4)); + })); + } + private BigInteger expectedMiMCMerkleRoot( BigInteger leaf, BigInteger sibling0, @@ -378,4 +621,47 @@ private BigInteger signalMiMCOffCircuit(BigInteger left, BigInteger right) { return hash; } + private CircuitBuilder buildEddsaVerifyCircuit() { + return CircuitBuilder.create("zk-eddsa-jubjub") + .publicVar("pkU").publicVar("pkV") + .publicVar("rU").publicVar("rV") + .publicVar("msg").publicVar("s") + .secretVar("kModL").secretVar("kQuotient") + .defineSignals(c -> { + var zk = new ZkContext(c); + var publicKey = ZkJubjubPoint.fromTrustedAffine( + zk, + ZkField.publicInput(c, "pkU"), + ZkField.publicInput(c, "pkV")); + var rPoint = ZkJubjubPoint.fromTrustedAffine( + zk, + ZkField.publicInput(c, "rU"), + ZkField.publicInput(c, "rV")); + ZkEdDSAJubjub.verify( + zk, + publicKey, + ZkField.publicInput(c, "msg"), + rPoint, + ZkUInt.publicInput(c, "s", 252), + ZkUInt.secret(c, "kModL", 252), + ZkUInt.secret(c, "kQuotient", 4)); + }); + } + + private Map> eddsaWitness( + EdDSAJubjub.Keypair keypair, + EdDSAJubjub.Signature signature, + BigInteger message) { + var kReduction = ZkEdDSAJubjub.witnessComputeKReduction(signature.r(), keypair.pk(), message); + return Map.of( + "pkU", List.of(keypair.pk().affineU()), + "pkV", List.of(keypair.pk().affineV()), + "rU", List.of(signature.r().affineU()), + "rV", List.of(signature.r().affineV()), + "msg", List.of(message), + "s", List.of(signature.s()), + "kModL", List.of(kReduction.kModL()), + "kQuotient", List.of(kReduction.kQuotient())); + } + } diff --git a/zeroj-examples/README.md b/zeroj-examples/README.md index f0a4d7a..d39b25e 100644 --- a/zeroj-examples/README.md +++ b/zeroj-examples/README.md @@ -18,7 +18,7 @@ End-to-end demonstrations of ZeroJ capabilities -- from Java DSL circuit definit Write circuits as annotated Java classes and use generated companions for `build(...)`, `schema(...)`, and witness input builders. - **Examples**: range proof, age verification, private transfer, MiMC - commitment, parameterized Merkle membership + commitment, parameterized Merkle membership, Pedersen commitment - **Source**: [`examples/annotation`](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation) - **Tests**: [`AnnotatedCircuitExamplesTest.java`](src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java) - **Guide**: [`docs/circuit-annotation-user-guide.md`](../docs/circuit-annotation-user-guide.md) diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedPedersenCommitment.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedPedersenCommitment.java new file mode 100644 index 0000000..e575960 --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedPedersenCommitment.java @@ -0,0 +1,25 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.UInt; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkPedersen; + +@ZKCircuit(name = "annotation-pedersen-commitment") +public class AnnotatedPedersenCommitment { + @Prove + void prove( + ZkContext zk, + @Secret @UInt(bits = 16) ZkUInt value, + @Secret @UInt(bits = 16) ZkUInt blinding, + @Public ZkField expectedU, + @Public ZkField expectedV) { + ZkPedersen.commit(zk, value, blinding, 16) + .assertAffineEquals(zk, expectedU, expectedV); + } +} diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java index 264d600..128ae83 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java @@ -2,6 +2,7 @@ import com.bloxbean.cardano.zeroj.api.CurveId; import com.bloxbean.cardano.zeroj.circuit.FieldConfig; +import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.PedersenCommitment; import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMerkle; import com.bloxbean.cardano.zeroj.examples.dsl.common.MiMCHash; import org.junit.jupiter.api.Test; @@ -153,6 +154,31 @@ void parameterizedMerkleMembershipUsesDepthAndHashType() { () -> circuit.calculateWitness(invalid.toWitnessMap(), CurveId.BN254)); } + @Test + void pedersenCommitmentUsesAdvancedSymbolicAdapter() { + var circuit = AnnotatedPedersenCommitmentCircuit.build(); + var value = BigInteger.valueOf(42); + var blinding = BigInteger.valueOf(12345); + var commitment = PedersenCommitment.commit(value, blinding); + + var inputs = AnnotatedPedersenCommitmentCircuit.inputs() + .value(value) + .blinding(blinding) + .expectedU(commitment.affineU()) + .expectedV(commitment.affineV()); + + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); + + var invalid = AnnotatedPedersenCommitmentCircuit.inputs() + .value(value) + .blinding(blinding) + .expectedU(commitment.affineU().add(BigInteger.ONE)) + .expectedV(commitment.affineV()); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(invalid.toWitnessMap(), CurveId.BLS12_381)); + } + private BigInteger merkleRoot( BigInteger leaf, List siblings, From d3caf903ccef0dd9090f0f6d54484ea61d19e661 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 03:41:00 +0800 Subject: [PATCH 10/26] phase 9: integrate annotated circuits with proving flows --- docs/adr/circuit-annotation/README.md | 69 ++++-- .../circuit-annotation/implementation-plan.md | 4 +- .../phase-9-proving-flow-integration.md | 102 +++++++++ docs/circuit-annotation-user-guide.md | 61 +++++- zeroj-circuit-annotation-api/build.gradle | 1 + .../zeroj/circuit/annotation/ZKCircuit.java | 1 + .../circuit/annotation/ZkCircuitMetadata.java | 77 +++++++ .../zeroj/circuit/annotation/ZkInputMap.java | 6 + .../circuit/annotation/package-info.java | 4 +- .../annotation/ZkSymbolicTypesTest.java | 38 ++++ .../processor/CircuitAnnotationProcessor.java | 143 ++++++++++-- .../CircuitAnnotationProcessorTest.java | 204 +++++++++++++++++- zeroj-examples/README.md | 3 +- .../AnnotatedAgeVerificationProofHelper.java | 84 ++++++++ .../AnnotatedCircuitExamplesTest.java | 63 +++++- 15 files changed, 815 insertions(+), 45 deletions(-) create mode 100644 docs/adr/circuit-annotation/phase-9-proving-flow-integration.md create mode 100644 zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitMetadata.java create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAgeVerificationProofHelper.java diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index 3ce9e3f..d9be8c4 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -24,7 +24,7 @@ pipeline. The target developer experience is: ```java -@ZKCircuit(name = "range-proof") +@ZKCircuit(name = "range-proof", version = 1) public class RangeProof { @Secret @UInt(bits = 64) @@ -170,6 +170,14 @@ R1CS / PlonK / Halo2 compilers and witness calculator No new constraint representation is introduced. +`@ZKCircuit(version = ...)` is author-controlled and defaults to `1`. Generated +`CircuitId` values are name-based, while the version is carried in +`ZkCircuitMetadata` and proof-envelope metadata. This keeps key lookup policy +explicit: deployments may key only by circuit name, or by name plus version. +Parameterized circuit names always append a canonical parameter suffix, even +when `nameTemplate` is used as a readable prefix, so different parameter sets do +not collide under ambiguous template formatting. + ## Module Design ### `zeroj-circuit-annotation-api` @@ -196,7 +204,7 @@ Jubjub, or Pedersen. Contents: -- annotations: `@ZKCircuit`, `@Prove`, `@Public`, `@Secret`, +- annotations: `@ZKCircuit`, `@ZKCircuit(version = N)`, `@Prove`, `@Public`, `@Secret`, `@CircuitParam`, `@UInt`, `@FieldElement`, `@FixedSize`, `@Order` - symbolic values: `ZkValue`, `ZkField`, `ZkBool`, `ZkUInt`, `ZkArray` - deferred symbolic values: `ZkBits`, `ZkBytes` @@ -458,15 +466,15 @@ Marks a class as a circuit source. public @interface ZKCircuit { String name() default ""; String nameTemplate() default ""; + int version() default 1; } ``` If `name` is empty, use the Java class name converted to a stable circuit name. For parameterized circuits, `nameTemplate` may include build-time parameters, -for example `merkle-{depth}-{hashType}`. If `nameTemplate` is empty and the -circuit has `@CircuitParam` values, generated code must append a canonical -parameter suffix to avoid reusing one circuit identity for different constraint -systems. +for example `merkle-{depth}-{hashType}`. The rendered template is a readable +prefix only; every parameterized circuit name appends a canonical parameter +suffix to avoid reusing one circuit identity for different constraint systems. ### `@Prove` @@ -955,17 +963,21 @@ The final generated class should include: - `inputs()` - parameterized `inputs(...)` when the source uses `@CircuitParam` - `publicInputs(...)` helper if useful +- typed `PublicInputs` extraction +- `calculateWitness(...)` helper +- `circuitId(...)`, `metadata(...)`, and `proofEnvelopeBuilder(...)` - constants for generated input names Phase 4 generates only `build(...)` and constants. Phase 5 adds `schema(...)`, -`inputs(...)`, and `publicInputs(...)`. Examples at the top of this ADR describe -the final target surface after Phase 5. +`inputs(...)`, and `publicInputs(...)`. Phase 9 adds typed public inputs, +circuit metadata, witness, and proof-envelope helpers. Example generated public API: ```java public final class RangeProofCircuit { public static final String CIRCUIT_NAME = "range-proof"; + public static final int CIRCUIT_VERSION = 1; public static final String SECRET = "secret"; public static final String LO = "lo"; public static final String HI = "hi"; @@ -976,12 +988,30 @@ public final class RangeProofCircuit { public static Inputs inputs(); + public static CircuitId circuitId(); + + public static ZkCircuitMetadata metadata(); + + public static PublicInputs publicInputValues(Inputs inputs); + + public static BigInteger[] calculateWitness(CircuitBuilder circuit, Inputs inputs, CurveId curve); + + public static ZkProofEnvelope.Builder proofEnvelopeBuilder( + CircuitBuilder circuit, + ProofSystemId proofSystem, + CurveId curve, + byte[] proofBytes, + Inputs inputs, + VerificationKeyRef vkRef); + public static final class Inputs { public Inputs secret(BigInteger value); public Inputs lo(BigInteger value); public Inputs hi(BigInteger value); public Map> toWitnessMap(); public List publicValues(); + public PublicInputs toPublicInputs(); + public BigInteger[] calculateWitness(CircuitBuilder circuit, CurveId curve); } } ``` @@ -1712,8 +1742,22 @@ Deliverables: Exit criteria: -- annotated circuit can go from source code to compile, witness, prove, verify - in an example +- annotated circuit can go from source code to compile, witness calculation, + optional witness export, prover handoff, and proof-envelope construction in an + example. Native proof generation and verification remain in opt-in E2E tests + because they depend on external/native prover setup. + +Implementation status as of Phase 9: completed with generated typed public +inputs, circuit ID/version metadata, witness helpers, and proof-envelope builder +helpers. `AnnotatedAgeVerificationProofHelper` shows generated annotated +circuits feeding existing R1CS, witness export, gnark helper, and +`ZkProofEnvelope` APIs without adding prover-specific dependencies to generated +companions. +Generated helpers validate that a supplied `CircuitBuilder` name matches the +generated `Inputs` schema before witness calculation or envelope construction. +`@CircuitParam` types are restricted to stable primitives/boxed values, `String`, +`BigInteger`, and enums; generated names and metadata use canonical +`length:value` parameter encoding. ## Suggested Release Slices @@ -1765,6 +1809,7 @@ Includes: - generated source stability tests - native-image review - documentation polish +- typed public input and proof-envelope helpers - end-to-end prover examples ## Phase 0 Decisions @@ -1779,9 +1824,9 @@ Includes: | Parameter style | Ship in MVP alongside field style. | | Field ordering | Public fields first, then secret fields; within each group sort non-negative unique `@Order` values first, then unordered fields by stable javac source order; fail if stable order is unavailable. | | Circuit parameters | Support constructor parameters and final fields initialized from those parameters. | -| Parameterized names | Use `nameTemplate` when provided; otherwise append a canonical parameter suffix. | +| Parameterized names | Use `nameTemplate` as a readable prefix when provided; always append a canonical parameter suffix. | | Processor output | Generate source code only. | -| First generated API slice | Phase 4 emits `build(...)` and constants; Phase 5 adds `schema(...)`, `inputs(...)`, and `publicInputs(...)`. | +| First generated API slice | Phase 4 emits `build(...)` and constants; Phase 5 adds `schema(...)`, `inputs(...)`, and `publicInputs(...)`; Phase 9 adds typed public inputs, metadata, witness, and proof-envelope helpers. | | First usable gadget adapters | `ZkMiMC`, `ZkPoseidon`, and basic `ZkMerkle`. | | BOM scope | Include annotation modules in `zeroj-bom-core` and `zeroj-bom-all` during Phase 1. | | UInt arithmetic width | Be conservative; arithmetic methods document range behavior and callers assert output range where overflow matters. | diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index c3abe71..4355b10 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -31,8 +31,8 @@ In progress. | 5 | Schema and input builders | Completed | c7b24b8 | | 6 | Examples and documentation | Completed | d655fee | | 7 | Bit and byte symbolic inputs | Completed | 126ef5c | -| 8 | Advanced gadget adapters | Completed | Pending final commit | -| 9 | Proving flow integration | Pending | Pending | +| 8 | Advanced gadget adapters | Completed | 3b873d2 | +| 9 | Proving flow integration | Completed | Phase 9 commit | ## Defaults diff --git a/docs/adr/circuit-annotation/phase-9-proving-flow-integration.md b/docs/adr/circuit-annotation/phase-9-proving-flow-integration.md new file mode 100644 index 0000000..86e4bf1 --- /dev/null +++ b/docs/adr/circuit-annotation/phase-9-proving-flow-integration.md @@ -0,0 +1,102 @@ +# Phase 9: Proving Flow Integration + +## Status + +Approved and completed for the Phase 9 commit. + +## Goal + +Make generated annotated circuits easy to hand to the existing ZeroJ compile, +witness, prover, verifier, and proof-envelope APIs without adding prover-specific +dependencies to the annotation API or generated companions. + +## Implemented Changes + +- Added `ZkCircuitMetadata` to carry generated circuit ID, circuit version, and + parameter metadata for proof envelopes. +- Added `@ZKCircuit(version = ...)` so circuit versions are author-controlled + and validated as positive integers. +- Added canonical parameter suffixes to parameterized circuit names, including + `nameTemplate`-based names, to avoid collisions between different parameter + sets. +- Added `ZkInputMap.publicInputs(schema)` so generated input builders can return + typed `PublicInputs` in canonical schema order. +- Extended generated companions with: + - `CIRCUIT_VERSION` + - `circuitId(...)` + - `metadata(...)` + - `publicInputValues(inputs)` + - `calculateWitness(circuit, inputs, curve)` + - `proofEnvelopeBuilder(circuit, ...)` +- Extended generated `Inputs` classes with: + - `toPublicInputs()` + - `calculateWitness(circuit, curve)` +- Added `AnnotatedAgeVerificationProofHelper` as the proof-flow example: + - compiles the generated annotated circuit to R1CS + - calculates witnesses through generated input builders + - exports `.wtns` bytes with the existing `WitnessExporter` + - passes generated witness maps to `GnarkProverHelper` + - builds `ZkProofEnvelope` values from generated metadata and public inputs +- Updated the user guide, ADR, examples README, and implementation plan. + +## Exit Criteria + +- Annotated circuits can expose typed public inputs in verifier order. +- Generated companions expose circuit ID/version metadata for proof envelopes. +- Existing prover helpers can consume generated circuits and input builders. +- Optional witness-byte export remains outside generated companions. +- Tests cover metadata, public input extraction, witness calculation, and proof + envelope construction. + +## Verification + +- `./gradlew :zeroj-circuit-annotation-api:test --tests com.bloxbean.cardano.zeroj.circuit.annotation.ZkSymbolicTypesTest` + passed. +- `./gradlew :zeroj-circuit-annotation-processor:test --tests com.bloxbean.cardano.zeroj.circuit.annotation.processor.CircuitAnnotationProcessorTest` + passed. +- `./gradlew :zeroj-examples:test --tests com.bloxbean.cardano.zeroj.examples.annotation.AnnotatedCircuitExamplesTest` + passed. +- `./gradlew :zeroj-circuit-dsl:test :zeroj-circuit-lib:test --tests com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkGadgetAdaptersTest` + passed. + +Exit criteria results: + +- `Inputs.toPublicInputs()` and `ZkInputMap.publicInputs(schema)` produce typed + `PublicInputs` in schema order. +- Generated `circuitId(...)` and `metadata(...)` expose stable circuit identity, + author-controlled version, and `@CircuitParam` metadata. Circuit IDs are + name-based; version is carried separately in proof-envelope metadata so key + registries can choose name-only or name-plus-version lookup policies. + Parameterized circuit IDs include a canonical `length:value` parameter suffix + even when a readable `nameTemplate` prefix is present. +- Generated `calculateWitness(...)` helpers delegate to the existing + `CircuitBuilder` witness calculator after validating that the circuit name + matches the generated input schema name. +- `AnnotatedAgeVerificationProofHelper` demonstrates R1CS generation, witness + calculation, `.wtns` export, gnark handoff, and proof-envelope construction + without requiring native proving during unit tests. +- Processor tests reject generated constant collisions with `CIRCUIT_VERSION`, + invalid circuit versions, unsupported `@CircuitParam` types, and mismatched + circuit/input shapes. Template-collision tests confirm that ambiguous + placeholders such as `{a}{b}` still produce distinct circuit names for + distinct parameter maps. + +## Review Results + +Approved after three independent review tracks: + +- API/design review: approved after adding author-controlled + `@ZKCircuit(version = ...)`, direct `zeroj-api` exposure from the annotation + API module, and explicit name-based `CircuitId` plus metadata-carried version + semantics. +- Correctness/security review: approved after generated witness and envelope + helpers validate circuit/schema identity, supported `@CircuitParam` types were + restricted, null parameters were rejected, and every parameterized circuit name + gained a canonical parameter suffix to avoid `nameTemplate` collisions. +- Docs/examples review: approved after the proof helper rejected prover-response + curve mismatches, docs stopped claiming native prove/verify in unit tests, and + ADR wording consistently described readable templates plus canonical suffixes. + +## Commit + +Included in the Phase 9 commit. diff --git a/docs/circuit-annotation-user-guide.md b/docs/circuit-annotation-user-guide.md index 68b26a2..bad8f35 100644 --- a/docs/circuit-annotation-user-guide.md +++ b/docs/circuit-annotation-user-guide.md @@ -7,7 +7,7 @@ ZeroJ circuits at compile time. ## Minimal Range Proof ```java -@ZKCircuit(name = "range-proof") +@ZKCircuit(name = "range-proof", version = 1) public class RangeProof { @Secret @UInt(bits = 16) ZkUInt secret; @@ -37,6 +37,16 @@ var inputs = RangeProofCircuit.inputs() circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254); inputs.publicValues(); +inputs.toPublicInputs(); + +var envelope = RangeProofCircuit.proofEnvelopeBuilder( + circuit, + ProofSystemId.GROTH16, + CurveId.BN254, + proofJson.getBytes(StandardCharsets.UTF_8), + inputs, + new VerificationKeyRef.ById("range-proof-v1")) + .build(); ``` ## Authoring Rules @@ -108,7 +118,10 @@ var inputs = MerkleMembershipCircuit.inputs(32, ZkMerkle.HashType.MIMC); ``` Changing a circuit parameter changes the generated circuit identity and should -be treated as a different proving/verifying key lifecycle. +be treated as a different proving/verifying key lifecycle. For parameterized +circuits, the rendered `nameTemplate` is a readable prefix; generated names also +append a canonical parameter suffix to avoid collisions between different +parameter sets. ## Testing Pattern @@ -118,12 +131,56 @@ Every annotated circuit should have tests for: - valid witness calculation - at least one invalid witness - public input extraction through `inputs.publicValues()` +- typed public input extraction through `inputs.toPublicInputs()` - backend compilation for the curve and proof system you intend to use The examples in `zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation` show this pattern without requiring external prover tooling. +## Proof Flow Integration + +Generated companions expose the same metadata and public inputs needed by the +existing prover and verifier APIs: + +```java +var circuit = AgeVerificationCircuit.build(); +var inputs = AgeVerificationCircuit.inputs() + .age(25) + .threshold(18); + +BigInteger[] witness = AgeVerificationCircuit.calculateWitness( + circuit, inputs, CurveId.BN254); +PublicInputs publicInputs = inputs.toPublicInputs(); +CircuitId circuitId = AgeVerificationCircuit.circuitId(); +ZkCircuitMetadata metadata = AgeVerificationCircuit.metadata(); +``` + +`metadata.envelopeMetadata()` includes the circuit name, author-controlled +`@ZKCircuit(version = ...)`, and `@CircuitParam` values. `CircuitId` is the +generated circuit name; keep version in metadata for key registries and +allowlists that need name-plus-version policies. Generated parameterized circuit +names and metadata use a restricted canonical encoding: supported values are +converted to stable display strings, then stored as `length:value`. +Use `proofEnvelopeBuilder(...)` to create a +`ZkProofEnvelope.Builder` with the generated circuit ID and public-input order: + +```java +var envelope = AgeVerificationCircuit.proofEnvelopeBuilder( + circuit, + ProofSystemId.GROTH16, + CurveId.BN254, + proof.proveResponse().proofJson().getBytes(StandardCharsets.UTF_8), + inputs, + new VerificationKeyRef.ById("age-v1")) + .build(); +``` + +Exporter- or prover-specific code remains outside the generated companions. +For example, `AnnotatedAgeVerificationProofHelper` converts a generated witness +to `.wtns` bytes with `WitnessExporter` and passes generated witness maps to the +existing `GnarkProverHelper`. + ## Bit And Byte Inputs Use `ZkBits` for fixed-size bit vectors and `ZkBytes` for fixed-size byte diff --git a/zeroj-circuit-annotation-api/build.gradle b/zeroj-circuit-annotation-api/build.gradle index 1a27f5a..7583b3c 100644 --- a/zeroj-circuit-annotation-api/build.gradle +++ b/zeroj-circuit-annotation-api/build.gradle @@ -5,6 +5,7 @@ plugins { description = 'ZeroJ Circuit Annotation API — annotations and symbolic types for Java circuit authoring' dependencies { + api project(':zeroj-api') api project(':zeroj-circuit-dsl') } diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZKCircuit.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZKCircuit.java index b35e0e5..208e9b6 100644 --- a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZKCircuit.java +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZKCircuit.java @@ -13,4 +13,5 @@ public @interface ZKCircuit { String name() default ""; String nameTemplate() default ""; + int version() default 1; } diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitMetadata.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitMetadata.java new file mode 100644 index 0000000..678cb31 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitMetadata.java @@ -0,0 +1,77 @@ +package com.bloxbean.cardano.zeroj.circuit.annotation; + +import com.bloxbean.cardano.zeroj.api.CircuitId; +import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.api.ProofSystemId; +import com.bloxbean.cardano.zeroj.api.PublicInputs; +import com.bloxbean.cardano.zeroj.api.VerificationKeyRef; +import com.bloxbean.cardano.zeroj.api.ZkProofEnvelope; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Stable circuit identity and proof-envelope metadata for generated annotated + * circuits. Circuit IDs are name-based; the author-controlled circuit version + * is carried separately in envelope metadata so deployments can choose name-only + * or name-plus-version key lookup policies. + */ +public record ZkCircuitMetadata( + CircuitId circuitId, + String circuitName, + int circuitVersion, + Map parameters) { + + public static final String CIRCUIT_NAME_KEY = "zeroj.circuit.name"; + public static final String CIRCUIT_VERSION_KEY = "zeroj.circuit.version"; + public static final String CIRCUIT_PARAM_PREFIX = "zeroj.circuit.param."; + + public ZkCircuitMetadata { + Objects.requireNonNull(circuitId, "circuitId"); + Objects.requireNonNull(circuitName, "circuitName"); + if (circuitVersion <= 0) { + throw new IllegalArgumentException("circuitVersion must be positive"); + } + parameters = Collections.unmodifiableMap(new LinkedHashMap<>( + Objects.requireNonNull(parameters, "parameters"))); + } + + public static ZkCircuitMetadata of(ZkCircuitSchema schema, int circuitVersion) { + Objects.requireNonNull(schema, "schema"); + var params = new LinkedHashMap(); + for (ZkCircuitSchema.Parameter parameter : schema.parameters()) { + params.put(parameter.name(), parameter.value()); + } + return new ZkCircuitMetadata( + new CircuitId(schema.name()), + schema.name(), + circuitVersion, + params); + } + + public Map envelopeMetadata() { + var out = new LinkedHashMap(); + out.put(CIRCUIT_NAME_KEY, circuitName); + out.put(CIRCUIT_VERSION_KEY, Integer.toString(circuitVersion)); + parameters.forEach((name, value) -> out.put(CIRCUIT_PARAM_PREFIX + name, value)); + return Collections.unmodifiableMap(out); + } + + public ZkProofEnvelope.Builder proofEnvelopeBuilder( + ProofSystemId proofSystem, + CurveId curve, + byte[] proofBytes, + PublicInputs publicInputs, + VerificationKeyRef vkRef) { + return ZkProofEnvelope.builder() + .proofSystem(proofSystem) + .curve(curve) + .circuitId(circuitId) + .proofBytes(proofBytes) + .publicInputs(publicInputs) + .vkRef(vkRef) + .metadata(envelopeMetadata()); + } +} diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java index 1e51f92..a020806 100644 --- a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java @@ -1,5 +1,7 @@ package com.bloxbean.cardano.zeroj.circuit.annotation; +import com.bloxbean.cardano.zeroj.api.PublicInputs; + import java.math.BigInteger; import java.util.ArrayList; import java.util.Collections; @@ -52,4 +54,8 @@ public List publicValues(ZkCircuitSchema schema) { } return List.copyOf(out); } + + public PublicInputs publicInputs(ZkCircuitSchema schema) { + return new PublicInputs(publicValues(schema)); + } } diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java index f29d438..845ac9d 100644 --- a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java @@ -3,6 +3,8 @@ * *

This package exposes the public annotations and symbolic value types used * by Java classes that are compiled into {@code CircuitBuilder} / - * {@code CircuitSpec} companions by the annotation processor.

+ * {@code CircuitSpec} companions by the annotation processor. It also contains + * the schema, input, public-input, and circuit-metadata helpers generated + * companions use to integrate with prover and verifier flows.

*/ package com.bloxbean.cardano.zeroj.circuit.annotation; diff --git a/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java b/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java index 848a610..e8fe717 100644 --- a/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java +++ b/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java @@ -1,6 +1,9 @@ package com.bloxbean.cardano.zeroj.circuit.annotation; import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.api.ProofSystemId; +import com.bloxbean.cardano.zeroj.api.PublicInputs; +import com.bloxbean.cardano.zeroj.api.VerificationKeyRef; import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; import com.bloxbean.cardano.zeroj.circuit.Signal; import org.junit.jupiter.api.Test; @@ -333,9 +336,44 @@ void schemaExposesStableNamesAndInputMapPublicValues() { assertEquals(List.of(BigInteger.valueOf(10), BigInteger.ZERO, BigInteger.ONE), inputs.publicValues(schema)); + assertEquals(new PublicInputs(List.of(BigInteger.valueOf(10), BigInteger.ZERO, BigInteger.ONE)), + inputs.publicInputs(schema)); assertEquals(BigInteger.valueOf(30), inputs.toWitnessMap().get("sibling_1").getFirst()); } + @Test + void circuitMetadataBuildsEnvelopeMetadataAndBuilder() { + var schema = ZkCircuitSchema.of( + "metadata-test-d2", + List.of(new ZkCircuitSchema.Parameter("depth", "int", "2")), + List.of(ZkCircuitSchema.Input.scalar( + "root", ZkCircuitSchema.Visibility.PUBLIC, ZkCircuitSchema.Kind.FIELD, -1)), + List.of()); + + var metadata = ZkCircuitMetadata.of(schema, 1); + assertEquals("metadata-test-d2", metadata.circuitId().value()); + assertEquals("2", metadata.parameters().get("depth")); + assertEquals("metadata-test-d2", + metadata.envelopeMetadata().get(ZkCircuitMetadata.CIRCUIT_NAME_KEY)); + assertEquals("1", + metadata.envelopeMetadata().get(ZkCircuitMetadata.CIRCUIT_VERSION_KEY)); + assertEquals("2", + metadata.envelopeMetadata().get(ZkCircuitMetadata.CIRCUIT_PARAM_PREFIX + "depth")); + + var publicInputs = new PublicInputs(List.of(BigInteger.valueOf(99))); + var envelope = metadata.proofEnvelopeBuilder( + ProofSystemId.GROTH16, + CurveId.BN254, + new byte[]{1, 2, 3}, + publicInputs, + new VerificationKeyRef.ById("vk-metadata-test")) + .build(); + + assertEquals(metadata.circuitId(), envelope.circuitId()); + assertEquals(publicInputs, envelope.publicInputs()); + assertEquals("1", envelope.metadata().get(ZkCircuitMetadata.CIRCUIT_VERSION_KEY)); + } + @Test void schemaLookupPrefersExactInputNamesBeforeFlattenedNames() { var schema = ZkCircuitSchema.of( diff --git a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java index 65b2d5a..0cf85bd 100644 --- a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java +++ b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java @@ -170,6 +170,11 @@ private List circuitParams(TypeElement sourceType, Executable if (!names.add(name)) { throw new GenerationException(parameter, "Duplicate @CircuitParam name: " + name); } + if (!isSupportedCircuitParamType(parameter.asType())) { + throw new GenerationException(parameter, + "@CircuitParam type must be a primitive or boxed integral/boolean/char type, " + + "String, BigInteger, or enum"); + } params.add(new CircuitParamModel(name, parameter.getSimpleName().toString(), parameter.asType().toString(), isIntegerCircuitParam(parameter.asType()))); } @@ -354,7 +359,8 @@ private List orderInputs(List inputs) { private void validateInputNames(List inputs) { Set names = new HashSet<>(); - Set constantNames = new HashSet<>(Set.of("CIRCUIT_NAME", "CIRCUIT_NAME_TEMPLATE")); + Set constantNames = new HashSet<>(Set.of( + "CIRCUIT_NAME", "CIRCUIT_NAME_TEMPLATE", "CIRCUIT_VERSION")); for (InputModel input : inputs) { if (!names.add(input.baseName())) { throw new GenerationException(null, "Duplicate generated input name: " + input.baseName()); @@ -426,6 +432,10 @@ private String render(TypeElement sourceType, String packageName, String sourceS ZKCircuit circuit = sourceType.getAnnotation(ZKCircuit.class); String circuitName = !circuit.name().isBlank() ? circuit.name() : sourceSimpleName; String nameTemplate = circuit.nameTemplate(); + int circuitVersion = circuit.version(); + if (circuitVersion <= 0) { + throw new GenerationException(sourceType, "@ZKCircuit version must be positive"); + } boolean parameterized = !circuitParams.isEmpty(); validateNameTemplate(circuitParams, nameTemplate); boolean hasNameTemplate = parameterized && !nameTemplate.isBlank(); @@ -448,11 +458,18 @@ private String render(TypeElement sourceType, String packageName, String sourceS if (!packageName.isEmpty()) { out.append("package ").append(packageName).append(";\n\n"); } - out.append("import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder;\n") + out.append("import com.bloxbean.cardano.zeroj.api.CircuitId;\n") + .append("import com.bloxbean.cardano.zeroj.api.CurveId;\n") + .append("import com.bloxbean.cardano.zeroj.api.ProofSystemId;\n") + .append("import com.bloxbean.cardano.zeroj.api.PublicInputs;\n") + .append("import com.bloxbean.cardano.zeroj.api.VerificationKeyRef;\n") + .append("import com.bloxbean.cardano.zeroj.api.ZkProofEnvelope;\n") + .append("import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBits;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBytes;\n") + .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkCircuitMetadata;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkCircuitSchema;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext;\n") .append("import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField;\n") @@ -464,7 +481,8 @@ private String render(TypeElement sourceType, String packageName, String sourceS .append("public final class ").append(generatedSimpleName).append(" {\n") .append(" private ").append(generatedSimpleName).append("() {}\n\n") .append(" public static final String CIRCUIT_NAME = ") - .append(stringLiteral(circuitName)).append(";\n"); + .append(stringLiteral(circuitName)).append(";\n") + .append(" public static final int CIRCUIT_VERSION = ").append(circuitVersion).append(";\n"); if (hasNameTemplate) { out.append(" public static final String CIRCUIT_NAME_TEMPLATE = ") @@ -536,22 +554,39 @@ private String render(TypeElement sourceType, String packageName, String sourceS if (hasNameTemplate) { for (CircuitParamModel param : circuitParams) { out.append(" ").append(nameLocal).append(" = ").append(nameLocal) - .append(".replace(\"{").append(param.name()).append("}\", String.valueOf(") + .append(".replace(\"{").append(param.name()).append("}\", circuitParamDisplayValue(") .append(param.javaName()).append("));\n"); } - } else { - out.append(" ").append(nameLocal).append(" = ").append(nameLocal).append(" + \"--\";\n"); - for (int i = 0; i < circuitParams.size(); i++) { - CircuitParamModel param = circuitParams.get(i); - if (i > 0) { - out.append(" ").append(nameLocal).append(" = ").append(nameLocal).append(" + \"--\";\n"); - } - out.append(" ").append(nameLocal).append(" = ").append(nameLocal) - .append(" + \"").append(param.name()).append("-\" + String.valueOf(") - .append(param.javaName()).append(");\n"); - } } - out.append(" return ").append(nameLocal).append(";\n") + out.append(" return ").append(nameLocal).append(" + circuitParamSuffix(") + .append(renderParamNames(circuitParams)) + .append(");\n") + .append(" }\n"); + + out.append("\n private static String circuitParamSuffix(") + .append(renderParamSignature(circuitParams)) + .append(") {\n") + .append(" String suffix = \"\";\n"); + for (CircuitParamModel param : circuitParams) { + out.append(" suffix = suffix + \"--").append(param.name()).append("-\" + circuitParamValue(") + .append(param.javaName()).append(");\n"); + } + out.append(" return suffix;\n") + .append(" }\n"); + + out.append("\n private static String circuitParamDisplayValue(Object value) {\n") + .append(" if (value == null) {\n") + .append(" throw new IllegalArgumentException(\"@CircuitParam values must not be null\");\n") + .append(" }\n") + .append(" if (value instanceof Enum enumValue) {\n") + .append(" return enumValue.name();\n") + .append(" }\n") + .append(" return String.valueOf(value);\n") + .append(" }\n") + .append("\n") + .append(" private static String circuitParamValue(Object value) {\n") + .append(" String displayValue = circuitParamDisplayValue(value);\n") + .append(" return displayValue.length() + \":\" + displayValue;\n") .append(" }\n"); } @@ -579,8 +614,53 @@ private void renderSchemaAndInputs(StringBuilder out, List ci .append(renderParamNames(circuitParams)) .append("));\n") .append(" }\n\n") + .append(" public static CircuitId circuitId(") + .append(renderParamSignature(circuitParams)) + .append(") {\n") + .append(" return metadata(") + .append(renderParamNames(circuitParams)) + .append(").circuitId();\n") + .append(" }\n\n") + .append(" public static ZkCircuitMetadata metadata(") + .append(renderParamSignature(circuitParams)) + .append(") {\n") + .append(" return ZkCircuitMetadata.of(schema(") + .append(renderParamNames(circuitParams)) + .append("), CIRCUIT_VERSION);\n") + .append(" }\n\n") .append(" public static List publicInputs(Inputs inputs) {\n") .append(" return inputs.publicValues();\n") + .append(" }\n\n") + .append(" public static PublicInputs publicInputValues(Inputs inputs) {\n") + .append(" return inputs.toPublicInputs();\n") + .append(" }\n\n") + .append(" public static BigInteger[] calculateWitness(CircuitBuilder circuit, Inputs inputs, CurveId curve) {\n") + .append(" return inputs.calculateWitness(circuit, curve);\n") + .append(" }\n\n") + .append(" public static ZkProofEnvelope.Builder proofEnvelopeBuilder(\n") + .append(" CircuitBuilder circuit,\n") + .append(" ProofSystemId proofSystem,\n") + .append(" CurveId curve,\n") + .append(" byte[] proofBytes,\n") + .append(" Inputs inputs,\n") + .append(" VerificationKeyRef vkRef) {\n") + .append(" validateCircuit(circuit, inputs.schema());\n") + .append(" return ZkCircuitMetadata.of(inputs.schema(), CIRCUIT_VERSION)\n") + .append(" .proofEnvelopeBuilder(proofSystem, curve, proofBytes, inputs.toPublicInputs(), vkRef);\n") + .append(" }\n"); + + out.append("\n private static void validateCircuit(CircuitBuilder circuit, ZkCircuitSchema schema) {\n") + .append(" if (circuit == null) {\n") + .append(" throw new NullPointerException(\"circuit\");\n") + .append(" }\n") + .append(" if (schema == null) {\n") + .append(" throw new NullPointerException(\"schema\");\n") + .append(" }\n") + .append(" String circuitName = circuit.constraintGraph().name();\n") + .append(" if (!circuitName.equals(schema.name())) {\n") + .append(" throw new IllegalArgumentException(\"Circuit name \" + circuitName\n") + .append(" + \" does not match generated input schema \" + schema.name());\n") + .append(" }\n") .append(" }\n"); renderInputsClass(out, inputs); @@ -610,7 +690,7 @@ private String renderParameterSchemaList(List circuitParams) .map(param -> "new ZkCircuitSchema.Parameter(" + stringLiteral(param.name()) + ", " + stringLiteral(param.type()) + ", " - + "String.valueOf(" + param.javaName() + "))") + + "circuitParamValue(" + param.javaName() + "))") .collect(Collectors.joining(",\n ", "List.of(", ")")); } @@ -664,6 +744,13 @@ private void renderInputsClass(StringBuilder out, List inputs) { .append(" }\n\n") .append(" public List publicValues() {\n") .append(" return __zerojInputs.publicValues(__zerojSchema);\n") + .append(" }\n\n") + .append(" public PublicInputs toPublicInputs() {\n") + .append(" return __zerojInputs.publicInputs(__zerojSchema);\n") + .append(" }\n\n") + .append(" public BigInteger[] calculateWitness(CircuitBuilder circuit, CurveId curve) {\n") + .append(" validateCircuit(circuit, __zerojSchema);\n") + .append(" return circuit.calculateWitness(toWitnessMap(), curve);\n") .append(" }\n") .append(" }\n"); } @@ -909,6 +996,28 @@ private boolean isIntegerCircuitParam(TypeMirror type) { return type.getKind() == TypeKind.INT || erasure(type).equals("java.lang.Integer"); } + private boolean isSupportedCircuitParamType(TypeMirror type) { + return switch (type.getKind()) { + case BOOLEAN, BYTE, SHORT, INT, LONG, CHAR -> true; + default -> { + String erased = erasure(type); + if (Set.of( + "java.lang.Boolean", + "java.lang.Byte", + "java.lang.Short", + "java.lang.Integer", + "java.lang.Long", + "java.lang.Character", + "java.lang.String", + "java.math.BigInteger").contains(erased)) { + yield true; + } + Element element = processingEnv.getTypeUtils().asElement(type); + yield element != null && element.getKind() == ElementKind.ENUM; + } + }; + } + private String erasure(TypeMirror type) { return processingEnv.getTypeUtils().erasure(type).toString(); } diff --git a/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java index 656b761..0d41889 100644 --- a/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java +++ b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java @@ -1,7 +1,13 @@ package com.bloxbean.cardano.zeroj.circuit.annotation.processor; +import com.bloxbean.cardano.zeroj.api.CircuitId; import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.api.ProofSystemId; +import com.bloxbean.cardano.zeroj.api.PublicInputs; +import com.bloxbean.cardano.zeroj.api.VerificationKeyRef; +import com.bloxbean.cardano.zeroj.api.ZkProofEnvelope; import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkCircuitMetadata; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkCircuitSchema; import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBN254T3; @@ -54,7 +60,7 @@ void fieldStyleRangeProofGeneratesBuildAndRejectsInvalidWitness() throws Excepti import com.bloxbean.cardano.zeroj.circuit.annotation.*; - @ZKCircuit(name = "range-proof") + @ZKCircuit(name = "range-proof", version = 2) public class RangeProof { @Secret @UInt(bits = 8) ZkUInt secret; @@ -105,6 +111,60 @@ ZkBool inRange() { assertDoesNotThrow(() -> circuit.calculateWitness(witnessMap, CurveId.BN254)); assertEquals(List.of(BigInteger.valueOf(18), BigInteger.valueOf(99)), inputs.getClass().getMethod("publicValues").invoke(inputs)); + var typedPublicInputs = new PublicInputs(List.of(BigInteger.valueOf(18), BigInteger.valueOf(99))); + assertEquals(typedPublicInputs, inputs.getClass().getMethod("toPublicInputs").invoke(inputs)); + assertEquals(typedPublicInputs, + companion.getMethod("publicInputValues", inputs.getClass()).invoke(null, inputs)); + BigInteger[] generatedWitness = (BigInteger[]) companion + .getMethod("calculateWitness", CircuitBuilder.class, inputs.getClass(), CurveId.class) + .invoke(null, circuit, inputs, CurveId.BN254); + assertTrue(generatedWitness.length > 0); + + assertEquals(2, companion.getField("CIRCUIT_VERSION").get(null)); + assertEquals(new CircuitId("range-proof"), companion.getMethod("circuitId").invoke(null)); + ZkCircuitMetadata metadata = (ZkCircuitMetadata) companion.getMethod("metadata").invoke(null); + assertEquals(new CircuitId("range-proof"), metadata.circuitId()); + assertEquals("2", metadata.envelopeMetadata().get(ZkCircuitMetadata.CIRCUIT_VERSION_KEY)); + ZkProofEnvelope envelope = ((ZkProofEnvelope.Builder) companion + .getMethod("proofEnvelopeBuilder", + CircuitBuilder.class, + ProofSystemId.class, + CurveId.class, + byte[].class, + inputs.getClass(), + VerificationKeyRef.class) + .invoke(null, + circuit, + ProofSystemId.GROTH16, + CurveId.BN254, + new byte[]{1, 2, 3}, + inputs, + new VerificationKeyRef.ById("vk-range-proof"))) + .build(); + assertEquals(new CircuitId("range-proof"), envelope.circuitId()); + assertEquals(typedPublicInputs, envelope.publicInputs()); + assertEquals("range-proof", envelope.metadata().get(ZkCircuitMetadata.CIRCUIT_NAME_KEY)); + assertEquals("2", envelope.metadata().get(ZkCircuitMetadata.CIRCUIT_VERSION_KEY)); + } + + @Test + void rejectsInvalidCircuitVersion() throws Exception { + var compilation = compile("test.BadVersion", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(version = 0) + public class BadVersion { + @Prove + ZkBool prove(@Public ZkBool ok) { + return ok; + } + } + """); + + assertFalse(compilation.success()); + assertTrue(compilation.diagnosticsText().contains("@ZKCircuit version must be positive")); } @Test @@ -364,14 +424,23 @@ ZkBool prove( CircuitBuilder circuit = (CircuitBuilder) companion .getMethod("build", int.class, ZkMerkle.HashType.class) .invoke(null, 1, ZkMerkle.HashType.POSEIDON); - assertEquals("membership-d1-POSEIDON", circuit.constraintGraph().name()); + String circuitName = "membership-d1-POSEIDON--depth-1:1--hashType-8:POSEIDON"; + assertEquals(circuitName, circuit.constraintGraph().name()); ZkCircuitSchema schema = (ZkCircuitSchema) companion .getMethod("schema", int.class, ZkMerkle.HashType.class) .invoke(null, 1, ZkMerkle.HashType.POSEIDON); - assertEquals("membership-d1-POSEIDON", schema.name()); + assertEquals(circuitName, schema.name()); assertEquals("depth", schema.parameters().get(0).name()); - assertEquals("1", schema.parameters().get(0).value()); - assertEquals("POSEIDON", schema.parameters().get(1).value()); + assertEquals("1:1", schema.parameters().get(0).value()); + assertEquals("8:POSEIDON", schema.parameters().get(1).value()); + assertEquals(new CircuitId(circuitName), companion + .getMethod("circuitId", int.class, ZkMerkle.HashType.class) + .invoke(null, 1, ZkMerkle.HashType.POSEIDON)); + ZkCircuitMetadata metadata = (ZkCircuitMetadata) companion + .getMethod("metadata", int.class, ZkMerkle.HashType.class) + .invoke(null, 1, ZkMerkle.HashType.POSEIDON); + assertEquals("1:1", metadata.parameters().get("depth")); + assertEquals("8:POSEIDON", metadata.parameters().get("hashType")); assertEquals(List.of("root"), schema.publicInputs().names()); assertEquals(List.of("leaf", "sibling_0", "pathBit_0"), schema.secretInputs().names()); assertEquals(1, schema.input("sibling").size()); @@ -405,6 +474,32 @@ ZkBool prove( assertDoesNotThrow(() -> circuit.calculateWitness(generatedWitness, CurveId.BN254)); assertEquals(List.of(root), companion.getMethod("publicInputs", inputs.getClass()).invoke(null, inputs)); + CircuitBuilder mismatchedCircuit = (CircuitBuilder) companion + .getMethod("build", int.class, ZkMerkle.HashType.class) + .invoke(null, 1, ZkMerkle.HashType.MIMC); + var mismatchedWitness = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> companion.getMethod("calculateWitness", CircuitBuilder.class, inputs.getClass(), CurveId.class) + .invoke(null, mismatchedCircuit, inputs, CurveId.BN254)); + assertTrue(mismatchedWitness.getCause() instanceof IllegalArgumentException); + assertTrue(mismatchedWitness.getCause().getMessage().contains("does not match generated input schema")); + + var mismatchedEnvelope = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> companion.getMethod("proofEnvelopeBuilder", + CircuitBuilder.class, + ProofSystemId.class, + CurveId.class, + byte[].class, + inputs.getClass(), + VerificationKeyRef.class) + .invoke(null, + mismatchedCircuit, + ProofSystemId.GROTH16, + CurveId.BN254, + new byte[]{1}, + inputs, + new VerificationKeyRef.ById("vk-membership"))); + assertTrue(mismatchedEnvelope.getCause() instanceof IllegalArgumentException); + Object badInputs = companion .getMethod("inputs", int.class, ZkMerkle.HashType.class) .invoke(null, 1, ZkMerkle.HashType.POSEIDON); @@ -485,8 +580,86 @@ static ZkBool prove(@Secret ZkBool ok) { CircuitBuilder depth3 = (CircuitBuilder) compilation.load("test.ParamStaticCircuit") .getMethod("build", int.class) .invoke(null, 3); - assertEquals("param-static--depth-2", depth2.constraintGraph().name()); - assertEquals("param-static--depth-3", depth3.constraintGraph().name()); + assertEquals("param-static--depth-1:2", depth2.constraintGraph().name()); + assertEquals("param-static--depth-1:3", depth3.constraintGraph().name()); + } + + @Test + void circuitParamValuesUseCanonicalEncodingAndRejectUnsupportedTypes() throws Exception { + var stringParam = compile("test.StringParam", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "string-param") + public class StringParam { + public StringParam(@CircuitParam("label") String label) {} + + @Prove + static ZkBool prove(@Public ZkBool ok) { + return ok; + } + } + """); + + assertTrue(stringParam.success(), stringParam.diagnosticsText()); + Class companion = stringParam.load("test.StringParamCircuit"); + CircuitBuilder circuit = (CircuitBuilder) companion.getMethod("build", String.class) + .invoke(null, "a-b"); + assertEquals("string-param--label-3:a-b", circuit.constraintGraph().name()); + ZkCircuitSchema schema = (ZkCircuitSchema) companion.getMethod("schema", String.class) + .invoke(null, "a-b"); + assertEquals("3:a-b", schema.parameters().getFirst().value()); + + var unsupported = compile("test.UnsupportedParam", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class UnsupportedParam { + public UnsupportedParam(@CircuitParam("shape") Object shape) {} + + @Prove + ZkBool prove(@Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(unsupported.success()); + assertTrue(unsupported.diagnosticsText().contains("@CircuitParam type must be")); + } + + @Test + void nameTemplateCollisionsAreAvoidedByCanonicalSuffix() throws Exception { + var compilation = compile("test.CollidingTemplate", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "colliding", nameTemplate = "colliding-{a}{b}") + public class CollidingTemplate { + public CollidingTemplate( + @CircuitParam("a") int a, + @CircuitParam("b") int b) {} + + @Prove + static ZkBool prove(@Public ZkBool ok) { + return ok; + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + Class companion = compilation.load("test.CollidingTemplateCircuit"); + CircuitBuilder oneTwentyThree = (CircuitBuilder) companion.getMethod("build", int.class, int.class) + .invoke(null, 1, 23); + ZkCircuitSchema twelveThree = (ZkCircuitSchema) companion.getMethod("schema", int.class, int.class) + .invoke(null, 12, 3); + + assertEquals("colliding-123--a-1:1--b-2:23", oneTwentyThree.constraintGraph().name()); + assertEquals("colliding-123--a-2:12--b-1:3", twelveThree.name()); + assertFalse(oneTwentyThree.constraintGraph().name().equals(twelveThree.name())); } @Test @@ -815,6 +988,23 @@ ZkBool prove(@Public(name = "circuitName") ZkBool ok) { """); assertFalse(reservedConstant.success()); assertTrue(reservedConstant.diagnosticsText().contains("Duplicate generated input constant name")); + + var reservedVersionConstant = compile("test.ReservedVersionInputConstant", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class ReservedVersionInputConstant { + @Prove + ZkBool prove(@Public(name = "circuitVersion") ZkBool ok) { + return ok; + } + } + """); + assertFalse(reservedVersionConstant.success()); + assertTrue(reservedVersionConstant.diagnosticsText() + .contains("Duplicate generated input constant name")); } @Test diff --git a/zeroj-examples/README.md b/zeroj-examples/README.md index d39b25e..6009390 100644 --- a/zeroj-examples/README.md +++ b/zeroj-examples/README.md @@ -18,7 +18,8 @@ End-to-end demonstrations of ZeroJ capabilities -- from Java DSL circuit definit Write circuits as annotated Java classes and use generated companions for `build(...)`, `schema(...)`, and witness input builders. - **Examples**: range proof, age verification, private transfer, MiMC - commitment, parameterized Merkle membership, Pedersen commitment + commitment, parameterized Merkle membership, Pedersen commitment, proof-flow + helper - **Source**: [`examples/annotation`](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation) - **Tests**: [`AnnotatedCircuitExamplesTest.java`](src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java) - **Guide**: [`docs/circuit-annotation-user-guide.md`](../docs/circuit-annotation-user-guide.md) diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAgeVerificationProofHelper.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAgeVerificationProofHelper.java new file mode 100644 index 0000000..89abdfb --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAgeVerificationProofHelper.java @@ -0,0 +1,84 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.api.ProofSystemId; +import com.bloxbean.cardano.zeroj.api.VerificationKeyRef; +import com.bloxbean.cardano.zeroj.api.ZkProofEnvelope; +import com.bloxbean.cardano.zeroj.circuit.FieldConfig; +import com.bloxbean.cardano.zeroj.circuit.r1cs.R1CSSerializer; +import com.bloxbean.cardano.zeroj.examples.dsl.common.GnarkProverHelper; +import com.bloxbean.cardano.zeroj.prover.gnark.GnarkProver; +import com.bloxbean.cardano.zeroj.prover.wasm.WitnessExporter; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * Phase 9 proof-flow helper showing how generated annotated companions plug + * into the existing compiler, witness, prover, and proof-envelope APIs. + */ +public final class AnnotatedAgeVerificationProofHelper { + private final CurveId curve; + + public AnnotatedAgeVerificationProofHelper(CurveId curve) { + this.curve = Objects.requireNonNull(curve, "curve"); + } + + public AnnotatedAgeVerificationCircuit.Inputs inputs(BigInteger age, BigInteger threshold) { + return AnnotatedAgeVerificationCircuit.inputs() + .age(age) + .threshold(threshold); + } + + public byte[] generateR1CS() { + var circuit = AnnotatedAgeVerificationCircuit.build(); + return R1CSSerializer.serialize(circuit.compileR1CS(curve)); + } + + public BigInteger[] calculateWitness(BigInteger age, BigInteger threshold) { + var circuit = AnnotatedAgeVerificationCircuit.build(); + var inputs = inputs(age, threshold); + return AnnotatedAgeVerificationCircuit.calculateWitness(circuit, inputs, curve); + } + + public byte[] generateWitnessBytes(BigInteger age, BigInteger threshold) { + var config = FieldConfig.forCurve(curve); + return WitnessExporter.toWtns(calculateWitness(age, threshold), config.prime(), config.n32()); + } + + public GnarkProver.FullProveResponse generateGroth16ProofNative( + BigInteger age, + BigInteger threshold, + GnarkProver prover) { + var inputs = inputs(age, threshold); + return GnarkProverHelper.groth16Prove( + AnnotatedAgeVerificationCircuit.build(), + inputs.toWitnessMap(), + curve, + prover); + } + + public ZkProofEnvelope toEnvelope( + GnarkProver.FullProveResponse proof, + AnnotatedAgeVerificationCircuit.Inputs inputs, + VerificationKeyRef vkRef) { + Objects.requireNonNull(proof, "proof"); + Objects.requireNonNull(inputs, "inputs"); + CurveId responseCurve = CurveId.fromValue(proof.proveResponse().curve()); + if (responseCurve != curve) { + throw new IllegalArgumentException("proof curve does not match helper curve"); + } + if (!proof.proveResponse().publicSignals().equals(inputs.publicValues())) { + throw new IllegalArgumentException("proof public signals do not match generated public inputs"); + } + return AnnotatedAgeVerificationCircuit.proofEnvelopeBuilder( + AnnotatedAgeVerificationCircuit.build(), + ProofSystemId.fromValue(proof.proveResponse().protocol()), + responseCurve, + proof.proveResponse().proofJson().getBytes(StandardCharsets.UTF_8), + inputs, + vkRef) + .build(); + } +} diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java index 128ae83..b8d38eb 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java @@ -1,10 +1,17 @@ package com.bloxbean.cardano.zeroj.examples.annotation; +import com.bloxbean.cardano.zeroj.api.CircuitId; import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.api.ProofSystemId; +import com.bloxbean.cardano.zeroj.api.PublicInputs; +import com.bloxbean.cardano.zeroj.api.VerificationKeyRef; import com.bloxbean.cardano.zeroj.circuit.FieldConfig; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkCircuitMetadata; import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.PedersenCommitment; import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMerkle; import com.bloxbean.cardano.zeroj.examples.dsl.common.MiMCHash; +import com.bloxbean.cardano.zeroj.prover.gnark.GnarkProver; +import com.bloxbean.cardano.zeroj.prover.spi.ProveResponse; import org.junit.jupiter.api.Test; import java.math.BigInteger; @@ -14,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class AnnotatedCircuitExamplesTest { @@ -63,6 +71,55 @@ void parameterStyleAgeVerificationUsesGeneratedSchema() { () -> circuit.calculateWitness(underAge.toWitnessMap(), CurveId.BN254)); } + @Test + void ageVerificationHelperBuildsProverArtifactsAndProofEnvelope() { + var helper = new AnnotatedAgeVerificationProofHelper(CurveId.BN254); + var inputs = helper.inputs(BigInteger.valueOf(25), BigInteger.valueOf(18)); + var circuit = AnnotatedAgeVerificationCircuit.build(); + var publicInputs = new PublicInputs(List.of(BigInteger.valueOf(18))); + + assertEquals(new CircuitId("annotation-age-verification"), + AnnotatedAgeVerificationCircuit.circuitId()); + assertEquals(publicInputs, inputs.toPublicInputs()); + assertEquals(publicInputs, AnnotatedAgeVerificationCircuit.publicInputValues(inputs)); + assertTrue(AnnotatedAgeVerificationCircuit + .calculateWitness(circuit, inputs, CurveId.BN254).length > 0); + assertTrue(helper.generateR1CS().length > 0); + assertTrue(helper.generateWitnessBytes(BigInteger.valueOf(25), BigInteger.valueOf(18)).length > 0); + + var metadata = AnnotatedAgeVerificationCircuit.metadata(); + assertEquals("1", metadata.envelopeMetadata().get(ZkCircuitMetadata.CIRCUIT_VERSION_KEY)); + + var response = new GnarkProver.FullProveResponse( + new ProveResponse( + "{\"proof\":\"demo\"}", + inputs.publicValues(), + "groth16", + CurveId.BN254.value(), + 0), + "{}"); + var envelope = helper.toEnvelope(response, inputs, new VerificationKeyRef.ById("vk-age-v1")); + + assertEquals(ProofSystemId.GROTH16, envelope.proofSystem()); + assertEquals(CurveId.BN254, envelope.curve()); + assertEquals(new CircuitId("annotation-age-verification"), envelope.circuitId()); + assertEquals(publicInputs, envelope.publicInputs()); + assertEquals("annotation-age-verification", + envelope.metadata().get(ZkCircuitMetadata.CIRCUIT_NAME_KEY)); + + var mismatched = new GnarkProver.FullProveResponse( + new ProveResponse("{}", List.of(BigInteger.ONE), "groth16", CurveId.BN254.value(), 0), + "{}"); + assertThrows(IllegalArgumentException.class, + () -> helper.toEnvelope(mismatched, inputs, new VerificationKeyRef.ById("vk-age-v1"))); + + var wrongCurve = new GnarkProver.FullProveResponse( + new ProveResponse("{}", inputs.publicValues(), "groth16", CurveId.BLS12_381.value(), 0), + "{}"); + assertThrows(IllegalArgumentException.class, + () -> helper.toEnvelope(wrongCurve, inputs, new VerificationKeyRef.ById("vk-age-v1"))); + } + @Test void privateTransferChecksConservationAndPublicAmount() { var circuit = AnnotatedPrivateTransferCircuit.build(); @@ -122,9 +179,9 @@ void parameterizedMerkleMembershipUsesDepthAndHashType() { var circuit = AnnotatedMerkleMembershipCircuit.build(depth, hashType); var schema = AnnotatedMerkleMembershipCircuit.schema(depth, hashType); - assertEquals("annotation-merkle-d2-MIMC", schema.name()); - assertEquals("2", schema.parameters().get(0).value()); - assertEquals("MIMC", schema.parameters().get(1).value()); + assertEquals("annotation-merkle-d2-MIMC--depth-1:2--hashType-4:MIMC", schema.name()); + assertEquals("1:2", schema.parameters().get(0).value()); + assertEquals("4:MIMC", schema.parameters().get(1).value()); assertEquals(List.of("root"), schema.publicInputs().names()); assertEquals(List.of("leaf", "sibling_0", "sibling_1", "pathBit_0", "pathBit_1"), schema.secretInputs().names()); From b5fbe036d57213aca2b7a0649d26399e1ce2d8ce Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 13:06:08 +0800 Subject: [PATCH 11/26] Updated docs for new circuit annotation support --- docs/circuit-annotation-user-guide.md | 12 ++- zeroj-circuit-annotation-api/README.md | 26 ++++- zeroj-circuit-annotation-processor/README.md | 11 ++- zeroj-examples/README.md | 6 +- .../AnnotatedCircuitExamplesTest.java | 97 +++++++++++++++++++ 5 files changed, 140 insertions(+), 12 deletions(-) diff --git a/docs/circuit-annotation-user-guide.md b/docs/circuit-annotation-user-guide.md index bad8f35..beee254 100644 --- a/docs/circuit-annotation-user-guide.md +++ b/docs/circuit-annotation-user-guide.md @@ -229,9 +229,17 @@ bind them as secret `ZkUInt` inputs, using 252 bits for `kModL` and 4 bits for ## Current Limits - Nested `@ZKCircuit` classes are not supported. +- Nested annotated array inputs such as `ZkArray>` are not + supported; flatten to fixed-size parallel arrays. - Private `@Prove` methods are not supported. +- Private field-style symbolic inputs are not supported; use package-private + fields or parameter-style inputs. - Static `@Prove` methods must use parameter-style inputs. - `@CircuitParam` belongs on constructor parameters, not proof method parameters. -- Packed byte encodings are deferred; Phase 7 stores one constrained field - element per bit or byte. +- `ZkPoseidon` exposes two-input hashes; use repeated folding or the lower-level + `PoseidonN`/Signal APIs for variable-arity hashing. +- `ZkMiMC` is BN254-only because the underlying MiMC gadget is BN254-only. Use + `ZkPoseidon` with BLS12-381 parameters for BLS12-381 circuits. +- `ZkBits` and `ZkBytes` store one constrained field element per bit or byte; + packed byte encodings are deferred. diff --git a/zeroj-circuit-annotation-api/README.md b/zeroj-circuit-annotation-api/README.md index 25f77f5..f8ea8ce 100644 --- a/zeroj-circuit-annotation-api/README.md +++ b/zeroj-circuit-annotation-api/README.md @@ -2,9 +2,9 @@ Public API for annotation-based ZeroJ circuit authoring. -Current Phase 7 status: this module exposes the foundational annotations, -symbolic `Zk*` types, and runtime schema/input helpers used by generated -companions. +Current Phase 9 status: this module exposes the foundational annotations, +symbolic `Zk*` types, runtime schema/input helpers, and proof-envelope metadata +helpers used by generated companions. This module contains: @@ -12,8 +12,8 @@ This module contains: `@CircuitParam`, `@UInt`, `@FieldElement`, `@FixedSize`, and `@Order` - symbolic circuit value types: `ZkField`, `ZkBool`, `ZkUInt`, `ZkArray`, `ZkBits`, and `ZkBytes` -- generated-circuit metadata and witness helpers: `ZkCircuitSchema` and - `ZkInputMap` +- generated-circuit metadata and witness helpers: `ZkCircuitSchema`, + `ZkInputMap`, and `ZkCircuitMetadata` The API is intentionally layered on top of `zeroj-circuit-dsl`. It does not replace `CircuitSpec`, `SignalBuilder`, or the existing circuit library. @@ -62,5 +62,21 @@ Important API rules: `ZkCircuitSchema.secretInputs().names()` expose flattened input order. - `ZkInputMap.publicValues(schema)` extracts public values in schema order. +Known limitations: + +- Annotated inputs support fixed-size `ZkArray` for built-in element types, + but not nested `ZkArray>`; flatten nested structures into parallel + arrays when needed. +- `ZkPoseidon` currently exposes two-input hashes. For N-input commitments, fold + inputs through repeated two-input hashes or use the lower-level + `PoseidonN`/Signal APIs until a dedicated `ZkPoseidonN` helper is added. +- `ZkMiMC` is BN254-only because it delegates to the existing MiMC gadget. Use + `ZkPoseidon` with explicit BLS12-381 parameters for BLS12-381 circuits. +- Elliptic-curve composite symbolic types are available for the shipped Jubjub + use cases (`ZkJubjubPoint`, Pedersen, EdDSA-Jubjub). Add a curve-specific + symbolic wrapper before using another curve family. +- Private field-style symbolic inputs are rejected by the processor. Use + package-private field style or parameter-style inputs. + See [docs/adr/circuit-annotation/README.md](../docs/adr/circuit-annotation/README.md) for the accepted implementation plan. diff --git a/zeroj-circuit-annotation-processor/README.md b/zeroj-circuit-annotation-processor/README.md index 727ab5b..58da76d 100644 --- a/zeroj-circuit-annotation-processor/README.md +++ b/zeroj-circuit-annotation-processor/README.md @@ -2,9 +2,11 @@ Compile-time annotation processor for annotation-based ZeroJ circuit authoring. -Current Phase 7 status: this module scans `@ZKCircuit` classes and generates +Current Phase 9 status: this module scans `@ZKCircuit` classes and generates `*Circuit` companions with `build(...)`, `schema(...)`, `inputs(...)`, -`publicInputs(...)`, and input-name constants. +`publicInputs(...)`, `publicInputValues(...)`, `calculateWitness(...)`, +`circuitId(...)`, `metadata(...)`, `proofEnvelopeBuilder(...)`, and input-name +constants. The generated companions build normal `CircuitBuilder` / `CircuitSpec` circuits and produce ordinary witness maps for `calculateWitness(...)`. @@ -16,15 +18,18 @@ Supported: - constructor `@CircuitParam` values - `@FixedSize(...)` arrays, bits, and bytes - `@Public`, `@Secret`, `@UInt`, `@FieldElement`, and `@Order` -- generated schema metadata and input builders +- generated schema metadata, input builders, circuit metadata, and proof + envelope helpers - compile-time diagnostics for unsupported symbolic types and proof returns Not supported: - nested `@ZKCircuit` classes +- nested annotated array inputs such as `ZkArray>` - private `@Prove` methods - static `@Prove` methods with field-style inputs - `@CircuitParam` on `@Prove` parameters +- private field-style symbolic inputs Consumer usage: diff --git a/zeroj-examples/README.md b/zeroj-examples/README.md index 6009390..9502316 100644 --- a/zeroj-examples/README.md +++ b/zeroj-examples/README.md @@ -18,11 +18,13 @@ End-to-end demonstrations of ZeroJ capabilities -- from Java DSL circuit definit Write circuits as annotated Java classes and use generated companions for `build(...)`, `schema(...)`, and witness input builders. - **Examples**: range proof, age verification, private transfer, MiMC - commitment, parameterized Merkle membership, Pedersen commitment, proof-flow - helper + commitment, sealed-bid auction, anonymous voting, parameterized Merkle + membership, Pedersen commitment, proof-flow helper - **Source**: [`examples/annotation`](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation) - **Tests**: [`AnnotatedCircuitExamplesTest.java`](src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java) - **Guide**: [`docs/circuit-annotation-user-guide.md`](../docs/circuit-annotation-user-guide.md) +- **Note**: MiMC-based annotation examples target BN254. For BLS12-381 circuits, + use Poseidon with explicit BLS12-381 parameters. ### 1. Sealed-Bid Auction Prove your bid exceeds a reserve price without revealing the bid amount. diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java index b8d38eb..40ab3df 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java @@ -172,6 +172,103 @@ void hashCommitmentUsesSymbolicGadgetAdapter() { () -> circuit.calculateWitness(wrong.toWitnessMap(), CurveId.BN254)); } + @Test + void annotatedSealedBidMirrorsReferenceDslCircuit() { + var circuit = AnnotatedSealedBidCircuit.build(); + var schema = AnnotatedSealedBidCircuit.schema(); + + assertEquals("annotation-sealed-bid", schema.name()); + assertEquals(List.of("reservePrice", "bidCommitment", "isAboveReserve"), + schema.publicInputs().names()); + assertEquals(List.of("bidAmount", "salt"), schema.secretInputs().names()); + + var bidAmount = BigInteger.valueOf(100); + var reservePrice = BigInteger.valueOf(75); + var salt = BigInteger.valueOf(88_001); + var commitment = MiMCHash.hash(bidAmount, salt, FieldConfig.BN254.prime()); + var inputs = AnnotatedSealedBidCircuit.inputs() + .reservePrice(reservePrice) + .bidCommitment(commitment) + .isAboveReserve(1) + .bidAmount(bidAmount) + .salt(salt); + + assertEquals(List.of(reservePrice, commitment, BigInteger.ONE), inputs.publicValues()); + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); + + var belowReserveBid = BigInteger.valueOf(50); + var belowReserveCommitment = MiMCHash.hash(belowReserveBid, salt, FieldConfig.BN254.prime()); + var belowReserveInputs = AnnotatedSealedBidCircuit.inputs() + .reservePrice(reservePrice) + .bidCommitment(belowReserveCommitment) + .isAboveReserve(0) + .bidAmount(belowReserveBid) + .salt(salt); + assertDoesNotThrow(() -> circuit.calculateWitness(belowReserveInputs.toWitnessMap(), CurveId.BN254)); + + var wrongFlag = AnnotatedSealedBidCircuit.inputs() + .reservePrice(reservePrice) + .bidCommitment(commitment) + .isAboveReserve(0) + .bidAmount(bidAmount) + .salt(salt); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(wrongFlag.toWitnessMap(), CurveId.BN254)); + + var wrongCommitment = AnnotatedSealedBidCircuit.inputs() + .reservePrice(reservePrice) + .bidCommitment(BigInteger.ONE) + .isAboveReserve(1) + .bidAmount(bidAmount) + .salt(salt); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(wrongCommitment.toWitnessMap(), CurveId.BN254)); + } + + @Test + void annotatedAnonymousVotingMirrorsReferenceDslCircuit() { + var circuit = AnnotatedAnonymousVotingCircuit.build(); + var schema = AnnotatedAnonymousVotingCircuit.schema(); + + assertEquals("annotation-anonymous-vote", schema.name()); + assertEquals(List.of("commitment"), schema.publicInputs().names()); + assertEquals(List.of("vote", "nullifier"), schema.secretInputs().names()); + + var vote = BigInteger.ONE; + var nullifier = BigInteger.valueOf(12_345); + var commitment = MiMCHash.hash(vote, nullifier, FieldConfig.BN254.prime()); + var inputs = AnnotatedAnonymousVotingCircuit.inputs() + .commitment(commitment) + .vote(vote) + .nullifier(nullifier); + + assertEquals(List.of(commitment), inputs.publicValues()); + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); + + var noVote = BigInteger.ZERO; + var noVoteCommitment = MiMCHash.hash(noVote, nullifier, FieldConfig.BN254.prime()); + var noVoteInputs = AnnotatedAnonymousVotingCircuit.inputs() + .commitment(noVoteCommitment) + .vote(noVote) + .nullifier(nullifier); + assertDoesNotThrow(() -> circuit.calculateWitness(noVoteInputs.toWitnessMap(), CurveId.BN254)); + + var wrongCommitment = AnnotatedAnonymousVotingCircuit.inputs() + .commitment(BigInteger.ONE) + .vote(vote) + .nullifier(nullifier); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(wrongCommitment.toWitnessMap(), CurveId.BN254)); + + var invalidVote = BigInteger.valueOf(2); + var invalidVoteInputs = AnnotatedAnonymousVotingCircuit.inputs() + .commitment(MiMCHash.hash(invalidVote, nullifier, FieldConfig.BN254.prime())) + .vote(invalidVote) + .nullifier(nullifier); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(invalidVoteInputs.toWitnessMap(), CurveId.BN254)); + } + @Test void parameterizedMerkleMembershipUsesDepthAndHashType() { int depth = 2; From dc354c421cdd03978d280a950f88c7fd52829c19 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 18:24:23 +0800 Subject: [PATCH 12/26] Updated docs as per the current support ZKCircuit --- docs/adr/circuit-annotation/README.md | 42 +- .../cardano-gadget-support-matrix.md | 394 ++++++++++++++++++ .../circuit-annotation/implementation-plan.md | 15 + docs/circuit-annotation-user-guide.md | 81 +++- docs/circuit-dsl-user-guide.md | 34 +- docs/getting-started.md | 4 +- docs/pure-java-prover-guide.md | 4 +- zeroj-circuit-annotation-api/README.md | 35 +- zeroj-circuit-annotation-processor/README.md | 5 +- zeroj-circuit-lib/README.md | 25 +- zeroj-examples/README.md | 37 +- zeroj-examples/docs/e2e-groth16-sealed-bid.md | 7 +- zeroj-examples/docs/e2e-groth16-voting.md | 7 +- .../annotation/AnnotatedAnonymousVoting.java | 23 + .../annotation/AnnotatedSealedBid.java | 31 ++ 15 files changed, 696 insertions(+), 48 deletions(-) create mode 100644 docs/adr/circuit-annotation/cardano-gadget-support-matrix.md create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAnonymousVoting.java create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedSealedBid.java diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index d9be8c4..1ffd7af 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -70,6 +70,32 @@ var merkle32 = MerkleMembershipCircuit.build(32, HashType.POSEIDON); This feature should not replace `CircuitSpec` or the current DSL. It should generate code that sits on top of them. +## Cardano-Oriented Defaults + +For annotated circuits intended to be verified on Cardano, the default target is +BLS12-381 Groth16: + +```text +@ZKCircuit + -> generated *Circuit companion + -> compileR1CS(CurveId.BLS12_381) + -> Groth16 proof + -> Julc / Plutus V3 BLS12-381 verifier +``` + +Hash gadgets must be selected with their circuit field in mind. MiMC is +BN254-only in the current circuit library, and no-params Poseidon is retained +for BN254 compatibility. Cardano-facing circuits should use Poseidon with +explicit BLS12-381 parameters: + +```java +ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right); +``` + +The current gadget, curve, symbolic-adapter, and Cardano support matrix is +tracked in +[`cardano-gadget-support-matrix.md`](cardano-gadget-support-matrix.md). + ## Why We Are Doing This ZeroJ's core direction is Java-first circuit authoring, pure Java verification, @@ -891,8 +917,18 @@ developer experience. The MVP needs symbolic adapters for the gadgets used by the first real privacy templates: ```java -ZkField hash = ZkPoseidon.hash(zk, left, right); -ZkMerkle.verify(zk, leaf, root, siblings, pathBits, ZkPoseidon::hash); +ZkField hash = ZkPoseidon.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + left, + right); +ZkMerkle.verify( + zk, + leaf, + root, + siblings, + pathBits, + (ctx, l, r) -> ZkPoseidon.hash(ctx, PoseidonParamsBLS12_381T3.INSTANCE, l, r)); ``` Proposed package for these adapters: @@ -910,6 +946,8 @@ MVP adapters: `ZkMiMC` uses the existing MiMC constants and is guarded as BN254-only. `ZkPoseidon` should expose both default and explicit-`PoseidonParams` overloads so BLS12-381 circuits can select the matching parameter set. +For Cardano-oriented circuits, use the explicit BLS12-381 overload; the default +Poseidon overload is BN254-oriented for backward compatibility. These adapters live in `zeroj-circuit-lib`, not in `zeroj-circuit-annotation-api`, so the dependency direction remains: diff --git a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md new file mode 100644 index 0000000..e0eec71 --- /dev/null +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -0,0 +1,394 @@ +# ADR: Cardano Gadget Support Matrix for Symbolic Annotated Circuits + +## Status + +Accepted follow-up plan. Priority 1, documentation and defaults, is completed +in the current docs. + +## Date + +2026-05-18 + +## Context + +ZeroJ now supports annotation-based circuit authoring with symbolic `Zk*` +types. The next design question is not whether annotated circuits can express +complex circuits in general; they can. The important operational question is: + +- which existing ZeroJ gadgets can be used directly from annotated circuits +- which curve each gadget requires +- whether a circuit using that gadget can produce a proof that is practical for + Cardano on-chain verification +- which gaps should be closed next + +This ADR records the current support matrix and the follow-up implementation +plan for Cardano-oriented annotated circuit authoring. + +## Key Distinction + +Circuit gadgets do not execute on-chain. They execute during circuit definition +and witness/proof generation. Cardano validators only verify the final proof. + +Therefore, "Cardano on-chain support" means: + +- the circuit can compile and prove over a Cardano-supported proof curve +- ZeroJ has, or can generate, a matching Plutus/Julc verifier for that proof + system and curve +- public inputs are serialized in the order expected by the verification key + and validator + +For production Cardano use, the default target should be: + +```text +Annotated symbolic circuit + -> CurveId.BLS12_381 + -> Groth16 + -> Julc / Plutus V3 BLS12-381 verifier +``` + +## Current Curve and Proof-System Matrix + +| Proof system | Curve | ZeroJ off-chain support | Cardano on-chain support | Current status | +|--------------|-------|-------------------------|--------------------------|----------------| +| Groth16 | BLS12-381 | Yes | Yes | Production path. `OnChainFeasibility` marks this `WORKING`. | +| PlonK | BLS12-381 | Yes | Experimental | Julc verifier prototype exists, but the full KZG batch opening pairing check is deferred. | +| Halo2 | BLS12-381 | Assessment only | No production verifier | Research path only in current repo. | +| Groth16 | BN254 | Yes | No | Plutus V3 has no BN254 pairing builtins in the current ZeroJ support model. | +| PlonK | BN254 | Yes | No | Useful off-chain, not a Cardano on-chain target today. | +| Halo2 | Pallas | Incubator/off-chain | No | No Pallas curve builtins or Cardano on-chain verifier in current repo. | +| BBS | BLS12-381 | Yes, separate proof system | No ZeroJ on-chain verifier today | Useful for off-chain selective disclosure; not an annotated circuit gadget. | + +Relevant source: + +- `zeroj-onchain-julc/.../OnChainFeasibility.java` +- `zeroj-onchain-julc/.../Groth16BLS12381Verifier.java` +- `zeroj-onchain-julc/.../PlonkBLS12381FullVerifier.java` +- `zeroj-verifier-groth16/...` +- `zeroj-verifier-plonk/...` +- `incubator/zeroj-verifier-halo2/...` + +## Current Circuit Field Support + +The circuit DSL currently exposes three scalar fields through `FieldConfig`: + +| FieldConfig | CurveId | Intended use | +|-------------|---------|--------------| +| `FieldConfig.BN254` | `CurveId.BN254` | Circom/snarkjs-compatible off-chain circuits and Ethereum-style proof ecosystems. | +| `FieldConfig.BLS12_381` | `CurveId.BLS12_381` | Cardano-oriented Groth16/PlonK circuits and Jubjub-in-BLS scalar-field gadgets. | +| `FieldConfig.PALLAS` | `CurveId.PALLAS` | Halo2/Pasta experiments. | + +Gadgets that depend on field-specific constants call +`CircuitAPI.requireField(...)`. `CircuitBuilder` then rejects compile or witness +calculation if the requested curve does not match the gadget's required field. +This is important for hashes such as Poseidon and MiMC, where using constants +from one field while compiling over another field would produce incompatible +and potentially unsafe circuits. + +Relevant source: + +- `zeroj-circuit-dsl/.../FieldConfig.java` +- `zeroj-circuit-dsl/.../CircuitAPI.java` +- `zeroj-circuit-dsl/.../CircuitBuilder.java` + +## Gadget Support Matrix + +| Gadget or library | Purpose | Curve / field support | Symbolic annotation support today | Cardano on-chain support | Action needed | +|-------------------|---------|-----------------------|-----------------------------------|--------------------------|---------------| +| Core arithmetic | Field add, sub, mul, div, equality | Generic over `BN254`, `BLS12_381`, `PALLAS` | Direct through `ZkField` | Yes when compiled/proved over `BLS12_381 + Groth16` | None. | +| `ZkField` | Raw field element | Generic | Direct | Yes on BLS12-381 Groth16 | None. | +| `ZkBool` | Boolean-constrained field bit | Generic | Direct | Yes on BLS12-381 Groth16 | None. | +| `ZkUInt` | Unsigned integer with bit width and range constraints | Generic, width-limited | Direct | Yes on BLS12-381 Groth16 | Document max width and comparison limits. Current `MAX_BITS` is 253 and comparisons require compare width `< 253`. | +| `ZkArray` | Fixed-size symbolic arrays | Generic | Direct | Yes on BLS12-381 Groth16 | Add nested arrays only when a real circuit needs them. | +| `ZkBits` | Fixed-size bit vector | Generic | Direct for binding/equality | Yes on BLS12-381 Groth16 | Add ergonomic bitwise operations if bit-heavy circuits appear. | +| `ZkBytes` | Fixed-size byte vector | Generic | Direct for binding/equality | Yes on BLS12-381 Groth16 | Add packing/unpacking helpers when byte-oriented circuits appear. | +| `Comparators` / `SignalComparators` | `<`, `<=`, `>`, `>=`, range, min, max | Generic | Mostly direct through `ZkUInt` | Yes on BLS12-381 Groth16 | Optional symbolic helpers for `min` and `max`. | +| `Binary` / `SignalBinary` | Bit decomposition, recomposition, bitwise ops, shifts | Generic | Partial through `ZkBits` and `ZkBool` | Yes on BLS12-381 Groth16 | Add `ZkBits.and/or/xor/rotate/shift` wrappers for better ergonomics. | +| `Mux` | Conditional select and array access | Generic | Direct scalar select through `ZkBool.select` | Yes on BLS12-381 Groth16 | Add `ZkArray.select` or dynamic array access helper if needed. | +| `AliasCheck` | Canonical field representation check | Generic | Mostly covered by `ZkUInt` range checks | Yes on BLS12-381 Groth16 | Optional `ZkAliasCheck` for raw `ZkField`. | +| `MiMC` / `SignalMiMC` | MiMC-7 two-input hash | BN254 only in current circuit lib | Direct through `ZkMiMC` | No for Cardano on-chain, because the circuit requires BN254 | Use Poseidon for Cardano. Consider a separate BLS12-381 MiMC variant only if there is a concrete interop need. | +| `MiMCSponge` | Variable-length MiMC sponge | BN254 only, because it calls `MiMC` | No direct `ZkMiMCSponge` | No for Cardano on-chain | Add symbolic wrapper only for BN254/off-chain legacy use. | +| `Poseidon` | Two-input Poseidon T3 hash | BN254 default; BLS12-381 supported with explicit params | Direct through `ZkPoseidon` for two inputs | Yes if `PoseidonParamsBLS12_381T3.INSTANCE` is used | Make BLS12-381 params prominent in Cardano examples. | +| `PoseidonN` | Variable-arity folded Poseidon | BN254 default; BLS12-381 supported with explicit params | Not directly exposed as `ZkPoseidonN` | Yes via manual fold or signal escape hatch | Add `ZkPoseidonN.hash(zk, params, ZkField...)`. | +| `Merkle` / `SignalMerkle` | Fixed-depth Merkle membership | Hash-dependent | Direct through `ZkMerkle` | Yes when the hash is BLS12-381 compatible | Add params-aware symbolic API for BLS12-381 Poseidon. | +| `ZkMerkle.HashType.MIMC` | Merkle with MiMC | BN254 only | Direct | No for Cardano on-chain | Mark as BN254/off-chain in docs. | +| `ZkMerkle.HashType.POSEIDON` | Merkle with default Poseidon | BN254 by default today | Direct | No if using default enum path | Add `POSEIDON_BLS12_381_T3` or a params-aware factory. | +| `ZkMerkle` with custom hash lambda | Merkle with caller-provided hash | Depends on lambda | Direct | Yes with BLS12-381 Poseidon lambda | Keep as escape hatch; document example. | +| `JubjubPoint` | Off-circuit Jubjub point arithmetic | Jubjub over BLS12-381 scalar field | Used by symbolic wrappers | Yes for BLS12-381 circuits | None. | +| `InCircuitJubjub` | In-circuit Jubjub arithmetic | Requires BLS12-381 scalar field | Direct through `ZkJubjubPoint` | Yes | Document trusted point binding and subgroup-check contract. | +| `ZkJubjubPoint` | Symbolic Jubjub point | Requires BLS12-381 scalar field | Direct | Yes | Add in-circuit curve/subgroup checks only if untrusted public points must be accepted directly. | +| `InCircuitPedersen` / `ZkPedersen` | Jubjub Pedersen commitment | Requires BLS12-381 scalar field | Direct | Yes | None; document constraint cost and scalar width. | +| `InCircuitEdDSAJubjub` / `ZkEdDSAJubjub` | EdDSA-Jubjub verification | Requires BLS12-381 scalar field | Direct | Yes | None for trusted/subgroup-checked points. | +| `zeroj-bls12381` | BLS12-381 field, G1/G2, pairing, hash-to-curve | BLS12-381 | Not a circuit gadget | Supports off-chain proof verification and conversion paths; Cardano has BLS builtins | Use for proof systems and BBS, not directly inside symbolic circuits unless a circuit gadget is built. | +| `zeroj-blst` | Native BLS12-381 provider | BLS12-381 | Not a circuit gadget | Off-chain helper | No annotation work. | +| `zeroj-bls12381-wasm` | WASM BLS12-381 provider | BLS12-381 | Not a circuit gadget | Off-chain helper | No annotation work. | +| `zeroj-bbs` / `zeroj-bbs-wasm` | CFRG BBS signatures and presentations | BLS12-381 | Not an annotated circuit gadget | No current ZeroJ on-chain verifier | Keep separate from annotated circuits for now. | +| Groth16 pure Java provers | Proof generation | BN254 and BLS12-381 | Consumes generated circuits through R1CS/witness APIs | BLS12-381 proofs can target Cardano | Improve generic on-chain verifier generation for arbitrary public-input counts. | +| PlonK pure Java provers | Proof generation | BN254 and BLS12-381 | Consumes generated circuits through existing compile APIs | BLS12-381 on-chain is experimental | Do not make PlonK the Cardano default until on-chain verifier is complete. | +| Halo2 incubator verifier | Halo2 IPA verification | Pallas | Not a symbolic circuit target for Cardano | No | Keep as incubator/off-chain. | + +## Important Current Limitations + +### Generic Groth16 On-Chain Verifier Public-Input Count + +The current generic Julc `Groth16BLS12381Verifier` is specialized to two +public inputs. It has `vkIc0`, `vkIc1`, and `vkIc2` parameters and computes: + +```text +vk_x = IC[0] + pub[0] * IC[1] + pub[1] * IC[2] +``` + +That is sufficient for small examples, but it is not sufficient as the default +Cardano path for arbitrary annotated circuits. Annotated circuits can have any +stable public-input schema. The on-chain verifier needs to either: + +- be generated per circuit with exactly the required `IC` points and public + input handling, or +- use a generalized list-based MSM/fold over public inputs and `IC` points if + Julc/Plutus ergonomics and budget allow it. + +Until this is solved, some BLS12-381 annotated circuits are off-chain-verifiable +but require a custom on-chain validator. + +### MiMC Is BN254-Only in the Circuit Library + +The current `MiMC`, `SignalMiMC`, `ZkMiMC`, and `MiMCSponge` path is BN254-only. +`MiMC.hash(...)` calls `api.requireField(FieldConfig.BN254)`. + +This is not a problem for off-chain BN254 circuits, but it is not a good default +for Cardano. Cardano-facing examples should use BLS12-381 Poseidon instead. + +Existing examples that compute MiMC with a BLS12-381 prime outside the circuit +should be treated as legacy/test code that needs migration or clarification. +The in-circuit gadget now correctly prevents compiling that path as a +BLS12-381 circuit. + +### Poseidon Defaults Are BN254 for Back Compatibility + +The no-params Poseidon overload defaults to BN254. This is correct for +backward compatibility but risky for Cardano examples. + +For Cardano circuits, code should use: + +```java +ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right) +``` + +or a future convenience API that makes the BLS12-381 choice explicit. + +### BBS Is Not an Annotated Circuit Gadget + +BBS support is important for selective disclosure, but it is a separate proof +system in ZeroJ today. It should not be presented as a symbolic annotated +circuit gadget until ZeroJ either has: + +- an in-circuit BBS verification gadget, or +- a Cardano on-chain BBS verifier path. + +## Decision + +For new Cardano-oriented annotated circuits: + +1. Use `CurveId.BLS12_381` as the default compile/prove target. +2. Use Groth16 as the default proof system for on-chain verification. +3. Use Poseidon with explicit BLS12-381 parameters as the default ZK hash. +4. Use Jubjub/Pedersen/EdDSA-Jubjub symbolic adapters only on BLS12-381. +5. Treat MiMC as BN254/off-chain unless a BLS12-381 MiMC variant is explicitly + designed and documented. +6. Treat PlonK on-chain support as experimental until the full KZG opening + verifier is implemented. +7. Generate or generalize the Groth16 BLS12-381 on-chain verifier so annotated + circuits with arbitrary public-input schemas have a first-class Cardano path. + +## Follow-Up Implementation Plan + +### Phase A: Documentation and Defaults + +Goal: prevent developers from accidentally writing BN254-only annotated +circuits when they intend to target Cardano. + +Tasks: + +- Add this support matrix to the circuit annotation documentation. +- Update annotated examples and user guide text to state that Cardano examples + should use BLS12-381 Poseidon, not default Poseidon or MiMC. +- Mark `ZkMiMC` and `ZkMerkle.HashType.MIMC` as BN254/off-chain in docs. +- Add a short Cardano recipe: + `@ZKCircuit -> build -> compileR1CS(BLS12_381) -> prove Groth16 -> Julc verifier`. + +Exit criteria: + +- A user can identify which symbolic gadgets are Cardano-compatible without + reading source. +- Docs clearly say that no-params Poseidon and MiMC are not Cardano defaults. + +### Phase B: `ZkPoseidonN` + +Goal: expose variable-arity Poseidon cleanly in symbolic annotated circuits. + +Tasks: + +- Add `ZkPoseidonN.hash(ZkContext, PoseidonParams, ZkField...)`. +- Add `ZkPoseidonN.hash(ZkContext, ZkField...)` only if it is clearly + documented as BN254 default, or skip the no-params overload to avoid + Cardano mistakes. +- Add tests comparing `ZkPoseidonN` to `PoseidonN` for BN254 and BLS12-381. +- Add at least one annotated example using BLS12-381 `ZkPoseidonN`. + +Exit criteria: + +- Multi-input commitments can be written without dropping to `Signal`. +- BLS12-381 params are supported and tested. + +### Phase C: Params-Aware `ZkMerkle` + +Goal: make BLS12-381 Merkle circuits ergonomic and hard to misconfigure. + +Tasks: + +- Add a params-aware helper such as: + +```java +ZkMerkle.verifyPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + leaf, + root, + siblings, + pathBits); +``` + +- Or add an explicit enum value such as `POSEIDON_BLS12_381_T3`. +- Keep the custom hash lambda path for advanced users. +- Add tests that reject mismatched compile fields through `requireField`. +- Update `AnnotatedMerkleMembership` examples to show the BLS12-381 path. + +Exit criteria: + +- A Cardano Merkle membership circuit can be written without a custom lambda. +- `ZkMerkle.HashType.POSEIDON` ambiguity is documented or removed from + Cardano-facing examples. + +### Phase D: Cardano Groth16 Verifier Generation or Generalization + +Goal: make arbitrary annotated BLS12-381 Groth16 circuits usable on-chain. + +Tasks: + +- Design one of the following: + - a generated Julc verifier per circuit/public-input count + - a generalized Julc verifier that folds public inputs and `IC` points from + list parameters +- Preserve stable public-input order from generated circuit schema. +- Add budget estimates based on public-input count. +- Add tests for 0, 1, 2, and more-than-2 public inputs. +- Add an annotated circuit on-chain example with more than two public inputs. + +Exit criteria: + +- Annotated circuits are not limited by the current 2-public-input generic + verifier. +- The generated schema, proof envelope, and on-chain validator agree on public + input ordering. + +### Phase E: Example Migration + +Goal: align reference examples with the Cardano support model. + +Tasks: + +- Add BLS12-381 Poseidon variants for sealed bid, anonymous voting, hash chain, + multi-input commitment, and Merkle membership where they are meant to be + Cardano examples. +- Leave BN254 MiMC examples in place only when they are explicitly labeled + off-chain/BN254. +- Make tests use `FieldConfig.BN254` only for MiMC examples and + `FieldConfig.BLS12_381` for Cardano examples. + +Exit criteria: + +- Example names and README text make it obvious whether an example is + Cardano-on-chain-ready or BN254/off-chain. + +### Phase F: Optional BLS12-381 MiMC Decision + +Goal: decide whether ZeroJ needs a BLS12-381 MiMC variant. + +Recommendation: + +- Do not prioritize this unless a concrete external protocol requires MiMC over + the BLS12-381 scalar field. +- Prefer BLS12-381 Poseidon for new Cardano circuits because ZeroJ already has + BLS12-381 Poseidon constants and symbolic adapters. + +If implemented: + +- Create a separate params-named gadget instead of changing existing MiMC + behavior. +- Keep BN254 MiMC backward compatibility. +- Add independent test vectors for the BLS12-381 variant. +- Add explicit docs explaining that this is not circomlib BN254 MiMC. + +Exit criteria: + +- No existing BN254 MiMC circuit changes behavior. +- BLS12-381 MiMC is only exposed under an explicit name. + +## Recommended Priority + +1. Documentation and defaults. +2. `ZkPoseidonN`. +3. Params-aware BLS12-381 `ZkMerkle`. +4. Generic/generated Cardano Groth16 verifier for arbitrary public-input count. +5. Example migration to BLS12-381 Poseidon. +6. Optional BLS12-381 MiMC only if a real integration requires it. + +## Testing Strategy + +For every new symbolic wrapper: + +- compare generated constraints/witness behavior against the existing + `Signal*` or low-level gadget +- test both valid and invalid witnesses +- test compile/witness rejection when field-specific params do not match the + requested compile curve +- include at least one generated annotated circuit using the wrapper + +For Cardano on-chain readiness: + +- compile annotated circuit to R1CS over `CurveId.BLS12_381` +- generate or load a Groth16 BLS12-381 proof +- verify off-chain with the BLS12-381 Groth16 verifier +- convert proof and verification key to Cardano compressed BLS bytes +- evaluate the Julc validator in tests +- confirm public input order matches generated `schema()` and + `publicInputValues(...)` + +## Consequences + +Positive: + +- Cardano-oriented circuit authors get a clear default path. +- Symbolic annotation APIs remain usable for BN254/off-chain circuits without + pretending those circuits are Cardano-on-chain-ready. +- Poseidon, Merkle, Pedersen, and EdDSA-Jubjub become the primary building + blocks for real Cardano privacy circuits. +- Public-input ordering and verifier generation become explicit requirements, + reducing deployment risk. + +Negative: + +- Documentation must distinguish between "usable in annotated circuits" and + "usable for Cardano on-chain verification." +- Some existing MiMC examples need migration or clearer labels. +- A generalized/generated on-chain verifier adds a new implementation slice. + +## Open Questions + +- Should no-params symbolic Poseidon remain available in Cardano-facing docs, + or should all docs require explicit params? +- Should `ZkMerkle.HashType.POSEIDON` be deprecated in favor of explicit + parameterized factories? +- Should the on-chain Groth16 verifier be generated per circuit, or should a + generic list-based verifier be implemented first? +- Do any target partner ecosystems require MiMC over BLS12-381, or is Poseidon + sufficient for all near-term Cardano use cases? diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index 4355b10..446a4c6 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -56,3 +56,18 @@ For each phase: - Tests include at least one negative witness or negative compile scenario when behavior can fail. - Review findings are resolved or documented as non-blocking. + +## Post-Phase 9 Cardano Follow-Ups + +The Cardano-oriented gadget and curve support matrix is tracked in +[`cardano-gadget-support-matrix.md`](cardano-gadget-support-matrix.md). The +recommended follow-up order is: + +| Priority | Work | Status | +|----------|------|--------| +| 1 | Documentation and defaults | Completed | +| 2 | `ZkPoseidonN` symbolic adapter | Pending | +| 3 | Params-aware BLS12-381 `ZkMerkle` helpers | Pending | +| 4 | Generic or generated Cardano Groth16 verifier for arbitrary public-input count | Pending | +| 5 | Example migration to BLS12-381 Poseidon where examples are Cardano-facing | Pending | +| 6 | Optional BLS12-381 MiMC decision | Pending | diff --git a/docs/circuit-annotation-user-guide.md b/docs/circuit-annotation-user-guide.md index beee254..e6727c4 100644 --- a/docs/circuit-annotation-user-guide.md +++ b/docs/circuit-annotation-user-guide.md @@ -35,20 +35,45 @@ var inputs = RangeProofCircuit.inputs() .lo(18) .hi(99); -circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254); +circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381); inputs.publicValues(); inputs.toPublicInputs(); var envelope = RangeProofCircuit.proofEnvelopeBuilder( circuit, ProofSystemId.GROTH16, - CurveId.BN254, + CurveId.BLS12_381, proofJson.getBytes(StandardCharsets.UTF_8), inputs, new VerificationKeyRef.ById("range-proof-v1")) .build(); ``` +## Cardano Default Path + +For circuits intended to be verified on Cardano, use BLS12-381 Groth16 as the +default target: + +```text +@ZKCircuit + -> generated *Circuit companion + -> compileR1CS(CurveId.BLS12_381) + -> Groth16 proof + -> Julc / Plutus V3 BLS12-381 verifier +``` + +When a circuit needs a hash, prefer Poseidon with explicit BLS12-381 +parameters: + +```java +ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right); +``` + +Do not use no-params Poseidon or MiMC as Cardano defaults. The no-params +Poseidon overload is retained for BN254 compatibility, and `ZkMiMC` delegates +to the BN254-only MiMC gadget. The full gadget and curve support matrix is in +[`docs/adr/circuit-annotation/cardano-gadget-support-matrix.md`](adr/circuit-annotation/cardano-gadget-support-matrix.md). + ## Authoring Rules - Use symbolic types in proof code: `ZkField`, `ZkBool`, `ZkUInt`, @@ -91,14 +116,13 @@ ZkBool prove(@Secret @UInt(bits = 8) ZkUInt age, Parameterized circuits keep Java's template-like circuit generation: +For Cardano-oriented Merkle circuits, bind the hash to BLS12-381 Poseidon +explicitly: + ```java -@ZKCircuit(name = "merkle", nameTemplate = "merkle-d{depth}-{hashType}") +@ZKCircuit(name = "merkle-bls12-381", nameTemplate = "merkle-bls-d{depth}") public class MerkleMembership { - private final ZkMerkle.HashType hashType; - - public MerkleMembership(@CircuitParam("depth") int depth, - @CircuitParam("hashType") ZkMerkle.HashType hashType) { - this.hashType = hashType; + public MerkleMembership(@CircuitParam("depth") int depth) { } @Prove @@ -107,14 +131,25 @@ public class MerkleMembership { @Public ZkField root, @Secret @FixedSize(param = "depth") ZkArray siblings, @Secret @FixedSize(param = "depth") ZkArray pathBits) { - return ZkMerkle.isMember(zk, leaf, root, siblings, pathBits, hashType); + return ZkMerkle.isMember( + zk, + leaf, + root, + siblings, + pathBits, + (ctx, left, right) -> ZkPoseidon.hash( + ctx, + PoseidonParamsBLS12_381T3.INSTANCE, + left, + right)); } } ``` ```java -var circuit = MerkleMembershipCircuit.build(32, ZkMerkle.HashType.MIMC); -var inputs = MerkleMembershipCircuit.inputs(32, ZkMerkle.HashType.MIMC); +var circuit = MerkleMembershipCircuit.build(32); +var inputs = MerkleMembershipCircuit.inputs(32); +var r1cs = circuit.compileR1CS(CurveId.BLS12_381); ``` Changing a circuit parameter changes the generated circuit identity and should @@ -123,6 +158,12 @@ circuits, the rendered `nameTemplate` is a readable prefix; generated names also append a canonical parameter suffix to avoid collisions between different parameter sets. +`ZkMerkle.HashType.MIMC` and `ZkMerkle.HashType.POSEIDON` remain useful for +BN254/off-chain compatibility. Today they are not the Cardano default path: +`MIMC` is BN254-only, and `POSEIDON` uses the no-params Poseidon overload. Use +the custom hash lambda shown above until a params-aware `ZkMerkle` convenience +API is added. + ## Testing Pattern Every annotated circuit should have tests for: @@ -150,7 +191,7 @@ var inputs = AgeVerificationCircuit.inputs() .threshold(18); BigInteger[] witness = AgeVerificationCircuit.calculateWitness( - circuit, inputs, CurveId.BN254); + circuit, inputs, CurveId.BLS12_381); PublicInputs publicInputs = inputs.toPublicInputs(); CircuitId circuitId = AgeVerificationCircuit.circuitId(); ZkCircuitMetadata metadata = AgeVerificationCircuit.metadata(); @@ -169,7 +210,7 @@ Use `proofEnvelopeBuilder(...)` to create a var envelope = AgeVerificationCircuit.proofEnvelopeBuilder( circuit, ProofSystemId.GROTH16, - CurveId.BN254, + CurveId.BLS12_381, proof.proveResponse().proofJson().getBytes(StandardCharsets.UTF_8), inputs, new VerificationKeyRef.ById("age-v1")) @@ -237,9 +278,15 @@ bind them as secret `ZkUInt` inputs, using 252 bits for `kModL` and 4 bits for - Static `@Prove` methods must use parameter-style inputs. - `@CircuitParam` belongs on constructor parameters, not proof method parameters. -- `ZkPoseidon` exposes two-input hashes; use repeated folding or the lower-level - `PoseidonN`/Signal APIs for variable-arity hashing. -- `ZkMiMC` is BN254-only because the underlying MiMC gadget is BN254-only. Use - `ZkPoseidon` with BLS12-381 parameters for BLS12-381 circuits. +- `ZkPoseidon` exposes two-input hashes. Use repeated folding or the + lower-level `PoseidonN`/Signal APIs for variable-arity hashing until + `ZkPoseidonN` is added. For Cardano, pass + `PoseidonParamsBLS12_381T3.INSTANCE` explicitly. +- `ZkMiMC` is BN254-only because the underlying MiMC gadget is BN254-only. + Treat MiMC-based annotated circuits as BN254/off-chain unless a separate + BLS12-381 MiMC variant is added. +- `ZkMerkle.HashType.MIMC` and the no-params `HashType.POSEIDON` path are not + Cardano defaults today. Use a custom BLS12-381 Poseidon hash lambda for + Cardano Merkle circuits. - `ZkBits` and `ZkBytes` store one constrained field element per bit or byte; packed byte encodings are deferred. diff --git a/docs/circuit-dsl-user-guide.md b/docs/circuit-dsl-user-guide.md index 019e942..8e72b6d 100644 --- a/docs/circuit-dsl-user-guide.md +++ b/docs/circuit-dsl-user-guide.md @@ -357,19 +357,34 @@ BigInteger[] witness = circuit.calculateWitness(inputs, CurveId.BLS12_381); ## Standard Library (zeroj-circuit-lib) -Import: `com.bloxbean.cardano.zeroj.circuit.lib.*` +Import: `com.bloxbean.cardano.zeroj.circuit.lib.*`. For explicit BLS12-381 +Poseidon parameters, also import +`com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3`. ### Hash Functions +MiMC is BN254-only in the current circuit library. For Cardano/BLS12-381 +circuits, use Poseidon with explicit BLS12-381 parameters. + ```java -// MiMC-7 hash (2 inputs → 1 output, ~364 constraints) +// MiMC-7 hash (2 inputs -> 1 output, ~364 constraints, BN254-only) var hash = MiMC.hash(api, api.var("left"), api.var("right")); -// Poseidon hash (2 inputs → 1 output, ~330 constraints) -var hash = Poseidon.hash(api, api.var("in0"), api.var("in1")); +// Poseidon hash (2 inputs -> 1 output, ~330 constraints, Cardano/BLS12-381) +var hash = Poseidon.hash( + api, + PoseidonParamsBLS12_381T3.INSTANCE, + api.var("in0"), + api.var("in1")); // Variable-arity Poseidon (N inputs via left-fold) -var hash = PoseidonN.hash(api, api.var("a"), api.var("b"), api.var("c"), api.var("d")); +var hash = PoseidonN.hash( + api, + PoseidonParamsBLS12_381T3.INSTANCE, + api.var("a"), + api.var("b"), + api.var("c"), + api.var("d")); // MiMC Sponge (variable-length input, single or multi-output) var hash = MiMCSponge.hash(api, new Variable[]{api.var("in0"), api.var("in1"), api.var("in2")}); @@ -379,8 +394,8 @@ var outputs = MiMCSponge.hashMulti(api, inputs, 2); // 2 outputs Signal API equivalents: ```java Signal hash = SignalMiMC.hash(c, left, right); -Signal hash = SignalPoseidon.hash(c, in0, in1); -Signal hash = PoseidonN.hash(c, a, b, c, d); +Signal hash = SignalPoseidon.hash(c, PoseidonParamsBLS12_381T3.INSTANCE, in0, in1); +Signal hash = PoseidonN.hash(c, PoseidonParamsBLS12_381T3.INSTANCE, a, b, inputC, inputD); Signal hash = MiMCSponge.hash(c, new Signal[]{in0, in1, in2}); ``` @@ -397,7 +412,8 @@ var smallest = Comparators.min(api, a, b, 8); ```java // Verify a Merkle proof with any hash function Merkle.verifyProof(api, leaf, root, siblings, pathBits, MiMC::hash); -Merkle.verifyProof(api, leaf, root, siblings, pathBits, Poseidon::hash); // or Poseidon +Merkle.verifyProof(api, leaf, root, siblings, pathBits, + (ctx, l, r) -> Poseidon.hash(ctx, PoseidonParamsBLS12_381T3.INSTANCE, l, r)); ``` For a depth-20 tree: 20 × 364 ≈ 7,280 constraints with MiMC, or 20 × 330 ≈ 6,600 with Poseidon. @@ -743,7 +759,7 @@ See the [Pure Java Prover Guide](pure-java-prover-guide.md) for the complete pip 4. **Prefer `select` over branching** — ZK circuits can't branch. Use `api.select(cond, a, b)` for conditional logic. -5. **Use ZK-friendly hashes** — Poseidon (~300 constraints) and MiMC (~364 constraints) are much cheaper than SHA-256 (~25,000 constraints) inside circuits. +5. **Use ZK-friendly hashes** — Poseidon (~300 constraints) and MiMC (~364 constraints) are much cheaper than SHA-256 (~25,000 constraints) inside circuits. For Cardano/BLS12-381 circuits, use Poseidon with explicit BLS12-381 parameters; MiMC is BN254-only in the current circuit library. ## Module Dependencies diff --git a/docs/getting-started.md b/docs/getting-started.md index d6e1d41..49b0d85 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -269,7 +269,9 @@ The `zeroj-examples` module contains complete working examples: ### Example Circuits - **Sealed Bid Auction** -- prove bid >= reserve without revealing bid amount -- **Anonymous Voting** -- prove vote is 0/1 with MiMC commitment (double-vote prevention via nullifier) +- **Anonymous Voting** -- prove vote is 0/1 with a MiMC commitment in the + BN254/off-chain reference flow. For Cardano/BLS12-381 circuits, use Poseidon + with explicit BLS12-381 parameters. - **Balance Threshold** -- prove balance >= threshold without revealing exact balance See the [examples README](../zeroj-examples/README.md) for detailed descriptions of each flow. diff --git a/docs/pure-java-prover-guide.md b/docs/pure-java-prover-guide.md index 5d2e8a4..e318c73 100644 --- a/docs/pure-java-prover-guide.md +++ b/docs/pure-java-prover-guide.md @@ -400,8 +400,8 @@ testImplementation 'com.bloxbean.cardano:cardano-client-lib' | Test | Circuit | What It Proves | |------|---------|---------------| -| `SealedBidPureJavaE2ETest` | Sealed bid auction (497 constraints) | MiMC hash + range comparison | -| `AnonymousVotingPureJavaE2ETest` | Anonymous voting (367 constraints) | MiMC commitment + boolean | +| `SealedBidPureJavaE2ETest` | Sealed bid auction (497 constraints) | MiMC hash + range comparison; BN254/off-chain reference unless migrated to BLS12-381 Poseidon | +| `AnonymousVotingPureJavaE2ETest` | Anonymous voting (367 constraints) | MiMC commitment + boolean; BN254/off-chain reference unless migrated to BLS12-381 Poseidon | | `BalanceThresholdPureJavaE2ETest` | Balance threshold (132 constraints) | Range comparison | | `PureJavaProverYaciE2ETest` | Multiplier | **Full stack: prove → Yaci DevKit on-chain** | | `Groth16BLS381ZkeyEndToEndTest` | Multiplier + Cubic | snarkjs .zkey import → Java prove → pairing verify | diff --git a/zeroj-circuit-annotation-api/README.md b/zeroj-circuit-annotation-api/README.md index f8ea8ce..ea3d4d3 100644 --- a/zeroj-circuit-annotation-api/README.md +++ b/zeroj-circuit-annotation-api/README.md @@ -40,10 +40,29 @@ var inputs = RangeProofCircuit.inputs() .lo(BigInteger.valueOf(18)) .hi(BigInteger.valueOf(99)); -var witness = circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254); +var witness = circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381); var publicValues = inputs.publicValues(); ``` +Cardano-oriented annotated circuits should use BLS12-381 Groth16 by default: + +```text +generated *Circuit companion + -> compileR1CS(CurveId.BLS12_381) + -> Groth16 proof + -> Julc / Plutus V3 BLS12-381 verifier +``` + +For hashes in Cardano circuits, use explicit BLS12-381 Poseidon parameters: + +```java +ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right); +``` + +Do not rely on no-params Poseidon or MiMC for Cardano defaults. The no-params +Poseidon path is BN254-compatible behavior, and `ZkMiMC` delegates to the +BN254-only MiMC gadget. + Important API rules: - `ZkBool.publicInput` / `secret` add boolean constraints eagerly. @@ -69,9 +88,15 @@ Known limitations: arrays when needed. - `ZkPoseidon` currently exposes two-input hashes. For N-input commitments, fold inputs through repeated two-input hashes or use the lower-level - `PoseidonN`/Signal APIs until a dedicated `ZkPoseidonN` helper is added. + `PoseidonN`/Signal APIs until a dedicated `ZkPoseidonN` helper is added. For + Cardano, pass BLS12-381 Poseidon params explicitly. - `ZkMiMC` is BN254-only because it delegates to the existing MiMC gadget. Use - `ZkPoseidon` with explicit BLS12-381 parameters for BLS12-381 circuits. + `ZkPoseidon` with explicit BLS12-381 parameters for Cardano/BLS12-381 + circuits. +- `ZkMerkle.HashType.MIMC` and the no-params `HashType.POSEIDON` convenience + path are BN254-oriented today. For Cardano Merkle circuits, use a custom hash + lambda that calls `ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, + left, right)` until a params-aware `ZkMerkle` helper is added. - Elliptic-curve composite symbolic types are available for the shipped Jubjub use cases (`ZkJubjubPoint`, Pedersen, EdDSA-Jubjub). Add a curve-specific symbolic wrapper before using another curve family. @@ -79,4 +104,6 @@ Known limitations: package-private field style or parameter-style inputs. See [docs/adr/circuit-annotation/README.md](../docs/adr/circuit-annotation/README.md) -for the accepted implementation plan. +for the accepted implementation plan, and +[docs/adr/circuit-annotation/cardano-gadget-support-matrix.md](../docs/adr/circuit-annotation/cardano-gadget-support-matrix.md) +for the current Cardano gadget/curve support matrix. diff --git a/zeroj-circuit-annotation-processor/README.md b/zeroj-circuit-annotation-processor/README.md index 58da76d..72ec2d6 100644 --- a/zeroj-circuit-annotation-processor/README.md +++ b/zeroj-circuit-annotation-processor/README.md @@ -41,4 +41,7 @@ dependencies { ``` See [docs/adr/circuit-annotation/README.md](../docs/adr/circuit-annotation/README.md) -for the accepted implementation plan. +for the accepted implementation plan, and +[docs/adr/circuit-annotation/cardano-gadget-support-matrix.md](../docs/adr/circuit-annotation/cardano-gadget-support-matrix.md) +for the current Cardano-oriented gadget, curve, and symbolic-adapter support +matrix. diff --git a/zeroj-circuit-lib/README.md b/zeroj-circuit-lib/README.md index 0d582ec..89d67d9 100644 --- a/zeroj-circuit-lib/README.md +++ b/zeroj-circuit-lib/README.md @@ -48,24 +48,41 @@ For larger circuits, prefer the `Signal*` helper classes with `SignalBuilder` and reusable `CircuitSpec` components. Annotation-based circuits can use symbolic adapters from -`com.bloxbean.cardano.zeroj.circuit.lib.zk`: +`com.bloxbean.cardano.zeroj.circuit.lib.zk`. Cardano/BLS12-381 hash examples +also use `PoseidonParamsBLS12_381T3` from +`com.bloxbean.cardano.zeroj.circuit.lib.poseidon`: ```java -var hash = ZkPoseidon.hash(zk, left, right); -var root = ZkMerkle.computeRoot(zk, leaf, siblings, pathBits, ZkMiMC::hash); +var hash = ZkPoseidon.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + left, + right); +var root = ZkMerkle.computeRoot( + zk, + leaf, + siblings, + pathBits, + (ctx, l, r) -> ZkPoseidon.hash(ctx, PoseidonParamsBLS12_381T3.INSTANCE, l, r)); var commitment = ZkPedersen.commit(zk, value, blinding, 64); ``` These adapters delegate to the existing `Signal*` and in-circuit gadgets and validate that their inputs belong to the supplied `ZkContext`. `ZkMiMC` is guarded as BN254-only; use explicit Poseidon parameters when targeting -BLS12-381. Jubjub, Pedersen, and EdDSA-Jubjub adapters are BLS12-381-only and +BLS12-381. The no-params Poseidon helpers are BN254-oriented for backward +compatibility, and `ZkMerkle.HashType.MIMC` / no-params `HashType.POSEIDON` +should be treated as BN254/off-chain conveniences until params-aware Merkle +helpers are added. Jubjub, Pedersen, and EdDSA-Jubjub adapters are BLS12-381-only and inherit the curve/subgroup-check contracts documented on the underlying in-circuit gadgets. Use `ZkJubjubPoint.fromTrustedAffine(...)` only for points validated off-circuit for curve membership, subgroup membership, and non-identity where the protocol requires it. `ZkEdDSAJubjub.verify(...)` rejects identity public keys in-circuit. +The Cardano-oriented support matrix is maintained in +[`docs/adr/circuit-annotation/cardano-gadget-support-matrix.md`](../docs/adr/circuit-annotation/cardano-gadget-support-matrix.md). + ## Gradle ```gradle diff --git a/zeroj-examples/README.md b/zeroj-examples/README.md index 9502316..db027b7 100644 --- a/zeroj-examples/README.md +++ b/zeroj-examples/README.md @@ -14,6 +14,25 @@ End-to-end demonstrations of ZeroJ capabilities -- from Java DSL circuit definit ## Example Circuits +### Cardano Defaults + +For new examples intended to be verified on Cardano, prefer: + +```text +Java DSL or @ZKCircuit + -> CurveId.BLS12_381 + -> Groth16 + -> Julc / Plutus V3 BLS12-381 verifier +``` + +Use Poseidon with explicit BLS12-381 parameters for hashes. MiMC-based Java DSL +and annotation examples are BN254/off-chain references unless they are migrated +to a separate BLS12-381 hash. The no-params Poseidon path is also BN254-oriented +for backward compatibility. + +See the circuit annotation support matrix: +[`docs/adr/circuit-annotation/cardano-gadget-support-matrix.md`](../docs/adr/circuit-annotation/cardano-gadget-support-matrix.md). + ### 0. Annotation-Based Circuits Write circuits as annotated Java classes and use generated companions for `build(...)`, `schema(...)`, and witness input builders. @@ -23,19 +42,23 @@ Write circuits as annotated Java classes and use generated companions for - **Source**: [`examples/annotation`](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation) - **Tests**: [`AnnotatedCircuitExamplesTest.java`](src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java) - **Guide**: [`docs/circuit-annotation-user-guide.md`](../docs/circuit-annotation-user-guide.md) -- **Note**: MiMC-based annotation examples target BN254. For BLS12-381 circuits, - use Poseidon with explicit BLS12-381 parameters. +- **Note**: MiMC-based annotation examples target BN254/off-chain. For + Cardano/BLS12-381 circuits, use Poseidon with explicit BLS12-381 parameters. ### 1. Sealed-Bid Auction Prove your bid exceeds a reserve price without revealing the bid amount. - **Private**: bidAmount, salt - **Public**: reservePrice, bidCommitment (MiMC hash), isAboveReserve (0/1) +- **Cardano note**: the Java DSL MiMC version is a BN254/off-chain reference. + Use a BLS12-381 Poseidon commitment for new Cardano-ready sealed-bid circuits. - **Source**: [`SealedBidCircuit.java`](src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidCircuit.java) ### 2. Anonymous Voting Prove a vote is valid (0 or 1) with a hash commitment for double-vote prevention. - **Private**: vote, nullifier - **Public**: commitment (MiMC hash) +- **Cardano note**: the MiMC commitment version is BN254/off-chain. Use + BLS12-381 Poseidon for Cardano-ready voting circuits. - **Source**: [`AnonymousVotingCircuit.java`](src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingCircuit.java) ### 3. Balance Threshold @@ -48,10 +71,10 @@ Prove a balance exceeds a threshold without revealing the exact balance. | Test | Circuit | Prover | Verifier | On-Chain | |------|---------|--------|----------|----------| -| `SealedBidE2ETest` | Sealed bid | snarkjs CLI | Pure Java (BLS12-381) | No | -| `SealedBidGnarkE2ETest` | Sealed bid | gnark FFM | Pure Java (BLS12-381) | No | +| `SealedBidE2ETest` | Sealed bid, MiMC reference | snarkjs CLI | Pure Java | No | +| `SealedBidGnarkE2ETest` | Sealed bid, MiMC reference | gnark FFM | Pure Java | No | | `SealedBidOnChainE2ETest` | Sealed bid | Pre-generated | Julc/Plutus V3 | Yes (Yaci DevKit) | -| `AnonymousVotingE2ETest` | Voting | snarkjs CLI | Pure Java (BLS12-381) | No | +| `AnonymousVotingE2ETest` | Voting, MiMC reference | snarkjs CLI | Pure Java | No | | `BalanceThresholdE2ETest` | Balance | snarkjs CLI | Pure Java (BLS12-381) | No | ## Three Proving Paths @@ -74,7 +97,9 @@ proof adapter is added. ``` Java DSL → R1CS → gnark/snarkjs prove → Julc Plutus V3 verify (Yaci DevKit) ``` -Used in: `SealedBidOnChainE2ETest` +Used in: `SealedBidOnChainE2ETest`. The on-chain path uses BLS12-381 proof +artifacts and should not be confused with the BN254/off-chain MiMC Java DSL +reference circuit. ## On-Chain Flow (SealedBidOnChainE2ETest) diff --git a/zeroj-examples/docs/e2e-groth16-sealed-bid.md b/zeroj-examples/docs/e2e-groth16-sealed-bid.md index dc1ff5f..a8df901 100644 --- a/zeroj-examples/docs/e2e-groth16-sealed-bid.md +++ b/zeroj-examples/docs/e2e-groth16-sealed-bid.md @@ -1,4 +1,9 @@ -# E2E: Sealed-Bid Auction (Java DSL → Groth16 → Cardano) +# E2E: Sealed-Bid Auction (Java DSL -> Groth16 -> Cardano) + +> Status note: this guide documents the legacy MiMC sealed-bid reference flow. +> In the current circuit library, MiMC is BN254-only. New Cardano/BLS12-381 +> sealed-bid circuits should use Poseidon with explicit BLS12-381 parameters. +> Treat any BLS12-381 MiMC snippets in this guide as migration candidates. ## Overview diff --git a/zeroj-examples/docs/e2e-groth16-voting.md b/zeroj-examples/docs/e2e-groth16-voting.md index 6bcfe4a..94f445d 100644 --- a/zeroj-examples/docs/e2e-groth16-voting.md +++ b/zeroj-examples/docs/e2e-groth16-voting.md @@ -1,4 +1,9 @@ -# E2E: Anonymous Voting (Java DSL → Groth16 → Cardano) +# E2E: Anonymous Voting (Java DSL -> Groth16 -> Cardano) + +> Status note: this guide documents the legacy MiMC voting reference flow. In +> the current circuit library, MiMC is BN254-only. New Cardano/BLS12-381 voting +> circuits should use Poseidon with explicit BLS12-381 parameters. Treat any +> BLS12-381 MiMC snippets in this guide as migration candidates. ## Overview diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAnonymousVoting.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAnonymousVoting.java new file mode 100644 index 0000000..6b4734c --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAnonymousVoting.java @@ -0,0 +1,23 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMiMC; + +@ZKCircuit(name = "annotation-anonymous-vote", version = 1) +public class AnnotatedAnonymousVoting { + @Prove + ZkBool prove( + ZkContext zk, + @Public ZkField commitment, + @Secret ZkBool vote, + @Secret ZkField nullifier) { + return ZkMiMC.hash(zk, vote.asField(), nullifier) + .isEqual(commitment); + } +} diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedSealedBid.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedSealedBid.java new file mode 100644 index 0000000..24e1728 --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedSealedBid.java @@ -0,0 +1,31 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.UInt; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMiMC; + +@ZKCircuit(name = "annotation-sealed-bid", version = 1) +public class AnnotatedSealedBid { + @Prove + ZkBool prove( + ZkContext zk, + @Public @UInt(bits = 64) ZkUInt reservePrice, + @Public ZkField bidCommitment, + @Public ZkBool isAboveReserve, + @Secret @UInt(bits = 64) ZkUInt bidAmount, + @Secret ZkField salt) { + var commitmentMatches = ZkMiMC.hash(zk, bidAmount.asField(), salt) + .isEqual(bidCommitment); + var reserveFlagMatches = bidAmount.gte(reservePrice) + .isEqual(isAboveReserve); + + return commitmentMatches.and(reserveFlagMatches); + } +} From 3dc3579de5edb965509375ebbd84f778ad294a58 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 18:35:42 +0800 Subject: [PATCH 13/26] feat(circuit): add symbolic PoseidonN adapter --- docs/adr/circuit-annotation/README.md | 1 + .../cardano-gadget-support-matrix.md | 24 +-- .../circuit-annotation/implementation-plan.md | 2 +- .../zk-poseidon-n-symbolic-adapter.md | 126 ++++++++++++++++ docs/circuit-annotation-user-guide.md | 9 +- zeroj-circuit-annotation-api/README.md | 9 +- zeroj-circuit-lib/README.md | 16 +- .../zeroj/circuit/lib/zk/ZkPoseidonN.java | 47 ++++++ .../circuit/lib/zk/ZkGadgetAdaptersTest.java | 137 ++++++++++++++++++ zeroj-examples/README.md | 8 +- .../AnnotatedMultiInputCommitment.java | 30 ++++ .../AnnotatedCircuitExamplesTest.java | 40 +++++ 12 files changed, 422 insertions(+), 27 deletions(-) create mode 100644 docs/adr/circuit-annotation/zk-poseidon-n-symbolic-adapter.md create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPoseidonN.java create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMultiInputCommitment.java diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index 1ffd7af..932daf6 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -90,6 +90,7 @@ explicit BLS12-381 parameters: ```java ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right); +ZkPoseidonN.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, owner, assetId, nonce); ``` The current gadget, curve, symbolic-adapter, and Cardano support matrix is diff --git a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md index e0eec71..444182f 100644 --- a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -108,7 +108,7 @@ Relevant source: | `MiMC` / `SignalMiMC` | MiMC-7 two-input hash | BN254 only in current circuit lib | Direct through `ZkMiMC` | No for Cardano on-chain, because the circuit requires BN254 | Use Poseidon for Cardano. Consider a separate BLS12-381 MiMC variant only if there is a concrete interop need. | | `MiMCSponge` | Variable-length MiMC sponge | BN254 only, because it calls `MiMC` | No direct `ZkMiMCSponge` | No for Cardano on-chain | Add symbolic wrapper only for BN254/off-chain legacy use. | | `Poseidon` | Two-input Poseidon T3 hash | BN254 default; BLS12-381 supported with explicit params | Direct through `ZkPoseidon` for two inputs | Yes if `PoseidonParamsBLS12_381T3.INSTANCE` is used | Make BLS12-381 params prominent in Cardano examples. | -| `PoseidonN` | Variable-arity folded Poseidon | BN254 default; BLS12-381 supported with explicit params | Not directly exposed as `ZkPoseidonN` | Yes via manual fold or signal escape hatch | Add `ZkPoseidonN.hash(zk, params, ZkField...)`. | +| `PoseidonN` | Variable-arity folded Poseidon | BN254 default; BLS12-381 supported with explicit params | Direct through `ZkPoseidonN` with explicit params | Yes if `PoseidonParamsBLS12_381T3.INSTANCE` is used | None. Params-aware `ZkMerkle` remains next. | | `Merkle` / `SignalMerkle` | Fixed-depth Merkle membership | Hash-dependent | Direct through `ZkMerkle` | Yes when the hash is BLS12-381 compatible | Add params-aware symbolic API for BLS12-381 Poseidon. | | `ZkMerkle.HashType.MIMC` | Merkle with MiMC | BN254 only | Direct | No for Cardano on-chain | Mark as BN254/off-chain in docs. | | `ZkMerkle.HashType.POSEIDON` | Merkle with default Poseidon | BN254 by default today | Direct | No if using default enum path | Add `POSEIDON_BLS12_381_T3` or a params-aware factory. | @@ -223,16 +223,18 @@ Exit criteria: ### Phase B: `ZkPoseidonN` +Status: completed. The symbolic adapter is tracked in +[`zk-poseidon-n-symbolic-adapter.md`](zk-poseidon-n-symbolic-adapter.md). + Goal: expose variable-arity Poseidon cleanly in symbolic annotated circuits. Tasks: -- Add `ZkPoseidonN.hash(ZkContext, PoseidonParams, ZkField...)`. -- Add `ZkPoseidonN.hash(ZkContext, ZkField...)` only if it is clearly - documented as BN254 default, or skip the no-params overload to avoid - Cardano mistakes. -- Add tests comparing `ZkPoseidonN` to `PoseidonN` for BN254 and BLS12-381. -- Add at least one annotated example using BLS12-381 `ZkPoseidonN`. +- Added `ZkPoseidonN.hash(ZkContext, PoseidonParams, ZkField...)`. +- Skipped the no-params overload to avoid Cardano mistakes. +- Added BN254 differential tests against `PoseidonN` and BLS12-381 tests + against `PoseidonHash.hashN(...)` plus compile-curve guards. +- Added an annotated example using BLS12-381 `ZkPoseidonN`. Exit criteria: @@ -335,8 +337,8 @@ Exit criteria: ## Recommended Priority -1. Documentation and defaults. -2. `ZkPoseidonN`. +1. Documentation and defaults. Completed. +2. `ZkPoseidonN`. Completed. 3. Params-aware BLS12-381 `ZkMerkle`. 4. Generic/generated Cardano Groth16 verifier for arbitrary public-input count. 5. Example migration to BLS12-381 Poseidon. @@ -384,8 +386,8 @@ Negative: ## Open Questions -- Should no-params symbolic Poseidon remain available in Cardano-facing docs, - or should all docs require explicit params? +- Should the existing no-params two-input `ZkPoseidon` overload remain visible + in Cardano-facing docs, or should all Cardano docs require explicit params? - Should `ZkMerkle.HashType.POSEIDON` be deprecated in favor of explicit parameterized factories? - Should the on-chain Groth16 verifier be generated per circuit, or should a diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index 446a4c6..9475813 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -66,7 +66,7 @@ recommended follow-up order is: | Priority | Work | Status | |----------|------|--------| | 1 | Documentation and defaults | Completed | -| 2 | `ZkPoseidonN` symbolic adapter | Pending | +| 2 | `ZkPoseidonN` symbolic adapter | Completed | | 3 | Params-aware BLS12-381 `ZkMerkle` helpers | Pending | | 4 | Generic or generated Cardano Groth16 verifier for arbitrary public-input count | Pending | | 5 | Example migration to BLS12-381 Poseidon where examples are Cardano-facing | Pending | diff --git a/docs/adr/circuit-annotation/zk-poseidon-n-symbolic-adapter.md b/docs/adr/circuit-annotation/zk-poseidon-n-symbolic-adapter.md new file mode 100644 index 0000000..24fcb7f --- /dev/null +++ b/docs/adr/circuit-annotation/zk-poseidon-n-symbolic-adapter.md @@ -0,0 +1,126 @@ +# ADR: ZkPoseidonN Symbolic Adapter + +## Status + +Implemented. + +## Date + +2026-05-18 + +## Context + +The Cardano gadget support matrix identifies variable-arity Poseidon as the +next priority for symbolic annotated circuits. The low-level circuit library +already has `PoseidonN`, which folds N inputs through the reviewed two-input +`Poseidon` gadget: + +```text +PoseidonN(a, b, c, d) = Poseidon(Poseidon(Poseidon(a, b), c), d) +``` + +That API already supports explicit `PoseidonParams`, including +`PoseidonParamsBLS12_381T3.INSTANCE`. Annotation authors can use it today only +by extracting `Signal` values and wrapping the result back into `ZkField`, which +breaks the symbolic style and makes common multi-input commitments noisier than +they should be. + +## Decision + +ZeroJ now provides a symbolic adapter: + +```java +ZkField commitment = ZkPoseidonN.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + owner, + assetId, + nonce, + amount); +``` + +The adapter: + +- live in `com.bloxbean.cardano.zeroj.circuit.lib.zk` +- accept `ZkContext`, explicit `PoseidonParams`, and `ZkField... inputs` +- reject empty input lists +- reject null inputs +- reject symbolic values from another `SignalBuilder` +- delegate directly to `PoseidonN.hash(SignalBuilder, PoseidonParams, Signal...)` +- return a wrapped `ZkField` + +Do not add a no-params symbolic overload in this phase. The existing no-params +low-level `PoseidonN` overload defaults to BN254 for backward compatibility. +For annotated circuits, especially Cardano-facing examples, forcing an explicit +`PoseidonParams` argument is safer and keeps the compile curve visible at the +call site. + +## Scope + +In scope: + +- `ZkPoseidonN` symbolic adapter +- differential tests against existing `PoseidonN` +- BLS12-381 compile/witness tests +- one annotated example using `PoseidonParamsBLS12_381T3.INSTANCE` +- documentation updates that remove the previous "drop to Signal" guidance + +Out of scope: + +- true width-N Poseidon parameter sets +- changing the existing `PoseidonN` folding semantics +- changing the BN254 default on existing non-symbolic APIs +- params-aware `ZkMerkle`; that remains the next follow-up + +## API Shape + +```java +public final class ZkPoseidonN { + public static ZkField hash( + ZkContext zk, + PoseidonParams params, + ZkField... inputs); +} +``` + +Single-input semantics match `PoseidonN`: `ZkPoseidonN.hash(zk, params, x)` is +equivalent to `Poseidon(x, 0)` under the same params. This is a ZeroJ +convention and not a separate external Poseidon one-input standard. + +## Testing + +Add tests that: + +- compare `ZkPoseidonN` witness output and gate count against `PoseidonN` for + BN254 parameters +- verify BLS12-381 params compile on `CurveId.BLS12_381` and reject + `CurveId.BN254` +- compare BLS12-381 circuit output against `PoseidonHash.hashN(...)` +- reject empty input arrays +- reject inputs from another circuit builder +- compile and exercise an annotated BLS12-381 multi-input commitment example + +Implemented coverage: + +- `ZkGadgetAdaptersTest` covers BN254 differential behavior, BLS12-381 + field-guard behavior, invalid outputs, empty input rejection, and foreign + builder rejection. +- `AnnotatedCircuitExamplesTest` covers the generated + `AnnotatedMultiInputCommitmentCircuit` companion over `CurveId.BLS12_381`. + +## Consequences + +Positive: + +- annotated circuits can express multi-input commitments without using the + Signal escape hatch +- Cardano examples can use explicit BLS12-381 Poseidon params directly +- implementation stays thin and inherits existing `PoseidonN` behavior and + tests + +Negative: + +- BN254 users must pass `PoseidonParamsBN254T3.INSTANCE` explicitly in symbolic + code +- developers who want true arity-specific Poseidon must still use a future + width-specific gadget diff --git a/docs/circuit-annotation-user-guide.md b/docs/circuit-annotation-user-guide.md index e6727c4..1671e1c 100644 --- a/docs/circuit-annotation-user-guide.md +++ b/docs/circuit-annotation-user-guide.md @@ -67,6 +67,7 @@ parameters: ```java ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right); +ZkPoseidonN.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, owner, assetId, nonce); ``` Do not use no-params Poseidon or MiMC as Cardano defaults. The no-params @@ -278,10 +279,10 @@ bind them as secret `ZkUInt` inputs, using 252 bits for `kModL` and 4 bits for - Static `@Prove` methods must use parameter-style inputs. - `@CircuitParam` belongs on constructor parameters, not proof method parameters. -- `ZkPoseidon` exposes two-input hashes. Use repeated folding or the - lower-level `PoseidonN`/Signal APIs for variable-arity hashing until - `ZkPoseidonN` is added. For Cardano, pass - `PoseidonParamsBLS12_381T3.INSTANCE` explicitly. +- `ZkPoseidon` exposes two-input hashes. Use `ZkPoseidonN` for folded + variable-arity hashing. For Cardano, pass + `PoseidonParamsBLS12_381T3.INSTANCE` explicitly; there is intentionally no + no-params symbolic overload. - `ZkMiMC` is BN254-only because the underlying MiMC gadget is BN254-only. Treat MiMC-based annotated circuits as BN254/off-chain unless a separate BLS12-381 MiMC variant is added. diff --git a/zeroj-circuit-annotation-api/README.md b/zeroj-circuit-annotation-api/README.md index ea3d4d3..15b841a 100644 --- a/zeroj-circuit-annotation-api/README.md +++ b/zeroj-circuit-annotation-api/README.md @@ -57,6 +57,7 @@ For hashes in Cardano circuits, use explicit BLS12-381 Poseidon parameters: ```java ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right); +ZkPoseidonN.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, owner, assetId, nonce); ``` Do not rely on no-params Poseidon or MiMC for Cardano defaults. The no-params @@ -86,10 +87,10 @@ Known limitations: - Annotated inputs support fixed-size `ZkArray` for built-in element types, but not nested `ZkArray>`; flatten nested structures into parallel arrays when needed. -- `ZkPoseidon` currently exposes two-input hashes. For N-input commitments, fold - inputs through repeated two-input hashes or use the lower-level - `PoseidonN`/Signal APIs until a dedicated `ZkPoseidonN` helper is added. For - Cardano, pass BLS12-381 Poseidon params explicitly. +- `ZkPoseidon` exposes two-input hashes. Use `ZkPoseidonN` for folded N-input + commitments. For Cardano, pass BLS12-381 Poseidon params explicitly; the + symbolic API intentionally does not provide a no-params `ZkPoseidonN` + overload. - `ZkMiMC` is BN254-only because it delegates to the existing MiMC gadget. Use `ZkPoseidon` with explicit BLS12-381 parameters for Cardano/BLS12-381 circuits. diff --git a/zeroj-circuit-lib/README.md b/zeroj-circuit-lib/README.md index 89d67d9..a777825 100644 --- a/zeroj-circuit-lib/README.md +++ b/zeroj-circuit-lib/README.md @@ -17,7 +17,7 @@ or Jubjub-style primitives. | Binary gadgets | `Binary`, `SignalBinary`, `AliasCheck` | | Selection | `Mux` | | Signal helpers | `SignalPoseidon`, `SignalMiMC` | -| Annotation helpers | `ZkPoseidon`, `ZkMiMC`, `ZkMerkle`, `ZkJubjubPoint`, `ZkPedersen`, `ZkEdDSAJubjub` | +| Annotation helpers | `ZkPoseidon`, `ZkPoseidonN`, `ZkMiMC`, `ZkMerkle`, `ZkJubjubPoint`, `ZkPedersen`, `ZkEdDSAJubjub` | | Jubjub primitives | `JubjubCurve`, `PedersenCommitment`, `EdDSAJubjub`, in-circuit variants | | Poseidon parameters | `PoseidonParams*`, `PoseidonHash`, Grain LFSR generation helpers | @@ -58,20 +58,28 @@ var hash = ZkPoseidon.hash( PoseidonParamsBLS12_381T3.INSTANCE, left, right); +var commitment = ZkPoseidonN.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + owner, + assetId, + nonce); var root = ZkMerkle.computeRoot( zk, leaf, siblings, pathBits, (ctx, l, r) -> ZkPoseidon.hash(ctx, PoseidonParamsBLS12_381T3.INSTANCE, l, r)); -var commitment = ZkPedersen.commit(zk, value, blinding, 64); +var pedersen = ZkPedersen.commit(zk, value, blinding, 64); ``` These adapters delegate to the existing `Signal*` and in-circuit gadgets and validate that their inputs belong to the supplied `ZkContext`. `ZkMiMC` is guarded as BN254-only; use explicit Poseidon parameters when targeting -BLS12-381. The no-params Poseidon helpers are BN254-oriented for backward -compatibility, and `ZkMerkle.HashType.MIMC` / no-params `HashType.POSEIDON` +BLS12-381. `ZkPoseidonN` requires explicit Poseidon params and is the symbolic +path for folded multi-input commitments. The no-params Poseidon helpers are +BN254-oriented for backward compatibility, and `ZkMerkle.HashType.MIMC` / +no-params `HashType.POSEIDON` should be treated as BN254/off-chain conveniences until params-aware Merkle helpers are added. Jubjub, Pedersen, and EdDSA-Jubjub adapters are BLS12-381-only and inherit the curve/subgroup-check contracts documented on the underlying diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPoseidonN.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPoseidonN.java new file mode 100644 index 0000000..dbd2a08 --- /dev/null +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkPoseidonN.java @@ -0,0 +1,47 @@ +package com.bloxbean.cardano.zeroj.circuit.lib.zk; + +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.lib.PoseidonN; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; + +import java.util.Objects; + +/** + * Symbolic variable-arity Poseidon adapter for annotation-based circuits. + * + *

This wrapper requires explicit {@link PoseidonParams}. The lower-level + * {@link PoseidonN} no-params overload remains BN254 for backward + * compatibility, but symbolic code should make the target field visible at the + * call site. + */ +public final class ZkPoseidonN { + + private ZkPoseidonN() {} + + /** + * Hash one or more symbolic field elements using folded two-input + * Poseidon under the supplied parameters. + * + *

Single-input semantics match {@link PoseidonN}: {@code hash(x)} is + * {@code Poseidon(x, 0)} under the same parameters. + */ + public static ZkField hash(ZkContext zk, PoseidonParams params, ZkField... inputs) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(params, "params"); + Objects.requireNonNull(inputs, "inputs"); + if (inputs.length == 0) { + throw new IllegalArgumentException("inputs must not be empty"); + } + + Signal[] signals = new Signal[inputs.length]; + for (int i = 0; i < inputs.length; i++) { + ZkField input = Objects.requireNonNull(inputs[i], "inputs[" + i + "]"); + zk.requireSignal(input.signal()); + signals[i] = input.signal(); + } + + return ZkField.wrap(zk, PoseidonN.hash(zk.builder(), params, signals)); + } +} diff --git a/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java b/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java index cfed06f..ded69aa 100644 --- a/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java +++ b/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java @@ -9,6 +9,7 @@ import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; +import com.bloxbean.cardano.zeroj.circuit.lib.PoseidonN; import com.bloxbean.cardano.zeroj.circuit.lib.SignalMerkle; import com.bloxbean.cardano.zeroj.circuit.lib.SignalMiMC; import com.bloxbean.cardano.zeroj.circuit.lib.SignalPoseidon; @@ -88,6 +89,142 @@ void poseidonAdapterMatchesSignalAdapter() { symbolic.calculateWitness(inputs, CurveId.BN254)); } + @Test + void poseidonNAdapterMatchesSignalAdapterForBn254() { + var symbolic = CircuitBuilder.create("zk-poseidon-n") + .secretVar("a") + .secretVar("b") + .secretVar("c") + .secretVar("d") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkPoseidonN.hash( + zk, + PoseidonParamsBN254T3.INSTANCE, + ZkField.secret(c, "a"), + ZkField.secret(c, "b"), + ZkField.secret(c, "c"), + ZkField.secret(c, "d")); + }); + + var signal = CircuitBuilder.create("signal-poseidon-n") + .secretVar("a") + .secretVar("b") + .secretVar("c") + .secretVar("d") + .defineSignals(c -> PoseidonN.hash( + c, + PoseidonParamsBN254T3.INSTANCE, + c.privateInput("a"), + c.privateInput("b"), + c.privateInput("c"), + c.privateInput("d"))); + + var inputs = Map.of( + "a", List.of(BigInteger.valueOf(5)), + "b", List.of(BigInteger.valueOf(9)), + "c", List.of(BigInteger.valueOf(13)), + "d", List.of(BigInteger.valueOf(17))); + + assertEquals(signal.constraintGraph().gates().size(), symbolic.constraintGraph().gates().size()); + assertArrayEquals( + signal.calculateWitness(inputs, CurveId.BN254), + symbolic.calculateWitness(inputs, CurveId.BN254)); + } + + @Test + void poseidonNExplicitParamsSupportBls12381() { + BigInteger a = BigInteger.valueOf(5); + BigInteger b = BigInteger.valueOf(9); + BigInteger cValue = BigInteger.valueOf(13); + BigInteger expected = PoseidonHash.hashN( + PoseidonParamsBLS12_381T3.INSTANCE, + a, + b, + cValue); + + var circuit = CircuitBuilder.create("zk-poseidon-n-bls") + .secretVar("a") + .secretVar("b") + .secretVar("c") + .publicVar("out") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkPoseidonN.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + ZkField.secret(c, "a"), + ZkField.secret(c, "b"), + ZkField.secret(c, "c")) + .assertEqual(ZkField.publicInput(c, "out")); + }); + + var valid = Map.of( + "a", List.of(a), + "b", List.of(b), + "c", List.of(cValue), + "out", List.of(expected)); + assertDoesNotThrow(() -> circuit.calculateWitness(valid, CurveId.BLS12_381)); + assertDoesNotThrow(() -> circuit.compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); + + var invalid = Map.of( + "a", List.of(a), + "b", List.of(b), + "c", List.of(cValue), + "out", List.of(BigInteger.ONE)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(invalid, CurveId.BLS12_381)); + } + + @Test + void poseidonNRejectsEmptyInputsAndForeignBuilderSignals() { + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-poseidon-n-empty") + .defineSignals(c -> ZkPoseidonN.hash( + new ZkContext(c), + PoseidonParamsBLS12_381T3.INSTANCE))); + + ZkField[] fieldFromOtherBuilder = new ZkField[1]; + CircuitBuilder.create("zk-poseidon-n-other") + .secretVar("a") + .defineSignals(c -> fieldFromOtherBuilder[0] = ZkField.secret(c, "a")); + + assertThrows(IllegalArgumentException.class, () -> CircuitBuilder.create("zk-poseidon-n-local") + .secretVar("b") + .defineSignals(c -> ZkPoseidonN.hash( + new ZkContext(c), + PoseidonParamsBLS12_381T3.INSTANCE, + fieldFromOtherBuilder[0], + ZkField.secret(c, "b")))); + } + + @Test + void poseidonNRejectsNullArguments() { + CircuitBuilder.create("zk-poseidon-n-null") + .secretVar("a") + .defineSignals(c -> { + var zk = new ZkContext(c); + var input = ZkField.secret(c, "a"); + + assertThrows(NullPointerException.class, () -> ZkPoseidonN.hash( + null, + PoseidonParamsBLS12_381T3.INSTANCE, + input)); + assertThrows(NullPointerException.class, () -> ZkPoseidonN.hash( + zk, + null, + input)); + assertThrows(NullPointerException.class, () -> ZkPoseidonN.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + (ZkField[]) null)); + assertThrows(NullPointerException.class, () -> ZkPoseidonN.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + input, + null)); + }); + } + @Test void merkleComputeRootMatchesSignalAdapterRootBehavior() { var symbolic = CircuitBuilder.create("zk-merkle") diff --git a/zeroj-examples/README.md b/zeroj-examples/README.md index db027b7..b5c01b6 100644 --- a/zeroj-examples/README.md +++ b/zeroj-examples/README.md @@ -37,13 +37,15 @@ See the circuit annotation support matrix: Write circuits as annotated Java classes and use generated companions for `build(...)`, `schema(...)`, and witness input builders. - **Examples**: range proof, age verification, private transfer, MiMC - commitment, sealed-bid auction, anonymous voting, parameterized Merkle - membership, Pedersen commitment, proof-flow helper + commitment, [BLS12-381 PoseidonN multi-input commitment](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMultiInputCommitment.java), + sealed-bid auction, anonymous voting, parameterized Merkle membership, + Pedersen commitment, proof-flow helper - **Source**: [`examples/annotation`](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation) - **Tests**: [`AnnotatedCircuitExamplesTest.java`](src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java) - **Guide**: [`docs/circuit-annotation-user-guide.md`](../docs/circuit-annotation-user-guide.md) - **Note**: MiMC-based annotation examples target BN254/off-chain. For - Cardano/BLS12-381 circuits, use Poseidon with explicit BLS12-381 parameters. + Cardano/BLS12-381 circuits, use `ZkPoseidon` or `ZkPoseidonN` with explicit + BLS12-381 parameters. ### 1. Sealed-Bid Auction Prove your bid exceeds a reserve price without revealing the bid amount. diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMultiInputCommitment.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMultiInputCommitment.java new file mode 100644 index 0000000..436cc05 --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMultiInputCommitment.java @@ -0,0 +1,30 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkPoseidonN; + +@ZKCircuit(name = "annotation-multi-input-commitment", version = 1) +public class AnnotatedMultiInputCommitment { + @Prove + ZkBool prove( + ZkContext zk, + @Secret ZkField owner, + @Secret ZkField assetId, + @Secret ZkField nonce, + @Public ZkField commitment) { + return ZkPoseidonN.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + owner, + assetId, + nonce) + .isEqual(commitment); + } +} diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java index 40ab3df..f815b6b 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java @@ -8,6 +8,8 @@ import com.bloxbean.cardano.zeroj.circuit.FieldConfig; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkCircuitMetadata; import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.PedersenCommitment; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMerkle; import com.bloxbean.cardano.zeroj.examples.dsl.common.MiMCHash; import com.bloxbean.cardano.zeroj.prover.gnark.GnarkProver; @@ -172,6 +174,44 @@ void hashCommitmentUsesSymbolicGadgetAdapter() { () -> circuit.calculateWitness(wrong.toWitnessMap(), CurveId.BN254)); } + @Test + void multiInputCommitmentUsesBlsPoseidonNAdapter() { + var circuit = AnnotatedMultiInputCommitmentCircuit.build(); + var schema = AnnotatedMultiInputCommitmentCircuit.schema(); + + assertEquals("annotation-multi-input-commitment", schema.name()); + assertEquals(List.of("commitment"), schema.publicInputs().names()); + assertEquals(List.of("owner", "assetId", "nonce"), schema.secretInputs().names()); + + var owner = BigInteger.valueOf(101); + var assetId = BigInteger.valueOf(202); + var nonce = BigInteger.valueOf(303); + var commitment = PoseidonHash.hashN( + PoseidonParamsBLS12_381T3.INSTANCE, + owner, + assetId, + nonce); + + var inputs = AnnotatedMultiInputCommitmentCircuit.inputs() + .owner(owner) + .assetId(assetId) + .nonce(nonce) + .commitment(commitment); + + assertEquals(List.of(commitment), inputs.publicValues()); + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381)); + assertDoesNotThrow(() -> circuit.compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); + + var wrong = AnnotatedMultiInputCommitmentCircuit.inputs() + .owner(owner) + .assetId(assetId) + .nonce(nonce) + .commitment(BigInteger.ONE); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(wrong.toWitnessMap(), CurveId.BLS12_381)); + } + @Test void annotatedSealedBidMirrorsReferenceDslCircuit() { var circuit = AnnotatedSealedBidCircuit.build(); From 2f5b02970aff9c74b15aa0bb0df108e91f268968 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 19:06:18 +0800 Subject: [PATCH 14/26] feat(circuit): add params-aware ZkMerkle helpers --- docs/adr/circuit-annotation/README.md | 23 ++- .../cardano-gadget-support-matrix.md | 26 +-- .../circuit-annotation/implementation-plan.md | 2 +- .../zk-merkle-poseidon-params-helpers.md | 169 ++++++++++++++++++ .../zk-poseidon-n-symbolic-adapter.md | 3 +- docs/circuit-annotation-user-guide.md | 19 +- zeroj-circuit-annotation-api/README.md | 7 +- zeroj-circuit-lib/README.md | 12 +- .../zeroj/circuit/lib/zk/ZkMerkle.java | 59 ++++++ .../circuit/lib/zk/ZkGadgetAdaptersTest.java | 167 +++++++++++++++++ zeroj-examples/README.md | 1 + .../AnnotatedBlsPoseidonMerkleMembership.java | 36 ++++ .../AnnotatedCircuitExamplesTest.java | 55 ++++++ 13 files changed, 537 insertions(+), 42 deletions(-) create mode 100644 docs/adr/circuit-annotation/zk-merkle-poseidon-params-helpers.md create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedBlsPoseidonMerkleMembership.java diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index 932daf6..bc96027 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -63,8 +63,10 @@ var plonk = circuit.compilePlonK(CurveId.BLS12_381); Parameterized circuits should keep the current Java-as-template advantage: ```java -var merkle16 = MerkleMembershipCircuit.build(16, HashType.POSEIDON); -var merkle32 = MerkleMembershipCircuit.build(32, HashType.POSEIDON); +// BN254/off-chain enum path. Cardano Merkle circuits use explicit +// PoseidonParams through ZkMerkle.*Poseidon helpers. +var merkle16 = MerkleMembershipCircuit.build(16, HashType.MIMC); +var merkle32 = MerkleMembershipCircuit.build(32, HashType.MIMC); ``` This feature should not replace `CircuitSpec` or the current DSL. It should @@ -91,6 +93,7 @@ explicit BLS12-381 parameters: ```java ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right); ZkPoseidonN.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, owner, assetId, nonce); +ZkMerkle.isMemberPoseidon(zk, PoseidonParamsBLS12_381T3.INSTANCE, leaf, root, siblings, pathBits); ``` The current gadget, curve, symbolic-adapter, and Cardano support matrix is @@ -583,7 +586,8 @@ V1 supported parameter types: Generated companion classes should expose parameterized build methods: ```java -var circuit = MerkleMembershipCircuit.build(32, HashType.POSEIDON); +// BN254/off-chain enum path. +var circuit = MerkleMembershipCircuit.build(32, HashType.MIMC); ``` Each unique parameter set represents a distinct circuit and normally requires a @@ -923,13 +927,13 @@ ZkField hash = ZkPoseidon.hash( PoseidonParamsBLS12_381T3.INSTANCE, left, right); -ZkMerkle.verify( +ZkMerkle.verifyPoseidon( zk, + PoseidonParamsBLS12_381T3.INSTANCE, leaf, root, siblings, - pathBits, - (ctx, l, r) -> ZkPoseidon.hash(ctx, PoseidonParamsBLS12_381T3.INSTANCE, l, r)); + pathBits); ``` Proposed package for these adapters: @@ -1063,9 +1067,10 @@ For parameterized circuits, generated helpers take the same build-time parameters and produce schema/input builders for that concrete circuit shape: ```java -var circuit = MerkleMembershipCircuit.build(32, HashType.POSEIDON); -var schema = MerkleMembershipCircuit.schema(32, HashType.POSEIDON); -var inputs = MerkleMembershipCircuit.inputs(32, HashType.POSEIDON); +// BN254/off-chain enum path. +var circuit = MerkleMembershipCircuit.build(32, HashType.MIMC); +var schema = MerkleMembershipCircuit.schema(32, HashType.MIMC); +var inputs = MerkleMembershipCircuit.inputs(32, HashType.MIMC); ``` ## Input Naming and Ordering diff --git a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md index 444182f..ef48a6b 100644 --- a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -108,11 +108,11 @@ Relevant source: | `MiMC` / `SignalMiMC` | MiMC-7 two-input hash | BN254 only in current circuit lib | Direct through `ZkMiMC` | No for Cardano on-chain, because the circuit requires BN254 | Use Poseidon for Cardano. Consider a separate BLS12-381 MiMC variant only if there is a concrete interop need. | | `MiMCSponge` | Variable-length MiMC sponge | BN254 only, because it calls `MiMC` | No direct `ZkMiMCSponge` | No for Cardano on-chain | Add symbolic wrapper only for BN254/off-chain legacy use. | | `Poseidon` | Two-input Poseidon T3 hash | BN254 default; BLS12-381 supported with explicit params | Direct through `ZkPoseidon` for two inputs | Yes if `PoseidonParamsBLS12_381T3.INSTANCE` is used | Make BLS12-381 params prominent in Cardano examples. | -| `PoseidonN` | Variable-arity folded Poseidon | BN254 default; BLS12-381 supported with explicit params | Direct through `ZkPoseidonN` with explicit params | Yes if `PoseidonParamsBLS12_381T3.INSTANCE` is used | None. Params-aware `ZkMerkle` remains next. | -| `Merkle` / `SignalMerkle` | Fixed-depth Merkle membership | Hash-dependent | Direct through `ZkMerkle` | Yes when the hash is BLS12-381 compatible | Add params-aware symbolic API for BLS12-381 Poseidon. | +| `PoseidonN` | Variable-arity folded Poseidon | BN254 default; BLS12-381 supported with explicit params | Direct through `ZkPoseidonN` with explicit params | Yes if `PoseidonParamsBLS12_381T3.INSTANCE` is used | None. | +| `Merkle` / `SignalMerkle` | Fixed-depth Merkle membership | Hash-dependent | Direct through `ZkMerkle`; params-aware Poseidon helpers are available | Yes when `ZkMerkle.*Poseidon(..., PoseidonParamsBLS12_381T3.INSTANCE, ...)` is used | None. | | `ZkMerkle.HashType.MIMC` | Merkle with MiMC | BN254 only | Direct | No for Cardano on-chain | Mark as BN254/off-chain in docs. | -| `ZkMerkle.HashType.POSEIDON` | Merkle with default Poseidon | BN254 by default today | Direct | No if using default enum path | Add `POSEIDON_BLS12_381_T3` or a params-aware factory. | -| `ZkMerkle` with custom hash lambda | Merkle with caller-provided hash | Depends on lambda | Direct | Yes with BLS12-381 Poseidon lambda | Keep as escape hatch; document example. | +| `ZkMerkle.HashType.POSEIDON` | Merkle with default Poseidon | BN254 by default today | Direct | No if using default enum path | Use params-aware `ZkMerkle.*Poseidon(...)` for Cardano. | +| `ZkMerkle` with custom hash lambda | Merkle with caller-provided hash | Depends on lambda | Direct | Yes with a BLS12-381-compatible lambda | Keep as advanced escape hatch. | | `JubjubPoint` | Off-circuit Jubjub point arithmetic | Jubjub over BLS12-381 scalar field | Used by symbolic wrappers | Yes for BLS12-381 circuits | None. | | `InCircuitJubjub` | In-circuit Jubjub arithmetic | Requires BLS12-381 scalar field | Direct through `ZkJubjubPoint` | Yes | Document trusted point binding and subgroup-check contract. | | `ZkJubjubPoint` | Symbolic Jubjub point | Requires BLS12-381 scalar field | Direct | Yes | Add in-circuit curve/subgroup checks only if untrusted public points must be accepted directly. | @@ -243,11 +243,14 @@ Exit criteria: ### Phase C: Params-Aware `ZkMerkle` +Status: completed. The helper design is tracked in +[`zk-merkle-poseidon-params-helpers.md`](zk-merkle-poseidon-params-helpers.md). + Goal: make BLS12-381 Merkle circuits ergonomic and hard to misconfigure. Tasks: -- Add a params-aware helper such as: +- Added params-aware helpers: ```java ZkMerkle.verifyPoseidon( @@ -259,16 +262,15 @@ ZkMerkle.verifyPoseidon( pathBits); ``` -- Or add an explicit enum value such as `POSEIDON_BLS12_381_T3`. -- Keep the custom hash lambda path for advanced users. -- Add tests that reject mismatched compile fields through `requireField`. -- Update `AnnotatedMerkleMembership` examples to show the BLS12-381 path. +- Kept the custom hash lambda path for advanced users. +- Added tests that reject mismatched compile fields through `requireField`. +- Added `AnnotatedBlsPoseidonMerkleMembership` for the BLS12-381 path. Exit criteria: - A Cardano Merkle membership circuit can be written without a custom lambda. -- `ZkMerkle.HashType.POSEIDON` ambiguity is documented or removed from - Cardano-facing examples. +- `ZkMerkle.HashType.POSEIDON` ambiguity is documented in Cardano-facing + examples. ### Phase D: Cardano Groth16 Verifier Generation or Generalization @@ -339,7 +341,7 @@ Exit criteria: 1. Documentation and defaults. Completed. 2. `ZkPoseidonN`. Completed. -3. Params-aware BLS12-381 `ZkMerkle`. +3. Params-aware BLS12-381 `ZkMerkle`. Completed. 4. Generic/generated Cardano Groth16 verifier for arbitrary public-input count. 5. Example migration to BLS12-381 Poseidon. 6. Optional BLS12-381 MiMC only if a real integration requires it. diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index 9475813..afbf0a4 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -67,7 +67,7 @@ recommended follow-up order is: |----------|------|--------| | 1 | Documentation and defaults | Completed | | 2 | `ZkPoseidonN` symbolic adapter | Completed | -| 3 | Params-aware BLS12-381 `ZkMerkle` helpers | Pending | +| 3 | Params-aware BLS12-381 `ZkMerkle` helpers | Completed | | 4 | Generic or generated Cardano Groth16 verifier for arbitrary public-input count | Pending | | 5 | Example migration to BLS12-381 Poseidon where examples are Cardano-facing | Pending | | 6 | Optional BLS12-381 MiMC decision | Pending | diff --git a/docs/adr/circuit-annotation/zk-merkle-poseidon-params-helpers.md b/docs/adr/circuit-annotation/zk-merkle-poseidon-params-helpers.md new file mode 100644 index 0000000..32d5af2 --- /dev/null +++ b/docs/adr/circuit-annotation/zk-merkle-poseidon-params-helpers.md @@ -0,0 +1,169 @@ +# ADR: Params-Aware ZkMerkle Poseidon Helpers + +## Status + +Implemented. + +## Date + +2026-05-18 + +## Context + +Annotated Cardano-oriented Merkle circuits previously worked through +`ZkMerkle`'s custom hash lambda: + +```java +ZkMerkle.isMember( + zk, + leaf, + root, + siblings, + pathBits, + (ctx, left, right) -> ZkPoseidon.hash( + ctx, + PoseidonParamsBLS12_381T3.INSTANCE, + left, + right)); +``` + +This is functionally correct, but it is too much ceremony for a default +Cardano path. Developers should not need to drop into a custom lambda for the +common "Merkle path with BLS12-381 Poseidon" case. + +The existing `ZkMerkle.HashType.POSEIDON` enum is not enough because it uses the +no-params `ZkPoseidon` path, which is BN254-oriented for backward +compatibility. Adding a named enum such as `POSEIDON_BLS12_381_T3` would solve +one preset, but it would not scale cleanly if ZeroJ adds more Poseidon +parameter sets. + +## Decision + +ZeroJ now provides params-aware symbolic Merkle helpers: + +```java +ZkField root = ZkMerkle.computeRootPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + leaf, + siblings, + pathBits); + +ZkBool ok = ZkMerkle.isMemberPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + leaf, + root, + siblings, + pathBits); + +ZkMerkle.verifyPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + leaf, + root, + siblings, + pathBits); +``` + +These helpers: + +- live on the existing `ZkMerkle` class +- accept explicit `PoseidonParams` +- delegate to the existing custom-hash implementation +- call `ZkPoseidon.hash(zk, params, left, right)` for each Merkle level +- inherit Poseidon field guards from the underlying `Poseidon` gadget +- record the Poseidon params field before path processing, so zero-depth paths + still reject mismatched compile or witness curves +- preserve the existing path-bit convention: + - `0`: current node is the left child, sibling is right + - `1`: sibling is left, current node is right + +Do not change the existing `HashType.POSEIDON` behavior in this slice. It +remains a BN254/off-chain convenience for backward compatibility. Cardano-facing +docs and examples should use the params-aware helper. + +## Scope + +In scope: + +- `computeRootPoseidon(...)` +- `isMemberPoseidon(...)` +- `verifyPoseidon(...)` +- `verifyProofPoseidon(...)` alias for consistency with existing naming +- differential tests against the existing custom lambda path +- BLS12-381 witness and compile tests +- BN254 compile rejection when BLS12-381 params are used +- an annotated BLS12-381 Poseidon Merkle membership example +- docs/support-matrix updates + +Out of scope: + +- changing `HashType.POSEIDON` +- deprecating `HashType.POSEIDON` +- true arity-specific Poseidon Merkle hash variants +- generic/generated Cardano Groth16 verifier work + +## API Shape + +```java +public static ZkField computeRootPoseidon( + ZkContext zk, + PoseidonParams params, + ZkField leaf, + ZkArray siblings, + ZkArray pathBits); + +public static void verifyPoseidon( + ZkContext zk, + PoseidonParams params, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits); + +public static void verifyProofPoseidon( + ZkContext zk, + PoseidonParams params, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits); + +public static ZkBool isMemberPoseidon( + ZkContext zk, + PoseidonParams params, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits); +``` + +The params argument is placed immediately after `ZkContext`, matching +`ZkPoseidon.hash(zk, params, left, right)` and `ZkPoseidonN.hash(zk, params, +inputs...)`. + +## Testing + +Implemented coverage: + +- compare the params-aware helper path to the existing custom lambda path +- verify BLS12-381 witnesses against `PoseidonHash.hash(...)` +- reject an invalid root +- reject compiling BLS12-381 params under `CurveId.BN254` +- reject null params +- exercise a generated annotated circuit using the helper + +## Consequences + +Positive: + +- Cardano-ready annotated Merkle circuits no longer need a custom lambda +- the API remains explicit about Poseidon params and target field +- existing `ZkMerkle` behavior remains backward compatible + +Negative: + +- `HashType.POSEIDON` remains ambiguous for new users until docs consistently + steer Cardano circuits to the params-aware helper +- additional Poseidon parameter presets still need explicit caller selection diff --git a/docs/adr/circuit-annotation/zk-poseidon-n-symbolic-adapter.md b/docs/adr/circuit-annotation/zk-poseidon-n-symbolic-adapter.md index 24fcb7f..78f952c 100644 --- a/docs/adr/circuit-annotation/zk-poseidon-n-symbolic-adapter.md +++ b/docs/adr/circuit-annotation/zk-poseidon-n-symbolic-adapter.md @@ -70,7 +70,8 @@ Out of scope: - true width-N Poseidon parameter sets - changing the existing `PoseidonN` folding semantics - changing the BN254 default on existing non-symbolic APIs -- params-aware `ZkMerkle`; that remains the next follow-up +- params-aware `ZkMerkle`; tracked separately in + `zk-merkle-poseidon-params-helpers.md` ## API Shape diff --git a/docs/circuit-annotation-user-guide.md b/docs/circuit-annotation-user-guide.md index 1671e1c..6cf9687 100644 --- a/docs/circuit-annotation-user-guide.md +++ b/docs/circuit-annotation-user-guide.md @@ -68,6 +68,7 @@ parameters: ```java ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right); ZkPoseidonN.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, owner, assetId, nonce); +ZkMerkle.isMemberPoseidon(zk, PoseidonParamsBLS12_381T3.INSTANCE, leaf, root, siblings, pathBits); ``` Do not use no-params Poseidon or MiMC as Cardano defaults. The no-params @@ -132,17 +133,13 @@ public class MerkleMembership { @Public ZkField root, @Secret @FixedSize(param = "depth") ZkArray siblings, @Secret @FixedSize(param = "depth") ZkArray pathBits) { - return ZkMerkle.isMember( + return ZkMerkle.isMemberPoseidon( zk, + PoseidonParamsBLS12_381T3.INSTANCE, leaf, root, siblings, - pathBits, - (ctx, left, right) -> ZkPoseidon.hash( - ctx, - PoseidonParamsBLS12_381T3.INSTANCE, - left, - right)); + pathBits); } } ``` @@ -162,8 +159,8 @@ parameter sets. `ZkMerkle.HashType.MIMC` and `ZkMerkle.HashType.POSEIDON` remain useful for BN254/off-chain compatibility. Today they are not the Cardano default path: `MIMC` is BN254-only, and `POSEIDON` uses the no-params Poseidon overload. Use -the custom hash lambda shown above until a params-aware `ZkMerkle` convenience -API is added. +`ZkMerkle.computeRootPoseidon`, `isMemberPoseidon`, or `verifyPoseidon` with +explicit BLS12-381 Poseidon params. ## Testing Pattern @@ -287,7 +284,7 @@ bind them as secret `ZkUInt` inputs, using 252 bits for `kModL` and 4 bits for Treat MiMC-based annotated circuits as BN254/off-chain unless a separate BLS12-381 MiMC variant is added. - `ZkMerkle.HashType.MIMC` and the no-params `HashType.POSEIDON` path are not - Cardano defaults today. Use a custom BLS12-381 Poseidon hash lambda for - Cardano Merkle circuits. + Cardano defaults today. Use `ZkMerkle.*Poseidon(...)` with explicit + BLS12-381 Poseidon params for Cardano Merkle circuits. - `ZkBits` and `ZkBytes` store one constrained field element per bit or byte; packed byte encodings are deferred. diff --git a/zeroj-circuit-annotation-api/README.md b/zeroj-circuit-annotation-api/README.md index 15b841a..3421535 100644 --- a/zeroj-circuit-annotation-api/README.md +++ b/zeroj-circuit-annotation-api/README.md @@ -58,6 +58,7 @@ For hashes in Cardano circuits, use explicit BLS12-381 Poseidon parameters: ```java ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right); ZkPoseidonN.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, owner, assetId, nonce); +ZkMerkle.isMemberPoseidon(zk, PoseidonParamsBLS12_381T3.INSTANCE, leaf, root, siblings, pathBits); ``` Do not rely on no-params Poseidon or MiMC for Cardano defaults. The no-params @@ -95,9 +96,9 @@ Known limitations: `ZkPoseidon` with explicit BLS12-381 parameters for Cardano/BLS12-381 circuits. - `ZkMerkle.HashType.MIMC` and the no-params `HashType.POSEIDON` convenience - path are BN254-oriented today. For Cardano Merkle circuits, use a custom hash - lambda that calls `ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, - left, right)` until a params-aware `ZkMerkle` helper is added. + path are BN254-oriented today. For Cardano Merkle circuits, use + `ZkMerkle.computeRootPoseidon`, `isMemberPoseidon`, or `verifyPoseidon` with + explicit BLS12-381 Poseidon params. - Elliptic-curve composite symbolic types are available for the shipped Jubjub use cases (`ZkJubjubPoint`, Pedersen, EdDSA-Jubjub). Add a curve-specific symbolic wrapper before using another curve family. diff --git a/zeroj-circuit-lib/README.md b/zeroj-circuit-lib/README.md index a777825..55ac829 100644 --- a/zeroj-circuit-lib/README.md +++ b/zeroj-circuit-lib/README.md @@ -64,12 +64,12 @@ var commitment = ZkPoseidonN.hash( owner, assetId, nonce); -var root = ZkMerkle.computeRoot( +var root = ZkMerkle.computeRootPoseidon( zk, + PoseidonParamsBLS12_381T3.INSTANCE, leaf, siblings, - pathBits, - (ctx, l, r) -> ZkPoseidon.hash(ctx, PoseidonParamsBLS12_381T3.INSTANCE, l, r)); + pathBits); var pedersen = ZkPedersen.commit(zk, value, blinding, 64); ``` @@ -80,8 +80,10 @@ BLS12-381. `ZkPoseidonN` requires explicit Poseidon params and is the symbolic path for folded multi-input commitments. The no-params Poseidon helpers are BN254-oriented for backward compatibility, and `ZkMerkle.HashType.MIMC` / no-params `HashType.POSEIDON` -should be treated as BN254/off-chain conveniences until params-aware Merkle -helpers are added. Jubjub, Pedersen, and EdDSA-Jubjub adapters are BLS12-381-only and +should be treated as BN254/off-chain conveniences. Use +`ZkMerkle.computeRootPoseidon`, `isMemberPoseidon`, or `verifyPoseidon` with +explicit BLS12-381 Poseidon params for Cardano Merkle circuits. Jubjub, +Pedersen, and EdDSA-Jubjub adapters are BLS12-381-only and inherit the curve/subgroup-check contracts documented on the underlying in-circuit gadgets. Use `ZkJubjubPoint.fromTrustedAffine(...)` only for points validated off-circuit for curve membership, subgroup membership, and non-identity diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMerkle.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMerkle.java index 9bd69f6..859ac1b 100644 --- a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMerkle.java +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMerkle.java @@ -6,6 +6,7 @@ import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; import com.bloxbean.cardano.zeroj.circuit.Signal; import com.bloxbean.cardano.zeroj.circuit.lib.SignalMerkle; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; import java.util.Objects; @@ -60,6 +61,21 @@ public static ZkField computeRoot( return ZkField.wrap(zk, root); } + public static ZkField computeRootPoseidon( + ZkContext zk, + PoseidonParams params, + ZkField leaf, + ZkArray siblings, + ZkArray pathBits) { + requirePoseidonParams(zk, params); + return computeRoot( + zk, + leaf, + siblings, + pathBits, + (context, left, right) -> ZkPoseidon.hash(context, params, left, right)); + } + public static void verify( ZkContext zk, ZkField leaf, @@ -81,6 +97,27 @@ public static void verifyProof( verify(zk, leaf, root, siblings, pathBits, hashType); } + public static void verifyPoseidon( + ZkContext zk, + PoseidonParams params, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits) { + requireRoot(zk, root); + computeRootPoseidon(zk, params, leaf, siblings, pathBits).assertEqual(root); + } + + public static void verifyProofPoseidon( + ZkContext zk, + PoseidonParams params, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits) { + verifyPoseidon(zk, params, leaf, root, siblings, pathBits); + } + public static void verify( ZkContext zk, ZkField leaf, @@ -113,6 +150,17 @@ public static ZkBool isMember( return computeRoot(zk, leaf, siblings, pathBits, hashType).isEqual(root); } + public static ZkBool isMemberPoseidon( + ZkContext zk, + PoseidonParams params, + ZkField leaf, + ZkField root, + ZkArray siblings, + ZkArray pathBits) { + requireRoot(zk, root); + return computeRootPoseidon(zk, params, leaf, siblings, pathBits).isEqual(root); + } + public static ZkBool isMember( ZkContext zk, ZkField leaf, @@ -149,6 +197,17 @@ private static void validateInputs( } } + private static void requirePoseidonParams(ZkContext zk, PoseidonParams params) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(params, "params"); + if (params.t() != 3 || params.alpha() != 5) { + throw new IllegalArgumentException( + "Poseidon gadget supports only t=3, alpha=5 (got t=" + params.t() + + ", alpha=" + params.alpha() + ")"); + } + zk.builder().api().requireField(params.field()); + } + private static void requireRoot(ZkContext zk, ZkField root) { Objects.requireNonNull(zk, "zk"); Objects.requireNonNull(root, "root"); diff --git a/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java b/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java index ded69aa..4599112 100644 --- a/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java +++ b/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java @@ -349,6 +349,158 @@ void merkleVerifySupportsCustomHashFunction() { assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(invalidRoot, CurveId.BN254)); } + @Test + void merklePoseidonParamsHelperMatchesCustomHashLambda() { + var helper = CircuitBuilder.create("zk-merkle-poseidon-helper") + .publicVar("root") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("sibling_1") + .secretVar("pathBit_0") + .secretVar("pathBit_1") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMerkle.verifyPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + ZkField.secret(c, "leaf"), + ZkField.publicInput(c, "root"), + ZkArray.secretFields(c, "sibling", 2), + ZkArray.secretBools(c, "pathBit", 2)); + }); + + var customLambda = CircuitBuilder.create("zk-merkle-poseidon-lambda") + .publicVar("root") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("sibling_1") + .secretVar("pathBit_0") + .secretVar("pathBit_1") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMerkle.verify( + zk, + ZkField.secret(c, "leaf"), + ZkField.publicInput(c, "root"), + ZkArray.secretFields(c, "sibling", 2), + ZkArray.secretBools(c, "pathBit", 2), + (context, left, right) -> ZkPoseidon.hash( + context, + PoseidonParamsBLS12_381T3.INSTANCE, + left, + right)); + }); + + var leaf = BigInteger.valueOf(10); + var sibling0 = BigInteger.valueOf(20); + var sibling1 = BigInteger.valueOf(30); + var pathBit0 = BigInteger.ZERO; + var pathBit1 = BigInteger.ONE; + var root = expectedPoseidonMerkleRoot( + PoseidonParamsBLS12_381T3.INSTANCE, + leaf, + List.of(sibling0, sibling1), + List.of(pathBit0, pathBit1)); + var valid = Map.of( + "root", List.of(root), + "leaf", List.of(leaf), + "sibling_0", List.of(sibling0), + "sibling_1", List.of(sibling1), + "pathBit_0", List.of(pathBit0), + "pathBit_1", List.of(pathBit1)); + + assertEquals(customLambda.constraintGraph().gates().size(), helper.constraintGraph().gates().size()); + assertArrayEquals( + customLambda.calculateWitness(valid, CurveId.BLS12_381), + helper.calculateWitness(valid, CurveId.BLS12_381)); + assertDoesNotThrow(() -> helper.compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> helper.compileR1CS(CurveId.BN254)); + assertThrows(IllegalStateException.class, () -> helper.calculateWitness(valid, CurveId.BN254)); + + var invalid = Map.of( + "root", List.of(BigInteger.ONE), + "leaf", List.of(leaf), + "sibling_0", List.of(sibling0), + "sibling_1", List.of(sibling1), + "pathBit_0", List.of(pathBit0), + "pathBit_1", List.of(pathBit1)); + assertThrows(ArithmeticException.class, () -> helper.calculateWitness(invalid, CurveId.BLS12_381)); + } + + @Test + void merklePoseidonParamsHelperRecordsFieldGuardForEmptyPath() { + var circuit = CircuitBuilder.create("zk-merkle-poseidon-empty") + .publicVar("root") + .secretVar("leaf") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMerkle.verifyPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + ZkField.secret(c, "leaf"), + ZkField.publicInput(c, "root"), + ZkArray.secretFields(c, "sibling", 0), + ZkArray.secretBools(c, "pathBit", 0)); + }); + + var valid = Map.of( + "root", List.of(BigInteger.valueOf(10)), + "leaf", List.of(BigInteger.valueOf(10))); + assertDoesNotThrow(() -> circuit.calculateWitness(valid, CurveId.BLS12_381)); + assertDoesNotThrow(() -> circuit.compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); + assertThrows(IllegalStateException.class, () -> circuit.calculateWitness(valid, CurveId.BN254)); + } + + @Test + void merklePoseidonParamsHelpersRejectNullParams() { + assertThrows(NullPointerException.class, () -> CircuitBuilder.create("zk-merkle-poseidon-null") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("pathBit_0") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMerkle.computeRootPoseidon( + zk, + null, + ZkField.secret(c, "leaf"), + ZkArray.secretFields(c, "sibling", 1), + ZkArray.secretBools(c, "pathBit", 1)); + })); + + assertThrows(NullPointerException.class, () -> CircuitBuilder.create("zk-merkle-poseidon-verify-null") + .publicVar("root") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("pathBit_0") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMerkle.verifyPoseidon( + zk, + null, + ZkField.secret(c, "leaf"), + ZkField.publicInput(c, "root"), + ZkArray.secretFields(c, "sibling", 1), + ZkArray.secretBools(c, "pathBit", 1)); + })); + + assertThrows(NullPointerException.class, () -> CircuitBuilder.create("zk-merkle-poseidon-member-null") + .publicVar("root") + .secretVar("leaf") + .secretVar("sibling_0") + .secretVar("pathBit_0") + .defineSignals(c -> { + var zk = new ZkContext(c); + ZkMerkle.isMemberPoseidon( + zk, + null, + ZkField.secret(c, "leaf"), + ZkField.publicInput(c, "root"), + ZkArray.secretFields(c, "sibling", 1), + ZkArray.secretBools(c, "pathBit", 1)); + })); + } + @Test void hashTypePoseidonAndIsMemberWorkTogether() { BigInteger expectedRoot = PoseidonHash.hash( @@ -733,6 +885,21 @@ private BigInteger hashOrderedByPathBit(BigInteger current, BigInteger sibling, return signalMiMCOffCircuit(sibling, current); } + private BigInteger expectedPoseidonMerkleRoot( + com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams params, + BigInteger leaf, + List siblings, + List pathBits) { + BigInteger current = leaf; + for (int i = 0; i < siblings.size(); i++) { + BigInteger sibling = siblings.get(i); + current = BigInteger.ZERO.equals(pathBits.get(i)) + ? PoseidonHash.hash(params, current, sibling) + : PoseidonHash.hash(params, sibling, current); + } + return current; + } + private BigInteger signalMiMCOffCircuit(BigInteger left, BigInteger right) { var circuit = CircuitBuilder.create("zk-mimc-oracle") .publicVar("hash") diff --git a/zeroj-examples/README.md b/zeroj-examples/README.md index b5c01b6..6d670a9 100644 --- a/zeroj-examples/README.md +++ b/zeroj-examples/README.md @@ -38,6 +38,7 @@ Write circuits as annotated Java classes and use generated companions for `build(...)`, `schema(...)`, and witness input builders. - **Examples**: range proof, age verification, private transfer, MiMC commitment, [BLS12-381 PoseidonN multi-input commitment](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMultiInputCommitment.java), + [BLS12-381 Poseidon Merkle membership](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedBlsPoseidonMerkleMembership.java), sealed-bid auction, anonymous voting, parameterized Merkle membership, Pedersen commitment, proof-flow helper - **Source**: [`examples/annotation`](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation) diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedBlsPoseidonMerkleMembership.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedBlsPoseidonMerkleMembership.java new file mode 100644 index 0000000..1e2f849 --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedBlsPoseidonMerkleMembership.java @@ -0,0 +1,36 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.CircuitParam; +import com.bloxbean.cardano.zeroj.circuit.annotation.FixedSize; +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMerkle; + +@ZKCircuit(name = "annotation-merkle-bls-poseidon", nameTemplate = "annotation-merkle-bls-poseidon-d{depth}") +public class AnnotatedBlsPoseidonMerkleMembership { + public AnnotatedBlsPoseidonMerkleMembership(@CircuitParam("depth") int depth) { + } + + @Prove + ZkBool prove( + ZkContext zk, + @Secret ZkField leaf, + @Public ZkField root, + @Secret @FixedSize(param = "depth") ZkArray siblings, + @Secret @FixedSize(param = "depth") ZkArray pathBits) { + return ZkMerkle.isMemberPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + leaf, + root, + siblings, + pathBits); + } +} diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java index f815b6b..070f0e9 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java @@ -348,6 +348,47 @@ void parameterizedMerkleMembershipUsesDepthAndHashType() { () -> circuit.calculateWitness(invalid.toWitnessMap(), CurveId.BN254)); } + @Test + void blsPoseidonMerkleMembershipUsesParamsAwareHelper() { + int depth = 2; + var circuit = AnnotatedBlsPoseidonMerkleMembershipCircuit.build(depth); + var schema = AnnotatedBlsPoseidonMerkleMembershipCircuit.schema(depth); + + assertEquals("annotation-merkle-bls-poseidon-d2--depth-1:2", schema.name()); + assertEquals("1:2", schema.parameters().get(0).value()); + assertEquals(List.of("root"), schema.publicInputs().names()); + assertEquals(List.of("leaf", "sibling_0", "sibling_1", "pathBit_0", "pathBit_1"), + schema.secretInputs().names()); + + var leaf = BigInteger.valueOf(10); + var sibling0 = BigInteger.valueOf(20); + var sibling1 = BigInteger.valueOf(30); + var pathBit0 = BigInteger.ZERO; + var pathBit1 = BigInteger.ONE; + var root = poseidonMerkleRoot(leaf, List.of(sibling0, sibling1), List.of(pathBit0, pathBit1)); + + var inputs = AnnotatedBlsPoseidonMerkleMembershipCircuit.inputs(depth) + .leaf(leaf) + .root(root) + .siblings(List.of(sibling0, sibling1)) + .pathBits(List.of(pathBit0, pathBit1)); + + assertEquals(List.of(root), inputs.publicValues()); + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381)); + assertDoesNotThrow(() -> circuit.compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); + assertThrows(IllegalStateException.class, + () -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); + + var invalid = AnnotatedBlsPoseidonMerkleMembershipCircuit.inputs(depth) + .leaf(leaf) + .root(BigInteger.ONE) + .siblings(List.of(sibling0, sibling1)) + .pathBits(List.of(pathBit0, pathBit1)); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(invalid.toWitnessMap(), CurveId.BLS12_381)); + } + @Test void pedersenCommitmentUsesAdvancedSymbolicAdapter() { var circuit = AnnotatedPedersenCommitmentCircuit.build(); @@ -386,4 +427,18 @@ private BigInteger merkleRoot( } return current; } + + private BigInteger poseidonMerkleRoot( + BigInteger leaf, + List siblings, + List pathBits) { + BigInteger current = leaf; + for (int i = 0; i < siblings.size(); i++) { + BigInteger sibling = siblings.get(i); + current = BigInteger.ZERO.equals(pathBits.get(i)) + ? PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, current, sibling) + : PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, sibling, current); + } + return current; + } } From 8a571d68c6cf48d9f5344fe49e58beedf78e005a Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 19:32:29 +0800 Subject: [PATCH 15/26] docs(circuit): track nested ZkArray follow-up --- .../cardano-gadget-support-matrix.md | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md index ef48a6b..a2b6e3a 100644 --- a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -98,7 +98,7 @@ Relevant source: | `ZkField` | Raw field element | Generic | Direct | Yes on BLS12-381 Groth16 | None. | | `ZkBool` | Boolean-constrained field bit | Generic | Direct | Yes on BLS12-381 Groth16 | None. | | `ZkUInt` | Unsigned integer with bit width and range constraints | Generic, width-limited | Direct | Yes on BLS12-381 Groth16 | Document max width and comparison limits. Current `MAX_BITS` is 253 and comparisons require compare width `< 253`. | -| `ZkArray` | Fixed-size symbolic arrays | Generic | Direct | Yes on BLS12-381 Groth16 | Add nested arrays only when a real circuit needs them. | +| `ZkArray` | Fixed-size symbolic arrays | Generic | Direct for one-dimensional arrays | Yes on BLS12-381 Groth16 | Track nested arrays as a lower-priority follow-up for matrix/grouped inputs. | | `ZkBits` | Fixed-size bit vector | Generic | Direct for binding/equality | Yes on BLS12-381 Groth16 | Add ergonomic bitwise operations if bit-heavy circuits appear. | | `ZkBytes` | Fixed-size byte vector | Generic | Direct for binding/equality | Yes on BLS12-381 Groth16 | Add packing/unpacking helpers when byte-oriented circuits appear. | | `Comparators` / `SignalComparators` | `<`, `<=`, `>`, `>=`, range, min, max | Generic | Mostly direct through `ZkUInt` | Yes on BLS12-381 Groth16 | Optional symbolic helpers for `min` and `max`. | @@ -313,7 +313,40 @@ Exit criteria: - Example names and README text make it obvious whether an example is Cardano-on-chain-ready or BN254/off-chain. -### Phase F: Optional BLS12-381 MiMC Decision +### Phase F: Nested `ZkArray>` + +Goal: support matrix-like and grouped fixed-size symbolic inputs without manual +flattening. + +Rationale: + +- Current annotated circuits support one-dimensional `ZkArray`. +- Complex circuits can work around the gap by flattening inputs, but that + pushes offset math and naming conventions into user code. +- Nested arrays are not Cardano-specific, but they improve ergonomics for + circuits with grouped attributes, batched Merkle openings, matrices, or + multi-row compliance proofs. + +Tasks: + +- Extend annotation validation so nested `ZkArray>` declarations + require explicit outer and inner fixed sizes. +- Define stable schema flattening such as `matrix_0_0`, `matrix_0_1`, + `matrix_1_0`, and `matrix_1_1`. +- Generate input builder methods that accept rectangular nested lists. +- Reject ragged nested input values at input-builder time. +- Preserve public-input order and witness-map order across generated schema, + input builders, and proof-envelope public values. +- Add tests for public and secret nested arrays of `ZkField`, `ZkBool`, and + `ZkUInt`. + +Exit criteria: + +- A two-dimensional annotated symbolic input can be declared, compiled, and + tested without manually flattening it in user code. +- Generated schema names and input builders are deterministic and documented. + +### Phase G: Optional BLS12-381 MiMC Decision Goal: decide whether ZeroJ needs a BLS12-381 MiMC variant. @@ -344,7 +377,8 @@ Exit criteria: 3. Params-aware BLS12-381 `ZkMerkle`. Completed. 4. Generic/generated Cardano Groth16 verifier for arbitrary public-input count. 5. Example migration to BLS12-381 Poseidon. -6. Optional BLS12-381 MiMC only if a real integration requires it. +6. Nested `ZkArray>` support. +7. Optional BLS12-381 MiMC only if a real integration requires it. ## Testing Strategy From de0196b2b91a8841dd11fbed2a2a22e940f8e0f7 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 19:55:48 +0800 Subject: [PATCH 16/26] feat(onchain): add arbitrary-input Groth16 verifier --- docs/adr/circuit-annotation/README.md | 7 +- .../cardano-gadget-support-matrix.md | 56 ++-- ...cardano-groth16-arbitrary-public-inputs.md | 287 ++++++++++++++++++ .../circuit-annotation/implementation-plan.md | 2 +- .../dsl/PureJavaProverYaciE2ETest.java | 55 ++-- .../julc/Groth16BLS12381GenericVerifier.java | 102 +++++++ .../zeroj/onchain/julc/ProverToCardano.java | 3 +- .../Groth16BLS12381GenericVerifierTest.java | 216 +++++++++++++ 8 files changed, 683 insertions(+), 45 deletions(-) create mode 100644 docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md create mode 100644 zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifier.java create mode 100644 zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifierTest.java diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index bc96027..289a0de 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -82,9 +82,14 @@ BLS12-381 Groth16: -> generated *Circuit companion -> compileR1CS(CurveId.BLS12_381) -> Groth16 proof - -> Julc / Plutus V3 BLS12-381 verifier + -> Groth16BLS12381GenericVerifier on Julc / Plutus V3 ``` +`Groth16BLS12381GenericVerifier` accepts the full verification-key `IC` vector +as a list parameter, so Cardano-facing circuits are not limited to two public +inputs. Public inputs must still be serialized in the exact order returned by +the generated circuit schema. + Hash gadgets must be selected with their circuit field in mind. MiMC is BN254-only in the current circuit library, and no-params Poseidon is retained for BN254 compatibility. Cardano-facing circuits should use Poseidon with diff --git a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md index a2b6e3a..833cccb 100644 --- a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -2,8 +2,8 @@ ## Status -Accepted follow-up plan. Priority 1, documentation and defaults, is completed -in the current docs. +Accepted follow-up plan. Priorities 1 through 4 are completed in the current +code and docs. ## Date @@ -122,7 +122,7 @@ Relevant source: | `zeroj-blst` | Native BLS12-381 provider | BLS12-381 | Not a circuit gadget | Off-chain helper | No annotation work. | | `zeroj-bls12381-wasm` | WASM BLS12-381 provider | BLS12-381 | Not a circuit gadget | Off-chain helper | No annotation work. | | `zeroj-bbs` / `zeroj-bbs-wasm` | CFRG BBS signatures and presentations | BLS12-381 | Not an annotated circuit gadget | No current ZeroJ on-chain verifier | Keep separate from annotated circuits for now. | -| Groth16 pure Java provers | Proof generation | BN254 and BLS12-381 | Consumes generated circuits through R1CS/witness APIs | BLS12-381 proofs can target Cardano | Improve generic on-chain verifier generation for arbitrary public-input counts. | +| Groth16 pure Java provers | Proof generation | BN254 and BLS12-381 | Consumes generated circuits through R1CS/witness APIs | BLS12-381 proofs can target Cardano through the fixed two-input verifier or the arbitrary-count generic verifier | None for public-input count; consider generated fixed-count validators only for budget-critical circuits. | | PlonK pure Java provers | Proof generation | BN254 and BLS12-381 | Consumes generated circuits through existing compile APIs | BLS12-381 on-chain is experimental | Do not make PlonK the Cardano default until on-chain verifier is complete. | | Halo2 incubator verifier | Halo2 IPA verification | Pallas | Not a symbolic circuit target for Cardano | No | Keep as incubator/off-chain. | @@ -130,8 +130,12 @@ Relevant source: ### Generic Groth16 On-Chain Verifier Public-Input Count -The current generic Julc `Groth16BLS12381Verifier` is specialized to two -public inputs. It has `vkIc0`, `vkIc1`, and `vkIc2` parameters and computes: +Status: completed. The design and implementation notes are tracked in +[`cardano-groth16-arbitrary-public-inputs.md`](cardano-groth16-arbitrary-public-inputs.md). + +The original reusable Julc `Groth16BLS12381Verifier` is specialized to two +public inputs. It remains available for backward compatibility. It has `vkIc0`, +`vkIc1`, and `vkIc2` parameters and computes: ```text vk_x = IC[0] + pub[0] * IC[1] + pub[1] * IC[2] @@ -139,15 +143,19 @@ vk_x = IC[0] + pub[0] * IC[1] + pub[1] * IC[2] That is sufficient for small examples, but it is not sufficient as the default Cardano path for arbitrary annotated circuits. Annotated circuits can have any -stable public-input schema. The on-chain verifier needs to either: +stable public-input schema. -- be generated per circuit with exactly the required `IC` points and public - input handling, or -- use a generalized list-based MSM/fold over public inputs and `IC` points if - Julc/Plutus ergonomics and budget allow it. +`Groth16BLS12381GenericVerifier` now provides the general path. It accepts the +full `IC` vector as one `PlutusData` list parameter and folds it against the +datum public-input list: -Until this is solved, some BLS12-381 annotated circuits are off-chain-verifiable -but require a custom on-chain validator. +```text +vk_x = IC[0] + pub[0] * IC[1] + ... + pub[n - 1] * IC[n] +``` + +The verifier rejects empty `IC` lists and any mismatch where +`len(IC) != len(publicInputs) + 1`. Generated fixed-count validators remain a +possible future optimization if budget-critical circuits need lower script cost. ### MiMC Is BN254-Only in the Circuit Library @@ -274,23 +282,25 @@ Exit criteria: ### Phase D: Cardano Groth16 Verifier Generation or Generalization +Status: completed. The implementation is tracked in +[`cardano-groth16-arbitrary-public-inputs.md`](cardano-groth16-arbitrary-public-inputs.md). + Goal: make arbitrary annotated BLS12-381 Groth16 circuits usable on-chain. Tasks: -- Design one of the following: - - a generated Julc verifier per circuit/public-input count - - a generalized Julc verifier that folds public inputs and `IC` points from - list parameters -- Preserve stable public-input order from generated circuit schema. -- Add budget estimates based on public-input count. -- Add tests for 0, 1, 2, and more-than-2 public inputs. -- Add an annotated circuit on-chain example with more than two public inputs. +- Added `Groth16BLS12381GenericVerifier`. +- Preserved the fixed two-input verifier for compatibility. +- Preserved stable public-input order by consuming datum values positionally. +- Added Julc VM budget output for two-input and three-input proofs. +- Added tests for two inputs, more-than-two inputs, wrong values, too few + values, too many values, and empty `IC` lists. +- Updated the pure Java Yaci DevKit e2e to use a three-public-input circuit and + the generic verifier. Exit criteria: -- Annotated circuits are not limited by the current 2-public-input generic - verifier. +- Annotated circuits are not limited by the current 2-public-input verifier. - The generated schema, proof envelope, and on-chain validator agree on public input ordering. @@ -375,7 +385,7 @@ Exit criteria: 1. Documentation and defaults. Completed. 2. `ZkPoseidonN`. Completed. 3. Params-aware BLS12-381 `ZkMerkle`. Completed. -4. Generic/generated Cardano Groth16 verifier for arbitrary public-input count. +4. Generic/generated Cardano Groth16 verifier for arbitrary public-input count. Completed. 5. Example migration to BLS12-381 Poseidon. 6. Nested `ZkArray>` support. 7. Optional BLS12-381 MiMC only if a real integration requires it. diff --git a/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md b/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md new file mode 100644 index 0000000..7642fac --- /dev/null +++ b/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md @@ -0,0 +1,287 @@ +# ADR: Generic Cardano Groth16 Verifier for Arbitrary Public Inputs + +## Status + +Implemented. + +## Date + +2026-05-18 + +## Context + +The current reusable Julc verifier, +`Groth16BLS12381Verifier`, is specialized to exactly two public inputs. It +accepts `vkIc0`, `vkIc1`, and `vkIc2` as script parameters and computes: + +```text +vk_x = IC[0] + public[0] * IC[1] + public[1] * IC[2] +``` + +That was enough for early examples such as private multiplication and sealed +bid. It is not enough for annotation-based circuits because generated circuit +schemas can have any stable public-input count. + +The verifier path must support circuits generated by: + +- hand-written Circuit DSL and `CircuitSpec` +- symbolic annotated circuits +- generated proof-envelope metadata and stable public-input ordering + +The user-facing requirement is simple: if a circuit compiles to Groth16 over +`CurveId.BLS12_381`, the Cardano verifier should not require a custom Julc +class just because the public-input count is not two. + +## JuLC Constraints + +The JuLC AI starter pack recommends typed values for normal application code +and reserves raw `PlutusData` for genuinely opaque or generic payloads. This +verifier has two generic payloads: + +- the datum is the public-input vector +- the verification-key `IC` points are a variable-length vector + +The same guidance also confirms that `while` loops are available as accumulator +loops when returns happen after the loop. During implementation, the BLS value +accumulator was clearer and more reliable as self-recursive helpers, which JuLC +also supports. The shipped verifier therefore uses recursion for length +checking and `vk_x` computation instead of a mutable `while` accumulator. + +## Decision + +Add a new reusable Julc spending validator: + +```java +Groth16BLS12381GenericVerifier +``` + +It will keep the same proof redeemer shape as the current fixed verifier: + +```java +record Groth16Proof(byte[] piA, byte[] piB, byte[] piC) {} +``` + +It will use these verification-key parameters: + +```java +@Param static byte[] vkAlpha; +@Param static byte[] vkBeta; +@Param static byte[] vkGamma; +@Param static byte[] vkDelta; +@Param static PlutusData vkIc; +``` + +`vkIc` is a Plutus list of compressed G1 points: + +```text +[IC[0], IC[1], ..., IC[n]] +``` + +The datum remains a Plutus list of public input integers: + +```text +[public[0], public[1], ..., public[n - 1]] +``` + +The validator computes: + +```text +vk_x = IC[0] +for i in 0..n-1: + vk_x = vk_x + public[i] * IC[i + 1] +``` + +The shipped implementation performs this with two self-recursive helpers: + +- `matchingLengths(...)` verifies that `len(vkIc) == len(publicInputs) + 1` +- `computeVkX(...)` folds the public inputs and IC tail into `vk_x` + +Then it runs the standard Groth16 pairing check: + +```text +e(A, B) * e(-alpha, beta) == e(vk_x, gamma) * e(C, delta) +``` + +The existing `Groth16BLS12381Verifier` remains in place for source and script +parameter compatibility. Existing deployed or test code with exactly two public +inputs does not need to change. + +## Length Contract + +The generic verifier must reject malformed datum or verification-key parameters +instead of silently ignoring extra values. + +Accepted: + +```text +len(vkIc) == len(publicInputs) + 1 +``` + +Rejected: + +```text +len(vkIc) == 0 +len(vkIc) < len(publicInputs) + 1 +len(vkIc) > len(publicInputs) + 1 +``` + +Zero-public-input circuits are supported when `vkIc` contains exactly one +point, `IC[0]`. + +## Public-Input Ordering + +The verifier does not infer names. It consumes public inputs positionally. +Correctness depends on the off-chain caller passing datum values in exactly the +same order as the circuit's generated schema and verification key. + +For annotated circuits, the intended flow is: + +```text +GeneratedCircuit.schema() + -> GeneratedCircuit.publicInputValues(...) + -> datum list in the same order + -> Groth16BLS12381GenericVerifier +``` + +For DSL or `CircuitSpec` circuits, callers should use witness indices +`witness[1]..witness[numPublicInputs]` or an equivalent schema-aware helper. + +## Why Not Generate a Verifier Per Count First? + +Generated fixed-count validators are still useful when budget is critical: + +- no runtime list traversal +- no runtime length check +- fewer dynamic `IC` reads +- easier static budget estimation per circuit + +However, generated validators create more moving parts: + +- one generated on-chain source per circuit/count +- generated source ownership and packaging decisions +- more compile artifacts in examples and applications +- a harder first integration with annotation-generated circuits + +The list-based verifier is the better first implementation because it removes +the two-public-input limitation with one stable API. If budget measurements show +that generic traversal is too expensive for a class of circuits, generated +fixed-count validators can be added later as an optimization using the same +public-input ordering contract. + +## API Shape + +Off-chain callers should construct `vkIc` as a list of compressed G1 point +bytes: + +```java +PlutusData vkIcData = PlutusData.list( + PlutusData.bytes(vk.ic().get(0)), + PlutusData.bytes(vk.ic().get(1)), + PlutusData.bytes(vk.ic().get(2)), + ...); +``` + +For Cardano client-lib code: + +```java +var vkIcData = ListPlutusData.of( + new BytesPlutusData(vk.ic().get(0)), + new BytesPlutusData(vk.ic().get(1)), + new BytesPlutusData(vk.ic().get(2))); +``` + +The script is loaded with five parameters: + +```java +JulcScriptLoader.load( + Groth16BLS12381GenericVerifier.class, + new BytesPlutusData(vk.alpha()), + new BytesPlutusData(vk.beta()), + new BytesPlutusData(vk.gamma()), + new BytesPlutusData(vk.delta()), + vkIcData); +``` + +## Testing Plan + +Julc VM tests: + +- verify an existing two-public-input fixture with the generic verifier +- prove and verify a pure Java BLS12-381 circuit with more than two public + inputs +- reject wrong public input values +- reject a datum with too few public inputs +- reject a datum with too many public inputs +- reject a verification key with an empty `IC` list + +Yaci DevKit e2e: + +- adapt the pure Java prover e2e path to load the generic verifier +- run a lock/unlock transaction with a generated Groth16 BLS12-381 proof +- keep the existing fixed verifier examples working + +Budget tracking: + +- print Julc VM budget for two-input and three-input generic verification +- use the numbers to decide whether a generated fixed-count verifier is needed + later + +## Implementation Steps + +1. Add `Groth16BLS12381GenericVerifier`. +2. Add helper methods in tests to encode `vk.ic()` as a Plutus list. +3. Add a three-public-input pure Java BLS12-381 circuit test. +4. Update Yaci e2e coverage to exercise the generic verifier. +5. Update the Cardano gadget support matrix and implementation plan. +6. Run module tests and the Yaci e2e test when the local DevKit is available. + +## Implementation Notes + +Status: implemented. + +The final verifier keeps `Groth16BLS12381Verifier` intact and adds +`Groth16BLS12381GenericVerifier`. The generic verifier accepts five script +parameters: alpha, beta, gamma, delta, and the full `IC` list. + +Julc VM tests cover: + +- existing two-public-input snarkjs fixture compatibility +- three-public-input pure Java BLS12-381 proof verification +- wrong public input rejection +- too few public inputs +- too many public inputs +- empty `IC` list rejection + +Measured Julc VM budgets in the implementation tests: + +- two public inputs: `cpu=2142040133`, `mem=71535` +- three public inputs: `cpu=2275351144`, `mem=84444` + +The Yaci DevKit e2e test was updated to use a three-public-input pure Java +circuit and the generic verifier. + +## Consequences + +Positive: + +- annotated circuits are no longer blocked by the old two-public-input verifier +- existing fixed verifier users remain compatible +- public-input ordering stays explicit and schema-driven +- one reusable on-chain verifier covers DSL, `CircuitSpec`, and symbolic + annotation-generated circuits + +Negative: + +- runtime list traversal adds cost proportional to public-input count +- malformed `vkIc` parameters fail at validation time, not script-load time +- generated fixed-count validators may still be needed for budget-sensitive + production circuits + +## Follow-Up + +- Add a schema-aware helper that converts generated annotation circuit public + values into the exact datum format expected by this verifier. +- Consider generated fixed-count validators once budget data from real Cardano + circuits is available. +- Add zero-public-input valid-proof coverage if such a circuit becomes common in + examples. diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index afbf0a4..774bb54 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -68,6 +68,6 @@ recommended follow-up order is: | 1 | Documentation and defaults | Completed | | 2 | `ZkPoseidonN` symbolic adapter | Completed | | 3 | Params-aware BLS12-381 `ZkMerkle` helpers | Completed | -| 4 | Generic or generated Cardano Groth16 verifier for arbitrary public-input count | Pending | +| 4 | Generic or generated Cardano Groth16 verifier for arbitrary public-input count | Completed | | 5 | Example migration to BLS12-381 Poseidon where examples are Cardano-facing | Pending | | 6 | Optional BLS12-381 MiMC decision | Pending | diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java index 4980562..ec6883f 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java @@ -11,13 +11,13 @@ import com.bloxbean.cardano.client.quicktx.Tx; import com.bloxbean.cardano.julc.clientlib.JulcScriptLoader; import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; import com.bloxbean.cardano.zeroj.crypto.groth16.Groth16ProverBLS381; -import com.bloxbean.cardano.zeroj.examples.dsl.multiplier.PrivateMultiplierCircuit; import com.bloxbean.cardano.zeroj.crypto.setup.Groth16SetupBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.PowersOfTauBLS381; import com.bloxbean.cardano.zeroj.onchain.julc.ProverToCardano; import com.bloxbean.cardano.zeroj.examples.dsl.common.YaciHelper; -import com.bloxbean.cardano.zeroj.onchain.julc.Groth16BLS12381Verifier; +import com.bloxbean.cardano.zeroj.onchain.julc.Groth16BLS12381GenericVerifier; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -39,7 +39,7 @@ *

  • Generate dev Powers of Tau + Groth16 setup (pure Java)
  • *
  • Compute witness and prove (pure Java BLS12-381 prover)
  • *
  • Compress proof + VK to BLS bytes
  • - *
  • Load generic {@link Groth16BLS12381Verifier} Plutus V3 script with VK params
  • + *
  • Load generic {@link Groth16BLS12381GenericVerifier} Plutus V3 script with VK params
  • *
  • Lock tADA at script address with public inputs as datum
  • *
  • Unlock with ZK proof as redeemer — verified on-chain by Cardano node
  • * @@ -83,8 +83,9 @@ static void setup() throws Exception { /** * Pure Java circuit → prove → Yaci DevKit on-chain verify using generic Groth16 verifier. * - *

    Circuit: {@link PrivateMultiplierCircuit} — a * b = c (a, c public; b private). - * Uses the generic {@link Groth16BLS12381Verifier} (2 public inputs, 3 IC points).

    + *

    Circuit: a * x + b = c, where a, b, and c are public and x is private. + * Uses the generic {@link Groth16BLS12381GenericVerifier} with 3 public + * inputs and 4 IC points.

    */ @Test void pureJavaProve_groth16_onChainVerify() throws Exception { @@ -92,19 +93,27 @@ void pureJavaProve_groth16_onChainVerify() throws Exception { System.out.println("=== Pure Java BLS12-381 Prove → Yaci DevKit On-Chain Verify ==="); - // 1. Define circuit via CircuitSpec class - var circuit = PrivateMultiplierCircuit.build(); + // 1. Define a three-public-input circuit via Java DSL + var circuit = CircuitBuilder.create("three-public-linear-yaci") + .publicVar("a") + .publicVar("b") + .publicVar("c") + .secretVar("x") + .define(api -> api.assertEqual( + api.add(api.mul(api.var("a"), api.var("x")), api.var("b")), + api.var("c"))); var r1cs = circuit.compileR1CS(CurveId.BLS12_381); - assertEquals(2, r1cs.numPublicInputs(), "Need exactly 2 public inputs for generic verifier"); + assertEquals(3, r1cs.numPublicInputs(), "Need three public inputs for arbitrary-count verifier e2e"); var constraints = r1cs.constraints(); - // 2. Witness: a=3, b=11, c=33 + // 2. Witness: a=3, b=4, c=25, x=7 BigInteger[] witness = circuit.calculateWitness(Map.of( "a", List.of(BigInteger.valueOf(3)), - "b", List.of(BigInteger.valueOf(11)), - "c", List.of(BigInteger.valueOf(33))), CurveId.BLS12_381); + "b", List.of(BigInteger.valueOf(4)), + "c", List.of(BigInteger.valueOf(25)), + "x", List.of(BigInteger.valueOf(7))), CurveId.BLS12_381); System.out.println("Circuit compiled: " + r1cs.numConstraints() + " constraints"); @@ -124,27 +133,27 @@ void pureJavaProve_groth16_onChainVerify() throws Exception { // 5. Compress VK + proof for on-chain var compressedVk = ProverToCardano.compressVk(setupResult); var compressedProof = ProverToCardano.compressProof(proof); - assertEquals(3, compressedVk.ic().size(), "IC must have numPublic+1 entries"); + assertEquals(4, compressedVk.ic().size(), "IC must have numPublic+1 entries"); // 6. Load the generic Groth16 BLS12-381 verifier with VK params - var script = JulcScriptLoader.load(Groth16BLS12381Verifier.class, + var script = JulcScriptLoader.load(Groth16BLS12381GenericVerifier.class, new BytesPlutusData(compressedVk.alpha()), new BytesPlutusData(compressedVk.beta()), new BytesPlutusData(compressedVk.gamma()), new BytesPlutusData(compressedVk.delta()), - new BytesPlutusData(compressedVk.ic().get(0)), - new BytesPlutusData(compressedVk.ic().get(1)), - new BytesPlutusData(compressedVk.ic().get(2))); + vkIcData(compressedVk.ic())); var scriptAddr = AddressProvider.getEntAddress(script, Networks.testnet()).toBech32(); System.out.println("Verifier script address: " + scriptAddr); // 7. Lock tADA at script address with public inputs as datum BigInteger pub0 = witness[1]; // a = 3 - BigInteger pub1 = witness[2]; // c = 33 + BigInteger pub1 = witness[2]; // b = 4 + BigInteger pub2 = witness[3]; // c = 25 var datum = ListPlutusData.of( BigIntPlutusData.of(pub0), - BigIntPlutusData.of(pub1)); + BigIntPlutusData.of(pub1), + BigIntPlutusData.of(pub2)); var quickTx = new QuickTxBuilder(backend); var lockTx = new Tx() @@ -194,8 +203,16 @@ void pureJavaProve_groth16_onChainVerify() throws Exception { System.out.println(); System.out.println("Pipeline: Java DSL circuit"); System.out.println(" → pure Java BLS12-381 Groth16 prover"); - System.out.println(" → generic Groth16BLS12381Verifier (Plutus V3)"); + System.out.println(" → generic Groth16BLS12381GenericVerifier (Plutus V3)"); System.out.println(" → Yaci DevKit (Cardano local devnet)"); System.out.println("Zero external tools. 100% Java 25."); } + + private static ListPlutusData vkIcData(List ic) { + var list = ListPlutusData.of(); + for (byte[] point : ic) { + list.add(new BytesPlutusData(point)); + } + return list; + } } diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifier.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifier.java new file mode 100644 index 0000000..0b334d5 --- /dev/null +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifier.java @@ -0,0 +1,102 @@ +package com.bloxbean.cardano.zeroj.onchain.julc; + +import com.bloxbean.cardano.julc.core.PlutusData; +import com.bloxbean.cardano.julc.stdlib.Builtins; +import com.bloxbean.cardano.julc.stdlib.annotation.Entrypoint; +import com.bloxbean.cardano.julc.stdlib.annotation.Param; +import com.bloxbean.cardano.julc.stdlib.annotation.SpendingValidator; +import com.bloxbean.cardano.julc.stdlib.lib.BlsLib; + +import java.math.BigInteger; + +/** + * Generic on-chain Groth16 BLS12-381 verifier as a Plutus V3 spending validator. + *

    + * Verification key points are baked in at deploy time. Unlike + * {@link Groth16BLS12381Verifier}, this verifier accepts the full IC vector as + * a Plutus list parameter, so it supports any public-input count. + *

    + * Datum: list of public input integers in verification-key schema order. + * Redeemer: compressed Groth16 proof points. + * Parameter {@code vkIc}: list of compressed G1 IC points, where + * {@code len(vkIc) == len(publicInputs) + 1}. + */ +@SpendingValidator +public class Groth16BLS12381GenericVerifier { + + @Param static byte[] vkAlpha; // G1 compressed 48 bytes + @Param static byte[] vkBeta; // G2 compressed 96 bytes + @Param static byte[] vkGamma; // G2 compressed 96 bytes + @Param static byte[] vkDelta; // G2 compressed 96 bytes + @Param static PlutusData vkIc; // List of G1 compressed 48-byte IC points + + /** + * Groth16 proof points (compressed BLS bytes), passed as redeemer. + */ + record Groth16Proof(byte[] piA, byte[] piB, byte[] piC) {} + + @Entrypoint + public static boolean validate(PlutusData datum, Groth16Proof proof, PlutusData ctx) { + PlutusData inputsCursor = Builtins.unListData(datum); + PlutusData icCursor = Builtins.unListData(vkIc); + + if (Builtins.nullList(icCursor)) { + return false; + } + + byte[] vkX = BlsLib.g1Uncompress(Builtins.unBData(Builtins.headList(icCursor))); + return verifyWithPublicInputs(inputsCursor, Builtins.tailList(icCursor), vkX, proof); + } + + private static boolean verifyWithPublicInputs(PlutusData inputsCursor, + PlutusData icCursor, + byte[] vkX, + Groth16Proof proof) { + if (!matchingLengths(inputsCursor, icCursor)) { + return false; + } + + byte[] computedVkX = computeVkX(inputsCursor, icCursor, vkX); + + byte[] a = BlsLib.g1Uncompress(proof.piA()); + byte[] b = BlsLib.g2Uncompress(proof.piB()); + byte[] c = BlsLib.g1Uncompress(proof.piC()); + + byte[] alpha = BlsLib.g1Uncompress(vkAlpha); + byte[] beta = BlsLib.g2Uncompress(vkBeta); + byte[] gamma = BlsLib.g2Uncompress(vkGamma); + byte[] delta = BlsLib.g2Uncompress(vkDelta); + + byte[] negAlpha = BlsLib.g1Neg(alpha); + byte[] lhs = BlsLib.mulMlResult( + BlsLib.millerLoop(a, b), + BlsLib.millerLoop(negAlpha, beta)); + byte[] rhs = BlsLib.mulMlResult( + BlsLib.millerLoop(computedVkX, gamma), + BlsLib.millerLoop(c, delta)); + + return BlsLib.finalVerify(lhs, rhs); + } + + private static boolean matchingLengths(PlutusData inputsCursor, PlutusData icCursor) { + if (Builtins.nullList(inputsCursor)) { + return Builtins.nullList(icCursor); + } else if (Builtins.nullList(icCursor)) { + return false; + } else { + return matchingLengths(Builtins.tailList(inputsCursor), Builtins.tailList(icCursor)); + } + } + + private static byte[] computeVkX(PlutusData inputsCursor, PlutusData icCursor, byte[] vkX) { + if (Builtins.nullList(inputsCursor)) { + return vkX; + } else { + BigInteger publicInput = Builtins.asInteger(Builtins.headList(inputsCursor)); + byte[] ic = BlsLib.g1Uncompress(Builtins.unBData(Builtins.headList(icCursor))); + byte[] scaled = BlsLib.g1ScalarMul(publicInput, ic); + byte[] nextVkX = BlsLib.g1Add(vkX, scaled); + return computeVkX(Builtins.tailList(inputsCursor), Builtins.tailList(icCursor), nextVkX); + } + } +} diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ProverToCardano.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ProverToCardano.java index 38326ce..ae3c1f0 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ProverToCardano.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ProverToCardano.java @@ -21,7 +21,8 @@ * *

    For production: Use this to convert proofs generated by the pure Java * prover ({@code Groth16ProverBLS381}) into the byte format expected by the - * on-chain verifier ({@link Groth16BLS12381Verifier}).

    + * on-chain verifiers ({@link Groth16BLS12381Verifier} and + * {@link Groth16BLS12381GenericVerifier}).

    */ public final class ProverToCardano { diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifierTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifierTest.java new file mode 100644 index 0000000..0aae033 --- /dev/null +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifierTest.java @@ -0,0 +1,216 @@ +package com.bloxbean.cardano.zeroj.onchain.julc; + +import com.bloxbean.cardano.julc.core.PlutusData; +import com.bloxbean.cardano.julc.testkit.ContractTest; +import com.bloxbean.cardano.julc.testkit.TestDataBuilder; +import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; +import com.bloxbean.cardano.zeroj.crypto.groth16.Groth16ProverBLS381; +import com.bloxbean.cardano.zeroj.crypto.setup.Groth16SetupBLS381; +import com.bloxbean.cardano.zeroj.crypto.setup.PowersOfTauBLS381; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the arbitrary-public-input Groth16 BLS12-381 verifier in the Julc VM. + */ +class Groth16BLS12381GenericVerifierTest extends ContractTest { + + private static SnarkjsToCardano.VkCompressed sealedBidVk; + private static SnarkjsToCardano.ProofCompressed sealedBidProof; + private static List sealedBidPublicInputs; + private static TestProof threePublicInputs; + + @BeforeAll + static void setup() throws Exception { + String proofJson = loadResource("/test-circuits/sealed-bid-bls12381/proof.json"); + String vkJson = loadResource("/test-circuits/sealed-bid-bls12381/verification_key.json"); + String publicJson = loadResource("/test-circuits/sealed-bid-bls12381/public.json"); + + sealedBidVk = SnarkjsToCardano.parseVk(vkJson); + sealedBidProof = SnarkjsToCardano.parseProof(proofJson); + sealedBidPublicInputs = SnarkjsToCardano.parsePublicInputs(publicJson); + threePublicInputs = buildThreePublicInputProof(); + } + + @Test + void sealedBid_twoPublicInputs_passes() { + assertEquals(3, sealedBidVk.ic().size(), "IC must have public input count + 1 entries"); + + assertGenericVerification( + sealedBidVk, + sealedBidProof, + datum(sealedBidPublicInputs.get(0), sealedBidPublicInputs.get(1)), + vkIcData(sealedBidVk.ic()), + true); + } + + @Test + void threePublicInputs_pureJavaProof_passes() { + assertEquals(4, threePublicInputs.vk().ic().size(), "IC must have public input count + 1 entries"); + + assertGenericVerification( + threePublicInputs.vk(), + threePublicInputs.proof(), + datum(threePublicInputs.publicInputs()), + vkIcData(threePublicInputs.vk().ic()), + true); + } + + @Test + void threePublicInputs_wrongPublicInput_fails() { + BigInteger[] publicInputs = threePublicInputs.publicInputs().clone(); + publicInputs[2] = publicInputs[2].add(BigInteger.ONE); + + assertGenericVerification( + threePublicInputs.vk(), + threePublicInputs.proof(), + datum(publicInputs), + vkIcData(threePublicInputs.vk().ic()), + false); + } + + @Test + void threePublicInputs_tooFewPublicInputs_fails() { + assertGenericVerification( + threePublicInputs.vk(), + threePublicInputs.proof(), + datum(threePublicInputs.publicInputs()[0], threePublicInputs.publicInputs()[1]), + vkIcData(threePublicInputs.vk().ic()), + false); + } + + @Test + void threePublicInputs_tooManyPublicInputs_fails() { + assertGenericVerification( + threePublicInputs.vk(), + threePublicInputs.proof(), + datum( + threePublicInputs.publicInputs()[0], + threePublicInputs.publicInputs()[1], + threePublicInputs.publicInputs()[2], + BigInteger.ONE), + vkIcData(threePublicInputs.vk().ic()), + false); + } + + @Test + void emptyIcList_fails() { + assertGenericVerification( + threePublicInputs.vk(), + threePublicInputs.proof(), + datum(threePublicInputs.publicInputs()), + PlutusData.list(), + false); + } + + private void assertGenericVerification(SnarkjsToCardano.VkCompressed vk, + SnarkjsToCardano.ProofCompressed proof, + PlutusData datum, + PlutusData vkIc, + boolean expectSuccess) { + var compiled = compileValidator(Groth16BLS12381GenericVerifier.class); + var program = compiled.program().applyParams( + PlutusData.bytes(vk.alpha()), + PlutusData.bytes(vk.beta()), + PlutusData.bytes(vk.gamma()), + PlutusData.bytes(vk.delta()), + vkIc); + + var redeemer = PlutusData.constr(0, + PlutusData.bytes(proof.piA()), + PlutusData.bytes(proof.piB()), + PlutusData.bytes(proof.piC())); + + var txOutRef = TestDataBuilder.randomTxOutRef_typed(); + var ctx = spendingContext(txOutRef, datum) + .redeemer(redeemer) + .buildPlutusData(); + + var result = evaluate(program, ctx); + if (expectSuccess) { + assertSuccess(result); + System.out.println("[Groth16BLS12381GenericVerifier] Budget consumed: " + result.budgetConsumed()); + } else { + assertFailure(result); + } + } + + private static TestProof buildThreePublicInputProof() { + var circuit = CircuitBuilder.create("three-public-linear") + .publicVar("a") + .publicVar("b") + .publicVar("c") + .secretVar("x") + .define(api -> api.assertEqual( + api.add(api.mul(api.var("a"), api.var("x")), api.var("b")), + api.var("c"))); + + var r1cs = circuit.compileR1CS(CurveId.BLS12_381); + assertEquals(3, r1cs.numPublicInputs(), "test circuit should have three public inputs"); + + BigInteger[] witness = circuit.calculateWitness(Map.of( + "a", List.of(BigInteger.valueOf(3)), + "b", List.of(BigInteger.valueOf(4)), + "c", List.of(BigInteger.valueOf(25)), + "x", List.of(BigInteger.valueOf(7))), CurveId.BLS12_381); + + var srs = PowersOfTauBLS381.generate(8); + var setupResult = Groth16SetupBLS381.setup( + r1cs.constraints(), + r1cs.numWires(), + r1cs.numPublicInputs(), + srs.tauScalar()); + var proof = Groth16ProverBLS381.prove( + setupResult.provingKey(), + witness, + r1cs.constraints(), + r1cs.numWires()); + + assertTrue(proof.a().isOnCurve()); + assertTrue(proof.b().isOnCurve()); + assertTrue(proof.c().isOnCurve()); + + return new TestProof( + ProverToCardano.compressVk(setupResult), + ProverToCardano.compressProof(proof), + new BigInteger[] { witness[1], witness[2], witness[3] }); + } + + private static PlutusData datum(BigInteger... inputs) { + PlutusData[] values = new PlutusData[inputs.length]; + for (int i = 0; i < inputs.length; i++) { + values[i] = PlutusData.integer(inputs[i]); + } + return PlutusData.list(values); + } + + private static PlutusData vkIcData(List ic) { + List values = new ArrayList<>(); + for (byte[] point : ic) { + values.add(PlutusData.bytes(point)); + } + return PlutusData.list(values.toArray(new PlutusData[0])); + } + + private static String loadResource(String path) throws IOException { + try (var is = Groth16BLS12381GenericVerifierTest.class.getResourceAsStream(path)) { + if (is == null) throw new IOException("Resource not found: " + path); + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private record TestProof(SnarkjsToCardano.VkCompressed vk, + SnarkjsToCardano.ProofCompressed proof, + BigInteger[] publicInputs) {} +} From bb250237a4228bb09772f6ae50141f51135d8eab Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 20:06:02 +0800 Subject: [PATCH 17/26] chore(onchain): make generic Groth16 verifier default --- docs/adr/0009-halo2-support-strategy.md | 2 +- docs/adr/0016-jubjub-in-circuit.md | 4 ++-- .../cardano-gadget-support-matrix.md | 3 ++- ...cardano-groth16-arbitrary-public-inputs.md | 13 ++++++----- docs/architecture-overview.md | 3 ++- docs/circuit-dsl-user-guide.md | 2 +- docs/getting-started.md | 15 ++++++++----- docs/pure-java-prover-guide.md | 11 ++++++---- docs/usecases/identity-and-credentials.md | 2 +- docs/usecases/private-nft-ownership.md | 2 +- zeroj-examples/README.md | 6 ++--- .../dsl/auction/SealedBidPureJavaE2ETest.java | 5 ++--- .../BalanceThresholdPureJavaE2ETest.java | 6 ++--- zeroj-onchain-julc/README.md | 3 ++- .../onchain/julc/Groth16BLS12381Verifier.java | 8 ++++++- .../onchain/julc/CircomToOnChainE2ETest.java | 22 ++++++++++++------- .../Groth16BLS12381PureJavaProverTest.java | 20 +++++++++++------ .../julc/Groth16BLS12381VerifierTest.java | 4 +++- 18 files changed, 80 insertions(+), 51 deletions(-) diff --git a/docs/adr/0009-halo2-support-strategy.md b/docs/adr/0009-halo2-support-strategy.md index 3eac65f..58e1d23 100644 --- a/docs/adr/0009-halo2-support-strategy.md +++ b/docs/adr/0009-halo2-support-strategy.md @@ -52,7 +52,7 @@ With Plutus V3 BLS12-381 builtins, Halo2 KZG verification is feasible: **ZeroJ deliverables:** 1. Halo2 KZG proof codec (parse Halo2 proof artifacts into `ZkProofEnvelope`) -2. On-chain verifier in Julc (like existing `Groth16BLS12381Verifier.java`) +2. On-chain verifier in Julc (like existing `Groth16BLS12381GenericVerifier.java`) 3. E2E test on Cardano preprod ### Phase 3: Recursive proof aggregation (Long-term) diff --git a/docs/adr/0016-jubjub-in-circuit.md b/docs/adr/0016-jubjub-in-circuit.md index 7082a6a..c0c8c9a 100644 --- a/docs/adr/0016-jubjub-in-circuit.md +++ b/docs/adr/0016-jubjub-in-circuit.md @@ -45,7 +45,7 @@ one constraint per bit of the scalar — a few hundred constraints total. That's the whole point of an "embedded curve". The upshot: **Jubjub is Cardano-native**, BabyJubJub is not. Existing -Plutus V3 Groth16 verifiers (`zeroj-onchain-julc/Groth16BLS12381Verifier`, +Plutus V3 Groth16 verifiers (`zeroj-onchain-julc/Groth16BLS12381GenericVerifier`, `PlonkBLS12381FullVerifier`) accept Jubjub proofs without modification — all the complexity lives inside the SNARK. @@ -201,7 +201,7 @@ end-to-end verification on yaci-devkit as a merge gate. proof-of-reserves, sealed-bid auctions, privacy-preserving loyalty. - Schnorr / EdDSA wallet-signature-in-circuit enables provable Cardano- wallet-ownership gated features. -- **No onchain changes**: existing `Groth16BLS12381Verifier` and +- **No onchain changes**: existing `Groth16BLS12381GenericVerifier` and `PlonkBLS12381FullVerifier` accept Jubjub-using proofs as-is. ### Harder diff --git a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md index 833cccb..149792b 100644 --- a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -61,7 +61,8 @@ Annotated symbolic circuit Relevant source: - `zeroj-onchain-julc/.../OnChainFeasibility.java` -- `zeroj-onchain-julc/.../Groth16BLS12381Verifier.java` +- `zeroj-onchain-julc/.../Groth16BLS12381GenericVerifier.java` +- `zeroj-onchain-julc/.../Groth16BLS12381Verifier.java` (deprecated fixed two-input compatibility) - `zeroj-onchain-julc/.../PlonkBLS12381FullVerifier.java` - `zeroj-verifier-groth16/...` - `zeroj-verifier-plonk/...` diff --git a/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md b/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md index 7642fac..4d2f4dd 100644 --- a/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md +++ b/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md @@ -102,9 +102,9 @@ Then it runs the standard Groth16 pairing check: e(A, B) * e(-alpha, beta) == e(vk_x, gamma) * e(C, delta) ``` -The existing `Groth16BLS12381Verifier` remains in place for source and script -parameter compatibility. Existing deployed or test code with exactly two public -inputs does not need to change. +The existing `Groth16BLS12381Verifier` remains in place only as a deprecated +source and script-parameter compatibility verifier. New code, examples, docs, +and generated flows should use `Groth16BLS12381GenericVerifier`. ## Length Contract @@ -239,9 +239,10 @@ Budget tracking: Status: implemented. -The final verifier keeps `Groth16BLS12381Verifier` intact and adds -`Groth16BLS12381GenericVerifier`. The generic verifier accepts five script -parameters: alpha, beta, gamma, delta, and the full `IC` list. +The final verifier deprecates the fixed two-input `Groth16BLS12381Verifier` +and adds `Groth16BLS12381GenericVerifier` as the default. The generic verifier +accepts five script parameters: alpha, beta, gamma, delta, and the full `IC` +list. Julc VM tests cover: diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 9086942..4315465 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -132,7 +132,8 @@ Anchoring verified results on L1: ### Layer 10: On-Chain Verification (`zeroj-onchain-julc`) Reusable Plutus V3 spending validators compiled via Julc: -- `Groth16BLS12381Verifier` -- on-chain Groth16 verification using BLS12-381 builtins +- `Groth16BLS12381GenericVerifier` -- on-chain Groth16 verification using BLS12-381 builtins and arbitrary public-input counts +- `Groth16BLS12381Verifier` -- deprecated fixed two-public-input compatibility verifier - `PlonkBLS12381FullVerifier` -- experimental on-chain PlonK prototype with Fiat-Shamir transcript and inverse checks; KZG pairing check deferred - `SnarkjsToCardano` -- converts snarkjs JSON to BLS compressed bytes for on-chain use - `ScriptBudgetEstimator`, `OnChainFeasibility`, `ReferenceScriptDeployer` -- on-chain budget and deployment helpers diff --git a/docs/circuit-dsl-user-guide.md b/docs/circuit-dsl-user-guide.md index 8e72b6d..ef985e8 100644 --- a/docs/circuit-dsl-user-guide.md +++ b/docs/circuit-dsl-user-guide.md @@ -742,7 +742,7 @@ Verification (pure Java, zero native deps): PlonkBN254Verifier / PlonkBLS12381Verifier On-Chain (Cardano Plutus V3): - Groth16BLS12381Verifier (Julc) + Groth16BLS12381GenericVerifier (Julc) PlonkBLS12381FullVerifier (Julc prototype: transcript/inverse checks only) ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 49b0d85..5c8d03f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -134,15 +134,18 @@ ZeroJ includes reusable Plutus V3 validators compiled from Java via Julc. The VK var compressedVk = ProverToCardano.compressVk(setupResult); var compressedProof = ProverToCardano.compressProof(proof); +var vkIcData = ListPlutusData.of(); +for (byte[] ic : compressedVk.ic()) { + vkIcData.add(new BytesPlutusData(ic)); +} + // Compile Julc validator with VK parameters -var script = JulcScriptLoader.load(Groth16BLS12381Verifier.class, +var script = JulcScriptLoader.load(Groth16BLS12381GenericVerifier.class, new BytesPlutusData(compressedVk.alpha()), new BytesPlutusData(compressedVk.beta()), new BytesPlutusData(compressedVk.gamma()), new BytesPlutusData(compressedVk.delta()), - new BytesPlutusData(compressedVk.ic().get(0)), - new BytesPlutusData(compressedVk.ic().get(1)), - new BytesPlutusData(compressedVk.ic().get(2)) + vkIcData ); var scriptAddr = AddressProvider.getEntAddress(script, Networks.testnet()).toBech32(); @@ -199,11 +202,11 @@ var unlockResult = new QuickTxBuilder(backend) ## What Happens On-Chain -The `Groth16BLS12381Verifier` Plutus V3 script executes: +The `Groth16BLS12381GenericVerifier` Plutus V3 script executes: 1. **Extract** public inputs from datum: `[a, c]` 2. **Decompress** proof points (piA, piB, piC) from BLS12-381 compressed bytes -3. **Compute** `vk_x = ic[0] + pub[0] * ic[1] + pub[1] * ic[2]` (linear combination) +3. **Compute** `vk_x = ic[0] + pub[0] * ic[1] + ... + pub[n-1] * ic[n]` 4. **Verify** pairing equation: `e(piA, piB) == e(alpha, beta) * e(vk_x, gamma) * e(piC, delta)` 5. Return `True` if pairing check passes -- UTXO unlocked diff --git a/docs/pure-java-prover-guide.md b/docs/pure-java-prover-guide.md index e318c73..95ca2df 100644 --- a/docs/pure-java-prover-guide.md +++ b/docs/pure-java-prover-guide.md @@ -223,15 +223,18 @@ assert valid; // Cryptographic verification passed! var compressedVk = ProverToCardano.compressVk(setup); var compressedProof = ProverToCardano.compressProof(proof); +var vkIcData = ListPlutusData.of(); +for (byte[] ic : compressedVk.ic()) { + vkIcData.add(new BytesPlutusData(ic)); +} + // Load the generic Groth16 BLS12-381 verifier with VK baked in -var script = JulcScriptLoader.load(Groth16BLS12381Verifier.class, +var script = JulcScriptLoader.load(Groth16BLS12381GenericVerifier.class, new BytesPlutusData(compressedVk.alpha()), new BytesPlutusData(compressedVk.beta()), new BytesPlutusData(compressedVk.gamma()), new BytesPlutusData(compressedVk.delta()), - new BytesPlutusData(compressedVk.ic().get(0)), - new BytesPlutusData(compressedVk.ic().get(1)), - new BytesPlutusData(compressedVk.ic().get(2))); + vkIcData); var scriptAddr = AddressProvider.getEntAddress(script, Networks.testnet()); diff --git a/docs/usecases/identity-and-credentials.md b/docs/usecases/identity-and-credentials.md index 67dadc1..7088b70 100644 --- a/docs/usecases/identity-and-credentials.md +++ b/docs/usecases/identity-and-credentials.md @@ -807,4 +807,4 @@ When BBS+ circuits are available: ### Upgrade Path -All three approaches use the same on-chain verifier (`Groth16BLS12381Verifier`). The circuit changes, but the Plutus V3 script is identical. You can upgrade from Poseidon-signed to EdDSA to BBS+ **without redeploying the on-chain verifier**. +All three approaches use the same on-chain verifier (`Groth16BLS12381GenericVerifier`). The circuit changes, but the Plutus V3 script shape is identical. You can upgrade from Poseidon-signed to EdDSA to BBS+ without writing a custom on-chain verifier; redeployment is only needed when the verification key changes. diff --git a/docs/usecases/private-nft-ownership.md b/docs/usecases/private-nft-ownership.md index b6ecffb..64b041d 100644 --- a/docs/usecases/private-nft-ownership.md +++ b/docs/usecases/private-nft-ownership.md @@ -823,4 +823,4 @@ Only needed if transfer privacy is critical (anonymous art sales, private collec ### The Circuit Is the Same On-Chain -All approaches use the same `Groth16BLS12381Verifier` Plutus V3 script. Only the circuit and snapshot mechanism differ. You can start with Approach 1 and upgrade to Approach 3 or 4 later without changing the on-chain verifier. +All approaches use the same `Groth16BLS12381GenericVerifier` Plutus V3 script shape. Only the circuit, verification key, and snapshot mechanism differ. You can start with Approach 1 and upgrade to Approach 3 or 4 later without writing a custom on-chain verifier. diff --git a/zeroj-examples/README.md b/zeroj-examples/README.md index 6d670a9..d17d48c 100644 --- a/zeroj-examples/README.md +++ b/zeroj-examples/README.md @@ -109,7 +109,7 @@ reference circuit. This is the full end-to-end flow from circuit to on-chain execution: 1. **Load** pre-generated BLS12-381 proof artifacts -2. **Compile** `Groth16BLS12381Verifier` Julc script with VK parameters baked in +2. **Compile** `Groth16BLS12381GenericVerifier` Julc script with VK parameters baked in 3. **Lock** ADA at script address with public inputs (commitment, reservePrice) as datum 4. **Unlock** with ZK proof (piA, piB, piC) as redeemer 5. **Plutus V3 executes** BLS12-381 pairing verification on-chain @@ -123,7 +123,7 @@ The on-chain Plutus V3 validators live in [`zeroj-onchain-julc`](../zeroj-onchai | Validator | Proof System | Source | |-----------|-------------|--------| -| `Groth16BLS12381Verifier` | Groth16 BLS12-381 | `zeroj-onchain-julc` | +| `Groth16BLS12381GenericVerifier` | Groth16 BLS12-381 | `zeroj-onchain-julc` | | `PlonkBLS12381FullVerifier` | PlonK BLS12-381 prototype | `zeroj-onchain-julc` | The example-specific `ZkAuctionVerifier` in this module extends the pattern with auction-specific logic (reserve price check). @@ -141,7 +141,7 @@ The example-specific `ZkAuctionVerifier` in this module extends the pattern with |-------------|-------|----------------| | Groth16 | BN254 | `Groth16BN254Verifier` | | Groth16 | BLS12-381 | `Groth16BLS12381PureJavaVerifier` | -| Groth16 | BLS12-381 | `Groth16BLS12381Verifier` (blst, faster) | +| Groth16 | BLS12-381 | `com.bloxbean.cardano.zeroj.verifier.groth16.bls12381.Groth16BLS12381Verifier` (off-chain blst verifier) | | PlonK | BN254 | `PlonkBN254Verifier` | | PlonK | BLS12-381 | `PlonkBLS12381Verifier` | diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java index c29dab7..3bbf913 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java @@ -87,9 +87,8 @@ void devTau_sealedBid_fullStack() { System.out.println("Off-chain pairing: PASSED"); System.out.println("=== SealedBid E2E (dev tau): off-chain COMPLETE ==="); - // Note: On-chain Julc VM verification for SealedBid requires a 4-IC-point - // verifier (3 public inputs). The generic Groth16BLS12381Verifier supports 2. - // See BalanceThresholdPureJavaE2ETest for full on-chain verification demo. + // On-chain Julc VM verification for arbitrary public-input counts is + // covered by Groth16BLS12381GenericVerifier in zeroj-onchain-julc. } // --- Helpers --- diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/balance/BalanceThresholdPureJavaE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/balance/BalanceThresholdPureJavaE2ETest.java index 03b08da..dc7f2cd 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/balance/BalanceThresholdPureJavaE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/balance/BalanceThresholdPureJavaE2ETest.java @@ -18,8 +18,8 @@ /** * Full-stack E2E for BalanceThreshold: circuit → pure Java prove → off-chain verify → Julc VM on-chain verify. * - *

    This circuit has 2 public inputs (threshold, isAboveThreshold), matching the generic - * {@link Groth16BLS12381Verifier} (3 IC points = IC[0] + pub0*IC[1] + pub1*IC[2]).

    + *

    This circuit has 2 public inputs (threshold, isAboveThreshold), which can + * be verified on-chain by {@code Groth16BLS12381GenericVerifier}.

    * *

    DEV/TEST (this test)

    *

    Uses {@code PowersOfTauBLS381.generate(8)} — for development only.

    @@ -72,7 +72,7 @@ void devTau_balanceThreshold_fullStack() { System.out.println("=== BalanceThreshold E2E (dev tau): off-chain COMPLETE ==="); // On-chain Julc VM verification runs in the zeroj-onchain-julc module - // where compileValidator(Groth16BLS12381Verifier.class) can access Julc bytecode. + // where compileValidator(Groth16BLS12381GenericVerifier.class) can access Julc bytecode. // Use ProverToCardano.compressVk/compressProof to convert for on-chain submission. } diff --git a/zeroj-onchain-julc/README.md b/zeroj-onchain-julc/README.md index 390e643..5744bba 100644 --- a/zeroj-onchain-julc/README.md +++ b/zeroj-onchain-julc/README.md @@ -12,7 +12,8 @@ V3. | Validator / Helper | Status | Notes | |--------------------|--------|-------| -| `Groth16BLS12381Verifier` | Working | Production-oriented BLS12-381 Groth16 verifier using Plutus V3 BLS builtins | +| `Groth16BLS12381GenericVerifier` | Working | Default BLS12-381 Groth16 verifier using Plutus V3 BLS builtins; supports arbitrary public-input counts | +| `Groth16BLS12381Verifier` | Deprecated compatibility | Fixed two-public-input verifier retained for older callers | | `PlonkBLS12381FullVerifier` | Experimental prototype | Re-derives transcript and checks inverse constraints; KZG batch opening pairing check is deferred | | `SnarkjsToCardano` | Working helper | Converts snarkjs Groth16 JSON points to Cardano-compatible compressed bytes | | `ProverToCardano` | Working helper | Converts ZeroJ prover artifacts to on-chain data shapes | diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java index d821d88..3062774 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java @@ -10,7 +10,8 @@ import java.math.BigInteger; /** - * On-chain Groth16 BLS12-381 verifier as a Plutus V3 spending validator. + * Fixed two-public-input Groth16 BLS12-381 verifier as a Plutus V3 spending + * validator. *

    * Verification key points are baked in at deploy time via {@link Param}. * The proof (piA, piB, piC) is passed as the redeemer (compressed BLS bytes). @@ -23,8 +24,13 @@ * mulMlResult(millerLoop(vk_x, gamma), millerLoop(C, delta)) * ) * + * + * @deprecated Use {@link Groth16BLS12381GenericVerifier}. This class is kept + * only for compatibility with older examples that baked exactly two public + * inputs as three separate IC parameters. */ @SpendingValidator +@Deprecated public class Groth16BLS12381Verifier { // VK points — compressed bytes baked at compile time diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/CircomToOnChainE2ETest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/CircomToOnChainE2ETest.java index d2e6bf5..95a07be 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/CircomToOnChainE2ETest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/CircomToOnChainE2ETest.java @@ -15,6 +15,8 @@ import java.io.IOException; import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -77,15 +79,13 @@ void circom_javaProve_onChainVerify() { byte[] piC = g1Compress(proof.c()); // Compile on-chain verifier with circom VK - var compiled = compileValidator(Groth16BLS12381Verifier.class); + var compiled = compileValidator(Groth16BLS12381GenericVerifier.class); var program = compiled.program().applyParams( PlutusData.bytes(vk.alpha()), PlutusData.bytes(vk.beta()), PlutusData.bytes(vk.gamma()), PlutusData.bytes(vk.delta()), - PlutusData.bytes(vk.ic().get(0)), - PlutusData.bytes(vk.ic().get(1)), - PlutusData.bytes(vk.ic().get(2))); + vkIcData(vk.ic())); // Datum: public signals [c=33, a=3] var datum = PlutusData.list( @@ -117,15 +117,13 @@ void circom_wrongWitness_onChainRejects() { byte[] piB = g2Compress(proof.b()); byte[] piC = g1Compress(proof.c()); - var compiled = compileValidator(Groth16BLS12381Verifier.class); + var compiled = compileValidator(Groth16BLS12381GenericVerifier.class); var program = compiled.program().applyParams( PlutusData.bytes(vk.alpha()), PlutusData.bytes(vk.beta()), PlutusData.bytes(vk.gamma()), PlutusData.bytes(vk.delta()), - PlutusData.bytes(vk.ic().get(0)), - PlutusData.bytes(vk.ic().get(1)), - PlutusData.bytes(vk.ic().get(2))); + vkIcData(vk.ic())); // Wrong public inputs: claim c=99, a=3 (instead of c=33, a=3) var wrongDatum = PlutusData.list( @@ -170,6 +168,14 @@ private static void writeFp(byte[] buf, int off, BigInteger v) { System.arraycopy(b, s, buf, off + FP - c, c); } + private static PlutusData vkIcData(List ic) { + List values = new ArrayList<>(); + for (byte[] point : ic) { + values.add(PlutusData.bytes(point)); + } + return PlutusData.list(values.toArray(new PlutusData[0])); + } + private static byte[] loadBytes(String path) throws IOException { try (var is = CircomToOnChainE2ETest.class.getResourceAsStream(path)) { if (is == null) throw new IOException("Resource not found: " + path); diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381PureJavaProverTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381PureJavaProverTest.java index e5682c1..20a1e4a 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381PureJavaProverTest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381PureJavaProverTest.java @@ -34,8 +34,8 @@ class Groth16BLS12381PureJavaProverTest extends ContractTest { /** * 2-public-input circuit: pure Java prove → on-chain Julc VM verify. * - *

    Uses the generic {@link Groth16BLS12381Verifier} which requires exactly - * 2 public inputs (3 IC points: IC[0], IC[1], IC[2]).

    + *

    Uses {@link Groth16BLS12381GenericVerifier}; the same verifier also + * supports circuits with more or fewer public inputs.

    * *

    Circuit: publicA * secretB = publicC (publicA and publicC are public).

    */ @@ -47,7 +47,7 @@ void twoPublicInputs_pureJavaProve_onChainVerify() { .define(api -> api.assertEqual(api.mul(api.var("a"), api.var("b")), api.var("c"))); var r1cs = circuit.compileR1CS(CurveId.BLS12_381); - assertEquals(2, r1cs.numPublicInputs(), "Must have exactly 2 public inputs for generic verifier"); + assertEquals(2, r1cs.numPublicInputs(), "test circuit should have two public inputs"); var constraints = r1cs.constraints(); @@ -76,15 +76,13 @@ void twoPublicInputs_pureJavaProve_onChainVerify() { assertEquals(3, compressedVk.ic().size(), "IC should have numPublic+1 = 3 entries"); // Compile on-chain verifier with VK params - var compiled = compileValidator(Groth16BLS12381Verifier.class); + var compiled = compileValidator(Groth16BLS12381GenericVerifier.class); var program = compiled.program().applyParams( PlutusData.bytes(compressedVk.alpha()), PlutusData.bytes(compressedVk.beta()), PlutusData.bytes(compressedVk.gamma()), PlutusData.bytes(compressedVk.delta()), - PlutusData.bytes(compressedVk.ic().get(0)), - PlutusData.bytes(compressedVk.ic().get(1)), - PlutusData.bytes(compressedVk.ic().get(2))); + vkIcData(compressedVk.ic())); // Datum: 2 public inputs [a=3, c=33] var datum = PlutusData.list( @@ -124,6 +122,14 @@ private static SnarkjsToCardano.ProofCompressed compressProof(Groth16ProofBLS381 g1Compress(proof.a()), g2Compress(proof.b()), g1Compress(proof.c())); } + private static PlutusData vkIcData(List ic) { + List values = new ArrayList<>(); + for (byte[] point : ic) { + values.add(PlutusData.bytes(point)); + } + return PlutusData.list(values.toArray(new PlutusData[0])); + } + private static byte[] g1Compress(JacobianG1BLS381.AffineG1 p) { if (p.isInfinity()) { byte[] r = new byte[FP_SIZE]; r[0] = (byte) 0xC0; return r; } byte[] u = new byte[FP_SIZE * 2]; diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java index 627c978..51bb52d 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java @@ -12,7 +12,8 @@ import java.util.List; /** - * Tests the on-chain Groth16 BLS12-381 verifier using the Julc VM. + * Compatibility tests for the deprecated fixed two-public-input Groth16 + * BLS12-381 verifier using the Julc VM. *

    * This demonstrates the full end-to-end flow: * 1. Parse snarkjs JSON artifacts → compressed BLS12-381 bytes @@ -20,6 +21,7 @@ * 3. Evaluate with proof redeemer and public inputs datum * 4. Assert the pairing check passes in the Julc VM */ +@SuppressWarnings("deprecation") class Groth16BLS12381VerifierTest extends ContractTest { private static SnarkjsToCardano.VkCompressed vk; From a9486454d759bd453166321e68601bcafe6a588e Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 20:52:12 +0800 Subject: [PATCH 18/26] refactor(onchain): make BLS12-381 Groth16 verifier canonical --- docs/adr/0009-halo2-support-strategy.md | 2 +- docs/adr/0016-jubjub-in-circuit.md | 4 +- docs/adr/circuit-annotation/README.md | 4 +- .../cardano-gadget-support-matrix.md | 33 +-- ...cardano-groth16-arbitrary-public-inputs.md | 55 ++-- docs/architecture-overview.md | 4 +- docs/circuit-dsl-user-guide.md | 2 +- docs/getting-started.md | 4 +- docs/pure-java-prover-guide.md | 2 +- docs/usecases/digital-product-passport.md | 3 +- docs/usecases/identity-and-credentials.md | 7 +- docs/usecases/private-nft-ownership.md | 6 +- docs/usecases/private-token-transfer.md | 6 +- docs/usecases/private-voting.md | 9 +- docs/usecases/proof-of-reserves.md | 3 +- zeroj-examples/README.md | 4 +- zeroj-examples/build.gradle | 3 +- .../auction/onchain/ZkAuctionVerifier.java | 34 +-- .../dsl/PureJavaProverYaciE2ETest.java | 12 +- .../dsl/auction/SealedBidOnChainE2ETest.java | 14 +- .../dsl/auction/SealedBidPureJavaE2ETest.java | 2 +- .../BalanceThresholdPureJavaE2ETest.java | 4 +- zeroj-onchain-julc/README.md | 4 +- zeroj-onchain-julc/build.gradle | 59 +++- .../zeroj/onchain/julc/Groth16BLS12381.java | 162 +++++++++++ .../julc/Groth16BLS12381GenericVerifier.java | 102 ------- .../onchain/julc/Groth16BLS12381Verifier.java | 75 +---- .../julc/PlonkBLS12381FullVerifier.java | 55 ++-- .../zeroj/onchain/julc/ProverToCardano.java | 3 +- .../onchain/julc/CircomToOnChainE2ETest.java | 4 +- ...th16BLS12381FirstInputBindingVerifier.java | 40 +++ .../Groth16BLS12381GenericVerifierTest.java | 216 -------------- .../Groth16BLS12381PureJavaProverTest.java | 4 +- .../julc/Groth16BLS12381VerifierTest.java | 266 ++++++++++++------ 34 files changed, 588 insertions(+), 619 deletions(-) create mode 100644 zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381.java delete mode 100644 zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifier.java create mode 100644 zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381FirstInputBindingVerifier.java delete mode 100644 zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifierTest.java diff --git a/docs/adr/0009-halo2-support-strategy.md b/docs/adr/0009-halo2-support-strategy.md index 58e1d23..3eac65f 100644 --- a/docs/adr/0009-halo2-support-strategy.md +++ b/docs/adr/0009-halo2-support-strategy.md @@ -52,7 +52,7 @@ With Plutus V3 BLS12-381 builtins, Halo2 KZG verification is feasible: **ZeroJ deliverables:** 1. Halo2 KZG proof codec (parse Halo2 proof artifacts into `ZkProofEnvelope`) -2. On-chain verifier in Julc (like existing `Groth16BLS12381GenericVerifier.java`) +2. On-chain verifier in Julc (like existing `Groth16BLS12381Verifier.java`) 3. E2E test on Cardano preprod ### Phase 3: Recursive proof aggregation (Long-term) diff --git a/docs/adr/0016-jubjub-in-circuit.md b/docs/adr/0016-jubjub-in-circuit.md index c0c8c9a..7082a6a 100644 --- a/docs/adr/0016-jubjub-in-circuit.md +++ b/docs/adr/0016-jubjub-in-circuit.md @@ -45,7 +45,7 @@ one constraint per bit of the scalar — a few hundred constraints total. That's the whole point of an "embedded curve". The upshot: **Jubjub is Cardano-native**, BabyJubJub is not. Existing -Plutus V3 Groth16 verifiers (`zeroj-onchain-julc/Groth16BLS12381GenericVerifier`, +Plutus V3 Groth16 verifiers (`zeroj-onchain-julc/Groth16BLS12381Verifier`, `PlonkBLS12381FullVerifier`) accept Jubjub proofs without modification — all the complexity lives inside the SNARK. @@ -201,7 +201,7 @@ end-to-end verification on yaci-devkit as a merge gate. proof-of-reserves, sealed-bid auctions, privacy-preserving loyalty. - Schnorr / EdDSA wallet-signature-in-circuit enables provable Cardano- wallet-ownership gated features. -- **No onchain changes**: existing `Groth16BLS12381GenericVerifier` and +- **No onchain changes**: existing `Groth16BLS12381Verifier` and `PlonkBLS12381FullVerifier` accept Jubjub-using proofs as-is. ### Harder diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index 289a0de..713c6de 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -82,10 +82,10 @@ BLS12-381 Groth16: -> generated *Circuit companion -> compileR1CS(CurveId.BLS12_381) -> Groth16 proof - -> Groth16BLS12381GenericVerifier on Julc / Plutus V3 + -> Groth16BLS12381Verifier on Julc / Plutus V3 ``` -`Groth16BLS12381GenericVerifier` accepts the full verification-key `IC` vector +`Groth16BLS12381Verifier` accepts the full verification-key `IC` vector as a list parameter, so Cardano-facing circuits are not limited to two public inputs. Public inputs must still be serialized in the exact order returned by the generated circuit schema. diff --git a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md index 149792b..71482f6 100644 --- a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -61,8 +61,8 @@ Annotated symbolic circuit Relevant source: - `zeroj-onchain-julc/.../OnChainFeasibility.java` -- `zeroj-onchain-julc/.../Groth16BLS12381GenericVerifier.java` -- `zeroj-onchain-julc/.../Groth16BLS12381Verifier.java` (deprecated fixed two-input compatibility) +- `zeroj-onchain-julc/.../Groth16BLS12381Verifier.java` +- `zeroj-onchain-julc/.../Groth16BLS12381.java` - `zeroj-onchain-julc/.../PlonkBLS12381FullVerifier.java` - `zeroj-verifier-groth16/...` - `zeroj-verifier-plonk/...` @@ -123,30 +123,18 @@ Relevant source: | `zeroj-blst` | Native BLS12-381 provider | BLS12-381 | Not a circuit gadget | Off-chain helper | No annotation work. | | `zeroj-bls12381-wasm` | WASM BLS12-381 provider | BLS12-381 | Not a circuit gadget | Off-chain helper | No annotation work. | | `zeroj-bbs` / `zeroj-bbs-wasm` | CFRG BBS signatures and presentations | BLS12-381 | Not an annotated circuit gadget | No current ZeroJ on-chain verifier | Keep separate from annotated circuits for now. | -| Groth16 pure Java provers | Proof generation | BN254 and BLS12-381 | Consumes generated circuits through R1CS/witness APIs | BLS12-381 proofs can target Cardano through the fixed two-input verifier or the arbitrary-count generic verifier | None for public-input count; consider generated fixed-count validators only for budget-critical circuits. | +| Groth16 pure Java provers | Proof generation | BN254 and BLS12-381 | Consumes generated circuits through R1CS/witness APIs | BLS12-381 proofs target Cardano through the canonical arbitrary-count verifier | None for public-input count; consider generated fixed-count validators only for budget-critical circuits. | | PlonK pure Java provers | Proof generation | BN254 and BLS12-381 | Consumes generated circuits through existing compile APIs | BLS12-381 on-chain is experimental | Do not make PlonK the Cardano default until on-chain verifier is complete. | | Halo2 incubator verifier | Halo2 IPA verification | Pallas | Not a symbolic circuit target for Cardano | No | Keep as incubator/off-chain. | ## Important Current Limitations -### Generic Groth16 On-Chain Verifier Public-Input Count +### Canonical Groth16 On-Chain Verifier Public-Input Count Status: completed. The design and implementation notes are tracked in [`cardano-groth16-arbitrary-public-inputs.md`](cardano-groth16-arbitrary-public-inputs.md). -The original reusable Julc `Groth16BLS12381Verifier` is specialized to two -public inputs. It remains available for backward compatibility. It has `vkIc0`, -`vkIc1`, and `vkIc2` parameters and computes: - -```text -vk_x = IC[0] + pub[0] * IC[1] + pub[1] * IC[2] -``` - -That is sufficient for small examples, but it is not sufficient as the default -Cardano path for arbitrary annotated circuits. Annotated circuits can have any -stable public-input schema. - -`Groth16BLS12381GenericVerifier` now provides the general path. It accepts the +`Groth16BLS12381Verifier` now provides the general path. It accepts the full `IC` vector as one `PlutusData` list parameter and folds it against the datum public-input list: @@ -155,7 +143,8 @@ vk_x = IC[0] + pub[0] * IC[1] + ... + pub[n - 1] * IC[n] ``` The verifier rejects empty `IC` lists and any mismatch where -`len(IC) != len(publicInputs) + 1`. Generated fixed-count validators remain a +`len(IC) != len(publicInputs) + 1`. Custom validators compose the reusable +`Groth16BLS12381` `@OnchainLibrary` helper with their own domain checks. possible future optimization if budget-critical circuits need lower script cost. ### MiMC Is BN254-Only in the Circuit Library @@ -290,14 +279,14 @@ Goal: make arbitrary annotated BLS12-381 Groth16 circuits usable on-chain. Tasks: -- Added `Groth16BLS12381GenericVerifier`. -- Preserved the fixed two-input verifier for compatibility. +- Added `Groth16BLS12381Verifier`. +- Removed the old fixed two-input verifier before release. +- Added `Groth16BLS12381` as the reusable on-chain library helper. - Preserved stable public-input order by consuming datum values positionally. - Added Julc VM budget output for two-input and three-input proofs. - Added tests for two inputs, more-than-two inputs, wrong values, too few values, too many values, and empty `IC` lists. -- Updated the pure Java Yaci DevKit e2e to use a three-public-input circuit and - the generic verifier. +- Updated the pure Java Yaci DevKit e2e to use the canonical verifier. Exit criteria: diff --git a/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md b/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md index 4d2f4dd..b12466e 100644 --- a/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md +++ b/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md @@ -10,9 +10,9 @@ Implemented. ## Context -The current reusable Julc verifier, -`Groth16BLS12381Verifier`, is specialized to exactly two public inputs. It -accepts `vkIc0`, `vkIc1`, and `vkIc2` as script parameters and computes: +The first reusable Julc verifier design was specialized to exactly two public +inputs. It accepted `vkIc0`, `vkIc1`, and `vkIc2` as script parameters and +computed: ```text vk_x = IC[0] + public[0] * IC[1] + public[1] * IC[2] @@ -52,7 +52,7 @@ checking and `vk_x` computation instead of a mutable `while` accumulator. Add a new reusable Julc spending validator: ```java -Groth16BLS12381GenericVerifier +Groth16BLS12381Verifier ``` It will keep the same proof redeemer shape as the current fixed verifier: @@ -102,14 +102,15 @@ Then it runs the standard Groth16 pairing check: e(A, B) * e(-alpha, beta) == e(vk_x, gamma) * e(C, delta) ``` -The existing `Groth16BLS12381Verifier` remains in place only as a deprecated -source and script-parameter compatibility verifier. New code, examples, docs, -and generated flows should use `Groth16BLS12381GenericVerifier`. +Because ZeroJ has not been released yet, the fixed-input compatibility class +was removed. `Groth16BLS12381Verifier` is the canonical arbitrary-input +validator, and `Groth16BLS12381` is the reusable `@OnchainLibrary` helper for +custom validators. ## Length Contract -The generic verifier must reject malformed datum or verification-key parameters -instead of silently ignoring extra values. +The canonical list-based verifier must reject malformed datum or +verification-key parameters instead of silently ignoring extra values. Accepted: @@ -140,7 +141,7 @@ For annotated circuits, the intended flow is: GeneratedCircuit.schema() -> GeneratedCircuit.publicInputValues(...) -> datum list in the same order - -> Groth16BLS12381GenericVerifier + -> Groth16BLS12381Verifier ``` For DSL or `CircuitSpec` circuits, callers should use witness indices @@ -194,7 +195,7 @@ The script is loaded with five parameters: ```java JulcScriptLoader.load( - Groth16BLS12381GenericVerifier.class, + Groth16BLS12381Verifier.class, new BytesPlutusData(vk.alpha()), new BytesPlutusData(vk.beta()), new BytesPlutusData(vk.gamma()), @@ -206,7 +207,7 @@ JulcScriptLoader.load( Julc VM tests: -- verify an existing two-public-input fixture with the generic verifier +- verify an existing two-public-input fixture with the canonical verifier - prove and verify a pure Java BLS12-381 circuit with more than two public inputs - reject wrong public input values @@ -216,9 +217,9 @@ Julc VM tests: Yaci DevKit e2e: -- adapt the pure Java prover e2e path to load the generic verifier +- adapt the pure Java prover e2e path to load the canonical verifier - run a lock/unlock transaction with a generated Groth16 BLS12-381 proof -- keep the existing fixed verifier examples working +- keep the existing examples working through the canonical verifier Budget tracking: @@ -228,10 +229,11 @@ Budget tracking: ## Implementation Steps -1. Add `Groth16BLS12381GenericVerifier`. +1. Replace the fixed-count `Groth16BLS12381Verifier` with the arbitrary-input + verifier. 2. Add helper methods in tests to encode `vk.ic()` as a Plutus list. 3. Add a three-public-input pure Java BLS12-381 circuit test. -4. Update Yaci e2e coverage to exercise the generic verifier. +4. Update Yaci e2e coverage to exercise the canonical verifier. 5. Update the Cardano gadget support matrix and implementation plan. 6. Run module tests and the Yaci e2e test when the local DevKit is available. @@ -239,10 +241,17 @@ Budget tracking: Status: implemented. -The final verifier deprecates the fixed two-input `Groth16BLS12381Verifier` -and adds `Groth16BLS12381GenericVerifier` as the default. The generic verifier -accepts five script parameters: alpha, beta, gamma, delta, and the full `IC` -list. +The final verifier removes the fixed two-input class and makes +`Groth16BLS12381Verifier` the default. It accepts five script parameters: +alpha, beta, gamma, delta, and the full `IC` list. + +`Groth16BLS12381` is packaged as a JuLC `@OnchainLibrary` and bundled into the +published JAR under `META-INF/plutus-sources/`. Downstream custom validators +use their own local redeemer record and call `Groth16BLS12381.verify(...)`. +The proof record is intentionally not part of the reusable library surface +because JuLC record resolution is validator-local today; keeping the record +beside the validator avoids cross-module nested/top-level record resolution +issues while still sharing all proof verification logic. Julc VM tests cover: @@ -258,15 +267,15 @@ Measured Julc VM budgets in the implementation tests: - two public inputs: `cpu=2142040133`, `mem=71535` - three public inputs: `cpu=2275351144`, `mem=84444` -The Yaci DevKit e2e test was updated to use a three-public-input pure Java -circuit and the generic verifier. +The Yaci DevKit e2e tests were updated to use the canonical verifier. ## Consequences Positive: - annotated circuits are no longer blocked by the old two-public-input verifier -- existing fixed verifier users remain compatible +- there is one canonical BLS12-381 Groth16 verifier name before ZeroJ's first + release - public-input ordering stays explicit and schema-driven - one reusable on-chain verifier covers DSL, `CircuitSpec`, and symbolic annotation-generated circuits diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 4315465..1d1e61d 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -132,8 +132,8 @@ Anchoring verified results on L1: ### Layer 10: On-Chain Verification (`zeroj-onchain-julc`) Reusable Plutus V3 spending validators compiled via Julc: -- `Groth16BLS12381GenericVerifier` -- on-chain Groth16 verification using BLS12-381 builtins and arbitrary public-input counts -- `Groth16BLS12381Verifier` -- deprecated fixed two-public-input compatibility verifier +- `Groth16BLS12381Verifier` -- on-chain Groth16 verification using BLS12-381 builtins and arbitrary public-input counts +- `Groth16BLS12381` -- reusable `@OnchainLibrary` Groth16 verification helper for custom validators - `PlonkBLS12381FullVerifier` -- experimental on-chain PlonK prototype with Fiat-Shamir transcript and inverse checks; KZG pairing check deferred - `SnarkjsToCardano` -- converts snarkjs JSON to BLS compressed bytes for on-chain use - `ScriptBudgetEstimator`, `OnChainFeasibility`, `ReferenceScriptDeployer` -- on-chain budget and deployment helpers diff --git a/docs/circuit-dsl-user-guide.md b/docs/circuit-dsl-user-guide.md index ef985e8..8e72b6d 100644 --- a/docs/circuit-dsl-user-guide.md +++ b/docs/circuit-dsl-user-guide.md @@ -742,7 +742,7 @@ Verification (pure Java, zero native deps): PlonkBN254Verifier / PlonkBLS12381Verifier On-Chain (Cardano Plutus V3): - Groth16BLS12381GenericVerifier (Julc) + Groth16BLS12381Verifier (Julc) PlonkBLS12381FullVerifier (Julc prototype: transcript/inverse checks only) ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 5c8d03f..531c24f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -140,7 +140,7 @@ for (byte[] ic : compressedVk.ic()) { } // Compile Julc validator with VK parameters -var script = JulcScriptLoader.load(Groth16BLS12381GenericVerifier.class, +var script = JulcScriptLoader.load(Groth16BLS12381Verifier.class, new BytesPlutusData(compressedVk.alpha()), new BytesPlutusData(compressedVk.beta()), new BytesPlutusData(compressedVk.gamma()), @@ -202,7 +202,7 @@ var unlockResult = new QuickTxBuilder(backend) ## What Happens On-Chain -The `Groth16BLS12381GenericVerifier` Plutus V3 script executes: +The `Groth16BLS12381Verifier` Plutus V3 script executes: 1. **Extract** public inputs from datum: `[a, c]` 2. **Decompress** proof points (piA, piB, piC) from BLS12-381 compressed bytes diff --git a/docs/pure-java-prover-guide.md b/docs/pure-java-prover-guide.md index 95ca2df..a2fd2aa 100644 --- a/docs/pure-java-prover-guide.md +++ b/docs/pure-java-prover-guide.md @@ -229,7 +229,7 @@ for (byte[] ic : compressedVk.ic()) { } // Load the generic Groth16 BLS12-381 verifier with VK baked in -var script = JulcScriptLoader.load(Groth16BLS12381GenericVerifier.class, +var script = JulcScriptLoader.load(Groth16BLS12381Verifier.class, new BytesPlutusData(compressedVk.alpha()), new BytesPlutusData(compressedVk.beta()), new BytesPlutusData(compressedVk.gamma()), diff --git a/docs/usecases/digital-product-passport.md b/docs/usecases/digital-product-passport.md index b3d6fbf..60bd675 100644 --- a/docs/usecases/digital-product-passport.md +++ b/docs/usecases/digital-product-passport.md @@ -410,8 +410,7 @@ public class DppComplianceGate { @Param static byte[] vkBeta; @Param static byte[] vkGamma; @Param static byte[] vkDelta; - @Param static byte[] vkIc0; - @Param static byte[] vkIc1; + @Param static PlutusData vkIc; record ComplianceProof( byte[] piA, byte[] piB, byte[] piC, diff --git a/docs/usecases/identity-and-credentials.md b/docs/usecases/identity-and-credentials.md index 7088b70..b2fca14 100644 --- a/docs/usecases/identity-and-credentials.md +++ b/docs/usecases/identity-and-credentials.md @@ -561,10 +561,7 @@ public class CredentialGatedProtocol { @Param static byte[] vkBeta; @Param static byte[] vkGamma; @Param static byte[] vkDelta; - @Param static byte[] vkIc0; - @Param static byte[] vkIc1; - @Param static byte[] vkIc2; - @Param static byte[] vkIc3; + @Param static PlutusData vkIc; // Trusted issuer public key (baked at deploy) @Param static byte[] trustedIssuerPubKey; @@ -807,4 +804,4 @@ When BBS+ circuits are available: ### Upgrade Path -All three approaches use the same on-chain verifier (`Groth16BLS12381GenericVerifier`). The circuit changes, but the Plutus V3 script shape is identical. You can upgrade from Poseidon-signed to EdDSA to BBS+ without writing a custom on-chain verifier; redeployment is only needed when the verification key changes. +All three approaches use the same on-chain verifier (`Groth16BLS12381Verifier`). The circuit changes, but the Plutus V3 script shape is identical. You can upgrade from Poseidon-signed to EdDSA to BBS+ without writing a custom on-chain verifier; redeployment is only needed when the verification key changes. diff --git a/docs/usecases/private-nft-ownership.md b/docs/usecases/private-nft-ownership.md index 64b041d..f265fdc 100644 --- a/docs/usecases/private-nft-ownership.md +++ b/docs/usecases/private-nft-ownership.md @@ -636,9 +636,7 @@ public class NFTGatedAccess { @Param static byte[] vkBeta; @Param static byte[] vkGamma; @Param static byte[] vkDelta; - @Param static byte[] vkIc0; - @Param static byte[] vkIc1; - @Param static byte[] vkIc2; + @Param static PlutusData vkIc; @Param static byte[] nullifierRegistryHash; record AccessProof( @@ -823,4 +821,4 @@ Only needed if transfer privacy is critical (anonymous art sales, private collec ### The Circuit Is the Same On-Chain -All approaches use the same `Groth16BLS12381GenericVerifier` Plutus V3 script shape. Only the circuit, verification key, and snapshot mechanism differ. You can start with Approach 1 and upgrade to Approach 3 or 4 later without writing a custom on-chain verifier. +All approaches use the same `Groth16BLS12381Verifier` Plutus V3 script shape. Only the circuit, verification key, and snapshot mechanism differ. You can start with Approach 1 and upgrade to Approach 3 or 4 later without writing a custom on-chain verifier. diff --git a/docs/usecases/private-token-transfer.md b/docs/usecases/private-token-transfer.md index c7396e9..fc43b5a 100644 --- a/docs/usecases/private-token-transfer.md +++ b/docs/usecases/private-token-transfer.md @@ -557,11 +557,11 @@ public class PrivacyPoolDeposit { ### Pool Validator (Withdrawal) ```java -@SpendingValidator -public class PrivacyPool { + @SpendingValidator + public class PrivacyPool { @Param static byte[] vkAlpha, vkBeta, vkGamma, vkDelta; - @Param static byte[] vkIc0, vkIc1, vkIc2, vkIc3; + @Param static PlutusData vkIc; @Param static byte[] nullifierRegistryHash; @Param static long denomination; diff --git a/docs/usecases/private-voting.md b/docs/usecases/private-voting.md index 1702e01..2431e92 100644 --- a/docs/usecases/private-voting.md +++ b/docs/usecases/private-voting.md @@ -303,10 +303,7 @@ public class VoteMintingPolicy { @Param static byte[] vkBeta; @Param static byte[] vkGamma; @Param static byte[] vkDelta; - @Param static byte[] vkIc0; - @Param static byte[] vkIc1; - @Param static byte[] vkIc2; - @Param static byte[] vkIc3; + @Param static PlutusData vkIc; record VoteProof( byte[] piA, byte[] piB, byte[] piC, @@ -316,9 +313,9 @@ public class VoteMintingPolicy { @Entrypoint static boolean validate(VoteProof redeemer, PlutusData ctx) { // 1. Groth16 BLS12-381 pairing check - boolean proofValid = verifyGroth16Pairing( + boolean proofValid = Groth16BLS12381.verify(publicInputs, redeemer.piA(), redeemer.piB(), redeemer.piC(), - vkAlpha, vkBeta, vkGamma, vkDelta, vkIc0, vkIc1, vkIc2, vkIc3); + vkAlpha, vkBeta, vkGamma, vkDelta, vkIc); // 2. Minted token name must equal the nullifier byte[] ownPolicy = getOwnPolicyId(ctx); diff --git a/docs/usecases/proof-of-reserves.md b/docs/usecases/proof-of-reserves.md index 4447f7d..0f048fe 100644 --- a/docs/usecases/proof-of-reserves.md +++ b/docs/usecases/proof-of-reserves.md @@ -584,8 +584,7 @@ public class ReserveProofVerifier { @Param static byte[] vkBeta; @Param static byte[] vkGamma; @Param static byte[] vkDelta; - @Param static byte[] vkIc0; - @Param static byte[] vkIc1; + @Param static PlutusData vkIc; record ReserveProof( byte[] piA, byte[] piB, byte[] piC, diff --git a/zeroj-examples/README.md b/zeroj-examples/README.md index d17d48c..eda32bd 100644 --- a/zeroj-examples/README.md +++ b/zeroj-examples/README.md @@ -109,7 +109,7 @@ reference circuit. This is the full end-to-end flow from circuit to on-chain execution: 1. **Load** pre-generated BLS12-381 proof artifacts -2. **Compile** `Groth16BLS12381GenericVerifier` Julc script with VK parameters baked in +2. **Compile** `Groth16BLS12381Verifier` Julc script with VK parameters baked in 3. **Lock** ADA at script address with public inputs (commitment, reservePrice) as datum 4. **Unlock** with ZK proof (piA, piB, piC) as redeemer 5. **Plutus V3 executes** BLS12-381 pairing verification on-chain @@ -123,7 +123,7 @@ The on-chain Plutus V3 validators live in [`zeroj-onchain-julc`](../zeroj-onchai | Validator | Proof System | Source | |-----------|-------------|--------| -| `Groth16BLS12381GenericVerifier` | Groth16 BLS12-381 | `zeroj-onchain-julc` | +| `Groth16BLS12381Verifier` | Groth16 BLS12-381 | `zeroj-onchain-julc` | | `PlonkBLS12381FullVerifier` | PlonK BLS12-381 prototype | `zeroj-onchain-julc` | The example-specific `ZkAuctionVerifier` in this module extends the pattern with auction-specific logic (reserve price check). diff --git a/zeroj-examples/build.gradle b/zeroj-examples/build.gradle index c2dd33f..c0a81a8 100644 --- a/zeroj-examples/build.gradle +++ b/zeroj-examples/build.gradle @@ -6,7 +6,7 @@ plugins { description = 'ZeroJ end-to-end examples and demos' ext { - julcVersion = '0.1.0-pre11' + julcVersion = '0.1.0-pre12' cclVersion = '0.8.0-pre2' } @@ -41,6 +41,7 @@ dependencies { // On-chain verifier module (Julc-based Plutus V3 validators) implementation project(':zeroj-onchain-julc') + annotationProcessor project(':zeroj-onchain-julc') // Julc for on-chain compilation implementation "com.bloxbean.cardano:julc-stdlib:${julcVersion}" diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/onchain/ZkAuctionVerifier.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/onchain/ZkAuctionVerifier.java index 6d7ba2e..a9599d0 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/onchain/ZkAuctionVerifier.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/onchain/ZkAuctionVerifier.java @@ -5,7 +5,7 @@ import com.bloxbean.cardano.julc.stdlib.annotation.Entrypoint; import com.bloxbean.cardano.julc.stdlib.annotation.Param; import com.bloxbean.cardano.julc.stdlib.annotation.SpendingValidator; -import com.bloxbean.cardano.julc.stdlib.lib.BlsLib; +import com.bloxbean.cardano.zeroj.onchain.julc.Groth16BLS12381; import java.math.BigInteger; @@ -21,9 +21,8 @@ * The bidCommitment is stored in the datum (set during bidding phase). * The reservePrice is a validator parameter (set by the auctioneer at deploy time). *

    - * This is a domain-specific verifier that extends the core Groth16 pattern - * (from {@code zeroj-onchain-julc}) with a {@code reservePriceBytes} binding - * parameter, demonstrating how to customize the core verifier for a specific use case. + * This domain-specific verifier composes the reusable {@link Groth16BLS12381} + * on-chain library with a {@code reservePriceBytes} binding parameter. */ @SpendingValidator public class ZkAuctionVerifier { @@ -33,41 +32,22 @@ public class ZkAuctionVerifier { @Param static byte[] vkBeta; @Param static byte[] vkGamma; @Param static byte[] vkDelta; - @Param static byte[] vkIc0; - @Param static byte[] vkIc1; - @Param static byte[] vkIc2; + @Param static PlutusData vkIc; record Groth16Proof(byte[] piA, byte[] piB, byte[] piC) {} @Entrypoint static boolean validate(PlutusData datum, Groth16Proof proof, PlutusData ctx) { PlutusData inputs = Builtins.unListData(datum); - BigInteger pub0 = Builtins.asInteger(Builtins.headList(inputs)); // bidCommitment BigInteger pub1 = Builtins.asInteger(Builtins.headList(Builtins.tailList(inputs))); // reservePrice // Binding: reserve price must match the auctioneer's configured reserve BigInteger committedReserve = Builtins.byteStringToInteger(true, reservePriceBytes); boolean reserveMatches = pub1.equals(committedReserve); - // Groth16 verification - byte[] a = BlsLib.g1Uncompress(proof.piA()); - byte[] b = BlsLib.g2Uncompress(proof.piB()); - byte[] c = BlsLib.g1Uncompress(proof.piC()); - byte[] alpha = BlsLib.g1Uncompress(vkAlpha); - byte[] beta = BlsLib.g2Uncompress(vkBeta); - byte[] gamma = BlsLib.g2Uncompress(vkGamma); - byte[] delta = BlsLib.g2Uncompress(vkDelta); - byte[] ic0 = BlsLib.g1Uncompress(vkIc0); - byte[] ic1 = BlsLib.g1Uncompress(vkIc1); - byte[] ic2 = BlsLib.g1Uncompress(vkIc2); + boolean proofValid = Groth16BLS12381.verify(datum, proof.piA(), proof.piB(), proof.piC(), + vkAlpha, vkBeta, vkGamma, vkDelta, vkIc); - byte[] vkX = BlsLib.g1Add(ic0, - BlsLib.g1Add(BlsLib.g1ScalarMul(pub0, ic1), BlsLib.g1ScalarMul(pub1, ic2))); - - byte[] negAlpha = BlsLib.g1Neg(alpha); - byte[] lhs = BlsLib.mulMlResult(BlsLib.millerLoop(a, b), BlsLib.millerLoop(negAlpha, beta)); - byte[] rhs = BlsLib.mulMlResult(BlsLib.millerLoop(vkX, gamma), BlsLib.millerLoop(c, delta)); - - return reserveMatches && BlsLib.finalVerify(lhs, rhs); + return reserveMatches && proofValid; } } diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java index ec6883f..6f11697 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java @@ -17,7 +17,7 @@ import com.bloxbean.cardano.zeroj.crypto.setup.PowersOfTauBLS381; import com.bloxbean.cardano.zeroj.onchain.julc.ProverToCardano; import com.bloxbean.cardano.zeroj.examples.dsl.common.YaciHelper; -import com.bloxbean.cardano.zeroj.onchain.julc.Groth16BLS12381GenericVerifier; +import com.bloxbean.cardano.zeroj.onchain.julc.Groth16BLS12381Verifier; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -39,7 +39,7 @@ *

  • Generate dev Powers of Tau + Groth16 setup (pure Java)
  • *
  • Compute witness and prove (pure Java BLS12-381 prover)
  • *
  • Compress proof + VK to BLS bytes
  • - *
  • Load generic {@link Groth16BLS12381GenericVerifier} Plutus V3 script with VK params
  • + *
  • Load canonical {@link Groth16BLS12381Verifier} Plutus V3 script with VK params
  • *
  • Lock tADA at script address with public inputs as datum
  • *
  • Unlock with ZK proof as redeemer — verified on-chain by Cardano node
  • * @@ -81,10 +81,10 @@ static void setup() throws Exception { } /** - * Pure Java circuit → prove → Yaci DevKit on-chain verify using generic Groth16 verifier. + * Pure Java circuit → prove → Yaci DevKit on-chain verify using canonical Groth16 verifier. * *

    Circuit: a * x + b = c, where a, b, and c are public and x is private. - * Uses the generic {@link Groth16BLS12381GenericVerifier} with 3 public + * Uses {@link Groth16BLS12381Verifier} with 3 public * inputs and 4 IC points.

    */ @Test @@ -136,7 +136,7 @@ void pureJavaProve_groth16_onChainVerify() throws Exception { assertEquals(4, compressedVk.ic().size(), "IC must have numPublic+1 entries"); // 6. Load the generic Groth16 BLS12-381 verifier with VK params - var script = JulcScriptLoader.load(Groth16BLS12381GenericVerifier.class, + var script = JulcScriptLoader.load(Groth16BLS12381Verifier.class, new BytesPlutusData(compressedVk.alpha()), new BytesPlutusData(compressedVk.beta()), new BytesPlutusData(compressedVk.gamma()), @@ -203,7 +203,7 @@ void pureJavaProve_groth16_onChainVerify() throws Exception { System.out.println(); System.out.println("Pipeline: Java DSL circuit"); System.out.println(" → pure Java BLS12-381 Groth16 prover"); - System.out.println(" → generic Groth16BLS12381GenericVerifier (Plutus V3)"); + System.out.println(" → Groth16BLS12381Verifier (Plutus V3)"); System.out.println(" → Yaci DevKit (Cardano local devnet)"); System.out.println("Zero external tools. 100% Java 25."); } diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidOnChainE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidOnChainE2ETest.java index f17199c..859dd6e 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidOnChainE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidOnChainE2ETest.java @@ -16,6 +16,8 @@ import org.junit.jupiter.api.Test; import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -65,9 +67,7 @@ void sealedBidVerifiedOnChain() throws Exception { new BytesPlutusData(vk.beta()), new BytesPlutusData(vk.gamma()), new BytesPlutusData(vk.delta()), - new BytesPlutusData(vk.ic().get(0)), - new BytesPlutusData(vk.ic().get(1)), - new BytesPlutusData(vk.ic().get(2))); + vkIcData(vk.ic())); var scriptAddr = AddressProvider.getEntAddress(script, Networks.testnet()).toBech32(); System.out.println("Auction script address: " + scriptAddr); @@ -129,4 +129,12 @@ void sealedBidVerifiedOnChain() throws Exception { System.out.println("Reserve price: " + reservePrice + " (verified: domain == circuit == on-chain)"); System.out.println("Bid amount: 1000 (PRIVATE — hidden inside ZK proof, never on-chain)"); } + + private static ListPlutusData vkIcData(List ic) { + List values = new ArrayList<>(); + for (byte[] point : ic) { + values.add(new BytesPlutusData(point)); + } + return ListPlutusData.of(values.toArray(new PlutusData[0])); + } } diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java index 3bbf913..deb6d2d 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java @@ -88,7 +88,7 @@ void devTau_sealedBid_fullStack() { System.out.println("=== SealedBid E2E (dev tau): off-chain COMPLETE ==="); // On-chain Julc VM verification for arbitrary public-input counts is - // covered by Groth16BLS12381GenericVerifier in zeroj-onchain-julc. + // covered by Groth16BLS12381Verifier in zeroj-onchain-julc. } // --- Helpers --- diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/balance/BalanceThresholdPureJavaE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/balance/BalanceThresholdPureJavaE2ETest.java index dc7f2cd..823e982 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/balance/BalanceThresholdPureJavaE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/balance/BalanceThresholdPureJavaE2ETest.java @@ -19,7 +19,7 @@ * Full-stack E2E for BalanceThreshold: circuit → pure Java prove → off-chain verify → Julc VM on-chain verify. * *

    This circuit has 2 public inputs (threshold, isAboveThreshold), which can - * be verified on-chain by {@code Groth16BLS12381GenericVerifier}.

    + * be verified on-chain by {@code Groth16BLS12381Verifier}.

    * *

    DEV/TEST (this test)

    *

    Uses {@code PowersOfTauBLS381.generate(8)} — for development only.

    @@ -72,7 +72,7 @@ void devTau_balanceThreshold_fullStack() { System.out.println("=== BalanceThreshold E2E (dev tau): off-chain COMPLETE ==="); // On-chain Julc VM verification runs in the zeroj-onchain-julc module - // where compileValidator(Groth16BLS12381GenericVerifier.class) can access Julc bytecode. + // where compileValidator(Groth16BLS12381Verifier.class) can access Julc bytecode. // Use ProverToCardano.compressVk/compressProof to convert for on-chain submission. } diff --git a/zeroj-onchain-julc/README.md b/zeroj-onchain-julc/README.md index 5744bba..53fe362 100644 --- a/zeroj-onchain-julc/README.md +++ b/zeroj-onchain-julc/README.md @@ -12,8 +12,8 @@ V3. | Validator / Helper | Status | Notes | |--------------------|--------|-------| -| `Groth16BLS12381GenericVerifier` | Working | Default BLS12-381 Groth16 verifier using Plutus V3 BLS builtins; supports arbitrary public-input counts | -| `Groth16BLS12381Verifier` | Deprecated compatibility | Fixed two-public-input verifier retained for older callers | +| `Groth16BLS12381Verifier` | Working | Default BLS12-381 Groth16 verifier using Plutus V3 BLS builtins; supports arbitrary public-input counts | +| `Groth16BLS12381` | Working library | Reusable `@OnchainLibrary` proof verification helper for custom validators | | `PlonkBLS12381FullVerifier` | Experimental prototype | Re-derives transcript and checks inverse constraints; KZG batch opening pairing check is deferred | | `SnarkjsToCardano` | Working helper | Converts snarkjs Groth16 JSON points to Cardano-compatible compressed bytes | | `ProverToCardano` | Working helper | Converts ZeroJ prover artifacts to on-chain data shapes | diff --git a/zeroj-onchain-julc/build.gradle b/zeroj-onchain-julc/build.gradle index 5e6fcb7..d497c6f 100644 --- a/zeroj-onchain-julc/build.gradle +++ b/zeroj-onchain-julc/build.gradle @@ -1,11 +1,28 @@ +buildscript { + repositories { + mavenCentral() + maven { + url "https://central.sonatype.com/repository/maven-snapshots" + content { + snapshotsOnly() + } + } + } + dependencies { + classpath "com.bloxbean.cardano:julc-gradle-plugin:0.1.0-pre12" + } +} + plugins { id 'java-library' } +apply plugin: 'com.bloxbean.cardano.julc' + description = 'ZeroJ on-chain Julc verifiers — reusable Groth16 validator and experimental PlonK BLS12-381 prototype' ext { - julcVersion = '0.1.0-pre10' + julcVersion = '0.1.0-pre12' } repositories { @@ -42,6 +59,46 @@ dependencies { testImplementation project(':zeroj-circuit-dsl') } +tasks.named('bundleJulcSources') { + mustRunAfter tasks.named('processResources') + + doLast { + def sourceRoot = layout.buildDirectory.dir('resources/main/META-INF/plutus-sources').get().asFile + def sources = new TreeSet(fileTree(sourceRoot) { + include '**/*.java' + }.files.collect { file -> + sourceRoot.toPath().relativize(file.toPath()).toString().replace(File.separatorChar, '/' as char) + }) + + configurations.compileClasspath.files.findAll { it.name.endsWith('.jar') }.each { jar -> + zipTree(jar).matching { + include 'META-INF/plutus-sources/index.txt' + }.files.each { indexFile -> + indexFile.eachLine { line -> + def entry = line.trim() + if (entry.endsWith('.java')) { + sources.add(entry) + } + } + } + } + + if (!sources.isEmpty()) { + def index = new File(sourceRoot, 'index.txt') + index.parentFile.mkdirs() + index.text = sources.join('\n') + '\n' + } + } +} + +tasks.named('compileTestJava') { + dependsOn tasks.named('bundleJulcSources') +} + +tasks.named('javadoc') { + dependsOn tasks.named('bundleJulcSources') +} + publishing { publications { mavenJava(MavenPublication) { diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381.java new file mode 100644 index 0000000..0ae8066 --- /dev/null +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381.java @@ -0,0 +1,162 @@ +package com.bloxbean.cardano.zeroj.onchain.julc; + +import com.bloxbean.cardano.julc.core.PlutusData; +import com.bloxbean.cardano.julc.stdlib.Builtins; +import com.bloxbean.cardano.julc.stdlib.annotation.OnchainLibrary; + +import java.math.BigInteger; + +/** + * Reusable on-chain Groth16 verifier logic for BLS12-381 proofs. + */ +@OnchainLibrary +public class Groth16BLS12381 { + + public static PlutusData publicInputs(BigInteger pub0) { + return Builtins.listData(Builtins.mkCons( + Builtins.iData(pub0), + Builtins.mkNilData())); + } + + public static PlutusData publicInputs(BigInteger pub0, BigInteger pub1) { + return Builtins.listData(Builtins.mkCons( + Builtins.iData(pub0), + Builtins.mkCons( + Builtins.iData(pub1), + Builtins.mkNilData()))); + } + + public static PlutusData publicInputs(BigInteger pub0, BigInteger pub1, BigInteger pub2) { + return Builtins.listData(Builtins.mkCons( + Builtins.iData(pub0), + Builtins.mkCons( + Builtins.iData(pub1), + Builtins.mkCons( + Builtins.iData(pub2), + Builtins.mkNilData())))); + } + + public static PlutusData publicInputs(BigInteger pub0, BigInteger pub1, BigInteger pub2, + BigInteger pub3) { + return Builtins.listData(Builtins.mkCons( + Builtins.iData(pub0), + Builtins.mkCons( + Builtins.iData(pub1), + Builtins.mkCons( + Builtins.iData(pub2), + Builtins.mkCons( + Builtins.iData(pub3), + Builtins.mkNilData()))))); + } + + public static PlutusData publicInputs(BigInteger pub0, BigInteger pub1, BigInteger pub2, + BigInteger pub3, BigInteger pub4) { + return Builtins.listData(Builtins.mkCons( + Builtins.iData(pub0), + Builtins.mkCons( + Builtins.iData(pub1), + Builtins.mkCons( + Builtins.iData(pub2), + Builtins.mkCons( + Builtins.iData(pub3), + Builtins.mkCons( + Builtins.iData(pub4), + Builtins.mkNilData())))))); + } + + public static PlutusData publicInputs(BigInteger pub0, BigInteger pub1, BigInteger pub2, + BigInteger pub3, BigInteger pub4, BigInteger pub5) { + return Builtins.listData(Builtins.mkCons( + Builtins.iData(pub0), + Builtins.mkCons( + Builtins.iData(pub1), + Builtins.mkCons( + Builtins.iData(pub2), + Builtins.mkCons( + Builtins.iData(pub3), + Builtins.mkCons( + Builtins.iData(pub4), + Builtins.mkCons( + Builtins.iData(pub5), + Builtins.mkNilData()))))))); + } + + public static boolean verify(PlutusData publicInputs, + byte[] piA, + byte[] piB, + byte[] piC, + byte[] vkAlpha, + byte[] vkBeta, + byte[] vkGamma, + byte[] vkDelta, + PlutusData vkIc) { + PlutusData inputsCursor = Builtins.unListData(publicInputs); + PlutusData icCursor = Builtins.unListData(vkIc); + + if (Builtins.nullList(icCursor)) { + return false; + } + + byte[] vkX = Builtins.bls12_381_G1_uncompress(Builtins.unBData(Builtins.headList(icCursor))); + return verifyWithPublicInputs(inputsCursor, Builtins.tailList(icCursor), vkX, + piA, piB, piC, vkAlpha, vkBeta, vkGamma, vkDelta); + } + + private static boolean verifyWithPublicInputs(PlutusData inputsCursor, + PlutusData icCursor, + byte[] vkX, + byte[] piA, + byte[] piB, + byte[] piC, + byte[] vkAlpha, + byte[] vkBeta, + byte[] vkGamma, + byte[] vkDelta) { + if (!matchingLengths(inputsCursor, icCursor)) { + return false; + } + + byte[] computedVkX = computeVkX(inputsCursor, icCursor, vkX); + + byte[] a = Builtins.bls12_381_G1_uncompress(piA); + byte[] b = Builtins.bls12_381_G2_uncompress(piB); + byte[] c = Builtins.bls12_381_G1_uncompress(piC); + + byte[] alpha = Builtins.bls12_381_G1_uncompress(vkAlpha); + byte[] beta = Builtins.bls12_381_G2_uncompress(vkBeta); + byte[] gamma = Builtins.bls12_381_G2_uncompress(vkGamma); + byte[] delta = Builtins.bls12_381_G2_uncompress(vkDelta); + + byte[] negAlpha = Builtins.bls12_381_G1_neg(alpha); + byte[] lhs = Builtins.bls12_381_mulMlResult( + Builtins.bls12_381_millerLoop(a, b), + Builtins.bls12_381_millerLoop(negAlpha, beta)); + byte[] rhs = Builtins.bls12_381_mulMlResult( + Builtins.bls12_381_millerLoop(computedVkX, gamma), + Builtins.bls12_381_millerLoop(c, delta)); + + return Builtins.bls12_381_finalVerify(lhs, rhs); + } + + private static boolean matchingLengths(PlutusData inputsCursor, PlutusData icCursor) { + if (Builtins.nullList(inputsCursor)) { + return Builtins.nullList(icCursor); + } else if (Builtins.nullList(icCursor)) { + return false; + } else { + return matchingLengths(Builtins.tailList(inputsCursor), Builtins.tailList(icCursor)); + } + } + + private static byte[] computeVkX(PlutusData inputsCursor, PlutusData icCursor, byte[] vkX) { + if (Builtins.nullList(inputsCursor)) { + return vkX; + } else { + BigInteger publicInput = Builtins.asInteger(Builtins.headList(inputsCursor)); + byte[] ic = Builtins.bls12_381_G1_uncompress(Builtins.unBData(Builtins.headList(icCursor))); + byte[] scaled = Builtins.bls12_381_G1_scalarMul(publicInput, ic); + byte[] nextVkX = Builtins.bls12_381_G1_add(vkX, scaled); + return computeVkX(Builtins.tailList(inputsCursor), Builtins.tailList(icCursor), nextVkX); + } + } +} diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifier.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifier.java deleted file mode 100644 index 0b334d5..0000000 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifier.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; - -import com.bloxbean.cardano.julc.core.PlutusData; -import com.bloxbean.cardano.julc.stdlib.Builtins; -import com.bloxbean.cardano.julc.stdlib.annotation.Entrypoint; -import com.bloxbean.cardano.julc.stdlib.annotation.Param; -import com.bloxbean.cardano.julc.stdlib.annotation.SpendingValidator; -import com.bloxbean.cardano.julc.stdlib.lib.BlsLib; - -import java.math.BigInteger; - -/** - * Generic on-chain Groth16 BLS12-381 verifier as a Plutus V3 spending validator. - *

    - * Verification key points are baked in at deploy time. Unlike - * {@link Groth16BLS12381Verifier}, this verifier accepts the full IC vector as - * a Plutus list parameter, so it supports any public-input count. - *

    - * Datum: list of public input integers in verification-key schema order. - * Redeemer: compressed Groth16 proof points. - * Parameter {@code vkIc}: list of compressed G1 IC points, where - * {@code len(vkIc) == len(publicInputs) + 1}. - */ -@SpendingValidator -public class Groth16BLS12381GenericVerifier { - - @Param static byte[] vkAlpha; // G1 compressed 48 bytes - @Param static byte[] vkBeta; // G2 compressed 96 bytes - @Param static byte[] vkGamma; // G2 compressed 96 bytes - @Param static byte[] vkDelta; // G2 compressed 96 bytes - @Param static PlutusData vkIc; // List of G1 compressed 48-byte IC points - - /** - * Groth16 proof points (compressed BLS bytes), passed as redeemer. - */ - record Groth16Proof(byte[] piA, byte[] piB, byte[] piC) {} - - @Entrypoint - public static boolean validate(PlutusData datum, Groth16Proof proof, PlutusData ctx) { - PlutusData inputsCursor = Builtins.unListData(datum); - PlutusData icCursor = Builtins.unListData(vkIc); - - if (Builtins.nullList(icCursor)) { - return false; - } - - byte[] vkX = BlsLib.g1Uncompress(Builtins.unBData(Builtins.headList(icCursor))); - return verifyWithPublicInputs(inputsCursor, Builtins.tailList(icCursor), vkX, proof); - } - - private static boolean verifyWithPublicInputs(PlutusData inputsCursor, - PlutusData icCursor, - byte[] vkX, - Groth16Proof proof) { - if (!matchingLengths(inputsCursor, icCursor)) { - return false; - } - - byte[] computedVkX = computeVkX(inputsCursor, icCursor, vkX); - - byte[] a = BlsLib.g1Uncompress(proof.piA()); - byte[] b = BlsLib.g2Uncompress(proof.piB()); - byte[] c = BlsLib.g1Uncompress(proof.piC()); - - byte[] alpha = BlsLib.g1Uncompress(vkAlpha); - byte[] beta = BlsLib.g2Uncompress(vkBeta); - byte[] gamma = BlsLib.g2Uncompress(vkGamma); - byte[] delta = BlsLib.g2Uncompress(vkDelta); - - byte[] negAlpha = BlsLib.g1Neg(alpha); - byte[] lhs = BlsLib.mulMlResult( - BlsLib.millerLoop(a, b), - BlsLib.millerLoop(negAlpha, beta)); - byte[] rhs = BlsLib.mulMlResult( - BlsLib.millerLoop(computedVkX, gamma), - BlsLib.millerLoop(c, delta)); - - return BlsLib.finalVerify(lhs, rhs); - } - - private static boolean matchingLengths(PlutusData inputsCursor, PlutusData icCursor) { - if (Builtins.nullList(inputsCursor)) { - return Builtins.nullList(icCursor); - } else if (Builtins.nullList(icCursor)) { - return false; - } else { - return matchingLengths(Builtins.tailList(inputsCursor), Builtins.tailList(icCursor)); - } - } - - private static byte[] computeVkX(PlutusData inputsCursor, PlutusData icCursor, byte[] vkX) { - if (Builtins.nullList(inputsCursor)) { - return vkX; - } else { - BigInteger publicInput = Builtins.asInteger(Builtins.headList(inputsCursor)); - byte[] ic = BlsLib.g1Uncompress(Builtins.unBData(Builtins.headList(icCursor))); - byte[] scaled = BlsLib.g1ScalarMul(publicInput, ic); - byte[] nextVkX = BlsLib.g1Add(vkX, scaled); - return computeVkX(Builtins.tailList(inputsCursor), Builtins.tailList(icCursor), nextVkX); - } - } -} diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java index 3062774..c622c4a 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java @@ -1,87 +1,36 @@ package com.bloxbean.cardano.zeroj.onchain.julc; import com.bloxbean.cardano.julc.core.PlutusData; -import com.bloxbean.cardano.julc.stdlib.Builtins; import com.bloxbean.cardano.julc.stdlib.annotation.Entrypoint; import com.bloxbean.cardano.julc.stdlib.annotation.Param; import com.bloxbean.cardano.julc.stdlib.annotation.SpendingValidator; -import com.bloxbean.cardano.julc.stdlib.lib.BlsLib; - -import java.math.BigInteger; /** - * Fixed two-public-input Groth16 BLS12-381 verifier as a Plutus V3 spending - * validator. + * On-chain Groth16 BLS12-381 verifier as a Plutus V3 spending validator. *

    - * Verification key points are baked in at deploy time via {@link Param}. - * The proof (piA, piB, piC) is passed as the redeemer (compressed BLS bytes). - * Public inputs are passed in the datum as a list of integers. + * Verification key points are baked in at deploy time. The full IC vector is + * passed as a Plutus list parameter, so this verifier supports any public-input + * count for Groth16 circuits over BLS12-381. *

    - * Verification equation: - *

    - *   finalVerify(
    - *     mulMlResult(millerLoop(A, B), millerLoop(-alpha, beta)),
    - *     mulMlResult(millerLoop(vk_x, gamma), millerLoop(C, delta))
    - *   )
    - * 
    - * - * @deprecated Use {@link Groth16BLS12381GenericVerifier}. This class is kept - * only for compatibility with older examples that baked exactly two public - * inputs as three separate IC parameters. + * Datum: list of public input integers in verification-key schema order. + * Redeemer: compressed Groth16 proof points. + * Parameter {@code vkIc}: list of compressed G1 IC points, where + * {@code len(vkIc) == len(publicInputs) + 1}. */ @SpendingValidator -@Deprecated public class Groth16BLS12381Verifier { - // VK points — compressed bytes baked at compile time @Param static byte[] vkAlpha; // G1 compressed 48 bytes @Param static byte[] vkBeta; // G2 compressed 96 bytes @Param static byte[] vkGamma; // G2 compressed 96 bytes @Param static byte[] vkDelta; // G2 compressed 96 bytes - @Param static byte[] vkIc0; // G1 compressed 48 bytes - @Param static byte[] vkIc1; // G1 compressed 48 bytes - @Param static byte[] vkIc2; // G1 compressed 48 bytes + @Param static PlutusData vkIc; // List of G1 compressed 48-byte IC points - /** - * Groth16 proof points (compressed BLS bytes), passed as redeemer. - */ record Groth16Proof(byte[] piA, byte[] piB, byte[] piC) {} @Entrypoint - static boolean validate(PlutusData datum, Groth16Proof proof, PlutusData ctx) { - // 1. Uncompress proof points - byte[] a = BlsLib.g1Uncompress(proof.piA()); - byte[] b = BlsLib.g2Uncompress(proof.piB()); - byte[] c = BlsLib.g1Uncompress(proof.piC()); - - // 2. Uncompress VK points - byte[] alpha = BlsLib.g1Uncompress(vkAlpha); - byte[] beta = BlsLib.g2Uncompress(vkBeta); - byte[] gamma = BlsLib.g2Uncompress(vkGamma); - byte[] delta = BlsLib.g2Uncompress(vkDelta); - byte[] ic0 = BlsLib.g1Uncompress(vkIc0); - byte[] ic1 = BlsLib.g1Uncompress(vkIc1); - byte[] ic2 = BlsLib.g1Uncompress(vkIc2); - - // 3. Extract public inputs from datum (list of integers) - PlutusData inputs = Builtins.unListData(datum); - BigInteger pub0 = Builtins.asInteger(Builtins.headList(inputs)); - BigInteger pub1 = Builtins.asInteger(Builtins.headList(Builtins.tailList(inputs))); - - // 4. Compute vk_x = IC[0] + pub[0]*IC[1] + pub[1]*IC[2] - byte[] s0 = BlsLib.g1ScalarMul(pub0, ic1); - byte[] s1 = BlsLib.g1ScalarMul(pub1, ic2); - byte[] vkX = BlsLib.g1Add(ic0, BlsLib.g1Add(s0, s1)); - - // 5. Groth16 pairing check - byte[] negAlpha = BlsLib.g1Neg(alpha); - byte[] lhs = BlsLib.mulMlResult( - BlsLib.millerLoop(a, b), - BlsLib.millerLoop(negAlpha, beta)); - byte[] rhs = BlsLib.mulMlResult( - BlsLib.millerLoop(vkX, gamma), - BlsLib.millerLoop(c, delta)); - - return BlsLib.finalVerify(lhs, rhs); + public static boolean validate(PlutusData datum, Groth16Proof proof, PlutusData ctx) { + return Groth16BLS12381.verify(datum, proof.piA(), proof.piB(), proof.piC(), + vkAlpha, vkBeta, vkGamma, vkDelta, vkIc); } } diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/PlonkBLS12381FullVerifier.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/PlonkBLS12381FullVerifier.java index e0deae2..f9bada4 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/PlonkBLS12381FullVerifier.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/PlonkBLS12381FullVerifier.java @@ -5,7 +5,6 @@ import com.bloxbean.cardano.julc.stdlib.annotation.Entrypoint; import com.bloxbean.cardano.julc.stdlib.annotation.Param; import com.bloxbean.cardano.julc.stdlib.annotation.SpendingValidator; -import com.bloxbean.cardano.julc.stdlib.lib.BlsLib; import java.math.BigInteger; @@ -147,31 +146,31 @@ static boolean validate(PlutusData datum, PlonkProof p, PlutusData ctx) { BigInteger r0 = pi.subtract(l1.multiply(alpha2).mod(fr)).mod(fr).subtract(e3).mod(fr); // ======== G1 operations ======== - byte[] qm = BlsLib.g1Uncompress(vkQm); - byte[] ql = BlsLib.g1Uncompress(vkQl); - byte[] qr = BlsLib.g1Uncompress(vkQr); - byte[] qo = BlsLib.g1Uncompress(vkQo); - byte[] qc = BlsLib.g1Uncompress(vkQc); - byte[] s3u = BlsLib.g1Uncompress(vkS3); - byte[] x2u = BlsLib.g2Uncompress(vkX2); - byte[] cA = BlsLib.g1Uncompress(p.cmA()); - byte[] cB = BlsLib.g1Uncompress(p.cmB()); - byte[] cC = BlsLib.g1Uncompress(p.cmC()); - byte[] cZ = BlsLib.g1Uncompress(p.cmZ()); - byte[] t1 = BlsLib.g1Uncompress(p.cmT1()); - byte[] t2 = BlsLib.g1Uncompress(p.cmT2()); - byte[] t3 = BlsLib.g1Uncompress(p.cmT3()); - byte[] wXi = BlsLib.g1Uncompress(p.wXi()); - byte[] wXiw = BlsLib.g1Uncompress(p.wXiw()); - byte[] g1 = BlsLib.g1Uncompress(g1Gen); - byte[] g2 = BlsLib.g2Uncompress(g2Gen); + byte[] qm = Builtins.bls12_381_G1_uncompress(vkQm); + byte[] ql = Builtins.bls12_381_G1_uncompress(vkQl); + byte[] qr = Builtins.bls12_381_G1_uncompress(vkQr); + byte[] qo = Builtins.bls12_381_G1_uncompress(vkQo); + byte[] qc = Builtins.bls12_381_G1_uncompress(vkQc); + byte[] s3u = Builtins.bls12_381_G1_uncompress(vkS3); + byte[] x2u = Builtins.bls12_381_G2_uncompress(vkX2); + byte[] cA = Builtins.bls12_381_G1_uncompress(p.cmA()); + byte[] cB = Builtins.bls12_381_G1_uncompress(p.cmB()); + byte[] cC = Builtins.bls12_381_G1_uncompress(p.cmC()); + byte[] cZ = Builtins.bls12_381_G1_uncompress(p.cmZ()); + byte[] t1 = Builtins.bls12_381_G1_uncompress(p.cmT1()); + byte[] t2 = Builtins.bls12_381_G1_uncompress(p.cmT2()); + byte[] t3 = Builtins.bls12_381_G1_uncompress(p.cmT3()); + byte[] wXi = Builtins.bls12_381_G1_uncompress(p.wXi()); + byte[] wXiw = Builtins.bls12_381_G1_uncompress(p.wXiw()); + byte[] g1 = Builtins.bls12_381_G1_uncompress(g1Gen); + byte[] g2 = Builtins.bls12_381_G2_uncompress(g2Gen); // ======== [D] ======== BigInteger ab = p.evalA().multiply(p.evalB()).mod(fr); - byte[] d1 = BlsLib.g1Add( - BlsLib.g1Add(BlsLib.g1ScalarMul(ab, qm), BlsLib.g1ScalarMul(p.evalA(), ql)), - BlsLib.g1Add(BlsLib.g1ScalarMul(p.evalB(), qr), - BlsLib.g1Add(BlsLib.g1ScalarMul(p.evalC(), qo), qc))); + byte[] d1 = Builtins.bls12_381_G1_add( + Builtins.bls12_381_G1_add(Builtins.bls12_381_G1_scalarMul(ab, qm), Builtins.bls12_381_G1_scalarMul(p.evalA(), ql)), + Builtins.bls12_381_G1_add(Builtins.bls12_381_G1_scalarMul(p.evalB(), qr), + Builtins.bls12_381_G1_add(Builtins.bls12_381_G1_scalarMul(p.evalC(), qo), qc))); BigInteger betaxi = beta.multiply(xi).mod(fr); BigInteger d2c = p.evalA().add(betaxi).add(gamma).mod(fr) @@ -179,18 +178,18 @@ static boolean validate(PlutusData datum, PlonkProof p, PlutusData ctx) { .multiply(p.evalC().add(betaxi.multiply(k2).mod(fr)).add(gamma).mod(fr)).mod(fr) .multiply(alpha).mod(fr) .add(l1.multiply(alpha2).mod(fr)).mod(fr); - byte[] d2 = BlsLib.g1ScalarMul(d2c, cZ); + byte[] d2 = Builtins.bls12_381_G1_scalarMul(d2c, cZ); BigInteger d3c = p.evalA().add(beta.multiply(p.evalS1()).mod(fr)).add(gamma).mod(fr) .multiply(p.evalB().add(beta.multiply(p.evalS2()).mod(fr)).add(gamma).mod(fr)).mod(fr) .multiply(alpha.multiply(beta).mod(fr).multiply(p.evalZw()).mod(fr)).mod(fr); - byte[] d3 = BlsLib.g1ScalarMul(d3c, s3u); + byte[] d3 = Builtins.bls12_381_G1_scalarMul(d3c, s3u); BigInteger xi4sq = xi4.multiply(xi4).mod(fr); - byte[] d4 = BlsLib.g1ScalarMul(zh, - BlsLib.g1Add(t1, BlsLib.g1Add(BlsLib.g1ScalarMul(xi4, t2), BlsLib.g1ScalarMul(xi4sq, t3)))); + byte[] d4 = Builtins.bls12_381_G1_scalarMul(zh, + Builtins.bls12_381_G1_add(t1, Builtins.bls12_381_G1_add(Builtins.bls12_381_G1_scalarMul(xi4, t2), Builtins.bls12_381_G1_scalarMul(xi4sq, t3)))); - byte[] dPt = BlsLib.g1Add(BlsLib.g1Add(d1, d2), BlsLib.g1Neg(BlsLib.g1Add(d3, d4))); + byte[] dPt = Builtins.bls12_381_G1_add(Builtins.bls12_381_G1_add(d1, d2), Builtins.bls12_381_G1_neg(Builtins.bls12_381_G1_add(d3, d4))); // ======== [F], [E], pairing ======== // (simplified — gnark's v/u challenges come from KZG fold, not PlonK transcript) diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ProverToCardano.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ProverToCardano.java index ae3c1f0..38326ce 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ProverToCardano.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ProverToCardano.java @@ -21,8 +21,7 @@ * *

    For production: Use this to convert proofs generated by the pure Java * prover ({@code Groth16ProverBLS381}) into the byte format expected by the - * on-chain verifiers ({@link Groth16BLS12381Verifier} and - * {@link Groth16BLS12381GenericVerifier}).

    + * on-chain verifier ({@link Groth16BLS12381Verifier}).

    */ public final class ProverToCardano { diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/CircomToOnChainE2ETest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/CircomToOnChainE2ETest.java index 95a07be..80df154 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/CircomToOnChainE2ETest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/CircomToOnChainE2ETest.java @@ -79,7 +79,7 @@ void circom_javaProve_onChainVerify() { byte[] piC = g1Compress(proof.c()); // Compile on-chain verifier with circom VK - var compiled = compileValidator(Groth16BLS12381GenericVerifier.class); + var compiled = compileValidator(Groth16BLS12381Verifier.class); var program = compiled.program().applyParams( PlutusData.bytes(vk.alpha()), PlutusData.bytes(vk.beta()), @@ -117,7 +117,7 @@ void circom_wrongWitness_onChainRejects() { byte[] piB = g2Compress(proof.b()); byte[] piC = g1Compress(proof.c()); - var compiled = compileValidator(Groth16BLS12381GenericVerifier.class); + var compiled = compileValidator(Groth16BLS12381Verifier.class); var program = compiled.program().applyParams( PlutusData.bytes(vk.alpha()), PlutusData.bytes(vk.beta()), diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381FirstInputBindingVerifier.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381FirstInputBindingVerifier.java new file mode 100644 index 0000000..c286351 --- /dev/null +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381FirstInputBindingVerifier.java @@ -0,0 +1,40 @@ +package com.bloxbean.cardano.zeroj.onchain.julc; + +import com.bloxbean.cardano.julc.core.PlutusData; +import com.bloxbean.cardano.julc.stdlib.Builtins; +import com.bloxbean.cardano.julc.stdlib.annotation.Entrypoint; +import com.bloxbean.cardano.julc.stdlib.annotation.Param; +import com.bloxbean.cardano.julc.stdlib.annotation.SpendingValidator; + +import java.math.BigInteger; + +/** + * Test-only custom validator demonstrating composition with Groth16BLS12381. + */ +@SpendingValidator +public class Groth16BLS12381FirstInputBindingVerifier { + + @Param static BigInteger expectedFirstPublicInput; + @Param static byte[] vkAlpha; + @Param static byte[] vkBeta; + @Param static byte[] vkGamma; + @Param static byte[] vkDelta; + @Param static PlutusData vkIc; + + record Groth16Proof(byte[] piA, byte[] piB, byte[] piC) {} + + @Entrypoint + public static boolean validate(PlutusData datum, Groth16Proof proof, PlutusData ctx) { + PlutusData inputs = Builtins.unListData(datum); + if (Builtins.nullList(inputs)) { + return false; + } + + BigInteger firstPublicInput = Builtins.asInteger(Builtins.headList(inputs)); + boolean domainRuleHolds = firstPublicInput.equals(expectedFirstPublicInput); + boolean proofValid = Groth16BLS12381.verify(datum, proof.piA(), proof.piB(), proof.piC(), + vkAlpha, vkBeta, vkGamma, vkDelta, vkIc); + + return domainRuleHolds && proofValid; + } +} diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifierTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifierTest.java deleted file mode 100644 index 0aae033..0000000 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381GenericVerifierTest.java +++ /dev/null @@ -1,216 +0,0 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; - -import com.bloxbean.cardano.julc.core.PlutusData; -import com.bloxbean.cardano.julc.testkit.ContractTest; -import com.bloxbean.cardano.julc.testkit.TestDataBuilder; -import com.bloxbean.cardano.zeroj.api.CurveId; -import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; -import com.bloxbean.cardano.zeroj.crypto.groth16.Groth16ProverBLS381; -import com.bloxbean.cardano.zeroj.crypto.setup.Groth16SetupBLS381; -import com.bloxbean.cardano.zeroj.crypto.setup.PowersOfTauBLS381; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Tests the arbitrary-public-input Groth16 BLS12-381 verifier in the Julc VM. - */ -class Groth16BLS12381GenericVerifierTest extends ContractTest { - - private static SnarkjsToCardano.VkCompressed sealedBidVk; - private static SnarkjsToCardano.ProofCompressed sealedBidProof; - private static List sealedBidPublicInputs; - private static TestProof threePublicInputs; - - @BeforeAll - static void setup() throws Exception { - String proofJson = loadResource("/test-circuits/sealed-bid-bls12381/proof.json"); - String vkJson = loadResource("/test-circuits/sealed-bid-bls12381/verification_key.json"); - String publicJson = loadResource("/test-circuits/sealed-bid-bls12381/public.json"); - - sealedBidVk = SnarkjsToCardano.parseVk(vkJson); - sealedBidProof = SnarkjsToCardano.parseProof(proofJson); - sealedBidPublicInputs = SnarkjsToCardano.parsePublicInputs(publicJson); - threePublicInputs = buildThreePublicInputProof(); - } - - @Test - void sealedBid_twoPublicInputs_passes() { - assertEquals(3, sealedBidVk.ic().size(), "IC must have public input count + 1 entries"); - - assertGenericVerification( - sealedBidVk, - sealedBidProof, - datum(sealedBidPublicInputs.get(0), sealedBidPublicInputs.get(1)), - vkIcData(sealedBidVk.ic()), - true); - } - - @Test - void threePublicInputs_pureJavaProof_passes() { - assertEquals(4, threePublicInputs.vk().ic().size(), "IC must have public input count + 1 entries"); - - assertGenericVerification( - threePublicInputs.vk(), - threePublicInputs.proof(), - datum(threePublicInputs.publicInputs()), - vkIcData(threePublicInputs.vk().ic()), - true); - } - - @Test - void threePublicInputs_wrongPublicInput_fails() { - BigInteger[] publicInputs = threePublicInputs.publicInputs().clone(); - publicInputs[2] = publicInputs[2].add(BigInteger.ONE); - - assertGenericVerification( - threePublicInputs.vk(), - threePublicInputs.proof(), - datum(publicInputs), - vkIcData(threePublicInputs.vk().ic()), - false); - } - - @Test - void threePublicInputs_tooFewPublicInputs_fails() { - assertGenericVerification( - threePublicInputs.vk(), - threePublicInputs.proof(), - datum(threePublicInputs.publicInputs()[0], threePublicInputs.publicInputs()[1]), - vkIcData(threePublicInputs.vk().ic()), - false); - } - - @Test - void threePublicInputs_tooManyPublicInputs_fails() { - assertGenericVerification( - threePublicInputs.vk(), - threePublicInputs.proof(), - datum( - threePublicInputs.publicInputs()[0], - threePublicInputs.publicInputs()[1], - threePublicInputs.publicInputs()[2], - BigInteger.ONE), - vkIcData(threePublicInputs.vk().ic()), - false); - } - - @Test - void emptyIcList_fails() { - assertGenericVerification( - threePublicInputs.vk(), - threePublicInputs.proof(), - datum(threePublicInputs.publicInputs()), - PlutusData.list(), - false); - } - - private void assertGenericVerification(SnarkjsToCardano.VkCompressed vk, - SnarkjsToCardano.ProofCompressed proof, - PlutusData datum, - PlutusData vkIc, - boolean expectSuccess) { - var compiled = compileValidator(Groth16BLS12381GenericVerifier.class); - var program = compiled.program().applyParams( - PlutusData.bytes(vk.alpha()), - PlutusData.bytes(vk.beta()), - PlutusData.bytes(vk.gamma()), - PlutusData.bytes(vk.delta()), - vkIc); - - var redeemer = PlutusData.constr(0, - PlutusData.bytes(proof.piA()), - PlutusData.bytes(proof.piB()), - PlutusData.bytes(proof.piC())); - - var txOutRef = TestDataBuilder.randomTxOutRef_typed(); - var ctx = spendingContext(txOutRef, datum) - .redeemer(redeemer) - .buildPlutusData(); - - var result = evaluate(program, ctx); - if (expectSuccess) { - assertSuccess(result); - System.out.println("[Groth16BLS12381GenericVerifier] Budget consumed: " + result.budgetConsumed()); - } else { - assertFailure(result); - } - } - - private static TestProof buildThreePublicInputProof() { - var circuit = CircuitBuilder.create("three-public-linear") - .publicVar("a") - .publicVar("b") - .publicVar("c") - .secretVar("x") - .define(api -> api.assertEqual( - api.add(api.mul(api.var("a"), api.var("x")), api.var("b")), - api.var("c"))); - - var r1cs = circuit.compileR1CS(CurveId.BLS12_381); - assertEquals(3, r1cs.numPublicInputs(), "test circuit should have three public inputs"); - - BigInteger[] witness = circuit.calculateWitness(Map.of( - "a", List.of(BigInteger.valueOf(3)), - "b", List.of(BigInteger.valueOf(4)), - "c", List.of(BigInteger.valueOf(25)), - "x", List.of(BigInteger.valueOf(7))), CurveId.BLS12_381); - - var srs = PowersOfTauBLS381.generate(8); - var setupResult = Groth16SetupBLS381.setup( - r1cs.constraints(), - r1cs.numWires(), - r1cs.numPublicInputs(), - srs.tauScalar()); - var proof = Groth16ProverBLS381.prove( - setupResult.provingKey(), - witness, - r1cs.constraints(), - r1cs.numWires()); - - assertTrue(proof.a().isOnCurve()); - assertTrue(proof.b().isOnCurve()); - assertTrue(proof.c().isOnCurve()); - - return new TestProof( - ProverToCardano.compressVk(setupResult), - ProverToCardano.compressProof(proof), - new BigInteger[] { witness[1], witness[2], witness[3] }); - } - - private static PlutusData datum(BigInteger... inputs) { - PlutusData[] values = new PlutusData[inputs.length]; - for (int i = 0; i < inputs.length; i++) { - values[i] = PlutusData.integer(inputs[i]); - } - return PlutusData.list(values); - } - - private static PlutusData vkIcData(List ic) { - List values = new ArrayList<>(); - for (byte[] point : ic) { - values.add(PlutusData.bytes(point)); - } - return PlutusData.list(values.toArray(new PlutusData[0])); - } - - private static String loadResource(String path) throws IOException { - try (var is = Groth16BLS12381GenericVerifierTest.class.getResourceAsStream(path)) { - if (is == null) throw new IOException("Resource not found: " + path); - return new String(is.readAllBytes(), StandardCharsets.UTF_8); - } - } - - private record TestProof(SnarkjsToCardano.VkCompressed vk, - SnarkjsToCardano.ProofCompressed proof, - BigInteger[] publicInputs) {} -} diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381PureJavaProverTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381PureJavaProverTest.java index 20a1e4a..26c43fb 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381PureJavaProverTest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381PureJavaProverTest.java @@ -34,7 +34,7 @@ class Groth16BLS12381PureJavaProverTest extends ContractTest { /** * 2-public-input circuit: pure Java prove → on-chain Julc VM verify. * - *

    Uses {@link Groth16BLS12381GenericVerifier}; the same verifier also + *

    Uses {@link Groth16BLS12381Verifier}; the same verifier also * supports circuits with more or fewer public inputs.

    * *

    Circuit: publicA * secretB = publicC (publicA and publicC are public).

    @@ -76,7 +76,7 @@ void twoPublicInputs_pureJavaProve_onChainVerify() { assertEquals(3, compressedVk.ic().size(), "IC should have numPublic+1 = 3 entries"); // Compile on-chain verifier with VK params - var compiled = compileValidator(Groth16BLS12381GenericVerifier.class); + var compiled = compileValidator(Groth16BLS12381Verifier.class); var program = compiled.program().applyParams( PlutusData.bytes(compressedVk.alpha()), PlutusData.bytes(compressedVk.beta()), diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java index 51bb52d..a1211c1 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java @@ -3,138 +3,177 @@ import com.bloxbean.cardano.julc.core.PlutusData; import com.bloxbean.cardano.julc.testkit.ContractTest; import com.bloxbean.cardano.julc.testkit.TestDataBuilder; +import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; +import com.bloxbean.cardano.zeroj.crypto.groth16.Groth16ProverBLS381; +import com.bloxbean.cardano.zeroj.crypto.setup.Groth16SetupBLS381; +import com.bloxbean.cardano.zeroj.crypto.setup.PowersOfTauBLS381; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.io.IOException; import java.math.BigInteger; +import java.nio.file.Path; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Compatibility tests for the deprecated fixed two-public-input Groth16 - * BLS12-381 verifier using the Julc VM. - *

    - * This demonstrates the full end-to-end flow: - * 1. Parse snarkjs JSON artifacts → compressed BLS12-381 bytes - * 2. Compile the on-chain verifier with VK params - * 3. Evaluate with proof redeemer and public inputs datum - * 4. Assert the pairing check passes in the Julc VM + * Tests the arbitrary-public-input Groth16 BLS12-381 verifier in the Julc VM. */ -@SuppressWarnings("deprecation") class Groth16BLS12381VerifierTest extends ContractTest { - private static SnarkjsToCardano.VkCompressed vk; - private static SnarkjsToCardano.ProofCompressed proof; - private static List publicInputs; + private static SnarkjsToCardano.VkCompressed sealedBidVk; + private static SnarkjsToCardano.ProofCompressed sealedBidProof; + private static List sealedBidPublicInputs; + private static TestProof threePublicInputs; @BeforeAll - static void loadTestVectors() throws Exception { + static void setup() throws Exception { String proofJson = loadResource("/test-circuits/sealed-bid-bls12381/proof.json"); String vkJson = loadResource("/test-circuits/sealed-bid-bls12381/verification_key.json"); String publicJson = loadResource("/test-circuits/sealed-bid-bls12381/public.json"); - vk = SnarkjsToCardano.parseVk(vkJson); - proof = SnarkjsToCardano.parseProof(proofJson); - publicInputs = SnarkjsToCardano.parsePublicInputs(publicJson); + sealedBidVk = SnarkjsToCardano.parseVk(vkJson); + sealedBidProof = SnarkjsToCardano.parseProof(proofJson); + sealedBidPublicInputs = SnarkjsToCardano.parsePublicInputs(publicJson); + threePublicInputs = buildThreePublicInputProof(); } @Test - void validProof_passes() { - // 1. Compile validator - var compiled = compileValidator(Groth16BLS12381Verifier.class); + void sealedBid_twoPublicInputs_passes() { + assertEquals(3, sealedBidVk.ic().size(), "IC must have public input count + 1 entries"); - // 2. Apply VK params (compressed bytes) - var program = compiled.program().applyParams( - PlutusData.bytes(vk.alpha()), - PlutusData.bytes(vk.beta()), - PlutusData.bytes(vk.gamma()), - PlutusData.bytes(vk.delta()), - PlutusData.bytes(vk.ic().get(0)), - PlutusData.bytes(vk.ic().get(1)), - PlutusData.bytes(vk.ic().get(2))); + assertVerification( + sealedBidVk, + sealedBidProof, + datum(sealedBidPublicInputs.get(0), sealedBidPublicInputs.get(1)), + vkIcData(sealedBidVk.ic()), + true); + } - // 3. Build redeemer: Groth16Proof record → Constr(0, [piA, piB, piC]) - var redeemer = PlutusData.constr(0, - PlutusData.bytes(proof.piA()), - PlutusData.bytes(proof.piB()), - PlutusData.bytes(proof.piC())); + @Test + void threePublicInputs_pureJavaProof_passes() { + assertEquals(4, threePublicInputs.vk().ic().size(), "IC must have public input count + 1 entries"); - // 4. Build datum: list of public inputs - var datum = PlutusData.list( - PlutusData.integer(publicInputs.get(0)), - PlutusData.integer(publicInputs.get(1))); + assertVerification( + threePublicInputs.vk(), + threePublicInputs.proof(), + datum(threePublicInputs.publicInputs()), + vkIcData(threePublicInputs.vk().ic()), + true); + } - // 5. Build spending context - var txOutRef = TestDataBuilder.randomTxOutRef_typed(); - var ctx = spendingContext(txOutRef, datum) - .redeemer(redeemer) - .buildPlutusData(); + @Test + void threePublicInputs_wrongPublicInput_fails() { + BigInteger[] publicInputs = threePublicInputs.publicInputs().clone(); + publicInputs[2] = publicInputs[2].add(BigInteger.ONE); - // 6. Evaluate - var result = evaluate(program, ctx); + assertVerification( + threePublicInputs.vk(), + threePublicInputs.proof(), + datum(publicInputs), + vkIcData(threePublicInputs.vk().ic()), + false); + } + + @Test + void threePublicInputs_tooFewPublicInputs_fails() { + assertVerification( + threePublicInputs.vk(), + threePublicInputs.proof(), + datum(threePublicInputs.publicInputs()[0], threePublicInputs.publicInputs()[1]), + vkIcData(threePublicInputs.vk().ic()), + false); + } - // 7. Assert - assertSuccess(result); - System.out.println("[validProof_passes] Budget consumed: " + result.budgetConsumed()); + @Test + void threePublicInputs_tooManyPublicInputs_fails() { + assertVerification( + threePublicInputs.vk(), + threePublicInputs.proof(), + datum( + threePublicInputs.publicInputs()[0], + threePublicInputs.publicInputs()[1], + threePublicInputs.publicInputs()[2], + BigInteger.ONE), + vkIcData(threePublicInputs.vk().ic()), + false); + } + + @Test + void emptyIcList_fails() { + assertVerification( + threePublicInputs.vk(), + threePublicInputs.proof(), + datum(threePublicInputs.publicInputs()), + PlutusData.list(), + false); + } + + @Test + void customValidator_composesGroth16LibraryWithDomainLogic_passes() { + assertFirstInputBindingVerification(sealedBidPublicInputs.get(0), true); } @Test - void invalidProof_fails() { - // Same as validProof but tamper one byte of piA + void customValidator_domainLogicFailure_fails() { + assertFirstInputBindingVerification(sealedBidPublicInputs.get(0).add(BigInteger.ONE), false); + } + + private void assertVerification(SnarkjsToCardano.VkCompressed vk, + SnarkjsToCardano.ProofCompressed proof, + PlutusData datum, + PlutusData vkIc, + boolean expectSuccess) { var compiled = compileValidator(Groth16BLS12381Verifier.class); var program = compiled.program().applyParams( PlutusData.bytes(vk.alpha()), PlutusData.bytes(vk.beta()), PlutusData.bytes(vk.gamma()), PlutusData.bytes(vk.delta()), - PlutusData.bytes(vk.ic().get(0)), - PlutusData.bytes(vk.ic().get(1)), - PlutusData.bytes(vk.ic().get(2))); - - // Tamper the proof: flip a byte in piA - byte[] tamperedPiA = proof.piA().clone(); - tamperedPiA[10] ^= 0xFF; + vkIc); var redeemer = PlutusData.constr(0, - PlutusData.bytes(tamperedPiA), + PlutusData.bytes(proof.piA()), PlutusData.bytes(proof.piB()), PlutusData.bytes(proof.piC())); - var datum = PlutusData.list( - PlutusData.integer(publicInputs.get(0)), - PlutusData.integer(publicInputs.get(1))); - var txOutRef = TestDataBuilder.randomTxOutRef_typed(); var ctx = spendingContext(txOutRef, datum) .redeemer(redeemer) .buildPlutusData(); var result = evaluate(program, ctx); - assertFailure(result); + if (expectSuccess) { + assertSuccess(result); + System.out.println("[Groth16BLS12381Verifier] Budget consumed: " + result.budgetConsumed()); + } else { + assertFailure(result); + } } - @Test - void wrongPublicInputs_fails() { - var compiled = compileValidator(Groth16BLS12381Verifier.class); + private void assertFirstInputBindingVerification(BigInteger expectedFirstPublicInput, + boolean expectSuccess) { + var compiled = compileValidator(Groth16BLS12381FirstInputBindingVerifier.class, + Path.of("src/test/java")); var program = compiled.program().applyParams( - PlutusData.bytes(vk.alpha()), - PlutusData.bytes(vk.beta()), - PlutusData.bytes(vk.gamma()), - PlutusData.bytes(vk.delta()), - PlutusData.bytes(vk.ic().get(0)), - PlutusData.bytes(vk.ic().get(1)), - PlutusData.bytes(vk.ic().get(2))); + PlutusData.integer(expectedFirstPublicInput), + PlutusData.bytes(sealedBidVk.alpha()), + PlutusData.bytes(sealedBidVk.beta()), + PlutusData.bytes(sealedBidVk.gamma()), + PlutusData.bytes(sealedBidVk.delta()), + vkIcData(sealedBidVk.ic())); + var datum = datum(sealedBidPublicInputs.get(0), sealedBidPublicInputs.get(1)); var redeemer = PlutusData.constr(0, - PlutusData.bytes(proof.piA()), - PlutusData.bytes(proof.piB()), - PlutusData.bytes(proof.piC())); - - // Wrong public inputs: use incorrect values - var datum = PlutusData.list( - PlutusData.integer(99), - PlutusData.integer(99)); + PlutusData.bytes(sealedBidProof.piA()), + PlutusData.bytes(sealedBidProof.piB()), + PlutusData.bytes(sealedBidProof.piC())); var txOutRef = TestDataBuilder.randomTxOutRef_typed(); var ctx = spendingContext(txOutRef, datum) @@ -142,7 +181,68 @@ void wrongPublicInputs_fails() { .buildPlutusData(); var result = evaluate(program, ctx); - assertFailure(result); + if (expectSuccess) { + assertSuccess(result); + } else { + assertFailure(result); + } + } + + private static TestProof buildThreePublicInputProof() { + var circuit = CircuitBuilder.create("three-public-linear") + .publicVar("a") + .publicVar("b") + .publicVar("c") + .secretVar("x") + .define(api -> api.assertEqual( + api.add(api.mul(api.var("a"), api.var("x")), api.var("b")), + api.var("c"))); + + var r1cs = circuit.compileR1CS(CurveId.BLS12_381); + assertEquals(3, r1cs.numPublicInputs(), "test circuit should have three public inputs"); + + BigInteger[] witness = circuit.calculateWitness(Map.of( + "a", List.of(BigInteger.valueOf(3)), + "b", List.of(BigInteger.valueOf(4)), + "c", List.of(BigInteger.valueOf(25)), + "x", List.of(BigInteger.valueOf(7))), CurveId.BLS12_381); + + var srs = PowersOfTauBLS381.generate(8); + var setupResult = Groth16SetupBLS381.setup( + r1cs.constraints(), + r1cs.numWires(), + r1cs.numPublicInputs(), + srs.tauScalar()); + var proof = Groth16ProverBLS381.prove( + setupResult.provingKey(), + witness, + r1cs.constraints(), + r1cs.numWires()); + + assertTrue(proof.a().isOnCurve()); + assertTrue(proof.b().isOnCurve()); + assertTrue(proof.c().isOnCurve()); + + return new TestProof( + ProverToCardano.compressVk(setupResult), + ProverToCardano.compressProof(proof), + new BigInteger[] { witness[1], witness[2], witness[3] }); + } + + private static PlutusData datum(BigInteger... inputs) { + PlutusData[] values = new PlutusData[inputs.length]; + for (int i = 0; i < inputs.length; i++) { + values[i] = PlutusData.integer(inputs[i]); + } + return PlutusData.list(values); + } + + private static PlutusData vkIcData(List ic) { + List values = new ArrayList<>(); + for (byte[] point : ic) { + values.add(PlutusData.bytes(point)); + } + return PlutusData.list(values.toArray(new PlutusData[0])); } private static String loadResource(String path) throws IOException { @@ -151,4 +251,8 @@ private static String loadResource(String path) throws IOException { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } } + + private record TestProof(SnarkjsToCardano.VkCompressed vk, + SnarkjsToCardano.ProofCompressed proof, + BigInteger[] publicInputs) {} } From 42aa9a00de11cd34b407c68a754b701f38714fa0 Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 21:14:12 +0800 Subject: [PATCH 19/26] refactor(onchain): organize julc packages by role --- .../cardano-gadget-support-matrix.md | 15 ++--- ...cardano-groth16-arbitrary-public-inputs.md | 22 ++++++-- docs/architecture-overview.md | 10 ++-- docs/getting-started.md | 3 + docs/pure-java-prover-guide.md | 3 + docs/usecases/private-voting.md | 4 +- .../auction/onchain/ZkAuctionVerifier.java | 6 +- .../dsl/PureJavaProverYaciE2ETest.java | 4 +- .../examples/dsl/common/ZkE2ETestBase.java | 2 +- zeroj-onchain-julc/README.md | 55 +++++++++++++++---- .../{ => analysis}/OnChainFeasibility.java | 2 +- .../{ => analysis}/ScriptBudgetEstimator.java | 2 +- .../ReferenceScriptDeployer.java | 2 +- .../{ => groth16/codec}/ProverToCardano.java | 3 +- .../{ => groth16/codec}/SnarkjsToCardano.java | 2 +- .../lib/Groth16BLS12381Lib.java} | 4 +- .../validator}/Groth16BLS12381Verifier.java | 5 +- .../validator}/PlonkBLS12381FullVerifier.java | 2 +- .../OnChainFeasibilityTest.java | 2 +- .../ScriptBudgetEstimatorTest.java | 2 +- .../ReferenceScriptDeployerTest.java | 2 +- .../validator}/CircomToOnChainE2ETest.java | 3 +- ...th16BLS12381FirstInputBindingVerifier.java | 7 ++- .../Groth16BLS12381PureJavaProverTest.java | 3 +- .../Groth16BLS12381VerifierTest.java | 4 +- .../PlonkBLS12381FullVerifierTest.java | 2 +- 26 files changed, 117 insertions(+), 54 deletions(-) rename zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => analysis}/OnChainFeasibility.java (98%) rename zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => analysis}/ScriptBudgetEstimator.java (98%) rename zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => deployment}/ReferenceScriptDeployer.java (95%) rename zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => groth16/codec}/ProverToCardano.java (96%) rename zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => groth16/codec}/SnarkjsToCardano.java (99%) rename zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/{Groth16BLS12381.java => groth16/lib/Groth16BLS12381Lib.java} (98%) rename zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => groth16/validator}/Groth16BLS12381Verifier.java (85%) rename zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => plonk/validator}/PlonkBLS12381FullVerifier.java (99%) rename zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => analysis}/OnChainFeasibilityTest.java (96%) rename zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => analysis}/ScriptBudgetEstimatorTest.java (96%) rename zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => deployment}/ReferenceScriptDeployerTest.java (96%) rename zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => groth16/validator}/CircomToOnChainE2ETest.java (98%) rename zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => groth16/validator}/Groth16BLS12381FirstInputBindingVerifier.java (82%) rename zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => groth16/validator}/Groth16BLS12381PureJavaProverTest.java (97%) rename zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => groth16/validator}/Groth16BLS12381VerifierTest.java (97%) rename zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/{ => plonk/validator}/PlonkBLS12381FullVerifierTest.java (99%) diff --git a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md index 71482f6..ed9819c 100644 --- a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -60,10 +60,10 @@ Annotated symbolic circuit Relevant source: -- `zeroj-onchain-julc/.../OnChainFeasibility.java` -- `zeroj-onchain-julc/.../Groth16BLS12381Verifier.java` -- `zeroj-onchain-julc/.../Groth16BLS12381.java` -- `zeroj-onchain-julc/.../PlonkBLS12381FullVerifier.java` +- `zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/OnChainFeasibility.java` +- `zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381Verifier.java` +- `zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/lib/Groth16BLS12381Lib.java` +- `zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/plonk/validator/PlonkBLS12381FullVerifier.java` - `zeroj-verifier-groth16/...` - `zeroj-verifier-plonk/...` - `incubator/zeroj-verifier-halo2/...` @@ -144,8 +144,9 @@ vk_x = IC[0] + pub[0] * IC[1] + ... + pub[n - 1] * IC[n] The verifier rejects empty `IC` lists and any mismatch where `len(IC) != len(publicInputs) + 1`. Custom validators compose the reusable -`Groth16BLS12381` `@OnchainLibrary` helper with their own domain checks. -possible future optimization if budget-critical circuits need lower script cost. +`Groth16BLS12381Lib` `@OnchainLibrary` helper with their own domain checks. +Generated fixed-count validators remain a possible future optimization if +budget-critical circuits need lower script cost. ### MiMC Is BN254-Only in the Circuit Library @@ -281,7 +282,7 @@ Tasks: - Added `Groth16BLS12381Verifier`. - Removed the old fixed two-input verifier before release. -- Added `Groth16BLS12381` as the reusable on-chain library helper. +- Added `Groth16BLS12381Lib` as the reusable on-chain library helper. - Preserved stable public-input order by consuming datum values positionally. - Added Julc VM budget output for two-input and three-input proofs. - Added tests for two inputs, more-than-two inputs, wrong values, too few diff --git a/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md b/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md index b12466e..f522746 100644 --- a/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md +++ b/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md @@ -52,7 +52,7 @@ checking and `vk_x` computation instead of a mutable `while` accumulator. Add a new reusable Julc spending validator: ```java -Groth16BLS12381Verifier +com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator.Groth16BLS12381Verifier ``` It will keep the same proof redeemer shape as the current fixed verifier: @@ -104,9 +104,17 @@ e(A, B) * e(-alpha, beta) == e(vk_x, gamma) * e(C, delta) Because ZeroJ has not been released yet, the fixed-input compatibility class was removed. `Groth16BLS12381Verifier` is the canonical arbitrary-input -validator, and `Groth16BLS12381` is the reusable `@OnchainLibrary` helper for +validator, and `Groth16BLS12381Lib` is the reusable `@OnchainLibrary` helper for custom validators. +The on-chain Julc module uses package names to separate roles: + +- `groth16.validator` for spending validators +- `groth16.lib` for reusable `@OnchainLibrary` code +- `groth16.codec` for off-chain proof/VK conversion helpers +- `plonk.validator` for PlonK validator prototypes +- `analysis` and `deployment` for planning/configuration helpers + ## Length Contract The canonical list-based verifier must reject malformed datum or @@ -194,6 +202,8 @@ var vkIcData = ListPlutusData.of( The script is loaded with five parameters: ```java +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator.Groth16BLS12381Verifier; + JulcScriptLoader.load( Groth16BLS12381Verifier.class, new BytesPlutusData(vk.alpha()), @@ -245,9 +255,11 @@ The final verifier removes the fixed two-input class and makes `Groth16BLS12381Verifier` the default. It accepts five script parameters: alpha, beta, gamma, delta, and the full `IC` list. -`Groth16BLS12381` is packaged as a JuLC `@OnchainLibrary` and bundled into the -published JAR under `META-INF/plutus-sources/`. Downstream custom validators -use their own local redeemer record and call `Groth16BLS12381.verify(...)`. +`Groth16BLS12381Lib` is packaged as a JuLC `@OnchainLibrary` and bundled into the +published JAR under +`META-INF/plutus-sources/com/bloxbean/cardano/zeroj/onchain/julc/groth16/lib/Groth16BLS12381Lib.java`. +Downstream custom validators use their own local redeemer record and call +`Groth16BLS12381Lib.verify(...)`. The proof record is intentionally not part of the reusable library surface because JuLC record resolution is validator-local today; keeping the record beside the validator avoids cross-module nested/top-level record resolution diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 1d1e61d..4aefd07 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -132,11 +132,11 @@ Anchoring verified results on L1: ### Layer 10: On-Chain Verification (`zeroj-onchain-julc`) Reusable Plutus V3 spending validators compiled via Julc: -- `Groth16BLS12381Verifier` -- on-chain Groth16 verification using BLS12-381 builtins and arbitrary public-input counts -- `Groth16BLS12381` -- reusable `@OnchainLibrary` Groth16 verification helper for custom validators -- `PlonkBLS12381FullVerifier` -- experimental on-chain PlonK prototype with Fiat-Shamir transcript and inverse checks; KZG pairing check deferred -- `SnarkjsToCardano` -- converts snarkjs JSON to BLS compressed bytes for on-chain use -- `ScriptBudgetEstimator`, `OnChainFeasibility`, `ReferenceScriptDeployer` -- on-chain budget and deployment helpers +- `groth16.validator.Groth16BLS12381Verifier` -- on-chain Groth16 verification using BLS12-381 builtins and arbitrary public-input counts +- `groth16.lib.Groth16BLS12381Lib` -- reusable `@OnchainLibrary` Groth16 verification helper for custom validators +- `groth16.codec.SnarkjsToCardano` and `groth16.codec.ProverToCardano` -- convert proof/VK artifacts to BLS compressed bytes for on-chain use +- `plonk.validator.PlonkBLS12381FullVerifier` -- experimental on-chain PlonK prototype with Fiat-Shamir transcript and inverse checks; KZG pairing check deferred +- `analysis.ScriptBudgetEstimator`, `analysis.OnChainFeasibility`, `deployment.ReferenceScriptDeployer` -- on-chain budget and deployment helpers ## Crypto Backend Strategy diff --git a/docs/getting-started.md b/docs/getting-started.md index 531c24f..dd08c20 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -130,6 +130,9 @@ The complete helper is shown in `PureJavaProverYaciE2ETest`; it computes the Gro ZeroJ includes reusable Plutus V3 validators compiled from Java via Julc. The VK is baked into the script at deploy time: ```java +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec.ProverToCardano; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator.Groth16BLS12381Verifier; + // Compress proof + VK for on-chain BLS format var compressedVk = ProverToCardano.compressVk(setupResult); var compressedProof = ProverToCardano.compressProof(proof); diff --git a/docs/pure-java-prover-guide.md b/docs/pure-java-prover-guide.md index a2fd2aa..4cb0da0 100644 --- a/docs/pure-java-prover-guide.md +++ b/docs/pure-java-prover-guide.md @@ -219,6 +219,9 @@ assert valid; // Cryptographic verification passed! ### Step 6: Verify On-Chain (Cardano Plutus V3) ```java +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec.ProverToCardano; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator.Groth16BLS12381Verifier; + // Compress proof + VK for on-chain BLS format var compressedVk = ProverToCardano.compressVk(setup); var compressedProof = ProverToCardano.compressProof(proof); diff --git a/docs/usecases/private-voting.md b/docs/usecases/private-voting.md index 2431e92..9cf8ed7 100644 --- a/docs/usecases/private-voting.md +++ b/docs/usecases/private-voting.md @@ -296,6 +296,8 @@ Two scripts work together: ### Minting Policy — Validates ZK Proof ```java +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.lib.Groth16BLS12381Lib; + @MintingValidator public class VoteMintingPolicy { @@ -313,7 +315,7 @@ public class VoteMintingPolicy { @Entrypoint static boolean validate(VoteProof redeemer, PlutusData ctx) { // 1. Groth16 BLS12-381 pairing check - boolean proofValid = Groth16BLS12381.verify(publicInputs, + boolean proofValid = Groth16BLS12381Lib.verify(publicInputs, redeemer.piA(), redeemer.piB(), redeemer.piC(), vkAlpha, vkBeta, vkGamma, vkDelta, vkIc); diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/onchain/ZkAuctionVerifier.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/onchain/ZkAuctionVerifier.java index a9599d0..a0de28a 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/onchain/ZkAuctionVerifier.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/onchain/ZkAuctionVerifier.java @@ -5,7 +5,7 @@ import com.bloxbean.cardano.julc.stdlib.annotation.Entrypoint; import com.bloxbean.cardano.julc.stdlib.annotation.Param; import com.bloxbean.cardano.julc.stdlib.annotation.SpendingValidator; -import com.bloxbean.cardano.zeroj.onchain.julc.Groth16BLS12381; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.lib.Groth16BLS12381Lib; import java.math.BigInteger; @@ -21,7 +21,7 @@ * The bidCommitment is stored in the datum (set during bidding phase). * The reservePrice is a validator parameter (set by the auctioneer at deploy time). *

    - * This domain-specific verifier composes the reusable {@link Groth16BLS12381} + * This domain-specific verifier composes the reusable {@link Groth16BLS12381Lib} * on-chain library with a {@code reservePriceBytes} binding parameter. */ @SpendingValidator @@ -45,7 +45,7 @@ static boolean validate(PlutusData datum, Groth16Proof proof, PlutusData ctx) { BigInteger committedReserve = Builtins.byteStringToInteger(true, reservePriceBytes); boolean reserveMatches = pub1.equals(committedReserve); - boolean proofValid = Groth16BLS12381.verify(datum, proof.piA(), proof.piB(), proof.piC(), + boolean proofValid = Groth16BLS12381Lib.verify(datum, proof.piA(), proof.piB(), proof.piC(), vkAlpha, vkBeta, vkGamma, vkDelta, vkIc); return reserveMatches && proofValid; diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java index 6f11697..a1a81fb 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/PureJavaProverYaciE2ETest.java @@ -15,9 +15,9 @@ import com.bloxbean.cardano.zeroj.crypto.groth16.Groth16ProverBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.Groth16SetupBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.PowersOfTauBLS381; -import com.bloxbean.cardano.zeroj.onchain.julc.ProverToCardano; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec.ProverToCardano; import com.bloxbean.cardano.zeroj.examples.dsl.common.YaciHelper; -import com.bloxbean.cardano.zeroj.onchain.julc.Groth16BLS12381Verifier; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator.Groth16BLS12381Verifier; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/common/ZkE2ETestBase.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/common/ZkE2ETestBase.java index 8af07c5..ce66be5 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/common/ZkE2ETestBase.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/common/ZkE2ETestBase.java @@ -7,7 +7,7 @@ import com.bloxbean.cardano.client.function.helper.SignerProviders; import com.bloxbean.cardano.client.quicktx.QuickTxBuilder; import com.bloxbean.cardano.client.quicktx.Tx; -import com.bloxbean.cardano.zeroj.onchain.julc.SnarkjsToCardano; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec.SnarkjsToCardano; import java.io.IOException; import java.math.BigInteger; diff --git a/zeroj-onchain-julc/README.md b/zeroj-onchain-julc/README.md index 53fe362..bfbfbb6 100644 --- a/zeroj-onchain-julc/README.md +++ b/zeroj-onchain-julc/README.md @@ -10,16 +10,14 @@ V3. ## Current Status -| Validator / Helper | Status | Notes | -|--------------------|--------|-------| -| `Groth16BLS12381Verifier` | Working | Default BLS12-381 Groth16 verifier using Plutus V3 BLS builtins; supports arbitrary public-input counts | -| `Groth16BLS12381` | Working library | Reusable `@OnchainLibrary` proof verification helper for custom validators | -| `PlonkBLS12381FullVerifier` | Experimental prototype | Re-derives transcript and checks inverse constraints; KZG batch opening pairing check is deferred | -| `SnarkjsToCardano` | Working helper | Converts snarkjs Groth16 JSON points to Cardano-compatible compressed bytes | -| `ProverToCardano` | Working helper | Converts ZeroJ prover artifacts to on-chain data shapes | -| `ScriptBudgetEstimator` | Planning helper | Estimates Plutus CPU/memory budgets for supported combinations | -| `OnChainFeasibility` | Planning helper | Matrix for proof system / curve support on Cardano | -| `ReferenceScriptDeployer` | Config helper | Describes CIP-0033 reference-script deployment patterns; does not submit transactions | +| Package | Contents | Status | Notes | +|---------|----------|--------|-------| +| `com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator` | `Groth16BLS12381Verifier` | Working validator | Default BLS12-381 Groth16 spending validator using Plutus V3 BLS builtins; supports arbitrary public-input counts | +| `com.bloxbean.cardano.zeroj.onchain.julc.groth16.lib` | `Groth16BLS12381Lib` | Working on-chain library | Reusable `@OnchainLibrary` proof verification helper for custom validators | +| `com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec` | `SnarkjsToCardano`, `ProverToCardano` | Working off-chain helpers | Convert snarkjs and ZeroJ Groth16 artifacts to Cardano-compatible compressed bytes and Plutus data shapes | +| `com.bloxbean.cardano.zeroj.onchain.julc.plonk.validator` | `PlonkBLS12381FullVerifier` | Experimental validator prototype | Re-derives transcript and checks inverse constraints; KZG batch opening pairing check is deferred | +| `com.bloxbean.cardano.zeroj.onchain.julc.analysis` | `ScriptBudgetEstimator`, `OnChainFeasibility` | Planning helpers | Estimate Plutus CPU/memory budgets and report proof system / curve feasibility | +| `com.bloxbean.cardano.zeroj.onchain.julc.deployment` | `ReferenceScriptDeployer` | Config helper | Describes CIP-0033 reference-script deployment patterns; does not submit transactions | ## Why It Is Useful @@ -40,6 +38,43 @@ The tests run validators in the Julc VM and include Groth16 positive/negative checks, pure Java Groth16 prover to on-chain verification, budget estimation, and the PlonK prototype transcript/inverse-check path. +## Imports + +Use package names by role: + +```java +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator.Groth16BLS12381Verifier; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.lib.Groth16BLS12381Lib; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec.ProverToCardano; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec.SnarkjsToCardano; +``` + +Custom validators should define their own local redeemer record and compose the +shared library: + +```java +@SpendingValidator +public class MyVerifier { + @Param static byte[] vkAlpha; + @Param static byte[] vkBeta; + @Param static byte[] vkGamma; + @Param static byte[] vkDelta; + @Param static PlutusData vkIc; + + record Groth16Proof(byte[] piA, byte[] piB, byte[] piC) {} + + @Entrypoint + static boolean validate(PlutusData datum, Groth16Proof proof, PlutusData ctx) { + return Groth16BLS12381Lib.verify(datum, proof.piA(), proof.piB(), proof.piC(), + vkAlpha, vkBeta, vkGamma, vkDelta, vkIc); + } +} +``` + +The proof record is kept validator-local because Julc record decoding is +validator-local today. Sharing `Groth16BLS12381Lib` still avoids duplicating the +pairing and public-input folding logic. + ## Gradle ```gradle diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/OnChainFeasibility.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/OnChainFeasibility.java similarity index 98% rename from zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/OnChainFeasibility.java rename to zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/OnChainFeasibility.java index fad0a08..70e7536 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/OnChainFeasibility.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/OnChainFeasibility.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.analysis; import com.bloxbean.cardano.zeroj.api.CurveId; import com.bloxbean.cardano.zeroj.api.ProofSystemId; diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ScriptBudgetEstimator.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/ScriptBudgetEstimator.java similarity index 98% rename from zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ScriptBudgetEstimator.java rename to zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/ScriptBudgetEstimator.java index e4ed253..4ed218d 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ScriptBudgetEstimator.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/ScriptBudgetEstimator.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.analysis; import com.bloxbean.cardano.zeroj.api.CurveId; import com.bloxbean.cardano.zeroj.api.ProofSystemId; diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ReferenceScriptDeployer.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/deployment/ReferenceScriptDeployer.java similarity index 95% rename from zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ReferenceScriptDeployer.java rename to zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/deployment/ReferenceScriptDeployer.java index ee4499d..884a478 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ReferenceScriptDeployer.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/deployment/ReferenceScriptDeployer.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.deployment; /** * Structured deployment configuration for Cardano CIP-0033 reference scripts. diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ProverToCardano.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/codec/ProverToCardano.java similarity index 96% rename from zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ProverToCardano.java rename to zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/codec/ProverToCardano.java index 38326ce..a95236a 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/ProverToCardano.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/codec/ProverToCardano.java @@ -1,9 +1,10 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec; import com.bloxbean.cardano.zeroj.bls12381.ec.JacobianG1BLS381; import com.bloxbean.cardano.zeroj.bls12381.ec.JacobianG2BLS381; import com.bloxbean.cardano.zeroj.crypto.groth16.Groth16ProofBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.Groth16SetupBLS381; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator.Groth16BLS12381Verifier; import supranational.blst.P1_Affine; import supranational.blst.P2_Affine; diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/SnarkjsToCardano.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/codec/SnarkjsToCardano.java similarity index 99% rename from zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/SnarkjsToCardano.java rename to zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/codec/SnarkjsToCardano.java index c9ef84f..72eab3e 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/SnarkjsToCardano.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/codec/SnarkjsToCardano.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/lib/Groth16BLS12381Lib.java similarity index 98% rename from zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381.java rename to zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/lib/Groth16BLS12381Lib.java index 0ae8066..d1c2af1 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/lib/Groth16BLS12381Lib.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.groth16.lib; import com.bloxbean.cardano.julc.core.PlutusData; import com.bloxbean.cardano.julc.stdlib.Builtins; @@ -10,7 +10,7 @@ * Reusable on-chain Groth16 verifier logic for BLS12-381 proofs. */ @OnchainLibrary -public class Groth16BLS12381 { +public class Groth16BLS12381Lib { public static PlutusData publicInputs(BigInteger pub0) { return Builtins.listData(Builtins.mkCons( diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381Verifier.java similarity index 85% rename from zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java rename to zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381Verifier.java index c622c4a..a42874d 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381Verifier.java @@ -1,9 +1,10 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator; import com.bloxbean.cardano.julc.core.PlutusData; import com.bloxbean.cardano.julc.stdlib.annotation.Entrypoint; import com.bloxbean.cardano.julc.stdlib.annotation.Param; import com.bloxbean.cardano.julc.stdlib.annotation.SpendingValidator; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.lib.Groth16BLS12381Lib; /** * On-chain Groth16 BLS12-381 verifier as a Plutus V3 spending validator. @@ -30,7 +31,7 @@ record Groth16Proof(byte[] piA, byte[] piB, byte[] piC) {} @Entrypoint public static boolean validate(PlutusData datum, Groth16Proof proof, PlutusData ctx) { - return Groth16BLS12381.verify(datum, proof.piA(), proof.piB(), proof.piC(), + return Groth16BLS12381Lib.verify(datum, proof.piA(), proof.piB(), proof.piC(), vkAlpha, vkBeta, vkGamma, vkDelta, vkIc); } } diff --git a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/PlonkBLS12381FullVerifier.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/plonk/validator/PlonkBLS12381FullVerifier.java similarity index 99% rename from zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/PlonkBLS12381FullVerifier.java rename to zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/plonk/validator/PlonkBLS12381FullVerifier.java index f9bada4..df4d123 100644 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/PlonkBLS12381FullVerifier.java +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/plonk/validator/PlonkBLS12381FullVerifier.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.plonk.validator; import com.bloxbean.cardano.julc.core.PlutusData; import com.bloxbean.cardano.julc.stdlib.Builtins; diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/OnChainFeasibilityTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/OnChainFeasibilityTest.java similarity index 96% rename from zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/OnChainFeasibilityTest.java rename to zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/OnChainFeasibilityTest.java index d76adfb..a2bdb65 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/OnChainFeasibilityTest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/OnChainFeasibilityTest.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.analysis; import com.bloxbean.cardano.zeroj.api.CurveId; import com.bloxbean.cardano.zeroj.api.ProofSystemId; diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/ScriptBudgetEstimatorTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/ScriptBudgetEstimatorTest.java similarity index 96% rename from zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/ScriptBudgetEstimatorTest.java rename to zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/ScriptBudgetEstimatorTest.java index 2d48197..4055da5 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/ScriptBudgetEstimatorTest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/analysis/ScriptBudgetEstimatorTest.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.analysis; import com.bloxbean.cardano.zeroj.api.CurveId; import com.bloxbean.cardano.zeroj.api.ProofSystemId; diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/ReferenceScriptDeployerTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/deployment/ReferenceScriptDeployerTest.java similarity index 96% rename from zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/ReferenceScriptDeployerTest.java rename to zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/deployment/ReferenceScriptDeployerTest.java index fcabdcc..9512c56 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/ReferenceScriptDeployerTest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/deployment/ReferenceScriptDeployerTest.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.deployment; import org.junit.jupiter.api.Test; diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/CircomToOnChainE2ETest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/CircomToOnChainE2ETest.java similarity index 98% rename from zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/CircomToOnChainE2ETest.java rename to zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/CircomToOnChainE2ETest.java index 80df154..0d9dc3f 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/CircomToOnChainE2ETest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/CircomToOnChainE2ETest.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator; import com.bloxbean.cardano.zeroj.bls12381.ec.JacobianG1BLS381; import com.bloxbean.cardano.zeroj.bls12381.ec.JacobianG2BLS381; @@ -7,6 +7,7 @@ import com.bloxbean.cardano.julc.core.PlutusData; import com.bloxbean.cardano.julc.testkit.ContractTest; import com.bloxbean.cardano.julc.testkit.TestDataBuilder; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec.SnarkjsToCardano; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import supranational.blst.P1_Affine; diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381FirstInputBindingVerifier.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381FirstInputBindingVerifier.java similarity index 82% rename from zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381FirstInputBindingVerifier.java rename to zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381FirstInputBindingVerifier.java index c286351..b46097d 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381FirstInputBindingVerifier.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381FirstInputBindingVerifier.java @@ -1,15 +1,16 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator; import com.bloxbean.cardano.julc.core.PlutusData; import com.bloxbean.cardano.julc.stdlib.Builtins; import com.bloxbean.cardano.julc.stdlib.annotation.Entrypoint; import com.bloxbean.cardano.julc.stdlib.annotation.Param; import com.bloxbean.cardano.julc.stdlib.annotation.SpendingValidator; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.lib.Groth16BLS12381Lib; import java.math.BigInteger; /** - * Test-only custom validator demonstrating composition with Groth16BLS12381. + * Test-only custom validator demonstrating composition with Groth16BLS12381Lib. */ @SpendingValidator public class Groth16BLS12381FirstInputBindingVerifier { @@ -32,7 +33,7 @@ public static boolean validate(PlutusData datum, Groth16Proof proof, PlutusData BigInteger firstPublicInput = Builtins.asInteger(Builtins.headList(inputs)); boolean domainRuleHolds = firstPublicInput.equals(expectedFirstPublicInput); - boolean proofValid = Groth16BLS12381.verify(datum, proof.piA(), proof.piB(), proof.piC(), + boolean proofValid = Groth16BLS12381Lib.verify(datum, proof.piA(), proof.piB(), proof.piC(), vkAlpha, vkBeta, vkGamma, vkDelta, vkIc); return domainRuleHolds && proofValid; diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381PureJavaProverTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381PureJavaProverTest.java similarity index 97% rename from zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381PureJavaProverTest.java rename to zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381PureJavaProverTest.java index 26c43fb..4f229fd 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381PureJavaProverTest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381PureJavaProverTest.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator; import com.bloxbean.cardano.zeroj.api.CurveId; import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; @@ -11,6 +11,7 @@ import com.bloxbean.cardano.julc.core.PlutusData; import com.bloxbean.cardano.julc.testkit.ContractTest; import com.bloxbean.cardano.julc.testkit.TestDataBuilder; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec.SnarkjsToCardano; import org.junit.jupiter.api.Test; import supranational.blst.P1_Affine; import supranational.blst.P2_Affine; diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381VerifierTest.java similarity index 97% rename from zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java rename to zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381VerifierTest.java index a1211c1..6ea3cce 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381VerifierTest.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator; import com.bloxbean.cardano.julc.core.PlutusData; import com.bloxbean.cardano.julc.testkit.ContractTest; @@ -8,6 +8,8 @@ import com.bloxbean.cardano.zeroj.crypto.groth16.Groth16ProverBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.Groth16SetupBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.PowersOfTauBLS381; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec.ProverToCardano; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.codec.SnarkjsToCardano; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; diff --git a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/PlonkBLS12381FullVerifierTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/plonk/validator/PlonkBLS12381FullVerifierTest.java similarity index 99% rename from zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/PlonkBLS12381FullVerifierTest.java rename to zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/plonk/validator/PlonkBLS12381FullVerifierTest.java index e41cfc1..e77a503 100644 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/PlonkBLS12381FullVerifierTest.java +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/plonk/validator/PlonkBLS12381FullVerifierTest.java @@ -1,4 +1,4 @@ -package com.bloxbean.cardano.zeroj.onchain.julc; +package com.bloxbean.cardano.zeroj.onchain.julc.plonk.validator; import com.bloxbean.cardano.julc.core.PlutusData; import com.bloxbean.cardano.julc.testkit.ContractTest; From 029cd505fad5b9fbe9c5a61808fb447236de446e Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 22:10:28 +0800 Subject: [PATCH 20/26] refactor(examples): use BLS12-381 Poseidon for Cardano paths --- docs/adr/circuit-annotation/README.md | 6 +- .../cardano-gadget-support-matrix.md | 29 ++-- .../example-migration-bls-poseidon.md | 137 ++++++++++++++++++ .../circuit-annotation/implementation-plan.md | 5 +- docs/circuit-dsl-user-guide.md | 52 +++++-- .../annotation/AnnotatedAnonymousVoting.java | 5 +- .../annotation/AnnotatedHashCommitment.java | 6 + .../annotation/AnnotatedSealedBid.java | 16 +- .../dsl/auction/SealedBidCircuit.java | 30 ++-- .../dsl/auction/SealedBidProofHelper.java | 31 ++-- .../zeroj/examples/dsl/common/MiMCHash.java | 7 +- .../dsl/templates/HashChainCircuit.java | 9 +- .../MultiInputCommitmentCircuit.java | 13 +- .../dsl/templates/NWayMerkleCircuit.java | 7 +- .../dsl/voting/AnonymousVotingCircuit.java | 12 +- .../voting/AnonymousVotingProofHelper.java | 16 +- .../AnnotatedCircuitExamplesTest.java | 55 ++++--- .../dsl/auction/SealedBidCircuitTest.java | 87 +++-------- .../dsl/auction/SealedBidE2ETest.java | 40 ++--- .../dsl/auction/SealedBidPureJavaE2ETest.java | 12 +- .../ParameterizedCircuitE2ETest.java | 54 ++++--- .../dsl/voting/AnonymousVotingE2ETest.java | 2 +- .../AnonymousVotingPureJavaE2ETest.java | 17 +-- 23 files changed, 381 insertions(+), 267 deletions(-) create mode 100644 docs/adr/circuit-annotation/example-migration-bls-poseidon.md diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index 713c6de..0ad3a53 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -1212,7 +1212,11 @@ public class VoteCommitment { @Secret ZkField nullifier, @Public ZkField commitment) { - return ZkMiMC.hash(zk, vote.asField(), nullifier).isEqual(commitment); + return ZkPoseidon.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + vote.asField(), + nullifier).isEqual(commitment); } } ``` diff --git a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md index ed9819c..0c424f5 100644 --- a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -2,7 +2,7 @@ ## Status -Accepted follow-up plan. Priorities 1 through 4 are completed in the current +Accepted follow-up plan. Priorities 1 through 5 are completed in the current code and docs. ## Date @@ -297,16 +297,24 @@ Exit criteria: ### Phase E: Example Migration +Status: completed. The implementation is tracked in +[`example-migration-bls-poseidon.md`](example-migration-bls-poseidon.md). + Goal: align reference examples with the Cardano support model. Tasks: -- Add BLS12-381 Poseidon variants for sealed bid, anonymous voting, hash chain, - multi-input commitment, and Merkle membership where they are meant to be - Cardano examples. -- Leave BN254 MiMC examples in place only when they are explicitly labeled +- Migrated sealed bid, anonymous voting, hash chain, and multi-input + commitment examples to explicit BLS12-381 Poseidon. +- Kept Merkle `HashType.MIMC` only as an explicitly BN254/off-chain template + path and use params-aware BLS12-381 Poseidon for Cardano Merkle tests. +- Migrated annotated sealed bid and annotated anonymous voting to explicit + BLS12-381 `ZkPoseidon`. +- Migrated the `zeroj-usecases` annotated private voting, proof-of-reserves, + and compliance credential examples to explicit BLS12-381 Poseidon. +- Left BN254 MiMC examples in place only when they are explicitly labeled off-chain/BN254. -- Make tests use `FieldConfig.BN254` only for MiMC examples and +- Made tests use `FieldConfig.BN254` only for MiMC examples and `FieldConfig.BLS12_381` for Cardano examples. Exit criteria: @@ -377,7 +385,7 @@ Exit criteria: 2. `ZkPoseidonN`. Completed. 3. Params-aware BLS12-381 `ZkMerkle`. Completed. 4. Generic/generated Cardano Groth16 verifier for arbitrary public-input count. Completed. -5. Example migration to BLS12-381 Poseidon. +5. Example migration to BLS12-381 Poseidon. Completed. 6. Nested `ZkArray>` support. 7. Optional BLS12-381 MiMC only if a real integration requires it. @@ -418,16 +426,9 @@ Negative: - Documentation must distinguish between "usable in annotated circuits" and "usable for Cardano on-chain verification." -- Some existing MiMC examples need migration or clearer labels. - A generalized/generated on-chain verifier adds a new implementation slice. ## Open Questions -- Should the existing no-params two-input `ZkPoseidon` overload remain visible - in Cardano-facing docs, or should all Cardano docs require explicit params? -- Should `ZkMerkle.HashType.POSEIDON` be deprecated in favor of explicit - parameterized factories? -- Should the on-chain Groth16 verifier be generated per circuit, or should a - generic list-based verifier be implemented first? - Do any target partner ecosystems require MiMC over BLS12-381, or is Poseidon sufficient for all near-term Cardano use cases? diff --git a/docs/adr/circuit-annotation/example-migration-bls-poseidon.md b/docs/adr/circuit-annotation/example-migration-bls-poseidon.md new file mode 100644 index 0000000..5b42cdc --- /dev/null +++ b/docs/adr/circuit-annotation/example-migration-bls-poseidon.md @@ -0,0 +1,137 @@ +# ADR: Example Migration to BLS12-381 Poseidon + +## Status + +Implemented. + +## Date + +2026-05-18 + +## Context + +The Cardano gadget support matrix makes BLS12-381 Groth16 the default on-chain +path. It also makes one important hash distinction: + +- MiMC in the current circuit library is BN254-only. +- Poseidon supports BLS12-381 when callers pass explicit BLS12-381 params. + +Some reference examples still looked Cardano-facing while using MiMC or a +BN254/default hash path. That is confusing because those examples either fail +compile-time field checks on `CurveId.BLS12_381` or suggest a hash choice that is +not the recommended Cardano path. + +## Decision + +Migrate Cardano-facing examples to explicit BLS12-381 Poseidon: + +- sealed-bid auction +- anonymous voting +- hash chain template +- multi-input commitment template +- Merkle membership template when used as a Cardano example +- annotated sealed bid +- annotated anonymous voting +- `zeroj-usecases` annotated private voting +- `zeroj-usecases` annotated proof of reserves +- `zeroj-usecases` annotated compliance credential + +Keep MiMC examples only when they are explicitly BN254/off-chain: + +- `AnnotatedHashCommitment` remains the small MiMC adapter example. +- `AnnotatedMerkleMembership` with `HashType.MIMC` remains the parameterized + BN254/off-chain Merkle example. +- `NWayMerkleCircuit.HashType.MIMC` remains available for BN254/off-chain + template demonstrations. + +## Design + +Cardano-facing DSL examples call: + +```java +SignalPoseidon.hash(c, PoseidonParamsBLS12_381T3.INSTANCE, left, right) +``` + +Cardano-facing annotated examples call: + +```java +ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, left, right) +``` + +Variable-arity commitments use the already-shipped explicit params API: + +```java +ZkPoseidonN.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, a, b, c) +``` + +Off-circuit expected values are computed with: + +```java +PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, left, right) +PoseidonHash.hashN(PoseidonParamsBLS12_381T3.INSTANCE, values...) +``` + +This keeps the compile field visible at every hash call site. The no-params +Poseidon overload remains available for backward-compatible BN254 usage, but it +is not used by Cardano-facing examples. + +## Sealed Bid Shape + +The sealed-bid example now proves the reserve condition inside the circuit +instead of exposing a public `isAboveReserve` flag. + +Public inputs: + +```text +[bidCommitment, reservePrice] +``` + +Private inputs: + +```text +[bidAmount, salt] +``` + +Constraints: + +```text +bidCommitment == PoseidonBLS12_381(bidAmount, salt) +bidAmount >= reservePrice +``` + +This matches the on-chain auction validator flow where the datum contains only +the proof public inputs and the validator independently binds the reserve price +parameter to the public reserve value. + +## Testing + +Tests must prove or assert: + +- Cardano-facing examples compile and calculate witnesses on `CurveId.BLS12_381`. +- Cardano-facing examples reject `CurveId.BN254` because their Poseidon params + require BLS12-381. +- MiMC examples compile on `CurveId.BN254` and are labeled as BN254/off-chain. +- MiMC examples reject `CurveId.BLS12_381` when the in-circuit gadget enforces + `FieldConfig.BN254`. +- Negative witnesses still fail: wrong commitments, invalid vote bits, and + sealed bids below reserve. + +## Consequences + +Positive: + +- New users see BLS12-381 Poseidon in the examples that are intended for + Cardano. +- Example code aligns with the generic Cardano Groth16 verifier and the + on-chain sealed-bid fixture. +- BN254 MiMC remains usable and tested without being presented as a Cardano + default. +- The standalone annotated usecases now compile against the Cardano-oriented + BLS12-381 path rather than serving only as BN254/off-chain symbolic examples. + +Negative: + +- Existing example witness values and public-input counts change for sealed + bid. +- BN254 compilation is intentionally rejected for examples that now hard-code + BLS12-381 Poseidon params. diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index 774bb54..e0421fa 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -69,5 +69,6 @@ recommended follow-up order is: | 2 | `ZkPoseidonN` symbolic adapter | Completed | | 3 | Params-aware BLS12-381 `ZkMerkle` helpers | Completed | | 4 | Generic or generated Cardano Groth16 verifier for arbitrary public-input count | Completed | -| 5 | Example migration to BLS12-381 Poseidon where examples are Cardano-facing | Pending | -| 6 | Optional BLS12-381 MiMC decision | Pending | +| 5 | Example migration to BLS12-381 Poseidon where examples are Cardano-facing | Completed | +| 6 | Nested `ZkArray>` symbolic inputs | Pending | +| 7 | Optional BLS12-381 MiMC decision | Pending | diff --git a/docs/circuit-dsl-user-guide.md b/docs/circuit-dsl-user-guide.md index 8e72b6d..13ce3c1 100644 --- a/docs/circuit-dsl-user-guide.md +++ b/docs/circuit-dsl-user-guide.md @@ -197,8 +197,13 @@ public class HashCommitmentCircuit implements CircuitSpec { Signal salt = c.privateInput("salt"); Signal commitment = c.publicOutput("commitment"); - // MiMC hash: commitment = MiMC(secret, salt) - c.assertEqual(SignalMiMC.hash(c, secret, salt), commitment); + // Cardano-facing hash: Poseidon over the BLS12-381 scalar field. + var hash = SignalPoseidon.hash( + c, + PoseidonParamsBLS12_381T3.INSTANCE, + secret, + salt); + c.assertEqual(hash, commitment); } public static CircuitBuilder build() { @@ -229,7 +234,11 @@ public class HashChainCircuit implements CircuitSpec { Signal current = secret; Signal zero = c.constant(0); for (int i = 0; i < depth; i++) { - current = SignalMiMC.hash(c, current, zero); + current = SignalPoseidon.hash( + c, + PoseidonParamsBLS12_381T3.INSTANCE, + current, + zero); } c.assertEqual(current, digest); } @@ -280,7 +289,13 @@ public class MerkleProofCircuit implements CircuitSpec { for (int i = 0; i < depth; i++) { builder = builder.secretVar("sibling_" + i).secretVar("pathBit_" + i); } - return builder.defineSignals(new MerkleProofCircuit(depth, SignalMiMC::hash)); + return builder.defineSignals(new MerkleProofCircuit( + depth, + (sb, left, right) -> SignalPoseidon.hash( + sb, + PoseidonParamsBLS12_381T3.INSTANCE, + left, + right))); } } ``` @@ -493,7 +508,12 @@ public class VotingCircuit implements CircuitSpec { Signal commitment = c.publicOutput("commitment"); // hash output (public) vote.assertBoolean(); // vote must be 0 or 1 - c.assertEqual(SignalMiMC.hash(c, vote, nullifier), commitment); + var hash = SignalPoseidon.hash( + c, + PoseidonParamsBLS12_381T3.INSTANCE, + vote, + nullifier); + c.assertEqual(hash, commitment); } public static CircuitBuilder build() { @@ -526,8 +546,17 @@ public class MerkleMembershipCircuit implements CircuitSpec { pathBits[i] = c.privateInput("path_" + i); } - // Verify Merkle path using MiMC hash (or swap to SignalPoseidon::hash) - SignalMerkle.verifyProof(c, leaf, root, siblings, pathBits, SignalMiMC::hash); + SignalMerkle.verifyProof( + c, + leaf, + root, + siblings, + pathBits, + (sb, left, right) -> SignalPoseidon.hash( + sb, + PoseidonParamsBLS12_381T3.INSTANCE, + left, + right)); } public static CircuitBuilder build(int depth) { @@ -623,11 +652,10 @@ public class MultiFieldCommitCircuit implements CircuitSpec { } Signal commitment = c.publicOutput("commitment"); - // Chain: MiMC(MiMC(in[0], in[1]), in[2]) ... - Signal acc = SignalMiMC.hash(c, inputs[0], inputs[1]); - for (int i = 2; i < inputs.length; i++) { - acc = SignalMiMC.hash(c, acc, inputs[i]); - } + Signal acc = PoseidonN.hash( + c, + PoseidonParamsBLS12_381T3.INSTANCE, + inputs); c.assertEqual(acc, commitment); } diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAnonymousVoting.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAnonymousVoting.java index 6b4734c..4e32ee4 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAnonymousVoting.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAnonymousVoting.java @@ -7,7 +7,8 @@ import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; -import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMiMC; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkPoseidon; @ZKCircuit(name = "annotation-anonymous-vote", version = 1) public class AnnotatedAnonymousVoting { @@ -17,7 +18,7 @@ ZkBool prove( @Public ZkField commitment, @Secret ZkBool vote, @Secret ZkField nullifier) { - return ZkMiMC.hash(zk, vote.asField(), nullifier) + return ZkPoseidon.hash(zk, PoseidonParamsBLS12_381T3.INSTANCE, vote.asField(), nullifier) .isEqual(commitment); } } diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedHashCommitment.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedHashCommitment.java index 7b6b47f..4eea463 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedHashCommitment.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedHashCommitment.java @@ -9,6 +9,12 @@ import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMiMC; +/** + * BN254/off-chain MiMC adapter example. + * + *

    Cardano-facing hash examples use explicit BLS12-381 Poseidon params + * instead. This class remains as the small MiMC symbolic adapter example.

    + */ @ZKCircuit(name = "annotation-hash-commitment") public class AnnotatedHashCommitment { @Prove diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedSealedBid.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedSealedBid.java index 24e1728..556a928 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedSealedBid.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedSealedBid.java @@ -9,23 +9,25 @@ import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; -import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMiMC; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkPoseidon; @ZKCircuit(name = "annotation-sealed-bid", version = 1) public class AnnotatedSealedBid { @Prove ZkBool prove( ZkContext zk, - @Public @UInt(bits = 64) ZkUInt reservePrice, @Public ZkField bidCommitment, - @Public ZkBool isAboveReserve, + @Public @UInt(bits = 64) ZkUInt reservePrice, @Secret @UInt(bits = 64) ZkUInt bidAmount, @Secret ZkField salt) { - var commitmentMatches = ZkMiMC.hash(zk, bidAmount.asField(), salt) + var commitmentMatches = ZkPoseidon.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + bidAmount.asField(), + salt) .isEqual(bidCommitment); - var reserveFlagMatches = bidAmount.gte(reservePrice) - .isEqual(isAboveReserve); - return commitmentMatches.and(reserveFlagMatches); + return commitmentMatches.and(bidAmount.gte(reservePrice)); } } diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidCircuit.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidCircuit.java index 4b7c91c..885ea25 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidCircuit.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidCircuit.java @@ -5,12 +5,14 @@ import com.bloxbean.cardano.zeroj.circuit.Signal; import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; import com.bloxbean.cardano.zeroj.circuit.lib.SignalComparators; -import com.bloxbean.cardano.zeroj.circuit.lib.SignalMiMC; +import com.bloxbean.cardano.zeroj.circuit.lib.SignalPoseidon; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; /** * Sealed-bid auction circuit — proves a bid is valid without revealing the amount. * - *

    The bidder commits to their bid: {@code bidCommitment = MiMC(bidAmount, salt)}. + *

    The bidder commits to their bid: + * {@code bidCommitment = PoseidonBLS12_381(bidAmount, salt)}. * The circuit proves two things:

    *
      *
    1. The bidder knows {@code bidAmount} and {@code salt} that produce the public commitment
    2. @@ -20,7 +22,7 @@ *

      Signals:

      *
        *
      • Private: bidAmount, salt
      • - *
      • Public: reservePrice (input), bidCommitment (output), isAboveReserve (output)
      • + *
      • Public: bidCommitment (output), reservePrice (input)
      • *
      */ public class SealedBidCircuit implements CircuitSpec { @@ -31,32 +33,30 @@ public void define(SignalBuilder c) { Signal bidAmount = c.privateInput("bidAmount"); Signal salt = c.privateInput("salt"); - // Public input — the auction's minimum price - Signal reservePrice = c.publicInput("reservePrice"); - - // Public outputs — verifiable by anyone + // Public values — verifiable by anyone and consumed by the on-chain verifier Signal bidCommitment = c.publicOutput("bidCommitment"); - Signal isAboveReserve = c.publicOutput("isAboveReserve"); + Signal reservePrice = c.publicInput("reservePrice"); - // Constraint 1: bidCommitment == MiMC(bidAmount, salt) - c.assertEqual(SignalMiMC.hash(c, bidAmount, salt), bidCommitment); + // Constraint 1: bidCommitment == PoseidonBLS12_381(bidAmount, salt) + c.assertEqual( + SignalPoseidon.hash(c, PoseidonParamsBLS12_381T3.INSTANCE, bidAmount, salt), + bidCommitment); - // Constraint 2: isAboveReserve == (bidAmount >= reservePrice) ? 1 : 0 + // Constraint 2: bidAmount >= reservePrice must hold inside the proof. c.assertEqual( SignalComparators.greaterOrEqual(c, bidAmount, reservePrice, 64), - isAboveReserve); + c.constant(1)); } /** * Build a complete circuit with all signals declared. * - *

      Wire layout (iden3 convention): [1, reservePrice, bidCommitment, isAboveReserve, bidAmount, salt, ...]

      + *

      Wire layout (iden3 convention): [1, bidCommitment, reservePrice, bidAmount, salt, ...]

      */ public static CircuitBuilder build() { return CircuitBuilder.create("sealed-bid") - .publicVar("reservePrice") .publicVar("bidCommitment") - .publicVar("isAboveReserve") + .publicVar("reservePrice") .secretVar("bidAmount") .secretVar("salt") .defineSignals(new SealedBidCircuit()); diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidProofHelper.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidProofHelper.java index 17b40b8..fbd5f76 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidProofHelper.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidProofHelper.java @@ -1,10 +1,10 @@ package com.bloxbean.cardano.zeroj.examples.dsl.auction; import com.bloxbean.cardano.zeroj.api.CurveId; -import com.bloxbean.cardano.zeroj.circuit.FieldConfig; import com.bloxbean.cardano.zeroj.circuit.r1cs.R1CSSerializer; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; import com.bloxbean.cardano.zeroj.examples.dsl.common.GnarkProverHelper; -import com.bloxbean.cardano.zeroj.examples.dsl.common.MiMCHash; import com.bloxbean.cardano.zeroj.examples.dsl.common.SnarkjsProver; import com.bloxbean.cardano.zeroj.prover.gnark.GnarkProver; import com.bloxbean.cardano.zeroj.prover.wasm.WitnessExporter; @@ -20,7 +20,7 @@ *

      Handles the full lifecycle:

      *
        *
      1. Compile circuit to R1CS (pure Java)
      2. - *
      3. Compute bid commitment via standalone MiMC hash
      4. + *
      5. Compute bid commitment via standalone BLS12-381 Poseidon hash
      6. *
      7. Calculate witness and export to .wtns (pure Java)
      8. *
      9. Call snarkjs for trusted setup + proof generation
      10. *
      @@ -30,6 +30,9 @@ public class SealedBidProofHelper { private final CurveId curve; public SealedBidProofHelper(CurveId curve) { + if (curve != CurveId.BLS12_381) { + throw new IllegalArgumentException("SealedBidCircuit uses explicit BLS12-381 Poseidon params"); + } this.curve = curve; } @@ -42,33 +45,32 @@ public byte[] generateR1CS() { } /** - * Compute the bid commitment: MiMC(bidAmount, salt). + * Compute the bid commitment: PoseidonBLS12_381(bidAmount, salt). */ public BigInteger computeCommitment(BigInteger bidAmount, BigInteger salt) { - return MiMCHash.hash(bidAmount, salt, FieldConfig.forCurve(curve).prime()); + return PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, bidAmount, salt); } /** * Generate .wtns binary for the given bid parameters. * - *

      Computes bidCommitment and isAboveReserve internally.

      + *

      Computes bidCommitment internally. Bids below reserve fail witness + * calculation because the reserve check is constrained inside the circuit.

      */ public byte[] generateWitness(BigInteger bidAmount, BigInteger salt, BigInteger reservePrice) { - var config = FieldConfig.forCurve(curve); var bidCommitment = computeCommitment(bidAmount, salt); - var isAboveReserve = bidAmount.compareTo(reservePrice) >= 0 - ? BigInteger.ONE : BigInteger.ZERO; var circuit = SealedBidCircuit.build(); BigInteger[] witness = circuit.calculateWitness(Map.of( "bidAmount", List.of(bidAmount), "salt", List.of(salt), "bidCommitment", List.of(bidCommitment), - "reservePrice", List.of(reservePrice), - "isAboveReserve", List.of(isAboveReserve) + "reservePrice", List.of(reservePrice) ), curve); - return WitnessExporter.toWtns(witness, config.prime(), config.n32()); + return WitnessExporter.toWtns(witness, + PoseidonParamsBLS12_381T3.INSTANCE.field().prime(), + PoseidonParamsBLS12_381T3.INSTANCE.field().n32()); } /** @@ -115,8 +117,6 @@ public GnarkProver.FullProveResponse generateGroth16ProofNative( GnarkProver prover) { var bidCommitment = computeCommitment(bidAmount, salt); - var isAboveReserve = bidAmount.compareTo(reservePrice) >= 0 - ? BigInteger.ONE : BigInteger.ZERO; return GnarkProverHelper.groth16Prove( SealedBidCircuit.build(), @@ -124,8 +124,7 @@ public GnarkProver.FullProveResponse generateGroth16ProofNative( "bidAmount", List.of(bidAmount), "salt", List.of(salt), "bidCommitment", List.of(bidCommitment), - "reservePrice", List.of(reservePrice), - "isAboveReserve", List.of(isAboveReserve) + "reservePrice", List.of(reservePrice) ), curve, prover); } diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/common/MiMCHash.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/common/MiMCHash.java index dff4d17..aff1f01 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/common/MiMCHash.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/common/MiMCHash.java @@ -6,12 +6,15 @@ import java.security.NoSuchAlgorithmException; /** - * Standalone MiMC-7 hash computation (outside ZK circuits). + * Standalone BN254/off-chain MiMC-7 hash computation (outside ZK circuits). * *

      Produces the same output as the circuit-based {@code SignalMiMC.hash()} / {@code MiMC.hash()}, * allowing the prover to compute hash values before constructing the witness.

      * *

      MiMC-7 parameters: 91 rounds, S-box x^7, round constants = SHA-256("mimc_round_N").

      + * + *

      The in-circuit MiMC gadget is BN254-only. Cardano-facing examples should + * use BLS12-381 Poseidon instead.

      */ public final class MiMCHash { @@ -24,7 +27,7 @@ private MiMCHash() {} * * @param left first input * @param right second input (used as key in Feistel construction) - * @param prime the field prime (curve-dependent) + * @param prime the BN254 field prime when matching the in-circuit MiMC gadget * @return hash output in the field */ public static BigInteger hash(BigInteger left, BigInteger right, BigInteger prime) { diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/HashChainCircuit.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/HashChainCircuit.java index f79a000..82ef00e 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/HashChainCircuit.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/HashChainCircuit.java @@ -4,7 +4,8 @@ import com.bloxbean.cardano.zeroj.circuit.CircuitSpec; import com.bloxbean.cardano.zeroj.circuit.Signal; import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; -import com.bloxbean.cardano.zeroj.circuit.lib.SignalMiMC; +import com.bloxbean.cardano.zeroj.circuit.lib.SignalPoseidon; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; /** * Parameterized hash chain circuit — demonstrates Java-as-template-system. @@ -21,7 +22,7 @@ * signal intermediate[depth + 1]; * intermediate[0] <== secret; * for (var i = 0; i < depth; i++) { - * intermediate[i+1] <== MiMC(intermediate[i], 0); + * intermediate[i+1] <== PoseidonBLS12_381(intermediate[i], 0); * } * digest <== intermediate[depth]; * } @@ -46,11 +47,11 @@ public void define(SignalBuilder c) { Signal secret = c.privateInput("secret"); Signal digest = c.publicOutput("digest"); - // Hash chain: h_0 = secret, h_{i+1} = MiMC(h_i, 0) + // Hash chain: h_0 = secret, h_{i+1} = PoseidonBLS12_381(h_i, 0) Signal current = secret; Signal zero = c.constant(0); for (int i = 0; i < depth; i++) { - current = SignalMiMC.hash(c, current, zero); + current = SignalPoseidon.hash(c, PoseidonParamsBLS12_381T3.INSTANCE, current, zero); } c.assertEqual(current, digest); diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/MultiInputCommitmentCircuit.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/MultiInputCommitmentCircuit.java index 351b53c..a4e6124 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/MultiInputCommitmentCircuit.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/MultiInputCommitmentCircuit.java @@ -4,12 +4,13 @@ import com.bloxbean.cardano.zeroj.circuit.CircuitSpec; import com.bloxbean.cardano.zeroj.circuit.Signal; import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; -import com.bloxbean.cardano.zeroj.circuit.lib.SignalMiMC; +import com.bloxbean.cardano.zeroj.circuit.lib.PoseidonN; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; /** * Parameterized N-input commitment circuit — hash N secret values into one public digest. * - *

      Proves knowledge of N secret inputs whose sequential MiMC hash produces a + *

      Proves knowledge of N secret inputs whose sequential BLS12-381 Poseidon hash produces a * known public commitment. Useful for committing to structured data (e.g., a record * with multiple fields) without revealing any field.

      * @@ -20,7 +21,7 @@ * signal output commitment; * var acc = values[0]; * for (var i = 1; i < n; i++) { - * acc = MiMC(acc, values[i]); + * acc = PoseidonBLS12_381(acc, values[i]); * } * commitment <== acc; * } @@ -57,11 +58,7 @@ public void define(SignalBuilder c) { } Signal commitment = c.publicOutput("commitment"); - // Sequential hashing: acc = MiMC(MiMC(...MiMC(in[0], in[1])..., in[n-2]), in[n-1]) - Signal acc = SignalMiMC.hash(c, inputs[0], inputs[1]); - for (int i = 2; i < numInputs; i++) { - acc = SignalMiMC.hash(c, acc, inputs[i]); - } + Signal acc = PoseidonN.hash(c, PoseidonParamsBLS12_381T3.INSTANCE, inputs); c.assertEqual(acc, commitment); } diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/NWayMerkleCircuit.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/NWayMerkleCircuit.java index 1614adc..1f5a3ef 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/NWayMerkleCircuit.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/NWayMerkleCircuit.java @@ -34,7 +34,12 @@ */ public class NWayMerkleCircuit implements CircuitSpec { - /** Supported hash functions for Merkle nodes. */ + /** + * Supported hash functions for Merkle nodes. + *

      + * `MIMC` is the BN254/off-chain path. `POSEIDON` uses explicit BLS12-381 + * params and is the Cardano-oriented path. + */ public enum HashType { MIMC, POSEIDON } private final int depth; diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingCircuit.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingCircuit.java index 82f12ea..5828735 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingCircuit.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingCircuit.java @@ -4,12 +4,14 @@ import com.bloxbean.cardano.zeroj.circuit.CircuitSpec; import com.bloxbean.cardano.zeroj.circuit.Signal; import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; -import com.bloxbean.cardano.zeroj.circuit.lib.SignalMiMC; +import com.bloxbean.cardano.zeroj.circuit.lib.SignalPoseidon; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; /** * Anonymous voting circuit — proves a vote is valid without revealing the choice. * - *

      The voter commits to their vote: {@code commitment = MiMC(vote, nullifier)}. + *

      The voter commits to their vote: + * {@code commitment = PoseidonBLS12_381(vote, nullifier)}. * The nullifier prevents double-voting (revealed on-chain), while the vote remains private.

      * *

      Signals:

      @@ -32,8 +34,10 @@ public void define(SignalBuilder c) { // Constraint 1: vote must be boolean (0 or 1) vote.assertBoolean(); - // Constraint 2: commitment == MiMC(vote, nullifier) - c.assertEqual(SignalMiMC.hash(c, vote, nullifier), commitment); + // Constraint 2: commitment == PoseidonBLS12_381(vote, nullifier) + c.assertEqual( + SignalPoseidon.hash(c, PoseidonParamsBLS12_381T3.INSTANCE, vote, nullifier), + commitment); } /** diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingProofHelper.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingProofHelper.java index 3ffb6c6..41b1a0a 100644 --- a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingProofHelper.java +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingProofHelper.java @@ -1,10 +1,10 @@ package com.bloxbean.cardano.zeroj.examples.dsl.voting; import com.bloxbean.cardano.zeroj.api.CurveId; -import com.bloxbean.cardano.zeroj.circuit.FieldConfig; import com.bloxbean.cardano.zeroj.circuit.r1cs.R1CSSerializer; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; import com.bloxbean.cardano.zeroj.examples.dsl.common.GnarkProverHelper; -import com.bloxbean.cardano.zeroj.examples.dsl.common.MiMCHash; import com.bloxbean.cardano.zeroj.examples.dsl.common.SnarkjsProver; import com.bloxbean.cardano.zeroj.prover.gnark.GnarkProver; import com.bloxbean.cardano.zeroj.prover.wasm.WitnessExporter; @@ -22,6 +22,9 @@ public class AnonymousVotingProofHelper { private final CurveId curve; public AnonymousVotingProofHelper(CurveId curve) { + if (curve != CurveId.BLS12_381) { + throw new IllegalArgumentException("AnonymousVotingCircuit uses explicit BLS12-381 Poseidon params"); + } this.curve = curve; } @@ -34,10 +37,10 @@ public byte[] generateR1CS() { } /** - * Compute the vote commitment: MiMC(vote, nullifier). + * Compute the vote commitment: PoseidonBLS12_381(vote, nullifier). */ public BigInteger computeCommitment(BigInteger vote, BigInteger nullifier) { - return MiMCHash.hash(vote, nullifier, FieldConfig.forCurve(curve).prime()); + return PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, vote, nullifier); } /** @@ -47,7 +50,6 @@ public BigInteger computeCommitment(BigInteger vote, BigInteger nullifier) { * @param nullifier unique per voter (prevents double-voting) */ public byte[] generateWitness(BigInteger vote, BigInteger nullifier) { - var config = FieldConfig.forCurve(curve); var commitment = computeCommitment(vote, nullifier); var circuit = AnonymousVotingCircuit.build(); @@ -57,7 +59,9 @@ public byte[] generateWitness(BigInteger vote, BigInteger nullifier) { "commitment", List.of(commitment) ), curve); - return WitnessExporter.toWtns(witness, config.prime(), config.n32()); + return WitnessExporter.toWtns(witness, + PoseidonParamsBLS12_381T3.INSTANCE.field().prime(), + PoseidonParamsBLS12_381T3.INSTANCE.field().n32()); } /** diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java index 070f0e9..f17a3bc 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java @@ -152,7 +152,7 @@ void privateTransferChecksConservationAndPublicAmount() { } @Test - void hashCommitmentUsesSymbolicGadgetAdapter() { + void hashCommitmentUsesBn254MiMCSymbolicGadgetAdapter() { var circuit = AnnotatedHashCommitmentCircuit.build(); var value = BigInteger.valueOf(1234); var salt = BigInteger.valueOf(5678); @@ -165,6 +165,7 @@ void hashCommitmentUsesSymbolicGadgetAdapter() { assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); assertNotNull(circuit.compileR1CS(CurveId.BN254)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BLS12_381)); var wrong = AnnotatedHashCommitmentCircuit.inputs() .value(value) @@ -218,51 +219,42 @@ void annotatedSealedBidMirrorsReferenceDslCircuit() { var schema = AnnotatedSealedBidCircuit.schema(); assertEquals("annotation-sealed-bid", schema.name()); - assertEquals(List.of("reservePrice", "bidCommitment", "isAboveReserve"), + assertEquals(List.of("bidCommitment", "reservePrice"), schema.publicInputs().names()); assertEquals(List.of("bidAmount", "salt"), schema.secretInputs().names()); var bidAmount = BigInteger.valueOf(100); var reservePrice = BigInteger.valueOf(75); var salt = BigInteger.valueOf(88_001); - var commitment = MiMCHash.hash(bidAmount, salt, FieldConfig.BN254.prime()); + var commitment = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, bidAmount, salt); var inputs = AnnotatedSealedBidCircuit.inputs() - .reservePrice(reservePrice) .bidCommitment(commitment) - .isAboveReserve(1) + .reservePrice(reservePrice) .bidAmount(bidAmount) .salt(salt); - assertEquals(List.of(reservePrice, commitment, BigInteger.ONE), inputs.publicValues()); - assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); + assertEquals(List.of(commitment, reservePrice), inputs.publicValues()); + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381)); + assertDoesNotThrow(() -> circuit.compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); var belowReserveBid = BigInteger.valueOf(50); - var belowReserveCommitment = MiMCHash.hash(belowReserveBid, salt, FieldConfig.BN254.prime()); + var belowReserveCommitment = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, belowReserveBid, salt); var belowReserveInputs = AnnotatedSealedBidCircuit.inputs() - .reservePrice(reservePrice) .bidCommitment(belowReserveCommitment) - .isAboveReserve(0) - .bidAmount(belowReserveBid) - .salt(salt); - assertDoesNotThrow(() -> circuit.calculateWitness(belowReserveInputs.toWitnessMap(), CurveId.BN254)); - - var wrongFlag = AnnotatedSealedBidCircuit.inputs() .reservePrice(reservePrice) - .bidCommitment(commitment) - .isAboveReserve(0) - .bidAmount(bidAmount) + .bidAmount(belowReserveBid) .salt(salt); assertThrows(ArithmeticException.class, - () -> circuit.calculateWitness(wrongFlag.toWitnessMap(), CurveId.BN254)); + () -> circuit.calculateWitness(belowReserveInputs.toWitnessMap(), CurveId.BLS12_381)); var wrongCommitment = AnnotatedSealedBidCircuit.inputs() - .reservePrice(reservePrice) .bidCommitment(BigInteger.ONE) - .isAboveReserve(1) + .reservePrice(reservePrice) .bidAmount(bidAmount) .salt(salt); assertThrows(ArithmeticException.class, - () -> circuit.calculateWitness(wrongCommitment.toWitnessMap(), CurveId.BN254)); + () -> circuit.calculateWitness(wrongCommitment.toWitnessMap(), CurveId.BLS12_381)); } @Test @@ -276,41 +268,43 @@ void annotatedAnonymousVotingMirrorsReferenceDslCircuit() { var vote = BigInteger.ONE; var nullifier = BigInteger.valueOf(12_345); - var commitment = MiMCHash.hash(vote, nullifier, FieldConfig.BN254.prime()); + var commitment = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, vote, nullifier); var inputs = AnnotatedAnonymousVotingCircuit.inputs() .commitment(commitment) .vote(vote) .nullifier(nullifier); assertEquals(List.of(commitment), inputs.publicValues()); - assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381)); + assertDoesNotThrow(() -> circuit.compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); var noVote = BigInteger.ZERO; - var noVoteCommitment = MiMCHash.hash(noVote, nullifier, FieldConfig.BN254.prime()); + var noVoteCommitment = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, noVote, nullifier); var noVoteInputs = AnnotatedAnonymousVotingCircuit.inputs() .commitment(noVoteCommitment) .vote(noVote) .nullifier(nullifier); - assertDoesNotThrow(() -> circuit.calculateWitness(noVoteInputs.toWitnessMap(), CurveId.BN254)); + assertDoesNotThrow(() -> circuit.calculateWitness(noVoteInputs.toWitnessMap(), CurveId.BLS12_381)); var wrongCommitment = AnnotatedAnonymousVotingCircuit.inputs() .commitment(BigInteger.ONE) .vote(vote) .nullifier(nullifier); assertThrows(ArithmeticException.class, - () -> circuit.calculateWitness(wrongCommitment.toWitnessMap(), CurveId.BN254)); + () -> circuit.calculateWitness(wrongCommitment.toWitnessMap(), CurveId.BLS12_381)); var invalidVote = BigInteger.valueOf(2); var invalidVoteInputs = AnnotatedAnonymousVotingCircuit.inputs() - .commitment(MiMCHash.hash(invalidVote, nullifier, FieldConfig.BN254.prime())) + .commitment(PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, invalidVote, nullifier)) .vote(invalidVote) .nullifier(nullifier); assertThrows(ArithmeticException.class, - () -> circuit.calculateWitness(invalidVoteInputs.toWitnessMap(), CurveId.BN254)); + () -> circuit.calculateWitness(invalidVoteInputs.toWitnessMap(), CurveId.BLS12_381)); } @Test - void parameterizedMerkleMembershipUsesDepthAndHashType() { + void parameterizedMerkleMembershipUsesBn254MiMCDepthAndHashType() { int depth = 2; var hashType = ZkMerkle.HashType.MIMC; var circuit = AnnotatedMerkleMembershipCircuit.build(depth, hashType); @@ -337,6 +331,7 @@ void parameterizedMerkleMembershipUsesDepthAndHashType() { .pathBits(List.of(pathBit0, pathBit1)); assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BN254)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BLS12_381)); assertEquals(List.of(root), inputs.publicValues()); var invalid = AnnotatedMerkleMembershipCircuit.inputs(depth, hashType) diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidCircuitTest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidCircuitTest.java index 6eb35d9..e049775 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidCircuitTest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidCircuitTest.java @@ -1,8 +1,8 @@ package com.bloxbean.cardano.zeroj.examples.dsl.auction; import com.bloxbean.cardano.zeroj.api.CurveId; -import com.bloxbean.cardano.zeroj.circuit.FieldConfig; -import com.bloxbean.cardano.zeroj.examples.dsl.common.MiMCHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; import org.junit.jupiter.api.Test; import java.math.BigInteger; @@ -17,68 +17,46 @@ class SealedBidCircuitTest { @Test - void validBid_aboveReserve_bn254() { + void validBid_aboveReserve_bls12381Poseidon() { var circuit = SealedBidCircuit.build(); - var prime = FieldConfig.BN254.prime(); var bidAmount = BigInteger.valueOf(1000); var salt = BigInteger.valueOf(42); var reservePrice = BigInteger.valueOf(500); - var commitment = MiMCHash.hash(bidAmount, salt, prime); + var commitment = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, bidAmount, salt); var witness = circuit.calculateWitness(Map.of( "bidAmount", List.of(bidAmount), "salt", List.of(salt), "bidCommitment", List.of(commitment), - "reservePrice", List.of(reservePrice), - "isAboveReserve", List.of(BigInteger.ONE) - ), CurveId.BN254); + "reservePrice", List.of(reservePrice) + ), CurveId.BLS12_381); assertNotNull(witness); assertEquals(BigInteger.ONE, witness[0]); // constant wire } @Test - void validBid_aboveReserve_bls12381() { + void bn254CompileRejectedForCardanoPoseidonParams() { var circuit = SealedBidCircuit.build(); - var prime = FieldConfig.BLS12_381.prime(); - - var bidAmount = BigInteger.valueOf(1000); - var salt = BigInteger.valueOf(42); - var reservePrice = BigInteger.valueOf(500); - var commitment = MiMCHash.hash(bidAmount, salt, prime); - - var witness = circuit.calculateWitness(Map.of( - "bidAmount", List.of(bidAmount), - "salt", List.of(salt), - "bidCommitment", List.of(commitment), - "reservePrice", List.of(reservePrice), - "isAboveReserve", List.of(BigInteger.ONE) - ), CurveId.BLS12_381); - - assertNotNull(witness); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); } @Test - void validBid_belowReserve() { + void bidBelowReserve_failsWitness() { var circuit = SealedBidCircuit.build(); - var prime = FieldConfig.BN254.prime(); var bidAmount = BigInteger.valueOf(200); var salt = BigInteger.valueOf(99); var reservePrice = BigInteger.valueOf(500); - var commitment = MiMCHash.hash(bidAmount, salt, prime); + var commitment = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, bidAmount, salt); - // bidAmount < reservePrice → isAboveReserve=0 - var witness = circuit.calculateWitness(Map.of( + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( "bidAmount", List.of(bidAmount), "salt", List.of(salt), "bidCommitment", List.of(commitment), - "reservePrice", List.of(reservePrice), - "isAboveReserve", List.of(BigInteger.ZERO) - ), CurveId.BN254); - - assertNotNull(witness); + "reservePrice", List.of(reservePrice) + ), CurveId.BLS12_381)); } @Test @@ -90,39 +68,8 @@ void wrongCommitment_fails() { "bidAmount", List.of(BigInteger.valueOf(1000)), "salt", List.of(BigInteger.valueOf(42)), "bidCommitment", List.of(BigInteger.valueOf(999)), // wrong! - "reservePrice", List.of(BigInteger.valueOf(500)), - "isAboveReserve", List.of(BigInteger.ONE) - ), CurveId.BN254)); - } - - @Test - void wrongIsAboveReserve_fails() { - var circuit = SealedBidCircuit.build(); - var prime = FieldConfig.BN254.prime(); - - var bidAmount = BigInteger.valueOf(200); - var salt = BigInteger.valueOf(99); - var reservePrice = BigInteger.valueOf(500); - var commitment = MiMCHash.hash(bidAmount, salt, prime); - - // bidAmount(200) < reservePrice(500), but claiming isAboveReserve=1 - assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( - "bidAmount", List.of(bidAmount), - "salt", List.of(salt), - "bidCommitment", List.of(commitment), - "reservePrice", List.of(reservePrice), - "isAboveReserve", List.of(BigInteger.ONE) // wrong! - ), CurveId.BN254)); - } - - @Test - void compilesToR1CS() { - var circuit = SealedBidCircuit.build(); - var r1cs = circuit.compileR1CS(CurveId.BN254); - assertNotNull(r1cs); - assertTrue(r1cs.numConstraints() > 0, "Should have constraints"); - assertEquals(3, r1cs.numPublicInputs(), "3 public vars: reservePrice, bidCommitment, isAboveReserve"); - assertEquals(2, r1cs.numPrivateInputs(), "2 private vars: bidAmount, salt"); + "reservePrice", List.of(BigInteger.valueOf(500)) + ), CurveId.BLS12_381)); } @Test @@ -131,7 +78,9 @@ void compilesToR1CS_bls12381() { var r1cs = circuit.compileR1CS(CurveId.BLS12_381); assertNotNull(r1cs); assertTrue(r1cs.numConstraints() > 0); - assertEquals(FieldConfig.BLS12_381.prime(), r1cs.prime()); + assertEquals(PoseidonParamsBLS12_381T3.INSTANCE.field().prime(), r1cs.prime()); + assertEquals(2, r1cs.numPublicInputs(), "2 public vars: bidCommitment, reservePrice"); + assertEquals(2, r1cs.numPrivateInputs(), "2 private vars: bidAmount, salt"); } @Test diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidE2ETest.java index 8d9fc27..810541f 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidE2ETest.java @@ -78,21 +78,21 @@ void groth16_bls12381_bidAboveReserve(@TempDir Path workDir) throws Exception { // 5. Verify public inputs contain expected values var publicInputs = SnarkjsJsonCodec.parsePublicInputs(proof.publicJson()); - // Public vars: [reservePrice, bidCommitment, isAboveReserve] - assertEquals(3, publicInputs.size(), "Should have 3 public inputs"); + // Public vars: [bidCommitment, reservePrice] + assertEquals(2, publicInputs.size(), "Should have 2 public inputs"); + assertEquals(helper.computeCommitment(bidAmount, salt), publicInputs.get(0), + "Public commitment should match BLS12-381 Poseidon"); + assertEquals(reservePrice, publicInputs.get(1), + "Public reserve price should match the witness"); - // isAboveReserve should be 1 (bid 1000 >= reserve 500) - var isAboveReserve = publicInputs.get(2); // third public var - assertEquals(BigInteger.ONE, isAboveReserve, - "isAboveReserve should be 1 for bid above reserve"); } /** - * Groth16/BLS12-381: sealed bid BELOW reserve price — proof is still valid, - * but isAboveReserve = 0. + * Groth16/BLS12-381: sealed bid BELOW reserve price fails witness + * calculation because the reserve condition is constrained in-circuit. */ @Test - void groth16_bls12381_bidBelowReserve(@TempDir Path workDir) throws Exception { + void groth16_bls12381_bidBelowReserve_failsWitness(@TempDir Path workDir) throws Exception { var helper = new SealedBidProofHelper(CurveId.BLS12_381); var bidAmount = BigInteger.valueOf(200); @@ -100,26 +100,8 @@ void groth16_bls12381_bidBelowReserve(@TempDir Path workDir) throws Exception { var reservePrice = BigInteger.valueOf(500); Path ptau = snarkjs.powersOfTau("bls12-381", 13, workDir); - var proof = helper.generateGroth16Proof(bidAmount, salt, reservePrice, ptau, workDir, snarkjs); - - // Proof is valid (the circuit still works, it just outputs isAboveReserve=0) - assertTrue(snarkjs.groth16Verify(workDir)); - - // Off-chain Java verification - var envelope = SnarkjsJsonCodec.toEnvelopeFromJson( - proof.proofJson(), proof.vkJson(), proof.publicJson(), - new CircuitId("sealed-bid")); - var material = VerificationMaterial.of( - proof.vkJson().getBytes(StandardCharsets.UTF_8), - ProofSystemId.GROTH16, CurveId.BLS12_381, new CircuitId("sealed-bid")); - - var result = new Groth16BLS12381PureJavaVerifier().verify(envelope, material); - assertTrue(result.proofValid(), "Proof should be valid even for bid below reserve"); - - // isAboveReserve should be 0 - var publicInputs = SnarkjsJsonCodec.parsePublicInputs(proof.publicJson()); - assertEquals(BigInteger.ZERO, publicInputs.get(2), - "isAboveReserve should be 0 for bid below reserve"); + assertThrows(ArithmeticException.class, + () -> helper.generateGroth16Proof(bidAmount, salt, reservePrice, ptau, workDir, snarkjs)); } /** diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java index deb6d2d..c2cfb3e 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/SealedBidPureJavaE2ETest.java @@ -1,10 +1,11 @@ package com.bloxbean.cardano.zeroj.examples.dsl.auction; import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; import com.bloxbean.cardano.zeroj.crypto.groth16.Groth16ProverBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.Groth16SetupBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.PowersOfTauBLS381; -import com.bloxbean.cardano.zeroj.examples.dsl.common.MiMCHash; import com.bloxbean.cardano.zeroj.bls12381.ec.*; import com.bloxbean.cardano.zeroj.bls12381.field.*; import com.bloxbean.cardano.zeroj.bls12381.pairing.BLS12381Pairing; @@ -53,19 +54,16 @@ void devTau_sealedBid_fullStack() { BigInteger bidAmount = BigInteger.valueOf(1000); BigInteger salt = BigInteger.valueOf(42); BigInteger reservePrice = BigInteger.valueOf(500); - BigInteger bidCommitment = MiMCHash.hash(bidAmount, salt, - com.bloxbean.cardano.zeroj.circuit.FieldConfig.BLS12_381.prime()); - BigInteger isAboveReserve = BigInteger.ONE; + BigInteger bidCommitment = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, bidAmount, salt); BigInteger[] witness = circuit.calculateWitness(Map.of( "bidAmount", List.of(bidAmount), "salt", List.of(salt), "reservePrice", List.of(reservePrice), - "bidCommitment", List.of(bidCommitment), - "isAboveReserve", List.of(isAboveReserve)), CurveId.BLS12_381); + "bidCommitment", List.of(bidCommitment)), CurveId.BLS12_381); // === Step 3: Dev trusted setup (DEVELOPMENT ONLY) === - var srs = PowersOfTauBLS381.generate(10); // 2^10 = 1024 >= 497 constraints + var srs = PowersOfTauBLS381.generate(12); var setupResult = Groth16SetupBLS381.setup(constraints, r1cs.numWires(), r1cs.numPublicInputs(), srs.tauScalar()); var pk = setupResult.provingKey(); diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/ParameterizedCircuitE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/ParameterizedCircuitE2ETest.java index 11a4cdf..dedd5f7 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/ParameterizedCircuitE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/templates/ParameterizedCircuitE2ETest.java @@ -2,11 +2,11 @@ import com.bloxbean.cardano.zeroj.api.CurveId; import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; -import com.bloxbean.cardano.zeroj.circuit.FieldConfig; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; import com.bloxbean.cardano.zeroj.crypto.groth16.Groth16ProverBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.Groth16SetupBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.PowersOfTauBLS381; -import com.bloxbean.cardano.zeroj.examples.dsl.common.MiMCHash; import com.bloxbean.cardano.zeroj.bls12381.ec.*; import com.bloxbean.cardano.zeroj.bls12381.field.*; import com.bloxbean.cardano.zeroj.bls12381.pairing.BLS12381Pairing; @@ -31,8 +31,6 @@ */ class ParameterizedCircuitE2ETest { - private static final BigInteger PRIME = FieldConfig.BLS12_381.prime(); - // =================================================================== // HashChainCircuit — parameterized by depth // =================================================================== @@ -68,8 +66,8 @@ void hashChain_differentDepths_differentConstraintCounts() { assertTrue(c3 > c1, "depth=3 should have more constraints than depth=1"); assertTrue(c5 > c3, "depth=5 should have more constraints than depth=3"); - // Each additional hash adds ~273 constraints (MiMC-7) - assertTrue(c3 - c1 > 200, "Each depth level adds ~273 MiMC constraints"); + // Each additional hash adds one BLS12-381 Poseidon invocation. + assertTrue(c3 - c1 > 200, "Each depth level should add one Poseidon hash worth of constraints"); System.out.println("Constraint scaling: depth=1→" + c1 + ", depth=3→" + c3 + ", depth=5→" + c5); } @@ -105,9 +103,8 @@ void multiCommit_withNamedFields_proveAndVerify() { BigInteger age = BigInteger.valueOf(30); BigInteger balance = BigInteger.valueOf(1000000); - // Compute expected commitment: MiMC(MiMC(name, age), balance) - BigInteger step1 = MiMCHash.hash(name, age, PRIME); - BigInteger commitment = MiMCHash.hash(step1, balance, PRIME); + // Compute expected commitment: PoseidonN(name, age, balance) + BigInteger commitment = PoseidonHash.hashN(PoseidonParamsBLS12_381T3.INSTANCE, name, age, balance); var witness = circuit.calculateWitness(Map.of( "name", List.of(name), @@ -125,28 +122,33 @@ void multiCommit_withNamedFields_proveAndVerify() { // =================================================================== @Test - void merkle_depth2_mimc_proveAndVerify() { - var result = proveMerkle(2, NWayMerkleCircuit.HashType.MIMC); - assertTrue(result.verified, "depth=2 MiMC Merkle must verify"); - System.out.println("Merkle(depth=2, MiMC): " + result.constraints + " constraints — VERIFIED"); + void merkle_depth2_poseidon_proveAndVerify() { + var result = proveMerkle(2, NWayMerkleCircuit.HashType.POSEIDON); + assertTrue(result.verified, "depth=2 Poseidon Merkle must verify"); + System.out.println("Merkle(depth=2, Poseidon): " + result.constraints + " constraints — VERIFIED"); } @Test - void merkle_depth3_mimc_proveAndVerify() { - var result = proveMerkle(3, NWayMerkleCircuit.HashType.MIMC); - assertTrue(result.verified, "depth=3 MiMC Merkle must verify"); - System.out.println("Merkle(depth=3, MiMC): " + result.constraints + " constraints — VERIFIED"); + void merkle_depth3_poseidon_proveAndVerify() { + var result = proveMerkle(3, NWayMerkleCircuit.HashType.POSEIDON); + assertTrue(result.verified, "depth=3 Poseidon Merkle must verify"); + System.out.println("Merkle(depth=3, Poseidon): " + result.constraints + " constraints — VERIFIED"); } @Test - void merkle_sameDepth_differentHash_differentConstraints() { + void merkle_mimcIsBn254Only_poseidonIsBlsCardanoPath() { int mimcC = NWayMerkleCircuit.build(2, NWayMerkleCircuit.HashType.MIMC) - .compileR1CS(CurveId.BLS12_381).numConstraints(); + .compileR1CS(CurveId.BN254).numConstraints(); int poseidonC = NWayMerkleCircuit.build(2, NWayMerkleCircuit.HashType.POSEIDON) .compileR1CS(CurveId.BLS12_381).numConstraints(); - // Poseidon (~330/hash) vs MiMC (~273/hash) — different constraint counts for same depth assertNotEquals(mimcC, poseidonC, "Different hash functions should produce different constraint counts"); + assertThrows(IllegalStateException.class, + () -> NWayMerkleCircuit.build(2, NWayMerkleCircuit.HashType.MIMC) + .compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, + () -> NWayMerkleCircuit.build(2, NWayMerkleCircuit.HashType.POSEIDON) + .compileR1CS(CurveId.BN254)); System.out.println("Merkle(depth=2): MiMC=" + mimcC + " vs Poseidon=" + poseidonC + " constraints"); } @@ -160,10 +162,10 @@ private ProveResult proveHashChain(int depth, BigInteger secret) { var circuit = HashChainCircuit.build(depth); var r1cs = circuit.compileR1CS(CurveId.BLS12_381); - // Compute expected digest by applying MiMC `depth` times + // Compute expected digest by applying Poseidon `depth` times BigInteger current = secret; for (int i = 0; i < depth; i++) { - current = MiMCHash.hash(current, BigInteger.ZERO, PRIME); + current = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, current, BigInteger.ZERO); } var witness = circuit.calculateWitness(Map.of( @@ -178,11 +180,7 @@ private ProveResult proveMultiCommit(int n, BigInteger[] values) { var circuit = MultiInputCommitmentCircuit.build(n); var r1cs = circuit.compileR1CS(CurveId.BLS12_381); - // Compute expected commitment - BigInteger acc = MiMCHash.hash(values[0], values[1], PRIME); - for (int i = 2; i < n; i++) { - acc = MiMCHash.hash(acc, values[i], PRIME); - } + BigInteger acc = PoseidonHash.hashN(PoseidonParamsBLS12_381T3.INSTANCE, values); Map> inputs = new HashMap<>(); for (int i = 0; i < n; i++) { @@ -210,7 +208,7 @@ private ProveResult proveMerkle(int depth, NWayMerkleCircuit.HashType hashType) for (int i = 0; i < depth; i++) { siblings[i] = BigInteger.valueOf(100 + i); // arbitrary sibling pathBits[i] = BigInteger.ZERO; // leaf on left side - current = MiMCHash.hash(current, siblings[i], PRIME); + current = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, current, siblings[i]); } BigInteger root = current; diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingE2ETest.java index 81cc391..13474f4 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingE2ETest.java @@ -61,7 +61,7 @@ void groth16_bls12381_voteYes(@TempDir Path workDir) throws Exception { // Verify commitment matches what we'd compute standalone var expectedCommitment = helper.computeCommitment(vote, nullifier); assertEquals(expectedCommitment, publicInputs.get(0), - "Public commitment should match standalone MiMC computation"); + "Public commitment should match standalone BLS12-381 Poseidon computation"); } @Test diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingPureJavaE2ETest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingPureJavaE2ETest.java index f257331..a56b1a3 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingPureJavaE2ETest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/dsl/voting/AnonymousVotingPureJavaE2ETest.java @@ -1,10 +1,11 @@ package com.bloxbean.cardano.zeroj.examples.dsl.voting; import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; import com.bloxbean.cardano.zeroj.crypto.groth16.Groth16ProverBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.Groth16SetupBLS381; import com.bloxbean.cardano.zeroj.crypto.setup.PowersOfTauBLS381; -import com.bloxbean.cardano.zeroj.examples.dsl.common.MiMCHash; import com.bloxbean.cardano.zeroj.bls12381.ec.*; import com.bloxbean.cardano.zeroj.bls12381.field.*; import com.bloxbean.cardano.zeroj.bls12381.pairing.BLS12381Pairing; @@ -37,15 +38,14 @@ void devTau_voteYes_proveAndVerify() { BigInteger vote = BigInteger.ONE; // YES BigInteger nullifier = BigInteger.valueOf(12345); - BigInteger commitment = MiMCHash.hash(vote, nullifier, - com.bloxbean.cardano.zeroj.circuit.FieldConfig.BLS12_381.prime()); + BigInteger commitment = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, vote, nullifier); BigInteger[] witness = circuit.calculateWitness(Map.of( "vote", List.of(vote), "nullifier", List.of(nullifier), "commitment", List.of(commitment)), CurveId.BLS12_381); - var srs = PowersOfTauBLS381.generate(9); + var srs = PowersOfTauBLS381.generate(11); var setupResult = Groth16SetupBLS381.setup(constraints, r1cs.numWires(), r1cs.numPublicInputs(), srs.tauScalar()); @@ -60,8 +60,8 @@ void devTau_voteYes_proveAndVerify() { assertTrue(verified, "Vote YES pairing verification MUST pass"); System.out.println("Off-chain pairing: PASSED"); - // Verify commitment matches standalone MiMC - assertEquals(commitment, pubInputs[0], "Commitment matches MiMC hash"); + // Verify commitment matches standalone Poseidon + assertEquals(commitment, pubInputs[0], "Commitment matches BLS12-381 Poseidon hash"); System.out.println("=== AnonymousVoting E2E (vote=YES, dev tau): COMPLETE ==="); } @@ -73,15 +73,14 @@ void devTau_voteNo_proveAndVerify() { BigInteger vote = BigInteger.ZERO; // NO BigInteger nullifier = BigInteger.valueOf(67890); - BigInteger commitment = MiMCHash.hash(vote, nullifier, - com.bloxbean.cardano.zeroj.circuit.FieldConfig.BLS12_381.prime()); + BigInteger commitment = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, vote, nullifier); BigInteger[] witness = circuit.calculateWitness(Map.of( "vote", List.of(vote), "nullifier", List.of(nullifier), "commitment", List.of(commitment)), CurveId.BLS12_381); - var srs = PowersOfTauBLS381.generate(9); + var srs = PowersOfTauBLS381.generate(11); var setupResult = Groth16SetupBLS381.setup(constraints, r1cs.numWires(), r1cs.numPublicInputs(), srs.tauScalar()); From ab6f13d78a096df0566b7eff89c717b7540681de Mon Sep 17 00:00:00 2001 From: Satya Date: Mon, 18 May 2026 22:26:08 +0800 Subject: [PATCH 21/26] feat(annotation): support nested ZkArray inputs --- docs/adr/circuit-annotation/README.md | 30 +- .../cardano-gadget-support-matrix.md | 26 +- .../circuit-annotation/implementation-plan.md | 2 +- .../nested-zkarray-symbolic-inputs.md | 259 ++++++++++++++++++ .../zeroj/circuit/annotation/FixedSize.java | 2 + .../zeroj/circuit/annotation/ZkArray.java | 55 ++++ .../circuit/annotation/ZkCircuitSchema.java | 80 +++++- .../zeroj/circuit/annotation/ZkInputMap.java | 13 + .../annotation/ZkSymbolicTypesTest.java | 63 +++++ .../processor/CircuitAnnotationProcessor.java | 225 +++++++++++++-- .../CircuitAnnotationProcessorTest.java | 190 +++++++++++++ .../AnnotatedBatchThresholdMatrix.java | 49 ++++ .../AnnotatedCircuitExamplesTest.java | 47 ++++ 13 files changed, 995 insertions(+), 46 deletions(-) create mode 100644 docs/adr/circuit-annotation/nested-zkarray-symbolic-inputs.md create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedBatchThresholdMatrix.java diff --git a/docs/adr/circuit-annotation/README.md b/docs/adr/circuit-annotation/README.md index 0ad3a53..fd2b9d0 100644 --- a/docs/adr/circuit-annotation/README.md +++ b/docs/adr/circuit-annotation/README.md @@ -454,6 +454,24 @@ ZkArray pathBits = ZkArray.secretBools(c, "pathBit", depth); ZkArray amounts = ZkArray.publicUInts(c, "amount", count, 64); ``` +Two-dimensional rectangular arrays are supported for grouped inputs: + +```java +@Secret +@UInt(bits = 16) +@FixedSize(param = "rows", innerParam = "cols") +ZkArray> measurements; +``` + +Nested arrays flatten row-major in schema and witness maps: + +```text +measurement_0_0, measurement_0_1, measurement_1_0, measurement_1_1 +``` + +Generated input builders accept rectangular nested lists and reject ragged +values before witness calculation. + `ZkArray.bind(...)` is reserved for custom symbolic element types. Its visibility comes from the supplied factory, so generated code should prefer the visibility-specific helpers whenever the element type is built in. @@ -639,11 +657,14 @@ Defines fixed length for arrays, bit vectors, and byte values. public @interface FixedSize { int value() default -1; String param() default ""; + int inner() default -1; + String innerParam() default ""; } ``` Required for `ZkArray`, `ZkBits`, and `ZkBytes`. Use `value` for a literal size -and `param` to reference a build-time `@CircuitParam`. +and `param` to reference a build-time `@CircuitParam`. For +`ZkArray>`, use `inner` or `innerParam` for the second dimension. Examples: @@ -655,10 +676,15 @@ ZkArray fixedSiblings; @Secret @FixedSize(param = "depth") ZkArray parametricSiblings; + +@Secret +@FixedSize(param = "rows", innerParam = "cols") +ZkArray> matrix; ``` Exactly one of `value` or `param` must be set. `param` must reference a visible -integer `@CircuitParam`. +integer `@CircuitParam`. Nested arrays also require exactly one of `inner` or +`innerParam`. ### `@Order` diff --git a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md index 0c424f5..057c3de 100644 --- a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -2,7 +2,7 @@ ## Status -Accepted follow-up plan. Priorities 1 through 5 are completed in the current +Accepted follow-up plan. Priorities 1 through 6 are completed in the current code and docs. ## Date @@ -99,7 +99,7 @@ Relevant source: | `ZkField` | Raw field element | Generic | Direct | Yes on BLS12-381 Groth16 | None. | | `ZkBool` | Boolean-constrained field bit | Generic | Direct | Yes on BLS12-381 Groth16 | None. | | `ZkUInt` | Unsigned integer with bit width and range constraints | Generic, width-limited | Direct | Yes on BLS12-381 Groth16 | Document max width and comparison limits. Current `MAX_BITS` is 253 and comparisons require compare width `< 253`. | -| `ZkArray` | Fixed-size symbolic arrays | Generic | Direct for one-dimensional arrays | Yes on BLS12-381 Groth16 | Track nested arrays as a lower-priority follow-up for matrix/grouped inputs. | +| `ZkArray` | Fixed-size symbolic arrays | Generic | Direct for one-dimensional arrays and rectangular `ZkArray>` matrices | Yes on BLS12-381 Groth16 | Deeper nesting remains out of scope until a real circuit needs it. | | `ZkBits` | Fixed-size bit vector | Generic | Direct for binding/equality | Yes on BLS12-381 Groth16 | Add ergonomic bitwise operations if bit-heavy circuits appear. | | `ZkBytes` | Fixed-size byte vector | Generic | Direct for binding/equality | Yes on BLS12-381 Groth16 | Add packing/unpacking helpers when byte-oriented circuits appear. | | `Comparators` / `SignalComparators` | `<`, `<=`, `>`, `>=`, range, min, max | Generic | Mostly direct through `ZkUInt` | Yes on BLS12-381 Groth16 | Optional symbolic helpers for `min` and `max`. | @@ -324,13 +324,17 @@ Exit criteria: ### Phase F: Nested `ZkArray>` +Status: completed. The implementation is tracked in +[`nested-zkarray-symbolic-inputs.md`](nested-zkarray-symbolic-inputs.md). + Goal: support matrix-like and grouped fixed-size symbolic inputs without manual flattening. Rationale: -- Current annotated circuits support one-dimensional `ZkArray`. -- Complex circuits can work around the gap by flattening inputs, but that +- Annotated circuits now support one-dimensional `ZkArray` and rectangular + two-dimensional `ZkArray>`. +- Deeper nesting can still be worked around by flattening inputs, but that pushes offset math and naming conventions into user code. - Nested arrays are not Cardano-specific, but they improve ergonomics for circuits with grouped attributes, batched Merkle openings, matrices, or @@ -338,15 +342,15 @@ Rationale: Tasks: -- Extend annotation validation so nested `ZkArray>` declarations +- Extended annotation validation so nested `ZkArray>` declarations require explicit outer and inner fixed sizes. -- Define stable schema flattening such as `matrix_0_0`, `matrix_0_1`, +- Defined stable schema flattening such as `matrix_0_0`, `matrix_0_1`, `matrix_1_0`, and `matrix_1_1`. -- Generate input builder methods that accept rectangular nested lists. -- Reject ragged nested input values at input-builder time. -- Preserve public-input order and witness-map order across generated schema, +- Generated input builder methods that accept rectangular nested lists. +- Rejected ragged nested input values at input-builder time. +- Preserved public-input order and witness-map order across generated schema, input builders, and proof-envelope public values. -- Add tests for public and secret nested arrays of `ZkField`, `ZkBool`, and +- Added tests for public and secret nested arrays of `ZkField`, `ZkBool`, and `ZkUInt`. Exit criteria: @@ -386,7 +390,7 @@ Exit criteria: 3. Params-aware BLS12-381 `ZkMerkle`. Completed. 4. Generic/generated Cardano Groth16 verifier for arbitrary public-input count. Completed. 5. Example migration to BLS12-381 Poseidon. Completed. -6. Nested `ZkArray>` support. +6. Nested `ZkArray>` support. Completed. 7. Optional BLS12-381 MiMC only if a real integration requires it. ## Testing Strategy diff --git a/docs/adr/circuit-annotation/implementation-plan.md b/docs/adr/circuit-annotation/implementation-plan.md index e0421fa..b156e1b 100644 --- a/docs/adr/circuit-annotation/implementation-plan.md +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -70,5 +70,5 @@ recommended follow-up order is: | 3 | Params-aware BLS12-381 `ZkMerkle` helpers | Completed | | 4 | Generic or generated Cardano Groth16 verifier for arbitrary public-input count | Completed | | 5 | Example migration to BLS12-381 Poseidon where examples are Cardano-facing | Completed | -| 6 | Nested `ZkArray>` symbolic inputs | Pending | +| 6 | Nested `ZkArray>` symbolic inputs | Completed | | 7 | Optional BLS12-381 MiMC decision | Pending | diff --git a/docs/adr/circuit-annotation/nested-zkarray-symbolic-inputs.md b/docs/adr/circuit-annotation/nested-zkarray-symbolic-inputs.md new file mode 100644 index 0000000..4c7f0d3 --- /dev/null +++ b/docs/adr/circuit-annotation/nested-zkarray-symbolic-inputs.md @@ -0,0 +1,259 @@ +# ADR: Nested ZkArray Symbolic Inputs + +## Status + +Implemented. + +## Date + +2026-05-18 + +## Context + +Annotation-based circuits currently support one-dimensional fixed-size symbolic +arrays: + +```java +@Secret @FixedSize(param = "depth") ZkArray siblings +``` + +The generated companion flattens that input into deterministic witness and +schema names: + +```text +sibling_0, sibling_1, ..., sibling_n +``` + +This is enough for Merkle paths, but some real circuits naturally group inputs +as matrices or rows: + +- batched Merkle openings +- grouped credential attributes +- matrix-style compliance checks +- multi-row proof-of-reserves or solvency checks +- small fixed tables used by domain-specific validation logic + +Today those circuits must flatten the data manually and keep row/column offset +math in user code. That is error-prone because public-input order and witness +names are part of the circuit identity. + +## Decision + +Support two-dimensional symbolic arrays: + +```java +@ZKCircuit(name = "matrix-bounds", nameTemplate = "matrix-bounds-{rows}x{cols}") +public class MatrixBounds { + private final int rows; + private final int cols; + + public MatrixBounds( + @CircuitParam("rows") int rows, + @CircuitParam("cols") int cols) { + this.rows = rows; + this.cols = cols; + } + + @Prove + ZkBool prove( + ZkContext zk, + @Public @UInt(bits = 16) ZkUInt max, + @Secret @UInt(bits = 16) + @FixedSize(param = "rows", innerParam = "cols") + ZkArray> values) { + ZkBool ok = ZkBool.constant(zk, true); + for (int row = 0; row < values.size(); row++) { + for (int col = 0; col < values.get(row).size(); col++) { + ok = ok.and(values.get(row).get(col).lte(max)); + } + } + return ok; + } +} +``` + +The implementation supports `ZkArray>` where `T` is one of: + +- `ZkField` +- `ZkBool` +- `ZkUInt` + +It does not support deeper nesting in this phase. + +## Fixed Size Syntax + +Existing one-dimensional syntax remains valid: + +```java +@FixedSize(4) +@FixedSize(param = "depth") +``` + +Nested arrays use the existing outer dimension plus a new inner dimension: + +```java +@FixedSize(value = 2, inner = 3) +@FixedSize(param = "rows", inner = 3) +@FixedSize(value = 2, innerParam = "cols") +@FixedSize(param = "rows", innerParam = "cols") +``` + +Rules: + +- exactly one of `value` or `param` must define the outer dimension +- nested arrays require exactly one of `inner` or `innerParam` +- non-nested arrays, `ZkBits`, and `ZkBytes` must not set `inner` or + `innerParam` +- all literal dimensions must be positive +- all parameter dimensions must reference integer-like `@CircuitParam`s +- generated `build(...)`, `schema(...)`, and `inputs(...)` methods reject + non-positive parameter dimensions + +## Flattening Contract + +Nested arrays flatten in row-major order: + +```text +matrix_0_0, matrix_0_1, matrix_0_2, +matrix_1_0, matrix_1_1, matrix_1_2 +``` + +For a public nested input, this flattened order is the public-input order used +by: + +- `schema().publicInputs().names()` +- `Inputs.publicValues()` +- `Inputs.toPublicInputs()` +- proof-envelope public-input serialization +- Cardano Groth16 verifier datum public inputs + +This order is part of the circuit identity and must not change silently. + +## Generated Builder API + +For a nested input named `values`, the generated companion exposes: + +```java +inputs.values(row, col, BigInteger.valueOf(10)); +inputs.values(row, col, 10); +inputs.values(List.of( + List.of(BigInteger.ONE, BigInteger.TWO), + List.of(BigInteger.valueOf(3), BigInteger.valueOf(4)))); +``` + +The list-based method rejects: + +- wrong outer size +- wrong inner row size +- null row lists +- null element values + +Ragged nested input values fail before witness calculation. + +## Runtime Schema + +`ZkCircuitSchema.Input` continues to expose: + +- `name()` +- `kind()` +- `bits()` +- `size()` +- `signalNames()` + +For nested arrays: + +- `size()` is the flattened signal count +- `signalNames()` is row-major flattened +- a new `dimensions()` accessor records the shape, for example `[2, 3]` + +One-dimensional arrays have dimensions `[n]`. Scalars have an empty dimension +list. + +## Soundness Requirements + +- A nested `ZkBool` array must still boolean-constrain every element. +- A nested `ZkUInt` array must still range-constrain every element. +- Schema names, builder witness names, and circuit declared variables must be + identical. +- Public-input flattening must be deterministic and covered by tests. +- Duplicate flattened names must be rejected at compile time when detectable. +- Parameterized dimensions must be guarded at runtime before circuit + construction or input-builder creation. + +## Implementation Plan + +1. Extend `@FixedSize` with `inner()` and `innerParam()`. +2. Extend `ZkArray` with matrix factories for fields, bools, and uints. +3. Extend `ZkCircuitSchema.Input` with dimensions while preserving existing + one-dimensional factory methods. +4. Add `ZkInputMap.putNestedArray(...)` for row-major witness flattening. +5. Update the annotation processor to detect `ZkArray>`, validate + the second dimension, generate row-major variable declarations, bind nested + symbolic values, and generate nested input-builder methods. +6. Add processor tests for generated schema names, witness maps, public-input + order, ragged input rejection, and invalid declarations. +7. Add at least one real example under `zeroj-examples`. +8. Add one `zeroj-usecases` example or migration that demonstrates nested + symbolic input usage. + +## Implementation Notes + +- `@FixedSize` now has `inner()` and `innerParam()` for the second dimension. +- `ZkArray` exposes matrix factories for `ZkField`, `ZkBool`, and `ZkUInt`. +- `ZkCircuitSchema.Input.dimensions()` exposes `[]`, `[n]`, or `[rows, cols]`. +- `ZkInputMap.putNestedArray(...)` flattens nested values row-major. +- The annotation processor rejects missing inner dimensions, invalid inner + params, unsupported leaf types, and deeper nesting. +- `AnnotatedBatchThresholdMatrix` demonstrates a BLS12-381-compatible nested + `ZkArray>` circuit in `zeroj-examples`. + +## Testing Strategy + +Unit-level tests: + +- `ZkArray` matrix factories flatten signals row-major. +- `ZkCircuitSchema.Input.array(..., List.of(rows, cols))` exposes deterministic + dimensions and names. +- `ZkInputMap.putNestedArray(...)` flattens row-major. + +Processor tests: + +- `ZkArray>` +- `ZkArray>` +- `ZkArray>` +- literal dimensions +- parameter dimensions +- public nested arrays preserve public-input order +- secret nested arrays preserve witness names +- ragged values fail in generated input builders +- missing inner dimension fails compilation +- non-nested use of `inner` or `innerParam` fails compilation +- deeper nesting fails compilation + +Integration tests: + +- compile and calculate a valid witness for a nested-array annotated circuit +- fail witness calculation for a bad element value or failed assertion +- compile over `CurveId.BLS12_381` for a Cardano-oriented example + +## Consequences + +Positive: + +- Matrix and grouped-input circuits become easier to read and less error-prone. +- Public-input order remains generated and inspectable instead of hand-managed. +- Existing one-dimensional annotation code remains valid. + +Negative: + +- `@FixedSize` grows a second-dimension surface. +- Generated companion code becomes more complex. +- Only rectangular two-dimensional arrays are supported in this phase. + +## Out of Scope + +- `ZkArray>>` +- ragged arrays +- dynamic array sizes +- nested `ZkBits` or `ZkBytes` +- on-chain verifier changes diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FixedSize.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FixedSize.java index 5c91351..d2282df 100644 --- a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FixedSize.java +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FixedSize.java @@ -13,4 +13,6 @@ public @interface FixedSize { int value() default -1; String param() default ""; + int inner() default -1; + String innerParam() default ""; } diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkArray.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkArray.java index 25b38f4..3ef4abd 100644 --- a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkArray.java +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkArray.java @@ -42,6 +42,38 @@ public static ZkArray secretUInts(SignalBuilder builder, String baseName return bindElements(builder, baseName, size, (c, name) -> ZkUInt.secret(c, name, bits)); } + public static ZkArray> publicFieldMatrix( + SignalBuilder builder, String baseName, int outerSize, int innerSize) { + return bindMatrix(builder, baseName, outerSize, innerSize, ZkArray::publicFields); + } + + public static ZkArray> secretFieldMatrix( + SignalBuilder builder, String baseName, int outerSize, int innerSize) { + return bindMatrix(builder, baseName, outerSize, innerSize, ZkArray::secretFields); + } + + public static ZkArray> publicBoolMatrix( + SignalBuilder builder, String baseName, int outerSize, int innerSize) { + return bindMatrix(builder, baseName, outerSize, innerSize, ZkArray::publicBools); + } + + public static ZkArray> secretBoolMatrix( + SignalBuilder builder, String baseName, int outerSize, int innerSize) { + return bindMatrix(builder, baseName, outerSize, innerSize, ZkArray::secretBools); + } + + public static ZkArray> publicUIntMatrix( + SignalBuilder builder, String baseName, int outerSize, int innerSize, int bits) { + return bindMatrix(builder, baseName, outerSize, innerSize, + (c, name, size) -> ZkArray.publicUInts(c, name, size, bits)); + } + + public static ZkArray> secretUIntMatrix( + SignalBuilder builder, String baseName, int outerSize, int innerSize, int bits) { + return bindMatrix(builder, baseName, outerSize, innerSize, + (c, name, size) -> ZkArray.secretUInts(c, name, size, bits)); + } + /** * Bind a custom fixed-size array. Visibility comes from the supplied * factory; use the visibility-specific helpers for built-in symbolic types. @@ -66,6 +98,24 @@ private static ZkArray bindElements( return new ZkArray<>(values); } + private static ZkArray> bindMatrix( + SignalBuilder builder, String baseName, int outerSize, int innerSize, MatrixRowFactory factory) { + Objects.requireNonNull(builder, "builder"); + Objects.requireNonNull(baseName, "baseName"); + Objects.requireNonNull(factory, "factory"); + if (outerSize < 0) { + throw new IllegalArgumentException("outerSize must be >= 0, got " + outerSize); + } + if (innerSize < 0) { + throw new IllegalArgumentException("innerSize must be >= 0, got " + innerSize); + } + var rows = new ArrayList>(outerSize); + for (int i = 0; i < outerSize; i++) { + rows.add(factory.create(builder, baseName + "_" + i, innerSize)); + } + return new ZkArray<>(rows); + } + public int size() { return values.size(); } @@ -98,4 +148,9 @@ public void assertWellFormed() { public interface ElementFactory { T create(SignalBuilder builder, String name); } + + @FunctionalInterface + private interface MatrixRowFactory { + ZkArray create(SignalBuilder builder, String baseName, int size); + } } diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java index 1657d88..509115e 100644 --- a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java @@ -85,18 +85,37 @@ public record Input( int bits, int size, boolean array, - List signalNames) { + List signalNames, + List dimensions) { public Input { Objects.requireNonNull(name, "name"); Objects.requireNonNull(visibility, "visibility"); Objects.requireNonNull(kind, "kind"); + dimensions = List.copyOf(Objects.requireNonNull(dimensions, "dimensions")); if (bits < -1 || bits == 0) { throw new IllegalArgumentException("bits must be -1 or positive"); } if (size <= 0) { throw new IllegalArgumentException("size must be positive"); } + if (array && dimensions.isEmpty()) { + throw new IllegalArgumentException("array inputs must declare dimensions"); + } + if (!array && !dimensions.isEmpty()) { + throw new IllegalArgumentException("scalar inputs must not declare dimensions"); + } + int flattenedSize = 1; + for (Integer dimension : dimensions) { + Objects.requireNonNull(dimension, "dimensions element"); + if (dimension <= 0) { + throw new IllegalArgumentException("dimensions must be positive"); + } + flattenedSize = Math.multiplyExact(flattenedSize, dimension); + } + if (array && flattenedSize != size) { + throw new IllegalArgumentException("size must equal flattened dimensions"); + } validateKind(kind, bits, array); signalNames = List.copyOf(Objects.requireNonNull(signalNames, "signalNames")); if (signalNames.size() != size) { @@ -104,16 +123,67 @@ public record Input( } } + public Input( + String name, + Visibility visibility, + Kind kind, + int bits, + int size, + boolean array, + List signalNames) { + this(name, visibility, kind, bits, size, array, signalNames, array ? List.of(size) : List.of()); + } + public static Input scalar(String name, Visibility visibility, Kind kind, int bits) { - return new Input(name, visibility, kind, bits, 1, false, List.of(name)); + return new Input(name, visibility, kind, bits, 1, false, List.of(name), List.of()); } public static Input array(String name, Visibility visibility, Kind kind, int bits, int size) { - var names = new ArrayList(size); + return array(name, visibility, kind, bits, List.of(size)); + } + + public static Input array( + String name, + Visibility visibility, + Kind kind, + int bits, + List dimensions) { + Objects.requireNonNull(dimensions, "dimensions"); + int flattenedSize = 1; + for (Integer dimension : dimensions) { + Objects.requireNonNull(dimension, "dimensions element"); + flattenedSize = Math.multiplyExact(flattenedSize, dimension); + } + return new Input( + name, + visibility, + kind, + bits, + flattenedSize, + true, + flattenedNames(name, dimensions), + dimensions); + } + + private static List flattenedNames(String name, List dimensions) { + if (dimensions.isEmpty()) { + return List.of(name); + } + var names = new ArrayList(); + appendNames(names, name, dimensions, 0); + return List.copyOf(names); + } + + private static void appendNames(List names, String prefix, List dimensions, int depth) { + int size = dimensions.get(depth); for (int i = 0; i < size; i++) { - names.add(name + "_" + i); + String next = prefix + "_" + i; + if (depth == dimensions.size() - 1) { + names.add(next); + } else { + appendNames(names, next, dimensions, depth + 1); + } } - return new Input(name, visibility, kind, bits, size, true, names); } private static void validateKind(Kind kind, int bits, boolean array) { diff --git a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java index a020806..8f5cad2 100644 --- a/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java @@ -36,6 +36,19 @@ public ZkInputMap putArray(String baseName, List inputValues) { return this; } + public ZkInputMap putNestedArray(String baseName, List> inputValues) { + Objects.requireNonNull(baseName, "baseName"); + Objects.requireNonNull(inputValues, "inputValues"); + for (int row = 0; row < inputValues.size(); row++) { + List rowValues = Objects.requireNonNull(inputValues.get(row), + "inputValues[" + row + "]"); + for (int col = 0; col < rowValues.size(); col++) { + put(baseName + "_" + row + "_" + col, rowValues.get(col)); + } + } + return this; + } + public Map> toWitnessMap() { var copy = new LinkedHashMap>(); values.forEach((name, value) -> copy.put(name, List.copyOf(value))); diff --git a/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java b/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java index e8fe717..12a03ca 100644 --- a/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java +++ b/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java @@ -199,6 +199,43 @@ void arrayFlattensSignalsAndSupportsFactories() { "item_1", List.of(BigInteger.valueOf(20))), CurveId.BN254)); } + @Test + void nestedArrayFlattensSignalsRowMajorAndSupportsFactories() { + var circuit = CircuitBuilder.create("zk-array-matrix") + .publicVar("out") + .secretVar("cell_0_0") + .secretVar("cell_0_1") + .secretVar("cell_1_0") + .secretVar("cell_1_1") + .defineSignals(c -> { + var cells = ZkArray.secretFieldMatrix(c, "cell", 2, 2); + var out = ZkField.publicInput(c, "out"); + + cells.get(0).get(0) + .add(cells.get(0).get(1)) + .add(cells.get(1).get(0)) + .add(cells.get(1).get(1)) + .assertEqual(out); + if (cells.signals().size() != 4) { + throw new IllegalStateException("matrix should flatten to four signals"); + } + }); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "out", List.of(BigInteger.valueOf(100)), + "cell_0_0", List.of(BigInteger.TEN), + "cell_0_1", List.of(BigInteger.valueOf(20)), + "cell_1_0", List.of(BigInteger.valueOf(30)), + "cell_1_1", List.of(BigInteger.valueOf(40))), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "out", List.of(BigInteger.valueOf(101)), + "cell_0_0", List.of(BigInteger.TEN), + "cell_0_1", List.of(BigInteger.valueOf(20)), + "cell_1_0", List.of(BigInteger.valueOf(30)), + "cell_1_1", List.of(BigInteger.valueOf(40))), CurveId.BN254)); + } + @Test void merkleShapedInputsSupportFieldSiblingsAndBooleanPathBits() { var circuit = CircuitBuilder.create("zk-merkle-shape") @@ -341,6 +378,32 @@ void schemaExposesStableNamesAndInputMapPublicValues() { assertEquals(BigInteger.valueOf(30), inputs.toWitnessMap().get("sibling_1").getFirst()); } + @Test + void schemaExposesNestedArrayDimensionsAndPublicValues() { + var schema = ZkCircuitSchema.of( + "schema-matrix", + List.of(new ZkCircuitSchema.Parameter("rows", "int", "2"), + new ZkCircuitSchema.Parameter("cols", "int", "2")), + List.of(ZkCircuitSchema.Input.array( + "cell", ZkCircuitSchema.Visibility.PUBLIC, ZkCircuitSchema.Kind.UINT, 16, List.of(2, 2))), + List.of()); + + assertEquals(List.of("cell_0_0", "cell_0_1", "cell_1_0", "cell_1_1"), + schema.publicInputs().names()); + assertEquals(4, schema.input("cell").size()); + assertEquals(List.of(2, 2), schema.input("cell").dimensions()); + assertEquals(ZkCircuitSchema.Kind.UINT, schema.input("cell_1_1").kind()); + + var inputs = new ZkInputMap() + .putNestedArray("cell", List.of( + List.of(BigInteger.ONE, BigInteger.TWO), + List.of(BigInteger.valueOf(3), BigInteger.valueOf(4)))); + + assertEquals(List.of(BigInteger.ONE, BigInteger.TWO, BigInteger.valueOf(3), BigInteger.valueOf(4)), + inputs.publicValues(schema)); + assertEquals(BigInteger.valueOf(4), inputs.toWitnessMap().get("cell_1_1").getFirst()); + } + @Test void circuitMetadataBuildsEnvelopeMetadataAndBuilder() { var schema = ZkCircuitSchema.of( diff --git a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java index 0cf85bd..9fd8674 100644 --- a/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java +++ b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java @@ -266,16 +266,19 @@ private InputModel input(VariableElement element, boolean fieldStyle, String explicitName = publicAnnotation != null ? publicAnnotation.name() : secretAnnotation.name(); String javaName = element.getSimpleName().toString(); ValueKind valueKind = valueKind(element); + boolean nestedArray = isNestedArray(element.asType()); + String arrayLeafType = valueKind == ValueKind.ARRAY ? arrayLeafType(element.asType()) : ""; String baseName = explicitName.isBlank() ? (valueKind == ValueKind.ARRAY ? singularize(javaName) : javaName) : explicitName; UInt uint = element.getAnnotation(UInt.class); - if ((valueKind == ValueKind.UINT || isArrayOf(element.asType(), ZK_UINT)) && uint == null) { + if ((valueKind == ValueKind.UINT || ZK_UINT.equals(arrayLeafType)) && uint == null) { throw new GenerationException(element, "ZkUInt symbolic inputs require @UInt(bits = ...)"); } - if (uint != null && valueKind != ValueKind.UINT && !isArrayOf(element.asType(), ZK_UINT)) { - throw new GenerationException(element, "@UInt can only be used with ZkUInt or ZkArray"); + if (uint != null && valueKind != ValueKind.UINT && !ZK_UINT.equals(arrayLeafType)) { + throw new GenerationException(element, + "@UInt can only be used with ZkUInt, ZkArray, or ZkArray>"); } if (element.getAnnotation(FieldElement.class) != null && valueKind != ValueKind.FIELD) { throw new GenerationException(element, "@FieldElement can only be used with ZkField"); @@ -283,16 +286,33 @@ private InputModel input(VariableElement element, boolean fieldStyle, FixedSize fixedSize = element.getAnnotation(FixedSize.class); SizeModel size = null; + SizeModel innerSize = null; if (isFixedVector(valueKind)) { if (fixedSize == null) { throw new GenerationException(element, typeLabel(valueKind) + " symbolic inputs require @FixedSize"); } - size = sizeModel(element, fixedSize, circuitParams); + size = sizeModel(element, fixedSize.value(), fixedSize.param(), "@FixedSize", circuitParams); + boolean hasInner = fixedSize.inner() >= 0 || !fixedSize.innerParam().isBlank(); + if (nestedArray) { + innerSize = sizeModel(element, fixedSize.inner(), fixedSize.innerParam(), + "@FixedSize inner dimension", circuitParams); + } else if (hasInner) { + throw new GenerationException(element, + "@FixedSize inner or innerParam can only be used with ZkArray>"); + } } else if (fixedSize != null) { throw new GenerationException(element, "@FixedSize can only be used with ZkArray, ZkBits, or ZkBytes"); } + if (valueKind == ValueKind.ARRAY + && !arrayLeafType.equals(ZK_FIELD) + && !arrayLeafType.equals(ZK_BOOL) + && !arrayLeafType.equals(ZK_UINT)) { + throw new GenerationException(element, + "ZkArray inputs must use ZkField, ZkBool, ZkUInt, or one nested ZkArray of those types"); + } + Integer order = null; Order orderAnnotation = element.getAnnotation(Order.class); if (orderAnnotation != null) { @@ -312,29 +332,28 @@ private InputModel input(VariableElement element, boolean fieldStyle, } return new InputModel(javaName, baseName, constName(baseName), visibility, valueKind, - elementType(element.asType()), bits, size, order, fieldStyle); + arrayLeafType, bits, size, innerSize, order, fieldStyle); } - private SizeModel sizeModel(VariableElement element, FixedSize fixedSize, + private SizeModel sizeModel(VariableElement element, int literal, String paramName, String label, Map circuitParams) { - boolean hasLiteral = fixedSize.value() >= 0; - boolean hasParam = !fixedSize.param().isBlank(); + boolean hasLiteral = literal >= 0; + boolean hasParam = !paramName.isBlank(); if (hasLiteral == hasParam) { throw new GenerationException(element, - "@FixedSize requires exactly one of value or param"); + label + " requires exactly one literal value or @CircuitParam reference"); } if (hasLiteral) { - if (fixedSize.value() <= 0) { - throw new GenerationException(element, "@FixedSize value must be positive"); + if (literal <= 0) { + throw new GenerationException(element, label + " value must be positive"); } - return new SizeModel(Integer.toString(fixedSize.value()), false, ""); + return new SizeModel(Integer.toString(literal), false, ""); } - CircuitParamModel param = circuitParams.get(fixedSize.param()); + CircuitParamModel param = circuitParams.get(paramName); if (param == null || !param.intLike()) { throw new GenerationException(element, - "@FixedSize(param = \"" + fixedSize.param() - + "\") must reference an integer @CircuitParam"); + label + " \"" + paramName + "\" must reference an integer @CircuitParam"); } return new SizeModel(param.javaName(), true, param.name()); } @@ -374,10 +393,9 @@ private void validateInputNames(List inputs) { Map flattenedOwners = new java.util.HashMap<>(); for (InputModel input : inputs) { if (isFixedVector(input.valueKind())) { - if (!input.size().fromCircuitParam()) { - int size = Integer.parseInt(input.size().expression()); - for (int i = 0; i < size; i++) { - addFlattenedName(flattenedNames, flattenedOwners, input, input.baseName() + "_" + i); + if (hasLiteralShape(input)) { + for (String name : literalFlattenedNames(input)) { + addFlattenedName(flattenedNames, flattenedOwners, input, name); } } } else { @@ -395,7 +413,7 @@ private void validateInputNames(List inputs) { for (InputModel array : inputs.stream().filter(i -> isFixedVector(i.valueKind())).toList()) { for (InputModel other : inputs.stream().filter(i -> i != array).toList()) { - if (other.baseName().matches(java.util.regex.Pattern.quote(array.baseName()) + "_\\d+")) { + if (mayOverlapParameterizedFlattenedName(array, other.baseName())) { throw new GenerationException(null, "Duplicate flattened input name may be generated from array base " + array.baseName() + " and input " + other.baseName()); @@ -412,6 +430,38 @@ private void addFlattenedName(Set flattenedNames, Map literalFlattenedNames(InputModel input) { + int outerSize = Integer.parseInt(input.size().expression()); + var names = new ArrayList(); + if (isNestedArray(input)) { + int innerSize = Integer.parseInt(input.innerSize().expression()); + for (int row = 0; row < outerSize; row++) { + for (int col = 0; col < innerSize; col++) { + names.add(input.baseName() + "_" + row + "_" + col); + } + } + } else { + for (int i = 0; i < outerSize; i++) { + names.add(input.baseName() + "_" + i); + } + } + return names; + } + + private boolean mayOverlapParameterizedFlattenedName(InputModel array, String otherBaseName) { + String prefix = java.util.regex.Pattern.quote(array.baseName()); + if (isNestedArray(array)) { + return otherBaseName.matches(prefix + "_\\d+.*"); + } + return otherBaseName.matches(prefix + "_\\d+"); + } + private void validateReturnType(ExecutableElement method) { TypeMirror returnType = method.getReturnType(); if (returnType.getKind() == TypeKind.VOID || isType(returnType, ZK_BOOL)) { @@ -449,6 +499,7 @@ private String render(TypeElement sourceType, String packageName, String sourceS String signalContextLocal = uniqueLocalName("__zerojSignals", usedLocalNames); String zkLocal = uniqueLocalName("__zeroj", usedLocalNames); String loopLocal = uniqueLocalName("__zerojIndex", usedLocalNames); + String innerLoopLocal = uniqueLocalName("__zerojInnerIndex", usedLocalNames); Map inputLocals = new java.util.LinkedHashMap<>(); for (int i = 0; i < inputs.size(); i++) { inputLocals.put(inputs.get(i), uniqueLocalName("__zerojInput" + i, usedLocalNames)); @@ -509,10 +560,10 @@ private String render(TypeElement sourceType, String packageName, String sourceS .append(");\n"); for (InputModel input : inputs.stream().filter(i -> i.visibility() == Visibility.PUBLIC).toList()) { - renderVarDeclaration(out, input, builderLocal, loopLocal, "publicVar"); + renderVarDeclaration(out, input, builderLocal, loopLocal, innerLoopLocal, "publicVar"); } for (InputModel input : inputs.stream().filter(i -> i.visibility() == Visibility.SECRET).toList()) { - renderVarDeclaration(out, input, builderLocal, loopLocal, "secretVar"); + renderVarDeclaration(out, input, builderLocal, loopLocal, innerLoopLocal, "secretVar"); } out.append(" return ").append(builderLocal).append(".defineSignals(") @@ -672,6 +723,9 @@ private void renderFixedSizeParamGuards(StringBuilder out, List inpu if (input.size() != null && input.size().fromCircuitParam()) { sizeParams.putIfAbsent(input.size().expression(), input.size().paramName()); } + if (input.innerSize() != null && input.innerSize().fromCircuitParam()) { + sizeParams.putIfAbsent(input.innerSize().expression(), input.innerSize().paramName()); + } } for (Map.Entry entry : sizeParams.entrySet()) { out.append(indent).append("if (").append(entry.getKey()).append(" <= 0) {\n") @@ -710,7 +764,12 @@ private String renderInputSchema(InputModel input) { String prefix = isFixedVector(input.valueKind()) ? "ZkCircuitSchema.Input.array(" : "ZkCircuitSchema.Input.scalar("; - String size = isFixedVector(input.valueKind()) ? ", " + input.size().expression() : ""; + String size = ""; + if (isNestedArray(input)) { + size = ", List.of(" + input.size().expression() + ", " + input.innerSize().expression() + ")"; + } else if (isFixedVector(input.valueKind())) { + size = ", " + input.size().expression(); + } return prefix + input.constantName() + ", ZkCircuitSchema.Visibility." + input.visibility().name() @@ -733,7 +792,11 @@ private void renderInputsClass(StringBuilder out, List inputs) { for (InputModel input : inputs) { if (isFixedVector(input.valueKind())) { - renderArrayInputMethods(out, input); + if (isNestedArray(input)) { + renderNestedArrayInputMethods(out, input); + } else { + renderArrayInputMethods(out, input); + } } else { renderScalarInputMethods(out, input); } @@ -793,6 +856,52 @@ private void renderArrayInputMethods(StringBuilder out, InputModel input) { .append(" }\n\n"); } + private void renderNestedArrayInputMethods(StringBuilder out, InputModel input) { + String inputExpression = "__zerojSchema.input(" + input.constantName() + ")"; + String outerSizeExpression = inputExpression + ".dimensions().get(0)"; + String innerSizeExpression = inputExpression + ".dimensions().get(1)"; + out.append(" public Inputs ").append(input.javaName()) + .append("(int row, int col, BigInteger value) {\n") + .append(" if (row < 0 || row >= ").append(outerSizeExpression).append(") {\n") + .append(" throw new IllegalArgumentException(") + .append(stringLiteral("row out of bounds for " + input.baseName())).append(");\n") + .append(" }\n") + .append(" if (col < 0 || col >= ").append(innerSizeExpression).append(") {\n") + .append(" throw new IllegalArgumentException(") + .append(stringLiteral("col out of bounds for " + input.baseName())).append(");\n") + .append(" }\n") + .append(" __zerojInputs.put(").append(input.constantName()) + .append(" + \"_\" + row + \"_\" + col, value);\n") + .append(" return this;\n") + .append(" }\n\n") + .append(" public Inputs ").append(input.javaName()).append("(int row, int col, long value) {\n") + .append(" return ").append(input.javaName()) + .append("(row, col, BigInteger.valueOf(value));\n") + .append(" }\n\n") + .append(" public Inputs ").append(input.javaName()) + .append("(List> values) {\n") + .append(" if (values.size() != ").append(outerSizeExpression).append(") {\n") + .append(" throw new IllegalArgumentException(") + .append(stringLiteral(input.baseName() + " expects ")).append(" + ").append(outerSizeExpression) + .append(" + \" rows\");\n") + .append(" }\n") + .append(" for (int row = 0; row < values.size(); row++) {\n") + .append(" List rowValues = values.get(row);\n") + .append(" if (rowValues == null) {\n") + .append(" throw new NullPointerException(") + .append(stringLiteral(input.baseName() + " row must not be null")).append(");\n") + .append(" }\n") + .append(" if (rowValues.size() != ").append(innerSizeExpression).append(") {\n") + .append(" throw new IllegalArgumentException(") + .append(stringLiteral(input.baseName() + " expects ")).append(" + ").append(innerSizeExpression) + .append(" + \" values per row\");\n") + .append(" }\n") + .append(" }\n") + .append(" __zerojInputs.putNestedArray(").append(input.constantName()).append(", values);\n") + .append(" return this;\n") + .append(" }\n\n"); + } + private String schemaKind(InputModel input) { if (input.valueKind() == ValueKind.FIELD || input.arrayElementType().equals(ZK_FIELD)) { return "FIELD"; @@ -829,8 +938,20 @@ private int schemaBits(InputModel input) { } private void renderVarDeclaration(StringBuilder out, InputModel input, String builderLocal, - String loopLocal, String method) { - if (isFixedVector(input.valueKind())) { + String loopLocal, String innerLoopLocal, String method) { + if (isNestedArray(input)) { + out.append(" for (int ").append(loopLocal).append(" = 0; ") + .append(loopLocal).append(" < ").append(input.size().expression()).append("; ") + .append(loopLocal).append("++) {\n") + .append(" for (int ").append(innerLoopLocal).append(" = 0; ") + .append(innerLoopLocal).append(" < ").append(input.innerSize().expression()).append("; ") + .append(innerLoopLocal).append("++) {\n") + .append(" ").append(builderLocal).append(".").append(method).append("(") + .append(input.constantName()).append(" + \"_\" + ").append(loopLocal) + .append(" + \"_\" + ").append(innerLoopLocal).append(");\n") + .append(" }\n") + .append(" }\n"); + } else if (isFixedVector(input.valueKind())) { out.append(" for (int ").append(loopLocal).append(" = 0; ") .append(loopLocal).append(" < ").append(input.size().expression()).append("; ") .append(loopLocal).append("++) {\n") @@ -864,16 +985,32 @@ private String factoryCall(InputModel input, String signalContextLocal) { + input.constantName() + ", " + input.size().expression() + ")"; } if (input.arrayElementType().equals(ZK_FIELD)) { + if (isNestedArray(input)) { + return "ZkArray." + (input.visibility() == Visibility.PUBLIC ? "publicFieldMatrix" : "secretFieldMatrix") + + "(" + signalContextLocal + ", " + input.constantName() + ", " + + input.size().expression() + ", " + input.innerSize().expression() + ")"; + } return "ZkArray." + (input.visibility() == Visibility.PUBLIC ? "publicFields" : "secretFields") + "(" + signalContextLocal + ", " + input.constantName() + ", " + input.size().expression() + ")"; } if (input.arrayElementType().equals(ZK_BOOL)) { + if (isNestedArray(input)) { + return "ZkArray." + (input.visibility() == Visibility.PUBLIC ? "publicBoolMatrix" : "secretBoolMatrix") + + "(" + signalContextLocal + ", " + input.constantName() + ", " + + input.size().expression() + ", " + input.innerSize().expression() + ")"; + } return "ZkArray." + (input.visibility() == Visibility.PUBLIC ? "publicBools" : "secretBools") + "(" + signalContextLocal + ", " + input.constantName() + ", " + input.size().expression() + ")"; } if (input.arrayElementType().equals(ZK_UINT)) { + if (isNestedArray(input)) { + return "ZkArray." + (input.visibility() == Visibility.PUBLIC ? "publicUIntMatrix" : "secretUIntMatrix") + + "(" + signalContextLocal + ", " + input.constantName() + ", " + + input.size().expression() + ", " + input.innerSize().expression() + + ", " + input.bits() + ")"; + } return "ZkArray." + (input.visibility() == Visibility.PUBLIC ? "publicUInts" : "secretUInts") + "(" + signalContextLocal + ", " + input.constantName() + ", " + input.size().expression() + ", " + input.bits() + ")"; @@ -971,8 +1108,41 @@ private String elementType(TypeMirror type) { return erasure(declared.getTypeArguments().get(0)); } + private String arrayLeafType(TypeMirror type) { + if (!isType(type, ZK_ARRAY)) { + return ""; + } + DeclaredType declared = (DeclaredType) type; + if (declared.getTypeArguments().size() != 1) { + throw new GenerationException(null, "ZkArray must declare exactly one element type"); + } + TypeMirror element = declared.getTypeArguments().get(0); + if (!isType(element, ZK_ARRAY)) { + return erasure(element); + } + + DeclaredType inner = (DeclaredType) element; + if (inner.getTypeArguments().size() != 1) { + throw new GenerationException(null, "Nested ZkArray must declare exactly one element type"); + } + TypeMirror leaf = inner.getTypeArguments().get(0); + if (isType(leaf, ZK_ARRAY)) { + throw new GenerationException(null, + "Only two-dimensional ZkArray> inputs are supported"); + } + return erasure(leaf); + } + + private boolean isNestedArray(TypeMirror type) { + return isType(type, ZK_ARRAY) && elementType(type).equals(ZK_ARRAY); + } + + private boolean isNestedArray(InputModel input) { + return input.valueKind() == ValueKind.ARRAY && input.innerSize() != null; + } + private boolean isArrayOf(TypeMirror type, String elementType) { - return isType(type, ZK_ARRAY) && elementType(type).equals(elementType); + return isType(type, ZK_ARRAY) && arrayLeafType(type).equals(elementType); } private boolean isFixedVector(ValueKind valueKind) { @@ -1163,6 +1333,7 @@ private record InputModel( String arrayElementType, int bits, SizeModel size, + SizeModel innerSize, Integer order, boolean fieldStyle) {} diff --git a/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java index 0d41889..9d9d025 100644 --- a/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java +++ b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java @@ -553,6 +553,196 @@ ZkBool prove( assertDoesNotThrow(() -> circuit.calculateWitness(witness, CurveId.BN254)); } + @Test + void nestedUIntArrayGeneratesRowMajorSchemaBuilderAndWitness() throws Exception { + var compilation = compile("test.NestedMatrix", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "nested-matrix", nameTemplate = "nested-matrix-{rows}x{cols}") + public class NestedMatrix { + private final int rows; + private final int cols; + + public NestedMatrix( + @CircuitParam("rows") int rows, + @CircuitParam("cols") int cols) { + this.rows = rows; + this.cols = cols; + } + + @Prove + void prove( + ZkContext zk, + @Public @FixedSize(param = "rows", innerParam = "cols") + ZkArray> expected, + @Secret @UInt(bits = 8) @FixedSize(param = "rows", innerParam = "cols") + ZkArray> values) { + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + values.get(row).get(col).asField().assertEqual(expected.get(row).get(col)); + } + } + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + Class companion = compilation.load("test.NestedMatrixCircuit"); + ZkCircuitSchema schema = (ZkCircuitSchema) companion.getMethod("schema", int.class, int.class) + .invoke(null, 2, 2); + + assertEquals("nested-matrix-2x2--rows-1:2--cols-1:2", schema.name()); + assertEquals(List.of("expected_0_0", "expected_0_1", "expected_1_0", "expected_1_1"), + schema.publicInputs().names()); + assertEquals(List.of("value_0_0", "value_0_1", "value_1_0", "value_1_1"), + schema.secretInputs().names()); + assertEquals(List.of(2, 2), schema.input("value").dimensions()); + assertEquals(4, schema.input("value").size()); + assertEquals(8, schema.input("value_1_1").bits()); + + Object inputs = companion.getMethod("inputs", int.class, int.class).invoke(null, 2, 2); + inputs.getClass().getMethod("expected", List.class).invoke(inputs, List.of( + List.of(BigInteger.ONE, BigInteger.TWO), + List.of(BigInteger.valueOf(3), BigInteger.valueOf(4)))); + inputs.getClass().getMethod("values", int.class, int.class, long.class) + .invoke(inputs, 0, 0, 1L); + inputs.getClass().getMethod("values", List.class).invoke(inputs, List.of( + List.of(BigInteger.ONE, BigInteger.TWO), + List.of(BigInteger.valueOf(3), BigInteger.valueOf(4)))); + + @SuppressWarnings("unchecked") + Map> witness = + (Map>) inputs.getClass().getMethod("toWitnessMap").invoke(inputs); + assertEquals(BigInteger.valueOf(4), witness.get("value_1_1").getFirst()); + assertEquals(List.of(BigInteger.ONE, BigInteger.TWO, BigInteger.valueOf(3), BigInteger.valueOf(4)), + companion.getMethod("publicInputs", inputs.getClass()).invoke(null, inputs)); + + CircuitBuilder circuit = (CircuitBuilder) companion.getMethod("build", int.class, int.class) + .invoke(null, 2, 2); + assertDoesNotThrow(() -> circuit.calculateWitness(witness, CurveId.BN254)); + + Object badInputs = companion.getMethod("inputs", int.class, int.class).invoke(null, 2, 2); + var ragged = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> badInputs.getClass().getMethod("values", List.class).invoke(badInputs, List.of( + List.of(BigInteger.ONE), + List.of(BigInteger.TWO, BigInteger.valueOf(3))))); + assertTrue(ragged.getCause() instanceof IllegalArgumentException); + + Object wrongInputs = companion.getMethod("inputs", int.class, int.class).invoke(null, 2, 2); + wrongInputs.getClass().getMethod("expected", List.class).invoke(wrongInputs, List.of( + List.of(BigInteger.ONE, BigInteger.TWO), + List.of(BigInteger.valueOf(3), BigInteger.valueOf(4)))); + wrongInputs.getClass().getMethod("values", List.class).invoke(wrongInputs, List.of( + List.of(BigInteger.ONE, BigInteger.TWO), + List.of(BigInteger.valueOf(3), BigInteger.valueOf(5)))); + @SuppressWarnings("unchecked") + Map> wrongWitness = + (Map>) wrongInputs.getClass().getMethod("toWitnessMap").invoke(wrongInputs); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(wrongWitness, CurveId.BN254)); + } + + @Test + void nestedBoolArrayConstrainsEveryElement() throws Exception { + var compilation = compile("test.NestedBoolMatrix", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit(name = "nested-bool-matrix") + public class NestedBoolMatrix { + @Prove + ZkBool prove( + @Secret @FixedSize(value = 2, inner = 2) + ZkArray> flags, + @Public ZkBool ok) { + return flags.get(0).get(0) + .and(flags.get(0).get(1)) + .and(flags.get(1).get(0)) + .and(flags.get(1).get(1)) + .and(ok); + } + } + """); + + assertTrue(compilation.success(), compilation.diagnosticsText()); + Class companion = compilation.load("test.NestedBoolMatrixCircuit"); + CircuitBuilder circuit = (CircuitBuilder) companion.getMethod("build").invoke(null); + + assertDoesNotThrow(() -> circuit.calculateWitness(Map.of( + "flag_0_0", List.of(BigInteger.ONE), + "flag_0_1", List.of(BigInteger.ONE), + "flag_1_0", List.of(BigInteger.ONE), + "flag_1_1", List.of(BigInteger.ONE), + "ok", List.of(BigInteger.ONE)), CurveId.BN254)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness(Map.of( + "flag_0_0", List.of(BigInteger.ONE), + "flag_0_1", List.of(BigInteger.TWO), + "flag_1_0", List.of(BigInteger.ONE), + "flag_1_1", List.of(BigInteger.ONE), + "ok", List.of(BigInteger.ONE)), CurveId.BN254)); + } + + @Test + void rejectsInvalidNestedArrayShapes() throws Exception { + var missingInner = compile("test.MissingInner", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class MissingInner { + @Prove + ZkBool prove( + @Secret @FixedSize(2) ZkArray> matrix, + @Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(missingInner.success()); + assertTrue(missingInner.diagnosticsText().contains("@FixedSize inner dimension")); + + var innerOnFlat = compile("test.InnerOnFlat", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class InnerOnFlat { + @Prove + ZkBool prove( + @Secret @FixedSize(value = 2, inner = 2) ZkArray items, + @Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(innerOnFlat.success()); + assertTrue(innerOnFlat.diagnosticsText().contains("inner or innerParam can only be used")); + + var deeper = compile("test.DeeperArray", """ + package test; + + import com.bloxbean.cardano.zeroj.circuit.annotation.*; + + @ZKCircuit + public class DeeperArray { + @Prove + ZkBool prove( + @Secret @FixedSize(value = 2, inner = 2) + ZkArray>> matrix, + @Public ZkBool ok) { + return ok; + } + } + """); + assertFalse(deeper.success()); + assertTrue(deeper.diagnosticsText().contains("Only two-dimensional")); + } + @Test void parameterizedCircuitWithoutNameTemplateUsesCanonicalSuffix() throws Exception { var compilation = compile("test.ParamStatic", """ diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedBatchThresholdMatrix.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedBatchThresholdMatrix.java new file mode 100644 index 0000000..a94f6a1 --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedBatchThresholdMatrix.java @@ -0,0 +1,49 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.CircuitParam; +import com.bloxbean.cardano.zeroj.circuit.annotation.FixedSize; +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.UInt; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; + +@ZKCircuit( + name = "annotation-batch-threshold-matrix", + nameTemplate = "annotation-batch-threshold-matrix-{rows}x{cols}", + version = 1) +public class AnnotatedBatchThresholdMatrix { + private final int rows; + private final int cols; + + public AnnotatedBatchThresholdMatrix( + @CircuitParam("rows") int rows, + @CircuitParam("cols") int cols) { + this.rows = rows; + this.cols = cols; + } + + @Prove + ZkBool prove( + ZkContext zk, + @Public @UInt(bits = 16) + @FixedSize(param = "cols") + ZkArray columnMaximums, + @Secret @UInt(bits = 16) + @FixedSize(param = "rows", innerParam = "cols") + ZkArray> measurements) { + ZkBool ok = measurements.get(0).get(0).lte(columnMaximums.get(0)); + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + if (row != 0 || col != 0) { + ok = ok.and(measurements.get(row).get(col).lte(columnMaximums.get(col))); + } + } + } + return ok; + } +} diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java index f17a3bc..1675dfe 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java @@ -384,6 +384,53 @@ void blsPoseidonMerkleMembershipUsesParamsAwareHelper() { () -> circuit.calculateWitness(invalid.toWitnessMap(), CurveId.BLS12_381)); } + @Test + void nestedBatchThresholdMatrixUsesRowMajorGeneratedInputs() { + int rows = 2; + int cols = 3; + var circuit = AnnotatedBatchThresholdMatrixCircuit.build(rows, cols); + var schema = AnnotatedBatchThresholdMatrixCircuit.schema(rows, cols); + + assertEquals("annotation-batch-threshold-matrix-2x3--rows-1:2--cols-1:3", schema.name()); + assertEquals(List.of("columnMaximum_0", "columnMaximum_1", "columnMaximum_2"), + schema.publicInputs().names()); + assertEquals(List.of( + "measurement_0_0", "measurement_0_1", "measurement_0_2", + "measurement_1_0", "measurement_1_1", "measurement_1_2"), + schema.secretInputs().names()); + assertEquals(List.of(2, 3), schema.input("measurement").dimensions()); + + var inputs = AnnotatedBatchThresholdMatrixCircuit.inputs(rows, cols) + .columnMaximums(List.of( + BigInteger.valueOf(10), + BigInteger.valueOf(20), + BigInteger.valueOf(30))) + .measurements(List.of( + List.of(BigInteger.valueOf(7), BigInteger.valueOf(18), BigInteger.valueOf(29)), + List.of(BigInteger.valueOf(10), BigInteger.valueOf(20), BigInteger.valueOf(30)))); + + assertEquals(List.of(BigInteger.valueOf(10), BigInteger.valueOf(20), BigInteger.valueOf(30)), + inputs.publicValues()); + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381)); + + var outOfBoundsMeasurement = AnnotatedBatchThresholdMatrixCircuit.inputs(rows, cols) + .columnMaximums(List.of( + BigInteger.valueOf(10), + BigInteger.valueOf(20), + BigInteger.valueOf(30))) + .measurements(List.of( + List.of(BigInteger.valueOf(7), BigInteger.valueOf(21), BigInteger.valueOf(29)), + List.of(BigInteger.valueOf(10), BigInteger.valueOf(20), BigInteger.valueOf(30)))); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(outOfBoundsMeasurement.toWitnessMap(), CurveId.BLS12_381)); + + assertThrows(IllegalArgumentException.class, + () -> AnnotatedBatchThresholdMatrixCircuit.inputs(rows, cols) + .measurements(List.of( + List.of(BigInteger.ONE), + List.of(BigInteger.TWO, BigInteger.valueOf(3), BigInteger.valueOf(4))))); + } + @Test void pedersenCommitmentUsesAdvancedSymbolicAdapter() { var circuit = AnnotatedPedersenCommitmentCircuit.build(); From f3f519676dbc6d8d8067826422729a019e51ca15 Mon Sep 17 00:00:00 2001 From: Satya Date: Tue, 19 May 2026 00:42:04 +0800 Subject: [PATCH 22/26] feat: add Poseidon MPF symbolic gadget --- build.gradle | 6 + .../cardano-gadget-support-matrix.md | 33 +- docs/adr/circuit-annotation/zk-mpf-gadget.md | 848 ++++++++++++++++++ docs/circuit-annotation-user-guide.md | 6 +- settings.gradle | 1 + zeroj-bom-all/build.gradle | 1 + zeroj-bom-core/build.gradle | 1 + zeroj-circuit-annotation-api/README.md | 7 +- zeroj-circuit-lib/README.md | 2 +- .../cardano/zeroj/circuit/lib/zk/ZkMpf.java | 632 +++++++++++++ .../zeroj/circuit/lib/zk/ZkMpfProof.java | 222 +++++ zeroj-examples/README.md | 1 + zeroj-examples/build.gradle | 2 + .../AnnotatedMpfPrivateRegistryInclusion.java | 97 ++ .../AnnotatedCircuitExamplesTest.java | 41 + zeroj-mpf-poseidon/README.md | 98 ++ zeroj-mpf-poseidon/build.gradle | 27 + .../zeroj/mpf/poseidon/InMemoryNodeStore.java | 50 ++ .../zeroj/mpf/poseidon/PoseidonMpfCodec.java | 277 ++++++ .../poseidon/PoseidonMpfCommitmentScheme.java | 156 ++++ .../zeroj/mpf/poseidon/PoseidonMpfHash.java | 153 ++++ .../mpf/poseidon/PoseidonMpfHashFunction.java | 31 + .../mpf/poseidon/PoseidonMpfReference.java | 38 + .../zeroj/mpf/poseidon/PoseidonMpfTrie.java | 42 + .../poseidon/PoseidonMpfValueCommitment.java | 29 + .../mpf/poseidon/PoseidonMpfWitness.java | 103 +++ .../mpf/poseidon/PoseidonMpfAdapterTest.java | 93 ++ .../mpf/poseidon/ZkMpfPoseidonGadgetTest.java | 318 +++++++ 28 files changed, 3308 insertions(+), 7 deletions(-) create mode 100644 docs/adr/circuit-annotation/zk-mpf-gadget.md create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpf.java create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpfProof.java create mode 100644 zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMpfPrivateRegistryInclusion.java create mode 100644 zeroj-mpf-poseidon/README.md create mode 100644 zeroj-mpf-poseidon/build.gradle create mode 100644 zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/InMemoryNodeStore.java create mode 100644 zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfCodec.java create mode 100644 zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfCommitmentScheme.java create mode 100644 zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfHash.java create mode 100644 zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfHashFunction.java create mode 100644 zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfReference.java create mode 100644 zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfTrie.java create mode 100644 zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfValueCommitment.java create mode 100644 zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfWitness.java create mode 100644 zeroj-mpf-poseidon/src/test/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfAdapterTest.java create mode 100644 zeroj-mpf-poseidon/src/test/java/com/bloxbean/cardano/zeroj/mpf/poseidon/ZkMpfPoseidonGadgetTest.java diff --git a/build.gradle b/build.gradle index 825e842..0fade4b 100644 --- a/build.gradle +++ b/build.gradle @@ -10,6 +10,12 @@ allprojects { repositories { mavenCentral() mavenLocal() + maven { + url = uri("https://central.sonatype.com/repository/maven-snapshots") + content { + snapshotsOnly() + } + } } } diff --git a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md index 057c3de..80bffe6 100644 --- a/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -114,6 +114,7 @@ Relevant source: | `ZkMerkle.HashType.MIMC` | Merkle with MiMC | BN254 only | Direct | No for Cardano on-chain | Mark as BN254/off-chain in docs. | | `ZkMerkle.HashType.POSEIDON` | Merkle with default Poseidon | BN254 by default today | Direct | No if using default enum path | Use params-aware `ZkMerkle.*Poseidon(...)` for Cardano. | | `ZkMerkle` with custom hash lambda | Merkle with caller-provided hash | Depends on lambda | Direct | Yes with a BLS12-381-compatible lambda | Keep as advanced escape hatch. | +| `ZkMpf` / `zeroj-mpf-poseidon` | Private CCL MPF inclusion and conservative exclusion over a Poseidon-rooted commitment | BLS12-381 Poseidon only | Direct through `ZkMpfProof` flattened arrays and `PoseidonMpfCodec` witness generation | Path exists through Groth16 BLS12-381; MPF-specific proof/Yaci demo is deferred until constraint optimization. Not compatible with native Aiken/Blake2b MPF roots. | Completed at witness level. Terminal fork exclusions are rejected in v1. Use `Groth16BLS12381Lib` in custom validators for root/nullifier/domain checks. | | `JubjubPoint` | Off-circuit Jubjub point arithmetic | Jubjub over BLS12-381 scalar field | Used by symbolic wrappers | Yes for BLS12-381 circuits | None. | | `InCircuitJubjub` | In-circuit Jubjub arithmetic | Requires BLS12-381 scalar field | Direct through `ZkJubjubPoint` | Yes | Document trusted point binding and subgroup-check contract. | | `ZkJubjubPoint` | Symbolic Jubjub point | Requires BLS12-381 scalar field | Direct | Yes | Add in-circuit curve/subgroup checks only if untrusted public points must be accepted directly. | @@ -383,6 +384,35 @@ Exit criteria: - No existing BN254 MiMC circuit changes behavior. - BLS12-381 MiMC is only exposed under an explicit name. +### Phase H: Poseidon MPF Gadget + +Status: completed. The design and implementation are tracked in +[`zk-mpf-gadget.md`](zk-mpf-gadget.md). + +Goal: support private CCL MPF inclusion and conservative exclusion witnesses +inside annotated BLS12-381 circuits. + +Tasks: + +- Added `zeroj-mpf-poseidon` with a CCL `HashFunction`, custom + `CommitmentScheme`, in-memory trie helpers, reference verifier, value + commitments, and a proof-to-witness codec. +- Added `ZkMpfProof` and `ZkMpf` symbolic helpers. +- Added BLS12-381 inclusion/exclusion differential tests against supported + CCL-generated proofs, plus rejection for forged terminal-fork exclusion. +- Added an annotated private-registry inclusion example using the generated + circuit surface. +- Added a standalone `zeroj-usecases` witness-level private registry example. +- Documented that ZeroJ Poseidon MPF roots are separate from native + Blake2b/Aiken MPF roots. + +Exit criteria: + +- CCL-generated Poseidon MPF inclusion and supported exclusion proofs verify + inside ZeroJ circuits. +- Developers can build witness maps from CCL proof bytes without hand-flattening + MPF arrays. + ## Recommended Priority 1. Documentation and defaults. Completed. @@ -391,7 +421,8 @@ Exit criteria: 4. Generic/generated Cardano Groth16 verifier for arbitrary public-input count. Completed. 5. Example migration to BLS12-381 Poseidon. Completed. 6. Nested `ZkArray>` support. Completed. -7. Optional BLS12-381 MiMC only if a real integration requires it. +7. Poseidon MPF gadget. Completed. +8. Optional BLS12-381 MiMC only if a real integration requires it. ## Testing Strategy diff --git a/docs/adr/circuit-annotation/zk-mpf-gadget.md b/docs/adr/circuit-annotation/zk-mpf-gadget.md new file mode 100644 index 0000000..cca1d7c --- /dev/null +++ b/docs/adr/circuit-annotation/zk-mpf-gadget.md @@ -0,0 +1,848 @@ +# ADR: Symbolic MPF Gadget for Poseidon-Rooted Cardano State + +## Status + +Implemented for off-chain Poseidon MPF adapters, codec, symbolic inclusion, +conservative symbolic exclusion, annotated example coverage, and a standalone +witness-level usecase. Groth16/Yaci full-stack MPF proving remains follow-up +work because the first symbolic verifier is constraint-heavy. + +## Date + +2026-05-18 + +## Vision + +ZeroJ should let Java developers prove statements about large Cardano-style +state commitments without revealing the full lookup proof, key, or value to the +validator. + +The intended developer experience is: + +```java +@ZKCircuit(name = "PrivateRegistryMembership", version = 1) +public final class PrivateRegistryMembership { + private static final PoseidonParams POSEIDON = PoseidonParamsBLS12_381T3.INSTANCE; + + @CircuitParam("MAX_STEPS") + private final int maxSteps; + @CircuitParam("MAX_FORK_PREFIX_CHUNKS") + private final int maxForkPrefixChunks; + + public PrivateRegistryMembership( + @CircuitParam("MAX_STEPS") int maxSteps, + @CircuitParam("MAX_FORK_PREFIX_CHUNKS") int maxForkPrefixChunks) { + this.maxSteps = maxSteps; + this.maxForkPrefixChunks = maxForkPrefixChunks; + } + + @Prove + public void prove( + ZkContext zk, + @Public ZkField registryRoot, + @Public ZkField keyPathNullifier, + @Secret @FixedSize(64) @UInt(bits = 4) ZkArray keyPath, + @Secret ZkField valueCommitment, + @Secret @FixedSize(param = "MAX_STEPS") @UInt(bits = 2) ZkArray stepKind, + @Secret @FixedSize(param = "MAX_STEPS") @UInt(bits = 8) ZkArray stepSkip, + @Secret @FixedSize(param = "MAX_STEPS", inner = 4) ZkArray> neighbors, + @Secret @FixedSize(param = "MAX_STEPS") @UInt(bits = 4) ZkArray neighborNibble, + @Secret @FixedSize(param = "MAX_STEPS") @UInt(bits = 8) ZkArray forkPrefixLength, + @Secret @FixedSize(param = "MAX_STEPS", innerParam = "MAX_FORK_PREFIX_CHUNKS") ZkArray> forkPrefixChunks, + @Secret @FixedSize(param = "MAX_STEPS") ZkArray forkRoot, + @Secret @FixedSize(param = "MAX_STEPS", inner = 64) @UInt(bits = 4) ZkArray> leafKeyPath, + @Secret @FixedSize(param = "MAX_STEPS") ZkArray leafValueDigest, + @Secret @FixedSize(param = "MAX_STEPS") ZkArray valid) { + + ZkMpfProof proof = ZkMpfProof.fromArrays( + stepKind, stepSkip, neighbors, neighborNibble, + forkPrefixLength, forkPrefixChunks, forkRoot, + leafKeyPath, leafValueDigest, valid); + + ZkMpf.verifyInclusionPoseidon( + zk, POSEIDON, keyPath, valueCommitment, registryRoot, proof); + + ZkMpf.keyPathNullifier(zk, POSEIDON, keyPath).assertEqual(keyPathNullifier); + } +} +``` + +The public validator sees only: + +```text +Groth16 proof + public inputs chosen by the application +``` + +For a private registry that may be only `(registryRoot, keyPathNullifier)`. +For a public proof it may expose the key path nibbles or a path commitment, +depending on the application. The MPF proof itself remains a witness inside the +circuit. + +The core MPF gadget works over `keyPath`, the nibble array obtained from +CCL's `hashFn.digest(key)`, not raw key bytes. This avoids unsafe 255-bit field +byte decomposition in the circuit and exactly matches the path consumed by CCL +`WireProof`. Applications that need to prove how raw key bytes map to +`keyPath` can add a separate byte/key binding gadget. The v1 usecase uses +field-native registry identifiers and publishes a nullifier/commitment of the +CCL key path. + +## Context + +Cardano Client Lib (CCL) provides Merkle Patricia Forestry (MPF) with: + +- `MpfTrie` +- pluggable `HashFunction` +- pluggable `CommitmentScheme` +- wire proofs consumed by `ProofVerifier` + +The default Cardano path uses Blake2b-256 and Aiken's native +`aiken-lang/merkle-patricia-forestry` verifier. That path is the right tool for +normal public inclusion or exclusion checks. + +ZeroJ needs a separate path for: + +- private registry membership/non-membership +- Java L2 or rollup state proofs +- proof compression for many off-chain MPF reads +- applications that already use CCL MPF off-chain but want a succinct Cardano + Groth16 verifier on-chain + +This ADR defines a Poseidon-rooted MPF path that is compatible with CCL's MPF +structure, but not interchangeable with the default Blake2b/Aiken commitment. + +CCL itself must not be changed for this feature. ZeroJ integrates with CCL +through public constructors and proof APIs. + +## Source Of Truth: CCL MPF Wire Semantics + +The circuit must match CCL's wire proof recomputation, not an independent MPF +interpretation. + +The local CCL checkout is at: + +```text +/Users/satya/work/bloxbean/cardano-client-lib +``` + +The relevant CCL classes are: + +- `com.bloxbean.cardano.vds.mpf.MpfTrie` +- `com.bloxbean.cardano.vds.mpf.commitment.CommitmentScheme` +- `com.bloxbean.cardano.vds.mpf.commitment.MpfCommitmentScheme` +- `com.bloxbean.cardano.vds.mpf.proof.ProofSerializer` +- `com.bloxbean.cardano.vds.mpf.proof.ProofVerifier` +- `com.bloxbean.cardano.vds.mpf.proof.WireProof` + +Important CCL facts the ZeroJ implementation must preserve: + +- `MpfTrie.put(key, value)` stores the path `hashFn.digest(key)`. +- `MpfTrie.getProofWire(key)` generates a proof for `hashFn.digest(key)`. +- `ProofVerifier.verify(root, key, value, including, proof, hashFn, + commitments)` recomputes the path by calling `hashFn.digest(key)`. +- `WireProof.computeRoot(...)` recursively recomputes the root. +- Branch proof steps carry four neighbor commitments. The neighbor order is the + CCL order produced by `ProofSerializer.computeNeighbors(...)`, from the + largest opposite subtree down to the local sibling. +- Branch recomputation uses `hashFn.digest(prefixBytes || binarySubRoot)`. +- Fork recomputation has two CCL cases: + - terminal fork non-inclusion returns `fork.root()` directly after checking + that the query nibble diverges from the fork neighbor nibble; this CCL wire + shape is not accepted by the v1 circuit because `fork.root()` is not + authenticated by any in-circuit hash relation; + - non-terminal fork roll-up hashes `forkPrefixBytes || forkRoot` before + placing the fork neighbor into a sparse branch. +- Leaf recomputation uses `commitments.commitLeaf(suffix, valueHash)`. +- Inclusion has an implicit terminal leaf when the proof step list is + exhausted. CCL does not require a terminal `LeafStep` for normal inclusion. + An empty proof is valid for a single-leaf inclusion. +- Empty-trie exclusion is represented by an empty proof whose recomputed root is + `null`, normalized by `ProofVerifier` to `commitments.nullHash()`. +- `LeafStep` is a terminal step only for the different-leaf non-inclusion case. +- CCL's default `MpfCommitmentScheme.commitLeaf(...)` uses MPF odd/even suffix + byte encoding, including the empty-suffix marker. The Poseidon ZK path does + not reuse that default byte encoding because some valid suffixes produce + 32-byte chunks that are not scalar-field elements. Instead, + `PoseidonMpfCommitmentScheme.commitLeaf(...)` defines a custom + circuit-friendly leaf commitment through CCL's public `CommitmentScheme` + interface. +- Branch `branchValueHash` is outside the v1 circuit-compatible profile. CCL's + default MPF mode for fixed-length hashed keys does not need branch values. + The codec must reject proofs that contain branch value hashes. +- `CommitmentScheme` customizes node commitment behavior, but it does not by + itself define all proof recomputation. `WireProof.computeRoot(...)` is the + complete behavior to mirror. + +Therefore the first implementation milestone is a CCL-compatible Poseidon MPF +reference and test-vector suite. Circuit code starts only after those vectors +are stable. + +## Key Distinction + +There are two different MPF commitment universes: + +| Path | Hash | Verifier | Interchangeable? | +|------|------|----------|------------------| +| Native Cardano MPF | Blake2b-256 | Aiken MPF verifier | Compatible with existing Aiken MPF roots | +| ZeroJ ZK MPF | BLS12-381 Poseidon byte digest | Groth16 BLS12-381 verifier | Not compatible with Blake2b/Aiken roots | + +The ZK path does not execute MPF logic on-chain. A proof-only test can use the +generic `Groth16BLS12381Verifier`. A real application validator should call the +reusable `Groth16BLS12381Lib.verify(...)` helper and then apply domain logic +such as registry-root matching and nullifier uniqueness in the same validator. + +## Decision + +Add a Poseidon-rooted MPF stack in ZeroJ: + +1. **`zeroj-mpf-poseidon` module** + - Provides a CCL `HashFunction` backed by BLS12-381 Poseidon. + - Provides a CCL `CommitmentScheme` compatible with the Poseidon digest. + - Provides `PoseidonMpfTrie` convenience builders around CCL `MpfTrie`. + - Provides `PoseidonMpfCodec` to convert CCL wire proofs into symbolic + witness inputs. + - Provides `PoseidonMpfReference` that calls or mirrors CCL + `ProofVerifier` behavior with the Poseidon adapters. + +2. **`ZkMpf` gadget in `zeroj-circuit-lib`** + - Verifies Poseidon-rooted MPF inclusion and conservative exclusion proofs + over `CurveId.BLS12_381`. + - Uses explicit `PoseidonParams`. + - Rejects mismatched circuit fields through the same field-guard pattern as + `ZkPoseidon` and `ZkMerkle`. + +3. **`ZkMpfProof` ergonomic wrapper in `zeroj-circuit-lib` or annotation API** + - Does not extend `ZkArray`; `ZkArray` is final today. + - Wraps arrays already supported by the annotation processor. + - Keeps v1 implementation compatible with existing generated-code support. + +4. **Annotated examples and witness-level usecase** + - Unit and integration tests live in ZeroJ. + - A standalone demonstration lives in `zeroj-usecases`, building the CCL + registry, witness arrays, and BLS12-381 circuit witness. Proof generation + and Yaci submission are follow-up work once constraint cost is reduced. + +## Non-Goals + +- No CCL source changes. +- No in-circuit Blake2b. +- No conversion between Blake2b MPF roots and Poseidon MPF roots. +- No native Aiken verifier for Poseidon MPF. +- No variable-length symbolic proof inputs. Proof length is bounded by + `@CircuitParam`. +- No batched MPF reads in v1. Batching is a follow-up once single-key proofs are + correct. +- No state-transition proof in v1. `(rootBefore, rootAfter, key, oldValue, + newValue)` is a follow-up built on the same proof primitives. + +## Poseidon Digest Contract + +CCL's `HashFunction.digest(byte[])` accepts arbitrary bytes. Poseidon accepts +field elements. The ZeroJ adapter must define a canonical byte-to-field digest +that is efficient to reproduce in-circuit for the byte strings that CCL +`WireProof` hashes directly: branch prefixes, fork prefixes, and child/root +commitments. Leaf commitments are handled by `PoseidonMpfCommitmentScheme` +instead of by CCL's default HP-byte leaf encoding. + +Define `PoseidonMpfHash.digest(bytes)` as a fixed-width, padded digest: + +```text +chunks = canonical 32-byte-aligned chunks: + if len(bytes) % 32 != 0: + chunk0 = first len(bytes) % 32 bytes as an unsigned integer + remaining chunks are 32-byte unsigned integers + else: + all chunks are 32-byte unsigned integers +fields = [domain, byteLength, chunk0, chunk1, chunk2] +missing chunks are zero +digest = PoseidonN_BLS12_381(fields) +output = digest encoded as exactly 32 big-endian bytes +``` + +Rules: + +- `domain` is a fixed field constant for MPF byte digests. +- `byteLength` is included to avoid chunk-padding ambiguity. +- v1 supports at most three digest chunks, enough for all CCL MPF internal + byte strings over a 64-nibble path: pair hashes (`64` bytes), prefix+root + hashes (`32..96` bytes), and field-native key/value examples. +- Every 32-byte chunk must be the canonical unsigned big-endian encoding of a + BLS12-381 scalar-field element. The adapter rejects byte strings whose + 32-byte chunks are not `< field modulus`. +- The first short chunk, when present, is always `< 2^248` and therefore is + safely inside the scalar field. +- Output bytes must be exactly 32 bytes, unsigned, big-endian. +- The in-circuit gadget operates on the field value of the digest and avoids + decomposing a 255-bit field element into bytes. Whenever CCL concatenates a + prefix with a digest, the circuit mirrors the same three padded chunks before + calling one fixed-arity `PoseidonN`. +- The generic digest must not be used for CCL default leaf HP byte strings in + the Poseidon profile. Leaf commitments use the custom leaf contract below. +- The same implementation is used by: + - CCL `HashFunction` + - off-chain test vectors + - `ZkMpf` circuit byte-digest helper + +This digest contract is a ZeroJ commitment. It is not Aiken MPF's Blake2b +digest and must be documented as a separate commitment scheme. + +Define `PoseidonMpfCommitmentScheme.commitLeaf(suffix, valueHash)` as: + +```text +suffixNibbles = suffix.getNibbles() +suffixChunks = pack suffix nibbles, 31 bytes per chunk, each nibble in [0, 15] +leaf = PoseidonN_BLS12_381( + DOMAIN_MPF_LEAF, + suffixLengthInNibbles, + suffixChunk0, + suffixChunk1, + suffixChunk2, + valueHashField) +``` + +This deliberately does not match Aiken/native MPF leaf bytes. It is still +CCL-compatible for the Poseidon profile because both CCL `MpfTrie` and CCL +`ProofVerifier` receive the same custom `PoseidonMpfCommitmentScheme`. + +Define `PoseidonMpfCommitmentScheme.commitBranch(prefix, children, valueHash)` +as the CCL `WireProof` branch behavior for the Poseidon profile: + +```text +subRoot = binary Merkle root over the 16 child commitments using + PoseidonMpfHash.digest(leftDigest || rightDigest) +branch = PoseidonMpfHash.digest(prefixNibbleBytes || subRootDigest) +``` + +`valueHash` in branch commitments is unsupported in v1; the codec rejects any +proof carrying branch value hashes. + +For v1 circuit-compatible tries, original keys and values should be encoded as +one or more canonical scalar-field byte chunks. The MPF gadget itself accepts +the resulting `keyPath` nibbles and `valueCommitment`; raw byte binding is +separate application logic. + +## Proof Input Shape + +The originally proposed `ZkMpfProof extends ZkArray` is rejected. +It is not compatible with current ZeroJ because `ZkArray` is final and the +annotation processor currently binds only: + +- `ZkField` +- `ZkBool` +- `ZkUInt` +- `ZkArray` +- `ZkArray` +- `ZkArray` +- rectangular `ZkArray>` for those scalar leaves + +The v1 proof shape uses supported arrays: + +```java +public final class ZkMpfProof implements ZkValue { + private final ZkArray kind; // bits=2 + private final ZkArray skip; // bits=8 or MAX_SKIP bits + private final ZkArray> neighbors; // [MAX_STEPS][4] + private final ZkArray neighborNibble; // bits=4, fork/leaf divergent nibble + private final ZkArray forkPrefixLength; // bytes in fork prefix + private final ZkArray> forkPrefixChunks; + private final ZkArray forkRoot; + private final ZkArray> leafKeyPath; + private final ZkArray leafValueDigest; + private final ZkArray valid; + + public static ZkMpfProof fromArrays(...); +} +``` + +The generated annotated circuit declares these arrays directly. Application +code wraps them immediately with `ZkMpfProof.fromArrays(...)`. + +Use explicit annotation names for codec-facing arrays. The annotation processor +singularizes array names by default, so generated examples should prefer: + +```java +@Secret(name = "mpf_kind") +@Secret(name = "mpf_neighbor") +@Secret(name = "mpf_fork_prefix") +``` + +`ZkMpfProof` must implement `signals()` by concatenating the wrapped arrays and +`assertWellFormed()` by delegating to every wrapped array. + +Future work may add true composite symbolic inputs to the annotation processor, +but the MPF v1 should not depend on that larger compiler feature. + +## Step Encoding + +Each proof slot is one of: + +| Kind | Meaning | Fields used | +|-----:|---------|-------------| +| `0` | Branch | `skip`, `neighbors[4]` | +| `1` | Fork | `skip`, `neighborNibble`, `forkPrefixLength`, `forkPrefixChunks`, `forkRoot` | +| `2` | Leaf | `skip`, `leafKeyPath[64]`, `leafValueDigest` | +| `3` | Padding | none; all payload fields must be zero or ignored | + +Circuit constraints: + +- `valid[i + 1] => valid[i]`. +- `valid[i] <=> kind[i] != 3`, except an all-padding proof is allowed for the + two CCL empty-step cases: single-leaf inclusion and empty-trie exclusion. +- `kind` is constrained to two bits and must be one of `0..3`. +- `neighborNibble` is constrained to four bits. +- `skip` is constrained to the configured maximum. +- The cumulative cursor must stay within the digest path length: + `cursor + skip + 1 <= 64` for valid branch/fork/leaf steps. +- Padding steps do not affect the accumulator. +- Inclusion termination is implicit when the valid step suffix is exhausted: + commit the remaining key-path suffix with `valueCommitment`, exactly as CCL + `WireProof.computeRoot(...)` does. +- Exclusion termination supports missing-branch, different-leaf, and empty-tree + cases. CCL terminal fork exclusion is rejected in v1 because the terminal + `forkRoot` payload is not authenticated by an in-circuit hash relation. +- The circuit must reject a proof that contains valid steps after padding. +- The query child nibble is always derived from `keyPath` at + `cursor + skip`; it is not witness-provided. +- A non-terminal fork or different-leaf terminal step must prove the neighbor + nibble differs from the query nibble. Terminal fork exclusion steps are + rejected. +- A different-leaf terminal step derives the neighbor suffix from + `leafKeyPath`, checks that it shares the already-consumed prefix with + `keyPath`, and checks that it diverges at the terminal nibble. It must not + use an opaque field in place of the neighbor path. +- Invalid slots must have zero payloads in the codec output; the circuit ignores + them through `valid` selectors. + +The codec is responsible for preserving CCL's exact neighbor ordering and +termination payloads. Tests must cover every CCL step type. +The codec must emit `keyPath = nibbles(hashFn.digest(rawKeyBytes))`; tests must +reject a witness where the proof is valid but `keyPath` is changed. + +## Public Inputs And Privacy Modes + +The gadget must not force `key` or `value` to be public. + +Recommended application modes: + +| Mode | Public inputs | Secret inputs | Use case | +|------|---------------|---------------|----------| +| Public inclusion | `[root, keyPathCommitment, valueCommitment, mode=1]` | `keyPath`, proof | Transparent-ish proof with compact public path binding | +| Private inclusion | `[root, keyPathNullifier]` | `keyPath`, `valueCommitment`, proof | Registry membership without revealing key/value | +| Public exclusion | `[root, keyPathCommitment, mode=0]` | `keyPath`, proof | Non-membership with compact public path binding | +| Private exclusion | `[root, keyPathNullifier]` | `keyPath`, proof | Private non-membership or anti-duplication | + +Encoding: + +- Public inputs are field integers in generated schema order. +- The datum public-input list passed to the generic verifier is positional and + must match the verification key's `IC` vector. +- Private inclusion schema order is exactly `[root, keyPathNullifier]`. +- Private exclusion schema order is exactly `[root, keyPathNullifier]`. +- Public inclusion schema order is exactly `[root, keyPathCommitment, + valueCommitment, mode]`. +- Public exclusion schema order is exactly `[root, keyPathCommitment, mode]`. +- `mode` is encoded as field `1` for inclusion and field `0` for exclusion. +- A custom application validator that needs domain checks must not use the + generic proof-only validator directly. It should call + `Groth16BLS12381Lib.verify(...)` and then enforce root/nullifier/domain + logic in the same validator. + +`mode_flag` should be public only when one circuit intentionally supports both +inclusion and exclusion. If the application has separate circuits, the mode +should be fixed by the circuit identity instead. + +## In-Circuit API + +```java +public final class ZkMpf { + public static ZkBool isIncludedPoseidon( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath, + ZkField valueCommitment, + ZkField expectedRoot, + ZkMpfProof proof); + + public static void verifyInclusionPoseidon( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath, + ZkField valueCommitment, + ZkField expectedRoot, + ZkMpfProof proof); + + public static ZkBool isExcludedPoseidon( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath, + ZkField expectedRoot, + ZkMpfProof proof); + + public static void verifyExclusionPoseidon( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath, + ZkField expectedRoot, + ZkMpfProof proof); + + public static ZkField keyPathCommitment( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath); + + public static ZkField keyPathNullifier( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath); +} +``` + +Argument order follows the existing symbolic convention: + +```text +zk, params, statement values, expected root, proof +``` + +`keyPath` is the nibble vector represented by `hashFn.digest(rawKeyBytes)` in +CCL. For the CCL-compatible profile, `valueCommitment` is the field value +represented by `hashFn.digest(rawValueBytes)`. Applications that want a +different symbolic value commitment must store that commitment as the MPF value +or use a separate non-CCL profile; otherwise the circuit root will not match the +root produced by CCL `MpfTrie`. + +`ZkMpf.keyPathCommitment(...)` and `ZkMpf.keyPathNullifier(...)` hash the +fixed-size nibble vector with PoseidonN so applications can expose a compact +public binding without making the full path public. + +All `ZkMpf` entry points reject non-BLS12-381 Poseidon params. This is stricter +than generic `ZkPoseidon` because this ADR is a Cardano MPF feature, not a +BN254/off-chain compatibility feature. + +## Module Placement + +Add a new module: + +```text +zeroj-mpf-poseidon +``` + +Dependencies: + +```gradle +api project(':zeroj-circuit-lib') +api "com.bloxbean.cardano:cardano-client-merkle-patricia-forestry:" +api "com.bloxbean.cardano:cardano-client-verified-structures-core:" +``` + +The module belongs in `zeroj-bom-all`. Whether it belongs in `zeroj-bom-core` +depends on dependency weight after implementation review. If it pulls only CCL +verified-structure artifacts and ZeroJ circuit lib, include it in core; if it +pulls RocksDB or application storage, keep those as optional or separate. + +Do not depend on RocksDB in the core Poseidon MPF module. Provide helpers that +accept a CCL `NodeStore`; examples can choose in-memory or RocksDB storage. + +## Implementation Plan + +### Phase 1: ADR Review And Test Vector Lock + +Deliverables: + +- This amended ADR. +- Three independent reviews: + - CCL compatibility review + - symbolic annotation/API review + - Cardano/on-chain/usecase review +- ADR iterations until all reviewers approve or findings are explicitly + accepted as follow-up work. + +Exit criteria: + +- No unresolved blocker on CCL wire compatibility. +- No unresolved blocker on symbolic input binding. +- No unresolved blocker on Cardano public-input semantics. + +### Phase 2: Off-Chain Poseidon MPF Adapter + +Deliverables: + +- `zeroj-mpf-poseidon` module. +- `PoseidonMpfHashFunction`. +- `PoseidonMpfCommitmentScheme`. +- `PoseidonMpfTrie` convenience factory. +- `PoseidonMpfValueCommitment`. +- `PoseidonMpfReference`. + +Tests: + +- Build a CCL `MpfTrie` with the Poseidon adapters. +- Insert deterministic fixtures. +- Verify inclusion and exclusion through CCL `ProofVerifier` with the same + adapters. +- Verify deterministic roots and wire proofs. +- Negative tests for tampered root, key, value, and proof bytes. + +Exit criteria: + +- CCL `verifyProofWire(...)` succeeds for valid Poseidon MPF proofs and fails + for tampered proofs. +- Golden vectors are stored under ZeroJ tests. + +### Phase 3: Codec To Symbolic Witness Inputs + +Deliverables: + +- `PoseidonMpfCodec`. +- Stable witness key naming for generated annotation input maps. +- Proof padding support for `MAX_STEPS`. +- Fork prefix chunking support, bounded by `MAX_FORK_PREFIX_CHUNKS`. +- Leaf-step support for CCL different-leaf non-inclusion, including + `leafKeyPath` nibble payloads. +- Empty proof support for single-leaf inclusion and empty-trie exclusion. +- Proof-shape validation before producing a witness map. + +Tests: + +- CCL proof -> witness map -> reference recomputation. +- Short proofs are padded as a suffix only. +- Proofs longer than `MAX_STEPS` fail with a clear error. +- Neighbor order matches CCL `ProofSerializer`. +- Branch proofs with `branchValueHash` are rejected in v1. + +Exit criteria: + +- An annotated circuit can receive the proof arrays generated by the codec. + +### Phase 4: Inclusion Gadget + +Deliverables: + +- `ZkMpfProof`. +- `ZkMpf.verifyInclusionPoseidon(...)`. +- In-circuit Poseidon byte digest helper for the exact `PoseidonMpfHash` + contract. +- Branch, fork, and leaf recomputation needed for inclusion paths. +- Implicit terminal leaf handling when valid steps are exhausted. + +Tests: + +- Differential tests against `PoseidonMpfReference`. +- Generated annotated circuit test. +- BLS12-381 compile/witness success. +- BN254 compile rejection. +- Negative witnesses for tampered neighbors, wrong key, wrong value, and bad + padding. + +Exit criteria: + +- A CCL-generated Poseidon MPF inclusion proof verifies inside a generated + annotated circuit. + +### Phase 5: Conservative Exclusion Gadget + +Deliverables: + +- `ZkMpf.isExcludedPoseidon(...)`. +- `ZkMpf.verifyExclusionPoseidon(...)`. +- Termination support for: + - missing branch/empty slot + - different leaf + - empty tree +- Explicit rejection for terminal fork exclusion. + +Tests: + +- CCL reference parity for supported exclusion modes. +- Negative test: exclusion for a key that exists must fail. +- Negative test: forged terminal fork exclusion must fail. +- Fuzz tests over deterministic random tries. + +Exit criteria: + +- Supported exclusion proofs generated by CCL with the Poseidon adapters verify + inside ZeroJ circuits. + +### Phase 6: Groth16/Cardano Integration Test (Follow-Up) + +Deliverables in ZeroJ: + +- E2E test that: + 1. builds a Poseidon MPF, + 2. generates an annotated circuit witness, + 3. compiles over `CurveId.BLS12_381`, + 4. generates a Groth16 proof, + 5. verifies with `Groth16BLS12381Lib.verify(...)` in `JulcVm`, + 6. records or asserts budget headroom. + +Yaci Devkit: + +- Use the already-running Yaci Devkit instance for a real transaction-style + validation path when available. +- Keep the default CI test runnable without requiring a long-lived external + node. Yaci-backed tests should be opt-in or guarded by environment + variables. + +Current status: + +- Deferred. The v1 symbolic verifier is correct for tested witness evaluation + but too constraint-heavy for a practical default Groth16/Yaci demonstration. + +Exit criteria: + +- Local deterministic E2E passes. +- Yaci-backed test passes in the developer environment or is clearly skipped + with a reason when Yaci configuration is absent. + +### Phase 7: Witness-Level Usecase + +Deliverables in `/Users/satya/work/bloxbean/zeroj-usecases`: + +- New example project, for example: + +```text +zk-mpf-private-registry/ +``` + +- Off-chain code: + - builds a Poseidon MPF registry with CCL + - generates inclusion witnesses + - evaluates the generated BLS12-381 circuit witness + +- On-chain code is a follow-up: + - custom Julc validator using `Groth16BLS12381Lib.verify(...)` + - app-specific public input checks such as root matching and nullifier + uniqueness + +- README explaining: + - why this is not native Aiken MPF + - which values are public + - how to extend the witness demo into a Groth16/Yaci flow after constraint + optimization + +Exit criteria: + +- The usecase demonstrates private MPF membership witness generation end to end. +- It uses the locally published ZeroJ snapshot. + +### Phase 8: Documentation And Support Matrix + +Deliverables: + +- Update `cardano-gadget-support-matrix.md`. +- Add a `ZkMpf` row: BLS12-381/Groth16 yes, native Aiken/Blake2b MPF no, + proof arrays secret by default, public inputs application-defined. +- Add annotation API docs for `ZkMpf`. +- Add `zeroj-mpf-poseidon/README.md`. +- Document known limitations and when to drop to lower-level APIs. + +Exit criteria: + +- A new developer can choose between `ZkMerkle`, native CCL/Aiken MPF, and + `ZkMpf` without reading source code. + +## Testing Strategy + +Implemented minimum tests: + +- CCL adapter unit tests. +- CCL `ProofVerifier` parity tests. +- Deterministic tests for roots, proof bytes, and witness maps. +- Negative test where `keyPath` differs from `nibbles(hashFn.digest(rawKeyBytes))`. +- Codec validation tests. +- Symbolic gadget unit tests. +- Generated annotation processor tests. +- Negative witness tests. +- Randomized deterministic trie tests. +- BLS12-381 positive compile/witness tests. +- BN254 rejection tests. +- Witness-level usecase smoke test using Maven-local ZeroJ artifacts. + +Follow-up tests before MPF is promoted as a practical full on-chain flow: + +- Groth16 proof generation and verification tests for an optimized MPF circuit. +- Julc VM verifier tests for the MPF application's custom validator. +- Yaci-backed integration test where environment is available. +- Full-stack proof/Yaci usecase smoke test. + +Before implementation is considered complete, run multiple review agents: + +- Review A: CCL compatibility and proof semantics. +- Review B: circuit soundness and symbolic API. +- Review C: Cardano/on-chain integration and usecase ergonomics. + +Findings must be fixed or explicitly documented as accepted follow-up work. + +## Risks And Mitigations + +### Risk: CCL Wire Semantics Drift + +Mitigation: + +- Tests call CCL `ProofVerifier` directly with the ZeroJ Poseidon adapters. +- Codec tests are based on generated CCL wire proofs. +- Golden vectors include proof bytes and witness maps. + +### Risk: Unsound Padding + +Mitigation: + +- Monotonic `valid` constraints. +- Padded steps are accumulator no-ops. +- Proof codec emits suffix-only padding. +- Circuit rejects valid steps after invalid slots. + +### Risk: Private Inputs Accidentally Made Public + +Mitigation: + +- ADR documents public and private modes separately. +- Examples include at least one private-key/private-value circuit. +- Generated schema tests assert public-input order. + +### Risk: Poseidon Byte Digest Ambiguity + +Mitigation: + +- Digest includes domain and byte length. +- Chunk parsing is fixed to the three-padded-chunk, 32-byte-aligned rule in + the Poseidon digest contract. +- Same helper is used off-circuit and in-circuit. +- Golden vectors lock the digest. + +### Risk: Constraint Cost + +Mitigation: + +- Start with `MAX_STEPS` values of 4 and 8. +- Record constraint counts and proving time. +- Keep Yaci/on-chain cost tied to Groth16 public-input count, not MPF proof + length. + +## Acceptance Criteria + +The feature is ready when: + +- ADR reviewers approve the amended design. +- CCL Poseidon MPF adapter tests pass. +- Inclusion and exclusion gadgets pass differential tests against CCL-derived + references. +- Annotated circuits can use MPF proofs without annotation processor composite + support. +- Groth16 BLS12-381 verification works through the canonical Julc verifier for + the general verifier path; MPF-specific proof/Yaci coverage remains follow-up + until constraint cost is reduced. +- `zeroj-usecases` contains a witness-level MPF example using the locally + published ZeroJ snapshot. +- Documentation explains that Poseidon MPF and native Aiken Blake2b MPF are + separate commitments. + +## Follow-Up Work + +- Batched MPF proofs. +- State transition proofs. +- Constraint optimization for MPF proof generation and Yaci-backed tests. +- Authenticated terminal-fork exclusion witness format. +- Composite symbolic input support in the annotation processor. +- Optional fixed-public-input generated validators for budget-critical + applications. +- Optional bridge to Blake2b MPF only if a future use case justifies the much + higher circuit cost. diff --git a/docs/circuit-annotation-user-guide.md b/docs/circuit-annotation-user-guide.md index 6cf9687..9801e56 100644 --- a/docs/circuit-annotation-user-guide.md +++ b/docs/circuit-annotation-user-guide.md @@ -268,8 +268,10 @@ bind them as secret `ZkUInt` inputs, using 252 bits for `kModL` and 4 bits for ## Current Limits - Nested `@ZKCircuit` classes are not supported. -- Nested annotated array inputs such as `ZkArray>` are not - supported; flatten to fixed-size parallel arrays. +- Rectangular nested array inputs such as `ZkArray>` are supported + when both dimensions are fixed with `@FixedSize(inner = ...)` or + `@FixedSize(innerParam = ...)`. Deeper nesting is not supported; flatten + those structures to fixed-size parallel arrays. - Private `@Prove` methods are not supported. - Private field-style symbolic inputs are not supported; use package-private fields or parameter-style inputs. diff --git a/settings.gradle b/settings.gradle index 854469b..4d21756 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,6 +17,7 @@ include 'zeroj-circuit-dsl' include 'zeroj-circuit-lib' include 'zeroj-circuit-annotation-api' include 'zeroj-circuit-annotation-processor' +include 'zeroj-mpf-poseidon' include 'zeroj-prover-spi' include 'zeroj-prover-gnark' include 'zeroj-onchain-julc' diff --git a/zeroj-bom-all/build.gradle b/zeroj-bom-all/build.gradle index 77e7cd2..f7b9eaf 100644 --- a/zeroj-bom-all/build.gradle +++ b/zeroj-bom-all/build.gradle @@ -28,6 +28,7 @@ dependencies { api project(':zeroj-circuit-lib') api project(':zeroj-circuit-annotation-api') api project(':zeroj-circuit-annotation-processor') + api project(':zeroj-mpf-poseidon') api project(':zeroj-prover-spi') api project(':zeroj-prover-gnark') api project(':zeroj-onchain-julc') diff --git a/zeroj-bom-core/build.gradle b/zeroj-bom-core/build.gradle index 657de73..0f097fc 100644 --- a/zeroj-bom-core/build.gradle +++ b/zeroj-bom-core/build.gradle @@ -25,6 +25,7 @@ dependencies { api project(':zeroj-circuit-lib') api project(':zeroj-circuit-annotation-api') api project(':zeroj-circuit-annotation-processor') + api project(':zeroj-mpf-poseidon') api project(':zeroj-prover-spi') api project(':zeroj-prover-gnark') api project(':zeroj-onchain-julc') diff --git a/zeroj-circuit-annotation-api/README.md b/zeroj-circuit-annotation-api/README.md index 3421535..5b7e4b2 100644 --- a/zeroj-circuit-annotation-api/README.md +++ b/zeroj-circuit-annotation-api/README.md @@ -85,9 +85,10 @@ Important API rules: Known limitations: -- Annotated inputs support fixed-size `ZkArray` for built-in element types, - but not nested `ZkArray>`; flatten nested structures into parallel - arrays when needed. +- Annotated inputs support fixed-size `ZkArray` for built-in element types + and rectangular `ZkArray>` matrices with explicit `@FixedSize` + outer and inner sizes. Deeper nesting remains out of scope; flatten those + structures into parallel arrays when needed. - `ZkPoseidon` exposes two-input hashes. Use `ZkPoseidonN` for folded N-input commitments. For Cardano, pass BLS12-381 Poseidon params explicitly; the symbolic API intentionally does not provide a no-params `ZkPoseidonN` diff --git a/zeroj-circuit-lib/README.md b/zeroj-circuit-lib/README.md index 55ac829..2402af1 100644 --- a/zeroj-circuit-lib/README.md +++ b/zeroj-circuit-lib/README.md @@ -17,7 +17,7 @@ or Jubjub-style primitives. | Binary gadgets | `Binary`, `SignalBinary`, `AliasCheck` | | Selection | `Mux` | | Signal helpers | `SignalPoseidon`, `SignalMiMC` | -| Annotation helpers | `ZkPoseidon`, `ZkPoseidonN`, `ZkMiMC`, `ZkMerkle`, `ZkJubjubPoint`, `ZkPedersen`, `ZkEdDSAJubjub` | +| Annotation helpers | `ZkPoseidon`, `ZkPoseidonN`, `ZkMiMC`, `ZkMerkle`, `ZkMpf`, `ZkMpfProof`, `ZkJubjubPoint`, `ZkPedersen`, `ZkEdDSAJubjub` | | Jubjub primitives | `JubjubCurve`, `PedersenCommitment`, `EdDSAJubjub`, in-circuit variants | | Poseidon parameters | `PoseidonParams*`, `PoseidonHash`, Grain LFSR generation helpers | diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpf.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpf.java new file mode 100644 index 0000000..79c48f2 --- /dev/null +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpf.java @@ -0,0 +1,632 @@ +package com.bloxbean.cardano.zeroj.circuit.lib.zk; + +import com.bloxbean.cardano.zeroj.circuit.FieldConfig; +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Symbolic Poseidon-rooted Merkle Patricia Forestry helpers. + * + *

      This gadget mirrors the ZeroJ Poseidon MPF profile. It is intentionally + * not compatible with native Cardano/Aiken Blake2b MPF roots. + */ +public final class ZkMpf { + public static final int KEY_PATH_NIBBLES = 64; + public static final int KIND_BRANCH = 0; + public static final int KIND_FORK = 1; + public static final int KIND_LEAF = 2; + public static final int KIND_PADDING = 3; + + public static final BigInteger DOMAIN_BYTES = BigInteger.valueOf(0x5a4d5046L); + public static final BigInteger DOMAIN_LEAF = BigInteger.valueOf(0x5a4d5047L); + public static final BigInteger DOMAIN_KEY_PATH = BigInteger.valueOf(0x5a4d5048L); + public static final BigInteger DOMAIN_KEY_NULLIFIER = BigInteger.valueOf(0x5a4d5049L); + + private static final int CURSOR_BITS = 8; + private static final int MAX_PREFIX_NIBBLES = KEY_PATH_NIBBLES; + private static final int LEAF_CHUNK_BYTES = 31; + private static final int BYTE_DIGEST_CHUNK_BYTES = 32; + + private ZkMpf() {} + + public static ZkBool isIncludedPoseidon( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath, + ZkField valueCommitment, + ZkField expectedRoot, + ZkMpfProof proof) { + requireRoot(zk, expectedRoot); + ZkField root = computeRoot(zk, params, keyPath, valueCommitment, proof, true); + return root.isEqual(expectedRoot); + } + + public static void verifyInclusionPoseidon( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath, + ZkField valueCommitment, + ZkField expectedRoot, + ZkMpfProof proof) { + isIncludedPoseidon(zk, params, keyPath, valueCommitment, expectedRoot, proof).assertTrue(); + } + + public static ZkBool isExcludedPoseidon( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath, + ZkField expectedRoot, + ZkMpfProof proof) { + requireRoot(zk, expectedRoot); + ZkField root = computeRoot(zk, params, keyPath, zk.constant(0), proof, false); + return root.isEqual(expectedRoot); + } + + public static void verifyExclusionPoseidon( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath, + ZkField expectedRoot, + ZkMpfProof proof) { + isExcludedPoseidon(zk, params, keyPath, expectedRoot, proof).assertTrue(); + } + + public static ZkField keyPathCommitment( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath) { + return hashKeyPath(zk, params, keyPath, DOMAIN_KEY_PATH); + } + + public static ZkField keyPathNullifier( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath) { + return hashKeyPath(zk, params, keyPath, DOMAIN_KEY_NULLIFIER); + } + + private static ZkField computeRoot( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath, + ZkField valueCommitment, + ZkMpfProof proof, + boolean inclusion) { + validateInputs(zk, params, keyPath, valueCommitment, proof); + + PathSignals keyPathSignals = PathSignals.of(keyPath); + int maxSteps = proof.maxSteps(); + ZkUInt[] cursorBefore = new ZkUInt[maxSteps]; + ZkUInt[] nextCursor = new ZkUInt[maxSteps]; + ZkUInt cursor = uintConst(zk, 0, CURSOR_BITS); + + for (int i = 0; i < maxSteps; i++) { + ZkBool valid = proof.valid().get(i); + ZkBool isPadding = eqConst(zk, proof.kind().get(i), KIND_PADDING); + valid.assertEqual(isPadding.not()); + if (i + 1 < maxSteps) { + valid.not().and(proof.valid().get(i + 1)).assertFalse(); + } + + cursorBefore[i] = cursor; + ZkUInt advanced = uintWrap( + zk, + cursor.signal().add(proof.skip().get(i).signal()).add(1), + CURSOR_BITS); + valid.and(lteConst(zk, advanced, KEY_PATH_NIBBLES).not()).assertFalse(); + valid.and(lteConst(zk, proof.forkPrefixLength().get(i), MAX_PREFIX_NIBBLES).not()).assertFalse(); + nextCursor[i] = advanced; + cursor = valid.select(advanced, cursor); + } + + ZkField current = inclusion + ? commitLeafFromPath(zk, params, keyPathSignals, cursor, valueCommitment) + : zk.constant(0); + + for (int i = maxSteps - 1; i >= 0; i--) { + ZkBool valid = proof.valid().get(i); + ZkUInt stepCursor = cursorBefore[i]; + ZkUInt stepNextCursor = nextCursor[i]; + ZkUInt skip = proof.skip().get(i); + ZkUInt queryIndex = uintWrap(zk, stepNextCursor.signal().add(-1), CURSOR_BITS); + ZkUInt queryNibble = keyPathSignals.at(zk, queryIndex.signal()); + + ZkBool branchKind = eqConst(zk, proof.kind().get(i), KIND_BRANCH); + ZkBool forkKind = eqConst(zk, proof.kind().get(i), KIND_FORK); + ZkBool leafKind = eqConst(zk, proof.kind().get(i), KIND_LEAF); + ZkBool lastValid = i + 1 == maxSteps + ? valid + : valid.and(proof.valid().get(i + 1).not()); + + ZkField branch = branchStep( + zk, + params, + keyPathSignals, + stepCursor, + skip, + queryNibble, + current, + proof.neighbors().get(i)); + + ZkField forkCommitment = prefixedDigestFromWitness( + zk, + params, + proof.forkPrefixLength().get(i), + proof.forkPrefixChunks().get(i), + proof.forkRoot().get(i)); + ZkField forkSparse = sparseBranch( + zk, + params, + keyPathSignals, + stepCursor, + skip, + queryNibble, + current, + proof.neighborNibble().get(i), + forkCommitment); + ZkField fork = inclusion + ? forkSparse + : lastValid.select(proof.forkRoot().get(i), forkSparse); + valid.and(forkKind).and(queryNibble.isEqual(proof.neighborNibble().get(i))).assertFalse(); + if (!inclusion) { + // CCL terminal-fork exclusion exposes an unauthenticated root + // in the proof. Reject it in-circuit until the witness format + // carries an authenticated terminal-fork commitment. + valid.and(forkKind).and(lastValid).assertFalse(); + } + + PathSignals leafPath = PathSignals.of(proof.leafKeyPath().get(i)); + ZkUInt leafNibble = leafPath.at(zk, queryIndex.signal()); + ZkField neighborLeaf = commitLeafFromPath( + zk, + params, + leafPath, + stepNextCursor, + proof.leafValueDigest().get(i)); + ZkField leafSparse = sparseBranch( + zk, + params, + keyPathSignals, + stepCursor, + skip, + queryNibble, + current, + leafNibble, + neighborLeaf); + ZkField terminalLeaf = commitLeafFromPath( + zk, + params, + leafPath, + stepCursor, + proof.leafValueDigest().get(i)); + ZkField leaf = inclusion + ? leafSparse + : lastValid.select(terminalLeaf, leafSparse); + assertLeafDivergence(zk, keyPath, proof.leafKeyPath().get(i), queryIndex, queryNibble, leafNibble, + valid.and(leafKind)); + + ZkField stepResult = branchKind.select(branch, current); + stepResult = forkKind.select(fork, stepResult); + stepResult = leafKind.select(leaf, stepResult); + current = valid.select(stepResult, current); + } + + return current; + } + + private static ZkField hashKeyPath( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath, + BigInteger domain) { + requireBlsParams(zk, params); + requireKeyPath(zk, keyPath); + + var fields = new ArrayList(KEY_PATH_NIBBLES + 2); + fields.add(zk.constant(domain)); + fields.add(zk.constant(KEY_PATH_NIBBLES)); + for (ZkUInt nibble : keyPath.values()) { + fields.add(nibble.asField()); + } + return ZkPoseidonN.hash(zk, params, fields.toArray(ZkField[]::new)); + } + + private static ZkField branchStep( + ZkContext zk, + PoseidonParams params, + PathSignals keyPath, + ZkUInt cursor, + ZkUInt skip, + ZkUInt queryNibble, + ZkField child, + ZkArray neighbors) { + ZkField aggregate = aggregateSiblingHashes(zk, params, queryNibble, child, neighbors); + return prefixedDigestFromPath(zk, params, keyPath, cursor, skip, aggregate); + } + + private static ZkField sparseBranch( + ZkContext zk, + PoseidonParams params, + PathSignals keyPath, + ZkUInt cursor, + ZkUInt skip, + ZkUInt queryNibble, + ZkField queryChild, + ZkUInt neighborNibble, + ZkField neighborChild) { + ZkField zero = zk.constant(0); + ZkField[] children = new ZkField[16]; + for (int i = 0; i < children.length; i++) { + ZkBool isQuery = eqConst(zk, queryNibble, i); + ZkBool isNeighbor = eqConst(zk, neighborNibble, i); + ZkField child = isQuery.select(queryChild, zero); + children[i] = isNeighbor.select(neighborChild, child); + } + ZkField subRoot = binaryMerkleRoot16(zk, params, children); + return prefixedDigestFromPath(zk, params, keyPath, cursor, skip, subRoot); + } + + private static ZkField aggregateSiblingHashes( + ZkContext zk, + PoseidonParams params, + ZkUInt nibble, + ZkField child, + ZkArray neighbors) { + ZkField current = child; + Signal[] bits = nibble.signal().toBinary(4); + for (int level = 0; level < 4; level++) { + ZkField sibling = neighbors.get(3 - level); + ZkBool bit = ZkBool.wrap(zk, bits[level]); + ZkField left = bit.select(sibling, current); + ZkField right = bit.select(current, sibling); + current = byteDigestPair(zk, params, left, right); + } + return current; + } + + private static ZkField binaryMerkleRoot16( + ZkContext zk, + PoseidonParams params, + ZkField[] children) { + if (children.length != 16) { + throw new IllegalArgumentException("children must contain 16 entries"); + } + ZkField[] level = children; + while (level.length > 1) { + ZkField[] next = new ZkField[level.length / 2]; + for (int i = 0; i < level.length; i += 2) { + next[i / 2] = byteDigestPair(zk, params, level[i], level[i + 1]); + } + level = next; + } + return level[0]; + } + + private static ZkField byteDigestPair( + ZkContext zk, + PoseidonParams params, + ZkField left, + ZkField right) { + return ZkPoseidonN.hash( + zk, + params, + zk.constant(DOMAIN_BYTES), + zk.constant(BYTE_DIGEST_CHUNK_BYTES * 2L), + left, + right, + zk.constant(0)); + } + + private static ZkField prefixedDigestFromPath( + ZkContext zk, + PoseidonParams params, + PathSignals path, + ZkUInt start, + ZkUInt length, + ZkField digest) { + return ZkPoseidonN.hash( + zk, + params, + zk.constant(DOMAIN_BYTES), + length.asField().add(zk.constant(BYTE_DIGEST_CHUNK_BYTES)), + selectPrefixedDigestChunkFromPath(zk, path, start, length, digest, 0), + selectPrefixedDigestChunkFromPath(zk, path, start, length, digest, 1), + selectPrefixedDigestChunkFromPath(zk, path, start, length, digest, 2)); + } + + private static ZkField prefixedDigestFromWitness( + ZkContext zk, + PoseidonParams params, + ZkUInt prefixLength, + ZkArray prefixChunks, + ZkField digest) { + if (prefixChunks.size() < 2) { + throw new IllegalArgumentException( + "forkPrefixChunks inner size must be at least 2 for 64-nibble MPF paths"); + } + return ZkPoseidonN.hash( + zk, + params, + zk.constant(DOMAIN_BYTES), + prefixLength.asField().add(zk.constant(BYTE_DIGEST_CHUNK_BYTES)), + selectPrefixedDigestChunkFromWitness(zk, prefixLength, prefixChunks, digest, 0), + selectPrefixedDigestChunkFromWitness(zk, prefixLength, prefixChunks, digest, 1), + selectPrefixedDigestChunkFromWitness(zk, prefixLength, prefixChunks, digest, 2)); + } + + private static ZkField selectPrefixedDigestChunkFromPath( + ZkContext zk, + PathSignals path, + ZkUInt start, + ZkUInt length, + ZkField digest, + int chunkIndex) { + ZkField selected = prefixedDigestChunkCandidateFromPath(zk, path, start, 0, digest, chunkIndex); + for (int len = 1; len <= MAX_PREFIX_NIBBLES; len++) { + ZkField candidate = prefixedDigestChunkCandidateFromPath(zk, path, start, len, digest, chunkIndex); + selected = eqConst(zk, length, len).select(candidate, selected); + } + return selected; + } + + private static ZkField prefixedDigestChunkCandidateFromPath( + ZkContext zk, + PathSignals path, + ZkUInt start, + int length, + ZkField digest, + int chunkIndex) { + List chunks = byteDigestChunks(length); + if (chunkIndex >= chunks.size()) { + return zk.constant(0); + } + DigestChunk chunk = chunks.get(chunkIndex); + return switch (chunk.kind()) { + case PATH -> packPathNibbles(zk, path, start, chunk.offset(), chunk.length()); + case ROOT -> digest; + case ZERO -> zk.constant(0); + }; + } + + private static ZkField selectPrefixedDigestChunkFromWitness( + ZkContext zk, + ZkUInt length, + ZkArray prefixChunks, + ZkField digest, + int chunkIndex) { + ZkField selected = prefixedDigestChunkCandidateFromWitness(zk, prefixChunks, 0, digest, chunkIndex); + for (int len = 1; len <= MAX_PREFIX_NIBBLES; len++) { + ZkField candidate = prefixedDigestChunkCandidateFromWitness(zk, prefixChunks, len, digest, chunkIndex); + selected = eqConst(zk, length, len).select(candidate, selected); + } + return selected; + } + + private static ZkField prefixedDigestChunkCandidateFromWitness( + ZkContext zk, + ZkArray prefixChunks, + int length, + ZkField digest, + int chunkIndex) { + List chunks = byteDigestChunks(length); + if (chunkIndex >= chunks.size()) { + return zk.constant(0); + } + DigestChunk chunk = chunks.get(chunkIndex); + return switch (chunk.kind()) { + case PATH -> prefixChunks.get(chunk.pathChunkIndex()); + case ROOT -> digest; + case ZERO -> zk.constant(0); + }; + } + + private static List byteDigestChunks(int prefixLength) { + var chunks = new ArrayList(); + int totalLength = prefixLength + BYTE_DIGEST_CHUNK_BYTES; + int remainder = totalLength % BYTE_DIGEST_CHUNK_BYTES; + int offset = 0; + int pathChunk = 0; + if (remainder != 0) { + chunks.add(DigestChunk.path(0, remainder, pathChunk++)); + offset = remainder; + } + while (offset < prefixLength) { + chunks.add(DigestChunk.path(offset, BYTE_DIGEST_CHUNK_BYTES, pathChunk++)); + offset += BYTE_DIGEST_CHUNK_BYTES; + } + chunks.add(DigestChunk.root()); + while (chunks.size() < 3) { + chunks.add(DigestChunk.zero()); + } + return chunks; + } + + private static ZkField commitLeafFromPath( + ZkContext zk, + PoseidonParams params, + PathSignals path, + ZkUInt start, + ZkField valueCommitment) { + ZkUInt length = uintWrap(zk, zk.builder().constant(KEY_PATH_NIBBLES).sub(start.signal()), CURSOR_BITS); + return ZkPoseidonN.hash( + zk, + params, + zk.constant(DOMAIN_LEAF), + length.asField(), + selectLeafChunk(zk, path, start, length, 0), + selectLeafChunk(zk, path, start, length, 1), + selectLeafChunk(zk, path, start, length, 2), + valueCommitment); + } + + private static ZkField selectLeafChunk( + ZkContext zk, + PathSignals path, + ZkUInt start, + ZkUInt length, + int chunkIndex) { + ZkField selected = leafChunkCandidate(zk, path, start, 0, chunkIndex); + for (int len = 1; len <= KEY_PATH_NIBBLES; len++) { + ZkField candidate = leafChunkCandidate(zk, path, start, len, chunkIndex); + selected = eqConst(zk, length, len).select(candidate, selected); + } + return selected; + } + + private static ZkField leafChunkCandidate( + ZkContext zk, + PathSignals path, + ZkUInt start, + int suffixLength, + int chunkIndex) { + int offset = chunkIndex * LEAF_CHUNK_BYTES; + if (offset >= suffixLength) { + return zk.constant(0); + } + int chunkLength = Math.min(LEAF_CHUNK_BYTES, suffixLength - offset); + return packPathNibbles(zk, path, start, offset, chunkLength); + } + + private static ZkField packPathNibbles( + ZkContext zk, + PathSignals path, + ZkUInt start, + int offset, + int length) { + ZkField acc = zk.constant(0); + for (int i = 0; i < length; i++) { + ZkUInt nibble = path.atOffset(zk, start, offset + i); + BigInteger coefficient = BigInteger.ONE.shiftLeft(8 * (length - 1 - i)); + acc = acc.add(nibble.asField().mul(zk.constant(coefficient))); + } + return acc; + } + + private static void assertLeafDivergence( + ZkContext zk, + ZkArray keyPath, + ZkArray leafPath, + ZkUInt divergenceIndex, + ZkUInt queryNibble, + ZkUInt leafNibble, + ZkBool condition) { + condition.and(queryNibble.isEqual(leafNibble)).assertFalse(); + for (int i = 0; i < KEY_PATH_NIBBLES; i++) { + ZkBool beforeDivergence = uintConst(zk, i, CURSOR_BITS).lt(divergenceIndex); + ZkBool same = keyPath.get(i).isEqual(leafPath.get(i)); + condition.and(beforeDivergence).and(same.not()).assertFalse(); + } + } + + private static ZkBool eqConst(ZkContext zk, ZkUInt value, int constant) { + return value.asField().isEqual(zk.constant(constant)); + } + + private static ZkBool lteConst(ZkContext zk, ZkUInt value, int constant) { + return value.lte(uintConst(zk, constant, value.bits())); + } + + private static ZkUInt uintConst(ZkContext zk, int value, int bits) { + return uintWrap(zk, zk.builder().constant(value), bits); + } + + private static ZkUInt uintWrap(ZkContext zk, Signal signal, int bits) { + return ZkUInt.wrap(zk, signal, bits); + } + + private static void validateInputs( + ZkContext zk, + PoseidonParams params, + ZkArray keyPath, + ZkField valueCommitment, + ZkMpfProof proof) { + requireBlsParams(zk, params); + requireKeyPath(zk, keyPath); + Objects.requireNonNull(valueCommitment, "valueCommitment"); + Objects.requireNonNull(proof, "proof"); + zk.requireSignal(valueCommitment.signal()); + proof.assertWellFormed(); + } + + private static void requireKeyPath(ZkContext zk, ZkArray keyPath) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(keyPath, "keyPath"); + if (keyPath.size() != KEY_PATH_NIBBLES) { + throw new IllegalArgumentException( + "keyPath must contain " + KEY_PATH_NIBBLES + " nibbles, got " + keyPath.size()); + } + for (int i = 0; i < keyPath.size(); i++) { + ZkUInt nibble = keyPath.get(i); + if (nibble.bits() != 4) { + throw new IllegalArgumentException("keyPath[" + i + "] must be a 4-bit ZkUInt"); + } + zk.requireSignal(nibble.signal()); + } + } + + private static void requireRoot(ZkContext zk, ZkField root) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(root, "root"); + zk.requireSignal(root.signal()); + } + + private static void requireBlsParams(ZkContext zk, PoseidonParams params) { + Objects.requireNonNull(zk, "zk"); + Objects.requireNonNull(params, "params"); + if (!FieldConfig.BLS12_381.equals(params.field())) { + throw new IllegalArgumentException("ZkMpf requires BLS12-381 Poseidon params"); + } + if (params.t() != 3 || params.alpha() != 5) { + throw new IllegalArgumentException("ZkMpf supports only Poseidon t=3, alpha=5 params"); + } + zk.builder().api().requireField(params.field()); + } + + private enum DigestChunkKind { + PATH, + ROOT, + ZERO + } + + private record DigestChunk(DigestChunkKind kind, int offset, int length, int pathChunkIndex) { + static DigestChunk path(int offset, int length, int pathChunkIndex) { + return new DigestChunk(DigestChunkKind.PATH, offset, length, pathChunkIndex); + } + + static DigestChunk root() { + return new DigestChunk(DigestChunkKind.ROOT, 0, 0, -1); + } + + static DigestChunk zero() { + return new DigestChunk(DigestChunkKind.ZERO, 0, 0, -1); + } + } + + private record PathSignals(ZkArray path, Signal[] signals) { + static PathSignals of(ZkArray path) { + var signals = new Signal[path.size()]; + for (int i = 0; i < path.size(); i++) { + signals[i] = path.get(i).signal(); + } + return new PathSignals(path, signals); + } + + ZkUInt atOffset(ZkContext zk, ZkUInt start, int offset) { + return at(zk, start.signal().add(offset)); + } + + ZkUInt at(ZkContext zk, Signal index) { + return ZkUInt.wrap(zk, zk.builder().arrayAccess(signals, index), 4); + } + } +} diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpfProof.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpfProof.java new file mode 100644 index 0000000..729dbe5 --- /dev/null +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpfProof.java @@ -0,0 +1,222 @@ +package com.bloxbean.cardano.zeroj.circuit.lib.zk; + +import com.bloxbean.cardano.zeroj.circuit.Signal; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkValue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Flattened symbolic MPF proof wrapper. + * + *

      The annotation processor binds the individual arrays directly. This + * wrapper gives circuit code one value object without requiring composite + * symbolic-input support in generated code. + */ +public final class ZkMpfProof implements ZkValue { + public static final int BRANCH_NEIGHBOR_COUNT = 4; + public static final int KEY_PATH_NIBBLES = 64; + + private final ZkArray kind; + private final ZkArray skip; + private final ZkArray> neighbors; + private final ZkArray neighborNibble; + private final ZkArray forkPrefixLength; + private final ZkArray> forkPrefixChunks; + private final ZkArray forkRoot; + private final ZkArray> leafKeyPath; + private final ZkArray leafValueDigest; + private final ZkArray valid; + + private ZkMpfProof( + ZkArray kind, + ZkArray skip, + ZkArray> neighbors, + ZkArray neighborNibble, + ZkArray forkPrefixLength, + ZkArray> forkPrefixChunks, + ZkArray forkRoot, + ZkArray> leafKeyPath, + ZkArray leafValueDigest, + ZkArray valid) { + this.kind = Objects.requireNonNull(kind, "kind"); + this.skip = Objects.requireNonNull(skip, "skip"); + this.neighbors = Objects.requireNonNull(neighbors, "neighbors"); + this.neighborNibble = Objects.requireNonNull(neighborNibble, "neighborNibble"); + this.forkPrefixLength = Objects.requireNonNull(forkPrefixLength, "forkPrefixLength"); + this.forkPrefixChunks = Objects.requireNonNull(forkPrefixChunks, "forkPrefixChunks"); + this.forkRoot = Objects.requireNonNull(forkRoot, "forkRoot"); + this.leafKeyPath = Objects.requireNonNull(leafKeyPath, "leafKeyPath"); + this.leafValueDigest = Objects.requireNonNull(leafValueDigest, "leafValueDigest"); + this.valid = Objects.requireNonNull(valid, "valid"); + validateShape(); + } + + public static ZkMpfProof fromArrays( + ZkArray kind, + ZkArray skip, + ZkArray> neighbors, + ZkArray neighborNibble, + ZkArray forkPrefixLength, + ZkArray> forkPrefixChunks, + ZkArray forkRoot, + ZkArray> leafKeyPath, + ZkArray leafValueDigest, + ZkArray valid) { + return new ZkMpfProof( + kind, + skip, + neighbors, + neighborNibble, + forkPrefixLength, + forkPrefixChunks, + forkRoot, + leafKeyPath, + leafValueDigest, + valid); + } + + public int maxSteps() { + return kind.size(); + } + + public int maxForkPrefixChunks() { + return forkPrefixChunks.size() == 0 ? 0 : forkPrefixChunks.get(0).size(); + } + + public ZkArray kind() { + return kind; + } + + public ZkArray skip() { + return skip; + } + + public ZkArray> neighbors() { + return neighbors; + } + + public ZkArray neighborNibble() { + return neighborNibble; + } + + public ZkArray forkPrefixLength() { + return forkPrefixLength; + } + + public ZkArray> forkPrefixChunks() { + return forkPrefixChunks; + } + + public ZkArray forkRoot() { + return forkRoot; + } + + public ZkArray> leafKeyPath() { + return leafKeyPath; + } + + public ZkArray leafValueDigest() { + return leafValueDigest; + } + + public ZkArray valid() { + return valid; + } + + @Override + public List signals() { + var signals = new ArrayList(); + signals.addAll(kind.signals()); + signals.addAll(skip.signals()); + signals.addAll(neighbors.signals()); + signals.addAll(neighborNibble.signals()); + signals.addAll(forkPrefixLength.signals()); + signals.addAll(forkPrefixChunks.signals()); + signals.addAll(forkRoot.signals()); + signals.addAll(leafKeyPath.signals()); + signals.addAll(leafValueDigest.signals()); + signals.addAll(valid.signals()); + return List.copyOf(signals); + } + + @Override + public void assertWellFormed() { + kind.assertWellFormed(); + skip.assertWellFormed(); + neighbors.assertWellFormed(); + neighborNibble.assertWellFormed(); + forkPrefixLength.assertWellFormed(); + forkPrefixChunks.assertWellFormed(); + forkRoot.assertWellFormed(); + leafKeyPath.assertWellFormed(); + leafValueDigest.assertWellFormed(); + valid.assertWellFormed(); + } + + private void validateShape() { + int size = kind.size(); + requireSameSize("skip", skip.size(), size); + requireSameSize("neighbors", neighbors.size(), size); + requireSameSize("neighborNibble", neighborNibble.size(), size); + requireSameSize("forkPrefixLength", forkPrefixLength.size(), size); + requireSameSize("forkPrefixChunks", forkPrefixChunks.size(), size); + requireSameSize("forkRoot", forkRoot.size(), size); + requireSameSize("leafKeyPath", leafKeyPath.size(), size); + requireSameSize("leafValueDigest", leafValueDigest.size(), size); + requireSameSize("valid", valid.size(), size); + + requireUIntBits("kind", kind, 2); + requireUIntBits("skip", skip, 8); + requireUIntBits("neighborNibble", neighborNibble, 4); + requireUIntBits("forkPrefixLength", forkPrefixLength, 8); + + Integer forkChunkCount = null; + for (int i = 0; i < size; i++) { + if (neighbors.get(i).size() != BRANCH_NEIGHBOR_COUNT) { + throw new IllegalArgumentException( + "neighbors[" + i + "] must have size " + BRANCH_NEIGHBOR_COUNT + + ", got " + neighbors.get(i).size()); + } + if (leafKeyPath.get(i).size() != KEY_PATH_NIBBLES) { + throw new IllegalArgumentException( + "leafKeyPath[" + i + "] must have size " + KEY_PATH_NIBBLES + + ", got " + leafKeyPath.get(i).size()); + } + requireUIntBits("leafKeyPath[" + i + "]", leafKeyPath.get(i), 4); + int rowSize = forkPrefixChunks.get(i).size(); + if (forkChunkCount == null) { + forkChunkCount = rowSize; + } else if (forkChunkCount != rowSize) { + throw new IllegalArgumentException("forkPrefixChunks must be rectangular"); + } + } + if (size > 0 && forkChunkCount != null && forkChunkCount < 2) { + throw new IllegalArgumentException( + "forkPrefixChunks inner size must be at least 2 when maxSteps > 0"); + } + } + + private static void requireUIntBits(String name, ZkArray values, int expectedBits) { + for (int i = 0; i < values.size(); i++) { + int actualBits = values.get(i).bits(); + if (actualBits != expectedBits) { + throw new IllegalArgumentException( + name + "[" + i + "] must be a " + expectedBits + "-bit ZkUInt, got " + + actualBits + " bits"); + } + } + } + + private static void requireSameSize(String name, int actual, int expected) { + if (actual != expected) { + throw new IllegalArgumentException( + name + " must have size " + expected + ", got " + actual); + } + } +} diff --git a/zeroj-examples/README.md b/zeroj-examples/README.md index eda32bd..78085ec 100644 --- a/zeroj-examples/README.md +++ b/zeroj-examples/README.md @@ -39,6 +39,7 @@ Write circuits as annotated Java classes and use generated companions for - **Examples**: range proof, age verification, private transfer, MiMC commitment, [BLS12-381 PoseidonN multi-input commitment](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMultiInputCommitment.java), [BLS12-381 Poseidon Merkle membership](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedBlsPoseidonMerkleMembership.java), + [Poseidon MPF private registry membership](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMpfPrivateRegistryInclusion.java), sealed-bid auction, anonymous voting, parameterized Merkle membership, Pedersen commitment, proof-flow helper - **Source**: [`examples/annotation`](src/main/java/com/bloxbean/cardano/zeroj/examples/annotation) diff --git a/zeroj-examples/build.gradle b/zeroj-examples/build.gradle index c0a81a8..882211b 100644 --- a/zeroj-examples/build.gradle +++ b/zeroj-examples/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation project(':zeroj-circuit-dsl') implementation project(':zeroj-circuit-lib') implementation project(':zeroj-circuit-annotation-api') + implementation project(':zeroj-mpf-poseidon') implementation project(':zeroj-crypto') implementation project(':zeroj-blst') implementation project(':zeroj-cardano') @@ -64,6 +65,7 @@ test { useJUnitPlatform { excludeTags 'e2e' } + maxHeapSize = '2g' } // E2E integration tests — requires Yaci DevKit running locally diff --git a/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMpfPrivateRegistryInclusion.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMpfPrivateRegistryInclusion.java new file mode 100644 index 0000000..92a8282 --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedMpfPrivateRegistryInclusion.java @@ -0,0 +1,97 @@ +package com.bloxbean.cardano.zeroj.examples.annotation; + +import com.bloxbean.cardano.zeroj.circuit.annotation.CircuitParam; +import com.bloxbean.cardano.zeroj.circuit.annotation.FixedSize; +import com.bloxbean.cardano.zeroj.circuit.annotation.Prove; +import com.bloxbean.cardano.zeroj.circuit.annotation.Public; +import com.bloxbean.cardano.zeroj.circuit.annotation.Secret; +import com.bloxbean.cardano.zeroj.circuit.annotation.UInt; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZKCircuit; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMpf; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMpfProof; + +@ZKCircuit( + name = "annotation-mpf-private-registry-inclusion", + nameTemplate = "annotation-mpf-private-registry-inclusion-s{maxSteps}-f{maxForkPrefixChunks}", + version = 1) +public class AnnotatedMpfPrivateRegistryInclusion { + public AnnotatedMpfPrivateRegistryInclusion( + @CircuitParam("maxSteps") int maxSteps, + @CircuitParam("maxForkPrefixChunks") int maxForkPrefixChunks) { + } + + @Prove + void prove( + ZkContext zk, + @Public ZkField registryRoot, + @Public ZkField keyPathNullifier, + @Secret(name = "key_path") + @FixedSize(64) + @UInt(bits = 4) + ZkArray keyPath, + @Secret(name = "value_commitment") + ZkField valueCommitment, + @Secret(name = "mpf_kind") + @FixedSize(param = "maxSteps") + @UInt(bits = 2) + ZkArray stepKind, + @Secret(name = "mpf_skip") + @FixedSize(param = "maxSteps") + @UInt(bits = 8) + ZkArray stepSkip, + @Secret(name = "mpf_neighbor") + @FixedSize(param = "maxSteps", inner = 4) + ZkArray> neighbors, + @Secret(name = "mpf_neighbor_nibble") + @FixedSize(param = "maxSteps") + @UInt(bits = 4) + ZkArray neighborNibble, + @Secret(name = "mpf_fork_prefix_length") + @FixedSize(param = "maxSteps") + @UInt(bits = 8) + ZkArray forkPrefixLength, + @Secret(name = "mpf_fork_prefix") + @FixedSize(param = "maxSteps", innerParam = "maxForkPrefixChunks") + ZkArray> forkPrefixChunks, + @Secret(name = "mpf_fork_root") + @FixedSize(param = "maxSteps") + ZkArray forkRoot, + @Secret(name = "mpf_leaf_key_path") + @FixedSize(param = "maxSteps", inner = 64) + @UInt(bits = 4) + ZkArray> leafKeyPath, + @Secret(name = "mpf_leaf_value_digest") + @FixedSize(param = "maxSteps") + ZkArray leafValueDigest, + @Secret(name = "mpf_valid") + @FixedSize(param = "maxSteps") + ZkArray valid) { + ZkMpfProof proof = ZkMpfProof.fromArrays( + stepKind, + stepSkip, + neighbors, + neighborNibble, + forkPrefixLength, + forkPrefixChunks, + forkRoot, + leafKeyPath, + leafValueDigest, + valid); + + ZkMpf.verifyInclusionPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + keyPath, + valueCommitment, + registryRoot, + proof); + ZkMpf.keyPathNullifier(zk, PoseidonParamsBLS12_381T3.INSTANCE, keyPath) + .assertEqual(keyPathNullifier); + } +} diff --git a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java index 1675dfe..de66858 100644 --- a/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java @@ -6,6 +6,7 @@ import com.bloxbean.cardano.zeroj.api.PublicInputs; import com.bloxbean.cardano.zeroj.api.VerificationKeyRef; import com.bloxbean.cardano.zeroj.circuit.FieldConfig; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkInputMap; import com.bloxbean.cardano.zeroj.circuit.annotation.ZkCircuitMetadata; import com.bloxbean.cardano.zeroj.circuit.lib.jubjub.PedersenCommitment; import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; @@ -14,9 +15,16 @@ import com.bloxbean.cardano.zeroj.examples.dsl.common.MiMCHash; import com.bloxbean.cardano.zeroj.prover.gnark.GnarkProver; import com.bloxbean.cardano.zeroj.prover.spi.ProveResponse; +import com.bloxbean.cardano.zeroj.mpf.poseidon.PoseidonMpfCodec; +import com.bloxbean.cardano.zeroj.mpf.poseidon.PoseidonMpfHash; +import com.bloxbean.cardano.zeroj.mpf.poseidon.PoseidonMpfTrie; +import com.bloxbean.cardano.zeroj.mpf.poseidon.PoseidonMpfValueCommitment; +import com.bloxbean.cardano.zeroj.mpf.poseidon.PoseidonMpfWitness; +import com.bloxbean.cardano.vds.mpf.MpfTrie; import org.junit.jupiter.api.Test; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.util.List; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -384,6 +392,35 @@ void blsPoseidonMerkleMembershipUsesParamsAwareHelper() { () -> circuit.calculateWitness(invalid.toWitnessMap(), CurveId.BLS12_381)); } + @Test + void annotatedMpfPrivateRegistryInclusionUsesGeneratedCircuit() { + MpfTrie trie = PoseidonMpfTrie.inMemory(); + byte[] key = bytes("registry:member:1"); + byte[] value = bytes("active"); + trie.put(key, value); + + byte[] proof = trie.getProofWire(key).orElseThrow(); + PoseidonMpfWitness witness = PoseidonMpfCodec.toWitness(key, proof, 1, 2); + int[] keyPath = witness.keyPath().stream().mapToInt(BigInteger::intValueExact).toArray(); + BigInteger root = PoseidonMpfHash.fieldFromDigestBytes(trie.getRootHash()); + BigInteger keyPathNullifier = PoseidonMpfHash.keyPathNullifier( + PoseidonParamsBLS12_381T3.INSTANCE, + keyPath); + + var circuit = AnnotatedMpfPrivateRegistryInclusionCircuit.build(1, 2); + var schema = AnnotatedMpfPrivateRegistryInclusionCircuit.schema(1, 2); + assertEquals(List.of("registryRoot", "keyPathNullifier"), schema.publicInputs().names()); + + var inputs = new ZkInputMap() + .put("registryRoot", root) + .put("keyPathNullifier", keyPathNullifier) + .put("value_commitment", PoseidonMpfValueCommitment.field(value)); + witness.putInto(inputs); + + assertTrue(circuit.constraintGraph().gates().size() > 0); + assertEquals(List.of(root, keyPathNullifier), inputs.publicValues(schema)); + } + @Test void nestedBatchThresholdMatrixUsesRowMajorGeneratedInputs() { int rows = 2; @@ -483,4 +520,8 @@ private BigInteger poseidonMerkleRoot( } return current; } + + private byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } } diff --git a/zeroj-mpf-poseidon/README.md b/zeroj-mpf-poseidon/README.md new file mode 100644 index 0000000..7afda85 --- /dev/null +++ b/zeroj-mpf-poseidon/README.md @@ -0,0 +1,98 @@ +# ZeroJ Poseidon MPF + +`zeroj-mpf-poseidon` connects Cardano Client Lib MPF proofs to ZeroJ symbolic +circuits. + +It is a separate commitment profile from native Cardano/Aiken MPF: + +| Profile | Hash | Verifier path | +| --- | --- | --- | +| Native MPF | Blake2b-256 | Aiken MPF verifier | +| ZeroJ Poseidon MPF | BLS12-381 Poseidon | Intended Groth16 BLS12-381 verifier path; current checked-in demo is witness-level | + +Use this module when an application needs to keep the MPF key, value, and proof +private inside a symbolic circuit, with the Cardano path going through a +Groth16 BLS12-381 proof after MPF constraint optimization. + +## Gradle + +```gradle +dependencies { + implementation "com.bloxbean.cardano:zeroj-mpf-poseidon:" +} +``` + +## Off-chain Flow + +```java +MpfTrie trie = PoseidonMpfTrie.inMemory(); +trie.put(keyBytes, valueBytes); + +byte[] root = trie.getRootHash(); +byte[] proof = trie.getProofWire(keyBytes).orElseThrow(); + +PoseidonMpfWitness witness = PoseidonMpfCodec.toWitness( + keyBytes, + proof, + maxSteps, + 2); +BigInteger valueCommitment = PoseidonMpfValueCommitment.field(valueBytes); +``` + +`PoseidonMpfCodec` emits the flattened arrays expected by `ZkMpfProof` and can +write them directly into a `ZkInputMap`. + +The default witness names emitted by `PoseidonMpfWitness.putInto(inputs)` are: + +```text +key_path +mpf_kind +mpf_skip +mpf_neighbor +mpf_neighbor_nibble +mpf_fork_prefix_length +mpf_fork_prefix +mpf_fork_root +mpf_leaf_key_path +mpf_leaf_value_digest +mpf_valid +``` + +Annotated circuits should use matching `@Secret(name = "...")` values. For +non-empty proof bounds, use `maxForkPrefixChunks >= 2`. + +## Circuit Flow + +```java +ZkMpfProof proof = ZkMpfProof.fromArrays( + stepKind, stepSkip, neighbors, neighborNibble, + forkPrefixLength, forkPrefixChunks, forkRoot, + leafKeyPath, leafValueDigest, valid); + +ZkMpf.verifyInclusionPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + keyPath, + valueCommitment, + registryRoot, + proof); + +ZkMpf.keyPathNullifier(zk, PoseidonParamsBLS12_381T3.INSTANCE, keyPath) + .assertEqual(publicNullifier); +``` + +## Limits + +- BLS12-381 Poseidon only. +- Branch values are rejected in v1. +- MPF byte digests are fixed to three padded 32-byte chunks. +- Terminal fork exclusions from CCL are not accepted by the in-circuit verifier + in v1 because that proof shape carries an unauthenticated root. Empty-trie, + missing-branch, and different-leaf exclusions remain the supported exclusion + paths. +- Raw key-to-path binding is application-specific; the gadget verifies the + CCL key path nibbles emitted by `PoseidonMpfCodec`. +- For future on-chain MPF applications, use `Groth16BLS12381Lib.verify(...)` + inside a custom validator when additional root, nullifier, or domain checks + are needed. The current example suite demonstrates witness evaluation; a + practical Groth16/Yaci MPF flow is deferred until the circuit cost is reduced. diff --git a/zeroj-mpf-poseidon/build.gradle b/zeroj-mpf-poseidon/build.gradle new file mode 100644 index 0000000..a46b0cf --- /dev/null +++ b/zeroj-mpf-poseidon/build.gradle @@ -0,0 +1,27 @@ +plugins { + id 'java-library' +} + +description = 'ZeroJ Poseidon-rooted Merkle Patricia Forestry integration for CCL' + +dependencies { + api project(':zeroj-circuit-lib') + api project(':zeroj-circuit-annotation-api') + api 'com.bloxbean.cardano:cardano-client-merkle-patricia-forestry:0.8.0-pre4-SNAPSHOT' + api 'com.bloxbean.cardano:cardano-client-verified-structures-core:0.8.0-pre4-SNAPSHOT' + + implementation 'co.nstant.in:cbor:0.9' + + testImplementation project(':zeroj-test-vectors') +} + +publishing { + publications { + mavenJava(MavenPublication) { + pom { + name = 'ZeroJ MPF Poseidon' + description = 'Poseidon-rooted CCL Merkle Patricia Forestry helpers for ZeroJ circuits' + } + } + } +} diff --git a/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/InMemoryNodeStore.java b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/InMemoryNodeStore.java new file mode 100644 index 0000000..d180196 --- /dev/null +++ b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/InMemoryNodeStore.java @@ -0,0 +1,50 @@ +package com.bloxbean.cardano.zeroj.mpf.poseidon; + +import com.bloxbean.cardano.vds.core.api.NodeStore; + +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Small public in-memory CCL {@link NodeStore} for examples and tests. + */ +public final class InMemoryNodeStore implements NodeStore { + private final Map values = new ConcurrentHashMap<>(); + + @Override + public byte[] get(byte[] hash) { + byte[] value = values.get(new Key(hash)); + return value == null ? null : Arrays.copyOf(value, value.length); + } + + @Override + public void put(byte[] hash, byte[] nodeBytes) { + values.put(new Key(hash), Arrays.copyOf(nodeBytes, nodeBytes.length)); + } + + @Override + public void delete(byte[] hash) { + values.remove(new Key(hash)); + } + + public void clear() { + values.clear(); + } + + private record Key(byte[] bytes) { + private Key { + bytes = bytes == null ? null : Arrays.copyOf(bytes, bytes.length); + } + + @Override + public boolean equals(Object other) { + return other instanceof Key key && Arrays.equals(bytes, key.bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + } +} diff --git a/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfCodec.java b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfCodec.java new file mode 100644 index 0000000..8e437d2 --- /dev/null +++ b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfCodec.java @@ -0,0 +1,277 @@ +package com.bloxbean.cardano.zeroj.mpf.poseidon; + +import co.nstant.in.cbor.CborDecoder; +import co.nstant.in.cbor.model.Array; +import co.nstant.in.cbor.model.ByteString; +import co.nstant.in.cbor.model.DataItem; +import co.nstant.in.cbor.model.Tag; +import co.nstant.in.cbor.model.UnsignedInteger; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; + +import java.io.ByteArrayInputStream; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * Converts CCL MPF wire proofs into stable symbolic witness arrays. + */ +public final class PoseidonMpfCodec { + public static final int KIND_BRANCH = 0; + public static final int KIND_FORK = 1; + public static final int KIND_LEAF = 2; + public static final int KIND_PADDING = 3; + + private static final int TAG_BRANCH = 121; + private static final int TAG_FORK = 122; + private static final int TAG_LEAF = 123; + + private PoseidonMpfCodec() {} + + public static PoseidonMpfWitness toWitness(byte[] key, byte[] proofCbor, int maxSteps, int maxForkPrefixChunks) { + return toWitness(PoseidonParamsBLS12_381T3.INSTANCE, key, proofCbor, maxSteps, maxForkPrefixChunks); + } + + public static PoseidonMpfWitness toWitness( + PoseidonParams params, + byte[] key, + byte[] proofCbor, + int maxSteps, + int maxForkPrefixChunks) { + PoseidonMpfHash.requireBlsParams(params); + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(proofCbor, "proofCbor"); + if (maxSteps < 0) { + throw new IllegalArgumentException("maxSteps must be >= 0"); + } + if (maxForkPrefixChunks < 0) { + throw new IllegalArgumentException("maxForkPrefixChunks must be >= 0"); + } + if (maxSteps > 0 && maxForkPrefixChunks < 2) { + throw new IllegalArgumentException("maxForkPrefixChunks must be >= 2 when maxSteps > 0"); + } + + List steps = decode(proofCbor); + if (steps.size() > maxSteps) { + throw new IllegalArgumentException("proof has " + steps.size() + + " steps, exceeding MAX_STEPS=" + maxSteps); + } + + byte[] keyDigest = new PoseidonMpfHashFunction(params).digest(key); + int[] keyNibbles = PoseidonMpfHash.digestToNibbles(keyDigest); + + var kind = new ArrayList(maxSteps); + var skip = new ArrayList(maxSteps); + var neighbors = new ArrayList>(maxSteps); + var neighborNibble = new ArrayList(maxSteps); + var forkPrefixLength = new ArrayList(maxSteps); + var forkPrefixChunks = new ArrayList>(maxSteps); + var forkRoot = new ArrayList(maxSteps); + var leafKeyPath = new ArrayList>(maxSteps); + var leafValueDigest = new ArrayList(maxSteps); + var valid = new ArrayList(maxSteps); + + for (Step step : steps) { + kind.add(BigInteger.valueOf(step.kind())); + skip.add(BigInteger.valueOf(step.skip())); + neighbors.add(padFlat(step.neighbors(), 4)); + neighborNibble.add(BigInteger.valueOf(step.neighborNibble())); + forkPrefixLength.add(BigInteger.valueOf(step.forkPrefixLength())); + forkPrefixChunks.add(padFlat(step.forkPrefixChunks(), maxForkPrefixChunks)); + forkRoot.add(step.forkRoot()); + leafKeyPath.add(padFlat(step.leafKeyPath(), PoseidonMpfHash.KEY_PATH_NIBBLES)); + leafValueDigest.add(step.leafValueDigest()); + valid.add(BigInteger.ONE); + } + + while (kind.size() < maxSteps) { + kind.add(BigInteger.valueOf(KIND_PADDING)); + skip.add(BigInteger.ZERO); + neighbors.add(padFlat(List.of(), 4)); + neighborNibble.add(BigInteger.ZERO); + forkPrefixLength.add(BigInteger.ZERO); + forkPrefixChunks.add(padFlat(List.of(), maxForkPrefixChunks)); + forkRoot.add(BigInteger.ZERO); + leafKeyPath.add(padFlat(List.of(), PoseidonMpfHash.KEY_PATH_NIBBLES)); + leafValueDigest.add(BigInteger.ZERO); + valid.add(BigInteger.ZERO); + } + + return new PoseidonMpfWitness( + toBigIntegers(keyNibbles), + kind, + skip, + neighbors, + neighborNibble, + forkPrefixLength, + forkPrefixChunks, + forkRoot, + leafKeyPath, + leafValueDigest, + valid); + } + + public static List decode(byte[] proofCbor) { + Objects.requireNonNull(proofCbor, "proofCbor"); + try { + List items = new CborDecoder(new ByteArrayInputStream(proofCbor)).decode(); + if (items.isEmpty() || !(items.getFirst() instanceof Array root)) { + throw new IllegalArgumentException("Invalid MPF proof CBOR encoding"); + } + var steps = new ArrayList(); + for (DataItem item : root.getDataItems()) { + if (!(item instanceof Array stepArray)) { + throw new IllegalArgumentException("Invalid MPF proof step encoding"); + } + steps.add(decodeStep(stepArray)); + } + return List.copyOf(steps); + } catch (Exception e) { + if (e instanceof IllegalArgumentException iae) { + throw iae; + } + throw new IllegalArgumentException("Failed to decode MPF proof", e); + } + } + + private static Step decodeStep(Array array) { + Tag tag = array.getTag(); + long tagValue = tag == null ? TAG_BRANCH : tag.getValue(); + if (tagValue == TAG_BRANCH) { + int skip = readUInt(array.getDataItems().get(0)); + byte[] neighborBytes = readBytes(array.getDataItems().get(1)); + if (array.getDataItems().size() > 2) { + throw new IllegalArgumentException("branch value hashes are not supported by Poseidon MPF v1"); + } + return Step.branch(skip, splitNeighbors(neighborBytes)); + } + if (tagValue == TAG_FORK) { + int skip = readUInt(array.getDataItems().get(0)); + Array neighbor = (Array) array.getDataItems().get(1); + int nibble = readUInt(neighbor.getDataItems().get(0)); + byte[] prefix = readBytes(neighbor.getDataItems().get(1)); + byte[] root = readBytes(neighbor.getDataItems().get(2)); + return Step.fork(skip, nibble, prefix, root); + } + if (tagValue == TAG_LEAF) { + int skip = readUInt(array.getDataItems().get(0)); + byte[] keyHash = readBytes(array.getDataItems().get(1)); + byte[] valueHash = readBytes(array.getDataItems().get(2)); + return Step.leaf(skip, keyHash, valueHash); + } + throw new IllegalArgumentException("Unknown MPF proof step tag: " + tagValue); + } + + private static int readUInt(DataItem item) { + if (!(item instanceof UnsignedInteger uint)) { + throw new IllegalArgumentException("Expected unsigned integer"); + } + return uint.getValue().intValueExact(); + } + + private static byte[] readBytes(DataItem item) { + if (!(item instanceof ByteString bytes)) { + throw new IllegalArgumentException("Expected byte string"); + } + return bytes.getBytes(); + } + + private static List splitNeighbors(byte[] bytes) { + if (bytes.length != 4 * PoseidonMpfHash.DIGEST_LENGTH) { + throw new IllegalArgumentException("branch neighbors must be 128 bytes, got " + bytes.length); + } + var out = new ArrayList(4); + for (int i = 0; i < 4; i++) { + byte[] digest = Arrays.copyOfRange(bytes, + i * PoseidonMpfHash.DIGEST_LENGTH, + (i + 1) * PoseidonMpfHash.DIGEST_LENGTH); + out.add(PoseidonMpfHash.fieldFromDigestBytes(digest)); + } + return List.copyOf(out); + } + + private static List prefixChunksForTrailingDigest(byte[] prefix) { + var chunks = new ArrayList(); + int totalLength = prefix.length + PoseidonMpfHash.DIGEST_LENGTH; + int remainder = totalLength % PoseidonMpfHash.DIGEST_LENGTH; + int offset = 0; + if (remainder != 0) { + chunks.add(PoseidonMpfHash.unsigned(Arrays.copyOfRange(prefix, 0, Math.min(remainder, prefix.length)))); + offset = Math.min(remainder, prefix.length); + } + while (offset < prefix.length) { + int end = Math.min(prefix.length, offset + PoseidonMpfHash.DIGEST_LENGTH); + chunks.add(PoseidonMpfHash.unsigned(Arrays.copyOfRange(prefix, offset, end))); + offset = end; + } + return List.copyOf(chunks); + } + + private static List padFlat(List values, int size) { + if (values.size() > size) { + throw new IllegalArgumentException("value count " + values.size() + " exceeds fixed size " + size); + } + var out = new ArrayList(values); + while (out.size() < size) { + out.add(BigInteger.ZERO); + } + return List.copyOf(out); + } + + private static List toBigIntegers(int[] values) { + var out = new ArrayList(values.length); + for (int value : values) { + out.add(BigInteger.valueOf(value)); + } + return List.copyOf(out); + } + + public record Step( + int kind, + int skip, + List neighbors, + int neighborNibble, + int forkPrefixLength, + List forkPrefixChunks, + BigInteger forkRoot, + List leafKeyPath, + BigInteger leafValueDigest) { + + static Step branch(int skip, List neighbors) { + return new Step(KIND_BRANCH, skip, neighbors, 0, 0, List.of(), + BigInteger.ZERO, List.of(), BigInteger.ZERO); + } + + static Step fork(int skip, int neighborNibble, byte[] prefix, byte[] root) { + return new Step(KIND_FORK, skip, List.of(), neighborNibble, prefix.length, + prefixChunksForTrailingDigest(prefix), + PoseidonMpfHash.fieldFromDigestBytes(root), + List.of(), + BigInteger.ZERO); + } + + static Step leaf(int skip, byte[] keyHash, byte[] valueHash) { + return new Step(KIND_LEAF, skip, List.of(), 0, 0, List.of(), + BigInteger.ZERO, + toBigIntegers(PoseidonMpfHash.digestToNibbles(keyHash)), + PoseidonMpfHash.fieldFromDigestBytes(valueHash)); + } + + public Step { + if (skip < 0) { + throw new IllegalArgumentException("skip must be >= 0"); + } + if (neighborNibble < 0 || neighborNibble > 15) { + throw new IllegalArgumentException("neighbor nibble out of range: " + neighborNibble); + } + neighbors = List.copyOf(Objects.requireNonNull(neighbors, "neighbors")); + forkPrefixChunks = List.copyOf(Objects.requireNonNull(forkPrefixChunks, "forkPrefixChunks")); + forkRoot = Objects.requireNonNull(forkRoot, "forkRoot"); + leafKeyPath = List.copyOf(Objects.requireNonNull(leafKeyPath, "leafKeyPath")); + leafValueDigest = Objects.requireNonNull(leafValueDigest, "leafValueDigest"); + } + } +} diff --git a/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfCommitmentScheme.java b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfCommitmentScheme.java new file mode 100644 index 0000000..34216ab --- /dev/null +++ b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfCommitmentScheme.java @@ -0,0 +1,156 @@ +package com.bloxbean.cardano.zeroj.mpf.poseidon; + +import com.bloxbean.cardano.vds.core.NibblePath; +import com.bloxbean.cardano.vds.mpf.commitment.CommitmentScheme; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * Circuit-friendly MPF commitment scheme for the Poseidon-rooted profile. + */ +public final class PoseidonMpfCommitmentScheme implements CommitmentScheme { + private static final int RADIX = 16; + private static final int LEAF_NIBBLES_PER_CHUNK = 31; + + private final PoseidonParams params; + private final PoseidonMpfHashFunction hashFunction; + private final byte[] nullHash = new byte[PoseidonMpfHash.DIGEST_LENGTH]; + + public PoseidonMpfCommitmentScheme() { + this(PoseidonParamsBLS12_381T3.INSTANCE); + } + + public PoseidonMpfCommitmentScheme(PoseidonParams params) { + PoseidonMpfHash.requireBlsParams(params); + this.params = Objects.requireNonNull(params, "params"); + this.hashFunction = new PoseidonMpfHashFunction(params); + } + + public PoseidonParams params() { + return params; + } + + @Override + public byte[] commitBranch(NibblePath prefix, byte[][] childHashes, byte[] valueHash) { + Objects.requireNonNull(prefix, "prefix"); + Objects.requireNonNull(childHashes, "childHashes"); + if (childHashes.length != RADIX) { + throw new IllegalArgumentException("branch must expose exactly 16 child slots"); + } + if (valueHash != null) { + throw new IllegalArgumentException("branch values are not supported by the Poseidon MPF profile"); + } + + byte[][] nodes = new byte[RADIX][]; + for (int i = 0; i < RADIX; i++) { + nodes[i] = sanitize(childHashes[i]); + } + byte[] subRoot = binaryMerkleRoot(nodes); + return hashFunction.digest(concat(nibbleBytes(prefix), subRoot)); + } + + @Override + public byte[] commitLeaf(NibblePath suffix, byte[] valueHash) { + Objects.requireNonNull(suffix, "suffix"); + BigInteger valueField = PoseidonMpfHash.fieldFromDigestBytes(valueHash); + int[] nibbles = suffix.getNibbles(); + + List fields = new ArrayList<>(); + fields.add(PoseidonMpfHash.DOMAIN_LEAF); + fields.add(BigInteger.valueOf(nibbles.length)); + for (int offset = 0; offset < nibbles.length; offset += LEAF_NIBBLES_PER_CHUNK) { + int end = Math.min(nibbles.length, offset + LEAF_NIBBLES_PER_CHUNK); + fields.add(packNibbleBytes(nibbles, offset, end)); + } + while (fields.size() < 2 + PoseidonMpfHash.MAX_DIGEST_CHUNKS) { + fields.add(BigInteger.ZERO); + } + fields.add(valueField); + return PoseidonMpfHash.toDigestBytes(PoseidonHash.hashN(params, fields.toArray(BigInteger[]::new))); + } + + @Override + public byte[] commitExtension(NibblePath path, byte[] childHash) { + Objects.requireNonNull(path, "path"); + return hashFunction.digest(concat(nibbleBytes(path), sanitize(childHash))); + } + + @Override + public byte[] nullHash() { + return Arrays.copyOf(nullHash, nullHash.length); + } + + @Override + public boolean encodesBranchValueInBranchCommitment() { + return false; + } + + byte[] binaryMerkleRoot(byte[][] nodes) { + if (nodes.length != RADIX) { + throw new IllegalArgumentException("expected 16 child commitments"); + } + byte[][] current = new byte[RADIX][]; + for (int i = 0; i < RADIX; i++) { + current[i] = sanitize(nodes[i]); + } + int size = current.length; + while (size > 1) { + byte[][] next = new byte[size / 2][]; + for (int i = 0; i < size; i += 2) { + next[i / 2] = hashFunction.digest(concat(current[i], current[i + 1])); + } + current = next; + size = current.length; + } + return current[0]; + } + + private byte[] sanitize(byte[] value) { + if (value == null || value.length == 0) { + return nullHash(); + } + if (value.length != PoseidonMpfHash.DIGEST_LENGTH) { + throw new IllegalArgumentException("commitment must be 32 bytes, got " + value.length); + } + PoseidonMpfHash.fieldFromDigestBytes(value); + return Arrays.copyOf(value, value.length); + } + + private static byte[] nibbleBytes(NibblePath path) { + int[] nibbles = path.getNibbles(); + byte[] out = new byte[nibbles.length]; + for (int i = 0; i < nibbles.length; i++) { + int nibble = nibbles[i]; + if (nibble < 0 || nibble > 15) { + throw new IllegalArgumentException("nibble out of range: " + nibble); + } + out[i] = (byte) nibble; + } + return out; + } + + private static BigInteger packNibbleBytes(int[] nibbles, int start, int end) { + byte[] bytes = new byte[end - start]; + for (int i = start; i < end; i++) { + int nibble = nibbles[i]; + if (nibble < 0 || nibble > 15) { + throw new IllegalArgumentException("nibble out of range: " + nibble); + } + bytes[i - start] = (byte) nibble; + } + return PoseidonMpfHash.unsigned(bytes); + } + + static byte[] concat(byte[] a, byte[] b) { + byte[] out = Arrays.copyOf(a, a.length + b.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } +} diff --git a/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfHash.java b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfHash.java new file mode 100644 index 0000000..547035f --- /dev/null +++ b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfHash.java @@ -0,0 +1,153 @@ +package com.bloxbean.cardano.zeroj.mpf.poseidon; + +import com.bloxbean.cardano.zeroj.circuit.FieldConfig; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonHash; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * Canonical Poseidon byte digest used by the Poseidon-rooted MPF profile. + */ +public final class PoseidonMpfHash { + public static final int DIGEST_LENGTH = 32; + public static final int KEY_PATH_NIBBLES = DIGEST_LENGTH * 2; + public static final int MAX_DIGEST_CHUNKS = 3; + + static final BigInteger DOMAIN_BYTES = BigInteger.valueOf(0x5a4d5046L); // ZMPF + static final BigInteger DOMAIN_LEAF = BigInteger.valueOf(0x5a4d5047L); + static final BigInteger DOMAIN_KEY_PATH = BigInteger.valueOf(0x5a4d5048L); + static final BigInteger DOMAIN_KEY_NULLIFIER = BigInteger.valueOf(0x5a4d5049L); + + private static final BigInteger PRIME = FieldConfig.BLS12_381.prime(); + + private PoseidonMpfHash() {} + + public static byte[] digest(byte[] bytes) { + return digest(PoseidonParamsBLS12_381T3.INSTANCE, bytes); + } + + public static byte[] digest(PoseidonParams params, byte[] bytes) { + return toDigestBytes(digestField(params, bytes)); + } + + public static BigInteger digestField(PoseidonParams params, byte[] bytes) { + requireBlsParams(params); + Objects.requireNonNull(bytes, "bytes"); + + List chunks = new ArrayList<>(); + + int offset = 0; + int remainder = bytes.length % DIGEST_LENGTH; + if (remainder != 0) { + chunks.add(unsigned(Arrays.copyOfRange(bytes, 0, remainder))); + offset = remainder; + } + + while (offset < bytes.length) { + BigInteger chunk = unsigned(Arrays.copyOfRange(bytes, offset, offset + DIGEST_LENGTH)); + if (chunk.compareTo(PRIME) >= 0) { + throw new IllegalArgumentException("32-byte chunk is not a canonical BLS12-381 scalar field element"); + } + chunks.add(chunk); + offset += DIGEST_LENGTH; + } + if (chunks.size() > MAX_DIGEST_CHUNKS) { + throw new IllegalArgumentException("Poseidon MPF byte digest supports at most " + + MAX_DIGEST_CHUNKS + " chunks, got " + chunks.size()); + } + + List fields = new ArrayList<>(); + fields.add(DOMAIN_BYTES); + fields.add(BigInteger.valueOf(bytes.length)); + fields.addAll(chunks); + while (fields.size() < 2 + MAX_DIGEST_CHUNKS) { + fields.add(BigInteger.ZERO); + } + + return PoseidonHash.hashN(params, fields.toArray(BigInteger[]::new)); + } + + public static BigInteger fieldFromDigestBytes(byte[] digest) { + Objects.requireNonNull(digest, "digest"); + if (digest.length != DIGEST_LENGTH) { + throw new IllegalArgumentException("digest must be 32 bytes, got " + digest.length); + } + BigInteger value = unsigned(digest); + if (value.compareTo(PRIME) >= 0) { + throw new IllegalArgumentException("digest is not a canonical BLS12-381 scalar field element"); + } + return value; + } + + public static byte[] toDigestBytes(BigInteger value) { + Objects.requireNonNull(value, "value"); + BigInteger normalized = value.mod(PRIME); + byte[] raw = normalized.toByteArray(); + byte[] out = new byte[DIGEST_LENGTH]; + int src = Math.max(0, raw.length - DIGEST_LENGTH); + int count = Math.min(raw.length, DIGEST_LENGTH); + System.arraycopy(raw, src, out, DIGEST_LENGTH - count, count); + return out; + } + + public static int[] digestToNibbles(byte[] digest) { + Objects.requireNonNull(digest, "digest"); + if (digest.length != DIGEST_LENGTH) { + throw new IllegalArgumentException("digest must be 32 bytes, got " + digest.length); + } + int[] nibbles = new int[KEY_PATH_NIBBLES]; + for (int i = 0; i < digest.length; i++) { + int b = digest[i] & 0xff; + nibbles[i * 2] = (b >>> 4) & 0x0f; + nibbles[i * 2 + 1] = b & 0x0f; + } + return nibbles; + } + + public static BigInteger keyPathCommitment(PoseidonParams params, int[] keyPath) { + return hashKeyPath(params, DOMAIN_KEY_PATH, keyPath); + } + + public static BigInteger keyPathNullifier(PoseidonParams params, int[] keyPath) { + return hashKeyPath(params, DOMAIN_KEY_NULLIFIER, keyPath); + } + + public static void requireBlsParams(PoseidonParams params) { + Objects.requireNonNull(params, "params"); + if (!FieldConfig.BLS12_381.equals(params.field())) { + throw new IllegalArgumentException("Poseidon MPF requires BLS12-381 Poseidon params"); + } + if (params.t() != 3 || params.alpha() != 5) { + throw new IllegalArgumentException("Poseidon MPF supports only t=3, alpha=5 params"); + } + } + + static BigInteger unsigned(byte[] bytes) { + return bytes.length == 0 ? BigInteger.ZERO : new BigInteger(1, bytes); + } + + private static BigInteger hashKeyPath(PoseidonParams params, BigInteger domain, int[] keyPath) { + requireBlsParams(params); + Objects.requireNonNull(keyPath, "keyPath"); + if (keyPath.length != KEY_PATH_NIBBLES) { + throw new IllegalArgumentException("keyPath must contain 64 nibbles, got " + keyPath.length); + } + BigInteger[] fields = new BigInteger[2 + keyPath.length]; + fields[0] = domain; + fields[1] = BigInteger.valueOf(keyPath.length); + for (int i = 0; i < keyPath.length; i++) { + int nibble = keyPath[i]; + if (nibble < 0 || nibble > 15) { + throw new IllegalArgumentException("keyPath nibble out of range at " + i + ": " + nibble); + } + fields[i + 2] = BigInteger.valueOf(nibble); + } + return PoseidonHash.hashN(params, fields); + } +} diff --git a/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfHashFunction.java b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfHashFunction.java new file mode 100644 index 0000000..e10169c --- /dev/null +++ b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfHashFunction.java @@ -0,0 +1,31 @@ +package com.bloxbean.cardano.zeroj.mpf.poseidon; + +import com.bloxbean.cardano.vds.core.api.HashFunction; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; + +import java.util.Objects; + +/** + * CCL {@link HashFunction} backed by the ZeroJ BLS12-381 Poseidon MPF digest. + */ +public final class PoseidonMpfHashFunction implements HashFunction { + public static final PoseidonMpfHashFunction INSTANCE = + new PoseidonMpfHashFunction(PoseidonParamsBLS12_381T3.INSTANCE); + + private final PoseidonParams params; + + public PoseidonMpfHashFunction(PoseidonParams params) { + PoseidonMpfHash.requireBlsParams(params); + this.params = Objects.requireNonNull(params, "params"); + } + + public PoseidonParams params() { + return params; + } + + @Override + public byte[] digest(byte[] in) { + return PoseidonMpfHash.digest(params, in); + } +} diff --git a/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfReference.java b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfReference.java new file mode 100644 index 0000000..75adca0 --- /dev/null +++ b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfReference.java @@ -0,0 +1,38 @@ +package com.bloxbean.cardano.zeroj.mpf.poseidon; + +import com.bloxbean.cardano.vds.mpf.proof.ProofVerifier; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; + +/** + * Reference verifier for Poseidon-rooted MPF proofs using CCL's public proof + * verifier and the ZeroJ Poseidon adapters. + */ +public final class PoseidonMpfReference { + private PoseidonMpfReference() {} + + public static boolean including(byte[] expectedRoot, byte[] key, byte[] value, byte[] proofCbor) { + return verify(PoseidonParamsBLS12_381T3.INSTANCE, expectedRoot, key, value, true, proofCbor); + } + + public static boolean excluding(byte[] expectedRoot, byte[] key, byte[] proofCbor) { + return verify(PoseidonParamsBLS12_381T3.INSTANCE, expectedRoot, key, null, false, proofCbor); + } + + public static boolean verify( + PoseidonParams params, + byte[] expectedRoot, + byte[] key, + byte[] value, + boolean including, + byte[] proofCbor) { + return ProofVerifier.verify( + expectedRoot, + key, + value, + including, + proofCbor, + new PoseidonMpfHashFunction(params), + new PoseidonMpfCommitmentScheme(params)); + } +} diff --git a/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfTrie.java b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfTrie.java new file mode 100644 index 0000000..0fc6fa4 --- /dev/null +++ b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfTrie.java @@ -0,0 +1,42 @@ +package com.bloxbean.cardano.zeroj.mpf.poseidon; + +import com.bloxbean.cardano.vds.core.api.NodeStore; +import com.bloxbean.cardano.vds.mpf.MpfTrie; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; + +import java.util.Objects; + +/** + * Convenience factory for CCL {@link MpfTrie} instances using the ZeroJ + * Poseidon-rooted MPF profile. + */ +public final class PoseidonMpfTrie { + private PoseidonMpfTrie() {} + + public static MpfTrie inMemory() { + return create(new InMemoryNodeStore(), null, PoseidonParamsBLS12_381T3.INSTANCE); + } + + public static MpfTrie inMemory(byte[] root) { + return create(new InMemoryNodeStore(), root, PoseidonParamsBLS12_381T3.INSTANCE); + } + + public static MpfTrie create(NodeStore store) { + return create(store, null, PoseidonParamsBLS12_381T3.INSTANCE); + } + + public static MpfTrie create(NodeStore store, byte[] root) { + return create(store, root, PoseidonParamsBLS12_381T3.INSTANCE); + } + + public static MpfTrie create(NodeStore store, byte[] root, PoseidonParams params) { + Objects.requireNonNull(store, "store"); + PoseidonMpfHash.requireBlsParams(params); + return new MpfTrie( + store, + new PoseidonMpfHashFunction(params), + root, + new PoseidonMpfCommitmentScheme(params)); + } +} diff --git a/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfValueCommitment.java b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfValueCommitment.java new file mode 100644 index 0000000..8f3fd50 --- /dev/null +++ b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfValueCommitment.java @@ -0,0 +1,29 @@ +package com.bloxbean.cardano.zeroj.mpf.poseidon; + +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; + +import java.math.BigInteger; + +/** + * Host-side helpers for values stored in the Poseidon MPF profile. + */ +public final class PoseidonMpfValueCommitment { + private PoseidonMpfValueCommitment() {} + + public static byte[] digest(byte[] value) { + return digest(PoseidonParamsBLS12_381T3.INSTANCE, value); + } + + public static byte[] digest(PoseidonParams params, byte[] value) { + return new PoseidonMpfHashFunction(params).digest(value); + } + + public static BigInteger field(byte[] value) { + return field(PoseidonParamsBLS12_381T3.INSTANCE, value); + } + + public static BigInteger field(PoseidonParams params, byte[] value) { + return PoseidonMpfHash.fieldFromDigestBytes(digest(params, value)); + } +} diff --git a/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfWitness.java b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfWitness.java new file mode 100644 index 0000000..48e9354 --- /dev/null +++ b/zeroj-mpf-poseidon/src/main/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfWitness.java @@ -0,0 +1,103 @@ +package com.bloxbean.cardano.zeroj.mpf.poseidon; + +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkInputMap; + +import java.math.BigInteger; +import java.util.List; +import java.util.Objects; + +/** + * Flattened witness arrays consumed by annotated MPF circuits. + */ +public record PoseidonMpfWitness( + List keyPath, + List kind, + List skip, + List> neighbors, + List neighborNibble, + List forkPrefixLength, + List> forkPrefixChunks, + List forkRoot, + List> leafKeyPath, + List leafValueDigest, + List valid) { + + public PoseidonMpfWitness { + keyPath = copyFlat(keyPath, "keyPath"); + kind = copyFlat(kind, "kind"); + skip = copyFlat(skip, "skip"); + neighbors = copyNested(neighbors, "neighbors"); + neighborNibble = copyFlat(neighborNibble, "neighborNibble"); + forkPrefixLength = copyFlat(forkPrefixLength, "forkPrefixLength"); + forkPrefixChunks = copyNested(forkPrefixChunks, "forkPrefixChunks"); + forkRoot = copyFlat(forkRoot, "forkRoot"); + leafKeyPath = copyNested(leafKeyPath, "leafKeyPath"); + leafValueDigest = copyFlat(leafValueDigest, "leafValueDigest"); + valid = copyFlat(valid, "valid"); + } + + public ZkInputMap putInto(ZkInputMap inputs, Names names) { + Objects.requireNonNull(inputs, "inputs"); + Objects.requireNonNull(names, "names"); + inputs.putArray(names.keyPath(), keyPath); + inputs.putArray(names.kind(), kind); + inputs.putArray(names.skip(), skip); + inputs.putNestedArray(names.neighbors(), neighbors); + inputs.putArray(names.neighborNibble(), neighborNibble); + inputs.putArray(names.forkPrefixLength(), forkPrefixLength); + inputs.putNestedArray(names.forkPrefixChunks(), forkPrefixChunks); + inputs.putArray(names.forkRoot(), forkRoot); + inputs.putNestedArray(names.leafKeyPath(), leafKeyPath); + inputs.putArray(names.leafValueDigest(), leafValueDigest); + inputs.putArray(names.valid(), valid); + return inputs; + } + + public ZkInputMap putInto(ZkInputMap inputs) { + return putInto(inputs, Names.defaults()); + } + + public record Names( + String keyPath, + String kind, + String skip, + String neighbors, + String neighborNibble, + String forkPrefixLength, + String forkPrefixChunks, + String forkRoot, + String leafKeyPath, + String leafValueDigest, + String valid) { + + public static Names defaults() { + return new Names( + "key_path", + "mpf_kind", + "mpf_skip", + "mpf_neighbor", + "mpf_neighbor_nibble", + "mpf_fork_prefix_length", + "mpf_fork_prefix", + "mpf_fork_root", + "mpf_leaf_key_path", + "mpf_leaf_value_digest", + "mpf_valid"); + } + } + + private static List copyFlat(List values, String name) { + Objects.requireNonNull(values, name); + for (int i = 0; i < values.size(); i++) { + Objects.requireNonNull(values.get(i), name + "[" + i + "]"); + } + return List.copyOf(values); + } + + private static List> copyNested(List> values, String name) { + Objects.requireNonNull(values, name); + return values.stream() + .map(row -> copyFlat(row, name + " row")) + .toList(); + } +} diff --git a/zeroj-mpf-poseidon/src/test/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfAdapterTest.java b/zeroj-mpf-poseidon/src/test/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfAdapterTest.java new file mode 100644 index 0000000..5270fd8 --- /dev/null +++ b/zeroj-mpf-poseidon/src/test/java/com/bloxbean/cardano/zeroj/mpf/poseidon/PoseidonMpfAdapterTest.java @@ -0,0 +1,93 @@ +package com.bloxbean.cardano.zeroj.mpf.poseidon; + +import com.bloxbean.cardano.vds.mpf.MpfTrie; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class PoseidonMpfAdapterTest { + + @Test + void cclTrieVerifiesInclusionWithPoseidonAdapters() { + MpfTrie trie = PoseidonMpfTrie.inMemory(); + byte[] key = bytes("product:1001"); + byte[] value = bytes("batch=A;status=ok"); + + trie.put(key, value); + byte[] root = trie.getRootHash(); + byte[] proof = trie.getProofWire(key).orElseThrow(); + + assertArrayEquals(value, trie.get(key)); + assertTrue(PoseidonMpfReference.including(root, key, value, proof)); + assertTrue(trie.verifyProofWire(root, key, value, true, proof)); + } + + @Test + void cclTrieVerifiesExclusionWithPoseidonAdapters() { + MpfTrie trie = PoseidonMpfTrie.inMemory(); + trie.put(bytes("product:1001"), bytes("ok")); + trie.put(bytes("product:1002"), bytes("ok")); + trie.put(bytes("product:1003"), bytes("recalled")); + + byte[] missing = bytes("product:9999"); + byte[] root = trie.getRootHash(); + byte[] proof = trie.getProofWire(missing).orElseThrow(); + + assertNull(trie.get(missing)); + assertTrue(PoseidonMpfReference.excluding(root, missing, proof)); + assertTrue(trie.verifyProofWire(root, missing, null, false, proof)); + } + + @Test + void tamperedValueFailsReferenceVerification() { + MpfTrie trie = PoseidonMpfTrie.inMemory(); + byte[] key = bytes("product:1001"); + byte[] value = bytes("ok"); + trie.put(key, value); + + byte[] proof = trie.getProofWire(key).orElseThrow(); + assertFalse(PoseidonMpfReference.including(trie.getRootHash(), key, bytes("bad"), proof)); + } + + @Test + void codecProducesStablePaddedWitnessArrays() { + MpfTrie trie = PoseidonMpfTrie.inMemory(); + byte[] key = bytes("product:1001"); + byte[] value = bytes("ok"); + trie.put(key, value); + + PoseidonMpfWitness witness = PoseidonMpfCodec.toWitness(key, trie.getProofWire(key).orElseThrow(), 8, 2); + + assertEquals(PoseidonMpfHash.KEY_PATH_NIBBLES, witness.keyPath().size()); + assertEquals(8, witness.kind().size()); + assertEquals(8, witness.neighbors().size()); + assertEquals(4, witness.neighbors().getFirst().size()); + assertEquals(8, witness.forkPrefixChunks().size()); + assertEquals(2, witness.forkPrefixChunks().getFirst().size()); + assertTrue(witness.valid().stream().allMatch(v -> v.equals(BigInteger.ONE) || v.equals(BigInteger.ZERO))); + } + + @Test + void codecRejectsProofsLongerThanMaxSteps() { + MpfTrie trie = PoseidonMpfTrie.inMemory(); + trie.put(bytes("product:1001"), bytes("ok")); + trie.put(bytes("product:1002"), bytes("ok")); + + byte[] proof = trie.getProofWire(bytes("product:1001")).orElseThrow(); + assertThrows(IllegalArgumentException.class, () -> PoseidonMpfCodec.toWitness(bytes("product:1001"), proof, 0, 2)); + } + + @Test + void valueCommitmentMatchesHashFunctionDigest() { + byte[] value = bytes("some value"); + byte[] digest = PoseidonMpfValueCommitment.digest(value); + assertEquals(PoseidonMpfHash.fieldFromDigestBytes(digest), PoseidonMpfValueCommitment.field(value)); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/zeroj-mpf-poseidon/src/test/java/com/bloxbean/cardano/zeroj/mpf/poseidon/ZkMpfPoseidonGadgetTest.java b/zeroj-mpf-poseidon/src/test/java/com/bloxbean/cardano/zeroj/mpf/poseidon/ZkMpfPoseidonGadgetTest.java new file mode 100644 index 0000000..666cc5d --- /dev/null +++ b/zeroj-mpf-poseidon/src/test/java/com/bloxbean/cardano/zeroj/mpf/poseidon/ZkMpfPoseidonGadgetTest.java @@ -0,0 +1,318 @@ +package com.bloxbean.cardano.zeroj.mpf.poseidon; + +import com.bloxbean.cardano.vds.mpf.MpfTrie; +import com.bloxbean.cardano.zeroj.api.CurveId; +import com.bloxbean.cardano.zeroj.circuit.CircuitBuilder; +import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkArray; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkBool; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkContext; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkField; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkInputMap; +import com.bloxbean.cardano.zeroj.circuit.annotation.ZkUInt; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMpf; +import com.bloxbean.cardano.zeroj.circuit.lib.zk.ZkMpfProof; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ZkMpfPoseidonGadgetTest { + private static final int MAX_FORK_PREFIX_CHUNKS = 2; + + @Test + void keyPathCommitmentMatchesOffChainAdapter() { + byte[] key = bytes("registry:member:1"); + PoseidonMpfWitness witness = PoseidonMpfCodec.toWitness(key, new byte[]{(byte) 0x80}, 0, 0); + int[] keyPath = witness.keyPath().stream().mapToInt(BigInteger::intValueExact).toArray(); + BigInteger expectedCommitment = PoseidonMpfHash.keyPathCommitment( + PoseidonParamsBLS12_381T3.INSTANCE, + keyPath); + BigInteger expectedNullifier = PoseidonMpfHash.keyPathNullifier( + PoseidonParamsBLS12_381T3.INSTANCE, + keyPath); + + var circuit = CircuitBuilder.create("zk-mpf-key-path-binding") + .publicVar("commitment") + .publicVar("nullifier"); + declareUIntArray(circuit, "key_path", ZkMpf.KEY_PATH_NIBBLES); + circuit.defineSignals(c -> { + var zk = new ZkContext(c); + var path = ZkArray.secretUInts(c, "key_path", ZkMpf.KEY_PATH_NIBBLES, 4); + ZkMpf.keyPathCommitment(zk, PoseidonParamsBLS12_381T3.INSTANCE, path) + .assertEqual(ZkField.publicInput(c, "commitment")); + ZkMpf.keyPathNullifier(zk, PoseidonParamsBLS12_381T3.INSTANCE, path) + .assertEqual(ZkField.publicInput(c, "nullifier")); + }); + + var inputs = new ZkInputMap() + .put("commitment", expectedCommitment) + .put("nullifier", expectedNullifier) + .putArray("key_path", witness.keyPath()) + .toWitnessMap(); + assertDoesNotThrow(() -> circuit.calculateWitness(inputs, CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); + } + + @Test + void verifiesSingleLeafInclusionProofInsideCircuit() { + MpfTrie trie = PoseidonMpfTrie.inMemory(); + byte[] key = bytes("registry:member:1"); + byte[] value = bytes("active"); + trie.put(key, value); + + byte[] proof = trie.getProofWire(key).orElseThrow(); + PoseidonMpfWitness witness = PoseidonMpfCodec.toWitness(key, proof, 0, 0); + BigInteger root = PoseidonMpfHash.fieldFromDigestBytes(trie.getRootHash()); + BigInteger valueCommitment = PoseidonMpfValueCommitment.field(value); + var circuit = inclusionCircuit(0, 0); + var inputs = new ZkInputMap() + .put("root", root) + .put("value_commitment", valueCommitment); + witness.putInto(inputs); + + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381)); + assertDoesNotThrow(() -> circuit.compileR1CS(CurveId.BLS12_381)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness( + withInput(inputs.toWitnessMap(), "value_commitment", BigInteger.ONE), + CurveId.BLS12_381)); + } + + @Test + void verifiesCclBranchInclusionProofInsideCircuit() { + MpfTrie trie = PoseidonMpfTrie.inMemory(); + byte[] key = bytes("registry:member:1"); + byte[] value = bytes("active"); + trie.put(key, value); + trie.put(bytes("registry:member:2"), bytes("active")); + + byte[] proof = trie.getProofWire(key).orElseThrow(); + int maxSteps = PoseidonMpfCodec.decode(proof).size(); + assertTrue(maxSteps > 0, "fixture should exercise at least one explicit MPF step"); + + PoseidonMpfWitness witness = PoseidonMpfCodec.toWitness(key, proof, maxSteps, MAX_FORK_PREFIX_CHUNKS); + var circuit = inclusionCircuit(maxSteps, MAX_FORK_PREFIX_CHUNKS); + var inputs = new ZkInputMap() + .put("root", PoseidonMpfHash.fieldFromDigestBytes(trie.getRootHash())) + .put("value_commitment", PoseidonMpfValueCommitment.field(value)); + witness.putInto(inputs); + + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness( + withInput(inputs.toWitnessMap(), "root", BigInteger.ONE), + CurveId.BLS12_381)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness( + withIncrementedNibble(inputs.toWitnessMap(), "key_path_0"), + CurveId.BLS12_381)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness( + withInput(inputs.toWitnessMap(), "mpf_fork_prefix_length_0", BigInteger.valueOf(65)), + CurveId.BLS12_381)); + } + + @Test + void verifiesEmptyTrieExclusionProofInsideCircuit() { + MpfTrie trie = PoseidonMpfTrie.inMemory(); + byte[] missing = bytes("registry:missing"); + byte[] proof = trie.getProofWire(missing).orElseThrow(); + PoseidonMpfWitness witness = PoseidonMpfCodec.toWitness(missing, proof, 0, 0); + + var circuit = exclusionCircuit(0, 0); + var inputs = new ZkInputMap().put("root", BigInteger.ZERO); + witness.putInto(inputs); + + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381)); + } + + @Test + void verifiesCclExclusionProofInsideCircuit() { + MpfTrie trie = PoseidonMpfTrie.inMemory(); + trie.put(bytes("registry:member:1"), bytes("active")); + trie.put(bytes("registry:member:2"), bytes("active")); + trie.put(bytes("registry:member:3"), bytes("suspended")); + + byte[] missing = bytes("registry:member:9"); + byte[] proof = trie.getProofWire(missing).orElseThrow(); + int maxSteps = PoseidonMpfCodec.decode(proof).size(); + assertTrue(maxSteps > 0, "fixture should exercise at least one explicit MPF step"); + + PoseidonMpfWitness witness = PoseidonMpfCodec.toWitness(missing, proof, maxSteps, MAX_FORK_PREFIX_CHUNKS); + var circuit = exclusionCircuit(maxSteps, MAX_FORK_PREFIX_CHUNKS); + var inputs = new ZkInputMap() + .put("root", PoseidonMpfHash.fieldFromDigestBytes(trie.getRootHash())); + witness.putInto(inputs); + + assertDoesNotThrow(() -> circuit.calculateWitness(inputs.toWitnessMap(), CurveId.BLS12_381)); + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness( + withInput(inputs.toWitnessMap(), "root", BigInteger.ONE), + CurveId.BLS12_381)); + } + + @Test + void rejectsForgedTerminalForkExclusionProof() { + var circuit = exclusionCircuit(1, MAX_FORK_PREFIX_CHUNKS); + BigInteger forgedRoot = BigInteger.valueOf(12_345); + var inputs = new ZkInputMap() + .put("root", forgedRoot) + .putArray("key_path", repeated(BigInteger.ZERO, ZkMpf.KEY_PATH_NIBBLES)) + .putArray("mpf_kind", List.of(BigInteger.ONE)) + .putArray("mpf_skip", List.of(BigInteger.ZERO)) + .putNestedArray("mpf_neighbor", List.of(repeated(BigInteger.ZERO, 4))) + .putArray("mpf_neighbor_nibble", List.of(BigInteger.ONE)) + .putArray("mpf_fork_prefix_length", List.of(BigInteger.ZERO)) + .putNestedArray("mpf_fork_prefix", List.of(repeated(BigInteger.ZERO, MAX_FORK_PREFIX_CHUNKS))) + .putArray("mpf_fork_root", List.of(forgedRoot)) + .putNestedArray("mpf_leaf_key_path", List.of(repeated(BigInteger.ZERO, ZkMpf.KEY_PATH_NIBBLES))) + .putArray("mpf_leaf_value_digest", List.of(BigInteger.ZERO)) + .putArray("mpf_valid", List.of(BigInteger.ONE)); + + assertThrows(ArithmeticException.class, () -> circuit.calculateWitness( + inputs.toWitnessMap(), + CurveId.BLS12_381)); + } + + @Test + void rejectsProofArraysWithWrongBitWidths() { + var circuit = CircuitBuilder.create("zk-mpf-invalid-widths"); + declareUIntArray(circuit, "mpf_kind", 1); + declareUIntArray(circuit, "mpf_skip", 1); + declareFieldMatrix(circuit, "mpf_neighbor", 1, 4); + declareUIntArray(circuit, "mpf_neighbor_nibble", 1); + declareUIntArray(circuit, "mpf_fork_prefix_length", 1); + declareFieldMatrix(circuit, "mpf_fork_prefix", 1, MAX_FORK_PREFIX_CHUNKS); + declareFieldArray(circuit, "mpf_fork_root", 1); + declareUIntMatrix(circuit, "mpf_leaf_key_path", 1, ZkMpf.KEY_PATH_NIBBLES); + declareFieldArray(circuit, "mpf_leaf_value_digest", 1); + declareBoolArray(circuit, "mpf_valid", 1); + + assertThrows(IllegalArgumentException.class, () -> circuit.defineSignals(c -> ZkMpfProof.fromArrays( + ZkArray.secretUInts(c, "mpf_kind", 1, 3), + ZkArray.secretUInts(c, "mpf_skip", 1, 8), + ZkArray.secretFieldMatrix(c, "mpf_neighbor", 1, 4), + ZkArray.secretUInts(c, "mpf_neighbor_nibble", 1, 4), + ZkArray.secretUInts(c, "mpf_fork_prefix_length", 1, 8), + ZkArray.secretFieldMatrix(c, "mpf_fork_prefix", 1, MAX_FORK_PREFIX_CHUNKS), + ZkArray.secretFields(c, "mpf_fork_root", 1), + ZkArray.secretUIntMatrix(c, "mpf_leaf_key_path", 1, ZkMpf.KEY_PATH_NIBBLES, 4), + ZkArray.secretFields(c, "mpf_leaf_value_digest", 1), + ZkArray.secretBools(c, "mpf_valid", 1)))); + } + + private static CircuitBuilder inclusionCircuit(int maxSteps, int maxForkPrefixChunks) { + var circuit = CircuitBuilder.create("zk-mpf-inclusion") + .publicVar("root") + .secretVar("value_commitment"); + declareUIntArray(circuit, "key_path", ZkMpf.KEY_PATH_NIBBLES); + declareProofArrays(circuit, maxSteps, maxForkPrefixChunks); + return circuit.defineSignals(c -> { + var zk = new ZkContext(c); + ZkMpf.verifyInclusionPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + ZkArray.secretUInts(c, "key_path", ZkMpf.KEY_PATH_NIBBLES, 4), + ZkField.secret(c, "value_commitment"), + ZkField.publicInput(c, "root"), + proof(c, maxSteps, maxForkPrefixChunks)); + }); + } + + private static CircuitBuilder exclusionCircuit(int maxSteps, int maxForkPrefixChunks) { + var circuit = CircuitBuilder.create("zk-mpf-exclusion") + .publicVar("root"); + declareUIntArray(circuit, "key_path", ZkMpf.KEY_PATH_NIBBLES); + declareProofArrays(circuit, maxSteps, maxForkPrefixChunks); + return circuit.defineSignals(c -> { + var zk = new ZkContext(c); + ZkMpf.verifyExclusionPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + ZkArray.secretUInts(c, "key_path", ZkMpf.KEY_PATH_NIBBLES, 4), + ZkField.publicInput(c, "root"), + proof(c, maxSteps, maxForkPrefixChunks)); + }); + } + + private static ZkMpfProof proof(SignalBuilder c, int maxSteps, int maxForkPrefixChunks) { + return ZkMpfProof.fromArrays( + ZkArray.secretUInts(c, "mpf_kind", maxSteps, 2), + ZkArray.secretUInts(c, "mpf_skip", maxSteps, 8), + ZkArray.secretFieldMatrix(c, "mpf_neighbor", maxSteps, 4), + ZkArray.secretUInts(c, "mpf_neighbor_nibble", maxSteps, 4), + ZkArray.secretUInts(c, "mpf_fork_prefix_length", maxSteps, 8), + ZkArray.secretFieldMatrix(c, "mpf_fork_prefix", maxSteps, maxForkPrefixChunks), + ZkArray.secretFields(c, "mpf_fork_root", maxSteps), + ZkArray.secretUIntMatrix(c, "mpf_leaf_key_path", maxSteps, ZkMpf.KEY_PATH_NIBBLES, 4), + ZkArray.secretFields(c, "mpf_leaf_value_digest", maxSteps), + ZkArray.secretBools(c, "mpf_valid", maxSteps)); + } + + private static void declareProofArrays(CircuitBuilder circuit, int maxSteps, int maxForkPrefixChunks) { + declareUIntArray(circuit, "mpf_kind", maxSteps); + declareUIntArray(circuit, "mpf_skip", maxSteps); + declareFieldMatrix(circuit, "mpf_neighbor", maxSteps, 4); + declareUIntArray(circuit, "mpf_neighbor_nibble", maxSteps); + declareUIntArray(circuit, "mpf_fork_prefix_length", maxSteps); + declareFieldMatrix(circuit, "mpf_fork_prefix", maxSteps, maxForkPrefixChunks); + declareFieldArray(circuit, "mpf_fork_root", maxSteps); + declareUIntMatrix(circuit, "mpf_leaf_key_path", maxSteps, ZkMpf.KEY_PATH_NIBBLES); + declareFieldArray(circuit, "mpf_leaf_value_digest", maxSteps); + declareBoolArray(circuit, "mpf_valid", maxSteps); + } + + private static void declareUIntArray(CircuitBuilder circuit, String baseName, int size) { + for (int i = 0; i < size; i++) { + circuit.secretVar(baseName + "_" + i); + } + } + + private static void declareBoolArray(CircuitBuilder circuit, String baseName, int size) { + declareUIntArray(circuit, baseName, size); + } + + private static void declareFieldArray(CircuitBuilder circuit, String baseName, int size) { + declareUIntArray(circuit, baseName, size); + } + + private static void declareUIntMatrix(CircuitBuilder circuit, String baseName, int rows, int cols) { + declareFieldMatrix(circuit, baseName, rows, cols); + } + + private static void declareFieldMatrix(CircuitBuilder circuit, String baseName, int rows, int cols) { + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + circuit.secretVar(baseName + "_" + row + "_" + col); + } + } + } + + private static Map> withInput( + Map> inputs, + String name, + BigInteger value) { + var copy = new LinkedHashMap<>(inputs); + copy.put(name, List.of(value)); + return Map.copyOf(copy); + } + + private static Map> withIncrementedNibble( + Map> inputs, + String name) { + BigInteger current = inputs.get(name).getFirst(); + return withInput(inputs, name, current.add(BigInteger.ONE).mod(BigInteger.valueOf(16))); + } + + private static List repeated(BigInteger value, int size) { + return java.util.Collections.nCopies(size, value); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} From c60c2ac5ae34564f0f7a463e55b22f5eb937a1f6 Mon Sep 17 00:00:00 2001 From: Satya Date: Tue, 19 May 2026 11:01:19 +0800 Subject: [PATCH 23/26] perf: reduce MPF path packing constraints --- .../cardano/zeroj/circuit/lib/zk/ZkMpf.java | 178 +++++++++--------- 1 file changed, 94 insertions(+), 84 deletions(-) diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpf.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpf.java index 79c48f2..f0924a8 100644 --- a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpf.java +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpf.java @@ -11,7 +11,6 @@ import java.math.BigInteger; import java.util.ArrayList; -import java.util.List; import java.util.Objects; /** @@ -371,86 +370,71 @@ private static ZkField selectPrefixedDigestChunkFromPath( ZkUInt length, ZkField digest, int chunkIndex) { - ZkField selected = prefixedDigestChunkCandidateFromPath(zk, path, start, 0, digest, chunkIndex); - for (int len = 1; len <= MAX_PREFIX_NIBBLES; len++) { - ZkField candidate = prefixedDigestChunkCandidateFromPath(zk, path, start, len, digest, chunkIndex); - selected = eqConst(zk, length, len).select(candidate, selected); - } - return selected; + // The prefixed digest layout has only three structural cases + // (0, 1..32, 33..64), so avoid repacking duplicate candidates for + // every possible length. + return switch (chunkIndex) { + case 0 -> selectPrefixedPathChunk0(zk, path, start, length, digest); + case 1 -> selectPrefixedPathChunk1(zk, path, start, length, digest); + case 2 -> lteConst(zk, length, BYTE_DIGEST_CHUNK_BYTES) + .select(zk.constant(0), digest); + default -> zk.constant(0); + }; } - private static ZkField prefixedDigestChunkCandidateFromPath( + private static ZkField selectPrefixedPathChunk0( ZkContext zk, PathSignals path, ZkUInt start, - int length, - ZkField digest, - int chunkIndex) { - List chunks = byteDigestChunks(length); - if (chunkIndex >= chunks.size()) { - return zk.constant(0); + ZkUInt length, + ZkField digest) { + ZkField selected = packPathNibbles(zk, path, start, 0, BYTE_DIGEST_CHUNK_BYTES); + for (int len = 1; len < BYTE_DIGEST_CHUNK_BYTES; len++) { + ZkBool matchesShortOrLong = eqConst(zk, length, len) + .or(eqConst(zk, length, len + BYTE_DIGEST_CHUNK_BYTES)); + selected = matchesShortOrLong.select( + packPathNibbles(zk, path, start, 0, len), + selected); } - DigestChunk chunk = chunks.get(chunkIndex); - return switch (chunk.kind()) { - case PATH -> packPathNibbles(zk, path, start, chunk.offset(), chunk.length()); - case ROOT -> digest; - case ZERO -> zk.constant(0); - }; + return eqConst(zk, length, 0).select(digest, selected); } - private static ZkField selectPrefixedDigestChunkFromWitness( + private static ZkField selectPrefixedPathChunk1( ZkContext zk, + PathSignals path, + ZkUInt start, ZkUInt length, - ZkArray prefixChunks, - ZkField digest, - int chunkIndex) { - ZkField selected = prefixedDigestChunkCandidateFromWitness(zk, prefixChunks, 0, digest, chunkIndex); - for (int len = 1; len <= MAX_PREFIX_NIBBLES; len++) { - ZkField candidate = prefixedDigestChunkCandidateFromWitness(zk, prefixChunks, len, digest, chunkIndex); - selected = eqConst(zk, length, len).select(candidate, selected); + ZkField digest) { + ZkField selected = lteConst(zk, length, BYTE_DIGEST_CHUNK_BYTES) + .select(digest, packPathNibbles(zk, path, start, BYTE_DIGEST_CHUNK_BYTES, BYTE_DIGEST_CHUNK_BYTES)); + selected = eqConst(zk, length, 0).select(zk.constant(0), selected); + for (int offset = 1; offset < BYTE_DIGEST_CHUNK_BYTES; offset++) { + selected = eqConst(zk, length, offset + BYTE_DIGEST_CHUNK_BYTES).select( + packPathNibbles(zk, path, start, offset, BYTE_DIGEST_CHUNK_BYTES), + selected); } return selected; } - private static ZkField prefixedDigestChunkCandidateFromWitness( + private static ZkField selectPrefixedDigestChunkFromWitness( ZkContext zk, + ZkUInt length, ZkArray prefixChunks, - int length, ZkField digest, int chunkIndex) { - List chunks = byteDigestChunks(length); - if (chunkIndex >= chunks.size()) { - return zk.constant(0); - } - DigestChunk chunk = chunks.get(chunkIndex); - return switch (chunk.kind()) { - case PATH -> prefixChunks.get(chunk.pathChunkIndex()); - case ROOT -> digest; - case ZERO -> zk.constant(0); + return switch (chunkIndex) { + case 0 -> eqConst(zk, length, 0).select(digest, prefixChunks.get(0)); + case 1 -> { + ZkField selected = lteConst(zk, length, BYTE_DIGEST_CHUNK_BYTES) + .select(digest, prefixChunks.get(1)); + yield eqConst(zk, length, 0).select(zk.constant(0), selected); + } + case 2 -> lteConst(zk, length, BYTE_DIGEST_CHUNK_BYTES) + .select(zk.constant(0), digest); + default -> zk.constant(0); }; } - private static List byteDigestChunks(int prefixLength) { - var chunks = new ArrayList(); - int totalLength = prefixLength + BYTE_DIGEST_CHUNK_BYTES; - int remainder = totalLength % BYTE_DIGEST_CHUNK_BYTES; - int offset = 0; - int pathChunk = 0; - if (remainder != 0) { - chunks.add(DigestChunk.path(0, remainder, pathChunk++)); - offset = remainder; - } - while (offset < prefixLength) { - chunks.add(DigestChunk.path(offset, BYTE_DIGEST_CHUNK_BYTES, pathChunk++)); - offset += BYTE_DIGEST_CHUNK_BYTES; - } - chunks.add(DigestChunk.root()); - while (chunks.size() < 3) { - chunks.add(DigestChunk.zero()); - } - return chunks; - } - private static ZkField commitLeafFromPath( ZkContext zk, PoseidonParams params, @@ -475,14 +459,60 @@ private static ZkField selectLeafChunk( ZkUInt start, ZkUInt length, int chunkIndex) { - ZkField selected = leafChunkCandidate(zk, path, start, 0, chunkIndex); - for (int len = 1; len <= KEY_PATH_NIBBLES; len++) { - ZkField candidate = leafChunkCandidate(zk, path, start, len, chunkIndex); - selected = eqConst(zk, length, len).select(candidate, selected); + // Leaf suffix chunks are 31/31/2 bytes. Lengths beyond a chunk's full + // width reuse the same packed candidate. + return switch (chunkIndex) { + case 0 -> selectLeafChunk0(zk, path, start, length); + case 1 -> selectLeafChunk1(zk, path, start, length); + case 2 -> selectLeafChunk2(zk, path, start, length); + default -> zk.constant(0); + }; + } + + private static ZkField selectLeafChunk0( + ZkContext zk, + PathSignals path, + ZkUInt start, + ZkUInt length) { + ZkField selected = lteConst(zk, length, LEAF_CHUNK_BYTES - 1) + .select(zk.constant(0), leafChunkCandidate(zk, path, start, LEAF_CHUNK_BYTES, 0)); + for (int len = 1; len < LEAF_CHUNK_BYTES; len++) { + selected = eqConst(zk, length, len).select( + leafChunkCandidate(zk, path, start, len, 0), + selected); } return selected; } + private static ZkField selectLeafChunk1( + ZkContext zk, + PathSignals path, + ZkUInt start, + ZkUInt length) { + ZkField selected = lteConst(zk, length, (LEAF_CHUNK_BYTES * 2) - 1) + .select(zk.constant(0), leafChunkCandidate(zk, path, start, LEAF_CHUNK_BYTES * 2, 1)); + for (int len = LEAF_CHUNK_BYTES + 1; len < LEAF_CHUNK_BYTES * 2; len++) { + selected = eqConst(zk, length, len).select( + leafChunkCandidate(zk, path, start, len, 1), + selected); + } + return selected; + } + + private static ZkField selectLeafChunk2( + ZkContext zk, + PathSignals path, + ZkUInt start, + ZkUInt length) { + ZkField selected = zk.constant(0); + selected = eqConst(zk, length, (LEAF_CHUNK_BYTES * 2) + 1).select( + leafChunkCandidate(zk, path, start, (LEAF_CHUNK_BYTES * 2) + 1, 2), + selected); + return eqConst(zk, length, KEY_PATH_NIBBLES).select( + leafChunkCandidate(zk, path, start, KEY_PATH_NIBBLES, 2), + selected); + } + private static ZkField leafChunkCandidate( ZkContext zk, PathSignals path, @@ -592,26 +622,6 @@ private static void requireBlsParams(ZkContext zk, PoseidonParams params) { zk.builder().api().requireField(params.field()); } - private enum DigestChunkKind { - PATH, - ROOT, - ZERO - } - - private record DigestChunk(DigestChunkKind kind, int offset, int length, int pathChunkIndex) { - static DigestChunk path(int offset, int length, int pathChunkIndex) { - return new DigestChunk(DigestChunkKind.PATH, offset, length, pathChunkIndex); - } - - static DigestChunk root() { - return new DigestChunk(DigestChunkKind.ROOT, 0, 0, -1); - } - - static DigestChunk zero() { - return new DigestChunk(DigestChunkKind.ZERO, 0, 0, -1); - } - } - private record PathSignals(ZkArray path, Signal[] signals) { static PathSignals of(ZkArray path) { var signals = new Signal[path.size()]; From 35abf5b01a29d7103f636f5bdef9efe729512a4a Mon Sep 17 00:00:00 2001 From: Satya Date: Wed, 20 May 2026 15:07:15 +0800 Subject: [PATCH 24/26] Add vision v3 --- docs/vision-v3.md | 758 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 758 insertions(+) create mode 100644 docs/vision-v3.md diff --git a/docs/vision-v3.md b/docs/vision-v3.md new file mode 100644 index 0000000..c6a468f --- /dev/null +++ b/docs/vision-v3.md @@ -0,0 +1,758 @@ +# JuLC + ZeroJ — A Java-Native Platform for Privacy-First Programmable Trust on Cardano + +> **Write validators and zero-knowledge applications in Java.** +> **Ship privacy-preserving Cardano systems where disclosure is intentional, +> verifiable, and minimal.** + +[JuLC](https://julc.dev) and [ZeroJ](https://github.com/bloxbean/zeroj) are +complementary, Java-first projects for Cardano. + +* **JuLC** compiles a deterministic subset of Java to UPLC validators. +* **ZeroJ** lets Java developers define zero-knowledge circuits, generate and + verify proofs in pure Java, and post Cardano-ready verification artifacts + on-chain through Plutus V3 BLS12-381 builtins. + +Together they form a JVM-native platform for privacy-preserving Cardano +applications — credentials, gated dApps, digital product passports, anonymous +governance, reputation, compliance, and verifiable computation — built and +audited with the same toolchain Java teams already use. + +The goal is not only "Java for Cardano" or "Java for ZK." The goal is: + +```text +Privacy as a first-class application primitive. + +Developers express what must be proven, what may be revealed, what must +remain hidden, and how proofs bind to Cardano transactions — without writing +cryptographic plumbing. +``` + +--- + +## 1. The Opportunity + +Cardano has strong foundations for verifiable computation: UTXO accounting, +script validation, native assets, on-chain governance, and Plutus V3 +BLS12-381 builtins for pairing-based proof verification. But the developer +story is still dominated by specialist languages: Aiken, Plutus, Plutarch, +Helios, OpShin — and the ZK story is dominated by research-grade toolchains +like Circom, Noir, Halo2, and arkworks. + +That is a barrier for enterprises, identity providers, governments, +supply-chain operators, and the millions of Java teams already running +production systems on the JVM. + +At the same time, privacy is becoming a normal product requirement: + +* prove eligibility without revealing identity +* prove compliance without revealing trade secrets +* prove a credential is valid without exposing the credential +* prove a vote is authorized without linking it to a voter +* prove a threshold, range, membership, or state transition without revealing + the underlying data + +Zero-knowledge systems can solve these problems, but today's developer +surface is still too close to cryptography research: fields, curves, +constraint systems, witness assignment, proving keys, ceremonies, +serialization formats, and backend-specific behavior. + +**Thesis:** + +```text +Java is one of the largest production software ecosystems in the world. +Cardano needs a serious JVM path for enterprise adoption. +Privacy-preserving applications need a higher-level developer surface. +JuLC + ZeroJ combine validators, circuits, transaction tooling, and +privacy patterns into one Java-native stack — practical for engineering +teams, auditable by security teams, safe for users by default. +``` + +--- + +## 2. Who This Is For + +| Audience | Why they care | +|-----------------------------------------|-----------------------------------------------------------------------------------------------------| +| Enterprise Java teams | Build Cardano applications without retraining engineers in Haskell, Rust, or ZK-specific DSLs. | +| Identity & credential issuers | Issue credentials and support selective disclosure with JVM tooling and W3C VC alignment. | +| Supply-chain and DPP integrators | Anchor product claims while hiding sensitive supplier, material, and pricing data. | +| Regulated dApp builders | Enforce KYC, accreditation, residency, or age policies without collecting raw user data. | +| Governance researchers | Prototype private voting, anonymous DRep proofs, stake thresholds, nullifier-based participation. | +| Bloxbean ecosystem users | Extend Cardano Client Lib, Yaci Store, and Yaci DevKit with native validator and ZK workflows. | + +--- + +## 3. How We're Different + +| Dimension | Aiken | Plutus / Plutarch | Helios | OpShin | **JuLC + ZeroJ** | +|-------------------------------------------------|----------|-------------------|--------|--------|------------------------------------------------------------| +| Source language | DSL | Haskell eDSL | TS-ish | Python | **Standard Java subset, JVM tooling** | +| Ecosystem reach | Cardano | Cardano | Cardano| Cardano| **JVM + Cardano** | +| ZK first-class | No | No | No | No | **Yes — built-in (ZeroJ)** | +| Privacy primitives (commitments, nullifiers, roots) | App-by-app | App-by-app | App-by-app | App-by-app | **Reusable library + typed proof envelopes** | +| Off-chain proving | Native CLIs / sidecars | Native CLIs / sidecars | Native CLIs / sidecars | Native CLIs / sidecars | **Pure-Java proving; optional native acceleration** | +| Enterprise tooling | Limited | Limited | Limited| Limited| **IntelliJ, Gradle/Maven, JUnit, GraalVM, JFR** | +| Shared stack with off-chain dApp code | No | No | No | No | **One stack: issuance → proof → transaction → index** | + +The platform should not claim that every proof system is equally +on-chain-ready. Cardano's Plutus V3 capabilities make some combinations +practical and others infeasible (see §10). The developer experience can be +backend-aware without leaking unnecessary cryptography into application code. + +--- + +## 4. Why Java, Why Now + +JVM is where the world's regulated enterprises already build — banks, +governments, identity providers, large supply-chain operators. Asking those +teams to retrain on Haskell, a Rust dialect, or a bespoke DSL is the wrong +question. The right question is: **what would make Cardano and ZK adoptable +in Java without compromising correctness?** + +JuLC + ZeroJ answer that with: + +* **Same toolchain.** IntelliJ / VS Code / Eclipse, Gradle / Maven, JUnit, + the JVM debugger and profiler, JFR — for validators *and* circuits. +* **Pure-Java default.** Witnesses, proofs, and verification run on the JVM + with no native dependencies required. Native acceleration (e.g. `blst`, + gnark via FFM) is opt-in for hot paths. +* **GraalVM-ready.** Modules ship `META-INF/native-image` configs so + applications can compile down to native images for cold-start-sensitive + proving services, CLIs, and edge use cases. +* **One stack from issuance to chain.** The same JVM code that issues a + credential can generate witnesses, build proofs, construct Cardano + transactions with CCL, submit through Yaci, and index results through + Yaci Store. No language switch anywhere. + +Now is the right moment because Plutus V3 ships BLS12-381 pairing builtins, +making Groth16 on-chain verification practical *today* and giving PlonK a +credible experimental path once the KZG opening check and budgets mature. Privacy +requirements are catching up to compliance and governance roadmaps. And the +Bloxbean Java-on-Cardano stack (CCL, Yaci) is mature enough that JuLC and +ZeroJ can plug in rather than start from zero. + +--- + +## 5. Privacy Model + +A proof system alone does not make an application private. The application +must define what is public, what is private, who learns what, and how +correlation is controlled. + +### 5.1 What May Be Public + +Public values are the minimum data needed by validators, indexers, verifiers, +and auditors — and must be assumed visible forever once on-chain: + +* proof bytes or proof hash +* public inputs +* verification key hash or reference +* policy / circuit identifier +* Merkle or accumulator root +* scoped nullifier +* commitment +* validity interval or epoch +* application-specific statement (e.g. `eligible = true`) + +### 5.2 What Must Remain Private + +Private values never go on-chain and should not reach dApps unless the +product explicitly requires disclosure: + +* age, country, salary, identity attributes, documents +* credential body and credential subject +* issuer signature or credential secret +* supply-chain bill of materials and exact measurements +* Merkle paths (unless intended to be disclosed) +* randomness used for commitments +* voting identity, stake credential, delegation identity +* reputation history or graph data + +ZeroJ circuits make this distinction explicit at the Java boundary: +`c.publicInput(...)` and `c.privateInput(...)` are different calls with +different types, and pattern APIs (see §6) refuse to surface witnesses as +public outputs. + +### 5.3 Adversary Model + +| Observer | What they can see | Privacy goal | +|-----------------|--------------------------------------------------|------------------------------------------------------------------------| +| Chain observer | Transactions, scripts, metadata, public inputs | Cannot recover private witness data. | +| dApp operator | Submitted proof and public statement | Learns eligibility/compliance, not raw credential or attributes. | +| Issuer | Credential issuance event and issuer records | Cannot track every future use unless the application reveals it. | +| Verifier | Proof, roots, nullifier, policy | Cannot link unrelated presentations unless nullifier scope permits. | +| Colluding apps | Shared public values across applications | Cannot correlate users when nullifiers are app-scoped. | +| Prover service | Witness, if outsourced | Must be avoided or explicitly trusted; local proving is the default. | + +### 5.4 Guarantees + +* **Data minimization.** Reveal statements, not source data. +* **Soundness.** A valid proof means the statement is true under the + accepted circuit and verification key. +* **Replay protection.** Nullifiers or transaction-bound public inputs + prevent proof reuse where reuse is unsafe. +* **Scoped unlinkability.** Different apps, policies, or epochs use + different nullifier scopes. +* **Auditability.** Public roots, commitments, proof hashes, and VK hashes + make claims tamper-evident. +* **Local-first proving.** Users generate proofs without sending secrets + to dApps or hosted provers. + +### 5.5 Non-Guarantees + +ZK alone does *not*: + +* prove that an issuer was honest at credential issuance +* hide transaction timing, funding patterns, IP addresses, or wallet fingerprints +* prevent credential sharing unless bound to a hardware key or external trust + mechanism +* make public inputs private — poor public-input design can defeat privacy +* remove the need for audits of circuits, libraries, ceremonies, and verifiers + +Privacy-first templates ship with explicit warnings and defaults for these +failure modes. + +--- + +## 6. Core Privacy Primitives + +ZeroJ ships reusable building blocks so every application is not reinventing +its own privacy protocol. + +| Primitive | Purpose | +|---------------------------------|-----------------------------------------------------------------------------------------------| +| Commitments | Bind to private data while revealing only a commitment. | +| Scoped nullifiers | Prevent replay or double-use without revealing identity. | +| Merkle / accumulator roots | Prove membership, non-membership, or state inclusion against public roots. | +| Revocation roots | Support expired, revoked, or refreshed credentials without exposing credential data. | +| Range and comparison gadgets | Prove predicates such as `age ≥ 18`, `stake ≥ T`, `carbon < limit`. | +| Signature gadgets | Prove an issuer signed private data. | +| Proof envelopes | Carry proof bytes, proof system, curve, VK reference, public inputs, and metadata uniformly. | +| Policy binding | Bind a proof to an application, validator, circuit, chain, epoch, or transaction context. | + +The highest-value abstraction is not a raw proof — it is a typed statement: + +```text +This holder has a valid credential from issuer I, satisfies policy P, +is not revoked under root R, and has not used this claim before under +nullifier N. +``` + +Today, pattern verifiers such as `NullifierClaimVerifier` and +`MembershipVerifier` in `zeroj-patterns` already encode this style at the +application boundary. + +--- + +## 7. Project Responsibilities + +### 7.1 JuLC — Java → UPLC + +JuLC compiles a deterministic, functional subset of Java into UPLC validators. + +* spending, minting, withdrawal, and certificate validators +* Cardano ledger abstractions (`ScriptContext`, `TxInfo`, value, datums, + redeemers, signatories, validity intervals, reference inputs) +* deterministic and bounded execution +* Plutus interop and reference-script-friendly composition +* on-chain ZK verifier validators that wrap supported ZeroJ proof formats + +```java +@SpendingValidator +public class VestingValidator { + + @Entrypoint + static boolean validate(VestingDatum datum, + PlutusData redeemer, + ScriptContext ctx) { + + return ctx.txInfo() + .signatories() + .contains(datum.beneficiary()); + } +} +``` + +JuLC already produces a working on-chain Groth16 verifier through +`zeroj-onchain-julc/Groth16BLS12381Verifier` against Plutus V3 BLS12-381 +builtins. `PlonkBLS12381FullVerifier` is an experimental prototype today: it +checks the transcript and inverse constraints, while the KZG batch opening +pairing check remains deferred. + +### 7.2 ZeroJ — Java → ZK Circuits and Proofs + +ZeroJ provides Java APIs for defining circuits, generating witnesses, +proving, verifying, and preparing Cardano-compatible artifacts. + +Shipping today: + +* `CircuitSpec` / `SignalBuilder` Java circuit DSL +* public / private signal declarations, comparators, hashes, Merkle +* pure-Java Groth16 over BN254 and BLS12-381 +* pure-Java PlonK over BLS12-381 +* native acceleration through `zeroj-blst` (BLS12-381 via FFM) and + `zeroj-prover-gnark` (gnark Groth16/PlonK prover via FFM) +* on-chain Plutus V3 verifier for Groth16 on BLS12-381, plus an experimental + PlonK Julc prototype with KZG pairing verification still deferred +* pattern verifiers (nullifier claims, membership, range) +* `zeroj-cardano` and `zeroj-ccl` integration for transaction layout and + submission + +Example circuit (real, matches the codebase): + +```java +public class AgeCredentialCircuit implements CircuitSpec { + + @Override + public void define(SignalBuilder c) { + Signal minAge = c.publicInput("minAge"); + Signal credentialHash = c.publicInput("credentialHash"); + + Signal age = c.privateInput("age"); + Signal credentialSecret = c.privateInput("credentialSecret"); + + Signal expected = SignalPoseidon.hash(c, credentialSecret, age); + + c.assertEqual(expected, credentialHash); + c.assertTrue(age.gte(minAge)); + } + + public static CircuitBuilder build() { + return CircuitBuilder.create("age-credential") + .publicVar("minAge") + .publicVar("credentialHash") + .secretVar("age") + .secretVar("credentialSecret") + .defineSignals(new AgeCredentialCircuit()); + } +} +``` + +### 7.3 Shared Foundation — `zeroj-crypto` and `zeroj-circuit-lib` + +`zeroj-crypto` is the common cryptographic substrate: field and EC +arithmetic, pairing operations, FFT/MSM, canonical serialization. Pure-Java +first, with `zeroj-blst` providing FFM-backed BLS12-381 acceleration where +performance matters. + +`zeroj-circuit-lib` provides the standard gadget catalog: Poseidon, MiMC, +Merkle/IMT, range checks, comparators, and credential primitives. + +Both substrates are auditable, reusable, and *boring in the best sense*: +stable APIs, deterministic serialization, test vectors, and clear versioning. +They are also usable directly by off-chain Java dApps — one cryptographic +stack from issuance to verification. + +### 7.4 One Restricted Java, Two Compilers + +| Property | JuLC | ZeroJ | +|----------------------|-----------|-----------| +| Deterministic | Required | Required | +| Immutable preferred | Yes | Yes | +| Bounded loops | Required | Required | +| Side effects | Forbidden | Forbidden | +| Reflection | Forbidden | Forbidden | +| Runtime randomness | Forbidden | Forbidden | +| Dynamic allocation | Limited | Limited | +| Field-friendly types | Optional | Required | + +This shared discipline — "ZK-safe Java" — is what lets the two projects +share linters, IDE plugins, education material, and review checklists. + +--- + +## 8. Joint Architecture and the Golden Path + +```text + Java Application Code + validators, circuits, policies, tests + │ + ┌───────────────┴───────────────┐ + ▼ ▼ + JuLC ZeroJ + Java → UPLC Java → Constraint Systems + │ │ + ▼ ▼ + Cardano Validator R1CS / PlonK / future IRs + │ │ + │ ▼ + │ Proving Key / Verification Key + │ │ + │ ▼ + │ Proof Envelope + Public Inputs + │ │ + └───────────────┬───────────────┘ + ▼ + Cardano Transaction Layout + validator · datum · redeemer · metadata + proof · public inputs · VK reference / hash + commitment · root · scoped nullifier + │ + ▼ + Cardano Ledger +``` + +Transaction layout is part of the privacy design. A technically valid proof +can still leak data through ill-chosen public inputs, metadata, roots, or +nullifiers — so transaction construction (via CCL) is treated as a +first-class step in the developer flow. + +### Golden Path — a privacy-preserving dApp end to end + +1. **Issuer creates a private claim.** A KYC provider, auditor, government + agency, DPP auditor, or DAO registry signs or commits to user/product + attributes. +2. **Application publishes policy.** The dApp defines a circuit, accepted + issuer keys, accepted roots, required thresholds, nullifier scope, and + VK references. +3. **User proves locally.** The wallet or local app generates a witness + and proof without sending private attributes to the dApp. +4. **Transaction carries only public privacy artifacts.** Proof or proof + hash, public inputs, VK hash/reference, root or commitment, scoped + nullifier, policy identifier. +5. **JuLC validator verifies the statement.** Checks the proof, binds it + to the expected policy, validates the nullifier or root, enforces + business logic. +6. **Observers learn the statement, not the source data.** "Eligible", + "not revoked", "stake threshold met", "product claim compliant", + "vote authorized". + +This is the default user journey for privacy-enabled Java Cardano apps. + +--- + +## 9. zk-Enabled Validators and Verification-Key Lifecycle + +### 9.1 Today's API surface + +Today, applications use concrete verifier classes and pattern verifiers: + +```java +boolean ok = NullifierClaimVerifier.verify( + AgeCredentialCircuit.class, + proofEnvelope, + publicInputs, + nullifierScope); +``` + +### 9.2 Target DX (not yet implemented) + +The aspirational JuLC bridge collapses the above into a single business-level +call inside a validator: + +```java +@SpendingValidator +public class PrivateClaimValidator { + + @Entrypoint + static boolean validate(ClaimDatum datum, + ClaimRedeemer redeemer, + ScriptContext ctx) { + + return ZeroJ.verify( // ← target DX + AgeCredentialCircuit.class, + redeemer.proof(), + redeemer.publicInputs()) + && ctx.txInfo().validRange().contains(datum.deadline()); + } +} +``` + +Under the hood the bridge will: + +* resolve the circuit identity to an accepted VK hash or reference +* bind public inputs to the validator, policy, datum, redeemer, chain, + and transaction context +* verify proof bytes with the supported on-chain verifier +* enforce nullifier scope and replay rules where configured +* fail closed on unknown proof systems, malformed public inputs, or + unsupported verifier layouts + +### 9.3 VK Lifecycle + +| Stage | Description | +|-----------|------------------------------------------------------------------------------| +| Compile | Circuit build produces constraints and VK material. | +| Identify | VK is canonically encoded and hashed. | +| Register | Issuer or application registry signs and publishes accepted VK hashes. | +| Deploy | Application chooses a deployment pattern (table below). | +| Consume | Validators accept only configured VK hashes/references. | +| Rotate | New VKs roll out through explicit transition windows. | +| Retire | Old VKs expire after pending proofs and users have migrated. | + +### 9.4 Deployment Patterns + +| Pattern | Strength | Tradeoff | +|-----------------------|------------------------------------------------|---------------------------------------------------------| +| VK in script | Simple and concrete | Validator redeploy needed for VK changes. | +| Reference datum VK | Smaller reusable logic; VK is rotatable | More complex UTxO governance. | +| VK hash commitment | Smallest script commitment | Full VK must be supplied off-chain or via reference. | + +--- + +## 10. Public Inputs, Private Witnesses, and Backend Feasibility + +### 10.1 The Public / Private Boundary + +| Public Inputs | Private Witnesses | +|----------------------------|--------------------------------------------| +| Issuer public key or ID | Credential body | +| Policy parameters | Age, country, salary, identifiers | +| Merkle / accumulator root | Merkle path | +| Nullifier | Nullifier secret | +| Commitment | Commitment randomness | +| Threshold or range limit | Exact value | +| Validity epoch | Raw document or event history | +| Proof context hash | User or wallet binding secret | + +Rule of thumb: + +```text +Anything the validator needs to read is public. +Anything that would harm the user, issuer, or business if disclosed is private. +``` + +ZeroJ ships a public-input linter that flags suspicious public values: raw +identifiers, exact birth dates, unscoped nullifiers, stable cross-app +commitments, or unnecessary metadata. + +### 10.2 Backend Feasibility on Cardano + +Developer APIs are backend-agnostic at the application boundary but +backend-*explicit* at deployment, cost estimation, and audit boundaries. + +| Capability | Status today | Notes | +|---------------------------------------------------|-----------------------------|----------------------------------------------------------------------| +| Java circuit definition with `CircuitSpec` | **Shipping** | Core developer path. | +| Pure-Java Groth16 (BN254 and BLS12-381) | **Shipping** | Default for zero-native-dependency workflows. | +| Native Groth16/PlonK proving via gnark / blst | **Shipping** | Opt-in acceleration through FFM. | +| Pure-Java PlonK on BLS12-381 | **Shipping** | Includes full Fiat-Shamir transcript verification. | +| Groth16 BLS12-381 on-chain verification | **Shipping** | `Groth16BLS12381Verifier` against Plutus V3 builtins. | +| PlonK BLS12-381 off-chain verification | **Shipping** | Pure Java verifier with full KZG pairing check. | +| PlonK BLS12-381 on-chain verification | Experimental | Julc prototype has transcript/inverse checks; KZG pairing check deferred. | +| BN254 on-chain verification | Not feasible | No BN254 pairing builtins in Plutus. | +| Halo2 / Pallas on-chain verification | Not feasible | No Pallas builtins in Plutus. | +| BBS / BBS+ selective disclosure | Mainline opt-in | Strong off-chain credential backend; outside `zeroj-bom-core`. | +| STARK / AIR on-chain verification | Research | Needs proof-size, verifier-cost, and builtin analysis. | +| Recursion / proof aggregation | Research | Valuable for scale; not a Phase 1 dependency. | + +--- + +## 11. Showcase Use Cases + +### 11.1 Selective-Disclosure Credentials + +A user proves a policy without revealing the credential: + +* age ≥ 18 +* country in an allow-list +* credential signed by an accepted issuer +* credential not revoked under the current validity root +* nullifier unused for this app and epoch + +Near-term implementation uses Poseidon and Merkle-based credential +circuits. BBS / BBS+ is strategically important for standards-aligned +selective disclosure and is positioned as an incubating off-chain +credential backend with future on-chain circuit integration. + +### 11.2 Privacy-Preserving Digital Product Passport (DPP) + +A manufacturer or auditor proves compliance predicates without revealing the +full bill of materials, supplier graph, exact measurements, or pricing data: + +* recycled content above a threshold +* carbon impact below a threshold +* origin in an approved set +* no banned material in the committed material list +* temperature kept in range during transport + +Cardano stores commitments, proof hashes, policy IDs, and roots. Detailed +documents stay off-chain, disclosed only to authorized parties. + +### 11.3 Anonymous Governance + +A voter proves: + +* eligibility at snapshot `S` +* stake or reputation at least threshold `T` +* the vote has not already been cast for proposal `P` + +The validator sees a proposal-scoped nullifier, vote payload, root, and +proof — never the voter's identity or unrelated governance activity. + +### 11.4 zk-KYC Gates + +A regulated dApp verifies a user satisfies compliance requirements +without collecting identity documents. The dApp learns *policy satisfied, +issuer accepted, credential not revoked, nullifier valid* — never name, +address, passport number, exact birth date, or document image. + +### 11.5 Verifiable Reputation + +A user proves a reputation statement — `score ≥ X`, top decile, no +unresolved slashing event, member for at least N epochs — without revealing +the underlying history or graph. + +--- + +## 12. Developer Experience and Ecosystem Alignment + +### 12.1 DX Pillars + +| Pillar | Target experience | +|---------------------|------------------------------------------------------------------------------------------------------------| +| **Same language** | Validators, circuits, tests, proof flows, and transaction code all in Java. | +| **Same build** | Gradle and Maven workflows for compile, prove, verify, package, deploy. | +| **Same tests** | JUnit for circuits, proof generation, verifier behavior, and end-to-end flows on Yaci DevKit. | +| **Strong types** | Typed proof envelopes, VK references, public-input schemas, policy identifiers — wrong types fail to compile. | +| **Local proving** | Default path keeps witnesses on the user's machine; native acceleration is opt-in. | +| **GraalVM AOT** | `META-INF/native-image` configs ship per module; CLIs and proving services can target native image. | +| **Clear deployment**| Cost estimation and feasibility warnings for on-chain verifier choices. | +| **Privacy templates** | Credential gate, nullifier claim, membership proof, DPP claim, private vote. | +| **Audit artifacts** | Constraint hash, VK hash, proof vectors, public-input schema, circuit version. | + +Target API shape (incubating): + +```java +var statement = CredentialPolicy.ageAtLeast(18) + .issuer("kyc-provider-1") + .validityRoot(root) + .nullifierScope("dex-v1", epoch); + +var proof = ZeroJ.prove(statement, walletCredential); + +txBuilder.attachZkProof(proof) + .payToContract(appAddress, datum); +``` + +### 12.2 Ecosystem Alignment + +JuLC + ZeroJ extend the existing Bloxbean Java-on-Cardano stack rather than +replace it: + +* **Cardano Client Lib (CCL).** Transaction building, signing, metadata, + redeemers, datums, fee estimation, submission. +* **Yaci DevKit.** Local end-to-end testing of validators, proofs, and + transaction flows. +* **Yaci Store.** Indexed chain data for roots, snapshots, credential + registries, governance state, and witness generation. +* **Cardano metadata and token standards.** CIP-25, CIP-68, DPP metadata, + governance metadata, anchor commitments. +* **Governance alignment.** CIP-1694 use cases — private voting, anonymous + DRep credentials, stake threshold proofs. +* **Credential standards.** W3C VC compatibility and emerging BBS / BBS+ + standards, with careful distinction between off-chain credential + presentation and on-chain SNARK verification. + +End state — one Java stack from issuance to chain index: + +```text +Issuer service → holder wallet/app → ZeroJ proof → CCL transaction + → JuLC validator → Yaci-indexed state → auditor/verifier tools +``` + +--- + +## 13. Roadmap and Success Criteria + +### Phase 1 — Production Foundation *(now)* + +Ship credible privacy-preserving apps with the proof systems Cardano can +verify today. + +* Harden `CircuitSpec`, witness generation, proof envelopes, canonical + serialization. +* Prioritize Groth16 BLS12-381 for production on-chain verification; keep + PlonK on the experimental track until the KZG pairing check and cost profile + are complete. +* Provide privacy templates: credential gates, membership proofs, nullifier + claims, range proofs, DPP compliance claims. +* Surface on-chain cost estimation and feasibility warnings at build time. +* Keep pure-Java proving the default; native acceleration opt-in. +* Use Yaci DevKit for repeatable end-to-end testing. + +### Phase 2 — Privacy Developer Experience *(next 12 months)* + +Make privacy workflows ergonomic and hard to misuse. + +* Generated types for proof envelopes, public-input schemas, VK references, + policies. +* `ZeroJ.verify(...)` JuLC bridge and statement-level proof APIs. +* Reference datum VK and VK-hash deployment patterns when governance and + tests are mature. +* Public-input linting and privacy review tooling. +* Full reference apps: credential-gated access, DPP compliance, anonymous + voting. +* Mature BBS / BBS+ as off-chain credential presentation, mapped cleanly + into ZeroJ proof envelopes. + +### Phase 3 — Scale, Standards, Advanced Backends *(12–24 months)* + +Broaden proof systems and reduce cost without weakening the privacy model. + +* Mature PlonK BLS12-381 on-chain verification with audited cost profiles. +* Explore aggregation and recursion for many proofs or high-throughput + applications. +* Evaluate STARK / AIR backends with explicit Cardano feasibility analysis. +* Enterprise issuance flows: HSM-backed keys, JCA providers, audit logs, + compliance reporting. +* Align with W3C VC and BBS / BBS+ standards as they stabilize. + +### Success Criteria + +JuLC + ZeroJ have succeeded when: + +1. A Java engineer can build and test a privacy-preserving Cardano dApp + without learning Haskell, Rust, or a ZK DSL. +2. Default examples reveal statements, not sensitive data. +3. Proofs are bound to application context, nullifier scope, VK identity, + and Cardano transaction semantics. +4. At least one production issuer or application uses ZeroJ for + credentials, DPP, governance, compliance, or reputation. +5. Independent teams build on `zeroj-crypto`, `zeroj-circuit-lib`, proof + envelopes, and verifier tooling without modifying internals. +6. JuLC and ZeroJ become credible reference options for Java-native + Cardano privacy applications, cited alongside Aiken and Plutus in + ecosystem documentation and CIPs. + +--- + +## 14. Design Principles and Vision + +### Principles + +* **Privacy first.** Start from the disclosure policy, not the proof system. +* **Reveal statements, not data.** Public inputs must be intentional and + minimal. +* **Fail closed.** Unsupported proof systems, malformed public inputs, + unknown VKs, and ambiguous policies must reject. +* **Local-first proving.** Private witnesses stay with the user whenever + possible. +* **Typed boundaries.** Public inputs, proof envelopes, VK references, and + policy IDs are structured and versioned. +* **Backend-aware deployment.** Hide backend complexity from business + logic; expose feasibility, cost, and audit details at deployment time. +* **Composable with Cardano.** CCL, Yaci, metadata standards, reference + scripts, UTxO patterns, and governance workflows are first-class. +* **Candid maturity.** Distinguish shipping features, target DX, incubating + features, and research clearly. + +### Vision + +JuLC lets Java developers build Cardano validators. + +ZeroJ lets Java developers build, prove, and verify zero-knowledge +statements. + +Together they can make Cardano a natural home for privacy-preserving +programmable trust — applications where users, businesses, and +institutions prove what matters without exposing what should remain +private. + +The north star: + +```text +Cardano privacy applications should be practical for Java teams, +auditable by security teams, +and safe for users by default. +``` From 693a329c060bcd5436f4928edf6778af96016a7e Mon Sep 17 00:00:00 2001 From: Satya Date: Wed, 20 May 2026 18:03:58 +0800 Subject: [PATCH 25/26] Update README with ZK annotation based circuit --- README.md | 59 +++++++++--- zeroj-circuit-lib/README.md | 177 ++++++++++++++++++++++++++++++------ 2 files changed, 194 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index e99aee0..ee8e163 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ZeroJ lets Java developers **define ZK circuits**, **generate proofs**, **verify - **CircuitSpec Java DSL** (recommended) — define circuits as reusable Java classes with `CircuitSpec` - **Inline lambda DSL** — quick prototyping with `CircuitBuilder.define(api -> ...)` - **circom interop** — use externally compiled circom/snarkjs artifacts (`.r1cs`, `.zkey`, `.wtns`; `.wasm` witness calculation in incubator) -- **Standard library** — Poseidon, PoseidonN, MiMC, MiMCSponge, Merkle, Comparators, Binary, Mux, AliasCheck +- **Standard library** — Poseidon, PoseidonN, MiMC, MiMCSponge, Merkle, Comparators, Binary, Mux, AliasCheck, symbolic `Zk*` adapters, and a per-gadget status table in [`zeroj-circuit-lib`](zeroj-circuit-lib/README.md) - **Multi-backend compilation** — one Java circuit can compile to R1CS for Groth16 or to PlonK ### Generate Proofs @@ -46,11 +46,37 @@ ZeroJ lets Java developers **define ZK circuits**, **generate proofs**, **verify ## Quick Start — Zero Dependencies -Define a circuit, prove it, and verify — all in pure Java: +Define a circuit, prove it, and verify — all in pure Java. + +ZeroJ supports multiple ways to write the same circuit. For new application +circuits, start with symbolic annotations. Use `CircuitSpec` when you want a +manual reusable circuit class, and use the inline DSL for small tests or +experiments. + +### Recommended: Symbolic Annotations ```java -// 1. Define the circuit (CircuitSpec — recommended pattern) -public class SecretMultiplierCircuit implements CircuitSpec { +// Define the circuit with @ZKCircuit and symbolic Zk* values. +@ZKCircuit(name = "secret-multiplier", version = 1) +public class SecretMultiplier { + @Prove + ZkBool prove( + ZkContext zk, + @Public ZkField a, + @Public ZkField product, + @Secret ZkField b) { + return a.mul(b).isEqual(product); + } +} + +// The annotation processor generates SecretMultiplierCircuit. +var circuit = SecretMultiplierCircuit.build(); +``` + +### Equivalent CircuitSpec + +```java +public class SecretMultiplierSpecCircuit implements CircuitSpec { @Override public void define(SignalBuilder c) { Signal a = c.publicInput("a"); @@ -62,12 +88,18 @@ public class SecretMultiplierCircuit implements CircuitSpec { public static CircuitBuilder build() { return CircuitBuilder.create("secret-multiplier") .publicVar("a").publicVar("product").secretVar("b") - .defineSignals(new SecretMultiplierCircuit()); + .defineSignals(new SecretMultiplierSpecCircuit()); } } -// 2. Compile and compute witness -var circuit = SecretMultiplierCircuit.build(); +var circuit = SecretMultiplierSpecCircuit.build(); +``` + +Choose one definition style. Both produce a `CircuitBuilder`, and the proof flow +is the same after that point: + +```java +// 1. Compile and compute witness var r1cs = circuit.compileR1CS(CurveId.BLS12_381); var witness = circuit.calculateWitness(Map.of( "a", List.of(BigInteger.valueOf(3)), @@ -75,7 +107,7 @@ var witness = circuit.calculateWitness(Map.of( "product", List.of(BigInteger.valueOf(33)) ), CurveId.BLS12_381); -// 3. Setup + Prove (pure Java — zero native dependencies) +// 2. Setup + Prove (pure Java — zero native dependencies) var srs = PowersOfTauBLS381.generate(4); // dev/test only var constraints = r1cs.constraints(); var setup = Groth16SetupBLS381.setup( @@ -83,10 +115,10 @@ var setup = Groth16SetupBLS381.setup( var proof = Groth16ProverBLS381.prove( setup.provingKey(), witness, constraints, r1cs.numWires()); -// 4. Verify off-chain (pure Java) +// 3. Verify off-chain (pure Java) boolean valid = BLS12381Pairing.pairingCheck(...); // Groth16 pairing equation -// 5. Verify on-chain (Cardano Plutus V3) +// 4. Verify on-chain (Cardano Plutus V3) var script = JulcScriptLoader.load(Groth16BLS12381Verifier.class, vkParams...); // Lock ADA → unlock with ZK proof → Cardano verifies BLS12-381 pairing ``` @@ -193,7 +225,7 @@ The **pure Java prover and verifier require no optional dependencies**. | [`zeroj-blst`](zeroj-blst/) | BLS12-381 pairing operations via blst native library | | [`zeroj-crypto`](zeroj-crypto/) | **Pure Java prover** — Montgomery field arithmetic, EC operations, Groth16 + PlonK for BN254 and BLS12-381 | | [`zeroj-circuit-dsl`](zeroj-circuit-dsl/) | Java Circuit DSL — define circuits with CircuitSpec, compile to R1CS/PlonK | -| [`zeroj-circuit-lib`](zeroj-circuit-lib/) | Circuit standard library — Poseidon, PoseidonN, MiMC, MiMCSponge, Merkle, Comparators, Binary, Mux, AliasCheck | +| [`zeroj-circuit-lib`](zeroj-circuit-lib/) | Circuit standard library — Poseidon, PoseidonN, MiMC, MiMCSponge, Merkle, Comparators, Binary, Mux, AliasCheck, symbolic adapters, and [per-gadget status](zeroj-circuit-lib/README.md#gadget-status) | | [`zeroj-prover-spi`](zeroj-prover-spi/) | Minimal prover request/response SPI shared by prover implementations | | [`zeroj-prover-gnark`](zeroj-prover-gnark/) | gnark native prover (Groth16 + PlonK) via FFM | | [`zeroj-patterns`](zeroj-patterns/) | High-level ZK patterns — state transitions, nullifier claims, membership proofs | @@ -256,6 +288,7 @@ dependencies { - **[Getting Started](docs/getting-started.md)** — end-to-end: circuit to on-chain verification - **[Pure Java Prover Guide](docs/pure-java-prover-guide.md)** — zero-dependency proving pipeline - **[Circuit DSL User Guide](docs/circuit-dsl-user-guide.md)** — CircuitSpec, Signal API, standard library +- **[Circuit Library Gadget Status](zeroj-circuit-lib/README.md#gadget-status)** — current curve, symbolic, and Cardano-readiness status for each reusable gadget - **[Alternate Prover Backends](docs/alternate-prover-backends.md)** — gnark FFM and snarkjs - **[Architecture Overview](docs/architecture-overview.md)** — module design and layer separation - **[PlonK Support](docs/plonk-support.md)** — PlonK proving, off-chain verification, and the experimental Julc prototype @@ -275,8 +308,8 @@ dependencies { | Example | What It Demonstrates | |---------|---------------------| -| `SealedBidPureJavaE2ETest` | MiMC hash + range proof → pure Java prove → pairing verify | -| `AnonymousVotingPureJavaE2ETest` | MiMC commitment + boolean → prove → verify | +| `SealedBidPureJavaE2ETest` | BLS12-381 Poseidon commitment + range proof → pure Java prove → pairing verify | +| `AnonymousVotingPureJavaE2ETest` | BLS12-381 Poseidon commitment + boolean → prove → verify | | `BalanceThresholdPureJavaE2ETest` | Range comparison → prove → verify | | `PureJavaProverYaciE2ETest` | **Full stack: prove → Yaci DevKit on-chain verify** | | `CircomToOnChainE2ETest` | circom `.zkey` → Java prove → Julc VM on-chain verify | diff --git a/zeroj-circuit-lib/README.md b/zeroj-circuit-lib/README.md index 2402af1..be1d2dd 100644 --- a/zeroj-circuit-lib/README.md +++ b/zeroj-circuit-lib/README.md @@ -21,6 +21,38 @@ or Jubjub-style primitives. | Jubjub primitives | `JubjubCurve`, `PedersenCommitment`, `EdDSAJubjub`, in-circuit variants | | Poseidon parameters | `PoseidonParams*`, `PoseidonHash`, Grain LFSR generation helpers | +## Gadget Status + +This table is intentionally conservative. "Cardano-ready" means the gadget can +be used in a circuit compiled over `CurveId.BLS12_381`, proved with Groth16, and +verified with ZeroJ's reusable Plutus V3 BLS12-381 verifier. The gadget logic +itself runs off-chain inside the circuit/prover flow; Cardano only sees the +final proof, verification key data, and public inputs. + +| Gadget | DSL APIs | Symbolic APIs | Field / curve status | Cardano status | Notes | +|--------|----------|---------------|----------------------|----------------|-------| +| Field arithmetic | `Signal`, `SignalBuilder`, `CircuitAPI` | `ZkField` | Generic over supported circuit fields | Ready on BLS12-381 Groth16 | Core DSL feature, not a separate gadget. | +| Boolean values | DSL equality and constraints | `ZkBool` | Generic | Ready on BLS12-381 Groth16 | `ZkBool` constrains values to 0/1 and prevents Java `boolean` control-flow mistakes. | +| Unsigned integers | `Comparators`, `SignalComparators`, `Binary`, `SignalBinary` | `ZkUInt` | Generic, `ZkUInt.MAX_BITS = 253`; comparisons require width `< 253` | Ready on BLS12-381 Groth16 | `ZkUInt` adds range constraints on construction. | +| Fixed arrays and matrices | Java arrays passed to gadgets | `ZkArray`, including rectangular `ZkArray>` | Generic | Ready on BLS12-381 Groth16 | Deeper nesting is intentionally out of scope until a real circuit needs it. | +| Bit and byte vectors | `Binary`, `SignalBinary` | `ZkBits`, `ZkBytes` | Generic | Ready for binding/equality on BLS12-381 Groth16 | Symbolic bitwise operations are still limited; use `SignalBinary` or add wrappers when needed. | +| Binary decomposition | `Binary`, `SignalBinary`, `AliasCheck` | Partly via `ZkUInt`, `ZkBits`, `ZkBool` | Generic | Ready on BLS12-381 Groth16 | `AliasCheck` remains a lower-level helper for canonical field representation checks. | +| Comparators and ranges | `Comparators`, `SignalComparators` | Mostly through `ZkUInt` | Generic | Ready on BLS12-381 Groth16 | Optional symbolic `min`/`max` helpers can be added later if needed. | +| Selection / mux | `Mux` | `ZkBool.select(...)`, `ZkJubjubPoint.select(...)` | Generic for scalar values | Ready on BLS12-381 Groth16 | Dynamic array access remains a lower-level `Mux.arrayAccess(...)` pattern. | +| MiMC | `MiMC`, `SignalMiMC` | `ZkMiMC` | BN254 only | Not Cardano-ready | `MiMC` and `SignalMiMC` call `requireField(FieldConfig.BN254)`. Use Poseidon for Cardano circuits. | +| MiMC sponge | `MiMCSponge` | No direct `ZkMiMCSponge` | BN254 only, because it uses MiMC | Not Cardano-ready | Useful for BN254/off-chain legacy circuits only. | +| Poseidon T3 | `Poseidon`, `SignalPoseidon` | `ZkPoseidon` | BN254 default; BLS12-381 with explicit params | Ready when using `PoseidonParamsBLS12_381T3.INSTANCE` | No-params overloads are BN254 for backward compatibility. | +| Folded Poseidon N | `PoseidonN` | `ZkPoseidonN` | BN254 default in DSL overloads; symbolic API requires explicit params | Ready when using `PoseidonParamsBLS12_381T3.INSTANCE` | Folded two-input Poseidon, not a separate variable-width Poseidon permutation. | +| Merkle membership | `Merkle`, `SignalMerkle` | `ZkMerkle` | Hash-dependent | Ready with params-aware BLS12-381 Poseidon helpers | Use `ZkMerkle.*Poseidon(..., PoseidonParamsBLS12_381T3.INSTANCE, ...)` for Cardano. `HashType.MIMC` and default `HashType.POSEIDON` are BN254-oriented convenience paths. | +| Poseidon MPF | No `Signal*` facade; host witness helpers live in `zeroj-mpf-poseidon` | `ZkMpf`, `ZkMpfProof` | BLS12-381 Poseidon only | Technically usable through gnark Groth16, but experimental/heavy | Ready at witness/circuit level. Current MPF circuit is large, so MPF-specific Yaci demo and pure Java proving are deferred until optimization. Not compatible with native Aiken/Blake2b MPF roots. | +| Jubjub point arithmetic | `InCircuitJubjub`, `JubjubPoint` | `ZkJubjubPoint` | BLS12-381 scalar field only | Ready on BLS12-381 Groth16 | `fromTrustedAffine(...)` assumes curve/subgroup/non-identity checks were done off-circuit when required by the protocol. | +| Pedersen commitment | `InCircuitPedersen`, `PedersenCommitment` | `ZkPedersen` | BLS12-381 scalar field only | Ready on BLS12-381 Groth16 | Symbolic scalar inputs are capped at `ZkPedersen.MAX_SCALAR_BITS = 252`. | +| EdDSA-Jubjub | `InCircuitEdDSAJubjub`, `EdDSAJubjub` | `ZkEdDSAJubjub` | BLS12-381 scalar field only | Ready on BLS12-381 Groth16 | Identity public keys are rejected in-circuit; affine inputs are still trusted for curve/subgroup membership unless separately checked. | +| Poseidon parameters and off-circuit hashing | `PoseidonParams*`, `PoseidonHash`, `PoseidonGrainLFSR` | Used by `ZkPoseidon*`, `ZkMerkle`, `ZkMpf` | BN254 T3, BLS12-381 T3, BLS12-381 T5 presets exist | Ready when matched to the circuit field and gadget shape | `PoseidonHash` is host-side hashing for expected roots/test vectors, not a circuit constraint by itself. The in-circuit `Poseidon` gadget currently supports T3/alpha-5 only. | + +For the broader Cardano/annotation matrix, see +[`docs/adr/circuit-annotation/cardano-gadget-support-matrix.md`](../docs/adr/circuit-annotation/cardano-gadget-support-matrix.md). + ## Why It Is Useful - Avoids reimplementing common ZK gadgets in every application circuit. @@ -30,27 +62,106 @@ or Jubjub-style primitives. ## Usage Shape -The library is designed to be called from a `CircuitBuilder` definition: +ZeroJ supports three circuit authoring styles. Prefer them in this order unless +you have a specific reason to drop lower in the stack. + +### 1. Symbolic Annotations + +Use this for new application circuits. The annotation processor generates the +`CircuitBuilder`, schema, metadata, and input helpers, while the body stays +close to ordinary Java domain code. + +```java +@ZKCircuit(name = "sealed-bid", version = 1) +public class SealedBid { + @Prove + ZkBool prove( + ZkContext zk, + @Public ZkField bidCommitment, + @Public @UInt(bits = 64) ZkUInt reservePrice, + @Secret @UInt(bits = 64) ZkUInt bidAmount, + @Secret ZkField salt) { + var commitmentMatches = ZkPoseidon.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + bidAmount.asField(), + salt) + .isEqual(bidCommitment); + + return commitmentMatches.and(bidAmount.gte(reservePrice)); + } +} + +var circuit = SealedBidCircuit.build(); +``` + +### 2. CircuitSpec + +Use `CircuitSpec` when you want an explicit reusable circuit class without the +annotation processor, or when working close to the `SignalBuilder` API is useful. + +```java +public class SealedBidCircuit implements CircuitSpec { + @Override + public void define(SignalBuilder c) { + Signal bidAmount = c.privateInput("bidAmount"); + Signal salt = c.privateInput("salt"); + Signal bidCommitment = c.publicOutput("bidCommitment"); + Signal reservePrice = c.publicInput("reservePrice"); + + c.assertEqual( + SignalPoseidon.hash(c, PoseidonParamsBLS12_381T3.INSTANCE, bidAmount, salt), + bidCommitment); + c.assertEqual( + SignalComparators.greaterOrEqual(c, bidAmount, reservePrice, 64), + c.constant(1)); + } + + public static CircuitBuilder build() { + return CircuitBuilder.create("sealed-bid") + .publicVar("bidCommitment") + .publicVar("reservePrice") + .secretVar("bidAmount") + .secretVar("salt") + .defineSignals(new SealedBidCircuit()); + } +} +``` + +### 3. Inline Circuit DSL + +Use inline `CircuitBuilder` definitions for small tests, examples, and quick +experiments. ```java -var circuit = CircuitBuilder.create("membership") - .publicVar("root") - .secretVar("leaf") +var circuit = CircuitBuilder.create("sealed-bid") + .publicVar("bidCommitment") + .publicVar("reservePrice") + .secretVar("bidAmount") + .secretVar("salt") .defineSignals(c -> { - var leaf = c.privateInput("leaf"); - var root = c.publicInput("root"); - var commitment = SignalPoseidon.hash(c, leaf, c.constant(BigInteger.ONE)); - c.assertEqual(commitment, root); + var bidAmount = c.privateInput("bidAmount"); + var salt = c.privateInput("salt"); + var bidCommitment = c.publicOutput("bidCommitment"); + var reservePrice = c.publicInput("reservePrice"); + + c.assertEqual( + SignalPoseidon.hash(c, PoseidonParamsBLS12_381T3.INSTANCE, bidAmount, salt), + bidCommitment); + c.assertEqual( + SignalComparators.greaterOrEqual(c, bidAmount, reservePrice, 64), + c.constant(1)); }); ``` -For larger circuits, prefer the `Signal*` helper classes with `SignalBuilder` -and reusable `CircuitSpec` components. - -Annotation-based circuits can use symbolic adapters from -`com.bloxbean.cardano.zeroj.circuit.lib.zk`. Cardano/BLS12-381 hash examples +All three styles use the same underlying circuit library gadgets. Symbolic +annotation-based circuits use adapters from +`com.bloxbean.cardano.zeroj.circuit.lib.zk`; `CircuitSpec` and inline DSL code +usually use the `Signal*` helpers directly. Cardano/BLS12-381 examples should also use `PoseidonParamsBLS12_381T3` from -`com.bloxbean.cardano.zeroj.circuit.lib.poseidon`: +`com.bloxbean.cardano.zeroj.circuit.lib.poseidon`. + +Common symbolic adapter calls: ```java var hash = ZkPoseidon.hash( @@ -74,25 +185,33 @@ var pedersen = ZkPedersen.commit(zk, value, blinding, 64); ``` These adapters delegate to the existing `Signal*` and in-circuit gadgets and -validate that their inputs belong to the supplied `ZkContext`. `ZkMiMC` is -guarded as BN254-only; use explicit Poseidon parameters when targeting -BLS12-381. `ZkPoseidonN` requires explicit Poseidon params and is the symbolic -path for folded multi-input commitments. The no-params Poseidon helpers are -BN254-oriented for backward compatibility, and `ZkMerkle.HashType.MIMC` / -no-params `HashType.POSEIDON` -should be treated as BN254/off-chain conveniences. Use -`ZkMerkle.computeRootPoseidon`, `isMemberPoseidon`, or `verifyPoseidon` with -explicit BLS12-381 Poseidon params for Cardano Merkle circuits. Jubjub, -Pedersen, and EdDSA-Jubjub adapters are BLS12-381-only and -inherit the curve/subgroup-check contracts documented on the underlying -in-circuit gadgets. Use `ZkJubjubPoint.fromTrustedAffine(...)` only for points -validated off-circuit for curve membership, subgroup membership, and non-identity -where the protocol requires it. `ZkEdDSAJubjub.verify(...)` rejects identity -public keys in-circuit. +validate that their inputs belong to the supplied `ZkContext`. + +Curve and parameter guidance: + +- **MiMC** — `ZkMiMC` is guarded as BN254-only. Use Poseidon when targeting BLS12-381. +- **Poseidon** — `ZkPoseidonN` requires explicit Poseidon params and is the + symbolic path for folded multi-input commitments. The no-params Poseidon + helpers are BN254-oriented for backward compatibility. +- **Merkle** — `ZkMerkle.HashType.MIMC` and the no-params `HashType.POSEIDON` + paths are BN254/off-chain conveniences. For Cardano Merkle circuits, use + `ZkMerkle.computeRootPoseidon`, `isMemberPoseidon`, or `verifyPoseidon` with + explicit BLS12-381 Poseidon params. +- **Jubjub / Pedersen / EdDSA-Jubjub** — BLS12-381-only adapters. They inherit + the curve/subgroup-check contracts documented on the underlying in-circuit + gadgets. Use `ZkJubjubPoint.fromTrustedAffine(...)` only for points validated + off-circuit for curve membership, subgroup membership, and non-identity where + the protocol requires it. `ZkEdDSAJubjub.verify(...)` rejects identity public + keys in-circuit. The Cardano-oriented support matrix is maintained in [`docs/adr/circuit-annotation/cardano-gadget-support-matrix.md`](../docs/adr/circuit-annotation/cardano-gadget-support-matrix.md). +The status table above was checked against the current implementation in +`src/main/java`, especially `MiMC`, `Poseidon`, `PoseidonN`, `ZkMerkle`, +`ZkMpf`, `ZkPedersen`, `ZkEdDSAJubjub`, and the adapter coverage in +`src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java`. + ## Gradle ```gradle From 0f6cf06c5e7ecc2812f34f556c903d79c4a4163c Mon Sep 17 00:00:00 2001 From: Satya Date: Wed, 27 May 2026 19:24:03 +0800 Subject: [PATCH 26/26] Fix CCL dependency --- zeroj-mpf-poseidon/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroj-mpf-poseidon/build.gradle b/zeroj-mpf-poseidon/build.gradle index a46b0cf..81ab225 100644 --- a/zeroj-mpf-poseidon/build.gradle +++ b/zeroj-mpf-poseidon/build.gradle @@ -7,8 +7,8 @@ description = 'ZeroJ Poseidon-rooted Merkle Patricia Forestry integration for CC dependencies { api project(':zeroj-circuit-lib') api project(':zeroj-circuit-annotation-api') - api 'com.bloxbean.cardano:cardano-client-merkle-patricia-forestry:0.8.0-pre4-SNAPSHOT' - api 'com.bloxbean.cardano:cardano-client-verified-structures-core:0.8.0-pre4-SNAPSHOT' + api 'com.bloxbean.cardano:cardano-client-merkle-patricia-forestry:0.8.0-pre4' + api 'com.bloxbean.cardano:cardano-client-verified-structures-core:0.8.0-pre4' implementation 'co.nstant.in:cbor:0.9'