From 4982a2f379aff78796062c9b5285d2ff9216b363 Mon Sep 17 00:00:00 2001 From: Satya Date: Sat, 18 Apr 2026 01:32:22 +0800 Subject: [PATCH 1/5] Add ADR-0015: standards-compatible Poseidon for BLS12-381 The BN254-over-BLS hybrid previously used by BLS12-381 Poseidon callers was non-interoperable with any published BLS12-381 Poseidon implementation, making third-party verification of ZeroJ-produced hashes impossible. ADR-0015 replaces the hybrid with paper-canonical BLS12-381 Poseidon (t=3, alpha=5, RF=8, RP=57) while preserving BN254 circomlib compatibility for existing callers. Changes: - PoseidonGrainLFSR: pure-Java port of the hadeshash Sage parameter generator, byte-verified against iden3/circomlibjs BN254 constants. - PoseidonParams record + codegen-produced BN254/BLS12-381 t=3 presets. - Poseidon / PoseidonN / SignalPoseidon gadgets parameterized on PoseidonParams; back-compat no-param overloads default to BN254. - PoseidonHash standalone BigInteger permutation for off-circuit use. - CircuitAPI.requireField + CircuitBuilder compile/witness-time guard: converts field-vs-curve mismatches from silent non-canonical-hash footguns into thrown exceptions. - PoseidonCacheVersion: SHA-256-derived marker that auto-wipes stale SRS/R1CS/Merkle caches when Poseidon parameters change. - SageMath reference implementation committed under src/test/resources/ poseidon-sage/ with Docker-pinned golden output. Test asserts Java fixtures byte-match the Sage-produced paper-spec reference. - PoseidonConstants marked @Deprecated; retained as BN254 facade. Evidence: 92 tests pass including LFSR byte-match vs circomlibjs, in-circuit == off-circuit self-consistency over 100 random inputs per preset, Sage golden-file match for Poseidon_BLS12_381(0,0)/(1,2)/(123,456), field-guard rejections, cache-version wipe behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/adr/0015-bls12-381-poseidon-constants.md | 194 +++++++++ .../cardano/zeroj/circuit/CircuitAPI.java | 19 + .../cardano/zeroj/circuit/CircuitAPIImpl.java | 16 +- .../cardano/zeroj/circuit/CircuitBuilder.java | 25 ++ .../zeroj/circuit/ConstraintGraph.java | 17 +- zeroj-circuit-lib/build.gradle | 19 + .../cardano/zeroj/circuit/lib/Poseidon.java | 113 ++++-- .../zeroj/circuit/lib/PoseidonConstants.java | 8 + .../cardano/zeroj/circuit/lib/PoseidonN.java | 69 ++-- .../zeroj/circuit/lib/SignalPoseidon.java | 16 +- .../lib/poseidon/PoseidonCacheVersion.java | 141 +++++++ .../lib/poseidon/PoseidonGrainLFSR.java | 315 +++++++++++++++ .../circuit/lib/poseidon/PoseidonHash.java | 118 ++++++ .../circuit/lib/poseidon/PoseidonParams.java | 160 ++++++++ .../poseidon/PoseidonParamsBLS12_381T3.java | 238 +++++++++++ .../lib/poseidon/PoseidonParamsBN254T3.java | 238 +++++++++++ .../lib/poseidon/PoseidonParamsCodegen.java | 152 +++++++ .../src/main/resources/poseidon/README.md | 101 +++++ .../poseidon/generate_parameters_grain.sage | 373 ++++++++++++++++++ .../poseidon/PoseidonCacheVersionTest.java | 99 +++++ .../PoseidonCrossVerificationTest.java | 294 ++++++++++++++ .../lib/poseidon/PoseidonGrainLFSRTest.java | 240 +++++++++++ .../PoseidonParameterizedGadgetTest.java | 251 ++++++++++++ .../lib/poseidon/PoseidonParamsTest.java | 136 +++++++ .../test/resources/poseidon-sage/README.md | 49 +++ .../poseidon_bls12_381_reference.sage | 140 +++++++ .../poseidon-sage/sage-reference-output.txt | 39 ++ 27 files changed, 3500 insertions(+), 80 deletions(-) create mode 100644 docs/adr/0015-bls12-381-poseidon-constants.md create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonCacheVersion.java create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonGrainLFSR.java create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonHash.java create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonParams.java create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonParamsBLS12_381T3.java create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonParamsBN254T3.java create mode 100644 zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonParamsCodegen.java create mode 100644 zeroj-circuit-lib/src/main/resources/poseidon/README.md create mode 100644 zeroj-circuit-lib/src/main/resources/poseidon/generate_parameters_grain.sage create mode 100644 zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonCacheVersionTest.java create mode 100644 zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonCrossVerificationTest.java create mode 100644 zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonGrainLFSRTest.java create mode 100644 zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonParameterizedGadgetTest.java create mode 100644 zeroj-circuit-lib/src/test/java/com/bloxbean/cardano/zeroj/circuit/lib/poseidon/PoseidonParamsTest.java create mode 100644 zeroj-circuit-lib/src/test/resources/poseidon-sage/README.md create mode 100644 zeroj-circuit-lib/src/test/resources/poseidon-sage/poseidon_bls12_381_reference.sage create mode 100644 zeroj-circuit-lib/src/test/resources/poseidon-sage/sage-reference-output.txt diff --git a/docs/adr/0015-bls12-381-poseidon-constants.md b/docs/adr/0015-bls12-381-poseidon-constants.md new file mode 100644 index 0000000..6bb7dfa --- /dev/null +++ b/docs/adr/0015-bls12-381-poseidon-constants.md @@ -0,0 +1,194 @@ +# ADR-0015: Standards-Compatible Poseidon Constants for BLS12-381 + +## Status +Accepted + +## Date +2026-04-17 + +## Context + +ZeroJ's circuit library ships a Poseidon hash gadget (`zeroj-circuit-lib/Poseidon.java`) that is field-agnostic at the gadget level — it uses `CircuitAPI.add` / `CircuitAPI.mul`, which reduce modulo whatever prime the `FieldConfig` provides. The host field is selected at `CircuitBuilder.compileR1CS(CurveId)` time and currently supports `BN254`, `BLS12_381`, and `PALLAS`. + +The **round constants** (`PoseidonConstants.C`) and **MDS matrix** (`PoseidonConstants.M`), however, are hardcoded from **iden3 / circomlibjs**, which were generated for the **BN254 scalar field**. When a circuit is compiled with `FieldConfig.BLS12_381`, those same constants are used — they happen to be valid BLS12-381 field elements (every BN254 constant is numerically less than the BN254 prime, which is less than the BLS12-381 prime), so arithmetic works and the off-circuit Java hash (`zeroj-usecases/*/PoseidonCompute.java`) matches the in-circuit gadget. + +This works end-to-end for ZeroJ-internal flows — `proof-of-reserves`, `digital-product-passport`, and `identity-kyc` all rely on it today. But the resulting hash function is **non-standard**: + +- Constants are not derived from the BLS12-381 scalar field parameters. +- Output values differ from every published BLS12-381 Poseidon reference. +- No external library can independently compute the same hash. + +### Interoperability problem + +The Jubjub ecosystem (Jubjub is the BLS12-381 sibling of BabyJubJub — a twisted Edwards curve embedded in the BLS12-381 scalar field) standardizes on **proper, field-derived BLS12-381 Poseidon**. Published references include: + +| Reference | Use | +|-----------|-----| +| Arkworks `ark-crypto-primitives::sponge::poseidon` | Widely used Rust ZK stack; reference for many circuits | +| zkcrypto / neptune (Filecoin, Lurk) | Merkle-tree-optimized Poseidon | +| Dusk Network `poseidon252` | Poseidon-based BLS12-381 stack | + +All of these use round constants generated by the **Grain LFSR procedure** specified in the Poseidon paper (Grassi et al., §5.1), which deterministically derives `(C, M)` from `(prime p, t, α, RF, RP)`. Given the same parameters, every correct implementation produces identical constants — so constants *can* be ported by extraction, or regenerated from the spec. + +Without standards-compatible Poseidon, ZeroJ cannot: + +1. Interoperate with external Jubjub circuit libraries (e.g., in-circuit EdDSA-over-Jubjub, Pedersen-over-Jubjub gadgets ported from arkworks / circom-jubjub / zcash). +2. Accept Poseidon-based commitments produced by external tooling (e.g., off-chain indexers, wallet SDKs, third-party identity issuers). +3. Publish Poseidon hashes that downstream consumers can independently re-verify. + +### Why this ADR now + +ADR-0014 (W3C Verifiable Credentials) and the upcoming Jubjub-in-circuit work (needed for standards-compatible in-circuit EdDSA, Pedersen commitments, and Merkle trees over Jubjub) both assume a BLS12-381-native Poseidon. Fixing constants is a **prerequisite** — the Jubjub gadgets hash into the BLS12-381 scalar field, and those hashes must be reproducible by external verifiers. + +### Current state + +| Component | Field | Constants source | Standards-compatible? | +|-----------|-------|------------------|----------------------| +| `PoseidonConstants.java` | BN254-derived | iden3/circomlibjs | Yes (for BN254 / circom) | +| `Poseidon.hash()` gadget | Whatever `FieldConfig` selects | Reads `PoseidonConstants` unconditionally | No, if field ≠ BN254 | +| `PoseidonCompute.java` (usecases) | BLS12-381 | Reflectively loads `PoseidonConstants` | No | + +## Decision + +### 1. Introduce a parameterized Poseidon configuration + +Replace the singleton `PoseidonConstants` with a `PoseidonParams` value type keyed on `(field, t, α, RF, RP)`: + +```java +public record PoseidonParams( + FieldConfig field, + int t, // state width (e.g., 3 for 2-to-1 hash) + int alpha, // S-box exponent (5) + int rf, // full rounds (8) + int rp, // partial rounds (depends on t and field) + BigInteger[] c, // round constants, length = (rf + rp) * t + BigInteger[] m // MDS matrix, length = t * t +) {} +``` + +Provide named presets: + +- `PoseidonParams.BN254_T3` — existing circomlib constants (unchanged, preserves BN254 / circom interop). +- `PoseidonParams.BLS12_381_T3` — **new**, standards-compatible (paper-spec, `α=5, RF=8, RP=57`). + +`BLS12_381_T5` and other widths (`t=5` for 4-ary Merkle, `t=9` for batch hashing) are **deferred to ADR-0016 (Jubjub-in-circuit)**, where they are a concrete requirement. The generator built here makes those additions a one-line preset extension when needed. + +### 2. Generate BLS12-381 constants via Grain LFSR (not hand-ported) + +Implement `PoseidonGrainLFSR` — a Java port of the Grain LFSR procedure from the Poseidon paper (§5.1, also `poseidon_hash/generate_parameters_grain.sage` from the original repo). Inputs: `(field_bytes, prime, t, α, RF, RP)`. Outputs: round constants and MDS matrix. + +Benefits over hand-porting from a single reference: + +- Any future `(p, t, α, ...)` combination is free — e.g., `t=5` for 4-ary Merkle, `t=9` for batch hashing. +- Reproducibility is self-evident; no "why these numbers" opacity. +- Cross-verification is straightforward: run generator, compare output byte-for-byte with arkworks' and zkcrypto's published constants. + +The generator runs **at build time** (Gradle task) and caches constants as Java source (same pattern as current `PoseidonConstants.java`) — no runtime generation, no reflection, GraalVM-native-image friendly. + +### 3. Anchor correctness on the Poseidon paper spec, not a specific implementation + +The canonical reference is the **original Poseidon paper (Grassi et al., 2021)** and its published Sage generator (`generate_parameters_grain.sage` from the IAIK `hadeshash` repository). Arkworks, zkcrypto, and Dusk are all *implementations* of this spec; conforming implementations produce identical `(C, M)` for the same `(p, t, α, RF, RP)`. + +Target parameters for v1: +- Field: **BLS12-381 scalar** +- `t = 3` (state width — two-to-one hash) +- `α = 5` (S-box exponent) +- `RF = 8` full rounds +- `RP = 57` partial rounds + +Implementation approach: +- Pin the **`hadeshash` commit** of `generate_parameters_grain.sage` as the authoritative generator. This is the source of truth. +- Port the LFSR logic to Java (see §2 above). +- Use **arkworks `ark-crypto-primitives`** and **zkcrypto reference** as cross-check oracles only. ZeroJ's Java-generated constants must match both byte-for-byte before merge. +- If any implementation disagrees with the paper's Sage script, the script wins — we file bugs upstream rather than match a divergent impl. + +This framing is resistant to "arkworks changed their default" and grounds correctness in the paper, not a moving downstream target. + +### 4. Delete the BN254-over-BLS hybrid; no legacy preset + +The current behavior — where BN254-derived `PoseidonConstants` are used unchanged over the BLS12-381 scalar field — has **no external conformance** and is **not yet released**. It is deleted outright. No `BLS12_381_T3_LEGACY` preset is introduced. + +Rationale: +- ZeroJ has not shipped a stable release; no downstream consumer depends on the hybrid's outputs. +- None of the usecases persist Poseidon hashes across runs — they recompute state each execution. Verified at ADR authoring: `proof-of-reserves`, `digital-product-passport`, and `identity-kyc` demos rebuild trees / commitments fresh. +- Keeping a legacy preset only invites accidental selection and multiplies the test surface. + +### 5. Migrate circuit-lib gadget and usecases + +- `Poseidon.hash(api, a, b)` → new overload `Poseidon.hash(api, params, a, b)`. +- **Legacy no-params overload delegates to `PoseidonParamsBN254T3.INSTANCE`** (not the compile-curve-derived preset as originally proposed). Reason: the gadget's `define` callback runs before `CircuitBuilder.compileR1CS(CurveId)` is called, so the curve is not available at gadget-define time. Auto-selection by `FieldConfig` would require plumbing the expected field through `CircuitBuilder` so the gadget can read it during `define` — a larger change deferred to a follow-up. BN254 is the safest back-compat default (circom-compatible, matches the pre-parameterization behavior exactly). +- **Footgun acknowledged**: a caller can pass `PoseidonParamsBLS12_381T3.INSTANCE` and then compile with `CurveId.BN254` (or vice versa). The result is a syntactically valid witness with a non-canonical hash. No runtime error is raised. Mitigation today is Javadoc-level; proper fix is the CircuitBuilder-field-threading follow-up above. +- `PoseidonN` likewise parameterized. +- `PoseidonCompute.java` in each usecase moves out of reflection into direct `PoseidonParams` usage. +- `PoseidonConstants` retained as a thin facade that exposes `BN254_T3` constants, marked `@Deprecated` with pointer to `PoseidonParams.BN254_T3`. + +### 6. Cross-verification test suite + +New module `zeroj-circuit-lib/src/test/.../PoseidonCrossVerificationTest.java`: + +- **Grain LFSR regeneration**: regenerate constants at test time from the pinned Sage-script logic, compare to committed constants byte-for-byte. +- **Paper-spec cross-check** ✓ executed: `zeroj-circuit-lib/src/test/resources/poseidon-sage/` contains an independent SageMath reference (Grain LFSR + Poseidon permutation per the paper spec) plus a pinned golden output file. Running it against `sagemath/sagemath:latest` in Docker produced `Poseidon_BLS12_381(0,0)`, `(1,2)`, `(123,456)` that byte-match ZeroJ's Java output for all three fixtures. `PoseidonCrossVerificationTest.java_matches_sageReferenceOutput` asserts this at every build. +- **Implementation cross-check**: hardcode known `Poseidon(a, b) = h` triples from **arkworks** and **zkcrypto** published test vectors; verify both the off-circuit Java and the in-circuit witness produce `h`. Both implementations must agree; if they diverge, fall back to the paper spec as arbiter. +- **Circomlibjs vectors**: retain existing BN254 test vectors for `BN254_T3` preset (no change). +- **Self-consistency**: for each preset, assert in-circuit gadget output matches off-circuit `PoseidonCompute` output across 100 random inputs. + +## Consequences + +### Easier +- Jubjub-in-circuit work (upcoming) can assume standards-compatible Poseidon — no custom fork needed. +- External tools (indexers, wallets, other circuits) can independently compute and verify ZeroJ-produced Poseidon hashes. +- W3C VC / BBS+ adjacent work (ADR-0014) gets a spec-aligned primitive. +- Adding new Poseidon widths (t=5, t=9) becomes a one-line preset addition. +- BN254 / circom interop path remains intact — explicit and named. + +### Harder / more work +- One-time migration: the three usecases (`proof-of-reserves`, `digital-product-passport`, `identity-kyc`) recompute any persisted Poseidon commitments. Current demos compute fresh each run — no stored state needs migration — but this must be verified before the switch. +- Test vectors in existing tests that assert specific hash outputs will change for BLS12-381 circuits; need regeneration from new constants. +- Build system gains a constant-generation step (kept deterministic and cached, but adds a dependency). + +### Neutral +- Performance unchanged — same round count, same S-box, same MDS dimensions. +- Code size slightly larger (two constant sets vs. one). + +### Migration note for existing installs +Any pre-ADR-0015 demo install that has **persisted hash-dependent state** must wipe and rebuild it on upgrade. Specifically: + +- `zeroj-usecases/digital-product-passport/data/dpp-trie*` (RocksDB MPF with Poseidon leaves) +- `zeroj-usecases/digital-product-passport/data/dpp-db*` (H2 DB with MPF root columns) +- `zeroj-usecases/digital-product-passport/data/setup-*.bin` and `srs.bin` (R1CS + SRS caches — circuit constants differ post-migration) +- Equivalent `./data/*` caches in other usecases + +There is no automatic migration path; pre-ADR hashes are the BN254-over-BLS hybrid and are not recoverable from the new standards-compatible Poseidon. Demos recompute state from seed data on startup, so wiping is safe. Any production system would need a one-time rehash pass; none exists today. + +## Risks + +1. **Grain LFSR porting error** — subtle endianness / bit-ordering mistakes in the LFSR can produce *almost* correct constants that drift from the spec. Mitigation: byte-level equality against output of the pinned `hadeshash` Sage script is the primary merge gate; arkworks + zkcrypto act as secondary oracles. If we can't reproduce the spec output exactly, we don't ship. + +2. **Parameter divergence between implementations** — arkworks, zkcrypto, and Dusk may choose different `(RF, RP)` or security-margin heuristics for the same `t`. Decision: follow the **Poseidon v1 paper's recommended parameters** for our target security level (128-bit), not any one downstream impl. If a user needs a specific library's variant (e.g., Dusk's `poseidon252`), add a clearly named preset (`BLS12_381_T3_DUSK`) rather than bending defaults. + +3. **Paper parameter interpretation** — the paper offers parameter *tables*; the reference Sage generator makes specific choices (e.g., exact RP for a given security level). Decision: follow the Sage generator's choices, not our own reading of the table. This removes ambiguity. + +4. **GraalVM native-image compatibility** — must keep constants as hardcoded `BigInteger` arrays in generated Java source, not loaded from resource files or computed at class init. Current approach already complies; the Gradle code-gen task must preserve this. + +5. **Accidental reflection reintroduction** — `PoseidonCompute.java` in usecases currently uses reflection to peek at `PoseidonConstants`. Migration must remove reflection entirely; otherwise we've just renamed the problem. + +6. **On-chain input compatibility** — Poseidon outputs appear as **public inputs** to Cardano-verified Groth16 proofs. Any party independently reconstructing a public input (e.g., recomputing a Merkle root from chain data) must reproduce the exact hash. Standards-compatible Poseidon is therefore not merely an interop nicety but a **correctness requirement for third-party verifiability** of ZeroJ proofs on Cardano. + +## Resolved questions + +All prior open questions resolved: + +- **Reference anchor**: pin the Poseidon v1 paper + `hadeshash` Sage script as the spec. Arkworks / zkcrypto are cross-check oracles, not the source of truth. +- **Widths in v1**: ship `t=3` only. Additional widths (`t=5`, others) deferred to ADR-0016 (Jubjub-in-circuit), where they are a concrete requirement driven by 4-ary Merkle and similar structures. +- **Legacy BN254-over-BLS variant**: delete outright. ZeroJ is pre-release, no persisted hashes depend on the hybrid, no legacy preset is retained. + +## Scope (implementation plan) + +1. **Grain LFSR generator** (`zeroj-circuit-lib`, ~200 LoC + tests) — 3 days. +2. **Gradle code-gen task** — generate `PoseidonParams.BLS12_381_T3` Java source at build time; commit the generated file for IDE ergonomics. 1 day. +3. **Parameterize `Poseidon`, `PoseidonN` gadgets** — 1 day. +4. **Cross-verification tests** (paper-spec Sage output + arkworks + zkcrypto vectors + self-consistency) — 2 days. +5. **Delete BN254-over-BLS hybrid path; migrate three usecases off reflection onto `PoseidonParams`** — 1 day. +6. **Documentation**: update `zeroj-circuit-lib/README.md`, add entry in `docs/circuit-primitives.md` — 0.5 day. + +**Total: ~8 working days**, single contributor. Sequenced before Jubjub-in-circuit work (ADR-0016), which depends on `PoseidonParams.BLS12_381_T3` and adds further widths (`t=5`, etc.) driven by concrete Jubjub Merkle / commitment requirements. \ No newline at end of file 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 948322d..b45ae23 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 @@ -97,4 +97,23 @@ public interface CircuitAPI { /** Look up a declared variable by name. */ Variable var(String name); + + // --- Field expectation (checked at compile/witness time) --- + + /** + * Declare that this circuit depends on constants tied to a specific scalar + * field (e.g. Poseidon round constants). Calling this from a gadget records + * the dependency on the circuit graph. At {@code compileR1CS(curve)} / + * {@code calculateWitness(..., curve)} time, if the compile curve's field + * differs from the recorded expectation, compilation throws. + * + *

