zk-credits 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +193 -17
  2. package/circuits/manifest.json +19 -7
  3. package/dist/artifact-bundle.d.ts +58 -0
  4. package/dist/artifact-bundle.js +201 -0
  5. package/dist/base-event-sync.d.ts +32 -0
  6. package/dist/base-event-sync.js +159 -0
  7. package/dist/base-registered-trial.d.ts +33 -0
  8. package/dist/base-registered-trial.js +168 -0
  9. package/dist/base-sidecar.d.ts +92 -0
  10. package/dist/base-sidecar.js +426 -0
  11. package/dist/cli-runtime.d.ts +2 -4
  12. package/dist/cli-runtime.js +8 -24
  13. package/dist/cli.js +411 -37
  14. package/dist/node-sidecar-lifecycle.d.ts +2 -0
  15. package/dist/node-sidecar-lifecycle.js +29 -7
  16. package/dist/proof-child.d.ts +23 -0
  17. package/dist/proof-child.js +31 -0
  18. package/dist/proof-coordinator.d.ts +54 -0
  19. package/dist/proof-coordinator.js +211 -0
  20. package/dist/proof-metrics.d.ts +47 -0
  21. package/dist/proof-metrics.js +89 -0
  22. package/dist/responses-bridge.d.ts +14 -0
  23. package/dist/responses-bridge.js +328 -0
  24. package/dist/setup-preflight.d.ts +2 -0
  25. package/dist/setup-preflight.js +12 -0
  26. package/dist/sidecar-config.d.ts +0 -1
  27. package/dist/sidecar-config.js +0 -1
  28. package/dist/sidecar.d.ts +16 -17
  29. package/dist/sidecar.js +91 -151
  30. package/dist/slot-ledger.d.ts +48 -0
  31. package/dist/slot-ledger.js +146 -0
  32. package/dist/zk-credits.js +82663 -841
  33. package/dist/zk-credits.js.LEGAL.txt +22 -0
  34. package/package.json +8 -8
  35. package/circuits/rln_nullifier.wasm +0 -0
  36. package/circuits/rln_nullifier_final.zkey +0 -0
  37. package/circuits/verification_key_rln.json +0 -109
  38. package/dist/anthropic-messages.d.ts +0 -81
  39. package/dist/anthropic-messages.js +0 -192
  40. package/dist/artifact-manifest.d.ts +0 -8
  41. package/dist/artifact-manifest.js +0 -29
  42. package/dist/claude-launcher.d.ts +0 -17
  43. package/dist/claude-launcher.js +0 -47
  44. package/dist/identity.d.ts +0 -18
  45. package/dist/identity.js +0 -48
  46. package/dist/local-prover.d.ts +0 -18
  47. package/dist/local-prover.js +0 -32
  48. package/dist/membership-client.d.ts +0 -14
  49. package/dist/membership-client.js +0 -66
  50. package/dist/ticket-ledger.d.ts +0 -22
  51. package/dist/ticket-ledger.js +0 -91
package/README.md CHANGED
@@ -1,52 +1,228 @@
1
1
  # zk-credits
2
2
 
3
- Loopback sidecar that attaches a ZK-RLN proof to each coding-agent LLM request.
3
+ Loopback sidecar that attaches a hash-pinned BN254 Groth16 proof to each
4
+ coding-agent LLM request through the experimental x402 `zk-prepaid` scheme.
5
+ Built for the invite-only, unpaid, experimental Base Sepolia pilot; credits
6
+ are founder-provisioned test credits, and the circuit is not independently
7
+ audited.
4
8
 
5
9
  ```bash
6
- npm install --global zk-credits
10
+ npm install --global zk-credits@latest
7
11
  ```
8
12
 
