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/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/README.md b/docs/adr/circuit-annotation/README.md new file mode 100644 index 0000000..fd2b9d0 --- /dev/null +++ b/docs/adr/circuit-annotation/README.md @@ -0,0 +1,2007 @@ +# 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", version = 1) +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 +// 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 +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 + -> Groth16BLS12381Verifier on Julc / Plutus V3 +``` + +`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. + +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); +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 +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, +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. + +`@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` + +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`, `@ZKCircuit(version = N)`, `@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. + +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`. + +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(); +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); +ZkField asField(); +Signal signal(); +``` + +`ZkBool` should call `Signal.assertBoolean()` when constructed from public or +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` + +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. + +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. + +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`. + +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); +``` + +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. + +### `ZkBits` + +Represents a fixed-length bit vector backed by constrained `ZkBool` values. + +Use cases: + +- bit decomposition +- bitwise operations +- fixed byte packing +- cryptographic gadgets that operate on bits + +`ZkBits` requires explicit length through `@FixedSize` in generated circuits. +The Phase 7 implementation uses one constrained boolean signal per bit. + +### `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` 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 + +### `@ZKCircuit` + +Marks a class as a circuit source. + +```java +@Target(TYPE) +@Retention(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}`. 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` + +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 +// BN254/off-chain enum path. +var circuit = MerkleMembershipCircuit.build(32, HashType.MIMC); +``` + +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, bit vectors, and byte values. + +```java +@Target({FIELD, PARAMETER}) +@Retention(SOURCE) +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`. For +`ZkArray>`, use `inner` or `innerParam` for the second dimension. + +Examples: + +```java +@Secret +@FixedSize(32) +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`. Nested arrays also require exactly one of `inner` or +`innerParam`. + +### `@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.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(); + }); + } +} +``` + +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, + PoseidonParamsBLS12_381T3.INSTANCE, + left, + right); +ZkMerkle.verifyPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + leaf, + root, + siblings, + pathBits); +``` + +Proposed package for these adapters: + +```text +com.bloxbean.cardano.zeroj.circuit.lib.zk +``` + +MVP adapters: + +- `ZkMiMC` +- `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. +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: + +```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 +- 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(...)`. 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"; + + public static CircuitBuilder build(); + + public static ZkCircuitSchema schema(); + + 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); + } +} +``` + +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 +// 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 + +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 ZkPoseidon.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + 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.secretFields(c, "sibling", depth); +``` + +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 + +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. + +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 + +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: Bit and Byte Symbolic Inputs + +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 + +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. + +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 + +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. + +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 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 + +### 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 +- typed public input and proof-envelope helpers +- 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` 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(...)`; 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. | + +## 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/cardano-gadget-support-matrix.md b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md new file mode 100644 index 0000000..80bffe6 --- /dev/null +++ b/docs/adr/circuit-annotation/cardano-gadget-support-matrix.md @@ -0,0 +1,469 @@ +# ADR: Cardano Gadget Support Matrix for Symbolic Annotated Circuits + +## Status + +Accepted follow-up plan. Priorities 1 through 6 are completed in the current +code and 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/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/...` + +## 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 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`. | +| `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 | 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 | 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. | +| `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 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 + +### 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). + +`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: + +```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`. Custom validators compose the reusable +`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 + +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` + +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: + +- 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: + +- Multi-input commitments can be written without dropping to `Signal`. +- BLS12-381 params are supported and tested. + +### 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: + +- Added params-aware helpers: + +```java +ZkMerkle.verifyPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + leaf, + root, + siblings, + pathBits); +``` + +- 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 in Cardano-facing + examples. + +### 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: + +- Added `Groth16BLS12381Verifier`. +- Removed the old fixed two-input verifier before release. +- 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 + values, too many values, and empty `IC` lists. +- Updated the pure Java Yaci DevKit e2e to use the canonical verifier. + +Exit criteria: + +- 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. + +### 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: + +- 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. +- Made 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: 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: + +- 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 + multi-row compliance proofs. + +Tasks: + +- Extended annotation validation so nested `ZkArray>` declarations + require explicit outer and inner fixed sizes. +- Defined stable schema flattening such as `matrix_0_0`, `matrix_0_1`, + `matrix_1_0`, and `matrix_1_1`. +- 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. +- Added 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. + +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. + +### 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. +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. Completed. +6. Nested `ZkArray>` support. Completed. +7. Poseidon MPF gadget. Completed. +8. 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." +- A generalized/generated on-chain verifier adds a new implementation slice. + +## Open Questions + +- 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/cardano-groth16-arbitrary-public-inputs.md b/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md new file mode 100644 index 0000000..f522746 --- /dev/null +++ b/docs/adr/circuit-annotation/cardano-groth16-arbitrary-public-inputs.md @@ -0,0 +1,309 @@ +# ADR: Generic Cardano Groth16 Verifier for Arbitrary Public Inputs + +## Status + +Implemented. + +## Date + +2026-05-18 + +## Context + +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] +``` + +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 +com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator.Groth16BLS12381Verifier +``` + +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) +``` + +Because ZeroJ has not been released yet, the fixed-input compatibility class +was removed. `Groth16BLS12381Verifier` is the canonical arbitrary-input +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 +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 + -> Groth16BLS12381Verifier +``` + +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 +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator.Groth16BLS12381Verifier; + +JulcScriptLoader.load( + Groth16BLS12381Verifier.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 canonical 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 canonical verifier +- run a lock/unlock transaction with a generated Groth16 BLS12-381 proof +- keep the existing examples working through the canonical verifier + +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. 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 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. + +## Implementation Notes + +Status: implemented. + +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. + +`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 +issues while still sharing all proof verification logic. + +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 tests were updated to use the canonical verifier. + +## Consequences + +Positive: + +- annotated circuits are no longer blocked by the old two-public-input verifier +- 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 + +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/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 new file mode 100644 index 0000000..b156e1b --- /dev/null +++ b/docs/adr/circuit-annotation/implementation-plan.md @@ -0,0 +1,74 @@ +# 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 | Completed | 823c422 | +| 1 | Module scaffolding | Completed | 86f122c | +| 2 | Symbolic foundation | Completed | bfe0b65 | +| 3 | MVP gadget adapters | Completed | 7f49413 | +| 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 | 126ef5c | +| 8 | Advanced gadget adapters | Completed | 3b873d2 | +| 9 | Proving flow integration | Completed | Phase 9 commit | + +## 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. + +## 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 | 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 | Completed | +| 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/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`. 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..89f3af4 --- /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 + +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/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/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/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/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/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/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/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/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-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/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..78f952c --- /dev/null +++ b/docs/adr/circuit-annotation/zk-poseidon-n-symbolic-adapter.md @@ -0,0 +1,127 @@ +# 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`; tracked separately in + `zk-merkle-poseidon-params-helpers.md` + +## 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/architecture-overview.md b/docs/architecture-overview.md index 9086942..4aefd07 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -132,10 +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 -- `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/circuit-annotation-user-guide.md b/docs/circuit-annotation-user-guide.md new file mode 100644 index 0000000..9801e56 --- /dev/null +++ b/docs/circuit-annotation-user-guide.md @@ -0,0 +1,292 @@ +# 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", version = 1) +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.BLS12_381); +inputs.publicValues(); +inputs.toPublicInputs(); + +var envelope = RangeProofCircuit.proofEnvelopeBuilder( + circuit, + ProofSystemId.GROTH16, + 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); +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 +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`, + `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`, `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. + +## 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: + +For Cardano-oriented Merkle circuits, bind the hash to BLS12-381 Poseidon +explicitly: + +```java +@ZKCircuit(name = "merkle-bls12-381", nameTemplate = "merkle-bls-d{depth}") +public class MerkleMembership { + public MerkleMembership(@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); + } +} +``` + +```java +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 +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. + +`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 +`ZkMerkle.computeRootPoseidon`, `isMemberPoseidon`, or `verifyPoseidon` with +explicit BLS12-381 Poseidon params. + +## 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()` +- 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.BLS12_381); +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.BLS12_381, + 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 +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. + +## 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. +- 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. +- Static `@Prove` methods must use parameter-style inputs. +- `@CircuitParam` belongs on constructor parameters, not proof method + parameters. +- `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. +- `ZkMerkle.HashType.MIMC` and the no-params `HashType.POSEIDON` path are not + 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/docs/circuit-dsl-user-guide.md b/docs/circuit-dsl-user-guide.md index 019e942..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))); } } ``` @@ -357,19 +372,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 +409,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 +427,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. @@ -477,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() { @@ -510,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) { @@ -607,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); } @@ -743,7 +787,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..dd08c20 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -130,19 +130,25 @@ 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); +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, 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(); @@ -203,7 +209,7 @@ 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 -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 @@ -269,7 +275,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..4cb0da0 100644 --- a/docs/pure-java-prover-guide.md +++ b/docs/pure-java-prover-guide.md @@ -219,19 +219,25 @@ 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); +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, 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()); @@ -400,8 +406,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/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 67dadc1..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 (`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 (`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 b6ecffb..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 `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 `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..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 { @@ -303,10 +305,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 +315,9 @@ public class VoteMintingPolicy { @Entrypoint static boolean validate(VoteProof redeemer, PlutusData ctx) { // 1. Groth16 BLS12-381 pairing check - boolean proofValid = verifyGroth16Pairing( + boolean proofValid = Groth16BLS12381Lib.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/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. +``` diff --git a/settings.gradle b/settings.gradle index 5c3eb57..4d21756 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,6 +15,9 @@ 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-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 63827aa..f7b9eaf 100644 --- a/zeroj-bom-all/build.gradle +++ b/zeroj-bom-all/build.gradle @@ -26,6 +26,9 @@ 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-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 ea196c7..0f097fc 100644 --- a/zeroj-bom-core/build.gradle +++ b/zeroj-bom-core/build.gradle @@ -23,6 +23,9 @@ 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-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 new file mode 100644 index 0000000..5b7e4b2 --- /dev/null +++ b/zeroj-circuit-annotation-api/README.md @@ -0,0 +1,112 @@ +# zeroj-circuit-annotation-api + +Public API for annotation-based ZeroJ circuit authoring. + +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: + +- circuit annotations such as `@ZKCircuit`, `@Prove`, `@Public`, `@Secret`, + `@CircuitParam`, `@UInt`, `@FieldElement`, `@FixedSize`, and `@Order` +- symbolic circuit value types: `ZkField`, `ZkBool`, `ZkUInt`, `ZkArray`, + `ZkBits`, and `ZkBytes` +- 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. + +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(); + }); +``` + +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.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); +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 +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. +- `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. +- `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. +- `ZkInputMap.publicValues(schema)` extracts public values in schema order. + +Known limitations: + +- 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` + 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. +- `ZkMerkle.HashType.MIMC` and the no-params `HashType.POSEIDON` convenience + 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. +- 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, 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-api/build.gradle b/zeroj-circuit-annotation-api/build.gradle new file mode 100644 index 0000000..7583b3c --- /dev/null +++ b/zeroj-circuit-annotation-api/build.gradle @@ -0,0 +1,21 @@ +plugins { + id 'java-library' +} + +description = 'ZeroJ Circuit Annotation API — annotations and symbolic types for Java circuit authoring' + +dependencies { + api project(':zeroj-api') + 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/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..d2282df --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/FixedSize.java @@ -0,0 +1,18 @@ +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 ""; + int inner() default -1; + String innerParam() 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..208e9b6 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZKCircuit.java @@ -0,0 +1,17 @@ +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 ""; + int version() default 1; +} 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..3ef4abd --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkArray.java @@ -0,0 +1,156 @@ +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)); + } + + 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. + */ + 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); + } + + 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(); + } + + 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); + } + + @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/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 new file mode 100644 index 0000000..95dc8a4 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkBool.java @@ -0,0 +1,144 @@ +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 ZkBool isEqual(ZkBool other) { + requireSameContext(other); + return trusted(context, signal.isEqual(other.signal)); + } + + 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/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/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/ZkCircuitSchema.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java new file mode 100644 index 0000000..509115e --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkCircuitSchema.java @@ -0,0 +1,232 @@ +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, + 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) { + throw new IllegalArgumentException("signalNames size must match input size"); + } + } + + 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), List.of()); + } + + public static Input array(String name, Visibility visibility, Kind kind, int bits, int 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++) { + String next = prefix + "_" + i; + if (depth == dimensions.size() - 1) { + names.add(next); + } else { + appendNames(names, next, dimensions, depth + 1); + } + } + } + + 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 { + PUBLIC, + SECRET + } + + public enum Kind { + FIELD, + BOOL, + UINT, + BITS, + BYTES + } +} 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/ZkInputMap.java b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java new file mode 100644 index 0000000..8f5cad2 --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkInputMap.java @@ -0,0 +1,74 @@ +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; +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 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))); + 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); + } + + 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/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 new file mode 100644 index 0000000..845ac9d --- /dev/null +++ b/zeroj-circuit-annotation-api/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/package-info.java @@ -0,0 +1,10 @@ +/** + * Annotation-based circuit authoring API for ZeroJ. + * + *

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. 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 new file mode 100644 index 0000000..12a03ca --- /dev/null +++ b/zeroj-circuit-annotation-api/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/ZkSymbolicTypesTest.java @@ -0,0 +1,514 @@ +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; + +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 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") + .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 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( + "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(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 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( + "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( + "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") + .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-annotation-processor/README.md b/zeroj-circuit-annotation-processor/README.md new file mode 100644 index 0000000..72ec2d6 --- /dev/null +++ b/zeroj-circuit-annotation-processor/README.md @@ -0,0 +1,47 @@ +# zeroj-circuit-annotation-processor + +Compile-time annotation processor for annotation-based ZeroJ circuit authoring. + +Current Phase 9 status: this module scans `@ZKCircuit` classes and generates +`*Circuit` companions with `build(...)`, `schema(...)`, `inputs(...)`, +`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(...)`. + +Supported: + +- field-style and parameter-style `@Prove` methods +- `ZkContext` proof parameters +- constructor `@CircuitParam` values +- `@FixedSize(...)` arrays, bits, and bytes +- `@Public`, `@Secret`, `@UInt`, `@FieldElement`, and `@Order` +- 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: + +```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, 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-annotation-processor/build.gradle b/zeroj-circuit-annotation-processor/build.gradle new file mode 100644 index 0000000..5c388ea --- /dev/null +++ b/zeroj-circuit-annotation-processor/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'java-library' +} + +description = 'ZeroJ Circuit Annotation Processor — generates CircuitBuilder companions from annotated Java circuits' + +dependencies { + implementation project(':zeroj-circuit-annotation-api') + + testImplementation project(':zeroj-circuit-lib') +} + +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..9fd8674 --- /dev/null +++ b/zeroj-circuit-annotation-processor/src/main/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessor.java @@ -0,0 +1,1352 @@ +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; + +/** + * 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_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"; + private static final String ZK_CONTEXT = ANNOTATION_PKG + "ZkContext"; + + @Override + public Set getSupportedAnnotationTypes() { + return Set.of(ZKCircuit.class.getCanonicalName()); + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + 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); + } + 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()))); + } + 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)) { + 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) { + 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); + 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 || ZK_UINT.equals(arrayLeafType)) && uint == null) { + throw new GenerationException(element, "ZkUInt symbolic inputs require @UInt(bits = ...)"); + } + 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"); + } + + 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.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) { + 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, + arrayLeafType, bits, size, innerSize, order, fieldStyle); + } + + private SizeModel sizeModel(VariableElement element, int literal, String paramName, String label, + Map circuitParams) { + boolean hasLiteral = literal >= 0; + boolean hasParam = !paramName.isBlank(); + if (hasLiteral == hasParam) { + throw new GenerationException(element, + label + " requires exactly one literal value or @CircuitParam reference"); + } + if (hasLiteral) { + if (literal <= 0) { + throw new GenerationException(element, label + " value must be positive"); + } + return new SizeModel(Integer.toString(literal), false, ""); + } + + CircuitParamModel param = circuitParams.get(paramName); + if (param == null || !param.intLike()) { + throw new GenerationException(element, + label + " \"" + paramName + "\" must reference an integer @CircuitParam"); + } + return new SizeModel(param.javaName(), true, param.name()); + } + + 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<>(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()); + } + if (!constantNames.add(input.constantName())) { + throw new GenerationException(null, "Duplicate generated input constant name: " + input.constantName()); + } + } + + Set flattenedNames = new HashSet<>(); + Map flattenedOwners = new java.util.HashMap<>(); + for (InputModel input : inputs) { + if (isFixedVector(input.valueKind())) { + if (hasLiteralShape(input)) { + for (String name : literalFlattenedNames(input)) { + addFlattenedName(flattenedNames, flattenedOwners, input, name); + } + } + } else { + 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 -> isFixedVector(i.valueKind())).toList()) { + for (InputModel other : inputs.stream().filter(i -> i != array).toList()) { + 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()); + } + } + } + } + + 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 boolean hasLiteralShape(InputModel input) { + return input.size() != null + && !input.size().fromCircuitParam() + && (input.innerSize() == null || !input.innerSize().fromCircuitParam()); + } + + private List 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)) { + 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(); + 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(); + 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); + 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)); + } + + StringBuilder out = new StringBuilder(); + if (!packageName.isEmpty()) { + out.append("package ").append(packageName).append(";\n\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") + .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 = ") + .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 = ") + .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"); + renderFixedSizeParamGuards(out, inputs, " "); + 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, innerLoopLocal, "publicVar"); + } + for (InputModel input : inputs.stream().filter(i -> i.visibility() == Visibility.SECRET).toList()) { + renderVarDeclaration(out, input, builderLocal, loopLocal, innerLoopLocal, "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"); + + renderSchemaAndInputs(out, circuitParams, inputs, parameterized); + + 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("}\", circuitParamDisplayValue(") + .append(param.javaName()).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"); + } + + out.append("}\n"); + 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"); + renderFixedSizeParamGuards(out, inputs, " "); + out.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 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); + } + + 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()); + } + 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") + .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()"; + } + return circuitParams.stream() + .map(param -> "new ZkCircuitSchema.Parameter(" + + stringLiteral(param.name()) + ", " + + stringLiteral(param.type()) + ", " + + "circuitParamValue(" + 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 = isFixedVector(input.valueKind()) + ? "ZkCircuitSchema.Input.array(" + : "ZkCircuitSchema.Input.scalar("; + 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() + + ", 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 (isFixedVector(input.valueKind())) { + if (isNestedArray(input)) { + renderNestedArrayInputMethods(out, input); + } else { + 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\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"); + } + + 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 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"; + } + if (input.valueKind() == ValueKind.BOOL || input.arrayElementType().equals(ZK_BOOL)) { + return "BOOL"; + } + 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()); + } + + 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; + } + 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 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") + .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.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)) { + 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() + ")"; + } + 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_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, ZkArray, ZkBits, or ZkBytes, 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 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) && arrayLeafType(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); + } + + 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(); + } + + 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) { + 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) { + 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, + BITS, + BYTES + } + + private record CircuitParamModel(String name, String javaName, String type, boolean intLike) {} + + private record SizeModel(String expression, boolean fromCircuitParam, String paramName) {} + + private record InputModel( + String javaName, + String baseName, + String constantName, + Visibility visibility, + ValueKind valueKind, + String arrayElementType, + int bits, + SizeModel size, + SizeModel innerSize, + 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/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..9d9d025 --- /dev/null +++ b/zeroj-circuit-annotation-processor/src/test/java/com/bloxbean/cardano/zeroj/circuit/annotation/processor/CircuitAnnotationProcessorTest.java @@ -0,0 +1,1411 @@ +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; +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); + assertTrue(processors.stream() + .map(ServiceLoader.Provider::type) + .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", version = 2) + 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); + 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)), + "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)); + + 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)); + 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 + 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 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", """ + 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 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", """ + 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); + 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(circuitName, schema.name()); + assertEquals("depth", schema.parameters().get(0).name()); + 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()); + assertTrue(schema.input("pathBit_0").array()); + + 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)); + + 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)); + + 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); + 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); + + 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 + 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 + 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", """ + 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-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 + 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 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", """ + 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")); + + 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")); + + 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 + 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", """ + 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 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", """ + 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; + } + } + +} 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. */ diff --git a/zeroj-circuit-lib/README.md b/zeroj-circuit-lib/README.md index 1ee23bc..be1d2dd 100644 --- a/zeroj-circuit-lib/README.md +++ b/zeroj-circuit-lib/README.md @@ -17,9 +17,42 @@ or Jubjub-style primitives. | Binary gadgets | `Binary`, `SignalBinary`, `AliasCheck` | | Selection | `Mux` | | Signal helpers | `SignalPoseidon`, `SignalMiMC` | +| 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 | +## 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. @@ -29,22 +62,155 @@ 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. +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`. + +Common symbolic adapter calls: + +```java +var hash = ZkPoseidon.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + left, + right); +var commitment = ZkPoseidonN.hash( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + owner, + assetId, + nonce); +var root = ZkMerkle.computeRootPoseidon( + zk, + PoseidonParamsBLS12_381T3.INSTANCE, + leaf, + siblings, + pathBits); +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`. + +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 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/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/ZkMerkle.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMerkle.java new file mode 100644 index 0000000..859ac1b --- /dev/null +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMerkle.java @@ -0,0 +1,232 @@ +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 com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; + +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 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, + 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 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, + 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 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, + 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 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"); + 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/ZkMpf.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpf.java new file mode 100644 index 0000000..f0924a8 --- /dev/null +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkMpf.java @@ -0,0 +1,642 @@ +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.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) { + // 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 selectPrefixedPathChunk0( + ZkContext zk, + PathSignals path, + ZkUInt start, + 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); + } + return eqConst(zk, length, 0).select(digest, selected); + } + + private static ZkField selectPrefixedPathChunk1( + ZkContext zk, + PathSignals path, + ZkUInt start, + ZkUInt length, + 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 selectPrefixedDigestChunkFromWitness( + ZkContext zk, + ZkUInt length, + ZkArray prefixChunks, + ZkField digest, + int chunkIndex) { + 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 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) { + // 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, + 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 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-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/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/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 new file mode 100644 index 0000000..4599112 --- /dev/null +++ b/zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/zk/ZkGadgetAdaptersTest.java @@ -0,0 +1,971 @@ +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.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.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; +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; +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 { + 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() { + 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 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") + .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 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( + 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]); + })); + } + + @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, + 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 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") + .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; + } + + 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 7bfef6c..78085ec 100644 --- a/zeroj-examples/README.md +++ b/zeroj-examples/README.md @@ -14,16 +14,55 @@ 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. +- **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) +- **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 `ZkPoseidon` or `ZkPoseidonN` 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 @@ -36,10 +75,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 @@ -62,7 +101,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) @@ -101,7 +142,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/build.gradle b/zeroj-examples/build.gradle index 171bf01..882211b 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' } @@ -30,6 +30,8 @@ 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-mpf-poseidon') implementation project(':zeroj-crypto') implementation project(':zeroj-blst') implementation project(':zeroj-cardano') @@ -40,9 +42,11 @@ 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}" + annotationProcessor project(':zeroj-circuit-annotation-processor') annotationProcessor "com.bloxbean.cardano:julc-annotation-processor:${julcVersion}" // Cardano Client Lib for tx building in E2E tests @@ -61,6 +65,7 @@ test { useJUnitPlatform { excludeTags 'e2e' } + maxHeapSize = '2g' } // E2E integration tests — requires Yaci DevKit running locally 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/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/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/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..4e32ee4 --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedAnonymousVoting.java @@ -0,0 +1,24 @@ +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.ZkPoseidon; + +@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 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/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/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/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..4eea463 --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedHashCommitment.java @@ -0,0 +1,28 @@ +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; + +/** + * 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 + 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/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/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/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/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/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..556a928 --- /dev/null +++ b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedSealedBid.java @@ -0,0 +1,33 @@ +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.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 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)); + } +} 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/auction/onchain/ZkAuctionVerifier.java b/zeroj-examples/src/main/java/com/bloxbean/cardano/zeroj/examples/dsl/auction/onchain/ZkAuctionVerifier.java index 6d7ba2e..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.julc.stdlib.lib.BlsLib; +import com.bloxbean.cardano.zeroj.onchain.julc.groth16.lib.Groth16BLS12381Lib; 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 Groth16BLS12381Lib} + * 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 = Groth16BLS12381Lib.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/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 new file mode 100644 index 0000000..de66858 --- /dev/null +++ b/zeroj-examples/src/test/java/com/bloxbean/cardano/zeroj/examples/annotation/AnnotatedCircuitExamplesTest.java @@ -0,0 +1,527 @@ +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.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; +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; +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; +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 { + + @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 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(); + 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 hashCommitmentUsesBn254MiMCSymbolicGadgetAdapter() { + 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)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BLS12_381)); + + var wrong = AnnotatedHashCommitmentCircuit.inputs() + .value(value) + .salt(salt) + .commitment(BigInteger.ONE); + assertThrows(ArithmeticException.class, + () -> 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(); + var schema = AnnotatedSealedBidCircuit.schema(); + + assertEquals("annotation-sealed-bid", schema.name()); + 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 = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, bidAmount, salt); + var inputs = AnnotatedSealedBidCircuit.inputs() + .bidCommitment(commitment) + .reservePrice(reservePrice) + .bidAmount(bidAmount) + .salt(salt); + + 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 = PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, belowReserveBid, salt); + var belowReserveInputs = AnnotatedSealedBidCircuit.inputs() + .bidCommitment(belowReserveCommitment) + .reservePrice(reservePrice) + .bidAmount(belowReserveBid) + .salt(salt); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(belowReserveInputs.toWitnessMap(), CurveId.BLS12_381)); + + var wrongCommitment = AnnotatedSealedBidCircuit.inputs() + .bidCommitment(BigInteger.ONE) + .reservePrice(reservePrice) + .bidAmount(bidAmount) + .salt(salt); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(wrongCommitment.toWitnessMap(), CurveId.BLS12_381)); + } + + @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 = 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.BLS12_381)); + assertDoesNotThrow(() -> circuit.compileR1CS(CurveId.BLS12_381)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BN254)); + + var noVote = BigInteger.ZERO; + 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.BLS12_381)); + + var wrongCommitment = AnnotatedAnonymousVotingCircuit.inputs() + .commitment(BigInteger.ONE) + .vote(vote) + .nullifier(nullifier); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(wrongCommitment.toWitnessMap(), CurveId.BLS12_381)); + + var invalidVote = BigInteger.valueOf(2); + var invalidVoteInputs = AnnotatedAnonymousVotingCircuit.inputs() + .commitment(PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, invalidVote, nullifier)) + .vote(invalidVote) + .nullifier(nullifier); + assertThrows(ArithmeticException.class, + () -> circuit.calculateWitness(invalidVoteInputs.toWitnessMap(), CurveId.BLS12_381)); + } + + @Test + void parameterizedMerkleMembershipUsesBn254MiMCDepthAndHashType() { + 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--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()); + + 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)); + assertThrows(IllegalStateException.class, () -> circuit.compileR1CS(CurveId.BLS12_381)); + 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)); + } + + @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 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; + 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(); + 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, + 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; + } + + 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; + } + + private byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} 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..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 @@ -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.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; @@ -39,7 +39,7 @@ *
  3. Generate dev Powers of Tau + Groth16 setup (pure Java)
  4. *
  5. Compute witness and prove (pure Java BLS12-381 prover)
  6. *
  7. Compress proof + VK to BLS bytes
  8. - *
  9. Load generic {@link Groth16BLS12381Verifier} Plutus V3 script with VK params
  10. + *
  11. Load canonical {@link Groth16BLS12381Verifier} Plutus V3 script with VK params
  12. *
  13. Lock tADA at script address with public inputs as datum
  14. *
  15. Unlock with ZK proof as redeemer — verified on-chain by Cardano node
  16. *