Calling multiple times with the same {@code field} is fine; with + * conflicting fields within one circuit, throws immediately at define time. + * + *

Default implementation is a no-op — legacy gadgets that do not + * depend on field-specific constants need not implement it. + */ + default void requireField(FieldConfig field) { + // no-op by default + } } 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 b4a7866..af7d9c2 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 @@ -21,6 +21,7 @@ class CircuitAPIImpl implements CircuitAPI { private final List intermediateVars = new ArrayList<>(); private final Variable oneWire; private int nextId; + private FieldConfig expectedField; CircuitAPIImpl(List publicVarNames, List secretVarNames) { // Wire 0 = constant "1" @@ -51,7 +52,20 @@ private Variable newIntermediate() { ConstraintGraph buildGraph(String name) { return new ConstraintGraph(name, gates, oneWire, publicInputs, secretInputs, - intermediateVars, nextId); + intermediateVars, nextId, expectedField); + } + + @Override + public void requireField(FieldConfig field) { + java.util.Objects.requireNonNull(field, "field"); + if (expectedField == null) { + expectedField = field; + } else if (!expectedField.equals(field)) { + throw new IllegalStateException( + "Conflicting field expectations within one circuit: " + + expectedField.name() + " vs " + field.name() + + ". A circuit may only depend on constants for a single scalar field."); + } } // --- Core primitives --- diff --git a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitBuilder.java b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitBuilder.java index 187883c..c6bb0b6 100644 --- a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitBuilder.java +++ b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/CircuitBuilder.java @@ -98,28 +98,53 @@ public ConstraintGraph constraintGraph() { /** Compile to R1CS constraint system for Groth16. */ public R1CSConstraintSystem compileR1CS(CurveId curve) { requireDefined(); + checkExpectedField(curve); return R1CSCompiler.compile(graph, FieldConfig.forCurve(curve)); } /** Compile to PlonK constraint system. */ public PlonKConstraintSystem compilePlonK(CurveId curve) { requireDefined(); + checkExpectedField(curve); return PlonKCompiler.compile(graph, FieldConfig.forCurve(curve)); } /** Compile to Halo2 PLONKish circuit system. */ public Halo2CircuitSystem compileHalo2(CurveId curve) { requireDefined(); + checkExpectedField(curve); return Halo2Compiler.compile(graph, FieldConfig.forCurve(curve)); } /** Calculate witness for given inputs. */ public BigInteger[] calculateWitness(Map> inputs, CurveId curve) { requireDefined(); + checkExpectedField(curve); return WitnessCalculator.calculate(graph, inputs, FieldConfig.forCurve(curve)); } private void requireDefined() { if (graph == null) throw new IllegalStateException("Circuit not defined yet. Call define() first."); } + + /** + * If a gadget called {@link CircuitAPI#requireField} during {@code define()}, + * assert the compile curve's field matches that expectation. Prevents + * silently producing non-canonical outputs when, e.g., Poseidon params for + * BLS12-381 are paired with a BN254 compile curve. + */ + private void checkExpectedField(CurveId curve) { + FieldConfig expected = graph.expectedField(); + if (expected == null) return; + FieldConfig actual = FieldConfig.forCurve(curve); + if (!expected.equals(actual)) { + throw new IllegalStateException( + "Field mismatch: circuit declared expected field " + expected.name() + + " (via requireField) but compilation / witness calculation " + + "was requested for " + curve + " (" + actual.name() + "). " + + "Typical cause: a gadget was given PoseidonParams for a field " + + "that does not match the target curve. Either pass matching " + + "params or compile for the matching curve."); + } + } } diff --git a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/ConstraintGraph.java b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/ConstraintGraph.java index c362b38..d3ba463 100644 --- a/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/ConstraintGraph.java +++ b/zeroj-circuit-dsl/src/main/java/com/bloxbean/cardano/zeroj/circuit/ConstraintGraph.java @@ -16,6 +16,13 @@ *

  • Wires nPub+1..nPub+nSec: secret input variables
  • *
  • Remaining wires: intermediate variables
  • * + * + *

    {@code expectedField} (nullable): if set, any attempt to compile or + * calculate a witness for a curve whose field differs from this value + * throws. Gadgets that depend on field-specific constants (e.g. Poseidon) + * use {@link CircuitAPI#requireField} during {@code define()} to record + * this expectation and catch field-vs-curve mismatches at compile time + * rather than silently producing non-canonical outputs. */ public record ConstraintGraph( String name, @@ -24,7 +31,8 @@ public record ConstraintGraph( List publicInputs, List secretInputs, List intermediateVars, - int numWires + int numWires, + FieldConfig expectedField ) { public ConstraintGraph { gates = List.copyOf(gates); @@ -33,6 +41,13 @@ public record ConstraintGraph( intermediateVars = List.copyOf(intermediateVars); } + /** Convenience overload for callers that don't set an expected field. */ + public ConstraintGraph(String name, List gates, Variable oneWire, + List publicInputs, List secretInputs, + List intermediateVars, int numWires) { + this(name, gates, oneWire, publicInputs, secretInputs, intermediateVars, numWires, null); + } + /** Total number of input signals (public + secret). */ public int numInputs() { return publicInputs.size() + secretInputs.size(); } diff --git a/zeroj-circuit-lib/build.gradle b/zeroj-circuit-lib/build.gradle index ddaa232..eed460b 100644 --- a/zeroj-circuit-lib/build.gradle +++ b/zeroj-circuit-lib/build.gradle @@ -10,6 +10,25 @@ dependencies { testImplementation project(':zeroj-test-vectors') } +/** + * Regenerates the committed Poseidon parameter source files + * (PoseidonParamsBN254T3.java, PoseidonParamsBLS12_381T3.java) from the + * Grain LFSR reference implementation in PoseidonGrainLFSR. Output is + * written directly into src/main/java so the constants are version-controlled + * and reviewable. + * + * Usage: ./gradlew :zeroj-circuit-lib:generatePoseidonParams + * + * See docs/adr/0015-bls12-381-poseidon-constants.md for the rationale. + */ +tasks.register('generatePoseidonParams', JavaExec) { + group = 'codegen' + description = 'Regenerate Poseidon parameter presets from the Grain LFSR.' + mainClass = 'com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsCodegen' + classpath = sourceSets.main.runtimeClasspath + args projectDir.absolutePath +} + publishing { publications { mavenJava(MavenPublication) { diff --git a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/Poseidon.java b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/Poseidon.java index 35f3044..cad356f 100644 --- a/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/Poseidon.java +++ b/zeroj-circuit-lib/src/main/java/com/bloxbean/cardano/zeroj/circuit/lib/Poseidon.java @@ -4,24 +4,37 @@ import com.bloxbean.cardano.zeroj.circuit.Signal; import com.bloxbean.cardano.zeroj.circuit.SignalBuilder; import com.bloxbean.cardano.zeroj.circuit.Variable; - -import java.math.BigInteger; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParams; +import com.bloxbean.cardano.zeroj.circuit.lib.poseidon.PoseidonParamsBN254T3; /** * Poseidon hash function circuit — the standard ZK-friendly hash used in circom. * - *

    Parameters for BN254 (matching circomlib exactly): + *

    Structurally supports {@code t=3, α=5, RF=8, RP=57}. Round constants and + * MDS matrix come from a {@link PoseidonParams} instance — pick the preset + * matching the scalar field you will compile the R1CS for: *

    * - *

    Approximately 330 constraints for 2 inputs.

    + *

    The no-params overload defaults to {@link PoseidonParamsBN254T3#INSTANCE} + * for back-compat with circuits that pre-date parameterization. Callers + * targeting BLS12-381 must pass {@code PoseidonParamsBLS12_381T3.INSTANCE} + * explicitly — the no-params default does not auto-select by compile curve + * because the gadget is defined before the curve is known to the circuit. + * + *

    The gadget calls {@link CircuitAPI#requireField} with the preset's field + * during {@code define()}. If you subsequently compile or calculate a witness + * for a curve whose field differs (e.g. {@code BLS12_381_T3} params with + * {@code CurveId.BN254}), {@link com.bloxbean.cardano.zeroj.circuit.CircuitBuilder} + * throws at compile/witness time — this replaces what used to be a silent + * non-canonical output. * - *

    Test vectors (verified against circomlibjs): + *

    Approximately 330 constraints for 2 inputs. + * + *

    Test vectors for the BN254 default (from circomlibjs): *