13
+ ## Pinned pilot versions
14
+
15
+ The pilot supports exactly these versions. Do not substitute another, and do
16
+ not mix versions between the sidecar, the adapter package, and the gateway:
17
+
18
+ | Package | Version |
19
+ | --- | --- |
20
+ | `zk-credits` (this sidecar) | `0.2.0` |
21
+ | `@zk-credits/x402-zk-prepaid` (adapter) | `0.1.0` |
22
+ | `@zk-credits/shared` | `0.1.0` |
23
+
24
+ `zk-credits@0.2.0` is the currently published Base Sepolia pilot version. It
25
+ uses the non-streaming `POST /v1/chat/completions` path below. The local
26
+ `0.2.1` candidate adds a loopback Responses bridge for Codex and is reserved
27
+ for the internal trial; it is not yet published or part of the pinned pilot
28
+ set. Neither version carries a Stellar or evaluation path. The proving artifacts
29
+ (`private-credit-spend-bn254-dev-sepolia-v1`) are delivered directly through
30
+ the invite channel, never from a public host.
31
+
32
+ Verify the shipped artifacts before your first prove. The package's
33
+ `circuits/manifest.json` fixes the SHA-256 of the frozen
34
+ `private_credit_spend.wasm`, `private_credit_spend.zkey`, and
35
+ `verification_key_private_credit.json`; compute the digest of each delivered
36
+ file and compare it to the manifest. A mismatch is a proof failure, and the
37
+ sidecar refuses to prove rather than sending a payment. The founder compares
38
+ the same digests against the delivered set before inviting you.
39
+
40
+ ## What one credit buys
41
+
42
+ One credit buys one successfully committed response from the single service
43
+ class `coding-deepseek-v4-flash-v1` (`deepseek/deepseek-v4-flash`). It is not
44
+ an arbitrary API call, and the gateway enforces the class before it reserves
45
+ the credit:
46
+
47
+ - text messages, tool definitions, and tool calls, with one generated choice;
48
+ - no provider streaming, images, files, audio, web search, model fallback,
49
+ client-selected routing, or unknown cost-affecting fields;
50
+ - input capped at 128,000 UTF-8 byte units (counted conservatively, never below the
51
+ provider's token count), output at 4,000 tokens, request body at 256 KiB,
52
+ encrypted replay at 1 MiB, and upstream timeout at 120 seconds.
53
+
54
+ The requested `model` field is ignored: the gateway always dispatches the
55
+ class model, so an OpenAI-compatible client configured with any model name
56
+ still receives the class. A rejected request consumes no credit.
57
+
9
58
  ## First run
10
59
 
11
- 1. Fund an identity at the [web app](https://feature-zk-api-credits-gadillacers-projects.vercel.app)
12
- (GitHub sign-in → generate 24-word phrase → buy Starter $1.00 / 100 tickets).
13
- 2. Import the phrase (hidden TTY, OS keychain):
60
+ 1. Redeem an invite and download the encrypted credential. Credits are
61
+ founder-provisioned test credits; there is no payment step.
62
+ 2. Install `zk-credits` and ask your pilot contact for the pinned proving
63
+ bundle and public Base tree file. The package ships
64
+ `circuits/manifest.json`, which fixes the SHA-256 of the frozen
65
+ `private_credit_spend.wasm`, `private_credit_spend.zkey`, and
66
+ `verification_key_private_credit.json`. The sidecar never fetches proving
67
+ material at runtime. Set the paths to the downloaded files and gateway:
14
68
 
15
69
  ```bash
16
- zk-credits import-mnemonic
70
+ export ZK_CREDITS_CREDENTIAL_PATH="$HOME/Downloads/zk-credits-credential.json"
71
+ export ZK_CREDITS_ARTIFACT_DIR="/path/to/pinned-bundle"
72
+ export ZK_CREDITS_WITNESS_PATH="/path/to/base-tree.json"
73
+ export ZK_CREDITS_GATEWAY_URL="https://zk-credits-gateway.onrender.com"
17
74
  ```
18
75
 
19
- 3. Run:
76
+ A missing, relocated, symlinked-out, or altered artifact fails closed
77
+ before the first prove. A hash mismatch is a proof failure: no payment
78
+ leaves, and the local slot stays reusable.
79
+
80
+ `ZK_CREDITS_WITNESS_PATH` points to a local witness artifact — either a
81
+ prepared witness (`root`, `pathElements`, `pathIndices`) or a public tree
82
+ (`leaves: [{ index, leaf, expiry? }]`, optional `root`) from which the
83
+ sidecar derives the depth-20 path — or configure `BASE_RPC_URL`,
84
+ `BASE_PRIVATE_CREDIT_BOND_ADDRESS`, and optionally
85
+ `BASE_DEPLOYMENT_BLOCK` so the sidecar synchronizes public `BundleFunded`
86
+ events and builds a local Merkle witness. The gateway never serves a path
87
+ for a named leaf or commitment.
88
+
89
+ 3. Start setup. It verifies the credential, pinned artifact hashes, and
90
+ witness locally, then prompts for the backup password without echo:
20
91
 
21
92
  ```bash
22
- zk-credits cline "summarize this repository"
23
- zk-credits claude -p "summarize this repository"
24
- zk-credits setup codex && zk-credits codex "summarize this repository"
93
+ zk-credits setup codex
25
94
  ```
26
95
 
27
- The sidecar binds `127.0.0.1:3210` only. It does not modify `~/.cline`,
28
- `~/.claude`, or `~/.codex`.
96
+ Then run `zk-credits codex` to open Codex with the local sidecar.
97
+
98
+ The sidecar binds `127.0.0.1:3210` only. It does not modify `~/.cline` or
99
+ `~/.codex`.
100
+
101
+ ## Supported route
102
+
103
+ The pilot serves one bounded upstream spend path: non-streaming
104
+ `POST /v1/chat/completions`, either through this sidecar or through an
105
+ x402-native agent that explicitly registers the project `zk-prepaid` adapter.
106
+ The sidecar also accepts Codex `POST /v1/responses` requests with text input
107
+ and function tools, translates them to that bounded chat request, and converts
108
+ the committed result into Responses SSE events. It rejects unsupported fields
109
+ before starting a proof. Generic x402 clients, public facilitators, Bazaar,
110
+ MCP, and the standard `exact` rail are unsupported; the sidecar never falls
111
+ back to another rail.
112
+
113
+ | Route | Auth | Purpose |
114
+ | --- | --- | --- |
115
+ | `GET /health` | none | Liveness for the local launcher |
116
+ | `GET /v1/models` | local token | Codex model discovery |
117
+ | `GET /metrics` | local token | Aggregate proof metrics (below) |
118
+ | `POST /v1/chat/completions` | local token | The only proving and spend path |
119
+ | `POST /v1/responses` | local token | Codex bridge to the bounded chat spend path |
120
+
121
+ The chat route rejects streaming bodies (`stream`, `stream_options`) and a
122
+ missing `model` with a `4xx` before any proof. The Responses route requires
123
+ `stream: true`, text-only message/function-call input, and the known Codex
124
+ metadata fields; it rejects images, unsupported tools, and unknown controls
125
+ before any proof. Its upstream request is always `stream: false`. Anthropic
126
+ `/v1/messages` and unknown paths return `404 unsupported_openai_path`. The
127
+ sidecar never substitutes a model and never falls back to another rail.
128
+
129
+ ### Gateway responses and retries
130
+
131
+ | Response | Meaning | What to do |
132
+ | --- | --- | --- |
133
+ | `402` | No usable authorization, or a stale or invalid one | Re-prove against the fresh challenge |
134
+ | `409 claim_already_committed` | Your exact request already succeeded | Read `encryptedReplay` from the response, or fetch it from `POST /x402/replay`; do not re-prove |
135
+ | `409 claim_commit_ambiguous` | The commit outcome is unknown | Retry the same request; never re-prove a different one |
136
+ | `503 pilot_paused` | The operator paused the pilot | Wait. This is retryable, consumes no credit, and is not a proof failure |
137
+ | `503 provider_spend_cap_exhausted` | The provider-spend cap paused the pilot | Wait for the operator to review. No credit was consumed |
138
+ | `503 claim_store_unavailable` | Transient gateway fault | Retry with backoff |
139
+
140
+ The gateway never asks you to re-prove for its own account. A `503` is
141
+ retryable and free; a proof failure is local and never reaches the gateway.
142
+
143
+ ## Proof path
144
+
145
+ - Local artifacts only, hash-pinned against the shipped manifest.
146
+ - One prove at a time per sidecar process, in a terminable child-process
147
+ worker with a 10-second deadline. (snarkjs cannot run inside a Node
148
+ `worker_threads` worker: its `web-worker` polyfill re-enters itself there.)
149
+ - Every proof is self-verified locally with the pinned verification key
150
+ before `PAYMENT-SIGNATURE` can be emitted, and its six public signals must
151
+ exactly match `[root, timestamp, domain, requestSignal, nullifier, share]`.
152
+ - A failed attempt retries with the same slot, request signal, nonce,
153
+ response key, and gateway `issuedAt` while more than ten seconds remain in
154
+ the challenge window.
155
+ - A slot is provisional during proving and recorded in the durable local
156
+ ledger (`$ZK_CREDITS_HOME/base-slots.json`) only after self-verification.
157
+ Proof misses, timeouts, hash failures, and verification failures release
158
+ it.
159
+
160
+ ## `GET /metrics`
161
+
162
+ Aggregate only, authenticated with the same local token:
163
+
164
+ ```json
165
+ {
166
+ "attempts": 12,
167
+ "successes": 11,
168
+ "failures": 1,
169
+ "retries": 1,
170
+ "failuresByCategory": { "timeout": 1, "artifact_hash_mismatch": 0, "...": 0 },
171
+ "hotProve": { "samples": 11, "p50Ms": 1180.4, "p95Ms": 1902.7 },
172
+ "updatedAt": "2026-09-20T14:00:00.000Z"
173
+ }
174
+ ```
175
+
176
+ The first prove in a process is the cold sample and is excluded from the hot
177
+ percentiles. Proofs, public signals, nullifiers, credentials, requests, and
178
+ any identifying label are never recorded or exposed.
29
179
 
30
180
  ## Commands
31
181
 
32
182
  ```
33
183
  zk-credits cline [cline arguments...]
34
- zk-credits claude [claude arguments...]
35
184
  zk-credits setup codex [--model <model>]
36
185
  zk-credits codex [codex arguments...]
37
186
  zk-credits status
38
- zk-credits import-mnemonic
39
187
  zk-credits serve [--port <port>]
188
+ zk-credits trial-registered-adapter
40
189
  eval "$(zk-credits env)"
41
190
  ```
42
191
 
192
+ `trial-registered-adapter` is a maintainer-only internal trial command. It
193
+ requires the locally recovered credential and pinned proving bundle, prompts
194
+ for the credential password and gateway admin token without echo when they are
195
+ not already configured, and requires an explicit confirmation before sending
196
+ one billable request. It checks gateway readiness, authenticated admin status,
197
+ Base root agreement, Codex profile/sidecar status, and remaining local slot
198
+ capacity, then reports only the exchange phases and aggregate counter deltas.
199
+ Run it only after recovery is complete and with the same `ZK_CREDITS_HOME`
200
+ used by the sidecar; it registers `zk-prepaid` directly through the official
201
+ x402 client adapter and reuses the durable slot ledger.
202
+
43
203
  Codex SDK:
44
204
 
45
205
  ```ts
46
206
  import { buildCodexSdkOptions, buildCodexThreadOptions } from 'zk-credits/codex';
47
207
  ```
48
208
 
49
- `ZK_CREDITS_MNEMONIC` is for a headless process only and is not persisted.
209
+ The credential secret is decrypted only in local memory. The sidecar cache
210
+ contains public Base event leaves and never stores the secret, prompt, response,
211
+ proof, account, or wallet.
212
+
213
+ ## Validation
214
+
215
+ ```bash
216
+ npm test # unit + fixture suites
217
+ npm run build # tsc + bundled CLI
218
+ ZK_CREDITS_ARTIFACT_DIR="$PWD/circuits/artifacts" \
219
+ npx vitest run pinned-artifacts # opt-in real fullProve + self-verify
220
+ ```
221
+
222
+ The opt-in artifact test needs an installed bundle and the built
223
+ `dist/proof-child.js`. CI stays deterministic through injected worker and
224
+ crypto fixtures; the default suites stay green with no bundle installed.
50
225
 
51
- Testnet only. See the [repo README](https://github.com/mangekyou-labs/haze-api)
52
- for caveats, tree capacity, and cold-start notes.
226
+ Base Sepolia only. The circuit is experimental and not independently audited.
227
+ Development proving material is not suitable for mainnet; the repository's
228
+ release gates require an audited production circuit and ceremony.
@@ -1,16 +1,28 @@
1
1
  {
2
+ "version": 1,
3
+ "scheme": "zk-prepaid",
4
+ "network": "eip155:84532",
5
+ "circuit": {
6
+ "id": "private-credit-spend-bn254-dev",
7
+ "depth": 20,
8
+ "wasm": "private_credit_spend.wasm",
9
+ "zkey": "private_credit_spend.zkey",
10
+ "verificationKey": "verification_key_private_credit.json"
11
+ },
2
12
  "artifacts": [
3
13
  {
4
- "file": "rln_nullifier.wasm",
5
- "sha256": "38370baebbafb76562d5c94e15354c04152c388bffe1bf85faeeb4dd221538c1"
14
+ "file": "private_credit_spend.wasm",
15
+ "sha256": "64da977cdbf3105a5948a6eb82cac610979e589558ddb1cd0419c460e7735c56"
6
16
  },
7
17
  {
8
- "file": "rln_nullifier_final.zkey",
9
- "sha256": "11cd9e881437283701dcebd75be469d57ec7428de3495d229268eb75f2413cc8"
18
+ "file": "private_credit_spend.zkey",
19
+ "sha256": "3afb378d832d646a7d207b7eecbbd33cf3b041b99274cb074bbe314ac0b79291"
10
20
  },
11
21
  {
12
- "file": "verification_key_rln.json",
13
- "sha256": "9a521e0e19dc272c281f1a41abf79571bb0094cda653c890bdd95aa5b4c5f1ee"
22
+ "file": "verification_key_private_credit.json",
23
+ "sha256": "eb0068396ae6a859e95af001589936b6a1cbcdf18947dccdbade6af0099487c2"
14
24
  }
15
- ]
25
+ ],
26
+ "publicSignals": ["root", "timestamp", "domain", "requestSignal", "nullifier", "share"],
27
+ "provingMaterial": "Development-only Sepolia artifacts. Complete an external production ceremony before mainnet."
16
28
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Hash-pinned local proving artifacts.
3
+ *
4
+ * The sidecar never fetches proving material at runtime. The shipped manifest
5
+ * pins the SHA-256 of every artifact of one frozen bundle; the bytes are
6
+ * installed out of band next to the operator's state. A missing, relocated,
7
+ * swapped, or partially installed bundle fails closed before any prove.
8
+ *
9
+ * SPDX-License-Identifier: AGPL-3.0-or-later
10
+ */
11
+ export declare const ARTIFACT_HASHES: readonly ["wasm", "zkey", "verificationKey"];
12
+ export type ArtifactRole = (typeof ARTIFACT_HASHES)[number];
13
+ export type ArtifactFailure = 'artifact_remote' | 'artifact_missing' | 'artifact_escaped' | 'artifact_hash_mismatch' | 'artifact_manifest_malformed';
14
+ export declare class ArtifactBundleError extends Error {
15
+ readonly category: ArtifactFailure;
16
+ constructor(category: ArtifactFailure, message: string);
17
+ }
18
+ export interface PinnedArtifact {
19
+ file: string;
20
+ sha256: string;
21
+ }
22
+ export interface CircuitManifest {
23
+ version: number;
24
+ scheme: string;
25
+ network: string;
26
+ circuit: {
27
+ id: string;
28
+ depth: number;
29
+ wasm: string;
30
+ zkey: string;
31
+ verificationKey: string;
32
+ };
33
+ artifacts: PinnedArtifact[];
34
+ }
35
+ export interface PinnedArtifactBundle {
36
+ directory: string;
37
+ circuitId: string;
38
+ depth: number;
39
+ wasmPath: string;
40
+ zkeyPath: string;
41
+ verificationKey: unknown;
42
+ artifacts: readonly PinnedArtifact[];
43
+ }
44
+ /** Parses the shipped manifest. Unknown pins and malformed hashes fail closed. */
45
+ export declare function parseCircuitManifest(value: unknown): CircuitManifest;
46
+ /** Loads the manifest shipped inside this package unless the caller supplies one. */
47
+ export declare function loadCircuitManifest(manifestPath?: string): Promise<CircuitManifest>;
48
+ /** The frozen verification key must describe the six-signal Groth16/BN254 ABI. */
49
+ export declare function assertVerificationKeyShape(value: unknown, expectedPublicSignals: number): void;
50
+ export interface ResolvePinnedBundleOptions {
51
+ /** Operator-installed directory that holds the frozen bytes. */
52
+ artifactDirectory: string;
53
+ manifest: CircuitManifest;
54
+ /** Public outputs of the circuits, checked against the verification key. */
55
+ expectedPublicSignals?: number;
56
+ }
57
+ /** Resolves one complete, hash-matching bundle or throws before any prove. */
58
+ export declare function resolvePinnedArtifactBundle(options: ResolvePinnedBundleOptions): Promise<PinnedArtifactBundle>;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Hash-pinned local proving artifacts.
3
+ *
4
+ * The sidecar never fetches proving material at runtime. The shipped manifest
5
+ * pins the SHA-256 of every artifact of one frozen bundle; the bytes are
6
+ * installed out of band next to the operator's state. A missing, relocated,
7
+ * swapped, or partially installed bundle fails closed before any prove.
8
+ *
9
+ * SPDX-License-Identifier: AGPL-3.0-or-later
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { createReadStream } from 'node:fs';
13
+ import { readFile, realpath, stat } from 'node:fs/promises';
14
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ export const ARTIFACT_HASHES = ['wasm', 'zkey', 'verificationKey'];
17
+ export class ArtifactBundleError extends Error {
18
+ category;
19
+ constructor(category, message) {
20
+ super(message);
21
+ this.category = category;
22
+ this.name = 'ArtifactBundleError';
23
+ }
24
+ }
25
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
26
+ /** A pinned artifact is a bare file name, never a path or a remote location. */
27
+ const SAFE_FILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
28
+ const REMOTE_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u;
29
+ function isRecord(value) {
30
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
31
+ }
32
+ function requiredString(record, key) {
33
+ const value = record[key];
34
+ if (typeof value !== 'string' || value.length === 0) {
35
+ throw new ArtifactBundleError('artifact_manifest_malformed', `Circuit manifest field ${key} is required`);
36
+ }
37
+ return value;
38
+ }
39
+ function pinnedFile(value, label) {
40
+ if (typeof value !== 'string' || !SAFE_FILE_PATTERN.test(value) || value.includes('..')) {
41
+ throw new ArtifactBundleError('artifact_manifest_malformed', `Circuit manifest ${label} must be a bare file name`);
42
+ }
43
+ return value;
44
+ }
45
+ /** Parses the shipped manifest. Unknown pins and malformed hashes fail closed. */
46
+ export function parseCircuitManifest(value) {
47
+ if (!isRecord(value))
48
+ throw new ArtifactBundleError('artifact_manifest_malformed', 'Circuit manifest must be an object');
49
+ if (value.version !== 1)
50
+ throw new ArtifactBundleError('artifact_manifest_malformed', 'Unsupported circuit manifest version');
51
+ if (!isRecord(value.circuit))
52
+ throw new ArtifactBundleError('artifact_manifest_malformed', 'Circuit manifest is missing the circuit block');
53
+ const circuit = value.circuit;
54
+ const depth = circuit.depth;
55
+ if (!Number.isSafeInteger(depth) || depth <= 0) {
56
+ throw new ArtifactBundleError('artifact_manifest_malformed', 'Circuit depth must be a positive integer');
57
+ }
58
+ if (!Array.isArray(value.artifacts) || value.artifacts.length === 0) {
59
+ throw new ArtifactBundleError('artifact_manifest_malformed', 'Circuit manifest must pin at least one artifact');
60
+ }
61
+ const artifacts = value.artifacts.map((artifact, index) => {
62
+ if (!isRecord(artifact))
63
+ throw new ArtifactBundleError('artifact_manifest_malformed', `Pinned artifact ${index} must be an object`);
64
+ const file = pinnedFile(artifact.file, `artifact ${index} file`);
65
+ if (typeof artifact.sha256 !== 'string' || !SHA256_PATTERN.test(artifact.sha256)) {
66
+ throw new ArtifactBundleError('artifact_manifest_malformed', `Pinned artifact ${file} must carry a lowercase SHA-256 digest`);
67
+ }
68
+ return { file, sha256: artifact.sha256 };
69
+ });
70
+ const unique = new Set(artifacts.map((artifact) => artifact.file));
71
+ if (unique.size !== artifacts.length)
72
+ throw new ArtifactBundleError('artifact_manifest_malformed', 'Pinned artifacts must be unique');
73
+ const resolved = {
74
+ version: 1,
75
+ scheme: requiredString(value, 'scheme'),
76
+ network: requiredString(value, 'network'),
77
+ circuit: {
78
+ id: requiredString(circuit, 'id'),
79
+ depth: depth,
80
+ wasm: pinnedFile(circuit.wasm, 'wasm'),
81
+ zkey: pinnedFile(circuit.zkey, 'zkey'),
82
+ verificationKey: pinnedFile(circuit.verificationKey, 'verificationKey'),
83
+ },
84
+ artifacts,
85
+ };
86
+ const pinned = new Set(artifacts.map((artifact) => artifact.file));
87
+ for (const role of ARTIFACT_HASHES) {
88
+ const file = resolved.circuit[role];
89
+ if (!pinned.has(file)) {
90
+ throw new ArtifactBundleError('artifact_manifest_malformed', `Circuit ${role} ${file} is not pinned with a SHA-256 digest`);
91
+ }
92
+ }
93
+ return resolved;
94
+ }
95
+ /** Loads the manifest shipped inside this package unless the caller supplies one. */
96
+ export async function loadCircuitManifest(manifestPath) {
97
+ const path = manifestPath ?? resolve(dirname(fileURLToPath(import.meta.url)), '..', 'circuits', 'manifest.json');
98
+ let raw;
99
+ try {
100
+ raw = await readFile(path, 'utf8');
101
+ }
102
+ catch {
103
+ throw new ArtifactBundleError('artifact_missing', `Circuit manifest is missing at ${path}`);
104
+ }
105
+ try {
106
+ return parseCircuitManifest(JSON.parse(raw));
107
+ }
108
+ catch (error) {
109
+ if (error instanceof ArtifactBundleError)
110
+ throw error;
111
+ throw new ArtifactBundleError('artifact_manifest_malformed', 'Circuit manifest is not valid JSON');
112
+ }
113
+ }
114
+ async function sha256File(path) {
115
+ const hash = createHash('sha256');
116
+ const stream = createReadStream(path);
117
+ for await (const chunk of stream)
118
+ hash.update(chunk);
119
+ return hash.digest('hex');
120
+ }
121
+ /** The frozen verification key must describe the six-signal Groth16/BN254 ABI. */
122
+ export function assertVerificationKeyShape(value, expectedPublicSignals) {
123
+ if (!isRecord(value))
124
+ throw new ArtifactBundleError('artifact_hash_mismatch', 'Verification key must be a JSON object');
125
+ const shape = value;
126
+ if (shape.protocol !== 'groth16' || shape.curve !== 'bn128' || shape.nPublic !== expectedPublicSignals) {
127
+ throw new ArtifactBundleError('artifact_hash_mismatch', `Verification key must be Groth16 on bn128 with ${expectedPublicSignals} public signals`);
128
+ }
129
+ }
130
+ function assertLocalDirectory(artifactDirectory) {
131
+ if (typeof artifactDirectory !== 'string' || artifactDirectory.length === 0) {
132
+ throw new ArtifactBundleError('artifact_missing', 'An artifact directory is required');
133
+ }
134
+ if (REMOTE_PATTERN.test(artifactDirectory)) {
135
+ throw new ArtifactBundleError('artifact_remote', 'Proving artifacts must be installed locally, never fetched');
136
+ }
137
+ if (!isAbsolute(artifactDirectory)) {
138
+ throw new ArtifactBundleError('artifact_missing', 'Artifact directory must be an absolute local path');
139
+ }
140
+ return resolve(artifactDirectory);
141
+ }
142
+ function assertWithinDirectory(directory, candidate, file) {
143
+ if (candidate !== directory && !candidate.startsWith(`${directory}${sep}`)) {
144
+ throw new ArtifactBundleError('artifact_escaped', `Artifact ${file} resolves outside the pinned artifact directory`);
145
+ }
146
+ }
147
+ /** Resolves one complete, hash-matching bundle or throws before any prove. */
148
+ export async function resolvePinnedArtifactBundle(options) {
149
+ const directory = assertLocalDirectory(options.artifactDirectory);
150
+ const manifest = options.manifest;
151
+ let realDirectory;
152
+ try {
153
+ const info = await stat(directory);
154
+ if (!info.isDirectory())
155
+ throw new ArtifactBundleError('artifact_missing', 'Artifact directory is not a directory');
156
+ realDirectory = await realpath(directory);
157
+ }
158
+ catch (error) {
159
+ if (error instanceof ArtifactBundleError)
160
+ throw error;
161
+ throw new ArtifactBundleError('artifact_missing', `Artifact directory ${directory} is not installed`);
162
+ }
163
+ for (const artifact of manifest.artifacts) {
164
+ const path = join(directory, artifact.file);
165
+ let realFile;
166
+ try {
167
+ const info = await stat(path);
168
+ if (!info.isFile())
169
+ throw new ArtifactBundleError('artifact_missing', `Artifact ${artifact.file} is not a regular file`);
170
+ realFile = await realpath(path);
171
+ }
172
+ catch (error) {
173
+ if (error instanceof ArtifactBundleError)
174
+ throw error;
175
+ throw new ArtifactBundleError('artifact_missing', `Artifact ${artifact.file} is missing from the pinned bundle`);
176
+ }
177
+ assertWithinDirectory(realDirectory, realFile, artifact.file);
178
+ const digest = await sha256File(realFile);
179
+ if (digest !== artifact.sha256) {
180
+ throw new ArtifactBundleError('artifact_hash_mismatch', `Artifact ${artifact.file} does not match the pinned SHA-256 digest`);
181
+ }
182
+ }
183
+ const verificationKeyPath = join(directory, manifest.circuit.verificationKey);
184
+ let verificationKey;
185
+ try {
186
+ verificationKey = JSON.parse(await readFile(verificationKeyPath, 'utf8'));
187
+ }
188
+ catch {
189
+ throw new ArtifactBundleError('artifact_missing', `Verification key ${manifest.circuit.verificationKey} is not valid JSON`);
190
+ }
191
+ assertVerificationKeyShape(verificationKey, options.expectedPublicSignals ?? 6);
192
+ return {
193
+ directory,
194
+ circuitId: manifest.circuit.id,
195
+ depth: manifest.circuit.depth,
196
+ wasmPath: join(directory, manifest.circuit.wasm),
197
+ zkeyPath: join(directory, manifest.circuit.zkey),
198
+ verificationKey,
199
+ artifacts: manifest.artifacts,
200
+ };
201
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Local Base event synchronization for sidecar Merkle witnesses.
3
+ *
4
+ * BundleFunded events are public chain data. The cache contains only indexed
5
+ * leaves, block continuity, and roots; credentials and request spend metadata
6
+ * never enter it.
7
+ *
8
+ * SPDX-License-Identifier: AGPL-3.0-or-later
9
+ */
10
+ import { type Hex } from 'viem';
11
+ import type { BaseWitnessProvider } from './base-sidecar.js';
12
+ export interface BaseEventWitnessClient {
13
+ getBlockNumber(): Promise<bigint>;
14
+ getBlock(args: {
15
+ blockNumber: bigint;
16
+ }): Promise<{
17
+ hash?: Hex | null;
18
+ }>;
19
+ getLogs(args: unknown): Promise<readonly unknown[]>;
20
+ }
21
+ export interface BaseEventWitnessProviderOptions {
22
+ rpcUrl: string;
23
+ contractAddress: string;
24
+ deploymentBlock?: bigint;
25
+ confirmations?: bigint;
26
+ maxBlockRange?: bigint;
27
+ cachePath?: string;
28
+ /** Dependency injection for deterministic tests and embedded clients. */
29
+ client?: BaseEventWitnessClient;
30
+ }
31
+ /** Creates a witness provider that syncs finalized BundleFunded events. */
32
+ export declare function createBaseEventWitnessProvider(options: BaseEventWitnessProviderOptions): BaseWitnessProvider;