@@ -81,10 +81,11 @@ 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: {@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 {@link Groth16BLS12381Verifier} 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,7 +133,7 @@ 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, @@ -132,19 +141,19 @@ void pureJavaProve_groth16_onChainVerify() throws Exception { 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(" → Groth16BLS12381Verifier (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-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/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 c29dab7..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(); @@ -87,9 +85,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 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 03b08da..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 @@ -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 Groth16BLS12381Verifier}.

* *

DEV/TEST (this test)

*

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

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-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()); 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..81ab225 --- /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' + api 'com.bloxbean.cardano:cardano-client-verified-structures-core:0.8.0-pre4' + + 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); + } +} diff --git a/zeroj-onchain-julc/README.md b/zeroj-onchain-julc/README.md index 390e643..bfbfbb6 100644 --- a/zeroj-onchain-julc/README.md +++ b/zeroj-onchain-julc/README.md @@ -10,15 +10,14 @@ V3. ## Current Status -| Validator / Helper | Status | Notes | -|--------------------|--------|-------| -| `Groth16BLS12381Verifier` | Working | Production-oriented BLS12-381 Groth16 verifier using Plutus V3 BLS builtins | -| `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 @@ -39,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/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/Groth16BLS12381Verifier.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java deleted file mode 100644 index d821d88..0000000 --- a/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381Verifier.java +++ /dev/null @@ -1,81 +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; - -/** - * 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 equation: - *

- *   finalVerify(
- *     mulMlResult(millerLoop(A, B), millerLoop(-alpha, beta)),
- *     mulMlResult(millerLoop(vk_x, gamma), millerLoop(C, delta))
- *   )
- * 
- */ -@SpendingValidator -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 - - /** - * 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); - } -} 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/groth16/lib/Groth16BLS12381Lib.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/lib/Groth16BLS12381Lib.java new file mode 100644 index 0000000..d1c2af1 --- /dev/null +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/lib/Groth16BLS12381Lib.java @@ -0,0 +1,162 @@ +package com.bloxbean.cardano.zeroj.onchain.julc.groth16.lib; + +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 Groth16BLS12381Lib { + + 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/groth16/validator/Groth16BLS12381Verifier.java b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381Verifier.java new file mode 100644 index 0000000..a42874d --- /dev/null +++ b/zeroj-onchain-julc/src/main/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381Verifier.java @@ -0,0 +1,37 @@ +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. + *

+ * 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. + *

+ * 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 Groth16BLS12381Verifier { + + @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 + + record Groth16Proof(byte[] piA, byte[] piB, byte[] piC) {} + + @Entrypoint + public static boolean validate(PlutusData datum, Groth16Proof proof, PlutusData ctx) { + 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 82% 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 e0deae2..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,11 +1,10 @@ -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; 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/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 deleted file mode 100644 index 627c978..0000000 --- a/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/Groth16BLS12381VerifierTest.java +++ /dev/null @@ -1,152 +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 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.List; - -/** - * Tests the on-chain 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 - */ -class Groth16BLS12381VerifierTest extends ContractTest { - - private static SnarkjsToCardano.VkCompressed vk; - private static SnarkjsToCardano.ProofCompressed proof; - private static List publicInputs; - - @BeforeAll - static void loadTestVectors() 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); - } - - @Test - void validProof_passes() { - // 1. Compile validator - var compiled = compileValidator(Groth16BLS12381Verifier.class); - - // 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))); - - // 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())); - - // 4. Build datum: list of public inputs - var datum = PlutusData.list( - PlutusData.integer(publicInputs.get(0)), - PlutusData.integer(publicInputs.get(1))); - - // 5. Build spending context - var txOutRef = TestDataBuilder.randomTxOutRef_typed(); - var ctx = spendingContext(txOutRef, datum) - .redeemer(redeemer) - .buildPlutusData(); - - // 6. Evaluate - var result = evaluate(program, ctx); - - // 7. Assert - assertSuccess(result); - System.out.println("[validProof_passes] Budget consumed: " + result.budgetConsumed()); - } - - @Test - void invalidProof_fails() { - // Same as validProof but tamper one byte of piA - 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; - - var redeemer = PlutusData.constr(0, - PlutusData.bytes(tamperedPiA), - 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); - } - - @Test - void wrongPublicInputs_fails() { - 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))); - - 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)); - - var txOutRef = TestDataBuilder.randomTxOutRef_typed(); - var ctx = spendingContext(txOutRef, datum) - .redeemer(redeemer) - .buildPlutusData(); - - var result = evaluate(program, ctx); - assertFailure(result); - } - - private static String loadResource(String path) throws IOException { - try (var is = Groth16BLS12381VerifierTest.class.getResourceAsStream(path)) { - if (is == null) throw new IOException("Resource not found: " + path); - return new String(is.readAllBytes(), StandardCharsets.UTF_8); - } - } -} 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 92% 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 d2e6bf5..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; @@ -15,6 +16,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.*; @@ -83,9 +86,7 @@ void circom_javaProve_onChainVerify() { 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( @@ -123,9 +124,7 @@ void circom_wrongWitness_onChainRejects() { 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 +169,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/groth16/validator/Groth16BLS12381FirstInputBindingVerifier.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381FirstInputBindingVerifier.java new file mode 100644 index 0000000..b46097d --- /dev/null +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381FirstInputBindingVerifier.java @@ -0,0 +1,41 @@ +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 Groth16BLS12381Lib. + */ +@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 = 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 89% 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 e5682c1..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; @@ -34,8 +35,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 Groth16BLS12381Verifier}; the same verifier also + * supports circuits with more or fewer public inputs.

* *

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

*/ @@ -47,7 +48,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(); @@ -82,9 +83,7 @@ void twoPublicInputs_pureJavaProve_onChainVerify() { 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 +123,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/groth16/validator/Groth16BLS12381VerifierTest.java b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381VerifierTest.java new file mode 100644 index 0000000..6ea3cce --- /dev/null +++ b/zeroj-onchain-julc/src/test/java/com/bloxbean/cardano/zeroj/onchain/julc/groth16/validator/Groth16BLS12381VerifierTest.java @@ -0,0 +1,260 @@ +package com.bloxbean.cardano.zeroj.onchain.julc.groth16.validator; + +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 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; + +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; + +/** + * Tests the arbitrary-public-input Groth16 BLS12-381 verifier in the Julc VM. + */ +class Groth16BLS12381VerifierTest 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"); + + assertVerification( + 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"); + + assertVerification( + 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); + + 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); + } + + @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 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()), + 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("[Groth16BLS12381Verifier] Budget consumed: " + result.budgetConsumed()); + } else { + assertFailure(result); + } + } + + private void assertFirstInputBindingVerification(BigInteger expectedFirstPublicInput, + boolean expectSuccess) { + var compiled = compileValidator(Groth16BLS12381FirstInputBindingVerifier.class, + Path.of("src/test/java")); + var program = compiled.program().applyParams( + 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(sealedBidProof.piA()), + PlutusData.bytes(sealedBidProof.piB()), + PlutusData.bytes(sealedBidProof.piC())); + + var txOutRef = TestDataBuilder.randomTxOutRef_typed(); + var ctx = spendingContext(txOutRef, datum) + .redeemer(redeemer) + .buildPlutusData(); + + var result = evaluate(program, ctx); + 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 { + try (var is = Groth16BLS12381VerifierTest.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/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;