diff --git a/.gitignore b/.gitignore index 2882d57c..1cb8b8d1 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,7 @@ Thumbs.db ### Development planning (not part of the source) ### plan.md +!/bindings/circuit/PLAN.md /.playwright-mcp/ ### Durable local benchmark datasets (not removed by Gradle clean) ### diff --git a/bindings/README.md b/bindings/README.md index 1f0842aa..9c1df2b9 100644 --- a/bindings/README.md +++ b/bindings/README.md @@ -4,11 +4,22 @@ Experimental Java-to-Wasm bindings under [ADR-0057](../docs/adr/0057-webassembly implemented in PR #92. See the [measured report](REPORT.md), [progress ledger](PROGRESS.md), [reviews](reviews/W4-W5.md) and [distribution status](DISTRIBUTION.md). +The separate circuit-compiler stack adds [typed TS authoring and portable graph import](circuit/README.md) +under [ADR-0064](../docs/adr/0064-portable-circuits-and-typescript-frontend.md). +Its [plan](circuit/PLAN.md) tracks completed slices and remaining gates. +[Measured Merkle support](circuit/M3-MERKLE.md) includes depth-1/2 browser proofs and a +[local Cardano claim](cardano/README.md); larger trees remain compile/witness-only in the default profile. + The adapters reuse ZeroJ's Java implementation. Typed ESM packages expose `Uint8Array`, `bigint`, Promises and worker-local handles. Browsers use Web Workers; Node uses worker threads. Importing an API or bundle descriptor does not fetch Wasm. An application explicitly selects its images. No JVM is needed at runtime. These images require a JavaScript host and are not generic WASI modules. +The opt-in [larger-Merkle experiment](circuit/M3-LARGE-MERKLE.md) also measures depths 8/16/32 +in Node, Chromium, Firefox and the JVM at 6g/8g. It uses a separate research image outside npm +packages; normal SDK limits are unchanged. Key import takes minutes, so these results establish +desktop research feasibility rather than supported large-circuit browser performance. + ## Build and try it Prerequisites: Java 25; **Oracle GraalVM 25.3.4.1+1.1**, **Binaryen 132** on PATH, Python 3 and @@ -26,9 +37,9 @@ cd ../.. python3 bindings/wasm/check.py ``` -This opt-in check builds eight images (six consumer choices and two private entropy probes), +This opt-in check builds nine images (seven consumer choices and two private entropy probes), generates development-only proof fixtures, runs adapter and SDK/browser tests, and installs -15 local npm tarballs outside the workspace. It never publishes packages. The default Java +local npm tarballs outside the workspace, including a separate compiler consumer. It never publishes packages. The default Java build/BOM/publication does not depend on Node, Binaryen or Web Image. After the check, from `bindings/js`: @@ -79,8 +90,11 @@ To rerun the affected core regressions, see [REPORT.md](REPORT.md). | `@zeroj/elgamal` | `createElGamal` | `elgamal` / `bundle-confidential` | | `@zeroj/threshold` | `createThreshold` | `threshold` / `bundle-confidential` | | `@zeroj/confidential-notes` | `createConfidentialNotes` | `confidential-notes` / `bundle-confidential` | +| `@zeroj/circuit` | `defineCircuit`, `createCircuitCompiler` | `circuit` / `bundle-circuit`; selected experimental graph/recipe compiler | +| `@zeroj/circuit-lint` | `zeroj-circuit-lint` CLI | Type-aware authoring checks; no Wasm | -`@zeroj/bundle-all` is optional: it supplies every capability through one image. Do not register +`@zeroj/bundle-all` is optional: it combines the original cryptographic capabilities. The new +compiler uses the separately selected `bundle-circuit`, which also supplies verification. Do not register it together with a second provider for the same capability. Several wrappers can share a single runtime/image; they do not dynamically link Java heaps across images. Default package imports never select or download all images for you. The two probe packages are test-only. @@ -122,12 +136,15 @@ Fr profile (t=3, alpha=5, RF=8, RP=57), rejects non-canonical scalars and requir secret-path opt-in because its Java arithmetic is variable-time. None of these ordinary calls builds ZK constraints. SHA-2/Poseidon circuit gadgets remain Java -circuit components. The shipped prover demonstrates `y = x³ + x + 5` with private x. Custom +circuit components. The standalone prover package demonstrates `y = x³ + x + 5` with private x. Custom small circuits implement [CompiledCircuit](wasm/adapter-groth16/src/main/java/org/zeroj/bindings/groth16/CompiledCircuit.java), register in a Java entry point, and compile into an explicit image. Import a ceremony key pinned by trusted hash and exact fingerprint; do not implement setup in the consumer image. The existing Java witness/circuit APIs can be used by a custom adapter. The current typed convenience method -is `proveCubic`; the transport supports only the compiled adapter's allowlisted input contract. +is `proveCubic`; that transport supports only the compiled adapter's allowlisted input contract. +The separate `@zeroj/circuit` / `bundle-circuit` path also accepts authenticated portable graphs +and typed recipes at runtime, including selected Poseidon/Merkle/SHA-256 gadgets. See the +[circuit guide](circuit/README.md) for measured limits and artifact authentication. ## Deployment and bundlers @@ -178,7 +195,8 @@ shows the policy without enabling general JavaScript `unsafe-eval`. | Boundary | Initial limit | |---|---| | Request JSON | 1,048,576 characters; depth 24; duplicate fields/trailing tokens rejected | -| Byte array / proving key | 262,144 bytes | +| Direct byte array / standalone proving key | 262,144 bytes | +| Compiler proving key (`circuit-key-v1`) | 2 MiB total, 64 KiB chunks, one upload / one successful key per compiled circuit | | Key import allocations | <=4,096 wires, <=4,096 domain cells, <=16,384 coefficients; compiled relation must also match | | Groth16 public inputs | 64 | | BBS messages | 64, each <=4,096 bytes | diff --git a/bindings/cardano/README.md b/bindings/cardano/README.md new file mode 100644 index 00000000..201ac4fc --- /dev/null +++ b/bindings/cardano/README.md @@ -0,0 +1,80 @@ +# Browser membership proof → local Cardano claim + +An opt-in, disposable Yaci DevKit example for [ADR-0064](../../docs/adr/0064-portable-circuits-and-typescript-frontend.md). +The browser compiles a TS-authored Merkle relation with the existing Java gadget and generates +a Groth16 BLS12-381 proof. This JVM runner uses ZeroJ's existing checked Cardano point codec +and JuLC Groth16 library to submit that exact Chromium proof. It implements no new cryptography. + +The [M3 report](../circuit/M3-MERKLE.md) records costs, limits and security boundaries. +This is a two-leaf tree with published test inputs and disposable setup, **not a production allowlist**. +The runner creates an ephemeral signing account in memory and requests 100 local test ADA. +It writes public evidence only under ignored `bindings/cardano/build/evidence`. + +## Prerequisites and run + +1. Run `GRAALVM_HOME=... python3 bindings/wasm/circuit-check.py` from the repository root. + This creates the matching development key/VK, a fresh `chromium-proof.json` and an independently + verified valid `unapproved-root-proof.json`. No browser wallet or upload endpoint is needed. +2. Run a **local** Yaci DevKit with protocol major 11, store API at `http://localhost:8080/api/v1/`, + faucet at `http://localhost:10000/local-cluster/api/addresses/topup`, testnet magic 42, and + node container `node1-yaci-cli-1`. The tested node socket is + `/clusters/nodes/default/node/node.sock`. Other node/socket profiles are not silently inferred. +3. Install the pinned JuLC experimental dependencies into Maven Local. This isolated runner uses + `0.1.0-pre20-c3a31c2-SNAPSHOT`, source commit + `c3a31c2a1a74b5de8d3f604f17b597c76395b664` of `https://github.com/bloxbean/julc`. + This commit-labelled snapshot was not available in the public repositories during validation. + In a separate clean checkout of that commit, with Java 25, run `./gradlew publishToMavenLocal`. + The build derives the snapshot suffix from the checked-out commit. Do not publish remotely. +4. From this ZeroJ repository root: + +```sh +export JAVA_HOME="$GRAALVM_HOME" +./gradlew -p bindings/cardano run --args=--local-devnet --max-workers=2 --console=plain +``` + +The composite build uses the current ZeroJ source for `zeroj-onchain-julc` and its checked codec. +The runner alone pins JuLC pre20 / Cardano Client Lib 0.8.0-pre7; it does not change the root +project's dependency profile or claim that the root profile was tested against this node. +The JVM evaluator can use native support for ledger builtins; browser proving remains pure Java +compiled to Wasm, with WebCrypto-backed entropy and no native blst dependency. + +`YACI_STORE_URL` and `YACI_FAUCET_URL` may select another loopback port/path; non-loopback URLs, +userinfo, query strings and fragments reject. `CARDANO_CONTAINER` selects the local Docker +container. The CLI flag is deliberately required, and this example is not a mainnet/preprod runner. +The browser proof/VK/manifest are trusted local development artifacts; this harness is not a +service for admitting arbitrary uploaded cryptographic files. Never reuse its development keys +as production ceremony artifacts. + +## What the validator actually authorizes + +The script parameters pin the approved root, verification key and one payment-key hash. +The inline datum must contain exactly that one root. The redeemer carries action `1` and the +compressed proof. The validator requires the pinned key in transaction signatories, the exact +action, the approved root, and a valid proof against the pinned VK. Ledger signature validation +then authorizes the transaction's contents. The authorized signer chooses the recipient/output; +this is not an issuer-restricted payout or a private state transition. + +Membership is reusable. A published proof can be reused for other deposits protected by the +same root/signer policy; it is not tied to a nullifier or a unique user. The signer is public. +Cardano prevents consuming **the same UTxO** twice; that does not provide global one-use +membership or anonymous spending. The test values (leaf 42, sibling 100, direction true) are +public fixtures, so there is no privacy claim about this particular witness. + +## Checks and evidence + +- Independent snarkjs and JVM checks cover fresh browser proofs before this runner is used. +- The valid signed claim is evaluated by JuLC Java, the remote evaluator, and Haskell + `cardano-cli calculate-plutus-script-cost`; their execution budgets must be identical. +- Signed submissions with a valid proof for an **unapproved root**, wrong action, missing required + signer, and negated proof A must all fail phase 2 with `ValidationTagMismatch`. +- The valid transaction must be submitted and confirmed in the store. +- A different transaction ID attempting to consume the same UTxO is constructed before that + confirmation and submitted afterwards. It must fail for spent/missing inputs. The observed + node error was `ConwayMempoolFailure: All inputs are spent`; `BadInputsUTxO` is also admitted. + Resubmitting identical bytes alone would not be a useful replay test. + +The runner records live protocol parameters, script/transaction sizes, consumed and declared +execution units, execution/total fees, artifact hashes, transaction hashes and all verdicts. +Fees exclude the 5 ADA locked output, change and returned collateral; these are values moved +through the example, not verification fees. The valid claim fee and separate funding/locking +fee are reported independently. Raw transaction/vector/log files remain untracked. diff --git a/bindings/cardano/build.gradle b/bindings/cardano/build.gradle new file mode 100644 index 00000000..f785502f --- /dev/null +++ b/bindings/cardano/build.gradle @@ -0,0 +1,25 @@ +plugins { id 'java'; id 'application' } +repositories { + // Commit-pinned JuLC experiment is installed from source; not a public release. + mavenLocal { content { includeGroup "org.julclang" } } + mavenCentral() + maven { url 'https://central.sonatype.com/repository/maven-snapshots' } + maven { url 'https://repo.bloxbean.org/maven/snapshots' } +} +java { toolchain { languageVersion = JavaLanguageVersion.of(25) } } +// Isolated experimental runner: no change to the core module's pinned compiler profile. +def julc = '0.1.0-pre20-c3a31c2-SNAPSHOT' +dependencies { + implementation 'org.zeroj:zeroj-onchain-julc:0.1.0' + implementation "org.julclang:julc-compiler:${julc}" + implementation "org.julclang:julc-cardano-client-lib:${julc}" + implementation "org.julclang:julc-vm-java:${julc}" + implementation 'com.bloxbean.cardano:cardano-client-lib:0.8.0-pre7' + implementation 'com.bloxbean.cardano:cardano-client-backend-blockfrost:0.8.0-pre7' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.3' +} +application { mainClass = 'org.zeroj.bindings.cardano.AllowlistDevnet' } +tasks.named('run') { + workingDir rootProject.file('../..') + maxHeapSize = '2g' +} diff --git a/bindings/cardano/settings.gradle b/bindings/cardano/settings.gradle new file mode 100644 index 00000000..64ce98cc --- /dev/null +++ b/bindings/cardano/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'zeroj-bindings-cardano-example' +includeBuild('../..') diff --git a/bindings/cardano/src/main/java/org/zeroj/bindings/cardano/AllowlistDevnet.java b/bindings/cardano/src/main/java/org/zeroj/bindings/cardano/AllowlistDevnet.java new file mode 100644 index 00000000..5908814b --- /dev/null +++ b/bindings/cardano/src/main/java/org/zeroj/bindings/cardano/AllowlistDevnet.java @@ -0,0 +1,195 @@ +package org.zeroj.bindings.cardano; + +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.address.AddressProvider; +import com.bloxbean.cardano.client.api.model.Amount; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.backend.api.DefaultProtocolParamsSupplier; +import com.bloxbean.cardano.client.backend.api.DefaultScriptSupplier; +import com.bloxbean.cardano.client.backend.api.DefaultUtxoSupplier; +import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.client.function.helper.SignerProviders; +import com.bloxbean.cardano.client.plutus.spec.BigIntPlutusData; +import com.bloxbean.cardano.client.plutus.spec.BytesPlutusData; +import com.bloxbean.cardano.client.plutus.spec.ConstrPlutusData; +import com.bloxbean.cardano.client.plutus.spec.ListPlutusData; +import com.bloxbean.cardano.client.plutus.spec.PlutusV3Script; +import com.bloxbean.cardano.client.quicktx.QuickTxBuilder; +import com.bloxbean.cardano.client.quicktx.ScriptTx; +import com.bloxbean.cardano.client.quicktx.Tx; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.julclang.clientlib.JulcScriptAdapter; +import org.julclang.clientlib.eval.JulcTransactionEvaluator; +import org.julclang.compiler.JulcCompiler; +import org.julclang.core.PlutusData; +import org.julclang.core.flat.UplcFlatEncoder; +import org.julclang.stdlib.StdlibRegistry; +import org.zeroj.onchain.julc.groth16.codec.SnarkjsToCardano; +import org.zeroj.onchain.julc.groth16.codec.SnarkjsToCardano.ProofCompressed; + +/** Opt-in disposable local Yaci runner. Never prints or writes wallet keys or mnemonics. */ +public final class AllowlistDevnet { + private static final ObjectMapper JSON=new ObjectMapper(); + private static final Path FIXTURE=Path.of("bindings/js/build/circuit/allowlist_d1"); + private static final Path OUT=Path.of("bindings/cardano/build/evidence"); + private static final HttpClient HTTP=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + private static URI local(String value) { + URI uri=URI.create(value); + if(!"http".equals(uri.getScheme())||!List.of("localhost","127.0.0.1","[::1]").contains(uri.getHost()) + ||uri.getUserInfo()!=null||uri.getQuery()!=null||uri.getFragment()!=null) + throw new IllegalArgumentException("This demonstration accepts loopback HTTP endpoints only"); + return uri; + } + private static final URI STORE=local(System.getenv().getOrDefault("YACI_STORE_URL","http://localhost:8080/api/v1/")); + private static final URI FAUCET=local(System.getenv().getOrDefault("YACI_FAUCET_URL","http://localhost:10000/local-cluster/api/addresses/topup")); + private static final BFBackendService BACKEND=new BFBackendService(STORE.toString(),"local-devnet"); + private static void write(String name,Object value) throws Exception { + Files.writeString(OUT.resolve(name),JSON.writerWithDefaultPrettyPrinter().writeValueAsString(value)); + } + private static String hash(byte[] bytes) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } + private static JsonNode read(String name) throws Exception {return JSON.readTree(Files.readString(FIXTURE.resolve(name)));} + private static void header(JsonNode node) { + String curve=node.path("curve").asText().toLowerCase().replace("-","").replace("_",""); + if(!"groth16".equals(node.path("protocol").asText())||!"bls12381".equals(curve)) + throw new IllegalArgumentException("Expected Groth16 BLS12-381 fixture"); + } + private static ListPlutusData datum(BigInteger root) {var d=new ListPlutusData();d.add(BigIntPlutusData.of(root));return d;} + private static ConstrPlutusData claim(ProofCompressed p,int action) { + return ConstrPlutusData.of(0,BigIntPlutusData.of(action),BytesPlutusData.of(p.piA()),BytesPlutusData.of(p.piB()),BytesPlutusData.of(p.piC())); + } + private static ScriptTx spend(Utxo input,ProofCompressed p,int action,Account account,PlutusV3Script script,int ada) { + return new ScriptTx().collectFrom(input,claim(p,action)).payToAddress(account.baseAddress(),Amount.ada(ada)).attachSpendingValidator(script); + } + private static void confirmed(String hash) throws Exception { + for(int i=0;i<45;i++){if(BACKEND.getTransactionService().getTransaction(hash).isSuccessful())return;Thread.sleep(1000);} + throw new IllegalStateException("Transaction confirmation timeout: "+hash); + } + public static void main(String[] args) throws Exception { + if(args.length!=1||!args[0].equals("--local-devnet"))throw new IllegalArgumentException("Run explicitly with --local-devnet; development fixtures only"); + Files.createDirectories(OUT); + var paramsResponse=HTTP.send(HttpRequest.newBuilder(STORE.resolve("epochs/latest/parameters")).timeout(Duration.ofSeconds(10)).GET().build(),HttpResponse.BodyHandlers.ofString()); + if(paramsResponse.statusCode()!=200)throw new IllegalStateException("Local protocol parameters unavailable"); + var params=JSON.readTree(paramsResponse.body());write("protocol-parameters.json",params); + // This evaluator profile was validated for this local node version. Never silently switch targets. + if(params.path("protocol_major_ver").asInt()!=11)throw new IllegalStateException("This experimental runner requires local protocol major 11"); + var manifest=JSON.readTree(Files.readString(FIXTURE.getParent().resolve("manifest.json"))); + JsonNode selected=null;for(var item:manifest)if(item.path("name").asText().equals("allowlist_d1"))selected=item; + if(selected==null||!selected.path("developmentOnly").asBoolean()||!selected.path("proofSupported").asBoolean()) + throw new IllegalStateException("Regenerate development fixtures and browser proofs first"); + JsonNode browser=read("chromium-proof.json"),alternate=read("unapproved-root-proof.json"),vkJson=read("vk.json"); + header(browser.path("proof"));header(alternate.path("proof"));header(vkJson); + var publicInputs=SnarkjsToCardano.parsePublicInputs(browser.path("publicInputs").toString()); + var otherInputs=SnarkjsToCardano.parsePublicInputs(alternate.path("publicInputs").toString()); + if(publicInputs.size()!=1||otherInputs.size()!=1)throw new IllegalArgumentException("Expected one root"); + BigInteger root=new BigInteger(read("inputs.json").path("root").asText()); + if(!publicInputs.getFirst().equals(root)||otherInputs.getFirst().equals(root))throw new IllegalStateException("Fixture root mismatch"); + var vk=SnarkjsToCardano.parseVk(vkJson.toString());if(vk.ic().size()!=2)throw new IllegalArgumentException("VK layout mismatch"); + var proof=SnarkjsToCardano.parseProof(browser.path("proof").toString()); + var otherProof=SnarkjsToCardano.parseProof(alternate.path("proof").toString()); + var account=new Account(Networks.testnet());byte[] pkh=account.hdKeyPair().getPublicKey().getKeyHash(); + String library=Files.readString(Path.of("zeroj-onchain-julc/src/main/java/org/zeroj/onchain/julc/groth16/lib/Groth16BLS12381Lib.java")); + String source=Files.readString(Path.of("bindings/cardano/src/validators/AllowlistValidator.java")); + var compiled=new JulcCompiler(StdlibRegistry.defaultRegistry()).compile(source,List.of(library)); + if(compiled.hasErrors())throw new IllegalStateException(compiled.diagnostics().toString()); + var applied=compiled.program().applyParams(PlutusData.bytes(vk.alpha()),PlutusData.bytes(vk.beta()),PlutusData.bytes(vk.gamma()),PlutusData.bytes(vk.delta()), + PlutusData.list(vk.ic().stream().map(PlutusData::bytes).toArray(PlutusData[]::new)),PlutusData.integer(root),PlutusData.bytes(pkh)); + var script=JulcScriptAdapter.fromProgram(applied); + var address=AddressProvider.getEntAddress(script,Networks.testnet()).toBech32(); + var funding=HTTP.send(HttpRequest.newBuilder(FAUCET).timeout(Duration.ofSeconds(15)).header("Content-Type","application/json") + .POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(Map.of("address",account.baseAddress(),"adaAmount",100)))) + .build(),HttpResponse.BodyHandlers.ofString()); + if(funding.statusCode()!=200)throw new IllegalStateException("Local faucet failed");Thread.sleep(3000); + var txb=new QuickTxBuilder(BACKEND); + var lockTx=txb.compose(new Tx().payToContract(address,Amount.ada(5),datum(root)) + .payToContract(address,Amount.ada(5),datum(otherInputs.getFirst())).from(account.baseAddress())) + .withSigner(SignerProviders.signerFrom(account)).buildAndSign(); + var lock=BACKEND.getTransactionService().submitTransaction(lockTx.serialize()); + if(!lock.isSuccessful())throw new IllegalStateException(lock.toString());confirmed(lock.getValue()); + var utxos=BACKEND.getUtxoService().getUtxos(address,100,1).getValue(); + Utxo input=utxos.stream().filter(u->u.getTxHash().equals(lock.getValue())&&u.getOutputIndex()==0).findFirst().orElseThrow(); + Utxo other=utxos.stream().filter(u->u.getTxHash().equals(lock.getValue())&&u.getOutputIndex()==1).findFirst().orElseThrow(); + var evaluator=new JulcTransactionEvaluator(new DefaultUtxoSupplier(BACKEND.getUtxoService()),new DefaultProtocolParamsSupplier(BACKEND.getEpochService()),new DefaultScriptSupplier(BACKEND.getScriptService())); + var tx=txb.compose(spend(input,proof,1,account,script,4)).withRequiredSigners(pkh).withTxEvaluator(evaluator) + .withSigner(SignerProviders.signerFrom(account)).feePayer(account.baseAddress()).collateralPayer(account.baseAddress()).buildAndSign(); + byte[] bytes=tx.serialize();var local=evaluator.evaluateTx(bytes,Set.of(input));var remote=BACKEND.getTransactionService().evaluateTx(bytes); + if(!local.isSuccessful()||!remote.isSuccessful())throw new IllegalStateException(local+" / "+remote); + var budget=local.getValue().getFirst().getExUnits(); + if(!budget.equals(remote.getValue().getFirst().getExUnits()))throw new AssertionError("Java/remote budget mismatch"); + write("transaction.json",Map.of("type","Tx ConwayEra","description","Browser allowlist local demonstration","cborHex",HexFormat.of().formatHex(bytes))); + var process=new ProcessBuilder("docker","exec","-i",System.getenv().getOrDefault("CARDANO_CONTAINER","node1-yaci-cli-1"), + "/app/cardano-bin/cardano-cli","conway","transaction","calculate-plutus-script-cost","online","--testnet-magic","42", + "--socket-path","/clusters/nodes/default/node/node.sock","--tx-file","/dev/stdin") + .redirectErrorStream(true).redirectOutput(OUT.resolve("haskell-cost.json").toFile()).start(); + try(var stdin=process.getOutputStream()){stdin.write(Files.readAllBytes(OUT.resolve("transaction.json")));} + if(!process.waitFor(30,TimeUnit.SECONDS)){process.destroyForcibly();throw new IllegalStateException("Haskell evaluation timeout");} + if(process.exitValue()!=0)throw new IllegalStateException("Haskell evaluation failed"); + var haskell=JSON.readTree(Files.readString(OUT.resolve("haskell-cost.json"))).get(0).get("executionUnits"); + if(!haskell.get("steps").bigIntegerValue().equals(budget.getSteps())||!haskell.get("memory").bigIntegerValue().equals(budget.getMem())) + throw new AssertionError("Haskell/Java budget mismatch"); + byte[] changed=proof.piA().clone();changed[0]^=0x20; + var invalid=new ProofCompressed(changed,proof.piB(),proof.piC()); + var negatives=new LinkedHashMap(); + for(String name:List.of("unapproved_root_valid_proof","wrong_action","missing_required_signer","tampered_proof")){ + var composition=txb.compose(spend(name.equals("unapproved_root_valid_proof")?other:input, + name.equals("unapproved_root_valid_proof")?otherProof:name.equals("tampered_proof")?invalid:proof, + name.equals("wrong_action")?2:1,account,script,4)).withTxEvaluator((cbor,ins)->local) + .withSigner(SignerProviders.signerFrom(account)).feePayer(account.baseAddress()).collateralPayer(account.baseAddress()); + if(!name.equals("missing_required_signer"))composition.withRequiredSigners(pkh); + byte[] bad=composition.buildAndSign().serialize(); + var eval=BACKEND.getTransactionService().evaluateTx(bad); + if(eval.isSuccessful())throw new AssertionError(name+" accepted by evaluator"); + var submit=BACKEND.getTransactionService().submitTransaction(bad); + if(submit.isSuccessful()||!submit.toString().contains("ValidationTagMismatch"))throw new AssertionError(name+": expected phase-2 rejection: "+submit); + negatives.put(name,Map.of("evaluationRejected",true,"signedSubmissionRejected",true,"reason","ValidationTagMismatch")); + } + // Build a DIFFERENT transaction ID consuming the same UTxO before it is spent. A duplicate + // submission of identical bytes can be idempotent and is not a meaningful replay test. + byte[] replay=txb.compose(spend(input,proof,1,account,script,3)).withRequiredSigners(pkh).withTxEvaluator((cbor,ins)->local) + .withSigner(SignerProviders.signerFrom(account)).feePayer(account.baseAddress()).collateralPayer(account.baseAddress()).buildAndSign().serialize(); + var submitted=BACKEND.getTransactionService().submitTransaction(bytes); + if(!submitted.isSuccessful())throw new IllegalStateException(submitted.toString());confirmed(submitted.getValue()); + var replayResult=BACKEND.getTransactionService().submitTransaction(replay); + String replayReason=replayResult.toString().contains("All inputs are spent")?"ConwayMempoolFailure: All inputs are spent": + replayResult.toString().contains("BadInputsUTxO")?"BadInputsUTxO":null; + if(replayResult.isSuccessful()||replayReason==null)throw new AssertionError("Expected spent-input rejection: "+replayResult); + negatives.put("same_utxo_different_transaction",Map.of("signedSubmissionRejected",true,"reason",replayReason)); + var report=new LinkedHashMap(); + report.put("timestamp",Instant.now().toString());report.put("browserProofSha256",hash(Files.readAllBytes(FIXTURE.resolve("chromium-proof.json")))); + report.put("verificationKeySha256",hash(Files.readAllBytes(FIXTURE.resolve("vk.json"))));report.put("validatorSourceSha256",hash(source.getBytes(StandardCharsets.UTF_8))); + report.put("graphSha256",selected.path("graphSha256").asText());report.put("fingerprint",selected.path("fingerprint").asText()); + report.put("appliedFlatBytes",UplcFlatEncoder.encodeProgram(applied).length);report.put("scriptCborBytes",script.getCborHex().length()/2); + report.put("compressedProofBytes",192);report.put("transactionBytes",bytes.length);report.put("cpuSteps",budget.getSteps());report.put("memoryUnits",budget.getMem()); + report.put("declaredCpuSteps",tx.getWitnessSet().getRedeemers().getFirst().getExUnits().getSteps()); + report.put("declaredMemoryUnits",tx.getWitnessSet().getRedeemers().getFirst().getExUnits().getMem()); + BigDecimal exFee=new BigDecimal(params.get("price_step").asText()).multiply(new BigDecimal(budget.getSteps())) + .add(new BigDecimal(params.get("price_mem").asText()).multiply(new BigDecimal(budget.getMem()))); + report.put("executionFeeLovelace",exFee.setScale(0,RoundingMode.CEILING).toBigIntegerExact());report.put("feeLovelace",tx.getBody().getFee()); + report.put("lockFeeLovelace",lockTx.getBody().getFee());report.put("transactionHash",submitted.getValue());report.put("lockTransactionHash",lock.getValue()); + report.put("confirmed",true);report.put("javaRemoteHaskellBudgetsIdentical",true);report.put("negativeChecks",negatives); + report.put("scope","Local PV11 development setup; pinned root, signer and action; reusable membership, no nullifier or anonymous authorization"); + write("result.json",report);System.out.println(JSON.writerWithDefaultPrettyPrinter().writeValueAsString(report)); + } +} diff --git a/bindings/cardano/src/validators/AllowlistValidator.java b/bindings/cardano/src/validators/AllowlistValidator.java new file mode 100644 index 00000000..48f048d5 --- /dev/null +++ b/bindings/cardano/src/validators/AllowlistValidator.java @@ -0,0 +1,37 @@ +package org.zeroj.bindings.cardano; + +import java.math.BigInteger; +import org.julclang.core.PlutusData; +import org.julclang.ledger.ScriptContext; +import org.julclang.stdlib.Builtins; +import org.julclang.stdlib.annotation.Entrypoint; +import org.julclang.stdlib.annotation.Param; +import org.julclang.stdlib.annotation.SpendingValidator; +import org.zeroj.onchain.julc.groth16.lib.Groth16BLS12381Lib; + +/** Local demonstration: reusable membership + explicit signed claim, not a nullifier protocol. */ +@SpendingValidator +public class AllowlistValidator { + @Param static byte[] vkAlpha; + @Param static byte[] vkBeta; + @Param static byte[] vkGamma; + @Param static byte[] vkDelta; + @Param static PlutusData vkIc; + @Param static BigInteger approvedRoot; + @Param static byte[] authorizedSigner; + + record Claim(BigInteger action,byte[] piA,byte[] piB,byte[] piC) {} + @Entrypoint public static boolean validate(PlutusData datum,Claim claim,ScriptContext ctx) { + if (!claim.action().equals(BigInteger.ONE)) return false; + boolean signed=false; + for(var signer:ctx.txInfo().signatories()) + signed=signed||Builtins.equalsByteString(signer.hash(),authorizedSigner); + if (!signed) return false; + PlutusData inputs=Builtins.unListData(datum); + if (Builtins.nullList(inputs)) return false; + BigInteger root=Builtins.asInteger(Builtins.headList(inputs)); + if (!root.equals(approvedRoot)||!Builtins.nullList(Builtins.tailList(inputs))) return false; + return Groth16BLS12381Lib.verify(datum,claim.piA(),claim.piB(),claim.piC(), + vkAlpha,vkBeta,vkGamma,vkDelta,vkIc); + } +} diff --git a/bindings/circuit/FORMAT.md b/bindings/circuit/FORMAT.md new file mode 100644 index 00000000..7896f59b --- /dev/null +++ b/bindings/circuit/FORMAT.md @@ -0,0 +1,178 @@ +# Experimental portable graph and symbolic recipe formats + +Governed by [ADR-0064](../../docs/adr/0064-portable-circuits-and-typescript-frontend.md). +The promoted tags are deliberately distinct from the research tags. This is an experimental +versioned format, not a stable `.circuit` standard. Its compiled output can use the existing +iden3 `.r1cs` exporter; Circom source/witness programs do not consume this input format. + +Canonical UTF-8 JSON has no whitespace and exactly this property order: + +```json +{"format":"zeroj-graph","version":1,"compiler":"zeroj-r1cs-bls12381-v1","field":"bls12-381-fr","name":"multiplier","public":["product"],"secret":["x","y"],"wires":5,"gates":[["c",0,"1"],["m",4,2,3],["e",4,1]]} +``` + +Input arrays contain flattened names. IDs: 0 = one; then public inputs in declaration order, +then secret inputs in declaration order; all remaining IDs are intermediate wires. `wires` +counts all wires, including one. The first gate must be exactly `["c",0,"1"]`. This is the +existing builder's mandatory constant declaration, and the only permitted write to wire 0. +Every intermediate must be assigned once. Operations read only already assigned wires. +Names match `[A-Za-z_][A-Za-z0-9_-]{0,95}` and input names are unique across both visibilities. + +| Opcode | Array | Existing ZeroJ meaning | +|---|---|---| +| c | `["c", out, "value"]` | Constant reduced to canonical Fr at export | +| a | `["a", out, left, right]` | Field addition | +| m | `["m", out, left, right]` | Field multiplication | +| e | `["e", left, right]` | Assert equality | +| b | `["b", wire]` | Assert boolean | +| l | `["l", out, [[wire,"coefficient"],...]]` | Ordered linear combination | +| h | `["h", out, "INVERSE", input]` | Existing hint enum; also IS_ZERO_RESULT, IS_ZERO_INVERSE | +| d | `["d", [outputs...], input, bits]` | Existing bit-decomposition witness hint | + +`h` and `d` do not independently constrain their output. Imported graphs must retain the +gadget's surrounding equality/boolean/range constraints. A compiler accepts a relation; +it cannot determine whether that relation implements an application's intended policy. +Bare hints are therefore not automatically proof of an inverse, bit decomposition or range. +`HintN` and raw `Select` reject in v1. There is no `eval`, custom callback or Java deserialization +entry point. The image's compiler-generated runtime can contain Java deserialization machinery, +but the adapter accepts only the described JSON graph and existing cryptographic codecs. + +Field values use nonnegative decimal strings, no sign/leading zero/exponent, and are strictly +less than BLS12-381 Fr. IDs/counts are integral JSON numbers. Duplicate/unknown fields, +trailing content, different key ordering, escaped alternative spellings and whitespace reject +through exact re-encoding. Parsing/re-encoding costs are explicitly measured as decode time. + +The JSON parser has a depth cap of 12, strings of at most 128 characters, numeric tokens of at +most 8 characters, and an outer 16 MiB cap. Names/scalars have tighter semantic bounds. +Counts are validated before graph arrays are allocated; JSON tree parsing necessarily occurs +first. Detailed limits are in ADR-0064. These bounds are not a hard worker-heap reservation. + +The graph has no witness values and no proving/VK bytes. Type widths are represented by the +generated constraints, not recovered as an ergonomic schema; higher-level metadata is carried separately in the recipe/export manifest. Shape parameters have already been expanded by the Java companion. Changing +them requires a newly exported graph, or a circuit factory built into Wasm (an alternative). + +## Two identities + +`graphSha256` hashes exact canonical graph bytes, including names and witness instructions. +`fingerprint` is ZeroJ's existing canonical R1CS content identity, including wire/public counts. +Different witness programs can describe the same constraints. Bind both identities plus the +compiler version and trusted key provenance in an application manifest. Do not allow the +prover to choose the verifier's verification key or to declare its own relation trusted. + +## Symbolic recipe + +```json +{"format":"zeroj-symbolic","version":1,"compiler":"zeroj-r1cs-bls12381-v1","field":"bls12-381-fr","name":"age_check","inputs":[{"name":"threshold","visibility":"public","type":"uint","bits":8},{"name":"age","visibility":"secret","type":"uint","bits":8}],"commands":[["u.gte",1,0],["b.assertTrue",2]]} +``` + +Root and input-record key ordering is canonical. Logical input symbols receive IDs in input +order, independent of their flattened wire count. Each value-returning command appends one +symbolic object to the table; assertions append none. References must point backward and have +the expected Java symbolic type. Bytes use names `base_0` through `base_(length-1)` and every +flattened name must be unique across public/secret input declarations. Public declarations +precede secret declarations. V1 types are field, bool, uint (bits 1..253) and bytes (length 1..64). + +Commands call the existing Java methods; they do not carry constraint formulas or user code: + +| Family | Operations | +|---|---| +| `f` | constant(decimal string), add/sub/mul/div(a,b), isEqual(a,b), assertEqual(a,b) | +| `u` | add/sub/mul, lt/lte/gt/gte/isEqual/assertEqual (a,b); inRange(value,lo,hi); asField(value) | +| `b` | and/or/xor/isEqual/assertEqual(a,b); not/asField/assertTrue/assertFalse(value); selectField/selectUInt/selectBool(condition,left,right) | +| `bytes` | get(value,index); isEqual/assertEqual(left,right) | +| `g` | poseidonBls12381T3(left,right); sha256(bytes) | + +Operands other than constants/indexes are symbolic IDs. Invalid arity, type, width, profile or +forward reference rejects. Existing Java constraints determine UInt widening/underflow and +boolean soundness. SHA-256 is limited to one invocation with 1..55 input bytes in this version. +No raw signal wrapping, custom parameters or additional hints are exposed. + +## Recipe v2: fixed arrays and Poseidon Merkle paths + +Existing TS definitions still emit **identical v1 bytes**. Definitions using arrays or Merkle +emit version 2. Java accepts both versions, but v1 rejects v2 vocabulary. Graph version 1 and +compiler policy `zeroj-r1cs-bls12381-v1` are unchanged: the existing Java Merkle gadget expands +into already-admitted boolean, linear-combination, multiplication, addition and equality gates. + +An array input uses this exact order (the outer recipe order is unchanged): + +```json +{"name":"siblings","visibility":"secret","type":"array","element":{"type":"field"},"length":2} +``` + +Elements are `{"type":"field"}`, `{"type":"bool"}` or `{"type":"uint","bits":8}` with +UInt widths 1..253. Length is 1..64. Nested/byte arrays are excluded. One logical input ID +represents the array; flattened scalar names are `siblings_0`, `siblings_1`, etc. Public arrays +retain public declaration order. Logical names and flattened names must be unique, including +collisions with scalars/bytes. The existing 64 logical / 256 flattened input caps remain. + +- `["a.get",arrayId,index]` returns one scalar symbol. The index is a literal nonnegative integer + strictly below the declared length; witness-dependent indexing rejects. +- `["g.merkleRootBls12381T3",leafId,siblingsId,pathBitsId,depth]` returns one field symbol using + `ZkMerkle.computeRootPoseidon` with `PoseidonParamsBLS12_381T3.INSTANCE`. The leaf is a field; + siblings are fields; directions are booleans. Both array lengths must equal the explicit + depth, which is 1..32. Arrays run from leaf to root: false = current node left, true = right. + There is no implicit leaf hash, domain separator, document encoding or nullifier. + +Only one Merkle invocation is admitted per recipe. Its preflight cost is 5,000 per explicit +level, then actual array lengths are checked before gadget expansion; callers cannot understate +work with a false depth. Array declaration cost accounts for every element and its UInt width. +The existing 180,000 expansion budget, four direct Poseidon calls and one SHA-256 call remain. +These work estimates are admission guards, not a proven bound on total heap use. + +TS uses `array(field(), depth)`, `array(bool(), depth)` or `array(uint(bits), depth)`, read-only +`.length`, and `.get(index)`. Input arrays are ordinary exact-length arrays of bigint/boolean; +values, ranges and sparse arrays are checked. Frozen copied descriptors and recorder ownership +prevent shape mutation or cross-definition symbolic handles. Java independently checks the +untrusted recipe and the existing constraints enforce ranges/booleans. Application manifests +still authenticate the exact expanded graph and R1CS fingerprint before private inputs. + +## Consumer worker protocol + +Prefer `@zeroj/circuit` to raw calls. The selected image supplies capabilities `circuit` and +`groth16-verifier` in one heap. Every compilation is bound to an application-trusted expected +graph hash and R1CS fingerprint, not a self-authenticating hash supplied by an arbitrary prover. + +1. Recipe path: `compileRecipe({recipe,expectedGraphSha256,fingerprint})`. +2. Graph path: `beginGraph({length,expectedGraphSha256,fingerprint})`, sequential + `appendGraph({offset,text})` with 1..200,000 characters, then `compileGraph({})`. +3. Either compilation returns a compiled handle, public identity/dimensions/layout and timings. + Only one compilation attempt is admitted per worker; create a fresh runtime for another. +4. `checkWitness({circuit,input})` checks constraints and returns only a boolean/timings. + It requires secret opt-in. No witness or witness digest crosses the consumer boundary. +5. `beginKey({circuit,fingerprint,length,expectedSha256,verificationKey})` returns `upload` and + `chunkBytes` (65,536). Length is 1..2,097,152 raw bytes in the normal profile. `appendKey({circuit,upload,offset,chunk})` + accepts 1..65,536 lowercase-hex-encoded bytes at the exact byte offset. A valid owned-upload + request with bad chunk/offset terminates that upload; requests rejected before ownership is + established cannot cancel another upload. Malformed outer JSON cannot dispatch cleanup. + `finishKey({circuit,upload})` consumes the upload, requires exact completion, checks the hash + before parsing and invokes the unchanged cryptographic importer/relation validator. It returns + a key handle and public import timings/domain/byte count. `abortKey({circuit,upload})` abandons + the upload. Both successful and failed finish are terminal; retries need a new upload. + One active upload and one successfully imported key per compiled circuit. An explicit raw + release of the upload handle also drops its buffer. Key release does not reset the one-key slot. + `keyResources({circuit})` returns exact staging/reservation counters, not heap usage. + Small direct `loadKey({circuit,fingerprint,zkey,expectedSha256,verificationKey})` requests remain + compatible at the original 256 KiB cap and share the single-key slot. `prove({circuit,key,input})` + checks ownership and self-verifies before returning a proof and public inputs. +6. Typed `compiled.dispose()` releases the circuit and its keys. Runtime disposal/timeout + terminates the worker and invalidates local handles. No secure-erasure claim is made. + +Wasm compilation is not Java source compilation. Gadget implementations are compiled into the +selected image; a recipe selects those implementations. Arbitrary graphs require application +review: accepting a graph does not certify that every intended relation was constrained. + + +## Opt-in larger-key research image + +The separate image described in [M3-LARGE-MERKLE.md](M3-LARGE-MERKLE.md) implements the same +protocol/graph/recipe identities with Java-selected `circuit-key-research-merkle-v1` limits: +32 MiB bytes, 65,536 wires, 8,192 domain entries, 262,144 coefficients and 64 public inputs. +This profile is not an RPC parameter or a new circuit/cryptographic format. The original direct +`loadKey` cap remains 256 KiB. Chunked loading uses the profile fixed on the owning circuit state. +Descriptions and `keyResources` expose `maxWires`, `maxDomainSize` and `maxCoefficients` alongside +existing byte/chunk counters. `withinProverDimensions` checks compiled shape, not eventual encoded +key bytes or coefficient count; the existing parser enforces the actual imported key's limits. +Both profiles reject incompatible compiled dimensions before allocating key staging. The normal +SDK package continues to select its original policy. Neither profile proves hostile-input heap +containment or authenticates the semantics of a caller-selected relation. diff --git a/bindings/circuit/M2-KEY-LOADING.md b/bindings/circuit/M2-KEY-LOADING.md new file mode 100644 index 00000000..db7cf616 --- /dev/null +++ b/bindings/circuit/M2-KEY-LOADING.md @@ -0,0 +1,206 @@ +# M2: bounded key loading and Poseidon browser proving + +Experimental implementation in draft PR #116, stacked on #92. This follow-up supersedes the +M1 report's Poseidon witness-only limit. **GO for this bounded desktop experiment; RESEARCH +for general browser proving and production deployment.** It does not complete all M2 gates. +No core cryptographic algorithms, key parser, R1CS semantics or security parameters changed. + +## What changed + +A TypeScript-defined BLS12-381 T3 Poseidon opening now expands through the existing Java gadget, +compiles, checks its witness, imports a supplied ceremony key, and generates/self-verifies a +fresh Groth16 proof in Node, Chromium and Firefox. Proofs are cross-checked with snarkjs 0.7.6 +and the JVM. Public-input/witness tampering is rejected. All keys in the demonstration are +explicitly disposable development artifacts, not a production ceremony. + +| Property | M1 | M2 compiler profile | +|---|---|---| +| Key transport | One hex JSON request | Sequential 64 KiB chunks | +| Maximum raw key | 262,144 bytes | 2,097,152 bytes | +| Concurrent uploads | No chunk transport | One per compiled circuit | +| Successful key imports | General handle limit | One lifetime import per compiled circuit | +| Wire / FFT-domain limits | 4,096 / 4,096 | Unchanged | +| Coefficient limit | 16,384 | Unchanged | +| Request / JSON string caps | 1 MiB / 512 KiB | Unchanged | +| Selected Poseidon opening | Compile + witness only | Compile + witness + prove + verify | +| SHA-256 abc | Compile + witness only | Unchanged, no proving claim | + +The opening has **241 constraints, 1,622 wires, domain 256**, and a **1,090,303-byte key**. +It fits the new transport policy without changing the circuit or cryptographic construction. +The standalone `@zeroj/groth16-prover` still uses its original 256 KiB key cap. The compiler's +one-key policy is deliberate: reuse the loaded key for repeated proofs, or create a new runtime +to change keys. Releasing its handle does not reopen the key slot. + +## Security and resource boundaries + +The upload reserves one exact-sized binary array only after metadata, circuit identity and +length admission. Chunks use exact offsets; duplicate, gapped, empty, oversized and overflowing +chunks terminate a recognized owned upload. Finish is terminal on success or failure. Hash +verification precedes parsing; the existing importer still validates canonical fields, +curve/subgroup membership, forbidden identity points, dimensions and the circuit relation. +Wrong-subgroup tests replace alpha with the on-curve order-three G1 point (0,2) and recompute +the artifact hash, ensuring subgroup validation—not just the hash check—rejects it. + +No alternative zkey parser, subgroup-check bypass, trusted-point cache or new cryptographic +implementation was added. Authenticating a key still requires a hash/VK selected by the trusted +application. An attacker choosing both bytes and the alleged expected hash is not authentication. +A structurally valid mismatched VK may fail only at the inherited proof self-verification step; +that does not resolve production ceremony/key-consistency assurance gates. + +`compiled.loadKey(artifact, {signal})` accepts a bounded caller-owned Uint8Array. Each request +encodes only one chunk; Java never assembles a full-key hex string. The original caller array +remains in host memory, and the existing parser still consumes a full binary buffer. This is +chunked transport, not a streaming cryptographic parser or a streaming network downloader. + +`keyResources()` reports exact transport reservation/receipt/high-water counters and import +elapsed time. These are **not total heap or retained-key measurements**. On finish/failure, +`reservedBytes` returns to zero; the successfully imported key has its normal parsed arrays/maps. +Garbage-collection timing and return of RSS to the operating system are separate concerns. + +Abort before import starts leaves the runtime usable. Abort during transfer/import terminates +**the whole runtime and all its workers/handles**, including a verifier sharing it. This can +interrupt synchronous Java validation; a queued release cannot. Protocol abort between calls, +explicit upload release and circuit disposal drop the staging buffer. Runtime deadlines also +terminate the worker. Malformed requests rejected before ownership can be established do not +cancel some other upload; callers can explicitly abort or dispose the runtime. No erasure claim. + +## Measurement methodology + +Each timing run uses five fresh workers, then five further proofs reusing the last loaded key. +“Cold” means a fresh worker; filesystem/browser/network/engine caches can be warm. Each fresh +worker separately measures startup, compilation, witness checking, key transport/import, proving +including self-verification, and another verification. The p95 is the nearest-rank maximum of +only five observations, not a reliable production-tail estimate. Key files are loaded before +timing and all browser runs are local, headless desktop tests. No network download time is claimed. + +Node RSS is sampled every 10 ms with per-phase maxima. RSS covers the entire Node process, +including worker threads; it is not the Java heap. Sampling can miss peaks, especially during +synchronous host encoding. `process.resourceUsage().maxRSS` adds the OS process high-water mark. +The other `process.memoryUsage` fields refer only to the current thread, so they are not used +as Wasm heap measurements. See the [Node process documentation](https://nodejs.org/api/process.html#processmemoryusage). + +A separate JVM JFR run profiles this same adapter/importer/prover path. Allocation samples are +weighted estimates of allocation volume, not retained size or peak heap, and JVM results do +not measure Wasm GC. See [Oracle's JFR guidance](https://docs.oracle.com/en/java/javase/25/troubleshoot/troubleshoot-performance-issues-using-jfr.html). + +## Completed checks and measurements (2026-10-30) + +`python3 bindings/wasm/circuit-check.py` passed on the final implementation: + +- 26 JVM tests: 21 compiler/transport tests, four shared boundary tests and one hash regression. +- 23 Node tests, including digest/parser/subgroup/dimension negatives, invalid Poseidon witness, + pre-abort, active-transfer/import abort, circuit disposal, deadline and direct-key compatibility. +- Six scenarios in Chromium 156.0.8078.4 and Firefox 157.0, plus cancellation during key import. + Each proving scenario rejects modified public inputs and accepts the independent snarkjs proof. +- Fresh Node/Chromium/Firefox Poseidon proofs verified on the JVM. Those proofs and a fresh JVM + proof verified with snarkjs and Wasm; altered commitments were rejected by independent verifiers. +- The affected standalone prover/verifier images were also rebuilt. Their existing Node Groth16 + interoperability test passed, including added checks that the 256 KiB standalone/direct cap + remains enforced. The full nine-image SDK suite was not rerun. +- Strict TypeScript positive/negative tests, authoring lint, and five SDK tarballs installed + offline outside the workspace with a new compile/prove/verify/tamper check. + +Environment: Apple M4 Max, macOS arm64, Node 22.12.0, GraalVM 25.3.4.1+1.1, +Binaryen 132, TypeScript 5.9.3 and snarkjs 0.7.6. Browsers are the local Playwright +headless builds above; these results do not cover Safari or actual mobile devices. +Five fresh workers and five reused-key proofs per engine, with no simultaneous build/test +workload started by this harness. Values below are **p50 / p95 in milliseconds**. + +| Phase | Node | Chromium | Firefox | +|---|---:|---:|---:| +| Worker startup | 212.9 / 236.8 | 153.2 / 156.0 | 241.6 / 262.9 | +| Recipe expansion + validation + compilation | 46.5 / 48.0 | 40.6 / 42.2 | 51.9 / 58.1 | +| Witness generation + constraint check | 3.9 / 4.0 | 3.2 / 3.3 | 4.4 / 4.7 | +| Key transport + validated import | 6887.2 / 6971.3 | 6566.8 / 6633.2 | 11698.8 / 11892.7 | +| Import alone (Java timer) | 6684.3 / 6775.4 | 6444.6 / 6506.4 | 11460.8 / 11683.9 | +| First proof + self-verification | 756.2 / 765.3 | 665.6 / 672.2 | 1296.1 / 1886.4 | +| Separate verification | 246.6 / 249.1 | 232.1 / 236.7 | 426.5 / 678.9 | +| Reused-key proof + self-verification | 752.6 / 760.3 | 652.9 / 663.2 | 1143.5 / 1234.6 | + +Key import dominates the first-use latency. Reusing the key avoids repeating this cost; the +prover still checks every witness and self-verifies every proof. File/network download is +excluded. At N=5, p95 is just the slowest observed sample; no SLA or production percentile claim. + +For Node, baseline process RSS was **48.0 MiB**. The largest 10 ms sample was +**377.6 MiB** (key-loading phase); the OS process high-water mark +was **380.5 MiB**. No OOM occurred in this workload. No browser heap high-water +number is claimed. This is neither a per-key memory requirement nor a browser memory limit. + +The upload buffer's exact high-water reservation was **1,090,303 bytes**, and its reservation +returned to zero after successful import. The caller's original array remains separate. JSON +and hex temporaries scale with a 64 KiB chunk, not the total key. The existing importer wraps +the upload array in a ByteBuffer; it still allocates its usual parsed key/constraint objects. + +The separate JVM run used `-Xmx2g` and JFR profile settings with 128-frame capture / 64-frame +export. **2,093 allocation samples** yielded these weighted volume estimates: + +| JVM path | Estimated cumulative allocation (MiB) | +|---|---:| +| Key import | 3,712.6 | +| All verification calls, including self-check | 2,385.5 | +| Proving | 247.0 | +| Other / startup | 236.0 | + +Within import, approximately **2,259.0 MiB** of weighted +allocation involved G1 subgroup checking and **1,448.1 MiB** +involved G2 subgroup checking. The entire run includes multiple independent verification calls +and one key import/proof, so these rows are not per-proof costs. The dominant sampled classes +were `int[]`, BigInteger and MutableBigInteger. Separate witness/FFT/MSM retained sizes were +not established by this short sampled run. No zero-allocation claim follows for unsampled phases. + +Recorded JVM pre-GC heap occupancy reached 1,322.9 MiB; the largest recorded post-GC occupancy +was 102.5 MiB. These are observations at GC events, not exact peak heap or retained-key sizes. +Cumulative allocation can exceed the 2 GiB heap because objects are collected. These JVM +measurements cannot be substituted for Wasm GC measurements. Any future optimization of the +existing subgroup-check arithmetic needs separate core/security review and differential tests; +removing subgroup checks is not an option. No such core change is included here. + +Measured image: `circuit-b37339dda96bb3b0b90815bb`. Wasm 13,399,576 raw bytes / 3,607,052 Brotli bytes; +launcher 112,275 raw / 18,503 Brotli bytes. Wasm SHA-256: +`8a729187214a29f83ae6ba430fbd7bb0cf650cfcdcd2e486975aeb4ba64aa259`. +The captured descriptor records dirty implementation base `392dd202` and classpath/build hash +`59cb9c8c2c468a921a65d5076e6169542392cf2d39b83696a8306a90f4349950`; +these identify the measured artifact, not a promise of bit-reproducible compiler output. + + +## Reproduce + +```sh +export JAVA_HOME="$GRAALVM_HOME" +python3 bindings/wasm/circuit-check.py + +cd bindings/js +node --experimental-wasm-exnref scripts/circuit-benchmark.mjs +node scripts/circuit-benchmark-browser.mjs +cd ../.. +./gradlew -p bindings/wasm :adapter-circuit:circuitInterop \ + -PcircuitJfr=../js/build/circuit-results/poseidon-jvm.jfr +"$JAVA_HOME/bin/jfr" print --json --stack-depth 64 --events jdk.ObjectAllocationSample \ + bindings/js/build/circuit-results/poseidon-jvm.jfr \ + > bindings/js/build/circuit-results/poseidon-jvm-allocations.json +python3 bindings/wasm/summarize-circuit-jfr.py \ + bindings/js/build/circuit-results/poseidon-jvm-allocations.json +``` + +Use the pinned GraalVM toolchain documented in bindings/README.md. Run benchmarks sequentially, +without simultaneous compilation/test workloads. The focused check regenerates all fixtures and +keys, so run it before benchmarks. Summaries belong in this report; generated keys, raw proof +vectors, JFR data, logs, images and raw benchmark JSON stay ignored. No packages are published. + +## Remaining gates and recommendation + +- This bounded Poseidon workload is demonstrated, not a general size/memory guarantee. SHA's + 103,259-wire example still exceeds the unchanged prover budgets. Merkle depths and multiple + Poseidon calls require independent measurement and may exceed the 2 MiB key profile. +- Compile/admission limits remain coarse. Complete hard heap/work accounting, retained key/ + witness/FFT/MSM attribution in Wasm, malicious-input soak tests and real low-memory devices + remain open. A single benchmark with no OOM does not establish maximum safe circuit size. +- Safari, actual mobile devices, independent review, side-channel assessment, production + ceremony/artifact provenance, release/distribution review and stable API guarantees remain + outstanding. The local demo's fixed value/salt is not a private application. +- No Yaci transaction, fee result, validator policy, replay protection, nullifier or private + state machine has been added in this milestone. Groth16/BLS12-381 is not post-quantum. + +Next functional work is fixed arrays and existing Java Poseidon Merkle relations, with per-depth +compile/key/prove measurements before expanding admission. Keep the Java cryptographic and +constraint implementations as the sole source of semantics. diff --git a/bindings/circuit/M3-LARGE-MERKLE.md b/bindings/circuit/M3-LARGE-MERKLE.md new file mode 100644 index 00000000..6236535d --- /dev/null +++ b/bindings/circuit/M3-LARGE-MERKLE.md @@ -0,0 +1,205 @@ +# M3 larger-Merkle experiment — 2026-10-30 + +**GO for a separate desktop research profile; RESEARCH for a supported larger browser SDK.** +The existing Java Poseidon Merkle relation can compile, generate witnesses and produce Groth16 +proofs at depths 8, 16 and 32 in the measured Node/Chromium/Firefox environments and on the JVM +at both 6 GB and 8 GB heaps. The main obstacle is validating/loading +the proving key, not repeated proof generation. The normal SDK still admits only its previously +measured small keys; no default resource limit was raised. + +This extends [M3](M3-MERKLE.md), with the security design in +[ADR-0064](../../docs/adr/0064-portable-circuits-and-typescript-frontend.md) and runnable instructions +in [research/README.md](research/README.md). Core R1CS compilation, witness generation, cryptography +and security parameters are unchanged. These are Groth16/BLS12-381 experiments, **not post-quantum +proofs** and not production readiness evidence. + +## What was measured + +The path is TS symbolic definition → existing Java `ZkMerkle` / BLS12-381 Poseidon T3 → existing +R1CS compiler → chunked development key import → private path witness → fresh proof → verification. +JVM timing uses the equivalent exported graph; graph and constraint identities match the TS recipe. +The public input is the root; private inputs are a leaf, fixed siblings and boolean directions. +Depth 32 processes 32 path levels, not a materialized tree with 2^32 leaves. This is the existing +raw-field Merkle relation, with no new leaf encoding, domain separator, nullifier or state protocol. + +| Depth | Constraints | Wires | FFT domain | Coefficient entries | Raw key bytes | +|---|---:|---:|---:|---:|---:| +| 8 | 1,945 | 11,592 | 2,048 | 52,037 | 8,052,135 | +| 16 | 3,889 | 22,976 | 4,096 | 104,077 | 16,002,823 | +| 32 | 7,777 | 45,744 | 8,192 | 208,157 | 31,904,199 | + +The separate `circuit-key-research-merkle-v1` image permits 32 MiB key bytes, 65,536 wires, +8,192 domain entries, 262,144 coefficients and 64 public inputs. The normal `circuit-key-v1` +remains 2 MiB / 4,096 wires / 4,096 domain / 16,384 coefficients / 64 public inputs. Dimensions +are rejected before key staging; actual bytes and coefficients remain checked by the importer. +`withinProverDimensions` is not a guarantee that the eventual key fits every limit. Research +policy is fixed by Java at image construction, retained by circuit state, and cannot be raised +through RPC. It is a resource policy, not a semantic allowlist certifying arbitrary graphs. + +The research source set and package output are separate from consumer npm packages. The driver +explicitly uses a 600-second per-call deadline; normal runtime defaults are unchanged. All imports +retain authenticated-digest checks, canonical field/point checks, subgroup checks, relation checks, +public-input order, witness checks and post-proof self-verification. Sequential 64 KiB chunks, +one active upload and one lifetime successfully imported key per circuit remain enforced. The +legacy direct key RPC still has its smaller 256 KiB cap even in the research image. + +## Desktop Wasm timings + +Seconds, one fresh worker per engine/depth. Key load includes transfer and full import. The first +proof and all three subsequent proofs include witness generation and self-verification; the +reused column is the median of those **three** subsequent samples. Verification is one separate +call. Startup includes instantiation/loading, not a cold internet download. + +| Engine | Depth | Startup | Recipe compile | Key load | First proof | Reused proof median | Verify | +|---|---:|---:|---:|---:|---:|---:|---:| +| node | 8 | 0.368 | 0.349 | 65.626 | 3.034 | 2.708 | 0.271 | +| chromium | 8 | 0.201 | 0.156 | 64.495 | 2.608 | 2.775 | 0.278 | +| firefox | 8 | 0.295 | 0.264 | 122.734 | 6.327 | 6.923 | 0.711 | +| node | 16 | 0.283 | 0.313 | 155.922 | 6.178 | 5.504 | 0.333 | +| chromium | 16 | 0.223 | 0.343 | 156.787 | 5.951 | 6.156 | 0.400 | +| firefox | 16 | 0.369 | 0.590 | 324.421 | 10.757 | 10.335 | 0.720 | +| node | 32 | 0.360 | 0.908 | 407.541 | 12.144 | 12.418 | 0.402 | +| chromium | 32 | 0.293 | 0.757 | 352.990 | 9.174 | 9.379 | 0.415 | +| firefox | 32 | 0.373 | 1.228 | 523.097 | 14.092 | 13.907 | 0.623 | + +Environment: Apple M4 Max, macOS arm64, Node 22.12.0 with `--experimental-wasm-exnref`, Playwright +Chromium 156.0.8078.4 and Firefox 157.0, pinned GraalVM 25.3.4.1+1.1 / Binaryen 132. Browser runs +are headless desktop tests. Matrices ran concurrently on a shared host, with other work present; +these are feasibility samples, **not isolated engine rankings or p95 estimates**. Larger cases +had different host contention. Do not interpret non-monotonic proof times as algorithmic gains. +Safari, actual phones and constrained desktop devices have not been validated. + +The measured matrix image was `circuit-large-research-d38483dd9ed318ad1339914a`, built on base +`219a27b8420bdd50740f5f5caabaf64b8bc1ff41` plus this experiment's uncommitted source. A later +ownership guard reads the profile from circuit state rather than the adapter receiving a call; +it does not change these single-adapter workloads. Final-image smoke/negative checks are recorded +below separately; the timing table is not silently relabeled as a new binary measurement. + +## JVM heaps and Node RSS + +Each JVM process generated two proofs: first and one reused-key proof. No OOM was observed in any +of the six runs. `-Xmx6g` and `-Xmx8g` were checked through `Runtime.maxMemory()` as 6/8 GiB. +Used heap was sampled every 10 ms; these peaks include uncollected garbage and may miss shorter +spikes. They are neither retained-key size nor total process RSS. No forced-GC retained-size claim +is made. The depth-32/6g run also enabled JFR and carries profiling overhead. + +| Depth | Heap cap | Key load (s) | First proof (s) | Reused proof (s) | Sampled used-heap peak (GiB) | +|---|---:|---:|---:|---:|---:| +| 8 | 6g | 50.111 | 1.552 | 1.470 | 1.223 | +| 8 | 8g | 53.144 | 1.694 | 1.545 | 1.223 | +| 16 | 6g | 122.815 | 3.530 | 3.217 | 1.253 | +| 16 | 8g | 157.767 | 4.341 | 3.941 | 1.262 | +| 32 | 6g | 300.696 | 3.525 | 3.215 | 1.404 | +| 32 | 8g | 228.780 | 2.702 | 2.512 | 1.306 | + +Per-phase heap samples are recorded for all runs except the initial depth-8/6g run, which has +only a global peak. At depth 32/6g, key-load sampling reached 1.379 GiB and first-proof sampling +1.404 GiB; at depth 32/8g, key loading reached the global 1.306 GiB peak. No inference about a +hard minimum heap follows: smaller JVM caps were not tested here. + +| Node depth | OS maximum process RSS (MiB) | Sampled key-load RSS (MiB) | +|---|---:|---:| +| 8 | 418.2 | 414.5 | +| 16 | 435.1 | 431.1 | +| 32 | 627.8 | 623.4 | + +Node RSS covers the main process and worker threads, not Java/Wasm managed heap. Sampling and OS +maximum capture stop before the independent snarkjs oracle runs. Browser process-tree peak memory +was not measured; Node RSS is **not** substituted for browser heap. An admission cap is not a hard +memory sandbox or evidence that hostile graphs cannot exhaust a worker. + +## Where time and allocation go + +The depth-32/6g JFR recording contained 60,887 allocation samples. Their sample weights estimate +**140.743 GiB of cumulative allocation traffic**, not simultaneously live memory. Key import +accounted for 128.078 GiB (91.0%); 99.51% of that import allocation weight had G1/G2 subgroup-check +frames. Integer arrays, `BigInteger`/`MutableBigInteger` and field objects dominated the allocation +classes. This locates temporary allocation pressure; allocation sampling alone is not a CPU-time +profile. The measured key-load wall times independently show the import bottleneck. + +Do not omit subgroup checks, accept opaque prevalidated point objects from callers, or replace +secure randomness to improve these numbers. Reusing an already checked key in the same runtime +is the implemented way to avoid repeat import cost. Persistent unchecked object snapshots would +introduce a new trust boundary and are not implemented. + +## Key transport and compiler options + +Read-only R1CS analysis found 9,637 / 19,069 / 37,933 unreferenced wire columns at depths 8/16/32. +The compiler inlines expressions while retaining original graph numbering. Constant one and all +ordered public inputs must always remain. Matching all-zero entries in generated query sections +corroborate the census; unused columns alone do not establish an under-constrained statement. + +A deterministic projection could remove about 4.63 / 9.15 / 18.21 MB of raw query bytes, respectively. +This is an estimate, **not an implemented or verified compacted key**. It changes wire mapping, +fingerprints and setup artifacts, and needs separate core review. It does not reduce coefficient +counts or domain sizes. Zero query entries already avoid expensive nontrivial subgroup work, so +byte savings do not imply proportional import speedup. The concrete follow-up is in +[WIRE-COMPACTION-PLAN.md](WIRE-COMPACTION-PLAN.md). + +Compression was measured on the generated local keys (gzip level 9 / Brotli quality 9): + +| Depth | Raw bytes | gzip bytes | Brotli bytes | +|---|---:|---:|---:| +| 8 | 8,052,135 | 1,285,834 | 1,020,368 | +| 16 | 16,002,823 | 2,568,578 | 1,998,671 | +| 32 | 31,904,199 | 5,134,820 | 4,243,616 | + +This measures network/storage potential only. No new compressed-key loader was implemented. +Parsing still requires the bounded decompressed key and all cryptographic checks. A future +loader must authenticate the artifact, bound compressed and expanded bytes, and cancel safely. + +## Validation and limitations + +- Default regression checks: **35 adapter JVM tests** (30 circuit, four common, one hash), + **34 Node compiler tests**, strict TS build and authoring lint. The four new JVM tests cover + normal-policy early rejection, research allocation bounds/RPC immutability, policy ownership, + and exact dimension/public-input boundaries. The full nine-image SDK suite was not rerun. +- All **15 engine/depth cases** (Node, Chromium, Firefox, JVM-6g, JVM-8g × 8/16/32) produced fresh + proofs independently verified by both JVM and snarkjs, with changed-root rejection. Wasm + drivers also accepted the independent snarkjs proofs and rejected altered private path bits; + JVM drivers rejected altered siblings before proving. Existing M3 gadget vectors remain the + independent constraint-assurance baseline; frontend parity alone is insufficient. +- Final image `circuit-large-research-f9e91aa2b031594cd2f378d0` passed normal-policy rejection + before staging, research length/profile-override limits, incomplete chunks, bad digest, + oversized wire/domain headers with recomputed hashes, cleanup, and cancellation during actual + import. JVM importer tests independently reject byte/wire/domain/coefficient ceilings using + an unchanged valid key and tighter limits. +- The rebuilt research image is **13,433,598 Wasm bytes + 112,275 launcher bytes**, excluding legal + files. Its actual Chromium page buttons passed cancel → restored controls → fresh depth-8 + compile/import/proof/verify. Lazy loading selected only this image; no external requests or + page errors occurred. Desktop/390 px screenshots were inspected; width checks are not mobile + device performance evidence. Its fresh depth-8 smoke import was 55.250 s and first proof + 2.202 s; those separate smoke numbers do not replace the shared-host matrix above. +- Both consumer/research packaging and build selections were checked for isolation. All raw + proofs, keys, JSON results, screenshots and JFR data remain ignored under `build/`; source, + reproduction commands and curated results are committed. + +Generated keys use pinned snarkjs 0.7.6, BLS12-381 power-13 development setup and fresh local +contributions. Each key and independent snarkjs proof was checked. This is **not a production +ceremony**. Test witnesses are published/generated fixtures, not user secrets; entropy remains +WebCrypto-backed in Wasm, with pure-Java curve implementation and no native blst dependency. +Experimental/variable-time browser secret handling under ADR-0057 is unchanged. + +No new on-chain transaction was submitted for depths 8/16/32 in this experiment. The earlier +[M3 local Cardano measurement](M3-MERKLE.md) used depth 1 and its own verification key. Do not +quote that fee as a measured depth-32 application cost. Membership is not anonymity, ownership, +a nullifier or a private state machine; applications still need trusted roots, authorization, +context binding and replay/state-transition rules. No independent audit was performed. + +## Recommendation and next work + +| Option | Finding | Recommendation | +|---|---|---| +| Existing default SDK | Existing small proofs work; larger keys intentionally reject | Keep current limits | +| Separate larger desktop profile | Useful proofs work with long first import; fixed 600 s deadline and browser differences matter | GO for opt-in research, not supported release | +| Reuse an imported key | Subsequent proofs take seconds without repeating import | Use within the existing runtime lifecycle | +| Compressed key delivery | Measured substantial byte savings; no parsed-memory/import validation savings established | Implement bounded authenticated delivery separately | +| Unused-wire compaction | Concrete zero-column census and significant byte opportunity | RESEARCH through separate core design and differential tests | +| Faster subgroup validation | Import dominates; checks are necessary | Profile existing implementation/reference-backed methods, never skip checks | +| Local JVM proving | All measured cases pass at 6g/8g with shorter observed proof times | Retain local fallback; remote proving exposes witnesses unless separately protected | +| Raise default limits / production release | Device, hostile-input, side-channel, ceremony and independent-review gates remain | Do not promote on benchmark evidence alone | + +The next useful optimization is to separate network savings, key-object/validation costs and +compiler-wire overhead, then test each independently. M4 SHA-512/BLAKE2b, richer authoring/lint, +additional Poseidon profiles and private-state protocols remain separate roadmap work. Nothing +in this resource experiment changes proof-system security parameters or creates a PQ guarantee. diff --git a/bindings/circuit/M3-MERKLE.md b/bindings/circuit/M3-MERKLE.md new file mode 100644 index 00000000..d3134c0d --- /dev/null +++ b/bindings/circuit/M3-MERKLE.md @@ -0,0 +1,261 @@ +# Fixed arrays, browser Merkle proofs and a Cardano claim + +2026-10-30 · experimental M3 / bounded M5 slice in stacked PR #116. +**GO for this bounded demonstration; RESEARCH for useful larger browser trees; not production-ready.** +The previous [M2 report](M2-KEY-LOADING.md) remains the historical key-loading baseline. + +TypeScript can now describe fixed arrays and call ZeroJ's existing Java Poseidon Merkle gadget. +Depths 1 and 2 generate proofs in Node, Chromium and Firefox. Depths 8, 16 and 32 compile and +check witnesses but exceed the unchanged proving profile. A fresh Chromium depth-1 proof was +verified and used in a confirmed local Cardano claim with root, signer and action checks. +No core compiler, hash implementation, field/curve algorithm, proof equation, key parser or +randomness provider was changed in this milestone. + +## How to use it + +A Merkle tree combines pairs of values until one public root remains. A member supplies a leaf, +one sibling per level and directions describing whether the current node is on the left or right. +The circuit computes the root and requires it to equal the public root. The proof exposes the +root rather than the leaf/path values. Privacy still depends on the application, witness entropy, +setup and runtime; the published test values here are not private. + +```ts +import {defineCircuit, field, bool, array} from '@zeroj/circuit'; + +const membership = defineCircuit({ + name: 'allowlist_d1', + public: {root: field()}, + secret: { + leaf: field(), + siblings: array(field(), 1), + pathBits: array(bool(), 1), + }, +}, ({public: p, secret: s, zk}) => { + zk.gadgets.merkleRootBls12381T3( + s.leaf, s.siblings, s.pathBits, + ).assertEqual(p.root); +}); + +// Use the authenticated graph/fingerprint and key-loading lifecycle in README.md. +// Inputs to compiled.prove(key, ...) then have this shape: +const inputs = { + public: {root: approvedRoot}, // bigint from the application's trusted tree + secret: {leaf: 42n, siblings: [100n], pathBits: [true]}, +}; +``` + +The fixture root is `Poseidon(100, 42)`. `true` means the current node is **right**; +`false` means it is left. Siblings and directions proceed from leaf to root. For a depth-2 +relation both arrays have length 2. Array lengths and the circuit name are public build-time +choices. Changing the definition requires matching authenticated artifacts and potentially a new +setup; it is not a witness-time option. `approvedRoot` above is intentionally application data, +not a magic SDK constant. + +The leaf is a field value. This API does not silently hash a document, authenticate an identity, +encode a Cardano credential, add a domain separator or create a nullifier. Choose a reviewed leaf +encoding/commitment protocol when those meanings matter. Fixed-depth root trust prevents simply +presenting a root chosen by the prover. Membership alone does not authorize a payment. + +The full [browser examples](../js/examples/circuit/index.html) include an illustration, proof +buttons for depths 1/2, compile-and-witness buttons for 8/16/32, compact results and expandable +artifact/resource details. [Source definitions](../js/examples/circuit/symbolic-examples.ts) +use only the public SDK. From the built `bindings/js` workspace: + +```sh +python3 scripts/serve.py --port 8791 +# Open http://127.0.0.1:8791/examples/circuit/ +node --experimental-wasm-exnref examples/circuit/node.mjs allowlist_d1 merkle_d2 +``` + +No inputs are uploaded by the example page. All fixtures are same-origin, local development +artifacts. Browser compilation invokes the Java R1CS compiler in Wasm; it does not compile Java +source or annotations. Java annotations and TS recipes independently reach the same graph. +Secure proof randomness continues through the WebCrypto-backed adapter; host WebCrypto digest +operations are not replacements for in-circuit Poseidon or SHA constraints. + +## Contract and validation + +Recipe v2 adds fixed field/bool/UInt arrays (length 1..64), `.length`, static `.get(index)` and one +explicit BLS12-381 T3 Merkle operation at depth 1..32. Existing definitions retain identical v1 +bytes, and v1 rejects the new vocabulary. Graph v1 and compiler policy remain unchanged. +The [format](FORMAT.md#recipe-v2-fixed-arrays-and-poseidon-merkle-paths) specifies canonical key +order, input flattening, length/type/index checks, work bounds and ownership rules. + +The Merkle wrapper calls `ZkMerkle.computeRootPoseidon` with the existing +`PoseidonParamsBLS12_381T3.INSTANCE`. Boolean directions and UInt array elements use Java's +existing constrained types. No TS hash rounds, selector equations or witness algorithms were +introduced. Native Java authoring and host Poseidon roots are separate oracles for recipe +expansion, although they still share ZeroJ's underlying cryptography. Existing published-vector +Poseidon suites and independent snarkjs checks add evidence beyond wrapper parity. + +Validation performed: + +- 31 adapter JVM tests: 26 circuit, 4 common, 1 hash. The five new circuit tests cover exact + graph/fingerprint/full-witness parity at depths 1/2/8/16/32, all-left/all-right/alternating paths, + annotation-authored allowlist parity, changed root/leaf/sibling/direction, non-boolean bits, + UInt ranges, public ordering, malformed arrays, false depth claims and v1 compatibility. +- 34 distinct Node compiler tests, including the existing importer/cancellation negatives, + new TS types/immutable schemas, actual Wasm array indexing, Merkle constraints and proofs. + The complete 34-test suite passed, including a valid raw-boundary witness check before + non-boolean rejection. Strict TS and authoring lint also pass. +- All 12 exported R1CS/witness pairs independently checked by snarkjs. Eight development + keys/proofs fit the profile; no key/setup/proof was attempted for the four out-of-profile + fixtures (SHA-256 and Merkle depths 8/16/32). +- Eleven scenarios plus import cancellation in both Chromium and Firefox, no external requests, + lazy image loading, desktop/mobile-width checks. These headless desktop browsers are not + evidence for Safari or an actual mobile device. +- Node, Chromium and Firefox proofs for Poseidon opening, allowlist depth 1 and Merkle depth 2 + verified on the JVM. Fresh JVM proofs were exported for reciprocal snarkjs/Wasm verification; + changed public inputs reject. A separate valid proof for an unapproved root verifies under + the same key before being rejected by the Cardano policy. +- Existing Java gadget assurance: 97 selected Poseidon/gadget tests plus 22 symbolic-type tests, + all passed. No original gadget behavior was changed. +- Five local SDK tarballs tested by an offline consumer outside the workspace; no npm publication. + +The independent reviewer/audit gate remains open. Tests and this self-review do not raise the +security maturity of the Java prover, GraalVM runtime or application protocol. + +## Measured circuit and key sizes + +| Depth | Constraints | Wires | Graph bytes | Key bytes | Measured support | +|---|---|---|---|---|---| +| 1 | 244 | 1,631 | 44,973 | 1,095,283 | Compile / witness / prove | +| 2 | 487 | 3,054 | 73,702 | 2,089,119 | Compile / witness / prove | +| 8 | 1,945 | 11,592 | 250,022 | Not generated | Compile / witness only | +| 16 | 3,889 | 22,976 | 507,797 | Not generated | Compile / witness only | +| 32 | 7,777 | 45,744 | 1,033,944 | Not generated | Compile / witness only | + +The allowlist variant uses the same 244 constraints / 1,631 wires as depth 1, with its own graph name and development key. + +Depth 1 is two possible positions; depth 2 is four. Depth 8 could represent 256 positions, +but compiling/checking its witness is **not** browser proof-generation support. Larger rows +have no measured key/proof result: they were stopped by profile admission before setup. +Depth 2's key has only 8,033 bytes of headroom below the 2,097,152-byte cap. Do not generalize +its success to an application with extra constraints, public inputs or leaf hashing. + +The selected compiler/prover Wasm image is 13,430,896 raw bytes plus 112,275 launcher bytes in +this build. This is not a new image per Merkle depth. It is still the selected combined compiler/ +prover profile; no claim is made that npm tree shaking removes parts of a precompiled image. +Compressed network transfer depends on hosting. The full nine-image workspace was not rebuilt. + +## Timings and memory + +| Depth | Engine | Key load p50 / p95 (s) | First proof p50 / p95 (s) | Reused proof p50 / p95 (s) | Verify p50 (s) | +|---|---|---|---|---|---| +| 1 | node | 6.79 / 6.88 | 0.75 / 0.75 | 0.72 / 0.73 | 0.24 | +| 1 | chromium | 6.40 / 6.56 | 0.64 / 0.66 | 0.62 / 0.63 | 0.23 | +| 1 | firefox | 10.30 / 10.88 | 1.10 / 1.17 | 1.15 / 1.16 | 0.38 | +| 2 | node | 13.45 / 13.59 | 1.16 / 1.19 | 1.15 / 1.18 | 0.25 | +| 2 | chromium | 12.65 / 12.72 | 0.97 / 1.00 | 0.98 / 0.98 | 0.22 | +| 2 | firefox | 22.13 / 22.55 | 1.77 / 1.79 | 1.77 / 1.79 | 0.46 | + +Five fresh-worker runs and five additional proofs reusing the last key, per circuit/engine. +Cold means a new worker; host/file/browser caches can already be warm. Key load includes bounded +transfer, digest and existing parser/point/subgroup/relation checks. Proof times include witness +construction and self-verification. Independent verifier calls are measured separately. The +reported p95 is the largest of just five samples, not an estimate of a population tail latency. +These are local desktop observations on Apple M4 Max, macOS arm64; background host work can +influence them. Node 22.12.0, Chromium 156.0.8078.4, Firefox 157.0; GraalVM 25.3.4.1+1.1, +Binaryen 132, TypeScript 5.9.3, snarkjs 0.7.6. Test fixtures use disposable BLS12-381 setup. + +| Depth | Node baseline RSS (MiB) | Sampled peak RSS (MiB) | OS maxRSS (MiB) | Raw staging bytes | +|---|---|---|---|---| +| 1 | 48.8 | 373.5 | 376.8 | 1,095,283 | +| 2 | 48.7 | 389.3 | 389.5 | 2,089,119 | + +RSS covers the whole Node process, including JS and Wasm workers, code, parsed keys and GC. +10 ms sampling can miss peaks; OS maxRSS is separately reported. Neither is retained-key size, +a JVM `-Xmx` measurement nor a browser heap bound. The staging counter is exactly the raw upload +allocation and returns to zero after import; it excludes parsed key/graph/FFT/MSM memory. +No comparable new browser peak-memory measurement or 6/8 GB JVM proving claim is made here. +The earlier [M2 allocation investigation](M2-KEY-LOADING.md) explains why subgroup validation +can dominate import work without implying that all sampled allocation is live memory. + +| Depth | Expand (ms) | Graph validate (ms) | R1CS compile (ms) | Witness (ms) | Constraint check (ms) | +|---|---|---|---|---|---| +| 8 | 4.73 | 28.25 | 71.07 | 3.45 | 10.30 | +| 16 | 5.91 | 45.66 | 123.24 | 7.02 | 18.98 | +| 32 | 6.92 | 85.50 | 226.51 | 16.26 | 37.37 | + +These larger-depth rows are single Node observations from the functional run, not five-sample +benchmarks. Expansion invokes the Java gadget; graph validation is measured separately; compile +is the inner R1CS compilation. Witness calculation and constraint checking are separate phases. +They show execution within the tested profile, not a malicious-input heap-containment guarantee. + +## Actual Cardano execution + +The [local runner and instructions](../cardano/README.md) consume the exact Chromium proof through +ZeroJ's existing checked `SnarkjsToCardano` codec and `Groth16BLS12381Lib`. A small application +validator pins the approved root, VK, authorized signer and claim action. The signer authorizes +transaction contents; the ledger enforces consumption of the input UTxO. The signer is public, +and membership proofs remain reusable for other deposits under that policy. There is no +nullifier, anonymity or private-state-machine claim. + +| Metric | Measured value | +|---|---| +| Compressed proof | 192 bytes | +| Applied script, flat / CBOR | 1,974 / 1,980 bytes | +| Claim transaction | 2,682 bytes | +| CPU steps | 2,058,505,076 | +| Memory units | 163,706 | +| Execution fee | 0.157865 ADA | +| Total claim fee | 0.435698 ADA | +| Separate lock transaction fee | 0.177029 ADA | + +Consumed and declared budgets matched in this run. JuLC Java, the remote transaction evaluator +and Haskell `cardano-cli` reported identical execution units. Live local protocol major 11 limits +were 10,000,000,000 CPU steps, 16,500,000 memory units and 16,384 transaction bytes. Prices were +0.0000721 lovelace/step and 0.0577 lovelace/memory unit. These are **local DevKit measurements**; +this run does not establish current mainnet parameters or identical mainnet fees. The runner +uses an isolated commit-pinned JuLC pre20/CCL pre7 profile; core dependencies are unchanged. + +Four fully signed invalid submissions rejected with `ValidationTagMismatch`: valid proof for +an unapproved root, wrong action, absent required signer and negated proof A. The valid claim was +submitted and confirmed. A different transaction attempting the same spent UTxO then rejected +with `ConwayMempoolFailure: All inputs are spent`. This tests ledger consumption, not global +one-use membership. A repeated proof on a fresh deposit is deliberately allowed by the policy. + +Recorded at `2026-10-30T08:47:15.831950Z`. Confirmed local claim transaction: +`267a16fa9e1630ed87147580eddddbc6e08aadca4a764237312287370ebc2f8e`. +Chromium proof file SHA-256: `43b35c99bd54d16cf98ec021fa12ec9146eec05b2a754b38ed710043dc3a3e80`. +Verification key SHA-256: `343c0937ced214121169435ff6ee7b15cddd8315a1e842e1f3b0d1e162a02790`. +Expanded allowlist graph SHA-256: `cb6da2a97829b7372e044441d51a15e797866784ff7819181883fd111b1f1ae2`. +The raw source/protocol-parameter/transaction evidence is generated by the runner. + +## Reproduce and next decisions + +```sh +export JAVA_HOME="$GRAALVM_HOME" +python3 bindings/wasm/circuit-check.py +cd bindings/js +node --experimental-wasm-exnref scripts/circuit-benchmark.mjs allowlist_d1 +node --experimental-wasm-exnref scripts/circuit-benchmark.mjs merkle_d2 +node scripts/circuit-benchmark-browser.mjs allowlist_d1 +node scripts/circuit-benchmark-browser.mjs merkle_d2 +# Then follow bindings/cardano/README.md from the repository root. +``` + +Generated keys, vectors, images, screenshots, raw benchmark JSON, transactions and logs stay +ignored. The repository contains source, deterministic fixture generators and this curated report. +Fresh setup/proof randomness changes artifact/transaction hashes on reproduction; graph identities, +constraint counts and admitted limits should remain stable for this source/compiler profile. + +The next resource decision is larger Merkle proving. Options, in order: + +1. Reuse the loaded key for repeated proofs within the supported profile. This is implemented + and measured; no point validation is skipped. +2. Profile larger keys/graphs before proposing any new worker budget. Increasing the byte cap + alone cannot solve the 4,096-wire bound. Retain cancellation and all cryptographic checks. +3. Investigate unused-wire/linear-expression compaction in the **existing Java compiler** only + through a separate security design and core review. That can affect witness mapping, circuit + fingerprints and ceremony compatibility. It is a research option, not an implemented result. +4. Keep JVM/local proving as a fallback for larger applications, with browser verification of + the result. This milestone does not measure a new JVM large-tree proof. Remote proving has + a separate witness-disclosure trust cost and is not equivalent to local proving. + +M1 still needs richer diagnostics/catalog/lint integration and additional type conveniences. +M3 does not yet expose `ZkPoseidonN` or other Poseidon profiles. M4 SHA-512/BLAKE2b and a reviewed +anonymous/nullifier or private-state protocol remain later milestones. No new cryptographic +construction is introduced to bypass these limits. Groth16 and the BLS12-381 pairing verification +used here are **not post-quantum**. Production still requires authenticated ceremony artifacts, +independent security review, runtime/side-channel assessment, device coverage and release review. diff --git a/bindings/circuit/PLAN.md b/bindings/circuit/PLAN.md new file mode 100644 index 00000000..3470be60 --- /dev/null +++ b/bindings/circuit/PLAN.md @@ -0,0 +1,242 @@ +# Circuit compiler and TypeScript SDK implementation plan + +Accepted experimental implementation plan, 2026-10-30; maintainer requested a separate stacked PR above #92. **GO for a bounded experimental SDK; RESEARCH for larger browser proving and advanced gadget packs.** This is a plan to promote the tested research, not a declaration that the APIs below are published. Implementation evidence is in the research report at `zeroj-research/browser-circuit-compiler-poc/docs/SYMBOLIC_REPORT.md` (research commit `d425988`). + +Sections 1–7 preserve the accepted roadmap; the implementation ledger at the end records current completion and measured limits. + +## 1. The architectural decision + +Keep **one implementation of circuit semantics and cryptography, in Java**. TypeScript describes intent and input shapes; it does not implement SHA rounds, Poseidon permutations, elliptic-curve arithmetic, range constraints, witness algorithms, MSM or FFT. + +```text +Java @ZKCircuit ── generated companion ───────────────┐ + v +TS defineCircuit ── bounded recipe ── Java Zk* ── ConstraintGraph + | + existing R1CSCompiler / WitnessCalculator + | + existing supplied-key Groth16 prover / verifier + | + existing Cardano codec → application transaction +``` + +The TS callback runs once during authoring, with symbolic objects and no witness values. It records a batch of commands. Java expands that batch in the worker using its existing symbolic classes and gadgets. Proving supplies actual inputs later. Do not close over a private value and use it as a definition constant or shape: that embeds it in the public recipe. There is no RPC per addition, no evaluation of TS source in Java, and no interpretation of Java annotations in the browser. + +Both frontends reach the same graph, but byte identity requires the same operation ordering, declarations and compiler version. Arbitrarily rewritten equivalent expressions need not have the same graph or R1CS fingerprint. A compiler upgrade may require different setup artifacts. Keep recipe identity, expanded graph identity, constraint fingerprint and trusted key identity distinct. + +## 2. The developer experience + +This example **works in the research prototype**: + +```ts +import {defineCircuit, uint} from './symbolic.js'; + +const ageCheck = defineCircuit({ + name: 'age_check', + public: {threshold: uint(8)}, + secret: {age: uint(8)}, +}, ({public: p, secret: s}) => { + s.age.gte(p.threshold).assertTrue(); +}); + +const input = ageCheck.encodeInputs({ + public: {threshold: 18n}, + secret: {age: 21n}, +}); +// Java expands ageCheck.recipe, compiles it and uses input at witness time. +``` + +The intended promoted API would add a lifecycle such as the following. **These names are proposed, not implemented exports**: + +```ts +const compiled = await compiler.compile(ageCheck, {manifest}); +const key = await compiled.loadKey(trustedKeyArtifact); +const proof = await compiled.prove(key, { + public: {threshold: 18n}, secret: {age: 21n}, +}); +await verifier.verify(trustedVerificationKey, proof); +await compiled.dispose(); +``` + +`manifest` binds the input layout, supported field/gadget profile, compiler version and accepted circuit identity. The key artifact includes the application-trusted key hash and verification key. The application chooses these; a prover cannot authorize its own relation by submitting a matching key and hash. + +Prefer bigint, boolean and Uint8Array; typed schemas produce editor completion for public and private inputs. Errors should identify an operation index and public source label, without echoing witness data. Expose `describe()` and a public cost estimate before key loading. Report compiler admission, witness admission and prover admission separately. + +Do not offer JS operator overloading: `x + y`, `x === y` and `if(flag)` do not express circuit operations. Use `x.add(y)`, `x.isEqual(y)` and `flag.select(a,b)`. JS objects are truthy; a proxy/coercion hook cannot intercept boolean truthiness. The prototype includes type-aware lint, but `any`, aliases to conversion functions or arbitrary JS can escape it. A supported SDK needs a documented supported-authoring subset and a required lint/build lane; lint is not a security sandbox. Witness-dependent loops and variable circuit shapes are excluded. Fixed host loops may unroll a public, bounded shape. See the [TypeScript truthiness discussion](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#truthiness-narrowing). + +## 3. What to reuse and what remains to expose + +Source inventory was checked against ZeroJ commit `844a0625282cc87e77f3c85df1c1cbdd4cc86a3a`. All classes in this table already exist in Java. “Next” means binding and validation work, not a new cryptographic construction. + +| Surface | Java owner | Research now | Next work | +|---|---|---|---| +| Field | `ZkField`, `ZkContext` | add/sub/mul/div, constants, equality/assertion | stable shape/field metadata, diagnostics, exceptional arithmetic tests | +| Unsigned integer | `ZkUInt` | arithmetic, comparisons, inRange, asField | preserve Java widening/underflow rules; expose useful checked conversions without inventing wraparound arithmetic | +| Boolean | `ZkBool` | logic, assertions, field/uint/bool select | required authoring lint; branch shape checking | +| Bytes | `ZkBytes` | fixed nonempty input, get/equality | byte/bit order contracts; safe array conversions and length policies | +| Bits and arrays | `ZkBits`, `ZkArray` | deferred | fixed shape vectors/matrices, typed indexing, composition and shape limits | +| Input schemas | `ZkCircuitSchema`, metadata/input maps | research descriptors/flattening | authoritative exported Java metadata and exact public-input ordering | +| Poseidon | `ZkPoseidon`, `ZkPoseidonN` | BLS12-381 T3 two-input hash | explicit supported T3/T5/T6 profiles and arities; domain separation policy per application | +| Merkle | `ZkMerkle` | deferred | Poseidon membership/root with fixed-depth field siblings and boolean path directions | +| SHA-2 | `ZkSha256`, `ZkSha512` | SHA-256 one-block expansion/witness | both algorithms, empty/padding boundary/multiblock policy; larger prover admission | +| BLAKE2b | `ZkBlake2b` | deferred | 224/256-bit profiles for Cardano, exact byte encoding | +| HMAC | `ZkHmacSha512` | deferred | explicit key/message lengths and padding boundary coverage | +| Pedersen | `ZkPedersen`, `ZkPedersenCommitment`, `ZkPedersenVector` | deferred | typed Jubjub points, scalar/blinding bounds, subgroup and schema provenance | +| Exponential ElGamal | `ZkElGamal`, public-key/ciphertext types | deferred | encryption and DLEQ relations with context, bounds and checked point types | +| Signature relations | `ZkEdDSAJubjub` | deferred | strict key/subgroup policies, reduction witness and supported hints | +| Cardano derivation | `ZkCip1852` | deferred, not browser-v1 | non-native/hint coverage and resource feasibility; retain JVM proving route | + +Do not expose internal `wrap`, arbitrary signal IDs, arbitrary hints or unsafe/trusted constructors as convenience methods. A TS brand is not proof of subgroup membership, authenticated public provenance, or valid circuit ownership. Validate again in Java. A future safe-layer type must be created by the appropriate validating Java operation. + +The preferred first useful gadget pack is **BLS12-381 Poseidon + Poseidon Merkle**, followed by **SHA-256/SHA-512**. The former supports application commitments/allowlists with small constraint counts; the latter connects existing document and governance digests. BLAKE2b is the next Cardano-oriented addition. HMAC and elliptic-curve relation packs follow only after their resource and hint prerequisites pass. + +## 4. Modules and download behavior + +Promote into the existing opt-in bindings workspace; do not make the root Java build require Node/GraalVM. + +```text +bindings/wasm/ + adapter-circuit/ recipe/graph admission, Java symbolic dispatch, + compiled-circuit handles, typed input layout + adapter-groth16/ existing prover/importer/verifier, reused + bundles/ explicit selected compiler/prover entry points +bindings/js/packages/ + circuit/ defineCircuit, descriptors, symbolic types, + compile lifecycle and typed inputs + circuit-gadgets/ small wrappers/subpath exports, no algorithms + eslint-plugin-circuit/ type-aware authoring rules + bundle-circuit-basic/ core symbolic compiler + bounded prover + bundle-circuit-poseidon/ compiler/prover + selected Poseidon/Merkle + bundle-circuit-sha2/ compiler/prover + SHA-256/SHA-512 + runtime/, groth16-*/ existing packages +``` + +Names are proposed `@zeroj/*` npm packages consistent with ADR-0057; publication is a later release step. Begin with one measured compiler/prover image in research, then measure the dedicated profile builds before choosing release defaults. The prototype is not evidence that splitting into three images saves total bytes. + +JS wrappers must stay small and inert on import. Loading a host hash wrapper must not fetch a circuit compiler. A circuit hash wrapper requests a constraint gadget; `@zeroj/hash` computes an ordinary digest and cannot substitute for that gadget. WebCrypto only helps ordinary SHA hashing. + +**A physical image is one Java heap.** Compiler, selected gadgets and prover must share that image to use their Java objects/handles directly. Separate images cannot share a graph or symbolic point by passing a handle. For an application needing both Poseidon and SHA, build an explicit combined profile or serialize a checked portable artifact and pay its transfer/validation cost. Do not promise dynamic linking or infer that npm tree shaking removes code from a precompiled Wasm file. A verifier-only user can retain the existing small verifier profile. + +Use an explicit, versioned operation catalog to generate TS signatures/schema documentation and Java dispatch metadata. Keep Java calls hand-reviewed. No reflective export of every public Java method. CI must fail when a catalog operation has no handler, the selected image lacks a requested gadget, or semantic parity fixtures change unexpectedly. + +## 5. Milestones, dependencies and acceptance gates + +The day ranges below are rough engineering effort for one developer familiar with ZeroJ, excluding external review and setup ceremonies. They are planning estimates, not delivery commitments; the larger-key path is the main uncertainty. + +### M0 — Validate the architecture (complete in research) + +Five TS definitions reproduce the annotation-authored graphs and witnesses. The small examples prove in Node/Chromium/Firefox and interoperate with snarkjs. Poseidon and SHA expand through existing Java gadgets. See the report for measured limits and actual proof coverage. + +### M1 — Stabilize the authoring and artifact contract (5–8 days) + +- Separate stable recipe version, graph version and compiler-policy version. Specify canonical ordering, input flattening, shapes, field profile and commitments to these identities. +- Add the Java circuit adapter and TS circuit package in a **new PR stacked on #92**. Update the relevant ADR scope: ADR-0057's exclusion of arbitrary runtime circuits cannot silently become a supported public feature. Preserve all runtime, entropy and key validation contracts. +- Promote typed lifecycle handles; keep input ownership/session checks on both sides. Add fixed bits/arrays, shape-only circuit parameters and composition within one recorder. +- Convert research lint into supported project tooling; reject dangerous conditions/operators/casts in the declared subset, explain escape hatches as unsupported, and add negative compile/lint fixtures. +- Add operation-source labels, deterministic identity, worker cancellation, and reproducible source maps without witness values. + +Exit: all primitive operations have Java/TS/Wasm parity and adverse-input coverage; exact schema/VK binding tests pass; browser/Node examples use only the promoted public API. No non-native or arbitrary hint support is implied. + +### M2 — Make selected gadget proving practical (5–10 days; research gate) + +This precedes a “Poseidon proofs supported” claim. The measured opening key is **1,090,303 bytes**, despite only 241 constraints. The current 256 KiB key cap, 512 KiB JSON string cap and 1 MiB request cap all matter; raising just one limit cannot solve the path safely. SHA also exceeds the 4,096-wire/domain budgets. + +- Reuse `ZkeyImporterBLS381` and its limits/point validation. Add a bounded key-transfer protocol (chunked or transferable binary), expected total size and authenticated hash, exact offsets/order and terminal states. Keep importing only after complete integrity/identity checks. Do not write another zkey parser. +- Measure copies, parsing, key objects, witness arrays, FFT buffers and MSM allocations separately, including during failures and cancellation. A 1 MiB file is not a 1 MiB memory budget. +- Benchmark the current Poseidon relation first. Then consider wire compaction/reuse in the existing compiler as a **separately reviewed core change**, only if profiling justifies it. Compaction affects witness mapping/fingerprints/setup compatibility and needs semantic proof/tests; it is not a TS optimization. +- Enforce measured per-profile limits before expansion/allocation, plus hard work/allocation counters inside graph construction where feasible. Current coarse recipe costs are admission heuristics, not heap containment. +- Exercise max/max+1 inputs, truncated/oversized key streams, invalid points/fields, wrong circuit/VK, replayed chunks, timeout and worker disposal. No weakening of checks to fit performance targets. + +Exit: fresh browser Poseidon proofs verify in Wasm, JVM and snarkjs; cold/warm p50/p95, peak-memory methodology and supported device matrix are recorded. If it fails, retain compile/verify and use a local JVM prover for this profile; do not advertise proof-generation support. Remote proving is a distinct trust choice because it reveals the witness unless another established protocol protects it. + +### M3 — First Cardano-friendly pack: Poseidon and Merkle (5–8 days after M1; proof gate depends on M2) + +- Expose explicit BLS12-381 profiles and `ZkPoseidonN`, then wrap `ZkMerkle` over fixed-size arrays. Enforce root/leaf/path shape and intended public root visibility in the application template. +- Add a private allowlist example with an application-bound statement and documented issuer/root trust. Membership alone is not uniqueness or authorization. Nullifier construction must use a reviewed domain-separated application protocol, not a new ad hoc TS hash formula. +- Benchmark depths 1, 8, 16 and 32 independently: recipe expansion, gates/wires, constraints, key size, witness/proof time and peak memory. A supported hash does not establish a supported depth-32 proof. +- Check leaf/sibling/direction/root tampering, all-zero/all-one paths, ownership and public-input ordering; compare existing Java vectors and host Merkle implementation. + +Exit: ship only depths/profiles that meet the documented prover/resource gates. Publish a capability matrix that distinguishes compile, witness, prove and verify. + +### M4 — Web2-aligned SHA-2 and Cardano BLAKE2b (5–10 days after M1/M2) + +- Wrap `ZkSha256`/`ZkSha512`, followed by `ZkBlake2b`; no port of their algorithms. Preserve current circuit implementations and test vectors. +- Define fixed input length and padding semantics. Cover SHA-256 lengths 0/1/55/56/63/64/65 and SHA-512 0/1/111/112/127/128/129 where the existing Java API supports them. Empty input needs an explicit Java/recipe representation; this prototype rejects zero-length ZkBytes. +- Cross-check official vectors, JDK/WebCrypto host digests, Java annotation graphs, and independent prover interoperability. A host digest match alone does not demonstrate a constrained hash relation; altered message/digest and witness tests are required. +- Start with bounded one-block proving. Extend to 1/2/4/8/16 KiB only under measured resource budgets. The JVM's document-size results do not establish browser feasibility. +- Add a document-preimage example and a selective-disclosure example only with an unambiguous canonical document format/parser. A plain hash preimage proof does not prove a date, signer, JSON property or legal meaning. + +Exit: per-length support/timing/memory table, admitted limits, proof tampering tests and an explicit fallback for unsupported sizes. HMAC-SHA512 follows with its existing key-length/padding semantics and separate budgets. + +### M5 — Cardano application integration (5–8 days, after at least one useful proving profile) + +- Reuse existing Java proof/VK/Cardano encoding and JuLC verifier conventions; do not add a second TS elliptic-curve codec. Independently check byte identity against the existing codec/vector suite. +- Export a typed public-input layout to the application. Bind the intended relation/VK to the validator and the proof's public inputs to the relevant datum/redeemer/transaction context. Test changed input order, wrong root, wrong action/network context and wrong VK. +- Build a Yaci DevKit example with browser-generated proof and a real validator transaction. Record live protocol parameters, script/transaction sizes, execution units and fees. This research task has not measured any new on-chain cost. +- Test consumption/replay and state transitions, not merely `verify(proof) == true`. An application that needs one-time authorization must constrain/check its own nullifier or UTxO transition appropriately. + +Exit: browser → proof → Cardano transaction example, valid/invalid end-to-end tests, beginner tutorial and advanced security/resource notes. BLS12-381's Cardano primitive support is described in [CIP-0381](https://cips.cardano.org/cip/CIP-0381); it does not establish costs or safety of a particular application. + +### M6 — Advanced selected relations and release hardening (separate estimates) + +After M1–M5, add Pedersen, exponential ElGamal and strict signature relation packs in separate measured slices. Preserve subgroup/identity checks, scalar widths, fresh randomness rules, schema bindings and exact public/secret provenance. Existing `fromVerifierCheckedPublic` paths must not be offered as unchecked TS imports. Audit which gadgets require `Gate.HintN`; the research graph codec deliberately rejects it. Define a closed, constrained, versioned hint registry and negative witness tests before admitting such graphs. + +Threshold decryption/DKG and confidential-note delivery already have host bindings in PR #92. Reuse those; they are protocols with context/admission/authenticated-channel obligations, not automatically ZK circuit gadgets. Add circuit-backed encryption/decryption-share relations only through the existing Java safe layers. Confidential notes still need balance/range, authorization, nullifiers and ledger state rules; exposing methods does not implement a private state machine. + +Keep large CIP-1852/account ownership off the browser-v1 promise. Its prior memory failures are relevant evidence. Support Java-authored artifacts and JVM/local proving without forcing the circuit into a browser budget. + +Before supported distribution: independent review, Safari/actual mobile coverage, entropy-failure and side-channel assessment, malicious-recipe fuzzing, lifetime/disposal tests, artifact provenance/licensing, npm scope/version policy and compatibility CI. No audit/constant-time claim follows from parity tests. The generated GraalVM artifact's distribution terms need explicit review. None of these Groth16/Jubjub/BLS-based features is post-quantum. + +## 6. Duplication budget and maintenance + +New code should be transport schemas, immutable TS recording objects, typed lifecycle, Java dispatch and examples/tests. The prototype has zero new hash/curve equations. Java still determines field reductions, range constraints, width growth, division hints, padding and gadget internals. Avoid duplicating these rules in generated TS validators: TS may reject obvious bad shapes/values early, but Java is authoritative. + +Exact graph/fingerprint/witness parity detects frontend drift; existing published vectors and independent prover checks detect additional classes of error. These are complementary: two wrappers invoking the same buggy Java gadget would agree. Retain the upstream gadget assurance suites and provenance. + +A useful bounded beta through M5 is roughly **25–44 engineering days**, with M2 potentially extending or stopping browser proving. Advanced EC relations, large account circuits and independent audit are outside that estimate. First ship a small, truthful capability matrix; broaden it only when each gadget has measured compiler, witness and proof evidence. + +## 7. Recommended next implementation + +Promote the recipe/graph contract and typed frontend in a new stack above #92, then implement the bounded key-transfer/resource work required for the measured Poseidon case. After that, add fixed arrays and Merkle membership before broadening to SHA-512/BLAKE2b. This order delivers a useful Cardano application path while keeping every cryptographic implementation in existing ZeroJ Java code. + +## Implementation ledger for this stack + +- Base: PR #92 at `844a0625282cc87e77f3c85df1c1cbdd4cc86a3a`. +- Branch: `feat/wasm-circuit-frontend`. +- Tracking PR: [#116](https://github.com/bloxbean/zeroj/pull/116), draft and explicitly based on PR #92’s branch. +- Governing security design: [ADR-0064](../../docs/adr/0064-portable-circuits-and-typescript-frontend.md). +- First bounded milestone: promote graph codec, selected symbolic recipe dispatcher, typed compilation/proving lifecycle and authoring lint; retain the existing proving limits. +- M1 initial slice implemented and validated: canonical graph/recipe contract; Java adapter and build-time artifact exporter; typed TS lifecycle/input conversion; CLI authoring lint; browser/Node examples; isolated local npm package test. +- M1 remains in progress for fixed bits/arrays, generated operation catalog, full lint/build integration, richer diagnostics and shape parameters. Independent review remains a draft-PR gate. +- M2 bounded transport/Poseidon slice is implemented in this stack; validation and remaining profiling/device gates are tracked in [M2-KEY-LOADING.md](M2-KEY-LOADING.md). M3 and the bounded M5 application slice now have measured evidence below; the remaining gates are still planned. +- Do not publish npm packages or merge this stack as part of implementation. + +- M1 validation baseline: 21 JVM and 17 Node tests; see [VALIDATION.md](VALIDATION.md). +- M2 validation: 26 JVM and 23 Node tests, six scenarios plus import cancellation in Chromium/Firefox, Poseidon proof cross-verification with JVM/snarkjs, and five SDK tarballs installed offline. Measurements and limitations are in [M2-KEY-LOADING.md](M2-KEY-LOADING.md). +- M2 retains the standalone prover limits and adds the separately bounded compiler key profile. Full heap containment/device gates remain open; the subsequent fixed-array/Merkle slice is documented below. + +- M3 bounded slice implemented: recipe v2 fixed field/bool/UInt arrays and static indexing, + existing Java BLS12-381 T3 Merkle expansion, browser allowlist illustration, Node/browser proofs + at depths 1/2, and compile/witness measurements at 8/16/32. V1 bytes and all proving limits remain + unchanged. Depth 2 nearly fills the 2 MiB key cap; larger browser trees remain RESEARCH. +- Bounded M5 application slice implemented: exact Chromium proof submitted on local Yaci with + pinned root/VK/signer/action; independent Java/remote/Haskell budget equality, four invalid signed + claims and same-UTxO replay rejection. Membership remains reusable on other deposits. This does + not complete private-state, anonymous authorization, nullifier or network-context protocol work. +- Current measurements, reproduction and security limits: [M3-MERKLE.md](M3-MERKLE.md). + Next resource work is profiling larger keys/graphs and evaluating separately reviewed core wire + compaction, before broadening the browser proving profile. Other Poseidon profiles, SHA-512 and + BLAKE2b still require their own measured slices. M1 catalog/lint/diagnostic work remains open. + + +- M3 larger-Merkle resource experiment completed: separate Java-selected 32 MiB / 65,536-wire / + 8,192-domain / 262,144-coefficient policy, excluded from consumer npm packaging. Defaults and + core cryptography/compiler remain unchanged. Depths 8/16/32 now prove and cross-verify in Node, + Chromium, Firefox, and JVM at 6g/8g. Import dominates startup; no OOM in the measured matrix. +- [Larger-Merkle report](M3-LARGE-MERKLE.md) records all phase timings, JVM heap/Node RSS scope, + JFR allocation attribution, negative tests and local browser controls. These are shared-host + feasibility samples, not stable p95, browser peak-memory or production evidence. +- Read-only wire/query census and compressed-file measurements motivate the + [separate core compaction proposal](WIRE-COMPACTION-PLAN.md). No compaction, validation shortcut, + new compressed loader, larger-depth Cardano measurement or default-limit increase is included. + Remaining M1/M4/M6/device/audit gates still apply; the overall roadmap is not complete. diff --git a/bindings/circuit/README.md b/bindings/circuit/README.md new file mode 100644 index 00000000..c93850b1 --- /dev/null +++ b/bindings/circuit/README.md @@ -0,0 +1,177 @@ +# Typed circuits in TypeScript and Wasm + +Experimental first implementation under [ADR-0064](../../docs/adr/0064-portable-circuits-and-typescript-frontend.md). +The [plan](PLAN.md) tracks the remaining milestones; [validation](VALIDATION.md) records what actually ran. +The API reuses ZeroJ's Java symbolic types, gadgets, R1CS compiler, witness calculator and Groth16 implementation. +The [format contract](FORMAT.md) specifies canonical graphs/recipes and their separate identities. +No cryptography is reimplemented in TS. The packages are not published to npm. + +## Try the examples + +From the ZeroJ repository root, with the pinned GraalVM/Binaryen/Node prerequisites in [bindings](../README.md): + +```sh +export JAVA_HOME="$GRAALVM_HOME" +python3 bindings/wasm/circuit-check.py +cd bindings/js +python3 scripts/serve.py --port 8791 +# http://127.0.0.1:8791/examples/circuit/ +# another terminal in bindings/js: +node --experimental-wasm-exnref examples/circuit/node.mjs age_check +``` + +The focused check installs pinned dependencies, builds the TS API, exports public recipes, +runs JVM parity/negative tests, builds/packages the compiler image, creates disposable snarkjs +keys, tests Node and Chromium/Firefox, and verifies an isolated local npm-tarball consumer. +Generated images, keys, logs, screenshots and raw vectors remain ignored. No npm publication occurs. + +The eleven browser scenarios cover portable graphs, field/UInt/boolean authoring, Poseidon, +SHA-256, an allowlist and Merkle paths. Seven generate fresh proofs, including Merkle depths +1 and 2. SHA-256 and depths 8/16/32 compile and check witnesses but exceed the default proving +limits. A separate [larger-Merkle experiment](M3-LARGE-MERKLE.md) now generates and cross-verifies +depth-8/16/32 proofs in Node, Chromium, Firefox and the JVM at 6g/8g; its long key imports and +resource policy are opt-in, outside consumer npm packages. See [M3 measurements and tutorial](M3-MERKLE.md) and the opt-in +[local Cardano example](../cardano/README.md). The full implementation plan remains in progress. + +## Author and prove + +Inside the built workspace or an installation of the local tarballs: + +```ts +import {createRuntime, hexToBytes} from '@zeroj/runtime'; +import {defineCircuit, uint, createCircuitCompiler} from '@zeroj/circuit'; +import {bundle} from '@zeroj/bundle-circuit'; + +const ageCheck = defineCircuit({ + name: 'age_check', + public: {threshold: uint(8)}, + secret: {age: uint(8)}, +}, ({public: p, secret: s}) => { + s.age.gte(p.threshold).assertTrue(); +}); + +// manifest, zkeyBytes and verificationKey are authenticated application artifacts. +const runtime = createRuntime({bundles: [bundle], experimentalSecrets: true}); +try { + const compiled = await createCircuitCompiler(runtime).compile(ageCheck, { + expectedGraphSha256: manifest.graphSha256, + fingerprint: manifest.fingerprint, + }); + const key = await compiled.loadKey({ + zkey: zkeyBytes, + expectedSha256: hexToBytes(manifest.keySha256), + verificationKey, + }); + const proof = await compiled.prove(key, { + public: {threshold: 18n}, secret: {age: 21n}, + }); + // Verify against the intended VK and expected public statement in your application. + await compiled.dispose(); +} finally { + await runtime.dispose(); +} +``` + +The complete runnable equivalents are in [examples/circuit](../js/examples/circuit/). +Use a fresh runtime per compiled circuit. `compiled.checkWitness()` returns success and timings, +never witness values or witness digests. `compiled.description` exposes public identities/counts +and dimensions; `withinProverDimensions` does not guarantee a supplied key fits the byte cap. +Closing a compiled circuit releases its owned keys. Disposing the runtime terminates its worker; +this does not prove erasure of every immutable Java/JS copy. + +`@zeroj/circuit/graph` also exports the earlier arithmetic `CircuitBuilder`. For Java-exported +canonical graph text, use `compiler.compileGraph(graphText, identity)`. That path accepts flattened +input names mapped to bigint; higher-level byte/UInt shapes are represented by constraints, not +reconstructed into TS types from a raw graph. + +## Produce artifacts for a new TS-only circuit + +A circuit definition is public build data. Save `definition.recipe` using Node's filesystem API; +no Java circuit class needs to be authored. Export the public graph, `.r1cs` and identity manifest +with the JVM build tool (paths below are relative to `bindings/wasm`): + +```sh +./gradlew -p bindings/wasm :adapter-circuit:exportRecipe \ + -PrecipeFile=../js/build/my-recipe.json \ + -PartifactDir=../js/build/my-artifacts +``` + +The exporter writes `recipe.json`, `graph.json`, `circuit.r1cs`, and `manifest.json`, including +public/private input layout. It creates no keys and receives no witness. A separate trusted +ceremony/build process must produce the matching BLS12-381 key/VK and authenticate their hashes. +The local test harness's disposable keys are not production ceremony artifacts. The consumer +image excludes the exporter and setup implementation. Java is build tooling here, not a browser +runtime dependency. + +Recipe hash, graph hash and constraint fingerprint are distinct. The manifest must be trusted by +the application: fetching an arbitrary graph and its self-declared hash does not authorize its +statement. Compilation requires the expected graph hash/fingerprint before private-input calls. +An edited compiler or circuit can require different setup artifacts. + +## Supported authoring subset + +- Field arithmetic/equality, constants; unsigned integers with Java's checked widening/range rules. +- Boolean logic/assertions and field/UInt/Bool `select`; fixed nonempty bytes/indexing/equality. +- Fixed arrays of field/bool/UInt, static `.get(index)` and `.length`; no nested/dynamic arrays. +- Existing `zk.gadgets.poseidonBls12381T3`, `merkleRootBls12381T3` and one-block `sha256`. +- bigint/boolean/Uint8Array and exact-length array inputs with exact public/private names. + Bit-vector convenience and broader gadget profiles remain later milestones. + +Use explicit symbolic assertions. `isEqual()` returns a symbolic boolean; ignoring it adds no +assertion that it is true. `if(symbol)`, `symbol === other`, `symbol + other` and `Boolean(symbol)` +are host operations. Use `select`, `isEqual().assertTrue()` and symbolic arithmetic instead. +Run the typed authoring lint on source files: + +```sh +cd bindings/js +node packages/circuit-lint/src/index.mjs examples/circuit/symbolic-examples.ts +# The installed local package also exposes: zeroj-circuit-lint path/to/circuits.ts +``` + +The CLI is a guardrail, not a JS sandbox or a complete ESLint plugin. `any`, reflection and +aliased conversion functions can bypass it. Callback code is trusted application build code; +do not embed private values in public definition constants or shape choices. Witness-dependent +branching/shape is unsupported. + +## Limits and security + +The compiler image uses `circuit-key-v1`: at most 2 MiB per key, 64 KiB sequential chunks, +one active upload and one successfully imported key per compiled circuit. The standalone +`@zeroj/groth16-prover` retains its 256 KiB limit. Both retain 4,096 wires/domain and 16,384 +coefficients. Poseidon's measured 1,090,303-byte key fits; SHA-256's 103,259 wires do not. +The current load API accepts a bounded caller-owned `Uint8Array`; it does not fetch or stream +arbitrarily large network responses for you. Check the expected length before buffering downloads. + +```ts +const cancellation = new AbortController(); +const key = await compiled.loadKey(trustedKeyArtifact, {signal: cancellation.signal}); +const resources = await compiled.keyResources(); +// reservedBytes is zero after import. peakStagingBytes counts raw key buffering only. +// Reuse key for subsequent proofs; this avoids repeating point/subgroup validation. +``` + +Abort before loading leaves the runtime usable. Abort during transfer/import terminates the +**entire runtime**, invalidating all its handles and capabilities, including any shared verifier. +A new runtime is needed afterwards. Ordinary `compiled.dispose()` releases the circuit, its +upload and owned key after queued work; use runtime disposal for immediate cancellation. +A failed import releases its staging reservation and can be retried. A successful import consumes +the single-key slot even if its handle is subsequently released; use a new runtime to change keys. + +The existing importer still checks the authenticated hash, canonical fields, curve/subgroup +membership, dimensions and relation. Transport counters are not heap measurements. Parsed key +objects, compiler data, FFT/MSM scratch and GC overhead add memory beyond the raw key buffer; +profile evidence and remaining limits are in [M2-KEY-LOADING.md](M2-KEY-LOADING.md). The present +schema limits are not proven heap containment for hostile graphs. + +Compiled graph acceptance is not a circuit-soundness audit. Hints are advice, not constraints; +review intended equations and trust the application-selected statement/VK. Existing gadget +vectors and independent prover checks complement frontend parity tests. Inherited key import +checks and post-proof self-verification do not replace application-authenticated artifacts or +resolve all production ceremony/key-consistency assurance gates. + +The age example does not authenticate an age, the fixed salt offers no privacy demonstration, +and proving a hash preimage does not interpret a document. The [M3 local devnet example](M3-MERKLE.md) +measures a browser proof with explicit root/signer/action policy. Real Cardano applications must bind their public inputs and proof to validator +policy, transaction context and replay/state-transition rules. Groth16/EC primitives here are +not post-quantum. Browser/Node secret operations remain experimental and unassessed for side +channels under ADR-0057. diff --git a/bindings/circuit/REVIEW.md b/bindings/circuit/REVIEW.md new file mode 100644 index 00000000..f00d51d4 --- /dev/null +++ b/bindings/circuit/REVIEW.md @@ -0,0 +1,160 @@ +# First compiler milestone — implementation review + +**Historical M1 review; later milestone self-reviews are appended below.** + +2026-10-30. This is an implementation self-review. Independent review remains a draft-PR gate; +no audit, production, constant-time or post-quantum claim is made. + +## Boundaries checked + +- Graph and recipe tags, explicit compiler policy, fixed canonical order, canonical scalars, + field profiles, input ordering, bounded parsing and references. Existing Java operations + determine constraints; raw/custom/multi-output hints are excluded. +- Five recipe expansions match separately authored Java annotation graphs/fingerprints/full + witnesses. The operation exercise covers every exposed primitive. Standard hash host oracles + and snarkjs provide additional checks; shared-Java parity alone is not cryptographic assurance. +- The compiler accepts expected graph/fingerprint identities before retaining an object that + can receive private input. These values must be selected by the trusted application; a digest + accompanying attacker-selected content does not authenticate its meaning. +- Private operations preserve entropy/experimental-secret opt-in. No consumer witness digest + or witness vector export remains. Diagnostics contain public metadata and timings only. +- Compiled handles own their proving-key handles. Closing a circuit releases Java key objects; + keys cannot prove through another typed circuit/runtime. Runtime disposal remains the way to + interrupt an active computation; compiled.dispose() releases resources through the queue. +- The common session release helper preserves prior behavior and allows composite resources + to release their children. No prover equations, curve/field code or key parser was modified. +- Build-time recipe export uses existing R1CS export and no setup/witness code. Reachability + guards exclude it, fixture generation, setup and native blst from the consumer image. + +## Findings resolved during implementation + +1. Research recipe decoding accepted alternate object-key ordering. The promoted canonical + recipe rebuilds root/input records and compares exact text; negative tests cover alternatives. +2. Research witness digests were useful fixture diagnostics but unsuitable for a consumer API. + They are now confined to test source sets; checkWitness returns only validity and timings. +3. Circuit disposal initially needed owned-key cleanup. The Java state tracks its retained keys + and closes them via BindingSession.release; the TS lifecycle rejects foreign/closed handles. +4. TS-only authors need public artifact identities before browser private work. A build-time + recipe exporter now emits graph/R1CS/manifest without requiring a Java companion circuit. +5. Repeat packaging failed because GraalVM legal files copy as read-only. Packaging recreates + only its generated legal-notice subtree, preserving the original notices unchanged. +6. The first Node oracle test used a different snarkjs module path from worker cleanup and left + oracle workers alive. It now uses the existing CommonJS dependency consistently and terminates + the matching curve workers; the test process exits normally. +7. Gradle parity tests declare exported TS recipes as inputs and diagnostic vectors as outputs. + Changing only the TS frontend therefore invalidates cached Java parity checks. + +## Remaining gates + +The initial promotion is deliberately bounded. Arrays/bits and schema convenience, generated +operation catalogs, full ESLint integration, richer source diagnostics, larger-key transfer and +hard resource accounting remain tracked in PLAN.md. The present estimates and caps are not a +proof of hostile-input heap containment. No test here validates a larger browser proving workload. + +The importer still requires application-trusted keys/VKs and self-verifies generated proofs. +Its inherited production key/ceremony consistency gaps are not resolved by this frontend. +A mismatched but structurally valid VK can be detected at proof self-verification; no rejected +proof is returned. Official gadget assurance, independent review, mobile/Safari validation and +release/distribution review remain necessary. Real Cardano application authorization/replay/state +checks and Yaci measurements belong to a later milestone. + + +## M2 bounded key loading — follow-up self-review + +- Separate compiler-only 2 MiB policy; original standalone key and global JSON/string caps + remain in place. Wire/domain/coefficient budgets and point/subgroup checks are unchanged. +- Small metadata/identity/VK shape are validated before an exact-sized staging reservation. + No concatenated full-key hex string or second assembled binary array in Java. One upload + and one lifetime successful key per compiled worker bound retained key growth. +- Upload handles belong to a compiled state. Bad owned chunks/finish release the reservation; + malformed outer requests cannot dispatch cleanup, but cannot exceed the one-upload bound. + Aborting/releasing the upload or closing the circuit releases its buffer. Successful finish + retains no upload buffer; the existing parser owns only its normal parsed objects. +- Failed imports cannot consume the one-key slot. Digest, canonical coordinate, forbidden + infinity, G1 non-subgroup (order-three point), dimension and relation negatives use real + snarkjs keys, with recomputed hashes where parser validation must be reached. +- AbortSignal during loading terminates all runtime workers/handles; queued release alone + cannot interrupt synchronous Java import. Node and both browser tests exercise actual + cancellation. Deadline failure also invalidates handles. Pre-abort leaves the runtime usable. +- Review caught missing import timing/byte accounting in the legacy direct-request path. + Both import paths now use the same state update and the compatibility test covers it. +- Imported-key reuse avoids repeating validation; no trusted-point cache or skipped check + was introduced. Public counters measure transport only; sampled RSS and JVM allocation + observations are labeled separately in M2-KEY-LOADING.md. +- No equations, core compiler, randomness provider, key parser or curve arithmetic changed. + This self-review does not satisfy the independent reviewer/audit gate for draft PR #116. + +M2 does not prove hostile-input heap containment, stable p95 on real devices, Safari support, +large SHA/Merkle feasibility, full ceremony/key-consistency assurance, or production security. + + +## M3 arrays/Merkle and bounded Cardano example — follow-up self-review + +- R2 design was appended to ADR-0064 before implementation. The new recipe vocabulary has its + own version; scalar/byte-only definitions retain their exact v1 bytes. Java rejects v2 features + in v1, noncanonical descriptor order, nested/byte arrays, forged depth/type/shape and collisions. +- Logical input IDs remain separate from flattened wire names. Public declarations precede + private ones. Array widths/ranges are accounted for in preflight; actual lengths are checked + against the claimed Merkle depth before expansion. Static `.get()` cannot select by a witness. +- TS schemas copy/freeze the nested element descriptor; symbolic arrays use recorder ownership. + A caught shape/index/type error invalidates the recorder. Java enforces types independently. +- Existing `ZkArray`, `ZkMerkle`, boolean/range types and Poseidon parameters provide all gadget + semantics. The Java selection path emits already-admitted gates; raw Select/hints were not + added to the graph codec. Core cryptographic code and the R1CS compiler are untouched. +- Exact Java annotation parity initially exposed the processor's default singularization of + array names. Explicit `@Secret(name=...)` declarations now match TS flattened names exactly; + this required no processor or compiler change. +- Negative tests reach the actual Wasm boundary as well as TS validation. The raw boolean-bit + rejection is preceded by a successful raw witness check on the same handle, avoiding a false + pass caused only by an invalid handle. Field/bool/UInt array indexing executes in Wasm. +- Circuit identity, setup/VK authentication, byte/wire/domain/coefficient caps, importer checks, + WebCrypto entropy and worker cancellation remain inherited. Depth-2 key loading fits by only + 8,033 bytes; no larger-key or production feasibility is inferred. +- The Cardano example pins root and signer and checks the claim action separately from proof + validity. A valid alternate-root proof passes snarkjs first, then fails the application policy. + The signer authorizes arbitrary transaction outputs; no payout restriction is implied. +- Four signed invalid claims reject phase 2. A different transaction ID spending the same UTxO + rejects after the valid claim confirms. The first harness expected `BadInputsUTxO`, whereas the + local node returned `ConwayMempoolFailure: All inputs are spent`; the assertion now accepts and + records those specific spent-input failures. Identical-transaction resubmission is not used as + evidence. The protocol deliberately permits membership-proof reuse on fresh deposits. +- The runner is opt-in, loopback-only and uses ephemeral test wallet keys without logging or + saving them. Its commit-pinned experimental JuLC profile is isolated from core dependency + versions. Raw vectors/keys/transactions/logs/images stay ignored; curated measurements remain. +- Browser results now show compact phase/count summaries, with full identities/resources under + expandable details. This avoids flooding the tutorial with 32-level flattened path names. + +This remains a self-review, not an independent audit. Large browser proving, hostile-input heap +containment, actual mobile/Safari, ceremony consistency, runtime side channels, other Poseidon +profiles, nullifier/private-state protocols and release gates remain open. See M3-MERKLE.md. + + +## Larger-Merkle resource experiment — follow-up self-review + +- R2 resource-profile design precedes implementation in ADR-0064. Java fixes an immutable policy; + untrusted RPC cannot set it. Normal limits, graph/compiler identity, cryptographic parameters, + standalone prover and direct-load caps remain unchanged. +- Review tightened profile ownership: both allocation and import use the policy on the owning + circuit state, even if a future Java session invokes another adapter instance. A regression + test rejects using a research adapter to enlarge a normal state's reservation. +- Compile dimensions reject before staging. Actual key bytes/wires/domain/coefficients still + pass through the unchanged bounded importer. A shape flag does not guarantee a key fits all + limits. The research policy does not certify arbitrary relations as Merkle circuits. +- Research entry point has its own Gradle source set. Build rejects mixed consumer/research + profile selection; packaging requires explicit research output outside npm workspace. Existing + reachability and legal-notice checks remain active. No generated key/image/result is tracked. +- The driver preserves an original timeout/failure if disposal also fails; cancelling invalidates + the runtime. The actual UI tests cancel loading, restore controls, and prove using a fresh + runtime. Default-policy rejection, malformed-key cleanup and import cancellation also execute + against the final image. Browser benchmark retries remove stale proof files and record failures. +- Five engines × three depths cross-verify with independent snarkjs and the JVM; changed roots + reject. Invalid paths/witnesses reject in the measured drivers. JVM 6g/8g and Node RSS are + reported separately, with no invented browser peak-memory or retained-key claim. +- JFR identifies cumulative allocation during required subgroup checks. The read-only zero-column + census and compressed-file sizes are evidence for further work, not implemented cryptographic + shortcuts. Core wire mapping/fingerprints/setup were not changed. + +This self-review does not satisfy independent review/audit gates. Measurements demonstrate the +bounded desktop experiment only. Default promotion, hostile-input containment, actual mobile/ +Safari, secret-dependent timing, production ceremony assurance and private-state protocols remain +open. See [M3-LARGE-MERKLE.md](M3-LARGE-MERKLE.md) and [the core proposal](WIRE-COMPACTION-PLAN.md). diff --git a/bindings/circuit/VALIDATION.md b/bindings/circuit/VALIDATION.md new file mode 100644 index 00000000..eb233c16 --- /dev/null +++ b/bindings/circuit/VALIDATION.md @@ -0,0 +1,104 @@ +# Circuit compiler first milestone — validation + +**Historical M1 baseline.** See [M2 key-loading results](M2-KEY-LOADING.md) for the current +Poseidon proving support, updated checks and resource measurements. + +2026-10-30. Experimental initial promotion on `feat/wasm-circuit-frontend`, stacked above +PR #92 at `844a0625282cc87e77f3c85df1c1cbdd4cc86a3a`. The preceding research is commit +`d425988` in the independent browser-circuit-compiler-poc repository. This report covers the +new ZeroJ implementation, not merely rerunning that research tree. + +## Result and scope + +The first bounded implementation works: portable graph import, canonical symbolic recipes, +existing Java gadget expansion/R1CS compilation, typed inputs, constraint checking, supplied-key +small-circuit proving and verification. Build-time TS recipe export supplies public R1CS/graph/ +identity artifacts without requiring a separately authored Java circuit class. It generates no +witness or ceremony keys and is excluded from the consumer image. + +All five TS recipes match annotation-authored Java graphs, R1CS fingerprints and full JVM witness +vectors. The promoted graph hashes differ intentionally from research because the format tag and +compiler-policy field changed. The underlying fixture R1CS identities remain unchanged. Every +exposed primitive also executes in actual Wasm with positive/negative relation checks. + +No cryptographic algorithms, gadget equations, core R1CS compiler or key parser were changed. +The small common-binding change factors session release so a compiled circuit can release its +owned keys. No new on-chain benchmark, production readiness, constant-time or quantum-resistance +claim is made. Full plan completion and independent review remain outstanding. + +## Reproduction and completed checks + +```sh +export JAVA_HOME="$GRAALVM_HOME" +python3 bindings/wasm/circuit-check.py +``` + +Host: Apple M4 Max/macOS 26.0.1 arm64; GraalVM 25.3.4.1+1.1, Binaryen 132, +Node 22.12.0 with the Wasm exception-handling flag, TypeScript 5.9.3, snarkjs 0.7.6. +The image build uses a 6 GiB compiler heap; compiler fixtures/tests use 2 GiB. These are +build-process settings, not measured browser heap bounds. + +- **21 JVM tests passed:** 16 circuit tests, four shared boundary tests and one hash regression. + Test methods contain multiple cases, including 100 deterministic arithmetic graphs, recipe + parity, malformed graphs/recipes, canonical ordering/policy, input ranges, booleans, hints, + integer overflow/underflow, division, lifetime, secret opt-in and build-time export. +- **17 Node tests passed:** actual packaged image, small proofs and bidirectional snarkjs + interoperability, malformed identity/format/policy, primitive/gadget negatives, wrong keys, + mismatched VK self-verification, cross-runtime/closed handles, key limits, and TS ownership/lint. +- Strict TS compilation and negative type fixtures passed. The type-aware lint accepts the + examples and rejects six deliberately unsafe symbolic JS uses. +- All six browser scenarios passed in **Chromium 156.0.8078.4** and **Firefox 157.0**. Checks + include proof tampering, foreign snarkjs proofs, ordinary SHA digest parity, no eager Wasm + download, no external-origin requests/page errors, and no mobile-viewport horizontal overflow. + Screenshots were reviewed; labels were changed to normal flow to avoid mobile heading crowding. +- Five local SDK tarballs were installed offline outside the workspace: runtime, verifier, + circuit, circuit-lint and bundle-circuit. A strict-TS consumer authored, compiled, proved and + verified cubic and rejected altered public values. No package was published. +- Six R1CS/witness pairs were independently checked by snarkjs. Five disposable keys/proofs + were produced and verified by snarkjs; four fit the unchanged Wasm proving limits. + +The focused check was rerun successfully after fixing repeat packaging of read-only GraalVM +notice files. The separate full nine-image binding check was updated to include this lane but +was not run in full in this iteration. No unchanged core cryptographic suite or Yaci application +run is claimed. Tests use public disposable fixtures; actual private values are not committed. + +## Measured observations + +Single Node test observations, fresh worker per scenario; **not** p50/p95 benchmarks. +Prove time includes the existing self-verification. No new browser/Node heap profiling was done. + +| Fixture | Constraints | Wires | R1CS compile ms | Witness ms | Prove + self-verify ms | +|---|---:|---:|---:|---:|---:| +| multiplier | 2 | 5 | 7.85 | 0.61 | 265.39 | +| cubic | 3 | 8 | 7.62 | 0.62 | 268.76 | +| age_check | 29 | 38 | 6.42 | 0.55 | 269.48 | +| symbolic_select | 3 | 9 | 7.34 | 0.54 | 266.24 | +| poseidon_opening | 241 | 1,622 | 20.91 | 1.03 | Not generated | +| sha256_abc | 25,831 | 103,259 | 112.62 | 12.27 | Not generated | + +Wasm image: **13,411,925 raw bytes**, **4,877,267 gzip**, **3,603,641 Brotli**; +launcher: 112,275 raw / 18,503 Brotli bytes. +Image identity: `circuit-14fbaeaaf81b2fee0ca4ef1e`. +Wasm SHA-256: `2e6da005d0cf89ea6118982926fb06c5a77b02f5564a0b1781efe239e5ab2dd8`. +Classpath/build digest: `edb5d7b81e0d61297b6636e2df68999d552b6187640014fe1e2b42a99f0889f1`. +Build metadata records a dirty implementation worktree based on the above PR #92 commit; +committed source and regenerated checks are the reproducibility inputs. Compression is measured +potential transfer size; the local example server serves raw files. + +## Limits retained and next work + +Poseidon's fixture key is **1,090,303 bytes** versus the existing **262,144-byte** key cap. +The Node wrapper rejects it; it also exceeds the current JSON string/request transport limits. +Its separate snarkjs proof verifies, but **no Poseidon proof was generated in Wasm**. SHA-256 has +**103,259 wires** versus the 4,096-wire/domain cap; no SHA proof/key was generated in this lane. +Both gadgets expand and their witnesses check in Node and both browsers. This does not establish +larger proving feasibility or infeasibility. + +Next: bounded larger-key transfer using the existing importer, identity/point/relation checks, +allocation/cancellation tests and measured memory. Then fixed arrays/Merkle, broader SHA-2/ +BLAKE2b, and the Cardano/Yaci application example. Resource accounting, stable compatibility, +independent review, Safari/physical mobile devices and distribution/secret-handling assessment +remain gates. See [PLAN.md](PLAN.md), [REVIEW.md](REVIEW.md), and [ADR-0064](../../docs/adr/0064-portable-circuits-and-typescript-frontend.md). + +Sources, generators, tests and this summary are tracked. Raw logs, proof/witness vectors, keys, +images, screenshots, copied build notices and npm tarballs remain in ignored build directories. diff --git a/bindings/circuit/WIRE-COMPACTION-PLAN.md b/bindings/circuit/WIRE-COMPACTION-PLAN.md new file mode 100644 index 00000000..d6acceed --- /dev/null +++ b/bindings/circuit/WIRE-COMPACTION-PLAN.md @@ -0,0 +1,72 @@ +# Candidate follow-up: remove unused R1CS wire slots + +**Proposed core work, not implemented or adopted in the current compiler.** This plan follows the +read-only `MerkleWireCensus` in PR #116. It needs a separate core security design and independent +review before implementation/promotion. The larger-key experiment changes resource admission only. + +The existing compiler inlines many linear expressions while preserving the graph's complete wire +numbering in exported R1CS. The census checks all nonzero A/B/C entries and always preserves wire 0 +and public input slots. It found: + +| Depth | Current wires | Referenced plus constant/public | Unreferenced | Potential raw query bytes removed | +|---|---:|---:|---:|---:| +| 1 | 1,631 | 247 | 1,384 | 664,320 | +| 2 | 3,054 | 491 | 2,563 | 1,230,240 | +| 8 | 11,592 | 1,955 | 9,637 | 4,625,760 | +| 16 | 22,976 | 3,907 | 19,069 | 9,153,120 | +| 32 | 45,744 | 7,811 | 37,933 | 18,207,840 | + +The byte estimate is 480 per removed non-public wire in the current uncompressed BLS12-381 zkey +query sections (A G1 + B G1 + B G2 + C G1). It excludes all headers, coefficients, H query and VK. +It is an estimate for a newly generated compatible key, not a tested compacted key size or proof. +All-zero query entries in the generated depth-8/16/32 keys corroborate the unused-column count. +These entries already avoid nontrivial point/subgroup work, so byte savings do **not** establish +proportional import speedups. Coefficient counts and FFT domains remain separate limits. + +## Security invariant and implementation path + +Removing a column whose A/B/C coefficients are all zero preserves the constraint equations: +project any old witness onto the retained columns, and every dot product remains identical. +Conversely, any satisfying projected witness extends to the old relation by assigning arbitrary +field values to the removed columns. This simple equivalence is the candidate's mathematical +basis; the implementation still needs to preserve public-input interpretation and witness mapping. +Unused wire slots are not, by themselves, evidence of an under-constrained intended statement. + +1. Freeze the input constraint system. Compute a deterministic retained-wire map from every + nonzero A/B/C term; always preserve constant one and **all ordered public inputs**, even unused. + Retain remaining active wires in original ID order. Reject invalid IDs/overflows. Zero terms + require explicit canonical treatment consistent with existing compiler/exporter rules. +2. Remap A/B/C indices without changing coefficients, row order, field, public count or equations. + Return original count, compact count, forward/reverse maps and a new fingerprint. Treat mapping + metadata as authenticated compiler output, not a caller-supplied unchecked shortcut. +3. Initially retain the original graph witness calculator and project its complete result through + the checked map. Do not remove witness instructions merely because their output column is + absent: later hints/operations may still depend on that value. This first phase need not save + witness-calculation memory; its target is exported relation/key/query overhead. +4. Introduce an explicit new compiler policy/manifest contract. Keep the current policy opt-in + behavior unchanged; do not silently compact when accepting an old graph/key fingerprint. + Old proving keys are incompatible. Require new setup artifacts and authenticated identities. +5. Reuse existing R1CS export, setup/key importer, FFT/MSM and verifier. The operation catalog, + symbolic semantics and cryptographic parameters do not need a second implementation. + +## Required evidence + +- Property checks of all A/B/C evaluations before/after projection over generated field witnesses, + including invalid witnesses. Keep constant/public slots, unused public and private inputs, + zero coefficients, sparse IDs, public order and all exceptional arithmetic cases explicit. +- Differential checks against the unchanged compiler for arithmetic, ranges/booleans, selectors, + division/zero tests, bit decomposition, Poseidon/Merkle and SHA/hint-heavy circuits. +- Known-answer vectors and adversarial witness tests for the original gadgets; honest proof success + alone is not an under-constraint test. Match expected rejected relations after projection. +- Newly generated snarkjs keys and proofs cross-verified by JVM/Wasm/snarkjs and the existing + on-chain codec/verifier. Tampered maps, keys and fingerprints must fail admission. +- Demonstrate that every published compiler backend/provider either uses an explicit correct + mapping or retains its old policy. Do not let R1CS changes silently affect PlonK/Halo2 behavior. +- Measure setup/key/import/proof/memory separately. Do not omit point validation or trust arbitrary + prevalidated query objects to improve benchmark numbers. A compressed download is not a reduced + in-memory key, and reduced query bytes do not imply fewer active subgroup checks. + +Even the projected depth-8 key would remain above the current 2 MiB cap by this estimate, and its +52,037 coefficient entries exceed 16,384. Depth 16 would approach the 4,096-wire bound, while +32 would exceed it; all need a separately justified resource policy. Compaction is useful research, +not an automatic reason to raise default SDK limits or claim production browser proving. diff --git a/bindings/circuit/research/README.md b/bindings/circuit/research/README.md new file mode 100644 index 00000000..386af860 --- /dev/null +++ b/bindings/circuit/research/README.md @@ -0,0 +1,102 @@ +# Larger Merkle resource experiment + +This opt-in experiment follows [M3](../M3-MERKLE.md) and the R2 extension in +[ADR-0064](../../../docs/adr/0064-portable-circuits-and-typescript-frontend.md). +It uses the same Java constraints, key importer and Groth16 prover with a separately selected +resource policy. The normal SDK keeps its 2 MiB / 4,096-wire/domain / 16,384-coefficient policy. + +**Research profile:** 32 MiB key, 65,536 wires, 8,192 domain, 262,144 coefficients, 64 public inputs. +These are admission ceilings, not a hard heap bound. The profile does not certify that an arbitrary +relation is a Merkle circuit. Java chooses it at image construction; RPC cannot change it. +Keys remain chunked at 64 KiB with one upload and one successfully imported key per circuit. +All hash, field, point, subgroup, relation, witness and self-verification checks remain active. +The driver explicitly selects a 600-second call deadline; normal runtime defaults are unchanged. + +No source or package in this directory is a production ceremony or an anonymous state-machine +protocol. Witnesses are published test data. Browser secrets remain experimental/variable-time +under ADR-0057; Groth16/BLS12-381 are not post-quantum. The local file loaders assume the generated +fixtures are trusted. Production downloads need authenticated manifests and bounded buffering; +a self-declared hash does not authenticate a circuit or key. + +## Generate and run + +First build the normal fixtures/workspace as in the [circuit guide](../README.md). From repo root: + +```sh +export JAVA_HOME="$GRAALVM_HOME" +node bindings/js/scripts/circuit-large-setup.mjs --research-only +python3 bindings/wasm/build.py circuit-large-research +python3 bindings/wasm/package.py --research-output circuit-large-research +``` + +The setup uses pinned snarkjs 0.7.6, BLS12-381 powers of tau with power 13, fresh development +contributions and the already-exported depth-8/16/32 `.r1cs`/`.wtns` files. It verifies each key +and an independent snarkjs proof. It writes only `bindings/js/build/circuit-large`; the default +manifest/keys remain unchanged. These disposable contributions are not a production ceremony. + +The research Java entry point lives in a separate Gradle source set, excluded from the normal +consumer classpath. Packaging refuses this profile without `--research-output`; assets and legal +notices are generated under ignored `build/circuit-large/bundle`, outside the npm workspace. +No research npm package is published or added to the release inventory. Native blst and setup +remain prohibited from the Wasm image by the existing reachability guards. + +```sh +# JVM: run each depth (8, 16, 32) with each heap (6g, 8g). +./gradlew -p bindings/wasm :adapter-circuit:largeMerkleBench \ + -PmerkleDepth=8 -PmerkleHeap=6g --max-workers=2 --console=plain +# Optional JFR on a JVM run; path is relative to bindings/wasm: +# -PmerkleJfr=../js/build/circuit-large/merkle_d32/import-prove-6g.jfr + +cd bindings/js +node --experimental-wasm-exnref scripts/circuit-large-node.mjs merkle_d8 +node scripts/circuit-large-browser.mjs chromium merkle_d8 +node scripts/circuit-large-browser.mjs firefox merkle_d8 +# Repeat for merkle_d16 and merkle_d32. Each driver uses one fresh worker and 3 reused-key proofs. +node --experimental-wasm-exnref tests/circuit-large-negative.mjs +node scripts/circuit-large-ui.mjs # actual buttons, cancellation/recovery, final-image proof +node scripts/circuit-large-census.mjs + +# Interactive local page, with progress and worker cancellation: +python3 scripts/serve.py --port 8791 +# http://127.0.0.1:8791/examples/circuit-large/ +``` + +Desktop experiments can take several minutes per import. The page processes a path, not the +full tree: depth 32 does not allocate 2^32 leaves. Closing/cancelling its worker abandons runtime +handles; it does not establish erasure of every immutable JS/Java copy. The page does not upload +inputs. Research assets must be built explicitly; they are never fetched by the normal demo. + +After all five engines (Node, Chromium, Firefox, JVM-6g, JVM-8g) have produced all three cases: + +```sh +# From repo root: +./gradlew -p bindings/wasm :adapter-circuit:largeMerkleInterop \ + :adapter-circuit:merkleWireCensus --max-workers=2 --console=plain +node --experimental-wasm-exnref bindings/js/scripts/circuit-large-interop.mjs +``` + +JVM benchmarks generate one first and one reused-key proof per run; JS/browser drivers generate +one first and three reused-key proofs. The UI check uses a fresh runtime after cancellation. + +The JVM interop also exercises byte/wire/domain/coefficient limits using an unchanged real key +and tighter importer policies. The Node negatives test normal-policy rejection before allocation, +research length/RPC-policy bounds, truncation, bad digest, oversized dimensions with recomputed +hashes, cleanup and cancellation during actual import. Normal tests still run independently. + +All raw vectors, keys, JSON results, screenshots, traces and images stay under ignored `build/`. +The curated [results](../M3-LARGE-MERKLE.md) distinguish timings, heap/RSS scope, measured engines, +security gates and options. This experiment does not change graph or R1CS fingerprints, nor +implement wire compaction or a new on-chain application protocol. + + +For allocation profiling, export the optional JFR recording and use the existing summarizer: + +```sh +# Repo root; exported JSON can be much larger than the binary recording. +jfr print --json --stack-depth 64 --events jdk.ObjectAllocationSample bindings/js/build/circuit-large/merkle_d32/import-prove-6g.jfr > /tmp/zeroj-merkle-allocations.json +python3 bindings/wasm/summarize-circuit-jfr.py /tmp/zeroj-merkle-allocations.json +``` + +JFR sample weights estimate cumulative allocation, not live/peak heap. The benchmark's JVM heap +sampler and Node process RSS have separate scopes; no browser peak-memory claim is made. Tests +use ordinary development fixtures; the census scripts are not parsers for hostile input files. diff --git a/bindings/js/examples/circuit-large/app.mjs b/bindings/js/examples/circuit-large/app.mjs new file mode 100644 index 00000000..bdb58592 --- /dev/null +++ b/bindings/js/examples/circuit-large/app.mjs @@ -0,0 +1,17 @@ +import {largeResearch} from '../circuit/large-research.mjs'; +const box=document.getElementById('result'),cancel=document.getElementById('cancel');let controller; +const load=async(name,kind)=>{const r=await fetch('/build/circuit-large/'+name,{redirect:'error'});if(!r.ok)throw Error('BUILD_RESEARCH_FIXTURES_FIRST');return kind==='text'?r.text():r.arrayBuffer();}; +cancel.addEventListener('click',()=>controller?.abort()); +for(const button of document.querySelectorAll('button[data-depth]'))button.addEventListener('click',async()=>{ + for(const b of document.querySelectorAll('button[data-depth]'))b.disabled=true; + controller=new AbortController();cancel.disabled=false;delete box.dataset.status;document.getElementById('details').hidden=true; + try{ + const {proof,...result}=await largeResearch('merkle_d'+button.dataset.depth,load,{signal:controller.signal,onPhase:p=>{box.textContent=p+'…';}}); + box.textContent=JSON.stringify({name:result.name,keyBytes:result.keyBytes, + constraints:result.description.constraints,wires:result.description.wires,timingsMs:result.timingsMs, + warmProofMs:result.warmProofMs,invalidPathRejected:result.invalidPathRejected, + changedRootRejected:result.changedRootRejected,snarkjsAccepted:result.snarkjsAccepted},null,2); + document.getElementById('full-result').textContent=JSON.stringify(result,null,2);document.getElementById('details').hidden=false;box.dataset.status='passed'; + }catch(e){box.textContent='Experiment stopped: '+(e.code??e.message);box.dataset.status='stopped';} + finally{controller=undefined;cancel.disabled=true;for(const b of document.querySelectorAll('button[data-depth]'))b.disabled=false;} +}); diff --git a/bindings/js/examples/circuit-large/index.html b/bindings/js/examples/circuit-large/index.html new file mode 100644 index 00000000..0267dc7f --- /dev/null +++ b/bindings/js/examples/circuit-large/index.html @@ -0,0 +1,15 @@ + +ZeroJ · Larger Merkle research + +
+

ZEROJ · OPT-IN RESOURCE EXPERIMENT

How deep can a browser prove?

+

Compile an existing Java Poseidon Merkle relation, load its development key, and generate a fresh proof locally. This page selects a separate research image. Normal SDK limits remain unchanged.

+

Published test witnesses and disposable setup. Experimental secret operations; no production, anonymity or post-quantum claim. Key loading may take several minutes and consume substantial memory. No inputs are uploaded.

+

Choose a fixed tree depth

A depth-8 tree has 256 positions, depth 16 has 65,536, and depth 32 has about 4.3 billion. Only the membership path is processed here; the full tree is not built or stored.

+

The experiment admits at most a 32 MiB key, 65,536 wires, 8,192 domain cells and 262,144 coefficient entries. These are admission limits, not a total memory guarantee.

+ + +
Select a depth. Build the research assets and fixtures first.
+ +

Return to the default compiler examples

+
diff --git a/bindings/js/examples/circuit/app.mjs b/bindings/js/examples/circuit/app.mjs new file mode 100644 index 00000000..b18d7f3a --- /dev/null +++ b/bindings/js/examples/circuit/app.mjs @@ -0,0 +1,21 @@ +import {scenario} from './scenarios.mjs'; +const load=async(name,kind)=>{const r=await fetch('../../build/circuit/'+name,{redirect:'error'});if(!r.ok)throw Error('FIXTURE_LOAD_FAILED');return kind==='text'?r.text():r.arrayBuffer();}; +for(const button of document.querySelectorAll('button[data-name]'))button.addEventListener('click',async()=>{ + const name=button.dataset.name,target=document.getElementById(name); + for(const b of document.querySelectorAll('button'))b.disabled=true; + target.textContent='Compiling in the Wasm worker…';delete target.dataset.status; + try{ + const {proof,verificationKey,...result}=await scenario(name,load); + for(const k of Object.keys(result))if(k.endsWith('Ms'))result[k]=Math.round(result[k]*10)/10; + const fields=['name','authoring','constraints','wires','valid','proofGenerated','proofLimit','expandMs','compileMs','witnessMs','constraintCheckMs','keyLoadMs','proveAndSelfVerifyMs','totalMs','tamperedAccepted','snarkjsAccepted','hostDigestMatches']; + target.textContent=JSON.stringify(Object.fromEntries(fields.filter(k=>k in result).map(k=>[k,result[k]])),null,2);target.dataset.status='passed'; + let details=target.nextElementSibling; + if(!details?.classList.contains('artifact-details')){ + details=document.createElement('details');details.className='artifact-details'; + const summary=document.createElement('summary');summary.textContent='Artifact identity and resource details'; + details.append(summary,document.createElement('pre'));target.insertAdjacentElement('afterend',details); + } + details.querySelector('pre').textContent=JSON.stringify(result,null,2); + }catch(error){target.textContent='Example failed: '+(error.code??error.message);target.dataset.status='failed';} + finally{for(const b of document.querySelectorAll('button'))b.disabled=false;} +}); diff --git a/bindings/js/examples/circuit/benchmark.mjs b/bindings/js/examples/circuit/benchmark.mjs new file mode 100644 index 00000000..7571a026 --- /dev/null +++ b/bindings/js/examples/circuit/benchmark.mjs @@ -0,0 +1,37 @@ +// Public development fixture only. Cold = fresh worker, not cold filesystem/browser cache. +import {bundle} from '@zeroj/bundle-circuit'; +import {createCircuitCompiler} from '@zeroj/circuit'; +import {createGroth16Verifier} from '@zeroj/groth16-verifier'; +import {circuitRuntime,allDefinitions,typedFixtureInputs} from './scenarios.mjs'; +const percentile=(values,q)=>[...values].sort((a,b)=>a-b)[Math.ceil(q*values.length)-1]; +export async function benchmark(load,{name="poseidon_opening",runs=5,warmRuns=5,onPhase=()=>{}}={}){ + if(!Number.isInteger(runs)||runs<1||runs>20||!Number.isInteger(warmRuns)||warmRuns<1||warmRuns>20)throw Error('BENCHMARK_LIMIT'); + const f=JSON.parse(await load('manifest.json','text')).find(x=>x.name===name); + if(!f?.developmentOnly)throw Error('DEVELOPMENT_FIXTURE_REQUIRED'); + const definition=allDefinitions.find(x=>x.name===name),input=typedFixtureInputs(definition,JSON.parse(await load(name+'/inputs.json','text'))); + const artifact={zkey:new Uint8Array(await load(name+'/key.zkey','bytes')),verificationKey:JSON.parse(await load(name+'/vk.json','text')), + expectedSha256:Uint8Array.from(f.keySha256.match(/../g),v=>parseInt(v,16))}; + const cold=[],warm=[];let resources; + for(let i=0;i{onPhase(label);const start=performance.now();const result=await fn();row[label+'Ms']=performance.now()-start;return result;}; + try{ + await phase('startup',()=>r.preload('circuit')); + c=await phase('compile',()=>createCircuitCompiler(r).compile(definition,{expectedGraphSha256:f.graphSha256,fingerprint:f.fingerprint})); + await phase('witness',()=>c.checkWitness(input)); + const key=await phase('keyLoad',()=>c.loadKey(artifact));resources=await c.keyResources(); + row.importMs=resources.lastImportMs; + const verifier=createGroth16Verifier(r); + const proof=await phase('proveAndSelfVerify',()=>c.prove(key,input)); + if(!await phase('verify',()=>verifier.verify({...proof,verificationKey:artifact.verificationKey})))throw Error('VERIFY_FAILED'); + cold.push(row); + if(i===runs-1)for(let j=0;j[k,{p50:percentile(cold.map(r=>r[k]),.5),p95:percentile(cold.map(r=>r[k]),.95)}])); + return {fixture:name,developmentOnly:true,image:bundle.id,graphSha256:f.graphSha256,fingerprint:f.fingerprint,keySha256:f.keySha256,keyBytes:artifact.zkey.length,coldDefinition:'fresh worker; host code/file caches may be warm', + runs,warmRuns,cold,warmProveAndSelfVerifyMs:warm,summary,warm:{p50:percentile(warm,.5),p95:percentile(warm,.95)},resources}; +} diff --git a/bindings/js/examples/circuit/index.html b/bindings/js/examples/circuit/index.html new file mode 100644 index 00000000..74bf7f4c --- /dev/null +++ b/bindings/js/examples/circuit/index.html @@ -0,0 +1,43 @@ + +ZeroJ · TypeScript circuit compiler + +
+

ZEROJ · EXPERIMENTAL CIRCUIT SDK

TypeScript intent.
Java constraints.

+

Write a typed circuit or import a Java-authored graph. ZeroJ’s existing compiler and gadgets run inside a Wasm worker. Small examples generate and verify a fresh proof locally.

+
TS recipe / Java graph → Java symbolic APIs → R1CS → witness → proof
+

Development fixtures only. No production ceremony or browser side-channel assurance. No inputs are uploaded. Poseidon now generates a proof using bounded key loading. SHA-256 remains witness-only under the current proving limits.

+

01 · PORTABLE JAVA GRAPH

A hidden multiplier

Import a Java-authored graph, compile it in Wasm, and prove 3 × 11 = 33. No circuit-specific Java example is included in the image.

Ready
+

02 · TYPED FIELD

A private computation

Author y = x³ + x + 5 in TypeScript. The resulting graph must match the Java fixture before the worker accepts private inputs.

s.x.mul(s.x).mul(s.x).add(s.x)
+  .add(zk.constant(5n)).assertEqual(p.y);
Ready
+

03 · TYPED UINT

Meet a threshold

A range-constrained secret age of 21 meets a public threshold of 18. This arithmetic example does not authenticate anyone’s age.

defineCircuit({
+  name: 'age_check',
+  public: { threshold: uint(8) },
+  secret: { age: uint(8) }
+}, ({public: p, secret: s}) => {
+  s.age.gte(p.threshold).assertTrue();
+});
Ready
+

04 · TYPED BOOLEAN

Choose within the circuit

Use symbolic select() to constrain a private choice. JavaScript if(symbol) would treat the object as true; the authoring lint detects typed uses of that mistake.

s.chooseLeft.select(s.left, s.right)
+  .assertEqual(p.selected);
Ready
+

05 · EXISTING POSEIDON GADGET

A commitment relation

Call ZeroJ’s BLS12-381 T3 Poseidon implementation. Compile the 241-constraint relation, load its 1.09 MB key in bounded chunks, then generate and verify a fresh proof in the browser. The salt is public test data; this fixture is not a private application.

zk.gadgets.poseidonBls12381T3(s.value, s.salt)
+  .assertEqual(p.commitment);
Ready
+

06 · EXISTING SHA-256 GADGET

A Web2 digest relation

A compact recipe expands SHA-256("abc") into 25,831 constraints using Java’s existing gadget. WebCrypto separately checks the public fixture digest. Browser proving is beyond the current wire/domain limits.

zk.gadgets.sha256(s.message).assertEqual(p.digest);
Ready
+

07 · FIXED ARRAYS + EXISTING MERKLE GADGET

Prove allowlist membership

Keep the leaf, sibling and path direction private to the prover. The public root identifies the approved two-leaf tree. This demo uses known test values; it does not establish a person’s identity or hide these published fixtures.

+Public Poseidon rootSibling: 100Private leaf: 42Hash(left, right) +
secret: {
+  leaf: field(),
+  siblings: array(field(), 1),
+  pathBits: array(bool(), 1)
+}
+// false = current node is left; true = current node is right
+zk.gadgets.merkleRootBls12381T3(
+  s.leaf, s.siblings, s.pathBits
+).assertEqual(p.root);
+

The local Cardano runner pins the root and authorized signer and accepts only its claim action. The signature authorizes the transaction, and Cardano prevents the same UTxO from being spent twice. Membership proofs remain reusable for other deposits. This is not an anonymous, nullifier-based protocol.

+
Ready
+
Ready
+

08 · RESOURCE BOUNDARIES

Explore larger paths

These depths compile and check witnesses. They exceed the current 4,096-wire proving limit; no key is loaded and no browser proof is generated. This is a limit of the experimental adapter profile, not a cryptographic limit of Merkle trees.

+
Ready
+
Ready
+
Ready
+

One selected compiler/prover image. Application-trusted circuit identities and ceremony keys remain required. Other ZeroJ Wasm examples

+
diff --git a/bindings/js/examples/circuit/large-research.mjs b/bindings/js/examples/circuit/large-research.mjs new file mode 100644 index 00000000..8b15b244 --- /dev/null +++ b/bindings/js/examples/circuit/large-research.mjs @@ -0,0 +1,49 @@ +// Opt-in resource experiment. Never imported by the default example page or normal SDK bundle. +import {createRuntime} from '@zeroj/runtime'; +import {createCircuitCompiler} from '@zeroj/circuit'; +import {createGroth16Verifier} from '@zeroj/groth16-verifier'; +import {bundle} from '../../build/circuit-large/bundle/dist/index.js'; +import {merkleMembership} from '../../build/circuit-examples/symbolic-examples.js'; +import {typedFixtureInputs} from './scenarios.mjs'; +export const largeRuntime=()=>createRuntime({bundles:[bundle],experimentalSecrets:true,timeoutMs:600_000}); +export async function largeResearch(name,load,{onPhase=()=>{},warmRuns=3,signal}={}){ + if(!['merkle_d8','merkle_d16','merkle_d32'].includes(name)||!Number.isInteger(warmRuns)||warmRuns<1||warmRuns>5)throw Error('RESEARCH_CASE_LIMIT'); + const f=JSON.parse(await load('manifest.json','text')).find(x=>x.name===name); + if(!f?.developmentOnly||!f.proofSupported||f.experiment!=='large-merkle-v1')throw Error('DEVELOPMENT_FIXTURE_REQUIRED'); + if(signal?.aborted)throw Error('ABORTED'); + const r=largeRuntime(),timingsMs={};let c,failed=false; + const abort=()=>{void r.dispose();};signal?.addEventListener('abort',abort,{once:true}); + const phase=async(label,action)=>{onPhase(label);const t=performance.now();const v=await action();timingsMs[label]=performance.now()-t;return v;}; + try{ + await phase('startup',()=>r.preload('circuit')); + const definition=merkleMembership(Number(name.slice(8))); + c=await phase('compile',()=>createCircuitCompiler(r).compile(definition,{expectedGraphSha256:f.graphSha256,fingerprint:f.fingerprint})); + if(c.description.keyProfile!=='circuit-key-research-merkle-v1'||c.description.maxKeyBytes!==33554432)throw Error('RESEARCH_PROFILE_MISMATCH'); + const input=typedFixtureInputs(definition,JSON.parse(await load(name+'/inputs.json','text'))); + await phase('witnessAndCheck',()=>c.checkWitness(input)); + const bytes=await phase('readKey',async()=>new Uint8Array(await load(name+'/key.zkey','bytes'))); + const vk=JSON.parse(await load(name+'/vk.json','text')); + const key=await phase('keyLoad',()=>c.loadKey({zkey:bytes,expectedSha256:Uint8Array.from(f.keySha256.match(/../g),h=>parseInt(h,16)),verificationKey:vk})); + const resources=await c.keyResources();timingsMs.keyImport=resources.lastImportMs; + const bad=structuredClone(input);bad.secret.pathBits[0]=!bad.secret.pathBits[0];let rejected=false; + try{await c.prove(key,bad);}catch(e){if(e.code!=='INVALID_REQUEST')throw e;rejected=true;} + if(!rejected)throw Error('INVALID_PATH_ACCEPTED'); + const proof=await phase('firstProofAndSelfVerify',()=>c.prove(key,input)); + const verifier=createGroth16Verifier(r); + if(!await phase('verify',()=>verifier.verify({...proof,verificationKey:vk})))throw Error('PROOF_REJECTED'); + if(await verifier.verify({...proof,publicInputs:[proof.publicInputs[0]+1n],verificationKey:vk}))throw Error('CHANGED_ROOT_ACCEPTED'); + const foreign=JSON.parse(await load(name+'/snarkjs-proof.json','text')); + const foreignInputs=JSON.parse(await load(name+'/snarkjs-public.json','text')).map(BigInt); + if(!await verifier.verify({proof:foreign,publicInputs:foreignInputs,verificationKey:vk}))throw Error('SNARKJS_REJECTED'); + const warm=[];for(let i=0;ifs.readFile(new URL('../../build/circuit/'+name,import.meta.url),kind==='text'?'utf8':undefined); +for(const name of process.argv.slice(2).length?process.argv.slice(2):['age_check','poseidon_opening']){ + const {proof,verificationKey,...result}=await scenario(name,load); + console.log(JSON.stringify(result,null,2)); +} diff --git a/bindings/js/examples/circuit/operation-examples.ts b/bindings/js/examples/circuit/operation-examples.ts new file mode 100644 index 00000000..30da623d --- /dev/null +++ b/bindings/js/examples/circuit/operation-examples.ts @@ -0,0 +1,20 @@ +/** Test-only algebra exercises; no cryptographic implementation. */ +import {defineCircuit,field,uint,bool,bytes} from '@zeroj/circuit'; +export const operations=defineCircuit({name:'operations',public:{sum:field()},secret:{x:field(),y:field(),u:uint(8),v:uint(8),yes:bool(),no:bool(),a:bytes(1),b:bytes(1)}},({public:p,secret:s,zk})=>{ + s.x.add(s.y).assertEqual(p.sum); + s.x.sub(s.y).assertEqual(zk.constant(1n)); + s.x.mul(s.y).assertEqual(zk.constant(6n)); + s.x.div(s.y).mul(s.y).assertEqual(s.x); + s.x.isEqual(s.y).assertFalse(); + s.u.add(s.v).asField().assertEqual(zk.constant(7n)); + s.u.sub(s.v).asField().assertEqual(zk.constant(3n)); + s.u.mul(s.v).asField().assertEqual(zk.constant(10n)); + s.u.lt(s.v).assertFalse();s.u.lte(s.u).assertTrue();s.u.gt(s.v).assertTrue();s.u.gte(s.u).assertTrue(); + s.u.isEqual(s.v).assertFalse();s.u.inRange(s.v,s.u).assertTrue();s.u.assertEqual(s.u); + s.yes.and(s.no).assertFalse();s.yes.or(s.no).assertTrue();s.yes.xor(s.no).assertTrue();s.yes.not().assertFalse(); + s.yes.isEqual(s.no).assertFalse();s.no.assertEqual(s.no);s.yes.asField().assertEqual(zk.constant(1n)); + s.yes.select(s.x,s.y).assertEqual(s.x);s.no.select(s.u,s.v).assertEqual(s.v);s.yes.select(s.no,s.yes).assertFalse(); + s.a.get(0).asField().assertEqual(zk.constant(42n));s.a.isEqual(s.b).assertTrue();s.a.assertEqual(s.b); +}); +export const underflow=defineCircuit({name:'underflow',public:{},secret:{u:uint(8),v:uint(8)}},({secret:s})=>{s.u.sub(s.v);}); +export const widthOverflow=defineCircuit({name:'widthOverflow',public:{},secret:{u:uint(200),v:uint(100)}},({secret:s})=>{s.u.mul(s.v);}); diff --git a/bindings/js/examples/circuit/scenarios.mjs b/bindings/js/examples/circuit/scenarios.mjs new file mode 100644 index 00000000..066a969e --- /dev/null +++ b/bindings/js/examples/circuit/scenarios.mjs @@ -0,0 +1,51 @@ +import {createRuntime} from '@zeroj/runtime'; +import {createCircuitCompiler} from '@zeroj/circuit'; +import {createGroth16Verifier} from '@zeroj/groth16-verifier'; +import {bundle} from '@zeroj/bundle-circuit'; +import {symbolicExamples,merkleExamples,allowlist} from '../../build/circuit-examples/symbolic-examples.js'; +export {symbolicExamples,merkleExamples,allowlist}; +export const allDefinitions=[...symbolicExamples,...merkleExamples,allowlist]; +export const circuitRuntime=(options={})=>createRuntime({bundles:[bundle],experimentalSecrets:true,...options}); +export function typedFixtureInputs(definition,flat){ + const input={public:{},secret:{}}; + for(const d of JSON.parse(definition.recipe).inputs){ + input[d.visibility][d.name]=d.type==='array'?Array.from({length:d.length},(_,i)=>d.element.type==='bool'?flat[d.name+'_'+i]==='1':BigInt(flat[d.name+'_'+i])):d.type==='bytes'?Uint8Array.from({length:d.length},(_,i)=>Number(flat[d.name+'_'+i])): + d.type==='bool'?flat[d.name]==='1':BigInt(flat[d.name]); + } + return input; +} +export async function scenario(name,load,{graph=false}={}){ + const fixture=JSON.parse(await load('manifest.json','text')).find(x=>x.name===name); + if(!fixture?.developmentOnly)throw Error('UNKNOWN_DEVELOPMENT_FIXTURE'); + const r=circuitRuntime(),verifier=createGroth16Verifier(r),start=performance.now();let circuit; + try{ + const definition=allDefinitions.find(x=>x.name===name),compiler=createCircuitCompiler(r); + const identity={expectedGraphSha256:fixture.graphSha256,fingerprint:fixture.fingerprint}; + const flat=JSON.parse(await load(name+'/inputs.json','text')); + const useGraph=graph||!definition; + circuit=useGraph?await compiler.compileGraph(await load(name+'/graph.json','text'),identity):await compiler.compile(definition,identity); + const input=useGraph?Object.fromEntries(Object.entries(flat).map(([k,v])=>[k,BigInt(v)])):typedFixtureInputs(definition,flat); + const result={...circuit.description,authoring:useGraph?'portable-graph':'symbolic-recipe',...await circuit.checkWitness(input),proofGenerated:false}; + if(fixture.proofSupported){ + let t=performance.now(); + const verificationKey=JSON.parse(await load(name+'/vk.json','text')); + const key=await circuit.loadKey({zkey:new Uint8Array(await load(name+'/key.zkey','bytes')), + expectedSha256:Uint8Array.from(fixture.keySha256.match(/../g),x=>parseInt(x,16)),verificationKey}); + result.keyLoadMs=performance.now()-t;result.keyResources=await circuit.keyResources();t=performance.now(); + const proof=await circuit.prove(key,input);result.proveAndSelfVerifyMs=performance.now()-t; + if(!await verifier.verify({...proof,verificationKey}))throw Error('PROOF_REJECTED'); + const bad=[...proof.publicInputs];bad[0]+=1n; + if(await verifier.verify({...proof,publicInputs:bad,verificationKey}))throw Error('TAMPER_ACCEPTED'); + const foreignProof=JSON.parse(await load(name+'/snarkjs-proof.json','text')); + const foreignInputs=JSON.parse(await load(name+'/snarkjs-public.json','text')).map(BigInt); + if(!await verifier.verify({proof:foreignProof,publicInputs:foreignInputs,verificationKey}))throw Error('SNARKJS_PROOF_REJECTED'); + Object.assign(result,{proofGenerated:true,tamperedAccepted:false,snarkjsAccepted:true,proof,verificationKey}); + }else result.proofLimit=fixture.proofLimit; + if(name==='sha256_abc'){ + const digest=new Uint8Array(await crypto.subtle.digest('SHA-256',new Uint8Array([97,98,99]))); + if(!digest.every((v,i)=>String(v)===flat['digest_'+i]))throw Error('HOST_DIGEST_MISMATCH'); + result.hostDigestMatches=true; + } + result.totalMs=performance.now()-start;return result; + }finally{if(circuit)await circuit.dispose();await r.dispose();} +} diff --git a/bindings/js/examples/circuit/symbolic-examples.ts b/bindings/js/examples/circuit/symbolic-examples.ts new file mode 100644 index 00000000..cbce32a4 --- /dev/null +++ b/bindings/js/examples/circuit/symbolic-examples.ts @@ -0,0 +1,35 @@ +import {defineCircuit,field,uint,bool,bytes,array} from '@zeroj/circuit'; + +export const symbolicCubic=defineCircuit({name:'cubic',public:{y:field()},secret:{x:field()}},({public:p,secret:s,zk})=>{ + s.x.mul(s.x).mul(s.x).add(s.x).add(zk.constant(5n)).assertEqual(p.y); +}); +export const ageCheck=defineCircuit({name:'age_check',public:{threshold:uint(8)},secret:{age:uint(8)}},({public:p,secret:s})=>{ + s.age.gte(p.threshold).assertTrue(); +}); +export const typedSelection=defineCircuit({name:'symbolic_select',public:{selected:field()}, + secret:{chooseLeft:bool(),left:field(),right:field()}},({public:p,secret:s})=>{ + s.chooseLeft.select(s.left,s.right).assertEqual(p.selected); +}); +export const poseidonOpening=defineCircuit({name:'poseidon_opening',public:{commitment:field()},secret:{value:field(),salt:field()}},({public:p,secret:s,zk})=>{ + zk.gadgets.poseidonBls12381T3(s.value,s.salt).assertEqual(p.commitment); +}); +export const sha256Preimage=defineCircuit({name:'sha256_abc',public:{digest:bytes(32)},secret:{message:bytes(3)}},({public:p,secret:s,zk})=>{ + zk.gadgets.sha256(s.message).assertEqual(p.digest); +}); +export const symbolicExamples=[symbolicCubic,ageCheck,typedSelection,poseidonOpening,sha256Preimage] as const; + +/** An input leaf is a field value; this profile does not implicitly hash/encode a document. */ +export const merkleMembership=(depth:number)=>defineCircuit({name:'merkle_d'+depth, + public:{root:field()},secret:{leaf:field(),siblings:array(field(),depth),pathBits:array(bool(),depth)}}, + ({public:p,secret:s,zk})=>{zk.gadgets.merkleRootBls12381T3(s.leaf,s.siblings,s.pathBits).assertEqual(p.root);}); +export const merkleExamples=[1,2,8,16,32].map(merkleMembership); +export const allowlist=defineCircuit({name:'allowlist_d1',public:{root:field()}, + secret:{leaf:field(),siblings:array(field(),1),pathBits:array(bool(),1)}}, + ({public:p,secret:s,zk})=>{ + zk.gadgets.merkleRootBls12381T3(s.leaf,s.siblings,s.pathBits).assertEqual(p.root); + }); +export const arrayExample=defineCircuit({name:'arrays',public:{out:array(field(),2)}, + secret:{flags:array(bool(),2),values:array(uint(8),2)}},({public:p,secret:s})=>{ + for(let i=0;i=22" + } + }, + "packages/circuit-lint": { + "name": "@zeroj/circuit-lint", + "version": "0.1.0-experimental.1", + "license": "MIT", + "bin": { + "zeroj-circuit-lint": "src/index.mjs" + }, + "peerDependencies": { + "typescript": "^5.9.3" + } + }, "packages/confidential-notes": { "name": "@zeroj/confidential-notes", "version": "0.1.0-experimental.1", diff --git a/bindings/js/packages/bundle-circuit/README.md b/bindings/js/packages/bundle-circuit/README.md new file mode 100644 index 00000000..cf0f14cf --- /dev/null +++ b/bindings/js/packages/bundle-circuit/README.md @@ -0,0 +1,5 @@ +# @zeroj/bundle-circuit + +Experimental precompiled image. Register its bundle descriptor with createRuntime. +Importing the descriptor loads no Wasm. Generated files are included in npm tarballs, not Git. +The launcher preserves its upstream license headers. Consult ADR-0057 and bindings documentation. diff --git a/bindings/js/packages/bundle-circuit/package.json b/bindings/js/packages/bundle-circuit/package.json new file mode 100644 index 00000000..a2d7bcbd --- /dev/null +++ b/bindings/js/packages/bundle-circuit/package.json @@ -0,0 +1,31 @@ +{ + "name": "@zeroj/bundle-circuit", + "version": "0.1.0-experimental.1", + "private": true, + "type": "module", + "description": "Experimental ZeroJ precompiled circuit Web Image bundle", + "license": "SEE LICENSE IN DISTRIBUTION.md", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "assets", + "README.md", + "LICENSE", + "NOTICE", + "DISTRIBUTION.md", + "third-party" + ], + "dependencies": { + "@zeroj/runtime": "0.1.0-experimental.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/bindings/js/packages/circuit-lint/README.md b/bindings/js/packages/circuit-lint/README.md new file mode 100644 index 00000000..85b02dc1 --- /dev/null +++ b/bindings/js/packages/circuit-lint/README.md @@ -0,0 +1,11 @@ +# @zeroj/circuit-lint + +Experimental type-aware guardrails for `@zeroj/circuit` authoring. Run +`zeroj-circuit-lint path/to/circuits.ts` using the locally installed package, or import +`lintSymbolic(files)` to receive diagnostics. Tested with TypeScript 5.9.3. + +Flags typed symbolic values in JS branch conditions, operators, templates and direct +Boolean/Number/String/BigInt conversions. Use symbolic methods and select() instead. +The checker is not a sandbox or a complete ESLint plugin: any/reflection/aliased conversion +functions can bypass it. Keep definition shapes/constants public; assert intended relations. +Use TypeScript strict compilation alongside lint. This package contains no cryptography or Wasm. diff --git a/bindings/js/packages/circuit-lint/package.json b/bindings/js/packages/circuit-lint/package.json new file mode 100644 index 00000000..d45cd894 --- /dev/null +++ b/bindings/js/packages/circuit-lint/package.json @@ -0,0 +1,26 @@ +{ + "name": "@zeroj/circuit-lint", + "version": "0.1.0-experimental.1", + "type": "module", + "description": "Experimental type-aware circuit authoring lint (not a JavaScript sandbox)", + "license": "MIT", + "main": "./src/index.mjs", + "exports": { + ".": "./src/index.mjs" + }, + "bin": { + "zeroj-circuit-lint": "./src/index.mjs" + }, + "files": [ + "src", + "README.md", + "LICENSE", + "NOTICE" + ], + "peerDependencies": { + "typescript": "^5.9.3" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/bindings/js/packages/circuit-lint/src/index.mjs b/bindings/js/packages/circuit-lint/src/index.mjs new file mode 100755 index 00000000..dc5f2ddd --- /dev/null +++ b/bindings/js/packages/circuit-lint/src/index.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +// Research type-aware authoring lint. This is a guardrail, not a JavaScript sandbox. +import ts from 'typescript'; +import {pathToFileURL} from 'node:url'; +export function lintSymbolic(files){ + const program=ts.createProgram(files,{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.NodeNext, + moduleResolution:ts.ModuleResolutionKind.NodeNext,strict:true,noEmit:true,skipLibCheck:true}); + const checker=program.getTypeChecker(),diagnostics=[]; + function symbolicType(t){return !!t.getProperty('__zerojSymbolic')||(t.isUnionOrIntersection()&&t.types.some(symbolicType));} + const symbolic=n=>n&&symbolicType(checker.getTypeAtLocation(n)); + for(const file of program.getSourceFiles())if(files.includes(file.fileName)){ + function visit(n){ + let bad=false; + if(ts.isIfStatement(n)||ts.isWhileStatement(n)||ts.isDoStatement(n)||ts.isConditionalExpression(n))bad=symbolic(n.expression??n.condition); + if(ts.isForStatement(n))bad=symbolic(n.condition); + if(ts.isSwitchStatement(n))bad=symbolic(n.expression); + if(ts.isBinaryExpression(n)&&n.operatorToken.kind!==ts.SyntaxKind.EqualsToken)bad=symbolic(n.left)||symbolic(n.right); + if(ts.isPrefixUnaryExpression(n)||ts.isPostfixUnaryExpression(n))bad=symbolic(n.operand); + if(ts.isTemplateExpression(n))bad=n.templateSpans.some(s=>symbolic(s.expression)); + if(ts.isCallExpression(n)&&ts.isIdentifier(n.expression)&&['Boolean','Number','String','BigInt'].includes(n.expression.text))bad=n.arguments.some(symbolic); + if(bad){const p=file.getLineAndCharacterOfPosition(n.getStart());diagnostics.push(`${file.fileName}:${p.line+1}:${p.character+1}: SYMBOLIC_JS_VALUE: use symbolic methods and select(), not JS operators or conditions`);} + ts.forEachChild(n,visit); + } + visit(file); + } + return diagnostics; +} +if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){ + const diagnostics=lintSymbolic(process.argv.slice(2));for(const d of diagnostics)console.error(d);if(diagnostics.length)process.exitCode=1; +} diff --git a/bindings/js/packages/circuit/README.md b/bindings/js/packages/circuit/README.md new file mode 100644 index 00000000..5e0c11e8 --- /dev/null +++ b/bindings/js/packages/circuit/README.md @@ -0,0 +1,22 @@ +# @zeroj/circuit + +Experimental typed circuit authoring and portable graph compilation using ZeroJ Java in Wasm. +Exports `defineCircuit`, `field`, `uint`, `bool`, `bytes`, `array`, `createCircuitCompiler` and typed +compiler/prover lifecycle interfaces. `@zeroj/circuit/graph` exports the advanced arithmetic +`CircuitBuilder`. Importing these wrappers starts no worker and downloads no image. + +Select `@zeroj/bundle-circuit` with `createRuntime`. It includes the compiler, selected existing +Poseidon/Merkle/SHA-256 gadgets and bounded Groth16 prover/verifier in the same Java heap. +One compilation attempt per runtime; use application-trusted graph/fingerprint and key artifacts. +Private input operations require `experimentalSecrets: true`. No witness export or trusted setup. + +See `bindings/circuit/README.md`, ADR-0064, and `bindings/js/examples/circuit` in the source +repository for runnable browser/Node examples, artifact export, limits and remaining milestones. +Small circuits, the measured Poseidon opening and Merkle depths 1–2 generate browser/Node proofs. +Recipe v2 adds fixed field/bool/UInt arrays and `zk.gadgets.merkleRootBls12381T3`; original +definitions retain v1 bytes. See `bindings/circuit/M3-MERKLE.md` for the measured limits. +SHA-256 and measured Merkle depths 8/16/32 remain +compile/witness-only under the current 4,096-wire/domain budgets. Compiler key loading uses +64 KiB chunks, a 2 MiB total cap and one successful key per circuit. `loadKey(artifact, {signal})` +can abort: cancellation during loading terminates the whole runtime. `keyResources()` reports +transport counters, not total heap. Reuse a loaded key to avoid repeated subgroup validation. Packages are experimental, not audited and not yet published to npm. diff --git a/bindings/js/packages/circuit/package.json b/bindings/js/packages/circuit/package.json new file mode 100644 index 00000000..58d929b4 --- /dev/null +++ b/bindings/js/packages/circuit/package.json @@ -0,0 +1,35 @@ +{ + "name": "@zeroj/circuit", + "version": "0.1.0-experimental.1", + "type": "module", + "description": "Experimental typed circuits and portable R1CS compilation in ZeroJ Wasm", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./graph": { + "types": "./dist/builder.d.ts", + "import": "./dist/builder.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "NOTICE" + ], + "dependencies": { + "@zeroj/runtime": "0.1.0-experimental.1", + "@zeroj/groth16-verifier": "0.1.0-experimental.1" + }, + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/bindings/js/packages/circuit/src/builder.ts b/bindings/js/packages/circuit/src/builder.ts new file mode 100644 index 00000000..d708c829 --- /dev/null +++ b/bindings/js/packages/circuit/src/builder.ts @@ -0,0 +1,34 @@ +/** Experimental field-arithmetic builder. Java is the validating/compiler authority. */ +type Gate = [string, ...(number|string)[]]; +export interface Wire {readonly id:number;} +const FR=BigInt('0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001'); +export class CircuitBuilder { + private readonly owners=new WeakSet(); + private readonly names=new Map(); + private readonly gates:Gate[]=[['c',0,'1']]; + private count=1; + private frozen=false; + constructor(private readonly name:string,private readonly publicNames:readonly string[],private readonly secretNames:readonly string[]){ + this.publicNames=[...publicNames];this.secretNames=[...secretNames]; + if(!/^[A-Za-z_][A-Za-z0-9_-]{0,95}$/.test(name)||publicNames.length+secretNames.length>4096)throw Error('INVALID_CIRCUIT'); + for(const n of [...publicNames,...secretNames]){ + if(!/^[A-Za-z_][A-Za-z0-9_-]{0,95}$/.test(n)||this.names.has(n))throw Error('INVALID_INPUT_NAME'); + this.names.set(n,this.next()); + } + } + private next():Wire{if(this.frozen||this.count>=200000)throw Error('BUILDER_CLOSED_OR_LIMIT');const w=Object.freeze({id:this.count++});this.owners.add(w);return w;} + private check(w:Wire):number{if(!w||!this.owners.has(w)||this.frozen)throw Error('FOREIGN_WIRE_OR_CLOSED');return w.id;} + private emit(gate:Gate):void{if(this.gates.length>=200000){this.frozen=true;throw Error('GATE_LIMIT');}this.gates.push(gate);} + input(name:string):Wire{const w=this.names.get(name);if(!w)throw Error('UNKNOWN_INPUT');return w;} + constant(value:bigint):Wire{if(typeof value!=='bigint'||value<0n||value>=FR)throw Error('NONCANONICAL_SCALAR');const w=this.next();this.emit(['c',w.id,value.toString()]);return w;} + add(a:Wire,b:Wire):Wire{const x=this.check(a),y=this.check(b),w=this.next();this.emit(['a',w.id,x,y]);return w;} + mul(a:Wire,b:Wire):Wire{const x=this.check(a),y=this.check(b),w=this.next();this.emit(['m',w.id,x,y]);return w;} + assertEqual(a:Wire,b:Wire):void{this.emit(['e',this.check(a),this.check(b)]);} + serialize():string{if(this.frozen)throw Error('BUILDER_CLOSED');this.frozen=true; + return JSON.stringify({format:'zeroj-graph',version:1,compiler:'zeroj-r1cs-bls12381-v1',field:'bls12-381-fr',name:this.name, + public:this.publicNames,secret:this.secretNames,wires:this.count,gates:this.gates});} +} +export function multiplier():string{const c=new CircuitBuilder('multiplier',['product'],['x','y']);c.assertEqual(c.mul(c.input('x'),c.input('y')),c.input('product'));return c.serialize();} +export function cubic():string{const c=new CircuitBuilder('cubic',['y'],['x']),x=c.input('x'); + const x2=c.mul(x,x),x3=c.mul(x2,x),sum=c.add(x3,x),five=c.constant(5n); + c.assertEqual(c.add(sum,five),c.input('y'));return c.serialize();} diff --git a/bindings/js/packages/circuit/src/index.ts b/bindings/js/packages/circuit/src/index.ts new file mode 100644 index 00000000..087e5680 --- /dev/null +++ b/bindings/js/packages/circuit/src/index.ts @@ -0,0 +1,116 @@ +/** Experimental circuit authoring/compiler lifecycle. Java owns every constraint and gadget. */ +export * from './symbolic.js'; +import {type ZeroJRuntime,type Handle,bytesToHex,scalarToString,ZeroJError} from '@zeroj/runtime'; +import type {Groth16Proof,Groth16VerificationKey} from '@zeroj/groth16-verifier'; +import type {CircuitDefinition,CircuitInputs,Schema} from './symbolic.js'; + +export const compilerPolicy = 'zeroj-r1cs-bls12381-v1' as const; +const CAPABILITY='circuit'; +const FR=52435875175126190479447740508185965837690552500527637822603658699938581184513n; +/** These identities must come from the application's trusted circuit manifest. */ +export type CircuitIdentity=Readonly<{expectedGraphSha256:string;fingerprint:string}>; +export type CircuitDescription=Readonly<{ + name:string;compiler:string;graphSha256:string;fingerprint:string; + wires:number;constraints:number;publicInputs:number;publicNames:readonly string[];secretNames:readonly string[]; + maxKeyBytes:number;maxWires:number;maxDomainSize:number;maxCoefficients:number;keyChunkBytes:number;keyProfile:string;withinProverDimensions:boolean;compileMs:number; + recipeSha256?:string;expandMs?:number;graphValidationMs:number; +}>; +export type CircuitProvingKey=Handle<'circuit-proving-key'>; +export type CircuitKeyArtifact=Readonly<{zkey:Uint8Array;expectedSha256:Uint8Array;verificationKey:Groth16VerificationKey}>; +export type KeyResources=Readonly<{ + profile:string;reservedBytes:number;receivedBytes:number;peakStagingBytes:number;importedKeys:number; + maxKeyBytes:number;maxWires:number;maxDomainSize:number;maxCoefficients:number;chunkBytes:number;lastImportMs:number;lastKeyBytes:number;lastDomainSize:number; +}>; +export type CircuitProof=Readonly<{proof:Groth16Proof;publicInputs:readonly bigint[]}>; +export interface CompiledCircuit { + readonly description:CircuitDescription; + /** Checks constraints locally; never returns witnesses or their hashes. */ + checkWitness(input:I):Promise<{valid:boolean;witnessMs:number;constraintCheckMs:number}>; + /** Supplied ceremony artifacts only; the hash and VK must be application-trusted. + * One successful import per compiled circuit. Abort during loading terminates the entire runtime. + */ + loadKey(artifact:CircuitKeyArtifact,options?:{signal?:AbortSignal}):Promise; + /** Exact key-transport counters, not total Java/Wasm heap measurements. */ + keyResources():Promise; + prove(key:CircuitProvingKey,input:I):Promise; + /** Releases the compiled circuit and all its keys. A new compilation needs a new runtime. */ + dispose():Promise; +} +function identity(value:CircuitIdentity):CircuitIdentity { + if(!value||!/^[a-f0-9]{64}$/.test(value.expectedGraphSha256)||typeof value.fingerprint!=='string' + ||value.fingerprint.length<1||value.fingerprint.length>160)throw new ZeroJError('INVALID_CIRCUIT_IDENTITY'); + return {expectedGraphSha256:value.expectedGraphSha256,fingerprint:value.fingerprint}; +} +type CompiledResponse=CircuitDescription&{handle:string}; +function lifecycle(runtime:ZeroJRuntime,response:CompiledResponse,encode:(input:I)=>Readonly>):CompiledCircuit{ + const handle=runtime.retainHandle('compiled-circuit',CAPABILITY,response.handle),keys=new WeakSet(); + const {handle:ignored,...data}=response; + const description=Object.freeze({...data,publicNames:Object.freeze([...data.publicNames]),secretNames:Object.freeze([...data.secretNames])}); + let closed=false,loading=false; + const id=()=>{if(closed)throw new ZeroJError('INVALID_HANDLE');return runtime.handleId(handle,'compiled-circuit',CAPABILITY);}; + return Object.freeze({description, + async checkWitness(input:I){return runtime.call<{valid:boolean;witnessMs:number;constraintCheckMs:number}>(CAPABILITY,'checkWitness',{circuit:id(),input:encode(input)});}, + async keyResources(){return runtime.call(CAPABILITY,'keyResources',{circuit:id()});}, + async loadKey(artifact:CircuitKeyArtifact,options:{signal?:AbortSignal}={}){ + const circuit=id(),signal=options.signal; + if(signal?.aborted)throw new ZeroJError('ABORTED'); + if(loading)throw new ZeroJError('UPLOAD_ACTIVE'); + if(!(artifact.zkey instanceof Uint8Array)||artifact.zkey.length<1||artifact.zkey.length>description.maxKeyBytes + ||!(artifact.expectedSha256 instanceof Uint8Array)||artifact.expectedSha256.length!==32)throw new ZeroJError('INVALID_BYTES'); + const metadata={circuit,fingerprint:description.fingerprint,length:artifact.zkey.length, + expectedSha256:bytesToHex(artifact.expectedSha256,32),verificationKey:artifact.verificationKey}; + const abort=()=>{void runtime.dispose();}; + loading=true;signal?.addEventListener('abort',abort,{once:true}); + let upload:string|undefined; + try{ + const started=await runtime.call<{upload:string;chunkBytes:number}>(CAPABILITY,'beginKey',metadata); + upload=started.upload; + if(started.chunkBytes!==65536)throw new ZeroJError('INVALID_RESPONSE'); + for(let offset=0;offset(CAPABILITY,'finishKey',{circuit,upload}); + upload=undefined; + if(signal?.aborted)throw new ZeroJError('ABORTED'); + const key=runtime.retainHandle('circuit-proving-key',CAPABILITY,result.handle); + if(closed){await runtime.release(key);throw new ZeroJError('INVALID_HANDLE');} + keys.add(key);return key; + }catch(error){ + if(upload!==undefined)await runtime.call(CAPABILITY,'abortKey',{circuit,upload}).catch(()=>{}); + if(signal?.aborted)throw new ZeroJError('ABORTED'); + throw error; + }finally{loading=false;signal?.removeEventListener('abort',abort);} + }, + async prove(key:CircuitProvingKey,input:I){ + const circuit=id();if(!keys.has(key))throw new ZeroJError('INVALID_HANDLE'); + const result=await runtime.call<{proof:Groth16Proof;publicInputs:string[]}>(CAPABILITY,'prove',{ + circuit,key:runtime.handleId(key,'circuit-proving-key',CAPABILITY),input:encode(input)}); + return {proof:result.proof,publicInputs:result.publicInputs.map(BigInt)}; + }, + async dispose(){if(closed)return;closed=true;await runtime.release(handle);} + }); +} +export function createCircuitCompiler(runtime:ZeroJRuntime){ + return Object.freeze({ + async compile

(definition:CircuitDefinition,expected:CircuitIdentity):Promise>>{ + const trusted=identity(expected); + const result=await runtime.call(CAPABILITY,'compileRecipe',{recipe:definition.recipe,...trusted}); + return lifecycle(runtime,result,input=>definition.encodeInputs(input)); + }, + /** Graph inputs are flattened names -> canonical Fr bigint, without a higher-level type schema. */ + async compileGraph(graph:string,expected:CircuitIdentity):Promise>>>{ + const trusted=identity(expected); + if(typeof graph!=='string'||graph.length<1||graph.length>16*1024*1024)throw new ZeroJError('INVALID_GRAPH'); + await runtime.call(CAPABILITY,'beginGraph',{length:graph.length,...trusted}); + for(let offset=0;offset(CAPABILITY,'compileGraph',{}); + return lifecycle(runtime,result,input=>{ + if(!input||typeof input!=='object'||![Object.prototype,null].includes(Object.getPrototypeOf(input)))throw new ZeroJError('INVALID_INPUT'); + return Object.fromEntries(Object.entries(input).map(([k,v])=>[k,scalarToString(v,FR)])); + }); + } + }); +} diff --git a/bindings/js/packages/circuit/src/symbolic.ts b/bindings/js/packages/circuit/src/symbolic.ts new file mode 100644 index 00000000..53c3b5d4 --- /dev/null +++ b/bindings/js/packages/circuit/src/symbolic.ts @@ -0,0 +1,202 @@ +/** Experimental symbolic frontend (ADR-0064). Every operation is expanded by the existing Java symbolic API. */ +const FR = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001n; +type Kind = 'field' | 'uint' | 'bool' | 'bytes' | 'array'; +export type ScalarInputSpec = Readonly<{type:'field'} | {type:'bool'} | {type:'uint';bits:number}>; +export type InputSpec = ScalarInputSpec | Readonly<{type:'bytes';length:number} | {type:'array';element:ScalarInputSpec;length:number}>; +export type Schema = Readonly>; +export const field = ():Readonly<{type:'field'}> => Object.freeze({type:'field'}); +export const bool = ():Readonly<{type:'bool'}> => Object.freeze({type:'bool'}); +export function uint(bits:number):Readonly<{type:'uint';bits:number}> { size(bits,1,253);return Object.freeze({type:'uint',bits}); } +export function bytes(length:number):Readonly<{type:'bytes';length:number}> { size(length,1,64);return Object.freeze({type:'bytes',length}); } +export function array(element:T,length:number):Readonly<{type:'array';element:T;length:number}> { + size(length,1,64);scalarSpec(element); + const copy=Object.freeze(element.type==='uint'?{type:'uint',bits:element.bits}:{type:element.type}) as T; + return Object.freeze({type:'array',element:copy,length}); +} +function scalarSpec(value:unknown):asserts value is ScalarInputSpec { + object(value); + if(value.type==='field'||value.type==='bool')exact(value,['type']); + else if(value.type==='uint'){exact(value,['type','bits']);size(value.bits as number,1,253);} + else fail('INVALID_ARRAY_ELEMENT'); +} +interface Symbolic { readonly __zerojSymbolic:K; } +export interface ZkField extends Symbolic<'field'> { + add(value:ZkField):ZkField; sub(value:ZkField):ZkField; mul(value:ZkField):ZkField; div(value:ZkField):ZkField; + isEqual(value:ZkField):ZkBool; assertEqual(value:ZkField):void; +} +export interface ZkUInt extends Symbolic<'uint'> { + add(value:ZkUInt):ZkUInt; sub(value:ZkUInt):ZkUInt; mul(value:ZkUInt):ZkUInt; + lt(value:ZkUInt):ZkBool; lte(value:ZkUInt):ZkBool; gt(value:ZkUInt):ZkBool; gte(value:ZkUInt):ZkBool; + isEqual(value:ZkUInt):ZkBool; inRange(lo:ZkUInt,hi:ZkUInt):ZkBool; + assertEqual(value:ZkUInt):void; asField():ZkField; +} +export interface ZkBool extends Symbolic<'bool'> { + and(value:ZkBool):ZkBool; or(value:ZkBool):ZkBool; xor(value:ZkBool):ZkBool; not():ZkBool; + isEqual(value:ZkBool):ZkBool; assertEqual(value:ZkBool):void; assertTrue():void; assertFalse():void; asField():ZkField; + select(ifTrue:ZkField,ifFalse:ZkField):ZkField; + select(ifTrue:ZkUInt,ifFalse:ZkUInt):ZkUInt; + select(ifTrue:ZkBool,ifFalse:ZkBool):ZkBool; +} +export interface ZkBytes extends Symbolic<'bytes'> { + get(index:number):ZkUInt; isEqual(value:ZkBytes):ZkBool; assertEqual(value:ZkBytes):void; +} +export interface ZkArray extends Symbolic<'array'> { + readonly length:number; + get(index:number):T; +} +export interface ZkContext { + constant(value:bigint):ZkField; + readonly gadgets:{ + poseidonBls12381T3(left:ZkField,right:ZkField):ZkField; + sha256(message:ZkBytes):ZkBytes; + merkleRootBls12381T3(leaf:ZkField,siblings:ZkArray,pathBits:ZkArray):ZkField; + }; +} +type Values = {field:ZkField;uint:ZkUInt;bool:ZkBool;bytes:ZkBytes;array:ZkArray}; +type SymbolFor = T extends {type:'array';element:infer E extends ScalarInputSpec} ? ZkArray : Values[T['type']]; +type Symbols = {readonly [K in keyof S]: SymbolFor}; +type InputValue = T extends {type:'array';element:infer E extends ScalarInputSpec} ? readonly InputValue[] : T extends {type:'bool'} ? boolean : T extends {type:'bytes'} ? Uint8Array : bigint; +export type Inputs = {readonly [K in keyof S]:InputValue}; +export interface CircuitDefinition

{ + readonly name:string; + /** Public declarations/commands/constants; encodeInputs values never enter this recipe. */ + readonly recipe:string; + encodeInputs(values:{public:Inputs

;secret:Inputs}):Readonly>; +} +export type CircuitInputs

= {public:Inputs

;secret:Inputs}; +type Declaration = {name:string;visibility:'public'|'secret'} & InputSpec; +type Reference = {owner:Recorder;id:number;kind:Kind;length?:number|undefined;element?:ScalarInputSpec['type']|undefined}; +const references=new WeakMap(); +function fail(code:string):never{throw new Error(code);} +function size(n:number,min:number,max:number):void{if(!Number.isSafeInteger(n)||nmax)fail('INVALID_SHAPE');} +function identifier(n:string):void{if(typeof n!=='string'||!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(n)||['constructor','prototype'].includes(n))fail('INVALID_NAME');} +function scalar(value:bigint):string{if(typeof value!=='bigint'||value<0n||value>=FR)fail('NONCANONICAL_SCALAR');return value.toString();} +function object(value:unknown):asserts value is Record{ + if(!value||typeof value!=='object'||![null,Object.prototype].includes(Object.getPrototypeOf(value)))fail('EXPECTED_RECORD'); +} +function exact(value:unknown,keys:readonly string[]):asserts value is Record{ + object(value);const found=Object.keys(value);if(found.length!==keys.length||found.some(k=>!keys.includes(k)))fail('INPUT_SHAPE_MISMATCH'); +} +class Recorder { + active=true; + extended=false; + next=0; + readonly commands:(string|number)[][]=[]; + check():void{if(!this.active)fail('DEFINITION_CLOSED');} + guard(action:()=>T):T{this.check();try{return action();}catch(error){this.active=false;throw error;}} + ref(value:unknown,kind?:Kind):Reference{ + this.check();const r=value&&typeof value==='object'?references.get(value):undefined; + if(!r||r.owner!==this||(kind!==undefined&&r.kind!==kind)){this.active=false;fail('FOREIGN_OR_WRONG_SYMBOL');}return r; + } + emit(op:string,args:(string|number)[]):void{this.check();if(this.commands.length>=128){this.active=false;fail('COMMAND_LIMIT');}this.commands.push([op,...args]);} + result(op:string,args:(string|number)[],kind:K,length?:number):Values[K]{this.emit(op,args);return this.value(kind,length);} + value(kind:K,length?:number,element?:ScalarInputSpec['type']):Values[K]{ + this.check();const id=this.next++,owner=this; + const value:Record={__zerojSymbolic:kind, + [Symbol.toPrimitive](){return fail('SYMBOLIC_VALUE_IS_NOT_A_JS_VALUE');}}; + references.set(value,{owner,id,kind,length,element}); + const own=()=>this.ref(value,kind).id; + const binary=(op:string,other:unknown,result:R)=>this.result(op,[own(),this.ref(other,kind).id],result); + const assertion=(op:string,...other:unknown[])=>this.emit(op,other.length===0?[own()]:[own(),this.ref(other[0],kind).id]); + if(kind==='field'){ + for(const op of ['add','sub','mul','div'])value[op]=(other:ZkField)=>binary('f.'+op,other,'field'); + value.isEqual=(other:ZkField)=>binary('f.isEqual',other,'bool');value.assertEqual=(other:ZkField)=>assertion('f.assertEqual',other); + }else if(kind==='uint'){ + for(const op of ['add','sub','mul'])value[op]=(other:ZkUInt)=>binary('u.'+op,other,'uint'); + for(const op of ['lt','lte','gt','gte','isEqual'])value[op]=(other:ZkUInt)=>binary('u.'+op,other,'bool'); + value.inRange=(lo:ZkUInt,hi:ZkUInt)=>this.result('u.inRange',[own(),this.ref(lo,'uint').id,this.ref(hi,'uint').id],'bool'); + value.asField=()=>this.result('u.asField',[own()],'field');value.assertEqual=(other:ZkUInt)=>assertion('u.assertEqual',other); + }else if(kind==='bool'){ + for(const op of ['and','or','xor','isEqual'])value[op]=(other:ZkBool)=>binary('b.'+op,other,'bool'); + value.not=()=>this.result('b.not',[own()],'bool');value.asField=()=>this.result('b.asField',[own()],'field'); + value.assertTrue=()=>assertion('b.assertTrue');value.assertFalse=()=>assertion('b.assertFalse'); + value.assertEqual=(other:ZkBool)=>assertion('b.assertEqual',other); + value.select=(a:ZkField|ZkUInt|ZkBool,b:ZkField|ZkUInt|ZkBool)=>{ + own();const left=this.ref(a),right=this.ref(b);if(left.kind!==right.kind||(left.kind==='bytes'||left.kind==='array')){this.active=false;fail('SELECT_TYPE_MISMATCH');} + const suffix={field:'Field',uint:'UInt',bool:'Bool'}[left.kind]; + return this.result('b.select'+suffix,[id,left.id,right.id],left.kind); + }; + }else if(kind==='array'){ + this.extended=true;value.length=length; + value.get=(index:number)=>{own();size(index,0,length!-1);return this.result('a.get',[id,index],element!);}; + }else{ + value.get=(index:number)=>{own();size(index,0,length!-1);return this.result('bytes.get',[id,index],'uint');}; + value.isEqual=(other:ZkBytes)=>{own();const r=this.ref(other,'bytes');if(r.length!==length)fail('BYTE_LENGTH_MISMATCH');return this.result('bytes.isEqual',[id,r.id],'bool');}; + value.assertEqual=(other:ZkBytes)=>{own();const r=this.ref(other,'bytes');if(r.length!==length)fail('BYTE_LENGTH_MISMATCH');this.emit('bytes.assertEqual',[id,r.id]);}; + } + // A caught authoring error must not leave a partially recorded definition usable. + for(const key of Reflect.ownKeys(value))if(typeof value[key]==='function'){ + const fn=value[key] as (...args:unknown[])=>unknown; + value[key]=(...args:unknown[])=>this.guard(()=>fn(...args)); + } + return Object.freeze(value) as unknown as Values[K]; + } + context():ZkContext{return Object.freeze({ + constant:(v:bigint)=>this.guard(()=>this.result('f.constant',[scalar(v)],'field')), + gadgets:Object.freeze({ + poseidonBls12381T3:(a:ZkField,b:ZkField)=>this.result('g.poseidonBls12381T3',[this.ref(a,'field').id,this.ref(b,'field').id],'field'), + merkleRootBls12381T3:(leaf:ZkField,siblings:ZkArray,pathBits:ZkArray)=>this.guard(()=>{ + const l=this.ref(leaf,'field'),s=this.ref(siblings,'array'),p=this.ref(pathBits,'array'); + if(s.element!=='field'||p.element!=='bool'||s.length!==p.length)fail('MERKLE_SHAPE_MISMATCH'); + size(s.length!,1,32);this.extended=true; + return this.result('g.merkleRootBls12381T3',[l.id,s.id,p.id,s.length!],'field'); + }), + sha256:(message:ZkBytes)=>this.guard(()=>{const r=this.ref(message,'bytes');if(r.length! > 55)fail('SHA256_PROTOTYPE_ONE_BLOCK_LIMIT');return this.result('g.sha256',[r.id],'bytes',32);}) + }) + });} +} +export function defineCircuit

( + spec:{name:string;public:P;secret:S}, + body:(values:{public:Symbols

;secret:Symbols;zk:ZkContext})=>void, +):CircuitDefinition{ + exact(spec,['name','public','secret']);identifier(spec.name);object(spec.public);object(spec.secret); + const recorder=new Recorder(),declarations:Declaration[]=[],flat=new Set(),logical=new Set(); + const pub:Record=Object.create(null),sec:Record=Object.create(null); + let expanded=0; + try{ + for(const visibility of ['public','secret'] as const)for(const [name,input] of Object.entries(spec[visibility])){ + identifier(name);if(logical.has(name))fail('DUPLICATE_INPUT');logical.add(name); + if(logical.size>64)fail('INPUT_LIMIT');object(input); + switch(input.type){ + case 'field':case 'bool':exact(input,['type']);break; + case 'uint':exact(input,['type','bits']);size(input.bits as number,1,253);break; + case 'array':exact(input,['type','element','length']);scalarSpec(input.element);size(input.length as number,1,64);break; + case 'bytes':exact(input,['type','length']);size(input.length as number,1,64);break; + default:fail('INVALID_TYPE'); + } + const typed=(input.type==='array'?array(input.element as ScalarInputSpec,input.length as number):input) as InputSpec,width=(typed.type==='bytes'||typed.type==='array')?typed.length:1; + expanded+=width;if(expanded>256)fail('INPUT_LIMIT'); + for(let i=0;i,secret:Object.freeze(sec) as Symbols,zk:recorder.context()}); + if(result!==undefined){if(result instanceof Promise)void result.catch(()=>{});fail('DEFINITION_MUST_BE_SYNCHRONOUS_AND_VOID');} + recorder.check(); + }finally{recorder.active=false;} + const recipe=JSON.stringify({format:'zeroj-symbolic',version:recorder.extended?2:1,compiler:'zeroj-r1cs-bls12381-v1',field:'bls12-381-fr',name:spec.name,inputs:declarations,commands:recorder.commands}); + return Object.freeze({name:spec.name,recipe, + encodeInputs(values:{public:Inputs

;secret:Inputs}):Readonly>{ + exact(values,['public','secret']); + for(const visibility of ['public','secret'] as const)exact(values[visibility],declarations.filter(d=>d.visibility===visibility).map(d=>d.name)); + const result:Record=Object.create(null); + for(const d of declarations){ + const v=(values[d.visibility] as Record)[d.name]; + if(d.type==='array'){ + if(!Array.isArray(v)||v.length!==d.length)fail('ARRAY_LENGTH_MISMATCH'); + for(let i=0;i=(1n<{server.stdout.once('data',resolve);server.once('error',reject);server.once('exit',code=>reject(Error('Server exited '+code)));}); +try{ + for(const [name,type]of [['chromium',chromium],['firefox',firefox]]){ + const browser=await type.launch({headless:true}); + try{ + const page=await browser.newPage();await page.goto(base+'/examples/circuit/'); + const result=await page.evaluate(async name=>{ + const {benchmark}=await import('/examples/circuit/benchmark.mjs'); + const load=async(n,k)=>{const r=await fetch('/build/circuit/'+n);if(!r.ok)throw Error('fixture');return k==='text'?r.text():r.arrayBuffer();}; + return benchmark(load,{name}); + },fixture); + result.environment={browser:name,version:browser.version(),headless:true}; + await fs.writeFile(new URL('../build/circuit-results/'+(fixture==='poseidon_opening'?'poseidon':fixture)+'-benchmark-'+name+'.json',import.meta.url),JSON.stringify(result,null,2)+'\n'); + console.log(name,JSON.stringify({summary:result.summary,warm:result.warm})); + }finally{await browser.close();} + } +}finally{server.kill();} diff --git a/bindings/js/scripts/circuit-benchmark.mjs b/bindings/js/scripts/circuit-benchmark.mjs new file mode 100644 index 00000000..12d827c3 --- /dev/null +++ b/bindings/js/scripts/circuit-benchmark.mjs @@ -0,0 +1,18 @@ +// RSS includes the whole Node process and workers; sampled peaks are lower bounds. +import fs from 'node:fs/promises'; +import os from 'node:os'; +import {benchmark} from '../examples/circuit/benchmark.mjs'; +const name=process.argv[2]??'poseidon_opening'; +if(!['poseidon_opening','allowlist_d1','merkle_d2'].includes(name))throw Error('Unknown benchmark'); +const dir=new URL('../build/circuit/',import.meta.url),out=new URL('../build/circuit-results/',import.meta.url); +const load=(name,kind)=>fs.readFile(new URL(name,dir),kind==='text'?'utf8':undefined); +let phase='baseline';const memory={},baseline=process.memoryUsage(); +function sample(){const rss=process.memoryUsage.rss();memory[phase]=Math.max(memory[phase]??0,rss);} +const timer=setInterval(sample,10);sample(); +try{ + const result=await benchmark(load,{name,onPhase:p=>{sample();phase=p;sample();}});sample(); + Object.assign(result,{environment:{node:process.version,platform:process.platform,arch:process.arch,cpu:os.cpus()[0]?.model}, + memory:{scope:'entire Node process, including workers; 10 ms RSS sampling, not Wasm/JVM heap or retained key size',baselineRss:baseline.rss,peakRssByPhase:memory,maxRssKiB:process.resourceUsage().maxRSS}}); + await fs.mkdir(out,{recursive:true});await fs.writeFile(new URL((name==='poseidon_opening'?'poseidon':name)+'-benchmark-node.json',out),JSON.stringify(result,null,2)+'\n'); + console.log(JSON.stringify({summary:result.summary,warm:result.warm,memory:result.memory},null,2)); +}finally{clearInterval(timer);} diff --git a/bindings/js/scripts/circuit-interop.mjs b/bindings/js/scripts/circuit-interop.mjs new file mode 100644 index 00000000..d8d89f25 --- /dev/null +++ b/bindings/js/scripts/circuit-interop.mjs @@ -0,0 +1,23 @@ +// JVM and browser proof JSON is generated, ignored development evidence. +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {createGroth16Verifier} from '@zeroj/groth16-verifier'; +import {circuitRuntime} from '../examples/circuit/scenarios.mjs'; +const require=createRequire(import.meta.url),snarkjs=require('snarkjs'); +try{ + for(const name of ['poseidon_opening','allowlist_d1','merkle_d2']){ + const dir=new URL('../build/circuit/'+name+'/',import.meta.url),r=circuitRuntime(); + try{ + const vk=JSON.parse(await fs.readFile(new URL('vk.json',dir),'utf8')); + for(const engine of ['node','chromium','firefox','jvm']){ + const {proof,publicInputs}=JSON.parse(await fs.readFile(new URL(engine+'-proof.json',dir),'utf8')); + assert.equal(await snarkjs.groth16.verify(vk,publicInputs,proof),true); + const bad=publicInputs.map(BigInt);bad[0]+=1n; + assert.equal(await snarkjs.groth16.verify(vk,bad.map(String),proof),false); + assert.equal(await createGroth16Verifier(r).verify({proof,publicInputs:publicInputs.map(BigInt),verificationKey:vk}),true); + console.log('snarkjs and Wasm verified '+engine+' '+name+' proof; snarkjs rejected changed public input'); + } + }finally{await r.dispose();} + } +}finally{const curve=await require('ffjavascript').getCurveFromName('bls12381');await curve.terminate();} diff --git a/bindings/js/scripts/circuit-large-browser.mjs b/bindings/js/scripts/circuit-large-browser.mjs new file mode 100644 index 00000000..de2a1c1a --- /dev/null +++ b/bindings/js/scripts/circuit-large-browser.mjs @@ -0,0 +1,39 @@ +import {chromium,firefox} from '@playwright/test'; +import {spawn} from 'node:child_process'; +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +const engine=process.argv[2],name=process.argv[3]; +if(!['chromium','firefox'].includes(engine)||!['merkle_d8','merkle_d16','merkle_d32'].includes(name))throw Error('Select browser and depth'); +const out=new URL('../build/circuit-large/'+name+'/',import.meta.url); +for(const suffix of ['-proof.json','-result.json'])await fs.rm(new URL(engine+suffix,out),{force:true}); +let phase='startup';const started=Date.now(); +const port=engine==='chromium'?'8796':'8797',base='http://127.0.0.1:'+port; +const server=spawn('python3',['scripts/serve.py','--port',port],{stdio:['ignore','pipe','inherit']}); +await new Promise((resolve,reject)=>{server.stdout.once('data',resolve);server.once('error',reject);server.once('exit',c=>reject(Error('Server exited '+c)));}); +try{ + const browser=await({chromium,firefox}[engine]).launch({headless:true}); + try{ + const page=await browser.newPage({viewport:{width:1100,height:900}}),external=[],errors=[],assets=[]; + page.on('request',r=>{if(new URL(r.url()).origin!==base)external.push(r.url());if(r.url().includes('/assets/'))assets.push(r.url());});page.on('pageerror',e=>errors.push(e.message)); + await page.goto(base+'/examples/circuit-large/');await page.waitForFunction(()=>!!document.querySelector('[data-depth]')); + assert.deepEqual(assets,[],'module import must remain inert'); + await page.exposeFunction('researchPhase',p=>{if(p!=='dispose')phase=p;console.log(engine,name,p);}); + const result=await page.evaluate(async name=>{ + const {largeResearch}=await import('/examples/circuit/large-research.mjs'); + const load=async(n,k)=>{const r=await fetch('/build/circuit-large/'+n);if(!r.ok)throw Error('fixture');return k==='text'?r.text():r.arrayBuffer();}; + return largeResearch(name,load,{onPhase:p=>{document.getElementById('result').textContent=p+'…';void window.researchPhase(p);}}); + },name); + const {proof,...report}=result;report.environment={browser:engine,version:browser.version(),headless:true}; + await page.evaluate(r=>{document.getElementById('result').textContent=JSON.stringify({name:r.name,timingsMs:r.timingsMs,warmProofMs:r.warmProofMs,keyBytes:r.keyBytes,invalidPathRejected:r.invalidPathRejected,changedRootRejected:r.changedRootRejected},null,2);},report); + assert.deepEqual(errors,[]);assert.deepEqual(external,[]);assert(assets.length>0); + assert(assets.every(u=>u.includes('/build/circuit-large/bundle/assets/')),'only selected research image may download'); + await fs.writeFile(new URL(engine+'-proof.json',out),JSON.stringify(proof));await fs.writeFile(new URL(engine+'-result.json',out),JSON.stringify(report,null,2)); + await page.screenshot({path:new URL(engine+'.png',out).pathname,fullPage:true}); + await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + console.log(JSON.stringify({engine,name,timingsMs:report.timingsMs,warmProofMs:report.warmProofMs})); + }finally{await browser.close();} +}catch(error){ + await fs.rm(new URL(engine+'-proof.json',out),{force:true}); + await fs.writeFile(new URL(engine+'-result.json',out),JSON.stringify({status:'failed',engine,name,phase,elapsedMs:Date.now()-started,errorCode:error.code??error.message},null,2)); + throw error; +}finally{server.kill();} diff --git a/bindings/js/scripts/circuit-large-census.mjs b/bindings/js/scripts/circuit-large-census.mjs new file mode 100644 index 00000000..2bdbd2a1 --- /dev/null +++ b/bindings/js/scripts/circuit-large-census.mjs @@ -0,0 +1,23 @@ +// Public local artifact census only. This is not a cryptographic zkey parser or admission path. +import fs from 'node:fs/promises'; +import {gzipSync,brotliCompressSync,constants} from 'node:zlib'; +const root=new URL('../build/circuit-large/',import.meta.url),rows=[]; +for(const depth of [8,16,32]){ + const key=await fs.readFile(new URL('merkle_d'+depth+'/key.zkey',root)); + if(key.length>33554432||key.toString('ascii',0,4)!=='zkey')throw Error('Expected bounded generated fixture'); + const row={depth,keyBytes:key.length,gzipBytes:gzipSync(key,{level:9}).length, + brotliBytes:brotliCompressSync(key,{params:{[constants.BROTLI_PARAM_QUALITY]:9}}).length,querySections:{}}; + let offset=12; + for(let i=0;ib===0))allZeroEntries++; + row.querySections[id]={bytes,entries:bytes/stride,allZeroEntries}; + } + offset+=bytes; + } + if(offset!==key.length)throw Error('Unexpected fixture sections');rows.push(row); +} +await fs.writeFile(new URL('query-census.json',root),JSON.stringify(rows,null,2)); +console.log(JSON.stringify(rows.map(({querySections,...r})=>r),null,2)); diff --git a/bindings/js/scripts/circuit-large-interop.mjs b/bindings/js/scripts/circuit-large-interop.mjs new file mode 100644 index 00000000..b9b70f66 --- /dev/null +++ b/bindings/js/scripts/circuit-large-interop.mjs @@ -0,0 +1,15 @@ +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url),snarkjs=require('snarkjs'),root=new URL('../build/circuit-large/',import.meta.url); +try{ + for(const depth of [8,16,32]){ + const dir=new URL('merkle_d'+depth+'/',root),vk=JSON.parse(await fs.readFile(new URL('vk.json',dir),'utf8')); + for(const engine of ['node','chromium','firefox','jvm-6g','jvm-8g']){ + const {proof,publicInputs}=JSON.parse(await fs.readFile(new URL(engine+'-proof.json',dir),'utf8')); + assert.equal(await snarkjs.groth16.verify(vk,publicInputs,proof),true); + assert.equal(await snarkjs.groth16.verify(vk,[String(BigInt(publicInputs[0])+1n)],proof),false); + console.log('snarkjs verified depth',depth,engine,'and rejected changed root'); + } + } +}finally{const curve=await require('ffjavascript').getCurveFromName('bls12381');await curve.terminate();} diff --git a/bindings/js/scripts/circuit-large-node.mjs b/bindings/js/scripts/circuit-large-node.mjs new file mode 100644 index 00000000..351ced38 --- /dev/null +++ b/bindings/js/scripts/circuit-large-node.mjs @@ -0,0 +1,24 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import {createRequire} from 'node:module'; +import {largeResearch} from '../examples/circuit/large-research.mjs'; +const name=process.argv[2];if(!['merkle_d8','merkle_d16','merkle_d32'].includes(name))throw Error('Select an admitted research fixture'); +const dir=new URL('../build/circuit-large/',import.meta.url),load=(n,k)=>fs.readFile(new URL(n,dir),k==='text'?'utf8':undefined); +for(const suffix of ['-proof.json','-result.json'])await fs.rm(new URL(name+'/node'+suffix,dir),{force:true}); +const baseline=process.memoryUsage.rss(),peak={};let current='baseline'; +const sample=()=>{peak[current]=Math.max(peak[current]??0,process.memoryUsage.rss());};const timer=setInterval(sample,10);sample(); +try{ + const result=await largeResearch(name,load,{onPhase:p=>{sample();current=p;sample();console.log(name,p);}});sample(); + const {proof,...report}=result; + report.environment={node:process.version,cpu:os.cpus()[0]?.model,platform:process.platform,arch:process.arch}; + report.memory={baselineRss:baseline,peakRssByPhase:{...peak},maxRssKiB:process.resourceUsage().maxRSS,scope:'Whole Node process including workers; 10 ms RSS sampling, not Java/Wasm heap'}; + clearInterval(timer); + const require=createRequire(import.meta.url);try{ + const vk=JSON.parse(await load(name+'/vk.json','text')); + if(!await require('snarkjs').groth16.verify(vk,proof.publicInputs,proof.proof))throw Error('Independent snarkjs verification failed'); + }finally{const curve=await require('ffjavascript').getCurveFromName('bls12381');await curve.terminate();} + report.independentSnarkjsVerified=true; + await fs.writeFile(new URL(name+'/node-proof.json',dir),JSON.stringify(proof)); + await fs.writeFile(new URL(name+'/node-result.json',dir),JSON.stringify(report,null,2)); + console.log(JSON.stringify({name,timingsMs:report.timingsMs,warmProofMs:report.warmProofMs,memory:report.memory},null,2)); +}finally{clearInterval(timer);} diff --git a/bindings/js/scripts/circuit-large-setup.mjs b/bindings/js/scripts/circuit-large-setup.mjs new file mode 100644 index 00000000..abbcc21a --- /dev/null +++ b/bindings/js/scripts/circuit-large-setup.mjs @@ -0,0 +1,42 @@ +// Explicit research only: generate separate dev keys, never overwrite the default fixture manifest. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {spawnSync} from 'node:child_process'; +import {randomBytes,createHash} from 'node:crypto'; +const root=fileURLToPath(new URL('../',import.meta.url)),source=path.join(root,'build/circuit'),out=path.join(root,'build/circuit-large'); +if(process.argv[2]!=='--research-only')throw Error('Pass --research-only; disposable local setup, not a production ceremony'); +const original=JSON.parse(await fs.readFile(path.join(source,'manifest.json'),'utf8')); +const manifest=original.filter(f=>['merkle_d8','merkle_d16','merkle_d32'].includes(f.name)).map(f=>({...f,proofSupported:false,experiment:'large-merkle-v1'})); +if(manifest.length!==3||manifest.some(f=>!f.developmentOnly))throw Error('Generate the three development R1CS/witness fixtures first'); +await fs.mkdir(out,{recursive:true}); +const cli=path.join(root,'node_modules/snarkjs/build/cli.cjs'); +const log=await fs.open(path.join(out,'setup.log'),'w'); +function run(...args){const t=performance.now();const r=spawnSync(process.execPath,[cli,...args],{cwd:out,stdio:['ignore',log.fd,log.fd],timeout:600000});if(r.status!==0)throw Error('snarkjs failed: '+args.slice(0,2).join(' '));return performance.now()-t;} +try{ + for(const f of manifest){await fs.mkdir(path.join(out,f.name),{recursive:true});for(const file of ['graph.json','inputs.json','circuit.r1cs','witness.wtns'])await fs.copyFile(path.join(source,f.name,file),path.join(out,f.name,file));} + run('powersoftau','new','bls12-381','13','pot0.ptau'); + run('powersoftau','contribute','pot0.ptau','pot1.ptau','--name=research-only','-e='+randomBytes(64).toString('hex')); + run('powersoftau','prepare','phase2','pot1.ptau','pot_final.ptau'); + for(const f of manifest){ + console.log('Generating',f.name,'(development ceremony)'); + run('wtns','check',f.name+'/circuit.r1cs',f.name+'/witness.wtns'); + f.setupMs=run('groth16','setup',f.name+'/circuit.r1cs','pot_final.ptau',f.name+'/key0.zkey'); + run('zkey','contribute',f.name+'/key0.zkey',f.name+'/key.zkey','--name=research-only','-e='+randomBytes(64).toString('hex')); + run('zkey','verify',f.name+'/circuit.r1cs','pot_final.ptau',f.name+'/key.zkey'); + run('zkey','export','verificationkey',f.name+'/key.zkey',f.name+'/vk.json'); + f.snarkjsProveMs=run('groth16','prove',f.name+'/key.zkey',f.name+'/witness.wtns',f.name+'/snarkjs-proof.json',f.name+'/snarkjs-public.json'); + run('groth16','verify',f.name+'/vk.json',f.name+'/snarkjs-public.json',f.name+'/snarkjs-proof.json'); + const key=await fs.readFile(path.join(out,f.name,'key.zkey'));f.keyBytes=key.length;f.keySha256=createHash('sha256').update(key).digest('hex'); + // Public size census of this locally generated zkey, not a cryptographic admission parser. + const sections={};let offset=12;for(let i=0;i[id,s.bytes])); + f.proofSupported=key.length<=32*1024*1024&&f.wires<=65536&&f.domainSize<=8192&&f.coefficients<=262144; + f.independentProofVerified=true;delete f.proofLimit; + await fs.unlink(path.join(out,f.name,'key0.zkey')); + await fs.writeFile(path.join(out,'manifest.json'),JSON.stringify(manifest,null,2)+'\n');console.log(f.name,JSON.stringify({bytes:f.keyBytes,coefficients:f.coefficients,domain:f.domainSize,admitted:f.proofSupported})); + } +}finally{ + await log.close();for(const name of ['pot0.ptau','pot1.ptau','pot_final.ptau'])await fs.rm(path.join(out,name),{force:true}); +} diff --git a/bindings/js/scripts/circuit-large-ui.mjs b/bindings/js/scripts/circuit-large-ui.mjs new file mode 100644 index 00000000..e283a21b --- /dev/null +++ b/bindings/js/scripts/circuit-large-ui.mjs @@ -0,0 +1,40 @@ +// Exercise the actual demo controls, including recovery after cancelling an import. +import {chromium} from '@playwright/test'; +import {spawn} from 'node:child_process'; +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +const base='http://127.0.0.1:8798'; +const server=spawn('python3',['scripts/serve.py','--port','8798'],{stdio:['ignore','pipe','inherit']}); +await new Promise((resolve,reject)=>{server.stdout.once('data',resolve);server.once('error',reject);server.once('exit',c=>reject(Error('Server exited '+c)));}); +try{ + const browser=await chromium.launch({headless:true}); + try{ + const page=await browser.newPage({viewport:{width:1100,height:900}}),errors=[],external=[],assets=[]; + page.on('pageerror',e=>errors.push(e.message)); + page.on('request',r=>{if(new URL(r.url()).origin!==base)external.push(r.url());if(r.url().includes('/assets/'))assets.push(r.url());}); + await page.goto(base+'/examples/circuit-large/'); + await page.waitForFunction(()=>!!document.querySelector('button[data-depth]'));assert.deepEqual(assets,[]); + await page.click('[data-depth="8"]'); + await page.waitForFunction(()=>document.getElementById('result').textContent.startsWith('keyLoad'),null,{timeout:30000}); + await page.click('#cancel'); + await page.waitForFunction(()=>document.getElementById('result').dataset.status==='stopped'); + assert.equal(await page.locator('[data-depth="8"]').isEnabled(),true); + assert.equal(await page.locator('#cancel').isEnabled(),false); + console.log('UI cancellation and controls recovered'); + await page.click('[data-depth="8"]'); + await page.waitForFunction(()=>!!document.getElementById('result').dataset.status,null,{timeout:660000}); + assert.equal(await page.locator('#result').getAttribute('data-status'),'passed',await page.locator('#result').textContent()); + const result=JSON.parse(await page.locator('#full-result').textContent()); + assert.equal(result.invalidPathRejected,true);assert.equal(result.changedRootRejected,true);assert.equal(result.snarkjsAccepted,true); + assert.equal(await page.locator('[data-depth="8"]').isEnabled(),true); + assert.equal(await page.locator('#cancel').isEnabled(),false); + assert.deepEqual(errors,[]);assert.deepEqual(external,[]); + assert(assets.length>0&&assets.every(u=>u.includes('/build/circuit-large/bundle/assets/'))); + const out=new URL('../build/circuit-large/',import.meta.url); + await fs.writeFile(new URL('ui-result.json',out),JSON.stringify({status:'passed',cancellation:true,browser:browser.version(),...result},null,2)); + await page.screenshot({path:new URL('ui-desktop.png',out).pathname,fullPage:true}); + await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + await page.screenshot({path:new URL('ui-mobile-width.png',out).pathname,fullPage:true}); + console.log('UI final-image depth-8 proof and responsive layout passed',result.image); + }finally{await browser.close();} +}finally{server.kill();} diff --git a/bindings/js/scripts/circuit-pack-test.mjs b/bindings/js/scripts/circuit-pack-test.mjs new file mode 100644 index 00000000..54183ba4 --- /dev/null +++ b/bindings/js/scripts/circuit-pack-test.mjs @@ -0,0 +1,54 @@ +// Install only the compiler's packages outside the workspace; run real packaged proving. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {spawnSync} from 'node:child_process'; +const root=fileURLToPath(new URL('../',import.meta.url)),out=path.join(root,'build/circuit-npm');fs.mkdirSync(out,{recursive:true}); +function run(cmd,args,cwd){const r=spawnSync(cmd,args,{cwd,encoding:'utf8',timeout:180000,maxBuffer:4*1024*1024});if(r.status!==0)throw Error(r.stderr+r.stdout);return r.stdout;} +const names=['runtime','groth16-verifier','circuit','circuit-lint','bundle-circuit'],tarballs=[]; +for(const name of names){ + const [packed]=JSON.parse(run('npm',['pack','--json','--ignore-scripts','--pack-destination',out],path.join(root,'packages',name))); + if(name!=='circuit-lint'&&!packed.files.some(f=>f.path==='dist/index.d.ts'))throw Error('Missing types'); + if(packed.files.some(f=>/\.log$|\.zkey$|\.wat$/.test(f.path)))throw Error('Raw artifacts in package'); + tarballs.push(path.join(out,packed.filename)); +} +const consumer=fs.mkdtempSync(path.join(os.tmpdir(),'zeroj-circuit-consumer-')); +try{ + fs.writeFileSync(path.join(consumer,'package.json'),'{"private":true,"type":"module"}'); + run('npm',['install','--ignore-scripts','--offline','--no-audit','--no-fund',...tarballs,'typescript@5.9.3'],consumer); + const dir=path.join(root,'build/circuit/cubic'),f=JSON.parse(fs.readFileSync(path.join(root,'build/circuit/manifest.json'))).find(x=>x.name==='cubic'); + const vk=JSON.parse(fs.readFileSync(path.join(dir,'vk.json'))),key=Array.from(fs.readFileSync(path.join(dir,'key.zkey'))); + const arrayIdentity=JSON.parse(fs.readFileSync(path.join(root,'../wasm/adapter-circuit/build/symbolic-test-vectors/arrays-identity.json'))); + const source=`import {createRuntime} from '@zeroj/runtime'; +import {createCircuitCompiler,defineCircuit,field,bool,uint,array} from '@zeroj/circuit'; +import {createGroth16Verifier,type Groth16VerificationKey} from '@zeroj/groth16-verifier'; +import {bundle} from '@zeroj/bundle-circuit'; +const d=defineCircuit({name:'cubic',public:{y:field()},secret:{x:field()}},({public:p,secret:s,zk})=>{s.x.mul(s.x).mul(s.x).add(s.x).add(zk.constant(5n)).assertEqual(p.y);}); +const r=createRuntime({bundles:[bundle],experimentalSecrets:true}); +try{ + const c=await createCircuitCompiler(r).compile(d,${JSON.stringify({expectedGraphSha256:f.graphSha256,fingerprint:f.fingerprint})}); + const vk=${JSON.stringify(vk)} as Groth16VerificationKey; + const key=await c.loadKey({zkey:new Uint8Array(${JSON.stringify(key)}),expectedSha256:new Uint8Array(${JSON.stringify(Array.from(Buffer.from(f.keySha256,'hex')))}),verificationKey:vk}); + const proof=await c.prove(key,{public:{y:35n},secret:{x:3n}}); + if(!await createGroth16Verifier(r).verify({...proof,verificationKey:vk}))throw Error('proof'); + if(await createGroth16Verifier(r).verify({...proof,publicInputs:[36n],verificationKey:vk}))throw Error('tamper'); + await c.dispose();console.log('Packed circuit SDK: author, compile, prove, verify, reject tamper PASS'); +}finally{await r.dispose();} +const arrays=defineCircuit({name:'arrays',public:{out:array(field(),2)},secret:{flags:array(bool(),2),values:array(uint(8),2)}},({public:p,secret:s})=>{ + for(let i=0;i4096||item.constraints+item.publicInputs+1>4096){item.proofSupported=false;item.proofLimit='Exceeds compiler profile 4096-wire / domain limits';continue;} + run('groth16','setup',item.name+'/circuit.r1cs','pot_final.ptau',item.name+'/key0.zkey'); + run('zkey','contribute',item.name+'/key0.zkey',item.name+'/key.zkey','--name=research-only','-e='+randomBytes(64).toString('hex')); + run('zkey','verify',item.name+'/circuit.r1cs','pot_final.ptau',item.name+'/key.zkey'); + run('zkey','export','verificationkey',item.name+'/key.zkey',item.name+'/vk.json'); + run('groth16','prove',item.name+'/key.zkey',item.name+'/witness.wtns',item.name+'/snarkjs-proof.json',item.name+'/snarkjs-public.json'); + run('groth16','verify',item.name+'/vk.json',item.name+'/snarkjs-public.json',item.name+'/snarkjs-proof.json'); + const key=await fs.readFile(path.join(dir,'key.zkey'));item.keySha256=createHash('sha256').update(key).digest('hex');item.keyBytes=key.length; + item.independentProofVerified=true; + item.proofSupported=key.length<=2*1024*1024&&item.wires<=4096; + if(!item.proofSupported)item.proofLimit=`Measured ${key.length}-byte key exceeds circuit-key-v1 2 MiB import cap; snarkjs proof verified separately`; + await fs.unlink(path.join(dir,'key0.zkey')); +} +await log.close(); +for(const name of ['pot0.ptau','pot1.ptau','pot_final.ptau'])await fs.unlink(path.join(out,name)); +await fs.writeFile(path.join(out,'manifest.json'),JSON.stringify(manifest,null,2)+'\n'); +console.log(`${manifest.length} independently checked R1CS/witness pairs; ${manifest.filter(x=>x.independentProofVerified).length} development keys and snarkjs proofs; ${manifest.filter(x=>x.proofSupported).length} fit Wasm import caps.`); +// snarkjs curve workers are not consistently released by its programmatic wtns check. +process.exit(0); diff --git a/bindings/js/scripts/serve.py b/bindings/js/scripts/serve.py index 063b5276..e9e2a52a 100644 --- a/bindings/js/scripts/serve.py +++ b/bindings/js/scripts/serve.py @@ -11,7 +11,7 @@ class Handler(SimpleHTTPRequestHandler): def log_message(self,*args): pass def end_headers(self): - html=(ROOT/'examples/browser/index.html').read_text() + html='\n'.join(p.read_text() for p in sorted((ROOT/'examples').glob('*/index.html'))) maps=re.findall(r'',html,re.S) hashes=' '.join("'sha256-"+base64.b64encode(hashlib.sha256(m.encode()).digest()).decode()+"'" for m in maps) self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self' blob: 'wasm-unsafe-eval' "+hashes+"; worker-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'") diff --git a/bindings/js/tests/circuit-browser.mjs b/bindings/js/tests/circuit-browser.mjs new file mode 100644 index 00000000..6b33a950 --- /dev/null +++ b/bindings/js/tests/circuit-browser.mjs @@ -0,0 +1,63 @@ +import {chromium,firefox} from '@playwright/test'; +import {spawn} from 'node:child_process'; +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +const port='8793',base='http://127.0.0.1:'+port; +const server=spawn('python3',['scripts/serve.py','--port',port],{stdio:['ignore','pipe','inherit']}); +const out=new URL('../build/circuit-results/',import.meta.url);await fs.mkdir(out,{recursive:true}); +await new Promise((resolve,reject)=>{server.stdout.once('data',resolve);server.once('error',reject);server.once('exit',code=>reject(Error('Server exited '+code)));}); +const all=[]; +try{ + for(const [name,type] of [['chromium',chromium],['firefox',firefox]]){ + const browser=await type.launch({headless:true}); + try{ + const page=await browser.newPage({viewport:{width:1280,height:900}}),errors=[],external=[],assets=[]; + page.on('pageerror',e=>errors.push(e.message)); + page.on('request',r=>{if(new URL(r.url()).origin!==base)external.push(r.url());if(r.url().includes('/assets/'))assets.push(r.url());}); + await page.goto(base+'/examples/circuit/'); + await page.waitForFunction(()=>!!document.querySelector('button[data-name]')); + assert.equal(assets.length,0,'imports must not eagerly fetch Wasm'); + await page.screenshot({path:new URL(name+'-initial.png',out).pathname,fullPage:true}); + const results=[]; + for(const id of ['multiplier','cubic','age_check','symbolic_select','poseidon_opening','sha256_abc','allowlist_d1','merkle_d2','merkle_d8','merkle_d16','merkle_d32']){ + await page.locator('button[data-name="'+id+'"]').click(); + await page.waitForFunction(id=>!!document.getElementById(id).dataset.status,id,{timeout:120000}); + const box=page.locator('#'+id);assert.equal(await box.getAttribute('data-status'),'passed',await box.textContent()); + const result=JSON.parse(await box.textContent());assert.equal(result.valid,true); + const details=page.locator("#"+id+" + details.artifact-details"); + assert.equal(await details.count(),1);assert.equal(await details.getAttribute("open"),null); + assert.equal((await details.textContent()).includes("graphSha256"),true); + if(['sha256_abc','merkle_d8','merkle_d16','merkle_d32'].includes(id))assert.equal(result.proofGenerated,false); + else {assert.equal(result.proofGenerated,true);assert.equal(result.tamperedAccepted,false);assert.equal(result.snarkjsAccepted,true);} + if(id==='sha256_abc')assert.equal(result.hostDigestMatches,true); + if(['poseidon_opening','allowlist_d1','merkle_d2'].includes(id)){ + const proof=await page.evaluate(async id=>{const {scenario}=await import('/examples/circuit/scenarios.mjs');const load=async(n,k)=>{const r=await fetch('/build/circuit/'+n);return k==='text'?r.text():r.arrayBuffer();};const r=await scenario(id,load);return {proof:r.proof.proof,publicInputs:r.proof.publicInputs.map(String)};},id); + await fs.writeFile(new URL('../circuit/'+id+'/'+name+'-proof.json',out),JSON.stringify(proof)); + } + results.push(result); + } + const cancelled=await page.evaluate(async()=>{ + const {createCircuitCompiler}=await import('@zeroj/circuit'); + const {circuitRuntime,symbolicExamples}=await import('/examples/circuit/scenarios.mjs'); + const f=(await(await fetch('/build/circuit/manifest.json')).json()).find(x=>x.name==='poseidon_opening'); + const r=circuitRuntime(),controller=new AbortController(); + try{ + const c=await createCircuitCompiler(r).compile(symbolicExamples.find(x=>x.name===f.name),{expectedGraphSha256:f.graphSha256,fingerprint:f.fingerprint}); + const a={zkey:new Uint8Array(await(await fetch('/build/circuit/poseidon_opening/key.zkey')).arrayBuffer()),expectedSha256:Uint8Array.from(f.keySha256.match(/../g),h=>parseInt(h,16)),verificationKey:await(await fetch('/build/circuit/poseidon_opening/vk.json')).json()}; + const call=r.call.bind(r);r.call=(cap,op,args)=>{const promise=call(cap,op,args);if(op==='finishKey')setTimeout(()=>controller.abort(),25);return promise;}; + try{await c.loadKey(a,{signal:controller.signal});return false;}catch(e){return e.code==='ABORTED';} + }finally{await r.dispose();} + }); + assert.equal(cancelled,true,'browser abort during import'); + assert.deepEqual(errors,[]);assert.deepEqual(external,[]); + await page.screenshot({path:new URL(name+'-results.png',out).pathname,fullPage:true}); + await page.locator('section').filter({has:page.locator('#allowlist_d1')}).screenshot({path:new URL(name+'-allowlist.png',out).pathname}); + await page.setViewportSize({width:390,height:844}); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + await page.screenshot({path:new URL(name+'-mobile.png',out).pathname,fullPage:true}); + all.push({browser:name,version:browser.version(),results}); + console.log(name,browser.version(),'all eleven compiler scenarios passed'); + }finally{await browser.close();} + } + await fs.writeFile(new URL('browser.json',out),JSON.stringify(all,null,2)+'\n'); +}finally{server.kill();} diff --git a/bindings/js/tests/circuit-frontend.test.mjs b/bindings/js/tests/circuit-frontend.test.mjs new file mode 100644 index 00000000..f0a6bf4b --- /dev/null +++ b/bindings/js/tests/circuit-frontend.test.mjs @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import {defineCircuit,field,uint,bool,bytes,array} from '@zeroj/circuit'; +import {ageCheck,sha256Preimage} from '../build/circuit-examples/symbolic-examples.js'; +import {lintSymbolic} from '@zeroj/circuit-lint'; +const spec=()=>({name:'test',public:{out:field()},secret:{x:field()}}); +test('typed inputs encode exactly and never enter authoring recipe',()=>{ + assert.deepEqual({...ageCheck.encodeInputs({public:{threshold:18n},secret:{age:21n}})},{threshold:'18',age:'21'}); + for(const age of [-1n,256n,21,'21'])assert.throws(()=>ageCheck.encodeInputs({public:{threshold:18n},secret:{age}})); + assert.throws(()=>ageCheck.encodeInputs({public:{threshold:18n},secret:{age:21n,extra:1n}})); + const data=sha256Preimage.encodeInputs({public:{digest:new Uint8Array(32)},secret:{message:new Uint8Array([97,98,99])}}); + assert.equal(data.message_2,'99');assert.equal(Object.keys(data).length,35); + assert.throws(()=>sha256Preimage.encodeInputs({public:{digest:new Uint8Array(32)},secret:{message:[97,98,99]}})); + assert.throws(()=>sha256Preimage.encodeInputs({public:{digest:new Uint8Array(32)},secret:{message:new Uint8Array(4)}})); + assert.equal(ageCheck.recipe.includes('21'),false); +}); +test('ownership, fake symbols, closed definitions and caught authoring errors reject',()=>{ + let escaped;defineCircuit(spec(),({secret:s})=>{escaped=s.x;}); + assert.throws(()=>escaped.add(escaped)); + assert.throws(()=>defineCircuit(spec(),({secret:s})=>{s.x.add(escaped);})); + assert.throws(()=>defineCircuit(spec(),({secret:s})=>{s.x.add({__zerojSymbolic:'field'});})); + assert.throws(()=>defineCircuit(spec(),({secret:s})=>{try{s.x.assertEqual(undefined);}catch{}})); + assert.throws(()=>defineCircuit(spec(),({zk})=>{try{zk.constant(-1n);}catch{}})); + assert.throws(()=>defineCircuit(spec(),({secret:s})=>{try{String(s.x);}catch{}})); + assert.throws(()=>defineCircuit(spec(),async()=>{})); + assert.throws(()=>defineCircuit(spec(),({secret:s})=>s.x)); + assert.throws(()=>defineCircuit(spec(),({secret:s})=>{for(let i=0;i<129;i++)s.x.assertEqual(s.x);})); +}); +test('shape validation and byte/profile limits reject before compilation',()=>{ + for(const n of [0,254,1.5,NaN])assert.throws(()=>uint(n)); + for(const n of [0,65,-1])assert.throws(()=>bytes(n)); + assert.throws(()=>defineCircuit({name:'bad',public:{x:field()},secret:{x:field()}},()=>{})); + assert.throws(()=>defineCircuit({name:'bad',public:{x:bytes(2)},secret:{x_0:field()}},()=>{})); + assert.throws(()=>defineCircuit({name:'bad',public:{},secret:{x:bytes(56)}},({zk,secret:s})=>{zk.gadgets.sha256(s.x);})); + assert.throws(()=>defineCircuit({name:'bad',public:{a:bytes(2)},secret:{b:bytes(3)}},({public:p,secret:s})=>{try{p.a.assertEqual(s.b);}catch{}})); + assert.throws(()=>defineCircuit({name:'bad',public:{},secret:{flag:bool()}},({secret:s})=>{s.flag.select(s.flag,{});})); +}); +test('type-aware lint catches symbolic JS truthiness and operators; symbolic select is allowed',async()=>{ + // TypeScript itself allows if(object); runtime coercion hooks cannot intercept it. + const file='build/lint-negative.ts'; + await fs.writeFile(file,`import {defineCircuit,field,bool} from '@zeroj/circuit'; + defineCircuit({name:'bad',public:{y:field()},secret:{x:field(),b:bool()}},({public:p,secret:s})=>{ + if(s.b) s.x.assertEqual(p.y); + const wrong=s.x===p.y; + const ignored=s.b&&s.x; + const cast=Boolean(s.b); + const negated=!s.b; + const selected=s.b?s.x:p.y; + s.b.select(s.x,p.y).assertEqual(p.y); + });`); + assert.equal(lintSymbolic([file]).length,6); + assert.deepEqual(lintSymbolic(['examples/circuit/symbolic-examples.ts']),[]); +}); + +test('fixed arrays have immutable schemas, typed values and bounded static indices',()=>{ + const element={type:'uint',bits:8},spec=array(element,2);element.bits=16; + const c=defineCircuit({name:'arrays_test',public:{out:array(field(),2)},secret:{values:spec,flags:array(bool(),2)}},({public:p,secret:s})=>{ + for(let i=0;ic.encodeInputs({...input,secret:{...input.secret,values}})); + assert.throws(()=>c.encodeInputs({...input,secret:{...input.secret,flags:[1,0]}})); + for(const n of [0,65,-1,1.5,NaN])assert.throws(()=>array(field(),n)); + assert.throws(()=>array(bytes(2),1));assert.throws(()=>array(array(field(),2),2)); + assert.throws(()=>defineCircuit({name:'collision',public:{x:array(field(),1)},secret:{x_0:field()}},()=>{})); + assert.throws(()=>defineCircuit({name:'bounds',public:{},secret:{a:array(field(),2)}},({secret:s})=>{try{s.a.get(2);}catch{}})); + assert.throws(()=>defineCircuit({name:'bounds',public:{},secret:{a:array(field(),2)}},({secret:s})=>{s.a.get(s.a.get(0));})); +}); +test('Merkle array ownership, element type and lengths cannot be forged',()=>{ + const spec={name:'merkle_test',public:{root:field()},secret:{leaf:field(),siblings:array(field(),1),bits:array(bool(),1)}}; + let foreign;defineCircuit(spec,({secret:s})=>{foreign=s.siblings;}); + for(const change of [(s)=>foreign,(s)=>({__zerojSymbolic:'array',length:1}),(s)=>s.bits]) + assert.throws(()=>defineCircuit(spec,({secret:s,zk})=>{try{zk.gadgets.merkleRootBls12381T3(s.leaf,change(s),s.bits);}catch{}})); + assert.throws(()=>defineCircuit({...spec,secret:{...spec.secret,bits:array(bool(),2)}},({secret:s,zk})=>{zk.gadgets.merkleRootBls12381T3(s.leaf,s.siblings,s.bits);})); + assert.throws(()=>defineCircuit({...spec,secret:{leaf:field(),siblings:array(field(),33),bits:array(bool(),33)}},({secret:s,zk})=>{zk.gadgets.merkleRootBls12381T3(s.leaf,s.siblings,s.bits);})); + assert.equal(JSON.parse(ageCheck.recipe).version,1,'existing recipes keep v1'); +}); diff --git a/bindings/js/tests/circuit-key.test.mjs b/bindings/js/tests/circuit-key.test.mjs new file mode 100644 index 00000000..3e41b866 --- /dev/null +++ b/bindings/js/tests/circuit-key.test.mjs @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import {createCircuitCompiler} from '@zeroj/circuit'; +import {circuitRuntime,symbolicExamples} from '../examples/circuit/scenarios.mjs'; +const root=new URL('../build/circuit/',import.meta.url); +const manifest=JSON.parse(await fs.readFile(new URL('manifest.json',root),'utf8')); +const fixture=n=>manifest.find(x=>x.name===n); +const definition=n=>symbolicExamples.find(x=>x.name===n); +const expected=f=>({expectedGraphSha256:f.graphSha256,fingerprint:f.fingerprint}); +const artifact=async n=>({zkey:new Uint8Array(await fs.readFile(new URL(n+'/key.zkey',root))), + expectedSha256:Buffer.from(fixture(n).keySha256,'hex'),verificationKey:JSON.parse(await fs.readFile(new URL(n+'/vk.json',root),'utf8'))}); +const digest=b=>createHash('sha256').update(b).digest(); +const sections=b=>{const v=new DataView(b.buffer,b.byteOffset,b.byteLength),m={};let o=12;for(let i=0;i{const stats=await c.keyResources();assert.equal(stats.reservedBytes,0);assert.equal(stats.importedKeys,0);}; +test('chunk import enforces digest, parser validation and unchanged dimensions before retaining a key',async()=>{ + const r=circuitRuntime();try{ + const c=await createCircuitCompiler(r).compile(definition('poseidon_opening'),expected(fixture('poseidon_opening'))),a=await artifact('poseidon_opening'); + await assert.rejects(c.loadKey({...a,expectedSha256:new Uint8Array(32)}),e=>e.code==='INVALID_PROVING_KEY');await clean(c); + for(const mutate of [ + b=>b[0]=0, + b=>b.fill(255,sections(b)[2]+100,sections(b)[2]+148), // noncanonical base field + b=>b.fill(0,sections(b)[2]+100,sections(b)[2]+196), // forbidden alpha infinity + b=>{ // (0,2) is on y²=x³+4 but has order 3, outside the prime-order G1 subgroup. + const h=sections(b)[2],q=BigInt('0x'+Buffer.from(b.subarray(h+4,h+52)).reverse().toString('hex')); + let y=(2n*(1n<<384n))%q;b.fill(0,h+100,h+196); + for(let i=0;i<48;i++){b[h+148+i]=Number(y&255n);y>>=8n;} + }, + b=>new DataView(b.buffer).setUint32(sections(b)[2]+88,4097,true), // max wires + 1 + b=>new DataView(b.buffer).setUint32(sections(b)[2]+96,8192,true), // above domain cap + b=>new DataView(b.buffer).setUint32(sections(b)[4],16385,true), + ]){ + const bad=a.zkey.slice();mutate(bad); + await assert.rejects(c.loadKey({...a,zkey:bad,expectedSha256:digest(bad)}),e=>e.code==='INVALID_PROVING_KEY');await clean(c); + } + await assert.rejects(c.loadKey({...a,zkey:new Uint8Array(2*1024*1024+1)}),e=>e.code==='INVALID_BYTES');await clean(c); + const key=await c.loadKey(a),stats=await c.keyResources(); + assert.equal(stats.reservedBytes,0);assert.equal(stats.peakStagingBytes,a.zkey.length);assert.equal(stats.importedKeys,1);assert.equal(stats.lastKeyBytes,a.zkey.length); + await assert.rejects(c.loadKey(a),e=>e.code==='KEY_LIMIT'); + await r.release(key);await assert.rejects(c.loadKey(a),e=>e.code==='KEY_LIMIT'); + }finally{await r.dispose();} +}); +test('invalid Poseidon witness/public commitment rejects after a valid key load',async()=>{ + const r=circuitRuntime();try{ + const f=fixture('poseidon_opening'),c=await createCircuitCompiler(r).compile(definition(f.name),expected(f)); + const key=await c.loadKey(await artifact(f.name)); + const flat=JSON.parse(await fs.readFile(new URL(f.name+'/inputs.json',root),'utf8')); + const input={public:{commitment:BigInt(flat.commitment)},secret:{value:BigInt(flat.value),salt:BigInt(flat.salt)}}; + input.secret.value+=1n;await assert.rejects(c.prove(key,input),e=>['INVALID_WITNESS','INVALID_REQUEST'].includes(e.code)); + input.secret.value-=1n;input.public.commitment+=1n;await assert.rejects(c.prove(key,input),e=>['INVALID_WITNESS','INVALID_REQUEST'].includes(e.code)); + }finally{await r.dispose();} +}); +test('pre-abort leaves runtime usable; abort during transfer/import terminates all handles',async()=>{ + for(const stage of ['before','appendKey','finishKey']){ + const r=circuitRuntime();try{ + const f=fixture('poseidon_opening'),c=await createCircuitCompiler(r).compile(definition(f.name),expected(f)),a=await artifact(f.name),controller=new AbortController(); + if(stage==='before')controller.abort(); + else{ + const call=r.call.bind(r);r.call=(cap,op,args)=>{const promise=call(cap,op,args);if(op===stage)setTimeout(()=>controller.abort(),stage==='finishKey'?25:0);return promise;}; + } + await assert.rejects(c.loadKey(a,{signal:controller.signal}),e=>e.code==='ABORTED'); + if(stage==='before')await clean(c);else await assert.rejects(c.keyResources(),e=>['INVALID_HANDLE','DISPOSED'].includes(e.code)); + }finally{await r.dispose();} + } +}); +test('circuit disposal during transfer drops the upload and rejects its load promise',async()=>{ + const r=circuitRuntime();try{ + const f=fixture('poseidon_opening'),c=await createCircuitCompiler(r).compile(definition(f.name),expected(f)),a=await artifact(f.name); + const call=r.call.bind(r);let closing;r.call=(cap,op,args)=>{const promise=call(cap,op,args);if(op==='appendKey'&&!closing)closing=c.dispose();return promise;}; + await assert.rejects(c.loadKey(a),e=>e.code==='INVALID_HANDLE');await closing; + }finally{await r.dispose();} +}); + +test('small direct-request compatibility shares the key slot and reports import accounting',async()=>{ + const r=circuitRuntime();try{ + const f=fixture('cubic'),a=await artifact('cubic'); + const c=await r.call('circuit','compileRecipe',{recipe:definition('cubic').recipe,...expected(f)}); + const loaded=await r.call('circuit','loadKey',{circuit:c.handle,fingerprint:f.fingerprint, + zkey:Buffer.from(a.zkey).toString('hex'),expectedSha256:Buffer.from(a.expectedSha256).toString('hex'),verificationKey:a.verificationKey}); + const stats=await r.call('circuit','keyResources',{circuit:c.handle}); + assert.equal(stats.lastKeyBytes,a.zkey.length);assert.ok(stats.lastImportMs>0);assert.equal(stats.importedKeys,1); + await assert.rejects(r.call('circuit','beginKey',{circuit:c.handle,fingerprint:f.fingerprint,length:a.zkey.length, + expectedSha256:Buffer.from(a.expectedSha256).toString('hex'),verificationKey:a.verificationKey}),e=>e.code==='KEY_LIMIT'); + assert.ok(loaded.handle); + }finally{await r.dispose();} +}); + +test('import deadline terminates the worker and invalidates its compiled handle',async()=>{ + const r=circuitRuntime({timeoutMs:1000});try{ + const f=fixture('poseidon_opening'),c=await createCircuitCompiler(r).compile(definition(f.name),expected(f)); + await assert.rejects(c.loadKey(await artifact(f.name)),e=>e.code==='CALL_TIMEOUT'); + await assert.rejects(c.keyResources(),e=>e.code==='INVALID_HANDLE'); + }finally{await r.dispose();} +}); diff --git a/bindings/js/tests/circuit-large-negative.mjs b/bindings/js/tests/circuit-large-negative.mjs new file mode 100644 index 00000000..db6ea332 --- /dev/null +++ b/bindings/js/tests/circuit-large-negative.mjs @@ -0,0 +1,48 @@ +// Explicit opt-in test: requires separate large fixtures/research assets and the default image. +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import {createRuntime} from '@zeroj/runtime'; +import {createCircuitCompiler} from '@zeroj/circuit'; +import {bundle as standardBundle} from '@zeroj/bundle-circuit'; +import {bundle as researchBundle} from '../build/circuit-large/bundle/dist/index.js'; +import {merkleMembership} from '../build/circuit-examples/symbolic-examples.js'; +const dir=new URL('../build/circuit-large/',import.meta.url),f=JSON.parse(await fs.readFile(new URL('manifest.json',dir),'utf8')).find(x=>x.name==='merkle_d8'); +if(!f?.developmentOnly||!f.proofSupported)throw Error('Generate research fixtures'); +const key=new Uint8Array(await fs.readFile(new URL('merkle_d8/key.zkey',dir))),vk=JSON.parse(await fs.readFile(new URL('merkle_d8/vk.json',dir),'utf8')); +const identity={expectedGraphSha256:f.graphSha256,fingerprint:f.fingerprint},hash=b=>new Uint8Array(createHash('sha256').update(b).digest()); +const r=createRuntime({bundles:[standardBundle],experimentalSecrets:true}); +try{ + const c=await createCircuitCompiler(r).compile(merkleMembership(8),identity); + assert.equal(c.description.keyProfile,'circuit-key-v1');assert.equal(c.description.withinProverDimensions,false); + await assert.rejects(c.loadKey({zkey:key,expectedSha256:hash(key),verificationKey:vk}),e=>e.code==='INVALID_BYTES'); + await assert.rejects(r.call('circuit','beginKey',{circuit:'h1',fingerprint:f.fingerprint,length:1,expectedSha256:f.keySha256,verificationKey:vk}),e=>e.code==='CIRCUIT_LIMIT'); + assert.equal((await c.keyResources()).peakStagingBytes,0); +}finally{await r.dispose();} +const s=createRuntime({bundles:[researchBundle],experimentalSecrets:true,timeoutMs:600000}); +try{ + const c=await createCircuitCompiler(s).compile(merkleMembership(8),identity); + const meta={circuit:'h1',fingerprint:f.fingerprint,expectedSha256:f.keySha256,verificationKey:vk}; + for(const length of [0,33554433,2147483647])await assert.rejects(s.call('circuit','beginKey',{...meta,length})); + await assert.rejects(s.call('circuit','beginKey',{...meta,length:1,profile:'unbounded'})); + const upload=await s.call('circuit','beginKey',{...meta,length:2}); + await s.call('circuit','appendKey',{circuit:'h1',upload:upload.upload,offset:0,chunk:'00'}); + await assert.rejects(s.call('circuit','finishKey',{circuit:'h1',upload:upload.upload}),e=>e.code==='INCOMPLETE_KEY'); + assert.equal((await c.keyResources()).reservedBytes,0); + await assert.rejects(c.loadKey({zkey:key,expectedSha256:new Uint8Array(32),verificationKey:vk}),e=>e.code==='INVALID_PROVING_KEY'); + // Oversized wire/domain header with recomputed integrity hash must reach parser validation. + let offset=12,header;const data=Buffer.from(key); + for(let i=0;ie.code==='INVALID_PROVING_KEY'); + assert.equal((await c.keyResources()).reservedBytes,0); + } + // Cancellation during actual subgroup import, after the last key chunk. + const controller=new AbortController(),call=s.call.bind(s);let entered=false; + s.call=(cap,op,args)=>{const promise=call(cap,op,args);if(op==='finishKey'){entered=true;setTimeout(()=>controller.abort(),100);}return promise;}; + await assert.rejects(c.loadKey({zkey:key,expectedSha256:hash(key),verificationKey:vk},{signal:controller.signal}),e=>e.code==='ABORTED'); + assert.equal(entered,true);await assert.rejects(c.keyResources(),e=>e.code==='INVALID_HANDLE'); +}finally{await s.dispose();} +console.log('Default admission, research ceilings, incomplete/digest/malformed-header rejection, cleanup and actual import cancellation PASS'); diff --git a/bindings/js/tests/circuit-types.ts b/bindings/js/tests/circuit-types.ts new file mode 100644 index 00000000..40094555 --- /dev/null +++ b/bindings/js/tests/circuit-types.ts @@ -0,0 +1,41 @@ +import {defineCircuit,field,bool,uint,bytes,array} from '@zeroj/circuit'; +const circuit=defineCircuit({name:'typing',public:{out:field()},secret:{x:field(),flag:bool(),count:uint(8),data:bytes(3)}},({public:p,secret:s,zk})=>{ + s.flag.select(s.x,p.out).assertEqual(p.out); + zk.gadgets.sha256(s.data).get(0).asField(); + // @ts-expect-error a boolean is not a field + s.x.mul(s.flag); + // @ts-expect-error selection branches must have the same symbolic type + s.flag.select(s.x,s.count); + // @ts-expect-error SHA input must be bytes + zk.gadgets.sha256(s.x); + // @ts-expect-error a host bigint is not a symbolic value + s.x.add(1n); +}); +circuit.encodeInputs({public:{out:1n},secret:{x:1n,flag:true,count:2n,data:new Uint8Array(3)}}); +// @ts-expect-error use bigint, not a lossy number +circuit.encodeInputs({public:{out:1},secret:{x:1n,flag:true,count:2n,data:new Uint8Array(3)}}); +// @ts-expect-error missing private data +circuit.encodeInputs({public:{out:1n},secret:{x:1n,flag:true,count:2n}}); + +import type {CompiledCircuit,CircuitKeyArtifact} from '@zeroj/circuit'; +declare const compiled:CompiledCircuit<{public:{y:bigint};secret:{x:bigint}}>; +declare const artifact:CircuitKeyArtifact; +compiled.loadKey(artifact,{signal:new AbortController().signal}); +compiled.keyResources().then(r=>r.reservedBytes+r.lastImportMs); +// @ts-expect-error cancellation requires an AbortSignal +compiled.loadKey(artifact,{signal:true}); +// @ts-expect-error an arbitrary key cannot bypass local ownership +compiled.prove('key',{public:{y:35n},secret:{x:3n}}); + +const membership=defineCircuit({name:'membership',public:{root:field()},secret:{leaf:field(),siblings:array(field(),1),path:array(bool(),1)}},({public:p,secret:s,zk})=>{ + zk.gadgets.merkleRootBls12381T3(s.leaf,s.siblings,s.path).assertEqual(p.root); + // @ts-expect-error path directions must be symbolic booleans + zk.gadgets.merkleRootBls12381T3(s.leaf,s.siblings,s.siblings); + // @ts-expect-error indexing is a host integer, not a field witness + s.siblings.get(s.leaf); +}); +membership.encodeInputs({public:{root:1n},secret:{leaf:2n,siblings:[3n],path:[true]}}); +// @ts-expect-error field arrays take bigint values +membership.encodeInputs({public:{root:1n},secret:{leaf:2n,siblings:[3],path:[true]}}); +// @ts-expect-error nested arrays are not admitted in recipe v2 +array(array(field(),2),2); diff --git a/bindings/js/tests/circuit.test.mjs b/bindings/js/tests/circuit.test.mjs new file mode 100644 index 00000000..352f8f9f --- /dev/null +++ b/bindings/js/tests/circuit.test.mjs @@ -0,0 +1,147 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {createCircuitCompiler} from '@zeroj/circuit'; +import {cubic} from '@zeroj/circuit/graph'; +import {scenario,circuitRuntime,symbolicExamples,allDefinitions,typedFixtureInputs} from '../examples/circuit/scenarios.mjs'; +import {operations,widthOverflow,underflow} from '../build/circuit-examples/operation-examples.js'; +const load=(name,kind)=>fs.readFile(new URL('../build/circuit/'+name,import.meta.url),kind==='text'?'utf8':undefined); +const require=createRequire(import.meta.url),snarkjs=require('snarkjs'); +const manifest=JSON.parse(await load('manifest.json','text')); +const expected=f=>({expectedGraphSha256:f.graphSha256,fingerprint:f.fingerprint}); +const fixture=name=>manifest.find(x=>x.name===name); +const definition=name=>symbolicExamples.find(x=>x.name===name); +const hash=text=>createHash('sha256').update(text).digest('hex'); +for(const name of ['multiplier',...allDefinitions.map(x=>x.name)])test('packaged compiler '+name+' compiles/checks and small proofs interoperate',async()=>{ + const result=await scenario(name,load); + assert.equal(result.valid,true); + if(result.proofGenerated)assert.equal(await snarkjs.groth16.verify(result.verificationKey,result.proof.publicInputs.map(String),result.proof.proof),true); + else assert.equal(fixture(name).proofSupported,false); + const {proof,verificationKey,...summary}=result; + if(proof)await fs.writeFile(new URL('../build/circuit/'+name+'/node-proof.json',import.meta.url),JSON.stringify({proof:proof.proof,publicInputs:proof.publicInputs.map(String)})); + await fs.mkdir(new URL('../build/circuit-results/',import.meta.url),{recursive:true}); + await fs.writeFile(new URL('../build/circuit-results/'+name+'.json',import.meta.url),JSON.stringify(summary,null,2)); +}); +test('portable TS graph matches Java and uses the same SDK lifecycle',async()=>{ + assert.equal(cubic(),await load('cubic/graph.json','text')); + assert.equal((await scenario('cubic',load,{graph:true})).proofGenerated,true); +}); +test('graph and constraint identities are both enforced; a failed attempt cannot compile again',async()=>{ + for(const wrong of [{...expected(fixture('cubic')),expectedGraphSha256:'0'.repeat(64)},{...expected(fixture('cubic')),fingerprint:'wrong'}]){ + const r=circuitRuntime();try{ + const c=createCircuitCompiler(r); + await assert.rejects(c.compile(definition('cubic'),wrong),e=>e.code==='CIRCUIT_MISMATCH'); + await assert.rejects(c.compile(definition('cubic'),expected(fixture('cubic')))); + }finally{await r.dispose();} + } +}); +test('canonical encoding, compiler policy and closed operation vocabulary reject',async()=>{ + const source=await load('cubic/graph.json','text'); + for(const bad of [' '+source,source+' {}',source.replace('zeroj-graph','zeroj-graph-research'),source.replace('zeroj-r1cs-bls12381-v1','different-policy'),source.replace('"format":"zeroj-graph","version":1','"version":1,"format":"zeroj-graph"')]){ + const r=circuitRuntime();try{await assert.rejects(createCircuitCompiler(r).compileGraph(bad,{expectedGraphSha256:hash(bad),fingerprint:fixture('cubic').fingerprint}));}finally{await r.dispose();} + } + const raw=JSON.parse(definition('age_check').recipe);raw.commands[0][1]=999; + const r=circuitRuntime();try{await assert.rejects(r.call('circuit','compileRecipe',{recipe:JSON.stringify(raw),...expected(fixture('age_check'))}));}finally{await r.dispose();} +}); +test('witness opt-in, byte/range constraints and disposal reject invalid use',async()=>{ + const r=circuitRuntime({experimentalSecrets:false});try{ + const c=await createCircuitCompiler(r).compile(definition('age_check'),expected(fixture('age_check'))); + await assert.rejects(c.checkWitness({public:{threshold:18n},secret:{age:21n}}),e=>e.code==='EXPERIMENTAL_SECRETS_DISABLED'); + }finally{await r.dispose();} + const s=circuitRuntime();try{ + const c=await createCircuitCompiler(s).compile(definition('age_check'),expected(fixture('age_check'))); + await assert.rejects(c.checkWitness({public:{threshold:18n},secret:{age:17n}})); + await assert.rejects(c.checkWitness({public:{threshold:18n},secret:{age:256n}})); + await c.dispose();await assert.rejects(c.checkWitness({public:{threshold:18n},secret:{age:21n}}),e=>e.code==='INVALID_HANDLE'); + }finally{await s.dispose();} + const t=circuitRuntime();try{ + const c=await createCircuitCompiler(t).compile(definition('sha256_abc'),expected(fixture('sha256_abc'))); + const input=typedFixtureInputs(definition('sha256_abc'),JSON.parse(await load('sha256_abc/inputs.json','text'))); + input.secret.message[0]=98;await assert.rejects(c.checkWitness(input)); + }finally{await t.dispose();} +}); +test('keys retain circuit/runtime ownership and are released with their circuit',async()=>{ + const a=circuitRuntime(),b=circuitRuntime();try{ + const f=fixture('cubic'),d=definition('cubic'),ca=await createCircuitCompiler(a).compile(d,expected(f)),cb=await createCircuitCompiler(b).compile(d,expected(f)); + const key=await ca.loadKey({zkey:new Uint8Array(await load('cubic/key.zkey','bytes')),expectedSha256:Buffer.from(f.keySha256,'hex'),verificationKey:JSON.parse(await load('cubic/vk.json','text'))}); + const input={public:{y:35n},secret:{x:3n}}; + await assert.rejects(cb.prove(key,input),e=>e.code==='INVALID_HANDLE'); + const keyId=a.handleId(key,'circuit-proving-key','circuit'); + await ca.dispose(); + await assert.rejects(ca.prove(key,input),e=>e.code==='INVALID_HANDLE'); + assert.equal((await a.call('circuit','compileRecipe',{recipe:d.recipe,...expected(f)}).catch(e=>({code:e.code}))).code,'INVALID_REQUEST'); + await a.release(key); // Java already removed the owned key; TS drops its stale wrapper. + await assert.rejects(a.call('circuit','prove',{circuit:'h1',key:keyId,input:{x:'3',y:'35'}})); + }finally{await a.dispose();await b.dispose();} +}); +test('wrong relation rejects and wrong VK fails self-verification',async()=>{ + const r=circuitRuntime();try{ + const f=fixture('cubic'),c=await createCircuitCompiler(r).compile(definition('cubic'),expected(f)); + const other=fixture('multiplier'); + await assert.rejects(c.loadKey({zkey:new Uint8Array(await load('multiplier/key.zkey','bytes')),expectedSha256:Buffer.from(other.keySha256,'hex'),verificationKey:JSON.parse(await load('multiplier/vk.json','text'))})); + const wrongVkKey=await c.loadKey({zkey:new Uint8Array(await load('cubic/key.zkey','bytes')),expectedSha256:Buffer.from(f.keySha256,'hex'),verificationKey:JSON.parse(await load('multiplier/vk.json','text'))}); + await assert.rejects(c.prove(wrongVkKey,{public:{y:35n},secret:{x:3n}}),e=>e.code==='KEY_PROOF_MISMATCH'); + + }finally{await r.dispose();} +}); +test('every exposed primitive executes in Wasm; zero divisor, integer underflow and width overflow reject',async()=>{ + const vectors=new URL('../../wasm/adapter-circuit/build/symbolic-test-vectors/',import.meta.url); + const id=JSON.parse(await fs.readFile(new URL('operations-identity.json',vectors),'utf8')); + const flat=JSON.parse(await fs.readFile(new URL('operations-input.json',vectors),'utf8')); + const r=circuitRuntime();try{ + const c=await createCircuitCompiler(r).compile(operations,id),input=typedFixtureInputs(operations,flat); + assert.equal((await c.checkWitness(input)).valid,true);input.secret.y=0n;await assert.rejects(c.checkWitness(input)); + }finally{await r.dispose();} + const s=circuitRuntime();try{ + // Expansion rejects width overflow before circuit-identity admission. + await assert.rejects(createCircuitCompiler(s).compile(widthOverflow,id)); + }finally{await s.dispose();} + const t=circuitRuntime();try{ + const underflowId=JSON.parse(await fs.readFile(new URL('underflow-identity.json',vectors),'utf8')); + const c=await createCircuitCompiler(t).compile(underflow,underflowId); + await assert.rejects(c.checkWitness({public:{},secret:{u:1n,v:2n}})); + }finally{await t.dispose();} +}); +test.after(async()=>{const curve=await require('ffjavascript').getCurveFromName('bls12381');await curve.terminate();}); + +test('Merkle Wasm constraints reject changed root, leaf, sibling, direction and non-boolean raw input',async()=>{ + for(const name of ['merkle_d1','merkle_d2']){ + const d=allDefinitions.find(x=>x.name===name),f=fixture(name),r=circuitRuntime(); + try{ + const c=await createCircuitCompiler(r).compile(d,expected(f)); + const flat=JSON.parse(await load(name+'/inputs.json','text')),input=typedFixtureInputs(d,flat); + for(const mutate of [v=>v.public.root+=1n,v=>v.secret.leaf+=1n,v=>v.secret.siblings[0]+=1n,v=>v.secret.pathBits[0]=!v.secret.pathBits[0]]){ + const bad=structuredClone(input);mutate(bad);await assert.rejects(c.checkWitness(bad)); + } + // Direct worker boundary bypasses TS validation; the Java relation must still reject 2 as a bit. + assert.equal((await r.call('circuit','checkWitness',{circuit:'h1',input:flat})).valid,true); + await assert.rejects(r.call('circuit','checkWitness',{circuit:'h1',input:{...flat,pathBits_0:'2'}})); + }finally{await r.dispose();} + } +}); +test('a valid proof for an unapproved root is exported for the Cardano policy negative case',async()=>{ + const d=allDefinitions.find(x=>x.name==='allowlist_d1'),f=fixture(d.name),r=circuitRuntime(); + try{ + const c=await createCircuitCompiler(r).compile(d,expected(f)); + const key=await c.loadKey({zkey:new Uint8Array(await load(d.name+'/key.zkey','bytes')),expectedSha256:Buffer.from(f.keySha256,'hex'),verificationKey:JSON.parse(await load(d.name+'/vk.json','text'))}); + // Same relation/key, opposite direction and a host-Java-computed alternate root. + const inputs=typedFixtureInputs(d,JSON.parse(await load('merkle_d1/inputs.json','text'))); + const proof=await c.prove(key,inputs); + assert.equal(await snarkjs.groth16.verify(JSON.parse(await load(d.name+'/vk.json','text')),proof.publicInputs.map(String),proof.proof),true); + await fs.writeFile(new URL('../build/circuit/allowlist_d1/unapproved-root-proof.json',import.meta.url),JSON.stringify({proof:proof.proof,publicInputs:proof.publicInputs.map(String)})); + }finally{await r.dispose();} +}); + + +test('fixed field/bool/UInt array indexing executes in Wasm with constrained values',async()=>{ + const {arrayExample}=await import('../build/circuit-examples/symbolic-examples.js'); + const identity=JSON.parse(await fs.readFile(new URL('../../wasm/adapter-circuit/build/symbolic-test-vectors/arrays-identity.json',import.meta.url),'utf8')); + const r=circuitRuntime();try{ + const c=await createCircuitCompiler(r).compile(arrayExample,identity); + const input={public:{out:[5n,7n]},secret:{flags:[true,true],values:[5n,7n]}}; + assert.equal((await c.checkWitness(input)).valid,true); + input.secret.flags[1]=false;await assert.rejects(c.checkWitness(input)); + }finally{await r.dispose();} +}); diff --git a/bindings/js/tests/groth16.test.mjs b/bindings/js/tests/groth16.test.mjs index 6060271a..066068f4 100644 --- a/bindings/js/tests/groth16.test.mjs +++ b/bindings/js/tests/groth16.test.mjs @@ -31,6 +31,10 @@ test('supplied-key Wasm proofs verify independently in snarkjs and vice versa',a const description=await p.describe(meta.circuit);assert.equal(description.fingerprint,meta.fingerprint); await assert.rejects(p.loadKey({...input,expectedSha256:new Uint8Array(32)}),{code:'INVALID_PROVING_KEY'}); await assert.rejects(p.loadKey({...input,fingerprint:'untrusted'}),{code:'CIRCUIT_MISMATCH'}); + // The compiler's chunk profile must not widen the standalone prover or JSON byte cap. + await assert.rejects(p.loadKey({...input,zkey:new Uint8Array(262145)}),{code:'INVALID_BYTES'}); + await assert.rejects(runtime.call('groth16-prover','loadKey',{ + ...input,zkey:'00'.repeat(262145),expectedSha256:meta.sha256}),{code:'INVALID_REQUEST'}); const key=await p.loadKey(input); const start=performance.now(),result=await p.proveCubic(key,3n),elapsed=performance.now()-start; assert.deepEqual(result.publicInputs,[35n]); diff --git a/bindings/wasm/adapter-circuit/build.gradle b/bindings/wasm/adapter-circuit/build.gradle new file mode 100644 index 00000000..cafd65ad --- /dev/null +++ b/bindings/wasm/adapter-circuit/build.gradle @@ -0,0 +1,73 @@ +dependencies { + implementation project(':adapter-groth16') + api 'org.zeroj:zeroj-circuit-dsl:0.1.0' + implementation 'org.zeroj:zeroj-circuit-annotation-api:0.1.0' + implementation 'org.zeroj:zeroj-circuit-lib:0.1.0' + implementation 'org.zeroj:zeroj-crypto:0.1.0' + testAnnotationProcessor 'org.zeroj:zeroj-circuit-annotation-processor:0.1.0' +} +test { + maxHeapSize = '2g' + inputs.files(fileTree('build/recipes') { include '*.json' }) + outputs.dir('build/symbolic-test-vectors') +} +tasks.register('fixtures', JavaExec) { + dependsOn testClasses + classpath = sourceSets.test.runtimeClasspath + mainClass = 'org.zeroj.bindings.circuit.Fixtures' + maxHeapSize = '2g' + args rootProject.file('../js/build/circuit').absolutePath +} + +tasks.register('exportRecipe', JavaExec) { + dependsOn classes + classpath = sourceSets.main.runtimeClasspath + mainClass = 'org.zeroj.bindings.circuit.ExportCircuit' + maxHeapSize = '2g' + doFirst { + if (!project.hasProperty('recipeFile') || !project.hasProperty('artifactDir')) + throw new GradleException('Set -PrecipeFile and -PartifactDir (relative to bindings/wasm or absolute)') + args rootProject.file(project.property('recipeFile')).absolutePath, + rootProject.file(project.property('artifactDir')).absolutePath + } +} + +// Test-only interop after Node and both browsers have exported fresh proofs. +tasks.register('circuitInterop', JavaExec) { + dependsOn testClasses + classpath = sourceSets.test.runtimeClasspath + mainClass = 'org.zeroj.bindings.circuit.CircuitInterop' + maxHeapSize = '2g' + args rootProject.file('../js/build/circuit').absolutePath + if (project.hasProperty('circuitJfr')) { + jvmArgs '-XX:FlightRecorderOptions=stackdepth=128' + jvmArgs '-XX:StartFlightRecording=settings=profile,filename=' + rootProject.file(project.property('circuitJfr')).absolutePath + } +} + +// Explicit resource experiment; never runs in the normal test/build lane. +tasks.register('largeMerkleBench', JavaExec) { + dependsOn testClasses + classpath = sourceSets.test.runtimeClasspath + mainClass = 'org.zeroj.bindings.circuit.LargeMerkleBench' + def heap = providers.gradleProperty('merkleHeap').getOrElse('6g') + def depth = providers.gradleProperty('merkleDepth').getOrElse('8') + if (!(heap in ['6g','8g']) || !(depth in ['8','16','32'])) throw new GradleException('Research heap is 6g/8g, depth is 8/16/32') + maxHeapSize = heap + args rootProject.file('../js/build/circuit-large').absolutePath, 'merkle_d'+depth, heap + if (project.hasProperty('merkleJfr')) jvmArgs '-XX:StartFlightRecording=settings=profile,filename='+rootProject.file(project.property('merkleJfr')).absolutePath +} +tasks.register('merkleWireCensus', JavaExec) { + dependsOn testClasses + classpath = sourceSets.test.runtimeClasspath + mainClass = 'org.zeroj.bindings.circuit.MerkleWireCensus' + maxHeapSize = '2g' + args rootProject.file('../js/build/circuit-large/wire-census.json').absolutePath +} +tasks.register('largeMerkleInterop', JavaExec) { + dependsOn testClasses + classpath = sourceSets.test.runtimeClasspath + mainClass = 'org.zeroj.bindings.circuit.LargeMerkleInterop' + maxHeapSize = '6g' + args rootProject.file('../js/build/circuit-large').absolutePath +} diff --git a/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/CircuitAdapter.java b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/CircuitAdapter.java new file mode 100644 index 00000000..8721efe6 --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/CircuitAdapter.java @@ -0,0 +1,278 @@ +package org.zeroj.bindings.circuit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.math.BigInteger; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.zeroj.bindings.common.Adapter; +import org.zeroj.bindings.common.BindingException; +import org.zeroj.bindings.common.BindingSession; +import org.zeroj.bindings.common.Boundary; +import org.zeroj.bindings.groth16.Groth16ProverAdapter; +import org.zeroj.circuit.ConstraintGraph; + +/** One compilation attempt per worker. ADR-0064; no source evaluation or witness export. */ +public final class CircuitAdapter implements Adapter { + public static final int MAX_KEY_BYTES = 2 * 1024 * 1024; + public static final int KEY_CHUNK_BYTES = 64 * 1024; + private final CircuitKeyProfile profile; + public CircuitAdapter() { this(CircuitKeyProfile.STANDARD); } + /** Trusted Java/image configuration, not an RPC option. */ + public CircuitAdapter(CircuitKeyProfile profile) { this.profile = Objects.requireNonNull(profile); } + private boolean consumed; + private StringBuilder upload; + private int expectedLength; + private String expectedGraphHash; + private String expectedFingerprint; + + private static final class State implements AutoCloseable { + private CompiledGraph circuit; + private Groth16ProverAdapter prover; + private final BindingSession session; + private final CircuitKeyProfile profile; + private final Set keys = new HashSet<>(); + private KeyUpload upload; + private String uploadId; + private int peakStagingBytes; + private double lastImportMs; + private int lastKeyBytes; + private int lastDomainSize; + private boolean imported; + void available() { + if (imported) throw new BindingException("KEY_LIMIT"); + if (upload != null && upload.bytes != null) throw new BindingException("UPLOAD_ACTIVE"); + } + void requireDimensions() { + if (!profile.admits(circuit.numWires(), circuit.numPublic(), circuit.constraints().size())) + throw new BindingException("CIRCUIT_LIMIT"); + } + void clearUpload() { + if (uploadId != null) session.release(uploadId); + upload = null; + uploadId = null; + } + void imported(JsonNode loaded) { + keys.add(Boundary.text(loaded, "handle")); + imported = true; + lastImportMs = loaded.path("importMs").asDouble(); + lastKeyBytes = loaded.path("keyBytes").asInt(); + lastDomainSize = loaded.path("domainSize").asInt(); + } + ObjectNode resources() { + return Boundary.object().put("profile", profile.id()) + .put("reservedBytes", upload == null || upload.bytes == null ? 0 : upload.bytes.length) + .put("receivedBytes", upload == null || upload.bytes == null ? 0 : upload.received) + .put("peakStagingBytes", peakStagingBytes).put("importedKeys", imported ? 1 : 0) + .put("maxKeyBytes", profile.limits().maxBytes()).put("chunkBytes", KEY_CHUNK_BYTES) + .put("maxWires", profile.limits().maxWires()).put("maxDomainSize", profile.limits().maxDomainSize()) + .put("maxCoefficients", profile.limits().maxCoefficients()) + .put("lastImportMs", lastImportMs).put("lastKeyBytes", lastKeyBytes) + .put("lastDomainSize", lastDomainSize); + } + State(CompiledGraph circuit, BindingSession session, CircuitKeyProfile profile) { + this.profile = profile; + this.circuit = circuit; + this.prover = new Groth16ProverAdapter(Map.of(circuit.id(), circuit)); + this.session = session; + } + @Override public void close() { + clearUpload(); + for (String key : keys) session.release(key); + keys.clear(); + circuit = null; + prover = null; + } + } + + /** One exact-sized public-key buffer; no accumulated hex or second binary copy. */ + private static final class KeyUpload implements AutoCloseable { + private byte[] bytes; + private int received; + private final ObjectNode metadata; + KeyUpload(int length, ObjectNode metadata) { + this.bytes = new byte[length]; + this.metadata = metadata; + } + @Override public void close() { bytes = null; } + } + + @Override public JsonNode invoke(String operation, JsonNode args, BindingSession session) { + return switch (operation) { + case "compileRecipe" -> { + consume(); + Boundary.fields(args, "recipe", "expectedGraphSha256", "fingerprint"); + identity(args); + String recipe = Boundary.text(args, "recipe"); + long start = System.nanoTime(); + var graph = SymbolicRecipe.expand(recipe); + double expandMs = elapsed(start); + start = System.nanoTime(); + String encoded = GraphCodec.encode(graph); + GraphCodec.decode(encoded); + double validationMs = elapsed(start); + yield finish(graph, encoded, session).put("recipeSha256", GraphCodec.hash(recipe)) + .put("expandMs", expandMs).put("graphValidationMs", validationMs); + } + case "beginGraph" -> { + if (consumed || upload != null) throw Boundary.invalid(); + Boundary.fields(args, "length", "expectedGraphSha256", "fingerprint"); + expectedLength = Boundary.integer(args, "length", 1, GraphCodec.MAX_CHARS); + identity(args); + upload = new StringBuilder(Math.min(expectedLength, 200_000)); + yield Boundary.object().put("ready", true); + } + case "appendGraph" -> { + Boundary.fields(args, "offset", "text"); + if (upload == null || consumed) throw Boundary.invalid(); + int offset = Boundary.integer(args, "offset", 0, expectedLength); + String text = Boundary.text(args, "text"); + if (offset != upload.length() || text.isEmpty() || text.length() > 200_000 + || upload.length() + text.length() > expectedLength) throw Boundary.invalid(); + upload.append(text); + yield Boundary.object().put("received", upload.length()); + } + case "compileGraph" -> { + if (consumed || upload == null) throw Boundary.invalid(); + consumed = true; + String encoded = upload.toString(); + upload = null; + Boundary.fields(args); + if (encoded.length() != expectedLength || !GraphCodec.hash(encoded).equals(expectedGraphHash)) + throw new BindingException("CIRCUIT_MISMATCH"); + long start = System.nanoTime(); + var graph = GraphCodec.decode(encoded); + double decodeMs = elapsed(start); + yield finish(graph, encoded, session).put("graphValidationMs", decodeMs); + } + case "checkWitness" -> { + Boundary.fields(args, "circuit", "input"); + session.requireSecrets(); + var state = state(args, session); + long start = System.nanoTime(); + BigInteger[] witness = state.circuit.witness(args.get("input")); + double witnessMs = elapsed(start); + try { + start = System.nanoTime(); + state.circuit.check(witness); + yield Boundary.object().put("valid", true).put("witnessMs", witnessMs) + .put("constraintCheckMs", elapsed(start)); + } finally { Arrays.fill(witness, BigInteger.ZERO); } + } + case "keyResources" -> { + Boundary.fields(args, "circuit"); + yield state(args, session).resources(); + } + case "beginKey" -> { + Boundary.fields(args, "circuit", "fingerprint", "length", "expectedSha256", "verificationKey"); + var state = state(args, session); + state.available(); + state.requireDimensions(); + int length = Boundary.integer(args, "length", 1, state.profile.limits().maxBytes()); + ObjectNode metadata = args.deepCopy(); + metadata.remove("length"); + metadata.put("circuit", state.circuit.id()); + state.prover.validateKeyMetadata(metadata); + var upload = new KeyUpload(length, metadata); + // If handle admission fails, no live state retains the buffer. + String id = session.retain(upload); + state.upload = upload; + state.uploadId = id; + state.peakStagingBytes = Math.max(state.peakStagingBytes, length); + yield Boundary.object().put("upload", id).put("chunkBytes", KEY_CHUNK_BYTES); + } + case "appendKey", "finishKey", "abortKey" -> { + var state = state(args, session); + String id = Boundary.text(args, "upload"); + var upload = session.get(id, KeyUpload.class); + if (!id.equals(state.uploadId) || upload != state.upload || upload.bytes == null) + throw new BindingException("INVALID_HANDLE"); + boolean keep = false; + try { + if (operation.equals("appendKey")) { + Boundary.fields(args, "circuit", "upload", "offset", "chunk"); + int offset = Boundary.integer(args, "offset", 0, upload.bytes.length); + byte[] chunk = Boundary.bytes(args, "chunk", 1, KEY_CHUNK_BYTES); + if (offset != upload.received || chunk.length > upload.bytes.length - offset) + throw Boundary.invalid(); + System.arraycopy(chunk, 0, upload.bytes, offset, chunk.length); + upload.received += chunk.length; + keep = true; + yield Boundary.object().put("received", upload.received); + } + Boundary.fields(args, "circuit", "upload"); + if (operation.equals("abortKey")) yield Boundary.object().put("aborted", true); + if (upload.received != upload.bytes.length) throw new BindingException("INCOMPLETE_KEY"); + JsonNode loaded = state.prover.loadKeyBytes(upload.metadata, upload.bytes, session, state.profile.limits()); + state.imported(loaded); + yield loaded; + } finally { if (!keep) state.clearUpload(); } + } + case "loadKey" -> { + // Compatibility for small direct requests; the standalone cap is unchanged. + Boundary.fields(args, "circuit", "fingerprint", "zkey", "expectedSha256", "verificationKey"); + var state = state(args, session); + state.available(); + state.requireDimensions(); + ObjectNode forwarded = args.deepCopy(); + forwarded.put("circuit", state.circuit.id()); + JsonNode loaded = state.prover.invoke("loadKey", forwarded, session); + state.imported(loaded); + yield loaded; + } + case "prove" -> { + Boundary.fields(args, "circuit", "key", "input"); + var state = state(args, session); + if (!state.keys.contains(Boundary.text(args, "key"))) throw new BindingException("INVALID_HANDLE"); + ObjectNode forwarded = args.deepCopy(); + forwarded.remove("circuit"); + yield state.prover.invoke("prove", forwarded, session); + } + default -> throw new BindingException("UNSUPPORTED_OPERATION"); + }; + } + + private void consume() { + if (consumed || upload != null) throw Boundary.invalid(); + consumed = true; + } + + private void identity(JsonNode args) { + expectedGraphHash = Boundary.text(args, "expectedGraphSha256"); + expectedFingerprint = Boundary.text(args, "fingerprint"); + if (!expectedGraphHash.matches("[0-9a-f]{64}") || expectedFingerprint.length() > 160) + throw Boundary.invalid(); + } + + private ObjectNode finish(ConstraintGraph graph, String encoded, BindingSession session) { + if (!GraphCodec.hash(encoded).equals(expectedGraphHash)) throw new BindingException("CIRCUIT_MISMATCH"); + var compiled = new CompiledGraph(graph); + if (!compiled.fingerprint().equals(expectedFingerprint)) throw new BindingException("CIRCUIT_MISMATCH"); + String handle = session.retain(new State(compiled, session, profile)); + var result = Boundary.object().put("handle", handle).put("compiler", GraphCodec.COMPILER) + .put("graphSha256", expectedGraphHash).put("fingerprint", compiled.fingerprint()) + .put("name", compiled.id()).put("wires", compiled.numWires()) + .put("constraints", compiled.constraints().size()).put("publicInputs", compiled.numPublic()) + .put("compileMs", compiled.compileMs).put("maxKeyBytes", profile.limits().maxBytes()) + .put("keyChunkBytes", KEY_CHUNK_BYTES).put("keyProfile", profile.id()) + .put("maxWires", profile.limits().maxWires()).put("maxDomainSize", profile.limits().maxDomainSize()) + .put("maxCoefficients", profile.limits().maxCoefficients()) + .put("withinProverDimensions", profile.admits(compiled.numWires(), compiled.numPublic(), compiled.constraints().size())); + var pub = result.putArray("publicNames"); + graph.publicInputs().forEach(v -> pub.add(v.name())); + var sec = result.putArray("secretNames"); + graph.secretInputs().forEach(v -> sec.add(v.name())); + return result; + } + + private static State state(JsonNode args, BindingSession session) { + var state = session.get(Boundary.text(args, "circuit"), State.class); + if (state.circuit == null) throw new BindingException("INVALID_HANDLE"); + return state; + } + + private static double elapsed(long start) { return (System.nanoTime() - start) / 1e6; } +} diff --git a/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/CircuitKeyProfile.java b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/CircuitKeyProfile.java new file mode 100644 index 00000000..dfa3a772 --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/CircuitKeyProfile.java @@ -0,0 +1,23 @@ +package org.zeroj.bindings.circuit; + +import org.zeroj.crypto.groth16.ZkeyImporterBLS381; + +/** Immutable, Java-selected resource policy. Never selectable through untrusted RPC arguments. */ +public enum CircuitKeyProfile { + STANDARD("circuit-key-v1", 2 * 1024 * 1024, 4096, 4096, 16384), + /** Opt-in desktop experiment, not a supported npm bundle or a hard heap bound. ADR-0064. */ + RESEARCH_MERKLE("circuit-key-research-merkle-v1", 32 * 1024 * 1024, 65536, 8192, 262144); + + private final String id; + private final ZkeyImporterBLS381.Limits limits; + CircuitKeyProfile(String id, int bytes, int wires, int domain, int coefficients) { + this.id = id; + this.limits = new ZkeyImporterBLS381.Limits(bytes, wires, domain, coefficients); + } + public String id() { return id; } + public ZkeyImporterBLS381.Limits limits() { return limits; } + boolean admits(int wires, int publicInputs, int constraints) { + return wires <= limits.maxWires() && publicInputs <= 64 + && (long) constraints + publicInputs + 1 <= limits.maxDomainSize(); + } +} diff --git a/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/CompiledGraph.java b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/CompiledGraph.java new file mode 100644 index 00000000..cf49ed53 --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/CompiledGraph.java @@ -0,0 +1,53 @@ +package org.zeroj.bindings.circuit; + +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.zeroj.api.R1CSConstraint; +import org.zeroj.bindings.common.Boundary; +import org.zeroj.bindings.groth16.CompiledCircuit; +import org.zeroj.circuit.ConstraintGraph; +import org.zeroj.circuit.FieldConfig; +import org.zeroj.circuit.WitnessCalculator; +import org.zeroj.circuit.r1cs.R1CSCompiler; +import org.zeroj.circuit.r1cs.R1CSConstraintSystem; +import org.zeroj.crypto.groth16.Groth16Pipeline; + +public final class CompiledGraph implements CompiledCircuit { + final ConstraintGraph graph; + final R1CSConstraintSystem system; + final String fingerprint; + final double compileMs; + CompiledGraph(ConstraintGraph graph) { + this.graph=graph; + long start=System.nanoTime(); + system=R1CSCompiler.compile(graph,FieldConfig.BLS12_381); + compileMs=(System.nanoTime()-start)/1e6; + fingerprint=new Groth16Pipeline.Compiled(system.flat(),system.numConstraints(),system.numWires(),system.numPublicInputs()).fingerprint(); + } + public String id() { return graph.name(); } + public int numWires() { return graph.numWires(); } + public int numPublic() { return graph.publicInputs().size(); } + public List constraints() { return system.constraints(); } + @Override public String fingerprint() { return fingerprint; } + public BigInteger[] witness(JsonNode input) { + var vars=graph.namedVariables(); + Boundary.fields(input,vars.keySet().toArray(String[]::new)); + Map> values=new LinkedHashMap<>(); + for (String name:vars.keySet()) values.put(name,List.of(GraphCodec.scalar(Boundary.text(input,name)))); + return WitnessCalculator.calculate(graph,values,FieldConfig.BLS12_381); + } + public void check(BigInteger[] witness) { + if (witness.length!=numWires() || !BigInteger.ONE.equals(witness[0])) throw new IllegalArgumentException("INVALID_WITNESS"); + for (var v:witness) if(v==null || v.signum()<0 || v.compareTo(GraphCodec.FR)>=0) throw new IllegalArgumentException("INVALID_WITNESS"); + for (var c:constraints()) if (!dot(c.a(),witness).multiply(dot(c.b(),witness)).mod(GraphCodec.FR).equals(dot(c.c(),witness))) + throw new IllegalArgumentException("INVALID_WITNESS"); + } + private static BigInteger dot(Map terms, BigInteger[] w) { + var sum=BigInteger.ZERO; + for(var entry:terms.entrySet()) sum=sum.add(w[entry.getKey()].multiply(entry.getValue())); + return sum.mod(GraphCodec.FR); + } +} diff --git a/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/ExportCircuit.java b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/ExportCircuit.java new file mode 100644 index 00000000..919307aa --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/ExportCircuit.java @@ -0,0 +1,35 @@ +package org.zeroj.bindings.circuit; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.zeroj.bindings.common.Boundary; +import org.zeroj.crypto.groth16.R1csExporter; + +/** Build-time public artifact export. No witness or setup; excluded from consumer images. */ +public final class ExportCircuit { + private ExportCircuit() {} + public static void main(String[] args) throws Exception { + if (args.length != 2) throw new IllegalArgumentException("Usage: ExportCircuit recipe.json output-directory"); + Path source = Path.of(args[0]), output = Path.of(args[1]); + if (Files.size(source) > Boundary.MAX_BYTES) throw Boundary.invalid(); + String recipe = Files.readString(source); + String encoded = GraphCodec.encode(SymbolicRecipe.expand(recipe)); + var compiled = new CompiledGraph(GraphCodec.decode(encoded)); + var manifest = Boundary.object().put("compiler", GraphCodec.COMPILER).put("field", "bls12-381-fr") + .put("name", compiled.id()).put("recipeSha256", GraphCodec.hash(recipe)) + .put("graphSha256", GraphCodec.hash(encoded)).put("fingerprint", compiled.fingerprint()) + .put("wires", compiled.numWires()).put("constraints", compiled.constraints().size()) + .put("publicInputs", compiled.numPublic()); + manifest.set("inputs", Boundary.parse(recipe).get("inputs")); + var pub = manifest.putArray("publicNames"); + compiled.graph.publicInputs().forEach(v -> pub.add(v.name())); + var sec = manifest.putArray("secretNames"); + compiled.graph.secretInputs().forEach(v -> sec.add(v.name())); + Files.createDirectories(output); + Files.writeString(output.resolve("recipe.json"), recipe); + Files.writeString(output.resolve("graph.json"), encoded); + R1csExporter.export(compiled.constraints(), compiled.numWires(), compiled.numPublic(), output.resolve("circuit.r1cs")); + Files.writeString(output.resolve("manifest.json"), manifest.toPrettyString() + "\n"); + System.out.println("Exported public circuit artifacts; ceremony keys must be supplied separately."); + } +} diff --git a/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/GraphCodec.java b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/GraphCodec.java new file mode 100644 index 00000000..f8e6165e --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/GraphCodec.java @@ -0,0 +1,174 @@ +package org.zeroj.bindings.circuit; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import org.zeroj.circuit.ConstraintGraph; +import org.zeroj.circuit.FieldConfig; +import org.zeroj.circuit.Gate; +import org.zeroj.circuit.Variable; + +/** Experimental, canonical BLS12-381 graph format governed by ADR-0064. */ +public final class GraphCodec { + public static final String COMPILER = "zeroj-r1cs-bls12381-v1"; + public static final int MAX_CHARS = 16 * 1024 * 1024; + public static final int MAX_WIRES = 200_000; + public static final int MAX_GATES = 200_000; + public static final int MAX_TERMS = 1_000_000; + public static final BigInteger FR = FieldConfig.BLS12_381.prime(); + public static final ObjectMapper JSON = new ObjectMapper(); + static { + JSON.getFactory().setStreamReadConstraints(StreamReadConstraints.builder() + .maxNestingDepth(12).maxStringLength(128).maxNumberLength(8).build()); + JSON.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); + JSON.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + } + private GraphCodec() {} + public static IllegalArgumentException invalid() { return new IllegalArgumentException("INVALID_GRAPH"); } + public static String hash(String text) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(text.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + public static BigInteger scalar(String text) { + if (text == null || text.length() > 77 || !text.matches("0|[1-9][0-9]*")) throw invalid(); + BigInteger v = new BigInteger(text); + if (v.compareTo(FR) >= 0) throw invalid(); + return v; + } + private static String name(JsonNode value) { + if (value == null || !value.isTextual() || !value.textValue().matches("[A-Za-z_][A-Za-z0-9_-]{0,95}")) throw invalid(); + return value.textValue(); + } + private static int number(JsonNode value, int min, int max) { + if (value == null || !value.isIntegralNumber() || !value.canConvertToInt()) throw invalid(); + int n = value.intValue(); if (n < min || n > max) throw invalid(); return n; + } + private static void arity(JsonNode node, int length) { + if (!node.isArray() || node.size() != length) throw invalid(); + } + private static void exact(JsonNode node, String... fields) { + if (!node.isObject() || node.size() != fields.length) throw invalid(); + var allowed = Set.of(fields); + node.fieldNames().forEachRemaining(k -> { if (!allowed.contains(k)) throw invalid(); }); + } + private static BigInteger coefficient(JsonNode value) { + if (value == null || !value.isTextual()) throw invalid(); return scalar(value.textValue()); + } + public static ConstraintGraph decode(String input) { + if (input == null || input.length() > MAX_CHARS) throw invalid(); + final JsonNode root; + try { root = JSON.readTree(input); } catch (java.io.IOException e) { throw invalid(); } + if (root == null) throw invalid(); + exact(root,"format","version","compiler","field","name","public","secret","wires","gates"); + if (!root.path("format").asText().equals("zeroj-graph") + || number(root.get("version"),1,1) != 1 + || !root.path("compiler").asText().equals(COMPILER) + || !root.path("field").asText().equals("bls12-381-fr")) throw invalid(); + String graphName = name(root.get("name")); + int count = number(root.get("wires"),1,MAX_WIRES); + JsonNode pub = root.get("public"), sec = root.get("secret"), nodes = root.get("gates"); + if (!pub.isArray() || !sec.isArray() || pub.size()+sec.size()>4096 + || 1+pub.size()+sec.size()>count || !nodes.isArray() || nodes.size()>MAX_GATES) throw invalid(); + Variable[] wires = new Variable[count]; + boolean[] defined = new boolean[count]; + wires[0] = new Variable(0,"ONE"); defined[0] = true; + List publicInputs = new ArrayList<>(), secretInputs = new ArrayList<>(), intermediates = new ArrayList<>(); + var names = new HashSet(); + int next=1; + for (JsonNode list : List.of(pub,sec)) for (JsonNode item : list) { + String n=name(item); if (!names.add(n)) throw invalid(); + wires[next]=new Variable(next,n); defined[next]=true; + (list==pub?publicInputs:secretInputs).add(wires[next++]); + } + int firstIntermediate=next; + for (;next(nodes.size()); + int totalTerms=0; + if (nodes.isEmpty() || !nodes.get(0).toString().equals("[\"c\",0,\"1\"]")) throw invalid(); + for (JsonNode node : nodes) { + if (!node.isArray() || node.size()<2 || !node.get(0).isTextual()) throw invalid(); + String op=node.get(0).textValue(); Gate gate; + switch (op) { + case "c" -> { arity(node,3); + var out=gates.isEmpty()?wires[0]:output(node.get(1),wires,defined,firstIntermediate); + gate=new Gate.Const(out,coefficient(node.get(2))); defined[out.id()]=true; } + case "a", "m" -> { arity(node,4); var out=output(node.get(1),wires,defined,firstIntermediate); + var left=read(node.get(2),wires,defined); var right=read(node.get(3),wires,defined); + gate=op.equals("a")?new Gate.Add(out,left,right):new Gate.Mul(out,left,right); defined[out.id()]=true; } + case "e" -> { arity(node,3); gate=new Gate.AssertEq(read(node.get(1),wires,defined),read(node.get(2),wires,defined)); } + case "b" -> { arity(node,2); gate=new Gate.AssertBoolean(read(node.get(1),wires,defined)); } + case "l" -> { arity(node,3); var out=output(node.get(1),wires,defined,firstIntermediate); + var terms=node.get(2); if (!terms.isArray() || terms.size()>4096 || (totalTerms+=terms.size())>MAX_TERMS) throw invalid(); + var list=new ArrayList(terms.size()); + for (var term:terms) { arity(term,2); list.add(new Gate.Term(coefficient(term.get(1)),read(term.get(0),wires,defined))); } + gate=new Gate.LinComb(out,List.copyOf(list)); defined[out.id()]=true; } + case "h" -> { arity(node,4); var out=output(node.get(1),wires,defined,firstIntermediate); + if (!node.get(2).isTextual()) throw invalid(); + Gate.HintType type; + type=switch(node.get(2).textValue()) { + case "INVERSE" -> Gate.HintType.INVERSE; + case "IS_ZERO_RESULT" -> Gate.HintType.IS_ZERO_RESULT; + case "IS_ZERO_INVERSE" -> Gate.HintType.IS_ZERO_INVERSE; + default -> throw invalid(); + }; + gate=new Gate.Hint(out,type,read(node.get(3),wires,defined)); defined[out.id()]=true; } + case "d" -> { arity(node,4); var inputWire=read(node.get(2),wires,defined); + int bits=number(node.get(3),1,253); var outs=node.get(1); arity(outs,bits); + var outputs=new Variable[bits]; + for (int i=0;i throw invalid(); + } + gates.add(gate); + } + for (boolean assigned:defined) if (!assigned) throw invalid(); + var graph=new ConstraintGraph(graphName,gates,wires[0],publicInputs,secretInputs,intermediates,count,FieldConfig.BLS12_381); + if (!encode(graph).equals(input)) throw invalid(); // one canonical spelling, including field order + return graph; + } + private static Variable read(JsonNode node, Variable[] wires, boolean[] defined) { + int id=number(node,0,wires.length-1); if (!defined[id]) throw invalid(); return wires[id]; + } + private static Variable output(JsonNode node, Variable[] wires, boolean[] defined, int first) { + int id=number(node,first,wires.length-1); if (defined[id]) throw invalid(); return wires[id]; + } + public static String encode(ConstraintGraph graph) { + graph.requireCompatibleField(FieldConfig.BLS12_381); + if (graph.numWires()>MAX_WIRES || graph.gates().size()>MAX_GATES) throw invalid(); + var root=JSON.createObjectNode(); + root.put("format","zeroj-graph").put("version",1).put("compiler",COMPILER).put("field","bls12-381-fr").put("name",graph.name()); + var pub=root.putArray("public"); graph.publicInputs().forEach(v->pub.add(v.name())); + var sec=root.putArray("secret"); graph.secretInputs().forEach(v->sec.add(v.name())); + root.put("wires",graph.numWires()); var nodes=root.putArray("gates"); + for (var gate:graph.gates()) { + ArrayNode n=nodes.addArray(); + switch(gate) { + case Gate.Const(var out,var value) -> n.add("c").add(out.id()).add(value.mod(FR).toString()); + case Gate.Add(var out,var left,var right) -> n.add("a").add(out.id()).add(left.id()).add(right.id()); + case Gate.Mul(var out,var left,var right) -> n.add("m").add(out.id()).add(left.id()).add(right.id()); + case Gate.AssertEq(var left,var right) -> n.add("e").add(left.id()).add(right.id()); + case Gate.AssertBoolean(var v) -> n.add("b").add(v.id()); + case Gate.LinComb(var out,var terms) -> { n.add("l").add(out.id()); var list=n.addArray(); + for (var t:terms) list.addArray().add(t.variable().id()).add(t.coefficient().mod(FR).toString()); } + case Gate.Hint(var out,var kind,var in) -> n.add("h").add(out.id()).add(kind.name()).add(in.id()); + case Gate.BitDecompose(var outs,var in,int bits) -> { n.add("d"); var ids=n.addArray(); + for(var v:outs) ids.add(v.id()); n.add(in.id()).add(bits); } + default -> throw invalid(); + } + } + String encoded=root.toString(); if(encoded.length()>MAX_CHARS) throw invalid(); return encoded; + } +} diff --git a/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/SymbolicRecipe.java b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/SymbolicRecipe.java new file mode 100644 index 00000000..5f391d10 --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/main/java/org/zeroj/bindings/circuit/SymbolicRecipe.java @@ -0,0 +1,203 @@ +package org.zeroj.bindings.circuit; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import org.zeroj.bindings.common.Boundary; +import org.zeroj.circuit.CircuitBuilder; +import org.zeroj.circuit.ConstraintGraph; +import org.zeroj.circuit.SignalBuilder; +import org.zeroj.circuit.annotation.ZkArray; +import org.zeroj.circuit.annotation.ZkValue; +import org.zeroj.circuit.annotation.ZkBool; +import org.zeroj.circuit.annotation.ZkBytes; +import org.zeroj.circuit.annotation.ZkContext; +import org.zeroj.circuit.annotation.ZkField; +import org.zeroj.circuit.annotation.ZkUInt; +import org.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; +import org.zeroj.circuit.lib.zk.ZkMerkle; +import org.zeroj.circuit.lib.zk.ZkPoseidon; +import org.zeroj.circuit.lib.zk.ZkSha256; + +/** Typed authoring commands delegate to Java symbolic APIs. This class contains no gadget equations. */ +public final class SymbolicRecipe { + private record Input(String name, boolean visible, String type, int size, String element, int bits) {} + private SymbolicRecipe() {} + public static ConstraintGraph expand(String text) { + JsonNode recipe=Boundary.parse(text); + Boundary.fields(recipe,"format","version","compiler","field","name","inputs","commands"); + int version=Boundary.integer(recipe,"version",1,2); + if (!Boundary.text(recipe,"format").equals("zeroj-symbolic") + || !Boundary.text(recipe,"compiler").equals(GraphCodec.COMPILER) + || !Boundary.text(recipe,"field").equals("bls12-381-fr") + || !recipe.toString().equals(text)) throw Boundary.invalid(); + String name=identifier(recipe.get("name")); + JsonNode inputs=recipe.get("inputs"),commands=recipe.get("commands"); + if(!inputs.isArray() || inputs.size()>64 || !commands.isArray() || commands.size()>128) throw Boundary.invalid(); + var canonical=Boundary.object().put("format","zeroj-symbolic").put("version",version) + .put("compiler",GraphCodec.COMPILER).put("field","bls12-381-fr").put("name",name); + var canonicalInputs=canonical.putArray("inputs"); + var declarations=new ArrayList();var names=new HashSet();var logicalNames=new HashSet(); + int expanded=0,budget=0;boolean secretSeen=false; + for(JsonNode n:inputs) { + String type=Boundary.text(n,"type"); + int size,bits=0;String element=null; + switch(type) { + case "field","bool" -> {Boundary.fields(n,"name","visibility","type");size=1;} + case "uint" -> {Boundary.fields(n,"name","visibility","type","bits");size=Boundary.integer(n,"bits",1,253);} + case "array" -> { + if(version!=2)throw Boundary.invalid(); + Boundary.fields(n,"name","visibility","type","element","length"); + size=Boundary.integer(n,"length",1,64); + JsonNode e=n.get("element");element=Boundary.text(e,"type"); + if(element.equals("uint")){Boundary.fields(e,"type","bits");bits=Boundary.integer(e,"bits",1,253);} + else if(element.equals("field")||element.equals("bool"))Boundary.fields(e,"type"); + else throw Boundary.invalid(); + } + case "bytes" -> {Boundary.fields(n,"name","visibility","type","length");size=Boundary.integer(n,"length",1,64);} + default -> throw Boundary.invalid(); + } + String id=identifier(n.get("name")),visibility=Boundary.text(n,"visibility"); + if(!visibility.equals("public")&&!visibility.equals("secret"))throw Boundary.invalid(); + boolean visible=visibility.equals("public"); + if(visible&&secretSeen)throw Boundary.invalid();if(!visible)secretSeen=true; + if(!logicalNames.add(id))throw Boundary.invalid(); + int width=(type.equals("bytes")||type.equals("array"))?size:1; + expanded+=width;if(expanded>256)throw Boundary.invalid(); + for(int i=0;i4*size+16;case "bytes"->40*size;case "array"->size*(element.equals("uint")?4*bits+16:16);default->16;}; + if(budget>180000)throw Boundary.invalid(); + declarations.add(new Input(id,visible,type,size,element,bits)); + var encoded=canonicalInputs.addObject().put("name",id).put("visibility",visibility).put("type",type); + if(type.equals("uint"))encoded.put("bits",size); + if(type.equals("bytes"))encoded.put("length",size); + if(type.equals("array")){ + var e=encoded.putObject("element").put("type",element); + if(element.equals("uint"))e.put("bits",bits); + encoded.put("length",size); + } + } + int sha=0,poseidon=0,merkle=0; + for(JsonNode command:commands) { + if(!command.isArray() || command.isEmpty() || !command.get(0).isTextual())throw Boundary.invalid(); + String op=command.get(0).textValue();int cost; + if(op.equals("g.merkleRootBls12381T3")){ + if(version!=2||++merkle>1)throw Boundary.invalid();arity(command,5); + int depth=integer(command.get(4),32);if(depth<1)throw Boundary.invalid();cost=5000*depth; + } + else if(op.equals("a.get")){if(version!=2)throw Boundary.invalid();cost=512;} + else if(op.equals("g.sha256")){if(++sha>1)throw Boundary.invalid();cost=150000;} + else if(op.equals("g.poseidonBls12381T3")){if(++poseidon>4)throw Boundary.invalid();cost=5000;} + else if(op.startsWith("u.")||op.startsWith("bytes."))cost=4096; + else if(op.startsWith("f."))cost=512; + else if(op.startsWith("b."))cost=256; + else throw Boundary.invalid(); + budget+=cost;if(budget>180000)throw Boundary.invalid(); + } + canonical.set("commands",commands); + if(!canonical.toString().equals(text))throw Boundary.invalid(); + var builder=CircuitBuilder.create(name); + for(Input in:declarations)for(int i=0;i<((in.type.equals("bytes")||in.type.equals("array"))?in.size:1);i++) { + String id=(in.type.equals("bytes")||in.type.equals("array"))?in.name+"_"+i:in.name; + if(in.visible)builder.publicVar(id);else builder.secretVar(id); + } + return builder.defineSignals(signals->{ + var zk=new ZkContext(signals);var values=new ArrayList(); + for(Input in:declarations)values.add(bind(signals,in)); + for(JsonNode command:commands) { + Object result=execute(command,values,zk); + if(result!=null)values.add(result); + } + }).constraintGraph(); + } + private static String identifier(JsonNode n) { + if(n==null||!n.isTextual()||!n.textValue().matches("[A-Za-z][A-Za-z0-9_]{0,63}") + || List.of("constructor","prototype").contains(n.textValue()))throw Boundary.invalid(); + return n.textValue(); + } + private static Object bind(SignalBuilder b,Input i) { + return switch(i.type) { + case "field" -> i.visible?ZkField.publicInput(b,i.name):ZkField.secret(b,i.name); + case "uint" -> i.visible?ZkUInt.publicInput(b,i.name,i.size):ZkUInt.secret(b,i.name,i.size); + case "bool" -> i.visible?ZkBool.publicInput(b,i.name):ZkBool.secret(b,i.name); + case "array" -> switch(i.element) { + case "field" -> i.visible?ZkArray.publicFields(b,i.name,i.size):ZkArray.secretFields(b,i.name,i.size); + case "bool" -> i.visible?ZkArray.publicBools(b,i.name,i.size):ZkArray.secretBools(b,i.name,i.size); + case "uint" -> i.visible?ZkArray.publicUInts(b,i.name,i.size,i.bits):ZkArray.secretUInts(b,i.name,i.size,i.bits); + default -> throw Boundary.invalid(); + }; + case "bytes" -> i.visible?ZkBytes.publicInput(b,i.name,i.size):ZkBytes.secret(b,i.name,i.size); + default -> throw Boundary.invalid(); + }; + } + private static void arity(JsonNode c,int n){if(c.size()!=n)throw Boundary.invalid();} + private static int integer(JsonNode n,int max) { + if(n==null||!n.isIntegralNumber()||!n.canConvertToInt()||n.intValue()<0||n.intValue()>max)throw Boundary.invalid(); + return n.intValue(); + } + private static T value(JsonNode c,int index,List values,Class type) { + int id=integer(c.get(index),values.size()-1);Object v=values.get(id); + if(!type.isInstance(v))throw Boundary.invalid();return type.cast(v); + } + private static ZkField f(JsonNode c,int i,List v){return value(c,i,v,ZkField.class);} + private static ZkUInt u(JsonNode c,int i,List v){return value(c,i,v,ZkUInt.class);} + private static ZkBool b(JsonNode c,int i,List v){return value(c,i,v,ZkBool.class);} + private static ZkBytes bytes(JsonNode c,int i,List v){return value(c,i,v,ZkBytes.class);} + private static ZkArray array(JsonNode c,int index,List values,Class element) { + var array=value(c,index,values,ZkArray.class); + var checked=new ArrayList(array.size()); + for(Object item:array.values()){ + if(!element.isInstance(item))throw Boundary.invalid();checked.add(element.cast(item)); + } + return new ZkArray<>(checked); + } + private static Object execute(JsonNode c,List v,ZkContext zk) { + String op=c.get(0).textValue(); + return switch(op) { + case "f.constant" -> {arity(c,2);if(!c.get(1).isTextual())throw Boundary.invalid();yield zk.constant(GraphCodec.scalar(c.get(1).textValue()));} + case "f.add" -> {arity(c,3);yield f(c,1,v).add(f(c,2,v));} + case "f.sub" -> {arity(c,3);yield f(c,1,v).sub(f(c,2,v));} + case "f.mul" -> {arity(c,3);yield f(c,1,v).mul(f(c,2,v));} + case "f.div" -> {arity(c,3);yield f(c,1,v).div(f(c,2,v));} + case "f.isEqual" -> {arity(c,3);yield f(c,1,v).isEqual(f(c,2,v));} + case "f.assertEqual" -> {arity(c,3);f(c,1,v).assertEqual(f(c,2,v));yield null;} + case "u.add" -> {arity(c,3);yield u(c,1,v).add(u(c,2,v));} + case "u.sub" -> {arity(c,3);yield u(c,1,v).sub(u(c,2,v));} + case "u.mul" -> {arity(c,3);yield u(c,1,v).mul(u(c,2,v));} + case "u.lt" -> {arity(c,3);yield u(c,1,v).lt(u(c,2,v));} + case "u.lte" -> {arity(c,3);yield u(c,1,v).lte(u(c,2,v));} + case "u.gt" -> {arity(c,3);yield u(c,1,v).gt(u(c,2,v));} + case "u.gte" -> {arity(c,3);yield u(c,1,v).gte(u(c,2,v));} + case "u.isEqual" -> {arity(c,3);yield u(c,1,v).isEqual(u(c,2,v));} + case "u.inRange" -> {arity(c,4);yield u(c,1,v).inRange(u(c,2,v),u(c,3,v));} + case "u.asField" -> {arity(c,2);yield u(c,1,v).asField();} + case "u.assertEqual" -> {arity(c,3);u(c,1,v).assertEqual(u(c,2,v));yield null;} + case "b.and" -> {arity(c,3);yield b(c,1,v).and(b(c,2,v));} + case "b.or" -> {arity(c,3);yield b(c,1,v).or(b(c,2,v));} + case "b.xor" -> {arity(c,3);yield b(c,1,v).xor(b(c,2,v));} + case "b.not" -> {arity(c,2);yield b(c,1,v).not();} + case "b.isEqual" -> {arity(c,3);yield b(c,1,v).isEqual(b(c,2,v));} + case "b.asField" -> {arity(c,2);yield b(c,1,v).asField();} + case "b.assertTrue" -> {arity(c,2);b(c,1,v).assertTrue();yield null;} + case "b.assertFalse" -> {arity(c,2);b(c,1,v).assertFalse();yield null;} + case "b.assertEqual" -> {arity(c,3);b(c,1,v).assertEqual(b(c,2,v));yield null;} + case "b.selectField" -> {arity(c,4);yield b(c,1,v).select(f(c,2,v),f(c,3,v));} + case "b.selectUInt" -> {arity(c,4);yield b(c,1,v).select(u(c,2,v),u(c,3,v));} + case "b.selectBool" -> {arity(c,4);yield b(c,1,v).select(b(c,2,v),b(c,3,v));} + case "bytes.get" -> {arity(c,3);var x=bytes(c,1,v);yield x.get(integer(c.get(2),x.size()-1));} + case "bytes.isEqual" -> {arity(c,3);yield bytes(c,1,v).isEqual(bytes(c,2,v));} + case "bytes.assertEqual" -> {arity(c,3);bytes(c,1,v).assertEqual(bytes(c,2,v));yield null;} + case "g.poseidonBls12381T3" -> {arity(c,3);yield ZkPoseidon.hash(zk,PoseidonParamsBLS12_381T3.INSTANCE,f(c,1,v),f(c,2,v));} + case "a.get" -> {arity(c,3);var array=value(c,1,v,ZkArray.class);yield array.get(integer(c.get(2),array.size()-1));} + case "g.merkleRootBls12381T3" -> { + arity(c,5);int depth=integer(c.get(4),32); + var siblings=array(c,2,v,ZkField.class);var directions=array(c,3,v,ZkBool.class); + if(depth<1||siblings.size()!=depth||directions.size()!=depth)throw Boundary.invalid(); + yield ZkMerkle.computeRootPoseidon(zk,PoseidonParamsBLS12_381T3.INSTANCE,f(c,1,v),siblings,directions); + } + case "g.sha256" -> {arity(c,2);var message=bytes(c,1,v);if(message.size()>55)throw Boundary.invalid();yield ZkSha256.hash(zk,message);} + default -> throw Boundary.invalid(); + }; + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/AgeCheck.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/AgeCheck.java new file mode 100644 index 00000000..0509face --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/AgeCheck.java @@ -0,0 +1,8 @@ +package org.zeroj.bindings.circuit; +import org.zeroj.circuit.annotation.*; +@ZKCircuit(name="age_check") +public final class AgeCheck { + @Prove ZkBool prove(@Public @UInt(bits=8) ZkUInt threshold, @Secret @UInt(bits=8) ZkUInt age) { + return age.gte(threshold); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Allowlist.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Allowlist.java new file mode 100644 index 00000000..4d04e7ad --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Allowlist.java @@ -0,0 +1,12 @@ +package org.zeroj.bindings.circuit; +import org.zeroj.circuit.annotation.*; +import org.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; +import org.zeroj.circuit.lib.zk.ZkMerkle; +@ZKCircuit(name="allowlist_d1") +public final class Allowlist { + @Prove void prove(ZkContext zk,@Public ZkField root, + @Secret ZkField leaf,@Secret(name="siblings") @FixedSize(1) ZkArray siblings, + @Secret(name="pathBits") @FixedSize(1) ZkArray pathBits) { + ZkMerkle.computeRootPoseidon(zk,PoseidonParamsBLS12_381T3.INSTANCE,leaf,siblings,pathBits).assertEqual(root); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/CircuitAdapterTest.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/CircuitAdapterTest.java new file mode 100644 index 00000000..fdcc8c7c --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/CircuitAdapterTest.java @@ -0,0 +1,70 @@ +package org.zeroj.bindings.circuit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.zeroj.bindings.common.BindingSession; +import org.zeroj.bindings.common.Boundary; +import static org.junit.jupiter.api.Assertions.*; + +class CircuitAdapterTest { + @TempDir Path temporary; + private BindingSession session(boolean secrets) { return new BindingSession(Map.of("circuit",new CircuitAdapter()),secrets); } + private JsonNode request(BindingSession s,String capability,String op,JsonNode args) { + var request=Boundary.object().put("apiVersion",1).put("capability",capability).put("operation",op); + request.set("arguments",args);return Boundary.parse(s.call(request.toString())); + } + private JsonNode call(BindingSession s,String op,JsonNode args) {return request(s,"circuit",op,args);} + private JsonNode compile(BindingSession s) throws Exception { + var graph=AgeCheckCircuit.build().constraintGraph(); + var args=Boundary.object().put("recipe",Files.readString(Path.of("build/recipes/age_check.json"))) + .put("expectedGraphSha256",GraphCodec.hash(GraphCodec.encode(graph))) + .put("fingerprint",new CompiledGraph(graph).fingerprint()); + return call(s,"compileRecipe",args); + } + @Test void compileIdentityWitnessOptInAndReleaseAreEnforced() throws Exception { + var s=session(true);var result=compile(s);assertTrue(result.path("ok").asBoolean(),result.toString()); + String handle=result.path("value").path("handle").asText(); + var args=Boundary.object().put("circuit",handle);args.set("input",Fixtures.inputs("age_check")); + var checked=call(s,"checkWitness",args);assertTrue(checked.path("value").path("valid").asBoolean()); + assertEquals(3,checked.path("value").size()); // no witness or digest in the consumer API + ((ObjectNode)args.get("input")).put("age","17"); + assertFalse(call(s,"checkWitness",args).path("ok").asBoolean()); + assertFalse(compile(s).path("ok").asBoolean()); + assertTrue(request(s,"runtime","release",Boundary.object().put("handle",handle)).path("ok").asBoolean()); + assertEquals("INVALID_HANDLE",call(s,"checkWitness",args).path("error").path("code").asText()); + var publicSession=session(false);var c=compile(publicSession);args.put("circuit",c.path("value").path("handle").asText()); + assertEquals("EXPERIMENTAL_SECRETS_DISABLED",call(publicSession,"checkWitness",args).path("error").path("code").asText()); + } + @Test void badIdentityAndUploadFailureCannotLeaveAUsableCircuit() { + var s=session(false);String graph=GraphCodec.encode(MultiplierCircuit.build().constraintGraph()); + var begin=Boundary.object().put("length",graph.length()).put("expectedGraphSha256","0".repeat(64)).put("fingerprint","wrong"); + assertTrue(call(s,"beginGraph",begin).path("ok").asBoolean()); + assertFalse(call(s,"appendGraph",Boundary.object().put("offset",1).put("text",graph)).path("ok").asBoolean()); + assertTrue(call(s,"appendGraph",Boundary.object().put("offset",0).put("text",graph)).path("ok").asBoolean()); + assertEquals("CIRCUIT_MISMATCH",call(s,"compileGraph",Boundary.object()).path("error").path("code").asText()); + assertFalse(call(s,"beginGraph",begin).path("ok").asBoolean()); + } + @Test void canonicalRecipeOrderAndCompilerPolicyAreNotOptional() throws Exception { + String recipe=Files.readString(Path.of("build/recipes/age_check.json")); + for(String bad:new String[]{recipe.replace("zeroj-symbolic","zeroj-symbolic-research"), + recipe.replace(GraphCodec.COMPILER,"other-policy"), + recipe.replace("\"format\":\"zeroj-symbolic\",\"version\":1","\"version\":1,\"format\":\"zeroj-symbolic\""), + recipe.replace("\"name\":\"threshold\",\"visibility\":\"public\"","\"visibility\":\"public\",\"name\":\"threshold\"")}) + assertThrows(RuntimeException.class,()->SymbolicRecipe.expand(bad)); + } + @Test void typescriptRecipeExportsPublicCeremonyArtifactsWithoutJavaCircuitAuthoring() throws Exception { + ExportCircuit.main(new String[]{"build/recipes/age_check.json",temporary.toString()}); + var manifest=Boundary.parse(Files.readString(temporary.resolve("manifest.json"))); + String graph=Files.readString(temporary.resolve("graph.json")); + assertEquals(GraphCodec.hash(graph),manifest.path("graphSha256").asText()); + assertEquals(new CompiledGraph(AgeCheckCircuit.build().constraintGraph()).fingerprint(),manifest.path("fingerprint").asText()); + assertEquals("uint",manifest.path("inputs").get(0).path("type").asText()); + assertTrue(Files.size(temporary.resolve("circuit.r1cs"))>0); + try(var files=Files.list(temporary)){assertEquals(4,files.count());} + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/CircuitInterop.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/CircuitInterop.java new file mode 100644 index 00000000..ce0b835f --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/CircuitInterop.java @@ -0,0 +1,58 @@ +package org.zeroj.bindings.circuit; + +import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigInteger; +import java.util.HexFormat; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Map; +import org.zeroj.bindings.common.BindingSession; +import org.zeroj.bindings.common.Boundary; +import org.zeroj.bindings.groth16.Groth16VerifierAdapter; + +/** Test-only: verify browser/Node proofs on the JVM, and export a JVM proof for the JS oracles. */ +public final class CircuitInterop { + private static JsonNode call(BindingSession s, String op, JsonNode args) { + var request=Boundary.object().put("apiVersion",1).put("capability","circuit").put("operation",op); + request.set("arguments",args); + var response=Boundary.parse(s.call(request.toString())); + if(!response.path("ok").asBoolean())throw new AssertionError(response.toString()); + return response.path("value"); + } + public static void main(String[] args) throws Exception { + for(String name:new String[]{"poseidon_opening","allowlist_d1","merkle_d2"}) run(Path.of(args[0]).resolve(name),name); + } + private static void run(Path dir,String name) throws Exception { + var vk=Boundary.parse(Files.readString(dir.resolve("vk.json"))); + for(String engine:new String[]{"node","chromium","firefox"}) { + var result=Boundary.parse(Files.readString(dir.resolve(engine+"-proof.json"))); + var values=Groth16VerifierAdapter.scalars(result.get("publicInputs")); + if(!Groth16VerifierAdapter.verify(result.get("proof"),vk,values))throw new AssertionError(engine); + values.set(0,values.getFirst().add(BigInteger.ONE)); + if(Groth16VerifierAdapter.verify(result.get("proof"),vk,values))throw new AssertionError("tamper "+engine); + System.out.println("JVM verified "+engine+" "+name+" proof and rejected changed public input"); + } + var session=new BindingSession(Map.of("circuit",new CircuitAdapter()),true); + String graph=Files.readString(dir.resolve("graph.json")); + String fingerprint=new CompiledGraph(GraphCodec.decode(graph)).fingerprint(); + call(session,"beginGraph",Boundary.object().put("length",graph.length()).put("expectedGraphSha256",GraphCodec.hash(graph)).put("fingerprint",fingerprint)); + for(int offset=0;offsetresearch.invoke("beginKey",metadata(c,2097153),s)); + assertEquals("INVALID_REQUEST",error.code()); + var r=ok(s,"keyResources",Boundary.object().put("circuit",c.path("handle").asText())); + assertEquals(0,r.path("peakStagingBytes").asInt()); + assertEquals("circuit-key-v1",r.path("profile").asText()); + } + @Test void dimensionBoundaryCountsBindingRowsAndPreservesGlobalPublicInputLimit() { + var p=CircuitKeyProfile.RESEARCH_MERKLE; + assertTrue(p.admits(65536,64,8127));assertFalse(p.admits(65537,64,8127)); + assertFalse(p.admits(65536,65,8126));assertFalse(p.admits(65536,64,8128)); + assertFalse(p.admits(65536,64,Integer.MAX_VALUE)); + assertThrows(NullPointerException.class,()->new CircuitAdapter(null)); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Cubic.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Cubic.java new file mode 100644 index 00000000..b55cf68a --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Cubic.java @@ -0,0 +1,8 @@ +package org.zeroj.bindings.circuit; +import org.zeroj.circuit.annotation.*; +@ZKCircuit(name="cubic") +public final class Cubic { + @Prove void prove(ZkContext zk, @Public ZkField y, @Secret ZkField x) { + x.mul(x).mul(x).add(x).add(zk.constant(5)).assertEqual(y); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Fixtures.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Fixtures.java new file mode 100644 index 00000000..97bcb05c --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Fixtures.java @@ -0,0 +1,88 @@ +package org.zeroj.bindings.circuit; + +import java.math.BigInteger; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import org.zeroj.circuit.CircuitBuilder; +import org.zeroj.circuit.FieldConfig; +import org.zeroj.circuit.WitnessCalculator; +import org.zeroj.crypto.groth16.R1csExporter; +import org.zeroj.circuit.lib.poseidon.PoseidonHash; +import org.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; + +/** JVM-only exporter and independent oracle fixture generator; never referenced from Wasm. */ +public final class Fixtures { + static CircuitBuilder circuit(String name) { + if(name.startsWith("merkle_d"))return MerkleFixtures.circuit(Integer.parseInt(name.substring(8))); + return switch(name) { + case "allowlist_d1" -> AllowlistCircuit.build(); + case "multiplier" -> MultiplierCircuit.build(); + case "cubic" -> CubicCircuit.build(); + case "age_check" -> AgeCheckCircuit.build(); + case "sha256_abc" -> Sha256PreimageCircuit.build(); + case "poseidon_opening" -> PoseidonOpeningCircuit.build(); + case "symbolic_select" -> TypedSelectionCircuit.build(); + default -> throw new IllegalArgumentException(); + }; + } + static ObjectNode inputs(String name) throws Exception { + if(name.startsWith("merkle_d"))return MerkleFixtures.input(Integer.parseInt(name.substring(8)),2); + if(name.equals("allowlist_d1"))return MerkleFixtures.input(1,1); + var n=GraphCodec.JSON.createObjectNode(); + switch(name) { + case "multiplier" -> n.put("product","33").put("x","3").put("y","11"); + case "cubic" -> n.put("y","35").put("x","3"); + case "age_check" -> n.put("threshold","18").put("age","21"); + case "symbolic_select" -> n.put("selected","42").put("chooseLeft","1").put("left","42").put("right","7"); + case "poseidon_opening" -> n.put("commitment",PoseidonHash.hash(PoseidonParamsBLS12_381T3.INSTANCE, + BigInteger.valueOf(42),BigInteger.valueOf(99)).toString()).put("value","42").put("salt","99"); + case "sha256_abc" -> { + byte[] message={97,98,99}; + byte[] digest=MessageDigest.getInstance("SHA-256").digest(message); + for(int i=0;i>(); + input.fields().forEachRemaining(e->sourceValues.put(e.getKey(),List.of(new BigInteger(e.getValue().textValue())))); + var reference=WitnessCalculator.calculate(original,sourceValues,FieldConfig.BLS12_381); + t=System.nanoTime(); var actual=portable.witness(input); double witnessMs=(System.nanoTime()-t)/1e6; + if(!Arrays.equals(reference,actual)) throw new AssertionError("Witness drift"); + portable.check(actual); + Path dir=out.resolve(name); Files.createDirectories(dir); + Files.writeString(dir.resolve("graph.json"),encoded); + Files.writeString(dir.resolve("inputs.json"),input.toString()); + var witness=GraphCodec.JSON.createArrayNode(); for(var v:actual) witness.add(v.toString()); + Files.writeString(dir.resolve("witness.json"),witness.toString()); + R1csExporter.export(portable.constraints(),portable.numWires(),portable.numPublic(),dir.resolve("circuit.r1cs")); + var item=manifest.addObject().put("name",name).put("graphSha256",GraphCodec.hash(encoded)) + .put("fingerprint",portable.fingerprint()).put("witnessSha256",TestWitness.digest(actual)) + .put("graphBytes",encoded.length()).put("wires",portable.numWires()).put("gates",decoded.gates().size()) + .put("constraints",portable.constraints().size()).put("publicInputs",portable.numPublic()) + .put("buildMs",buildMs).put("encodeMs",encodeMs).put("decodeMs",decodeMs) + .put("compileMs",portable.compileMs).put("witnessMs",witnessMs).put("developmentOnly",true); + var histogram=item.putObject("opcodes"); + for(var gate:decoded.gates()) { String k=gate.getClass().getSimpleName(); histogram.put(k,histogram.path(k).asInt()+1); } + Arrays.fill(reference,BigInteger.ZERO); Arrays.fill(actual,BigInteger.ZERO); + System.out.println(item); + } + Files.writeString(out.resolve("manifest.json"),manifest.toPrettyString()); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/GraphCodecTest.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/GraphCodecTest.java new file mode 100644 index 00000000..a4dfcfd6 --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/GraphCodecTest.java @@ -0,0 +1,119 @@ +package org.zeroj.bindings.circuit; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.math.BigInteger; +import java.util.List; +import java.util.Random; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; +import org.zeroj.circuit.CircuitBuilder; +import org.zeroj.circuit.Gate; +import static org.junit.jupiter.api.Assertions.*; + +class GraphCodecTest { + private String source() { return GraphCodec.encode(MultiplierCircuit.build().constraintGraph()); } + private ObjectNode tree() throws Exception { return (ObjectNode)GraphCodec.JSON.readTree(source()); } + private void reject(Consumer mutate) throws Exception { + var node=tree(); mutate.accept(node); assertThrows(IllegalArgumentException.class,()->GraphCodec.decode(node.toString())); + } + @Test void annotationGraphsRoundTripWithSameConstraintsAndWitnesses() throws Exception { + for(String name:List.of("multiplier","cubic","age_check","sha256_abc")) { + var original=Fixtures.circuit(name).constraintGraph(); + String encoded=GraphCodec.encode(original); + var decoded=GraphCodec.decode(encoded); + assertEquals(encoded,GraphCodec.encode(decoded)); + var a=new CompiledGraph(original); var b=new CompiledGraph(decoded); + assertEquals(a.fingerprint(),b.fingerprint()); + var wa=a.witness(Fixtures.inputs(name)); var wb=b.witness(Fixtures.inputs(name)); + assertArrayEquals(wa,wb); b.check(wb); + } + } + @Test void rejectsShapeEncodingFieldAndResourceViolations() throws Exception { + reject(n->n.put("version",2)); reject(n->n.put("version",1.0)); + reject(n->n.put("field","bn254")); reject(n->n.put("extra",true)); + reject(n->n.put("wires",Integer.MAX_VALUE)); reject(n->n.put("wires",0)); + reject(n->n.put("name","a\nb")); reject(n->((ArrayNode)n.get("secret")).set(0,GraphCodec.JSON.getNodeFactory().textNode("product"))); + reject(n->((ArrayNode)n.get("gates")).addArray().add("unsupported")); + assertThrows(IllegalArgumentException.class,()->GraphCodec.decode(source()+" {}")); + assertThrows(IllegalArgumentException.class,()->GraphCodec.decode(" "+source())); + assertThrows(IllegalArgumentException.class,()->GraphCodec.decode(source().replace("\"version\":1","\"version\":1,\"version\":1"))); + assertThrows(IllegalArgumentException.class,()->GraphCodec.decode(" ".repeat(GraphCodec.MAX_CHARS+1))); + } + @Test void rejectsWritesToInputsMissingOrForwardWiresAndNoncanonicalScalars() throws Exception { + reject(n->((ArrayNode)n.path("gates").get(1)).set(1,GraphCodec.JSON.getNodeFactory().numberNode(1))); + reject(n->((ArrayNode)n.path("gates").get(1)).set(2,GraphCodec.JSON.getNodeFactory().numberNode(n.get("wires").intValue()-1))); + reject(n->((ArrayNode)n.get("gates")).remove(1)); + reject(n->((ArrayNode)n.get("gates")).add(n.path("gates").get(1).deepCopy())); + reject(n->((ArrayNode)n.path("gates").get(0)).set(2,GraphCodec.JSON.getNodeFactory().textNode("0"))); + for(String s:List.of("01","-1","+1",GraphCodec.FR.toString(),"1.0","1e2")) + assertThrows(IllegalArgumentException.class,()->GraphCodec.scalar(s)); + } + @Test void rangeConstraintsRejectInvalidAndOutOfRangeWitnesses() throws Exception { + var c=new CompiledGraph(GraphCodec.decode(GraphCodec.encode(AgeCheckCircuit.build().constraintGraph()))); + for(String bad:List.of("17","256","511")) { + var n=Fixtures.inputs("age_check"); n.put("age",bad); + assertThrows(RuntimeException.class,()->{var w=c.witness(n);c.check(w);}); + } + var w=c.witness(Fixtures.inputs("age_check")); w[1]=BigInteger.valueOf(22); + assertThrows(IllegalArgumentException.class,()->c.check(w)); + } + @Test void rejectsMalformedDecompositionAndLinearTerms() throws Exception { + String encoded=GraphCodec.encode(AgeCheckCircuit.build().constraintGraph()); + for(int mode=0;mode<4;mode++) { + var root=(ObjectNode)GraphCodec.JSON.readTree(encoded); + for(var g:root.path("gates")) if(g.get(0).asText().equals("d")) { + var gate=(ArrayNode)g; + if(mode==0)((ArrayNode)gate.get(1)).set(1,gate.get(1).get(0)); + if(mode==1)gate.set(3,GraphCodec.JSON.getNodeFactory().numberNode(254)); + if(mode==2)gate.set(3,GraphCodec.JSON.getNodeFactory().numberNode(0)); + if(mode==3)((ArrayNode)gate.get(1)).remove(0); + break; + } + assertThrows(IllegalArgumentException.class,()->GraphCodec.decode(root.toString())); + } + var root=(ObjectNode)GraphCodec.JSON.readTree(encoded); + for(var g:root.path("gates")) if(g.get(0).asText().equals("l")) { + ((ArrayNode)g.get(2).get(0)).set(1,GraphCodec.JSON.getNodeFactory().textNode("-1")); break; + } + assertThrows(IllegalArgumentException.class,()->GraphCodec.decode(root.toString())); + } + @Test void sha256MatchesJdkAndRejectsWrongMessageOrDigest() throws Exception { + var c=new CompiledGraph(GraphCodec.decode(GraphCodec.encode(Sha256PreimageCircuit.build().constraintGraph()))); + var n=Fixtures.inputs("sha256_abc"); c.check(c.witness(n)); + n.put("message_0","98"); assertThrows(RuntimeException.class,()->c.check(c.witness(n))); + var bad=Fixtures.inputs("sha256_abc"); bad.put("digest_0","0"); + assertThrows(RuntimeException.class,()->c.check(c.witness(bad))); + } + @Test void hintsDoNotReplaceConstraints() { + var original=CircuitBuilder.create("inverse").publicVar("y").secretVar("x").define(api->{ + var inverse=api.div(api.constant(1),api.var("x")); api.assertEqual(inverse,api.var("y")); + }).constraintGraph(); + var graph=GraphCodec.decode(GraphCodec.encode(original)); var c=new CompiledGraph(graph); + var n=GraphCodec.JSON.createObjectNode().put("x","2").put("y",BigInteger.TWO.modInverse(GraphCodec.FR).toString()); + var w=c.witness(n); c.check(w); + for(var gate:graph.gates()) if(gate instanceof Gate.Hint h) { + var changed=w.clone(); changed[h.output().id()]=changed[h.output().id()].add(BigInteger.ONE).mod(GraphCodec.FR); + assertThrows(IllegalArgumentException.class,()->c.check(changed)); + } + } + @Test void randomArithmeticGraphsMatchIndependentIntegerEvaluation() { + var random=new Random(0x5a65726fL); // deterministic, public test generation only + for(int sample=0;sample<100;sample++) { + var n=GraphCodec.JSON.createObjectNode().put("format","zeroj-graph").put("version",1).put("compiler",GraphCodec.COMPILER) + .put("field","bls12-381-fr").put("name","random"); + n.putArray("public").add("result"); n.putArray("secret").add("x"); n.put("wires",23); + var gates=n.putArray("gates"); gates.addArray().add("c").add(0).add("1"); + var values=new BigInteger[23]; values[0]=BigInteger.ONE; values[2]=BigInteger.valueOf(random.nextInt(1000)); + for(int out=3;out<23;out++) { + int left=out==3?2:2+random.nextInt(out-2),right=2+random.nextInt(out-2); + boolean mul=random.nextBoolean(); gates.addArray().add(mul?"m":"a").add(out).add(left).add(right); + values[out]=(mul?values[left].multiply(values[right]):values[left].add(values[right])).mod(GraphCodec.FR); + } + gates.addArray().add("e").add(22).add(1); values[1]=values[22]; + var c=new CompiledGraph(GraphCodec.decode(n.toString())); + var input=GraphCodec.JSON.createObjectNode().put("result",values[1].toString()).put("x",values[2].toString()); + var actual=c.witness(input); assertArrayEquals(values,actual); c.check(actual); + } + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/KeyUploadTest.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/KeyUploadTest.java new file mode 100644 index 00000000..f9ce9ac7 --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/KeyUploadTest.java @@ -0,0 +1,85 @@ +package org.zeroj.bindings.circuit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.zeroj.bindings.common.BindingSession; +import org.zeroj.bindings.common.Boundary; +import static org.junit.jupiter.api.Assertions.*; + +/** Public, synthetic key bytes exercise transport allocation/state; real keys run in Wasm interop. */ +class KeyUploadTest { + private final BindingSession session = new BindingSession(Map.of("circuit", new CircuitAdapter()), false); + private final String circuit; + private final String fingerprint; + KeyUploadTest() { + var graph = MultiplierCircuit.build().constraintGraph(); + fingerprint = new CompiledGraph(graph).fingerprint(); + String text = GraphCodec.encode(graph); + ok("beginGraph", Boundary.object().put("length",text.length()).put("expectedGraphSha256",GraphCodec.hash(text)).put("fingerprint",fingerprint)); + ok("appendGraph", Boundary.object().put("offset",0).put("text",text)); + circuit = ok("compileGraph",Boundary.object()).path("handle").asText(); + } + private JsonNode call(String op, ObjectNode args) { + var request=Boundary.object().put("apiVersion",1).put("capability","circuit").put("operation",op); + request.set("arguments",args); + return Boundary.parse(session.call(request.toString())); + } + private JsonNode ok(String op, ObjectNode args) { + var result=call(op,args); assertTrue(result.path("ok").asBoolean(),result.toString());return result.path("value"); + } + private ObjectNode args() {return Boundary.object().put("circuit",circuit);} + private ObjectNode metadata(int length) { + var vk=Boundary.object().put("protocol","groth16").put("curve","bls12381").put("nPublic",1); + for(String name:new String[]{"vk_alpha_1","vk_beta_2","vk_gamma_2","vk_delta_2","vk_alphabeta_12","IC"})vk.putArray(name).add("0"); + var args=args().put("fingerprint",fingerprint).put("length",length).put("expectedSha256","0".repeat(64)); + args.set("verificationKey",vk);return args; + } + private String begin(int length) {return ok("beginKey",metadata(length)).path("upload").asText();} + private ObjectNode upload(String id) {return args().put("upload",id);} + private void empty() {assertEquals(0,ok("keyResources",args()).path("reservedBytes").asInt());} + @Test void lengthAndMetadataAreCheckedBeforeAllocation() { + for(int n:new int[]{0,-1,CircuitAdapter.MAX_KEY_BYTES+1,Integer.MAX_VALUE})assertFalse(call("beginKey",metadata(n)).path("ok").asBoolean()); + assertFalse(call("beginKey",metadata(10).put("fingerprint","wrong")).path("ok").asBoolean()); + assertFalse(call("beginKey",metadata(10).put("expectedSha256","00")).path("ok").asBoolean()); + empty();assertEquals(0,ok("keyResources",args()).path("peakStagingBytes").asInt()); + } + @Test void exactMaximumReservationIsBoundedAndAbortIsTerminal() { + String id=begin(CircuitAdapter.MAX_KEY_BYTES); + assertEquals(CircuitAdapter.MAX_KEY_BYTES,ok("keyResources",args()).path("reservedBytes").asInt()); + assertFalse(call("beginKey",metadata(1)).path("ok").asBoolean()); + for(int offset=0;offset("baseline");var phasePeaks=new ConcurrentHashMap();var sampler=Executors.newSingleThreadScheduledExecutor(r->{Thread t=new Thread(r,"heap-sampler");t.setDaemon(true);return t;}); + sampler.scheduleAtFixedRate(()->{long used=memory.getHeapMemoryUsage().getUsed();peak.accumulateAndGet(used,Math::max);phasePeaks.computeIfAbsent(phase.get(),k->new AtomicLong()).accumulateAndGet(used,Math::max);},0,10,TimeUnit.MILLISECONDS); + var s=new BindingSession(Map.of("circuit",new CircuitAdapter(CircuitKeyProfile.RESEARCH_MERKLE)),true);String circuit=null; + try{ + phase.set("compile");String graph=Files.readString(dir.resolve("graph.json"));long t=System.nanoTime(); + call(s,"beginGraph",Boundary.object().put("length",graph.length()).put("expectedGraphSha256",f.path("graphSha256").asText()).put("fingerprint",f.path("fingerprint").asText())); + for(int offset=0;offsetbyPhase.put(k,v.get())); + report.put("baselineHeapBytes",baseline).put("sampledPeakHeapBytes",peak.get()).put("heapAtEndBytes",memory.getHeapMemoryUsage().getUsed()); + Files.writeString(dir.resolve("jvm-"+heap+"-result.json"),report.toPrettyString()); + } + System.out.println(report.toPrettyString()); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/LargeMerkleInterop.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/LargeMerkleInterop.java new file mode 100644 index 00000000..e14450cf --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/LargeMerkleInterop.java @@ -0,0 +1,43 @@ +package org.zeroj.bindings.circuit; + +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.io.IOException; +import org.zeroj.bindings.common.Boundary; +import org.zeroj.bindings.groth16.Groth16VerifierAdapter; +import org.zeroj.crypto.groth16.ZkeyImporterBLS381; + +/** Independent JVM verification of every measured engine; cheap parser-policy boundary checks. */ +public final class LargeMerkleInterop { + public static void main(String[] args)throws Exception { + Path root=Path.of(args[0]); + for(String name:new String[]{"merkle_d8","merkle_d16","merkle_d32"}){ + Path dir=root.resolve(name);var vk=Boundary.parse(Files.readString(dir.resolve("vk.json"))); + for(String engine:new String[]{"node","chromium","firefox","jvm-6g","jvm-8g"}){ + var result=Boundary.parse(Files.readString(dir.resolve(engine+"-proof.json"))); + var values=Groth16VerifierAdapter.scalars(result.get("publicInputs")); + if(!Groth16VerifierAdapter.verify(result.get("proof"),vk,values))throw new AssertionError(name+" "+engine); + values.set(0,values.getFirst().add(BigInteger.ONE)); + if(Groth16VerifierAdapter.verify(result.get("proof"),vk,values))throw new AssertionError("changed root"); + System.out.println("JVM verified "+name+" "+engine+" and rejected changed root"); + } + } + var manifest=Boundary.JSON.readTree(Files.readString(root.resolve("manifest.json")));var f=manifest.get(0); + if(!f.path("name").asText().equals("merkle_d8"))throw new AssertionError("fixture order"); + byte[] key=Files.readAllBytes(root.resolve("merkle_d8/key.zkey")),digest=MessageDigest.getInstance("SHA-256").digest(key); + for(var limit:new ZkeyImporterBLS381.Limits[]{ + new ZkeyImporterBLS381.Limits(key.length-1,65536,8192,262144), + new ZkeyImporterBLS381.Limits(key.length,f.path("wires").asInt()-1,8192,262144), + new ZkeyImporterBLS381.Limits(key.length,65536,1024,262144), + new ZkeyImporterBLS381.Limits(key.length,65536,8192,f.path("coefficients").asInt()-1)}){ + boolean rejected=false; + try{ZkeyImporterBLS381.importZkeyFull(key,digest,limit);}catch(IOException e){ + rejected=e.getMessage().contains("limit"); + } + if(!rejected)throw new AssertionError("Importer resource boundary did not reject"); + } + System.out.println("Unchanged importer independently enforced byte, wire, domain and coefficient boundaries"); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/MerkleFixtures.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/MerkleFixtures.java new file mode 100644 index 00000000..8a8ef06c --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/MerkleFixtures.java @@ -0,0 +1,34 @@ +package org.zeroj.bindings.circuit; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.math.BigInteger; +import org.zeroj.circuit.CircuitBuilder; +import org.zeroj.circuit.annotation.*; +import org.zeroj.circuit.lib.poseidon.PoseidonHash; +import org.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; +import org.zeroj.circuit.lib.zk.ZkMerkle; + +/** Test-only Java authoring and host hashing, independent of recipe dispatch. */ +final class MerkleFixtures { + static CircuitBuilder circuit(int depth) { + var builder=CircuitBuilder.create("merkle_d"+depth).publicVar("root").secretVar("leaf"); + for(int i=0;i{ + var root=ZkField.publicInput(c,"root");var leaf=ZkField.secret(c,"leaf"); + var siblings=ZkArray.secretFields(c,"siblings",depth);var bits=ZkArray.secretBools(c,"pathBits",depth); + ZkMerkle.computeRootPoseidon(new ZkContext(c),PoseidonParamsBLS12_381T3.INSTANCE,leaf,siblings,bits).assertEqual(root); + }); + } + static ObjectNode input(int depth,int pattern) { + var n=GraphCodec.JSON.createObjectNode();BigInteger current=BigInteger.valueOf(42); + n.put("leaf",current.toString()); + for(int i=0;ic.check(broken)); + } + } + } + @Test void annotationAllowlistMatchesRecipeAndBindsRoot() throws Exception { + var c=compile(recipe("allowlist_d1"));var java=new CompiledGraph(AllowlistCircuit.build().constraintGraph()); + assertEquals(GraphCodec.hash(GraphCodec.encode(java.graph)),GraphCodec.hash(GraphCodec.encode(c.graph))); + var input=Fixtures.inputs("allowlist_d1");c.check(c.witness(input));assertArrayEquals(java.witness(input),c.witness(input)); + for(String k:List.of("root","leaf","siblings_0","pathBits_0")){ + var changed=input.deepCopy();changed.put(k,k.equals("pathBits_0")?"0":"123"); + assertThrows(RuntimeException.class,()->c.check(c.witness(changed)),k); + } + input.put("pathBits_0","2");assertThrows(RuntimeException.class,()->c.check(c.witness(input))); + } + @Test void arrayElementsAreConstrainedAndIndexedInDeclaredOrder() throws Exception { + var c=compile(recipe("arrays"));var input=GraphCodec.JSON.createObjectNode().put("out_0","5").put("out_1","7") + .put("flags_0","1").put("flags_1","1").put("values_0","5").put("values_1","7"); + c.check(c.witness(input)); + Files.createDirectories(Path.of("build/symbolic-test-vectors")); + Files.writeString(Path.of("build/symbolic-test-vectors/arrays-identity.json"),GraphCodec.JSON.createObjectNode() + .put("expectedGraphSha256",GraphCodec.hash(GraphCodec.encode(c.graph))).put("fingerprint",c.fingerprint()).toString()); + assertEquals(List.of("out_0","out_1"),c.graph.publicInputs().stream().map(v->v.name()).toList()); + for(String[] change:new String[][]{{"flags_0","2"},{"flags_1","0"},{"values_0","256"},{"out_1","5"}}){ + var bad=input.deepCopy();bad.put(change[0],change[1]);assertThrows(RuntimeException.class,()->c.check(c.witness(bad))); + } + } + @Test void v1DoesNotAcceptExtensionsAndShapesCannotLieAboutWork() throws Exception { + String text=recipe("merkle_d1"); + assertThrows(RuntimeException.class,()->compile(text.replace("\"version\":2","\"version\":1"))); + for(int depth:new int[]{0,2,33,Integer.MAX_VALUE}){ + var n=(ObjectNode)GraphCodec.JSON.readTree(text);((ArrayNode)n.get("commands").get(0)).set(4,GraphCodec.JSON.getNodeFactory().numberNode(depth)); + assertThrows(RuntimeException.class,()->compile(n.toString())); + } + for(String replacement:List.of("\"element\":{\"type\":\"bool\"}","\"element\":{\"type\":\"bytes\",\"length\":1}")) + assertThrows(RuntimeException.class,()->compile(text.replace("\"element\":{\"type\":\"field\"}",replacement))); + assertThrows(RuntimeException.class,()->compile(text.replace("\"length\":1","\"length\":0"))); + assertThrows(RuntimeException.class,()->compile(text.replace("\"length\":1","\"length\":65"))); + assertThrows(RuntimeException.class,()->compile(text.replace("\"element\":{\"type\":\"field\"},\"length\":1","\"length\":1,\"element\":{\"type\":\"field\"}"))); + var n=(ObjectNode)GraphCodec.JSON.readTree(text);var commands=(ArrayNode)n.get("commands");commands.insert(1,commands.get(0).deepCopy()); + assertThrows(RuntimeException.class,()->compile(n.toString())); + } + @Test void arrayIndexAndFlattenedCollisionsReject() throws Exception { + String text=recipe("arrays"); + assertThrows(RuntimeException.class,()->compile(text.replace("[\"a.get\",1,0]","[\"a.get\",1,2]"))); + var n=(ObjectNode)GraphCodec.JSON.readTree(text); + ((ObjectNode)n.get("inputs").get(1)).put("name","out_0").put("type","bool").remove(List.of("element","length")); + assertThrows(RuntimeException.class,()->compile(n.toString())); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/MerkleWireCensus.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/MerkleWireCensus.java new file mode 100644 index 00000000..7cdae03a --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/MerkleWireCensus.java @@ -0,0 +1,27 @@ +package org.zeroj.bindings.circuit; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.BitSet; +import java.util.List; +import org.zeroj.bindings.common.Boundary; + +/** Read-only census of existing R1CS wire references. Does not compact or change a relation. */ +public final class MerkleWireCensus { + public static void main(String[] args)throws Exception { + var rows=Boundary.JSON.createArrayNode(); + for(int depth:new int[]{1,2,8,16,32}){ + var c=new CompiledGraph(MerkleFixtures.circuit(depth).constraintGraph());var used=new BitSet(c.numWires()); + used.set(0,c.numPublic()+1); // The constant/public ABI must be retained even if unused. + long entries=0; + for(var constraint:c.constraints())for(var terms:List.of(constraint.a(),constraint.b(),constraint.c())){ + entries+=terms.size();for(var entry:terms.entrySet())if(entry.getValue().signum()!=0)used.set(entry.getKey()); + } + int unused=c.numWires()-used.cardinality(); + rows.addObject().put("depth",depth).put("wires",c.numWires()).put("constraints",c.constraints().size()) + .put("retainedWiresIncludingPublic",used.cardinality()).put("unreferencedWires",unused).put("r1csTermEntries",entries) + .put("potentialQueryBytesRemoved",unused*480L); + } + Path out=Path.of(args[0]);Files.createDirectories(out.getParent());Files.writeString(out,rows.toPrettyString());System.out.println(rows.toPrettyString()); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Multiplier.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Multiplier.java new file mode 100644 index 00000000..99a4bb97 --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Multiplier.java @@ -0,0 +1,8 @@ +package org.zeroj.bindings.circuit; +import org.zeroj.circuit.annotation.*; +@ZKCircuit(name="multiplier") +public final class Multiplier { + @Prove void prove(@Public ZkField product, @Secret ZkField x, @Secret ZkField y) { + x.mul(y).assertEqual(product); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/PoseidonOpening.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/PoseidonOpening.java new file mode 100644 index 00000000..d3b3182d --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/PoseidonOpening.java @@ -0,0 +1,10 @@ +package org.zeroj.bindings.circuit; +import org.zeroj.circuit.annotation.*; +import org.zeroj.circuit.lib.poseidon.PoseidonParamsBLS12_381T3; +import org.zeroj.circuit.lib.zk.ZkPoseidon; +@ZKCircuit(name="poseidon_opening") +public final class PoseidonOpening { + @Prove void prove(ZkContext zk,@Public ZkField commitment,@Secret ZkField value,@Secret ZkField salt) { + ZkPoseidon.hash(zk,PoseidonParamsBLS12_381T3.INSTANCE,value,salt).assertEqual(commitment); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Sha256Preimage.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Sha256Preimage.java new file mode 100644 index 00000000..09a86879 --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/Sha256Preimage.java @@ -0,0 +1,10 @@ +package org.zeroj.bindings.circuit; +import org.zeroj.circuit.annotation.*; +import org.zeroj.circuit.lib.zk.ZkSha256; +@ZKCircuit(name="sha256_abc") +public final class Sha256Preimage { + @Prove void prove(ZkContext zk, @Public @FixedSize(32) ZkBytes digest, + @Secret @FixedSize(3) ZkBytes message) { + ZkSha256.hash(zk,message).assertEqual(digest); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/SymbolicRecipeTest.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/SymbolicRecipeTest.java new file mode 100644 index 00000000..89481f47 --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/SymbolicRecipeTest.java @@ -0,0 +1,71 @@ +package org.zeroj.bindings.circuit; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class SymbolicRecipeTest { + private String recipe(String name) throws Exception {return Files.readString(Path.of("build/recipes/"+name+".json"));} + private CompiledGraph compile(String text) {return new CompiledGraph(GraphCodec.decode(GraphCodec.encode(SymbolicRecipe.expand(text))));} + @Test void typescriptRecipesExactlyMatchJavaAnnotations() throws Exception { + for(String name:List.of("cubic","age_check","symbolic_select","poseidon_opening","sha256_abc")) { + var graph=SymbolicRecipe.expand(recipe(name)); + assertEquals(GraphCodec.encode(Fixtures.circuit(name).constraintGraph()),GraphCodec.encode(graph),name); + var actual=compile(recipe(name));var expected=new CompiledGraph(Fixtures.circuit(name).constraintGraph()); + assertEquals(expected.fingerprint(),actual.fingerprint(),name); + var witness=actual.witness(Fixtures.inputs(name));actual.check(witness); + assertArrayEquals(expected.witness(Fixtures.inputs(name)),witness,name); + } + } + @Test void typedWitnessesRejectWrongCommitmentNonBooleanAndOutOfRange() throws Exception { + for(String[] test:new String[][]{{"poseidon_opening","value","43"},{"symbolic_select","chooseLeft","2"}, + {"symbolic_select","selected","7"},{"age_check","age","17"},{"age_check","age","256"}, + {"sha256_abc","message_0","98"},{"sha256_abc","digest_0","256"}}) { + var c=compile(recipe(test[0]));var n=Fixtures.inputs(test[0]);n.put(test[1],test[2]); + assertThrows(RuntimeException.class,()->c.check(c.witness(n)),String.join("/",test)); + } + } + @Test void rejectsInvalidRecipeReferencesTypesAndLimits() throws Exception { + String valid=recipe("symbolic_select"); + for(String text:List.of(" "+valid,valid+" {}",valid.replace("\"version\":1","\"version\":1,\"version\":1"), + valid.replace("bls12-381-fr","bn254"),valid.replace("b.selectField","g.unknown"), + valid.replace("[\"b.selectField\",1,2,3]","[\"b.selectField\",0,2,3]"), + valid.replace("[\"b.selectField\",1,2,3]","[\"b.selectField\",1,2,99]"), + valid.replace("[\"b.selectField\",1,2,3]","[\"b.selectField\",1,2]"))) + assertThrows(RuntimeException.class,()->SymbolicRecipe.expand(text),text); + var n=(ObjectNode)GraphCodec.JSON.readTree(recipe("poseidon_opening")); + var commands=n.putArray("commands");for(int i=0;i<5;i++)commands.addArray().add("g.poseidonBls12381T3").add(1).add(2); + assertThrows(RuntimeException.class,()->SymbolicRecipe.expand(n.toString())); + commands.removeAll();for(int i=0;i<129;i++)commands.addArray().add("f.constant").add("1"); + assertThrows(RuntimeException.class,()->SymbolicRecipe.expand(n.toString())); + var sha=(ObjectNode)GraphCodec.JSON.readTree(recipe("sha256_abc")); + ((ObjectNode)sha.get("inputs").get(1)).put("length",56); + assertThrows(RuntimeException.class,()->SymbolicRecipe.expand(sha.toString())); + } + @Test void allExposedOperationsHaveConstrainedSemantics() throws Exception { + var c=compile(recipe("operations")); + var input=GraphCodec.JSON.createObjectNode().put("sum","5").put("x","3").put("y","2").put("u","5").put("v","2") + .put("yes","1").put("no","0").put("a_0","42").put("b_0","42"); + c.check(c.witness(input)); + Files.createDirectories(Path.of("build/symbolic-test-vectors")); + Files.writeString(Path.of("build/symbolic-test-vectors/operations-input.json"),input.toString()); + Files.writeString(Path.of("build/symbolic-test-vectors/operations-witness.sha256"),TestWitness.digest(c.witness(input))); + Files.writeString(Path.of("build/symbolic-test-vectors/operations-identity.json"),GraphCodec.JSON.createObjectNode() + .put("expectedGraphSha256",GraphCodec.hash(GraphCodec.encode(c.graph))).put("fingerprint",c.fingerprint()).toString()); + for(String key:List.of("sum","x","y","u","v","yes","no","a_0","b_0")) { + var changed=input.deepCopy();changed.put(key,"43"); + assertThrows(RuntimeException.class,()->c.check(c.witness(changed)),key); + } + input.put("y","0");assertThrows(RuntimeException.class,()->c.check(c.witness(input))); + var subtract=compile(recipe("underflow")); + Files.writeString(Path.of("build/symbolic-test-vectors/underflow-identity.json"),GraphCodec.JSON.createObjectNode() + .put("expectedGraphSha256",GraphCodec.hash(GraphCodec.encode(subtract.graph))).put("fingerprint",subtract.fingerprint()).toString()); + var bad=GraphCodec.JSON.createObjectNode().put("u","1").put("v","2"); + assertThrows(RuntimeException.class,()->subtract.check(subtract.witness(bad))); + bad.put("u","255");subtract.check(subtract.witness(bad)); + assertThrows(RuntimeException.class,()->SymbolicRecipe.expand(recipe("widthOverflow"))); + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/TestWitness.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/TestWitness.java new file mode 100644 index 00000000..8a2d92fc --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/TestWitness.java @@ -0,0 +1,16 @@ +package org.zeroj.bindings.circuit; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.util.HexFormat; +final class TestWitness { + static String digest(BigInteger[] values) { + try { + var md=MessageDigest.getInstance("SHA-256"); + for(var value:values) { + var be=value.toByteArray();var fixed=new byte[32];int n=Math.min(32,be.length); + System.arraycopy(be,be.length-n,fixed,32-n,n);md.update(fixed); + } + return HexFormat.of().formatHex(md.digest()); + } catch(java.security.NoSuchAlgorithmException e) {throw new IllegalStateException(e);} + } +} diff --git a/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/TypedSelection.java b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/TypedSelection.java new file mode 100644 index 00000000..cc63863d --- /dev/null +++ b/bindings/wasm/adapter-circuit/src/test/java/org/zeroj/bindings/circuit/TypedSelection.java @@ -0,0 +1,8 @@ +package org.zeroj.bindings.circuit; +import org.zeroj.circuit.annotation.*; +@ZKCircuit(name="symbolic_select") +public final class TypedSelection { + @Prove void prove(@Public ZkField selected,@Secret ZkBool chooseLeft,@Secret ZkField left,@Secret ZkField right) { + chooseLeft.select(left,right).assertEqual(selected); + } +} diff --git a/bindings/wasm/adapter-common/src/main/java/org/zeroj/bindings/common/BindingSession.java b/bindings/wasm/adapter-common/src/main/java/org/zeroj/bindings/common/BindingSession.java index 5e2b4a12..fa3e063e 100644 --- a/bindings/wasm/adapter-common/src/main/java/org/zeroj/bindings/common/BindingSession.java +++ b/bindings/wasm/adapter-common/src/main/java/org/zeroj/bindings/common/BindingSession.java @@ -39,11 +39,7 @@ public String call(String input) { } case "release" -> { Boundary.fields(args,"handle"); - Object removed = handles.remove(Boundary.text(args,"handle")); - if (removed instanceof AutoCloseable closeable) { - try { closeable.close(); } catch (Exception e) { throw new BindingException("RELEASE_FAILED"); } - } - yield Boundary.object().put("released",removed != null); + yield Boundary.object().put("released",release(Boundary.text(args,"handle"))); } default -> throw new BindingException("UNSUPPORTED_OPERATION"); }; @@ -78,4 +74,12 @@ public T get(String id, Class type) { if (!type.isInstance(value)) throw new BindingException("INVALID_HANDLE"); return type.cast(value); } + /** Release a local object and its owned resources. No erasure guarantee. */ + public boolean release(String id) { + Object removed = handles.remove(id); + if (removed instanceof AutoCloseable closeable) { + try { closeable.close(); } catch (Exception e) { throw new BindingException("RELEASE_FAILED"); } + } + return removed != null; + } } diff --git a/bindings/wasm/adapter-groth16/src/main/java/org/zeroj/bindings/groth16/Groth16ProverAdapter.java b/bindings/wasm/adapter-groth16/src/main/java/org/zeroj/bindings/groth16/Groth16ProverAdapter.java index 622b7ea2..951b314a 100644 --- a/bindings/wasm/adapter-groth16/src/main/java/org/zeroj/bindings/groth16/Groth16ProverAdapter.java +++ b/bindings/wasm/adapter-groth16/src/main/java/org/zeroj/bindings/groth16/Groth16ProverAdapter.java @@ -1,6 +1,7 @@ package org.zeroj.bindings.groth16; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; import java.math.BigInteger; import java.util.Arrays; @@ -25,19 +26,11 @@ private record Loaded(CompiledCircuit circuit, ZkeyDataBLS381 data, JsonNode vk) } if (operation.equals("loadKey")) { Boundary.fields(args,"circuit","fingerprint","zkey","expectedSha256","verificationKey"); - var c = circuit(args); - if (!c.fingerprint().equals(Boundary.text(args,"fingerprint"))) throw new BindingException("CIRCUIT_MISMATCH"); - JsonNode vk = args.get("verificationKey"); - Groth16VerifierAdapter.keyShape(vk); - if (Boundary.integer(vk,"nPublic",0,64) != c.numPublic()) throw new BindingException("CIRCUIT_MISMATCH"); byte[] bytes = Boundary.bytes(args,"zkey",1,Boundary.MAX_BYTES); - byte[] digest = Boundary.bytes(args,"expectedSha256",32,32); - try { - var data = ZkeyImporterBLS381.importZkeyFull(bytes,digest, - new ZkeyImporterBLS381.Limits(Boundary.MAX_BYTES,4096,4096,16384)); - checkRelation(c,data); - return Boundary.object().put("handle",session.retain(new Loaded(c,data,vk.deepCopy()))); - } catch (IOException e) { throw new BindingException("INVALID_PROVING_KEY"); } + ObjectNode metadata = args.deepCopy(); + metadata.remove("zkey"); + return loadKeyBytes(metadata, bytes, session, + new ZkeyImporterBLS381.Limits(Boundary.MAX_BYTES,4096,4096,16384)); } if (operation.equals("prove")) { Boundary.fields(args,"key","input"); @@ -58,6 +51,31 @@ private record Loaded(CompiledCircuit circuit, ZkeyDataBLS381 data, JsonNode vk) } throw new BindingException("UNSUPPORTED_OPERATION"); } + /** Java-only entry point for bounded chunk transports. No alternate key parser. */ + public JsonNode loadKeyBytes(JsonNode metadata, byte[] bytes, BindingSession session, + ZkeyImporterBLS381.Limits limits) { + validateKeyMetadata(metadata); + var c = circuit(metadata); + byte[] digest = Boundary.bytes(metadata,"expectedSha256",32,32); + long start = System.nanoTime(); + try { + var data = ZkeyImporterBLS381.importZkeyFull(bytes,digest,limits); + checkRelation(c,data); + return Boundary.object().put("handle",session.retain(new Loaded(c,data,metadata.get("verificationKey").deepCopy()))) + .put("keyBytes",bytes.length).put("importMs",(System.nanoTime()-start)/1e6) + .put("domainSize",data.domainSize()); + } catch (IOException e) { throw new BindingException("INVALID_PROVING_KEY"); } + } + /** Validate small metadata before the transport reserves a key-sized buffer. */ + public void validateKeyMetadata(JsonNode metadata) { + Boundary.fields(metadata,"circuit","fingerprint","expectedSha256","verificationKey"); + var c = circuit(metadata); + if (!c.fingerprint().equals(Boundary.text(metadata,"fingerprint"))) throw new BindingException("CIRCUIT_MISMATCH"); + Boundary.bytes(metadata,"expectedSha256",32,32); + JsonNode vk = metadata.get("verificationKey"); + Groth16VerifierAdapter.keyShape(vk); + if (Boundary.integer(vk,"nPublic",0,64) != c.numPublic()) throw new BindingException("CIRCUIT_MISMATCH"); + } private CompiledCircuit circuit(JsonNode args) { var c = circuits.get(Boundary.text(args,"circuit")); if (c == null) throw new BindingException("UNSUPPORTED_CIRCUIT"); diff --git a/bindings/wasm/build.py b/bindings/wasm/build.py index e76d8294..c7bce002 100644 --- a/bindings/wasm/build.py +++ b/bindings/wasm/build.py @@ -5,6 +5,8 @@ ROOT = Path(__file__).resolve().parents[2] HERE = ROOT / 'bindings/wasm' PROFILES = {'hash': ('HashWeb', ['hash']), 'probe': ('PlatformProbeWeb', ['probe']), + 'circuit': ('CircuitWeb', ['circuit','groth16-verifier']), + 'circuit-large-research': ('CircuitLargeResearchWeb', ['circuit','groth16-verifier']), 'crypto': ('CryptoWeb', ['bbs','pedersen']), 'all': ('AllWeb', ['hash','pedersen','bbs','groth16-verifier','groth16-prover','elgamal','threshold','confidential-notes']), 'confidential': ('ConfidentialWeb', ['elgamal','threshold','confidential-notes']), @@ -13,6 +15,8 @@ parser = argparse.ArgumentParser() parser.add_argument('profiles', nargs='*', choices=list(PROFILES)) args = parser.parse_args() +if 'circuit-large-research' in args.profiles and len(args.profiles) != 1: + parser.error('Build the research image separately from consumer profiles') home = Path(os.environ.get('GRAALVM_HOME', os.environ.get('JAVA_HOME', ''))) if not (home / 'bin/native-image').is_file(): parser.error('Set GRAALVM_HOME to Oracle GraalVM 25.3.4.1+1.1') @@ -26,8 +30,10 @@ def run(command, log): if result.returncode: print(log.read_text()[-8000:]) raise SystemExit(result.returncode) -run([str(ROOT/'gradlew'), '-p', str(HERE), ':bundles:classpath', '--max-workers=2', '--console=plain'], out/'compile.log') -cp = (HERE/'bundles/build/classpath.txt').read_text() +research='circuit-large-research' in (args.profiles or []) +run([str(ROOT/'gradlew'), '-p', str(HERE), ':bundles:researchClasspath' if research else ':bundles:classpath', '--max-workers=2', '--console=plain'], out/'compile.log') +classpath_file=HERE/'bundles/build'/('research-classpath.txt' if research else 'classpath.txt') +cp = classpath_file.read_text() binaryen = subprocess.check_output(['wasm-as','--version'],text=True).strip() if binaryen != 'wasm-as version 132': raise SystemExit('Expected Binaryen 132, got '+binaryen) inputs = hashlib.sha256() @@ -54,9 +60,12 @@ def run(command, log): '-H:AbortOnTypeReachable=org.zeroj.blst.*', '-H:AbortOnTypeReachable=supranational.blst.*', '-H:AbortOnTypeReachable=org.zeroj.crypto.setup.*', + '-H:AbortOnTypeReachable=org.zeroj.bindings.circuit.Fixtures', + '-H:AbortOnTypeReachable=org.zeroj.bindings.circuit.ExportCircuit', 'org.zeroj.bindings.bundles.'+entry], folder/'compile.log') metadata = {'profile': name, 'capabilities': capabilities, 'apiVersion': 1, 'graalvm': '25.3.4.1+1.1', 'binaryen': binaryen, 'classpathAndBuildSha256': inputs.hexdigest(), + 'researchOnly': name=='circuit-large-research', 'classpathFile': str(classpath_file.relative_to(HERE)), 'worktreeDirty': bool(subprocess.check_output(['git','status','--porcelain'],cwd=ROOT,text=True).strip()), 'seconds': time.perf_counter()-start, 'zerojCommit': subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()} (folder/'build.json').write_text(json.dumps(metadata,indent=2)+'\n') diff --git a/bindings/wasm/bundles/build.gradle b/bindings/wasm/bundles/build.gradle index 081ca473..a2b68778 100644 --- a/bindings/wasm/bundles/build.gradle +++ b/bindings/wasm/bundles/build.gradle @@ -28,3 +28,15 @@ tasks.register('confidentialFixtures', JavaExec) { maxHeapSize = '6g' args rootProject.file('../js/build/confidential').absolutePath } + +dependencies { implementation project(':adapter-circuit') } + +// Research entry points are excluded from the ordinary consumer classpath. +sourceSets { research { java.srcDir 'src/research/java' } } +configurations.researchImplementation.extendsFrom configurations.implementation +configurations.researchRuntimeOnly.extendsFrom configurations.runtimeOnly +dependencies { researchImplementation sourceSets.main.output } +tasks.register('researchClasspath') { + dependsOn researchClasses, configurations.researchRuntimeClasspath.buildDependencies + doLast { layout.buildDirectory.file('research-classpath.txt').get().asFile.text = sourceSets.research.runtimeClasspath.asPath } +} diff --git a/bindings/wasm/bundles/src/main/java/org/zeroj/bindings/bundles/CircuitWeb.java b/bindings/wasm/bundles/src/main/java/org/zeroj/bindings/bundles/CircuitWeb.java new file mode 100644 index 00000000..750c1001 --- /dev/null +++ b/bindings/wasm/bundles/src/main/java/org/zeroj/bindings/bundles/CircuitWeb.java @@ -0,0 +1,13 @@ +package org.zeroj.bindings.bundles; + +import java.util.Map; +import org.zeroj.bindings.circuit.CircuitAdapter; +import org.zeroj.bindings.common.WebBridge; +import org.zeroj.bindings.groth16.Groth16VerifierAdapter; + +/** Selected compiler/gadgets and prover share one Java heap. */ +public final class CircuitWeb { + public static void main(String[] args) { + WebBridge.start(Map.of("circuit", new CircuitAdapter(), "groth16-verifier", new Groth16VerifierAdapter())); + } +} diff --git a/bindings/wasm/bundles/src/research/java/org/zeroj/bindings/bundles/CircuitLargeResearchWeb.java b/bindings/wasm/bundles/src/research/java/org/zeroj/bindings/bundles/CircuitLargeResearchWeb.java new file mode 100644 index 00000000..4e110b93 --- /dev/null +++ b/bindings/wasm/bundles/src/research/java/org/zeroj/bindings/bundles/CircuitLargeResearchWeb.java @@ -0,0 +1,15 @@ +package org.zeroj.bindings.bundles; + +import java.util.Map; +import org.zeroj.bindings.circuit.CircuitAdapter; +import org.zeroj.bindings.circuit.CircuitKeyProfile; +import org.zeroj.bindings.common.WebBridge; +import org.zeroj.bindings.groth16.Groth16VerifierAdapter; + +/** Explicit resource experiment. No npm release; default image uses CircuitWeb. */ +public final class CircuitLargeResearchWeb { + public static void main(String[] args) { + WebBridge.start(Map.of("circuit", new CircuitAdapter(CircuitKeyProfile.RESEARCH_MERKLE), + "groth16-verifier", new Groth16VerifierAdapter())); + } +} diff --git a/bindings/wasm/check.py b/bindings/wasm/check.py index 94965aa2..2d738d9d 100644 --- a/bindings/wasm/check.py +++ b/bindings/wasm/check.py @@ -11,12 +11,20 @@ def run(command,cwd=ROOT): print('Running:', ' '.join(command),flush=True) subprocess.run(command,cwd=cwd,env=env,check=True) run(['npm','ci','--ignore-scripts'],JS) -profiles=['hash','crypto','verifier','prover','confidential','all','probe','unprotected'] +run(['npm','run','build'],JS) +run(['npx','tsc','-p','examples/circuit'],JS) +run(['node','scripts/circuit-recipes.mjs'],JS) +profiles=['hash','crypto','verifier','prover','confidential','all','probe','unprotected','circuit'] run(['python3','bindings/wasm/build.py',*profiles]) run(['python3','bindings/wasm/package.py',*profiles]) run(['python3','bindings/wasm/tutorial-fixture.py']) -run(['./gradlew','-p','bindings/wasm',':bundles:confidentialFixtures',':adapter-common:test',':adapter-hash:test','--max-workers=2','--console=plain']) +run(['./gradlew','-p','bindings/wasm',':bundles:confidentialFixtures',':adapter-common:test',':adapter-hash:test',':adapter-circuit:test',':adapter-circuit:fixtures','--max-workers=2','--console=plain']) +run(['node','scripts/circuit-setup.mjs'],JS) run(['npm','run','build'],JS) run(['npm','test'],JS) run(['node','tests/browser.mjs'],JS) +run(['node','tests/circuit-browser.mjs'],JS) +run(['./gradlew','-p','bindings/wasm',':adapter-circuit:circuitInterop','--max-workers=2','--console=plain']) +run(['node','--experimental-wasm-exnref','scripts/circuit-interop.mjs'],JS) run(['node','scripts/pack-test.mjs'],JS) +run(['node','scripts/circuit-pack-test.mjs'],JS) diff --git a/bindings/wasm/circuit-check.py b/bindings/wasm/circuit-check.py new file mode 100644 index 00000000..3fb80a51 --- /dev/null +++ b/bindings/wasm/circuit-check.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Focused ADR-0064 validation. Generated artifacts stay ignored; never publishes.""" +from pathlib import Path +import os, subprocess +ROOT=Path(__file__).resolve().parents[2];JS=ROOT/'bindings/js' +home=os.environ.get('GRAALVM_HOME') +if not home: raise SystemExit('Set GRAALVM_HOME to the pinned toolchain') +env={**os.environ,'JAVA_HOME':home} +def run(args,cwd=ROOT): + print('Running:', ' '.join(args),flush=True) + subprocess.run(args,cwd=cwd,env=env,check=True) +run(['npm','ci','--ignore-scripts'],JS) +run(['npm','run','build'],JS) +run(['npx','tsc','-p','examples/circuit'],JS) +run(['node','scripts/circuit-recipes.mjs'],JS) +run(['./gradlew','-p','bindings/wasm',':adapter-circuit:test',':adapter-circuit:fixtures',':adapter-common:test',':adapter-hash:test','--max-workers=2','--console=plain']) +run(['python3','bindings/wasm/build.py','circuit']) +run(['python3','bindings/wasm/package.py','circuit']) +run(['node','scripts/circuit-setup.mjs'],JS) +run(['npx','tsc','--noEmit','--target','ES2022','--module','NodeNext','--strict','--skipLibCheck','tests/circuit-types.ts'],JS) +run(['node','packages/circuit-lint/src/index.mjs','examples/circuit/symbolic-examples.ts','examples/circuit/operation-examples.ts'],JS) +run(['node','--experimental-wasm-exnref','--test','tests/circuit-frontend.test.mjs','tests/circuit.test.mjs','tests/circuit-key.test.mjs'],JS) +run(['node','tests/circuit-browser.mjs'],JS) +run(['./gradlew','-p','bindings/wasm',':adapter-circuit:circuitInterop','--max-workers=2','--console=plain']) +run(['node','--experimental-wasm-exnref','scripts/circuit-interop.mjs'],JS) +run(['node','scripts/circuit-pack-test.mjs'],JS) diff --git a/bindings/wasm/package.py b/bindings/wasm/package.py index 958e5afa..c3884042 100644 --- a/bindings/wasm/package.py +++ b/bindings/wasm/package.py @@ -6,10 +6,12 @@ HERE = ROOT/'bindings/wasm' parser=argparse.ArgumentParser() parser.add_argument('profiles',nargs='+') +parser.add_argument('--research-output',action='store_true',help='Package the large-Merkle experiment into ignored build output, never npm workspace') args=parser.parse_args() version='0.1.0-experimental.1' sizes={} for name in args.profiles: + if (name=='circuit-large-research') != args.research_output: raise SystemExit('Research image requires --research-output; regular images must omit it') folder=HERE/'build/images'/name meta=json.loads((folder/'build.json').read_text()) launcher=(folder/'engine.js').read_text() @@ -26,7 +28,7 @@ launcher='/* ZeroJ modifications, 2026-10-30: verified byte injection, explicit Wasm path, bounded startup error hook. Original copyright and license headers preserved below. */\n'+launcher files={'launcher':launcher.encode(),'wasm':(folder/'engine.js.wasm').read_bytes()} identity=name+'-'+hashlib.sha256(files['launcher']+files['wasm']).hexdigest()[:24] - package=ROOT/'bindings/js/packages'/('bundle-'+name) + package=ROOT/'bindings/js/build/circuit-large/bundle' if args.research_output else ROOT/'bindings/js/packages'/('bundle-'+name) assets=package/'assets';dist=package/'dist' assets.mkdir(parents=True,exist_ok=True);dist.mkdir(exist_ok=True) info={'id':identity,'apiVersion':1,'capabilities':meta['capabilities']} @@ -60,10 +62,14 @@ if not (home/'LICENSE.txt').is_file(): raise SystemExit('Set GRAALVM_HOME when packaging to preserve compiler notices') legal=package/'third-party';legal.mkdir(exist_ok=True) shutil.copyfile(home/'LICENSE.txt',legal/'Oracle-GraalVM-GFTC.txt') - shutil.copytree(home/'legal',legal/'graalvm-legal',dirs_exist_ok=True) + # GraalVM notices include read-only files. Recreate this generated subtree so + # a second packaging run need not overwrite those files in place. + legal_tree=legal/'graalvm-legal' + if legal_tree.exists(): shutil.rmtree(legal_tree) + shutil.copytree(home/'legal',legal_tree) shutil.copyfile(ROOT/'licenses/Apache-2.0.txt',legal/'Apache-2.0.txt') (legal/'graalvm-release.txt').write_text((home/'release').read_text()) - for entry in (HERE/'bundles/build/classpath.txt').read_text().split(os.pathsep): + for entry in (HERE/meta.get('classpathFile','bundles/build/classpath.txt')).read_text().split(os.pathsep): jar=Path(entry) if not jar.is_file() or jar.suffix!='.jar': continue with zipfile.ZipFile(jar) as archive: diff --git a/bindings/wasm/settings.gradle b/bindings/wasm/settings.gradle index 215037d9..038326a6 100644 --- a/bindings/wasm/settings.gradle +++ b/bindings/wasm/settings.gradle @@ -1,4 +1,4 @@ rootProject.name = 'zeroj-web-bindings' includeBuild('../..') include 'adapter-common', 'adapter-hash', 'adapter-pedersen', 'adapter-bbs', - 'adapter-groth16', 'adapter-elgamal', 'adapter-threshold', 'adapter-notes', 'bundles' + 'adapter-circuit', 'adapter-groth16', 'adapter-elgamal', 'adapter-threshold', 'adapter-notes', 'bundles' diff --git a/bindings/wasm/summarize-circuit-jfr.py b/bindings/wasm/summarize-circuit-jfr.py new file mode 100644 index 00000000..61e64de1 --- /dev/null +++ b/bindings/wasm/summarize-circuit-jfr.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Summarize exported JFR allocation samples. Weighted volume, never peak/retained heap. +Usage: jfr print --json --stack-depth 64 --events jdk.ObjectAllocationSample recording.jfr > allocations.json + python3 bindings/wasm/summarize-circuit-jfr.py allocations.json +""" +import json, sys +from collections import Counter +with open(sys.argv[1]) as source: + events = json.load(source)['recording']['events'] +volume, counts, imports, classes = Counter(), Counter(), Counter(), Counter() +for event in events: + if event['type'] != 'jdk.ObjectAllocationSample': + continue + value = event['values'] + frames = (value.get('stackTrace') or {}).get('frames', []) + names = ' '.join(f["method"]["type"]["name"] + '.' + f['method']['name'] for f in frames) + weight = value['weight'] + category = 'other_or_startup' + if 'ZkeyImporterBLS381.' in names: + category = 'key_import' + group = 'other_import' + if 'G1Point.isInSubgroup' in names: + group = 'G1_subgroup' + elif 'G2Point.isInSubgroup' in names: + group = 'G2_subgroup' + imports[group] += weight + elif 'Groth16ProverBLS381.' in names: + category = 'proving' + elif 'WitnessCalculator.' in names: + category = 'witness' + elif 'Groth16VerifierAdapter.verify' in names: + category = 'verification_including_self_check' + elif 'CompiledGraph.' in names or 'GraphCodec.' in names: + category = 'compile_or_graph' + volume[category] += weight + counts[category] += 1 + classes[value['objectClass']['name']] += weight +print(json.dumps({'meaning': 'sample-weight estimates of cumulative JVM allocation, not retained bytes, peak heap, or Wasm GC', + 'samples': sum(counts.values()), 'weightedBytes': dict(volume), 'counts': dict(counts), + 'importWeightedBytes': dict(imports), 'topClassesWeightedBytes': dict(classes.most_common(8))}, indent=2)) diff --git a/docs/adr/0057-webassembly-and-native-library-bindings.md b/docs/adr/0057-webassembly-and-native-library-bindings.md index 9fab0f50..71e661a7 100644 --- a/docs/adr/0057-webassembly-and-native-library-bindings.md +++ b/docs/adr/0057-webassembly-and-native-library-bindings.md @@ -29,6 +29,14 @@ code compiled *to* WebAssembly or to a native library. `65412c40ed1b6397ecfeb654e7bff547f6ea5b96`. Follow-up research supersedes the monolithic POC. - **r1 (2026-10-13):** first version, written from the proof of concept described in §Evidence. +## Circuit compilation follow-up + +[ADR-0064](0064-portable-circuits-and-typescript-frontend.md) accepts a bounded portable +graph/recipe compiler and typed TS frontend in a separate stack above this ADR’s PR #92. +It supersedes the runtime-circuit exclusion only for its enumerated vocabulary. +The first promotion retains the existing proving limits; broader gadget proving and +Cardano application integration follow its explicit milestone gates. + ## r2 implementation decisions (governing) ### Scope, evidence and limits diff --git a/docs/adr/0064-portable-circuits-and-typescript-frontend.md b/docs/adr/0064-portable-circuits-and-typescript-frontend.md new file mode 100644 index 00000000..6c6d6e07 --- /dev/null +++ b/docs/adr/0064-portable-circuits-and-typescript-frontend.md @@ -0,0 +1,282 @@ +# ADR-0064: Portable circuits and the symbolic TypeScript frontend + +## Status and scope + +Accepted for bounded experimental implementation, 2026-10-30, by the maintainer's +request to promote the tested prototype in a separate PR stacked on #92. **R2**: +serialization, untrusted graph/recipe admission and private-witness integration. +This acceptance is not an audit, production approval, or permission to publish. +The implementation milestones and selected gadget priorities are in +[the implementation plan](../../bindings/circuit/PLAN.md). + +This amends ADR-0057's runtime-circuit exclusion for the explicitly enumerated +portable graph and symbolic recipe vocabulary only. Arbitrary Java/JS execution, +class loading, custom hints, setup in Wasm, additional proof systems and general +WASI targets remain outside scope. The Java R1CS compiler, witness calculator, +prover, verifier and cryptographic gadgets remain the single implementations. + +## References and prior evidence + +- ZeroJ `844a0625282cc87e77f3c85df1c1cbdd4cc86a3a`: `Gate`, `WitnessCalculator`, + `R1CSCompiler`, `ZkField`/`ZkUInt`/`ZkBool`/`ZkBytes`, `ZkPoseidon`, `ZkSha256`, + `ZkeyImporterBLS381`, and the bindings runtime/strict Groth16 adapters. These + pinned implementations define the operation semantics; this ADR adds no crypto. +- Research `zeroj-research/browser-circuit-compiler-poc` commit `d425988`: five TS + recipes matched Java annotation graph bytes/fingerprints/full witnesses; small + proofs interoperated with snarkjs 0.7.6 in Node/Chromium/Firefox. Poseidon and + SHA compiled and checked witnesses, but their Wasm proof path exceeded existing + importer limits. Historical measurements do not establish performance here. +- ADR-0057: WebCrypto entropy, explicit secret opt-in, physical-image ownership, + pure-Java providers, lifecycle and distribution restrictions remain governing. +- ADR-0063 and the existing gadget assurance suites govern SHA-2 correctness. +- [ECMAScript ToBoolean](https://tc39.es/ecma262/multipage/abstract-operations.html#sec-toboolean) + and [TypeScript truthiness](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#truthiness-narrowing): + symbolic objects must not be used as host branch conditions. Type-aware lint is + a guardrail; it cannot make arbitrary JavaScript a safe circuit language. + +## Threat model and trusted inputs + +Recipes, graph bytes, public/private input encodings, keys, metadata and handle IDs +can be malformed. Attackers may attempt allocation exhaustion, type/ownership +confusion, noncanonical encodings, wrong field/profile, mismatched relation/key, +invalid witnesses or stale/cross-session handle use. The application/serving origin, +compiler image and immutable bundle descriptor are trusted; integrity hashes do not +protect an application whose origin replaces both code and hashes. + +Private inputs are supplied only at witness/proving time. Definition constants and +shapes are public. Host code can accidentally capture a secret in a definition; +the API cannot infer that intent. Never log or return witnesses or witness digests +from the promoted API. Secret operations keep ADR-0057's unassessed browser/Node +label, WebCrypto provider and explicit opt-in. No constant-time/erasure claim. + +The application must select an authenticated graph identity, R1CS fingerprint, +proving-key hash and verification key. A caller-provided matching hash is not +independent authentication. An accepted graph can describe a vacuous or incorrect +application statement; graph validity is not circuit soundness or authorization. + +## Formats and identity + +First promoted formats are `zeroj-graph` v1 and `zeroj-symbolic` v1, field +`bls12-381-fr`, compiler policy `zeroj-r1cs-bls12381-v1`. They are **experimental**; +a semantic/encoding change requires a new version/policy and matching artifacts. +Research `*-research` tags are deliberately rejected. There is no compatibility +claim for Circom source or Circom witness executables. + +Graph root order is `format,version,compiler,field,name,public,secret,wires,gates`. +Recipe root order is `format,version,compiler,field,name,inputs,commands`. +Both are minified UTF-8 JSON with exact field sets, fixed order and canonical +nonnegative decimal strings below Fr for scalars. Reordered keys, whitespace, +duplicate keys, unknown fields, numeric rather than string scalars, leading zeroes, +negative/reduced aliases and unsupported versions are rejected. Arrays preserve +order. Input records have `name,visibility,type`, then `bits` or `length` if needed. +Numbers used for indexes/counts are integral JSON integers with bounded ranges. +ASCII identifiers and opcode spellings are fixed; Unicode escaping is noncanonical. + +Graph identity commits to witness instructions, field and policy; R1CS fingerprint +commits to the compiled relation. Recipe identity commits to the authoring command +sequence. Keep all identities distinct. The first API requires expected graph hash +and R1CS fingerprint when compiling for a lifecycle that can receive witnesses. +Different but mathematically equivalent authoring programs need not share identity. + +Build-time `ExportCircuit` expands a public TS recipe on the JVM and exports its +graph, R1CS, input layout and identity manifest. TS-only authors therefore need no +matching Java circuit class. This artifact/setup build lane uses Java; consumer +browser/Node execution does not. The exporter never receives a witness or creates +ceremony keys and is excluded from the consumer image by a reachability guard. + +## Closed operations and validation + +Graph v1 permits Const, Add, Mul, AssertEq, AssertBoolean, LinComb, BitDecompose and +single-output hints INVERSE/IS_ZERO_RESULT/IS_ZERO_INVERSE. Raw Select, HintN, custom +code and future enum additions are rejected. The first gate is Const(ONE,1); each +intermediate is assigned exactly once, only reads previously defined wires, and +cannot overwrite inputs/ONE. Field scalars, bit widths, term counts and names are +validated before compilation. A hint supplies advice, not a constraint: intended +relations still need equations, just as in Java. The reviewed Java gadget/DSL +builders supply those equations; the codec cannot certify arbitrary graph intent. + +Recipes contain backward-only symbolic IDs, not wires. A closed dispatcher calls +existing Java symbolic methods and validates concrete symbolic types. Public inputs +precede secret inputs; flattened names are unique. UInt widths/ranges, booleans, +selection and gadget constraints remain in Java. Initial gadget profiles are +explicit BLS12-381 T3 Poseidon and one-block SHA-256. No BN254 default is used. + +TS uses immutable recording objects and private ownership records. Failed authoring +closes the recorder. Callbacks are synchronous/void, with explicit assertions; +operator overloads, symbolic JS conditions and witness-dependent shapes are excluded. +Typed lint must run for supported application code; bypasses via any/reflection or +arbitrary host code remain outside the supported authoring subset. + +## Limits and lifecycle + +Retain the researched graph limits: 16 MiB text, 200,000 wires/gates, 1,000,000 linear +terms, 4,096 terms per linear combination and 4,096 named inputs. Recipes admit at +most 64 logical/256 flattened inputs, 128 commands, UInt 1..253, bytes 1..64, four +Poseidon calls and one SHA-256 call of 1..55 bytes, with the 180,000-unit conservative +preflight expansion estimate. Post-expansion graph validation remains mandatory. +These are bounded experimental workloads, not proven hostile-input heap containment. + +One compilation attempt per physical compiler worker, including failed attempts. +Graph uploads have bounded length, exact offsets and SHA-256; chunks stay within the +existing 1 MiB request/512 KiB string caps. A compiled-circuit session handle owns its +key handles. Closing it invalidates/releases its keys; runtime disposal/timeout +terminates the worker. A fresh compilation needs a fresh runtime. Keys from another +compiled circuit/runtime cannot be used. Public diagnostics contain counts/identity +and input layout, never witness values. Witness check returns only success/failure. + +The first milestone retains the 256 KiB key and 4,096-wire/domain limits and uses +the existing Groth16 adapter/parser. Larger-key transfer is a separately tested +milestone: it must retain exact offsets/total bounds, authenticated hash, point and +relation validation, with measured allocation behavior. No parser duplication or +blind cap increase. The compiler and prover share one Java heap in an explicit +`bundle-circuit` image; verifier-only consumers do not need it. JS tree shaking does +not prune precompiled Wasm. Core/default Java builds remain unchanged. + +## Verification and acceptance + +### M2 bounded key-loading profile (2026-10-30) + +The compiler image adds `circuit-key-v1`: at most 2 MiB of raw key bytes, +64 KiB per sequential chunk, one active upload and one successfully imported +key per compiled circuit. Existing standalone prover and JSON request/string +limits stay unchanged, as do 4,096 wires/domain and 16,384 coefficients. The +profile is sufficient for the measured 1,090,303-byte Poseidon opening key; +it is not admission for arbitrary larger circuits or SHA proving. + +`beginKey` validates circuit identity, expected digest, VK shape and declared +length before reserving one exact-sized byte array. `appendKey` requires an +owned upload handle and exact offset; replay, gaps, empty/oversized chunks or +overflow terminate that upload. `finishKey` consumes the upload, requires the +declared length and invokes the existing importer with its authenticated-hash, +canonical-field, point/subgroup and circuit-relation checks intact. No key handle +escapes on failure. Closing the upload/circuit drops the buffer. No second full +hex string or assembled JSON object is created. The parser still needs the full +binary buffer and allocates its existing parsed-key objects; this is chunked +transport, not a streaming cryptographic parser. + +Accounting reports reserved/received bytes, peak staging reservation and the +imported-key count. These exact transport counters are not Java/Wasm heap sizes. +The caller retains its original byte array; the worker receives one bounded hex +chunk at a time. Platform measurements must separately describe process/heap +scope and sampling limitations. A successful key consumes the single-key slot +until circuit disposal, even if the caller releases its handle earlier. + +The typed API accepts an optional AbortSignal. Abort before loading leaves the +runtime intact; abort during transfer/import terminates the entire physical +runtime (all its handles/capabilities), because synchronous Java point validation +cannot observe queued cancellation. Runtime timeout/disposal has the same hard +termination semantics. Protocol abort between calls only abandons the upload. +No cancellation, timing or memory-erasure guarantee follows for secrets already +handled by the trusted host. Secrets are not involved in key transfer itself. + +Acceptance requires malformed/chunk-state tests, independent Poseidon proof +verification in snarkjs and JVM, actual Chromium/Firefox proofs, repeatable timing +and memory measurements. Full M2 exit remains gated on wider allocation profiling +and device coverage; a successful desktop example is experimental support only. + +- Port graph/recipe positives and malformed-input tests; add canonical root/input + ordering and explicit hint allowlist tests. Add semantic/width/boolean/range and + forged-ref negatives, witness tampering and artifact mismatch tests. +- Compare annotation Java and TS recipes: canonical graphs, R1CS fingerprints and + full witnesses in test-only code. No secret diagnostics in consumer adapters. +- Run actual Node/Chromium/Firefox small proofs, bidirectional snarkjs checks and + wrong-public-input rejection. Preserve entropy opt-in/failure and ownership gates. +- Inspect independent reference/gadget vectors; wrapper parity is not independent + cryptographic assurance. Run affected Java and existing binding regressions. +- Measure image bytes, startup, compile/witness/prove/verify, with explicit memory + scope and supported gadget sizes. No production/large-browser/quantum claims. + +## M3 fixed arrays and Merkle extension (2026-10-30) + +R2 extension under the accepted plan: add `zeroj-symbolic` version 2 for fixed +homogeneous arrays of field/bool/uint (1..64 elements, no nested/byte arrays). +Existing definitions still emit v1; the decoder keeps its v1 vocabulary closed. +Array records have `name,visibility,type,element,length`; element descriptors have +`type`, then `bits` for uint. Flattening is `name_0` through `name_(length-1)`, +public before secret, with duplicate/collision and total-256-scalar checks. +Array indexing is a public host integer, never a witness-dependent index. Java +`ZkArray` supplies all input range/boolean constraints and element semantics. + +The only Merkle profile is existing `ZkMerkle.computeRootPoseidon` with explicit +`PoseidonParamsBLS12_381T3.INSTANCE`, pinned at ZeroJ `cb998587`. Siblings and +boolean directions are ordered leaf-to-root; false places the current node on +the left, true on the right. One call of depth 1..32 is admitted, with explicit +depth checked against both typed arrays and charged at 5,000 expansion units per +level within the existing 180,000-unit budget. Empty proofs and length/type +aliases reject. No raw select, custom hash/hint or new cryptography is admitted. +Graph v1, compiler policy, importer/prover and 2 MiB/4,096-wire budgets do not change. + +The demonstration proves knowledge of a field leaf and path under an +application-selected public root; it must not describe membership as identity, +one-time authorization or a nullifier protocol. Fixtures use public test values. +Compare native Java authoring/host hashing, all-false/all-true/mixed directions, +invalid booleans and every leaf/sibling/root/path tampering; measure depths before +claiming proving support. Depths outside prover admission remain compile/witness +experiments. Cardano integration must additionally bind the trusted root and +intended action to ScriptContext, with positive/negative and replay tests. The local +example pins the root and an authorized signer in the validator and admits only +an explicit claim action. The signer authorizes the transaction, while the proof +supplies membership; the proof alone is not authorization. Ledger UTxO consumption +prevents spending the same deposit twice. Membership proofs can be reused for +another deposit under the same policy: no global one-use/nullifier claim is made. +The required signer is public, so this is not an anonymous-spending protocol. +The opt-in Cardano runner uses existing ZeroJ point compression/verifier sources, +JuLC and CCL, and admits only loopback Yaci endpoints with an ephemeral test account. +Wallet secrets remain in memory. No mainnet/testnet external submission is allowed +by the example; only disposable local DevKit transactions are in scope. + +## Alternatives and remaining gates + +A second TS crypto/compiler implementation would duplicate security-critical rules; +rejected. RPC per symbolic operation is unnecessarily expensive; use a batch recipe. +Java-only exported graphs remain supported alongside recipes. A low-level graph +builder is an advanced escape hatch with the same format/validation limits. + +Before broader support: hard allocation/work counters, streaming graph/key memory +accounting, fuzz/property coverage, independent review, Safari/real-device validation, +explicit stable-format compatibility and artifact distribution review. The bounded arrays/Merkle +and local Cardano slices are recorded in `bindings/circuit/M3-MERKLE.md`; broader SHA-2/BLAKE2b, +other Poseidon profiles, EC relations and richer application protocols remain in the plan. + +## M3 larger-Merkle resource experiment (2026-10-30) + +R2 extension, after the bounded M3 result at `219a27b8420bdd50740f5f5caabaf64b8bc1ff41`. +The question is whether existing Java code can prove deeper trees with explicitly larger +resource budgets, without changing cryptography or the default SDK's admission policy. +Depth-2 metadata measures 13,007 coefficient entries as well as 3,054 wires and a 2,089,119-byte +key. Byte, wire, coefficient and domain bounds therefore need separate measurement. + +Add a Java-selected `RESEARCH_MERKLE` profile to the circuit adapter, reachable only through +an explicitly chosen separate research image. No JSON/RPC argument may choose or raise a +profile. The normal constructor/image remains `circuit-key-v1` (2 MiB, 4,096 wires/domain, +16,384 coefficients); standalone direct imports remain 256 KiB. Research bounds are +`circuit-key-research-merkle-v1`: 32 MiB, 65,536 wires, 8,192 domain cells, 262,144 coefficients, +64 public inputs. These are experimental admission ceilings, not a promise of heap containment +or support for every admitted circuit. Graph/recipe vocabulary and identities stay unchanged. +The profile name describes this experiment; it is not a cryptographic allowlist of Merkle relations. + +The immutable profile is selected in Java at image construction. Descriptions/resource counters +report its exact limits. Reject a compiled circuit outside its profile dimensions before reserving +an upload buffer. Preserve 64 KiB sequential chunks, one active upload/one lifetime imported key, +application-authenticated graph/fingerprint/key/VK, hash-before-parse, existing canonical field, +point/subgroup/relation validation, experimental-secret opt-in, WebCrypto entropy, witness checks +and proof self-verification. No alternate parser, trusted-point cache or unchecked path is added. +The research driver may explicitly use a bounded 600-second call timeout; the runtime/default +image's 120-second default remains unchanged. Cancellation terminates the whole worker. + +Build/package research assets into ignored experiment output, outside the npm workspace/release +package inventory. The research image must retain native-blst/setup reachability guards and +upstream notices. Generate separate development-only snarkjs keys for depths 8/16/32; do not +rewrite M3/default manifests or imply a production ceremony. Inspect actual byte/section counts, +then use the unchanged importer/prover on JVM (6 GB and 8 GB maximum heaps), Node and browsers. +Begin at depth 8; advance only after correctness and observed resource use permit it. Record +compile/witness/import/first proof/reused proof/verify independently, process RSS and sampled JVM +heap with their limitations. Cross-verify fresh proofs with snarkjs/JVM, change public roots and +private paths, check default-profile rejection, oversized lengths/dimensions/coefficients, +incomplete/digest-invalid imports and cancellation. Failures/timeouts are research results. + +Secrets and timing assumptions remain ADR-0057's experimental variable-time Java path; no audit, +constant-time, anonymous-authorization or post-quantum claim follows. Published fixtures have +no privacy claim. Do not increase production/default limits based on a desktop benchmark. +Core wire compaction is an alternative requiring a separate core design/review because it changes +witness mapping, fingerprints and setup compatibility; this experiment changes none of those.