zk-credits 0.1.3 → 0.2.0

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 (43) hide show
  1. package/README.md +172 -14
  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-sidecar.d.ts +79 -0
  8. package/dist/base-sidecar.js +416 -0
  9. package/dist/cli-runtime.d.ts +1 -4
  10. package/dist/cli-runtime.js +6 -23
  11. package/dist/cli.js +76 -35
  12. package/dist/proof-child.d.ts +23 -0
  13. package/dist/proof-child.js +31 -0
  14. package/dist/proof-coordinator.d.ts +54 -0
  15. package/dist/proof-coordinator.js +211 -0
  16. package/dist/proof-metrics.d.ts +47 -0
  17. package/dist/proof-metrics.js +89 -0
  18. package/dist/sidecar-config.d.ts +0 -1
  19. package/dist/sidecar-config.js +0 -1
  20. package/dist/sidecar.d.ts +16 -17
  21. package/dist/sidecar.js +47 -151
  22. package/dist/slot-ledger.d.ts +48 -0
  23. package/dist/slot-ledger.js +146 -0
  24. package/dist/zk-credits.js +81821 -866
  25. package/dist/zk-credits.js.LEGAL.txt +22 -0
  26. package/package.json +7 -8
  27. package/circuits/rln_nullifier.wasm +0 -0
  28. package/circuits/rln_nullifier_final.zkey +0 -0
  29. package/circuits/verification_key_rln.json +0 -109
  30. package/dist/anthropic-messages.d.ts +0 -81
  31. package/dist/anthropic-messages.js +0 -192
  32. package/dist/artifact-manifest.d.ts +0 -8
  33. package/dist/artifact-manifest.js +0 -29
  34. package/dist/claude-launcher.d.ts +0 -17
  35. package/dist/claude-launcher.js +0 -47
  36. package/dist/identity.d.ts +0 -18
  37. package/dist/identity.js +0 -48
  38. package/dist/local-prover.d.ts +0 -18
  39. package/dist/local-prover.js +0 -32
  40. package/dist/membership-client.d.ts +0 -14
  41. package/dist/membership-client.js +0 -66
  42. package/dist/ticket-ledger.d.ts +0 -22
  43. package/dist/ticket-ledger.js +0 -91
package/README.md CHANGED
@@ -1,41 +1,183 @@
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
10
  npm install --global zk-credits
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 a breaking release against the Base Sepolia pilot: it
25
+ speaks only the non-streaming `POST /v1/chat/completions` service class below
26
+ and carries no Stellar or evaluation path. The proving artifacts
27
+ (`private-credit-spend-bn254-dev-sepolia-v1`) are delivered directly through
28
+ the invite channel, never from a public host.
29
+
30
+ Verify the shipped artifacts before your first prove. The package's
31
+ `circuits/manifest.json` fixes the SHA-256 of the frozen
32
+ `private_credit_spend.wasm`, `private_credit_spend.zkey`, and
33
+ `verification_key_private_credit.json`; compute the digest of each delivered
34
+ file and compare it to the manifest. A mismatch is a proof failure, and the
35
+ sidecar refuses to prove rather than sending a payment. The founder compares
36
+ the same digests against the delivered set before inviting you.
37
+
38
+ ## What one credit buys
39
+
40
+ One credit buys one successfully committed response from the single service
41
+ class `coding-deepseek-v4-flash-v1` (`deepseek/deepseek-v4-flash`). It is not
42
+ an arbitrary API call, and the gateway enforces the class before it reserves
43
+ the credit:
44
+
45
+ - text messages, tool definitions, and tool calls, with one generated choice;
46
+ - no streaming, images, files, audio, web plugins, model fallback,
47
+ client-selected routing, or unknown cost-affecting fields;
48
+ - input capped at 16,000 UTF-8 bytes (counted conservatively, never below the
49
+ provider's token count), output at 4,000 tokens, request body at 256 KiB,
50
+ encrypted replay at 1 MiB, and upstream timeout at 120 seconds.
51
+
52
+ The requested `model` field is ignored: the gateway always dispatches the
53
+ class model, so an OpenAI-compatible client configured with any model name
54
+ still receives the class. A rejected request consumes no credit.
55
+
9
56
  ## First run
10
57
 
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):
58
+ 1. Redeem an invite to the unpaid, experimental Base Sepolia pilot and
59
+ download the password-encrypted credential backup. Credits are
60
+ founder-provisioned test credits; there is no payment step.
61
+ 2. Install the pinned proving artifacts. The package ships
62
+ `circuits/manifest.json`, which fixes the SHA-256 of the frozen
63
+ `private_credit_spend.wasm`, `private_credit_spend.zkey`, and
64
+ `verification_key_private_credit.json`. The bytes are installed out of
65
+ band; the sidecar never fetches proving material at runtime:
14
66
 
15
67
  ```bash
16
- zk-credits import-mnemonic
68
+ export ZK_CREDITS_ARTIFACT_DIR="$PWD/private-credit-bundle"
69
+ ls "$ZK_CREDITS_ARTIFACT_DIR" # the three pinned files
17
70
  ```
18
71
 
19
- 3. Run:
72
+ A missing, relocated, symlinked-out, or altered artifact fails closed
73
+ before the first prove. A hash mismatch is a proof failure: no payment
74
+ leaves, and the local slot stays reusable.
75
+
76
+ 3. Configure the credential and a witness source:
77
+
78
+ ```bash
79
+ export ZK_CREDITS_CREDENTIAL_PATH="$PWD/credential.json"
80
+ export ZK_CREDITS_CREDENTIAL_PASSWORD='use-a-long-local-password'
81
+ ```
82
+
83
+ Set `ZK_CREDITS_WITNESS_PATH` to a local witness artifact — either a
84
+ prepared witness (`root`, `pathElements`, `pathIndices`) or a public tree
85
+ (`leaves: [{ index, leaf, expiry? }]`, optional `root`) from which the
86
+ sidecar derives the depth-20 path — or configure `BASE_RPC_URL`,
87
+ `BASE_PRIVATE_CREDIT_BOND_ADDRESS`, and optionally
88
+ `BASE_DEPLOYMENT_BLOCK` so the sidecar synchronizes public `BundleFunded`
89
+ events and builds a local Merkle witness. The gateway never serves a path
90
+ for a named leaf or commitment.
91
+
92
+ 4. Run:
20
93
 
21
94
  ```bash
22
95
  zk-credits cline "summarize this repository"
23
- zk-credits claude -p "summarize this repository"
24
96
  zk-credits setup codex && zk-credits codex "summarize this repository"
25
97
  ```
26
98
 
27
- The sidecar binds `127.0.0.1:3210` only. It does not modify `~/.cline`,
28
- `~/.claude`, or `~/.codex`.
99
+ The sidecar binds `127.0.0.1:3210` only. It does not modify `~/.cline` or
100
+ `~/.codex`.
101
+
102
+ ## Supported route
103
+
104
+ The pilot serves one spend path: non-streaming `POST /v1/chat/completions`
105
+ on the loopback listener, either through this sidecar (the supported
106
+ OpenAI-compatible client) or through an x402-native agent that explicitly
107
+ registers the project `zk-prepaid` adapter. Generic x402 clients, unmodified
108
+ agents, public facilitators, Bazaar, MCP, and the standard `exact` rail are
109
+ unsupported; the sidecar never falls back to another rail.
110
+
111
+ | Route | Auth | Purpose |
112
+ | --- | --- | --- |
113
+ | `GET /health` | none | Liveness for the local launcher |
114
+ | `GET /v1/models` | local token | Codex model discovery |
115
+ | `GET /metrics` | local token | Aggregate proof metrics (below) |
116
+ | `POST /v1/chat/completions` | local token | The only proving and spend path |
117
+
118
+ `/v1/responses`, Anthropic `/v1/messages`, streaming bodies (`stream`,
119
+ `stream_options`), and a missing `model` are rejected with a `4xx` before any
120
+ proof is attempted. Unknown paths return `404 unsupported_openai_path`. The
121
+ sidecar never substitutes a model and never falls back to another rail.
122
+
123
+ ### Gateway responses and retries
124
+
125
+ | Response | Meaning | What to do |
126
+ | --- | --- | --- |
127
+ | `402` | No usable authorization, or a stale or invalid one | Re-prove against the fresh challenge |
128
+ | `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 |
129
+ | `409 claim_commit_ambiguous` | The commit outcome is unknown | Retry the same request; never re-prove a different one |
130
+ | `503 pilot_paused` | The operator paused the pilot | Wait. This is retryable, consumes no credit, and is not a proof failure |
131
+ | `503 provider_spend_cap_exhausted` | The provider-spend cap paused the pilot | Wait for the operator to review. No credit was consumed |
132
+ | `503 claim_store_unavailable` | Transient gateway fault | Retry with backoff |
133
+
134
+ The gateway never asks you to re-prove for its own account. A `503` is
135
+ retryable and free; a proof failure is local and never reaches the gateway.
136
+
137
+ ## Proof path
138
+
139
+ - Local artifacts only, hash-pinned against the shipped manifest.
140
+ - One prove at a time per sidecar process, in a terminable child-process
141
+ worker with a 10-second deadline. (snarkjs cannot run inside a Node
142
+ `worker_threads` worker: its `web-worker` polyfill re-enters itself there.)
143
+ - Every proof is self-verified locally with the pinned verification key
144
+ before `PAYMENT-SIGNATURE` can be emitted, and its six public signals must
145
+ exactly match `[root, timestamp, domain, requestSignal, nullifier, share]`.
146
+ - A failed attempt retries with the same slot, request signal, nonce,
147
+ response key, and gateway `issuedAt` while more than ten seconds remain in
148
+ the challenge window.
149
+ - A slot is provisional during proving and recorded in the durable local
150
+ ledger (`$ZK_CREDITS_HOME/base-slots.json`) only after self-verification.
151
+ Proof misses, timeouts, hash failures, and verification failures release
152
+ it.
153
+
154
+ ## `GET /metrics`
155
+
156
+ Aggregate only, authenticated with the same local token:
157
+
158
+ ```json
159
+ {
160
+ "attempts": 12,
161
+ "successes": 11,
162
+ "failures": 1,
163
+ "retries": 1,
164
+ "failuresByCategory": { "timeout": 1, "artifact_hash_mismatch": 0, "...": 0 },
165
+ "hotProve": { "samples": 11, "p50Ms": 1180.4, "p95Ms": 1902.7 },
166
+ "updatedAt": "2026-09-20T14:00:00.000Z"
167
+ }
168
+ ```
169
+
170
+ The first prove in a process is the cold sample and is excluded from the hot
171
+ percentiles. Proofs, public signals, nullifiers, credentials, requests, and
172
+ any identifying label are never recorded or exposed.
29
173
 
30
174
  ## Commands
31
175
 
32
176
  ```
33
177
  zk-credits cline [cline arguments...]
34
- zk-credits claude [claude arguments...]
35
178
  zk-credits setup codex [--model <model>]
36
179
  zk-credits codex [codex arguments...]
37
180
  zk-credits status
38
- zk-credits import-mnemonic
39
181
  zk-credits serve [--port <port>]
40
182
  eval "$(zk-credits env)"
41
183
  ```
@@ -46,7 +188,23 @@ Codex SDK:
46
188
  import { buildCodexSdkOptions, buildCodexThreadOptions } from 'zk-credits/codex';
47
189
  ```
48
190
 
49
- `ZK_CREDITS_MNEMONIC` is for a headless process only and is not persisted.
191
+ The credential secret is decrypted only in local memory. The sidecar cache
192
+ contains public Base event leaves and never stores the secret, prompt, response,
193
+ proof, account, or wallet.
194
+
195
+ ## Validation
196
+
197
+ ```bash
198
+ npm test # unit + fixture suites
199
+ npm run build # tsc + bundled CLI
200
+ ZK_CREDITS_ARTIFACT_DIR="$PWD/circuits/artifacts" \
201
+ npx vitest run pinned-artifacts # opt-in real fullProve + self-verify
202
+ ```
203
+
204
+ The opt-in artifact test needs an installed bundle and the built
205
+ `dist/proof-child.js`. CI stays deterministic through injected worker and
206
+ crypto fixtures; the default suites stay green with no bundle installed.
50
207
 
51
- Testnet only. See the [repo README](https://github.com/mangekyou-labs/haze-api)
52
- for caveats, tree capacity, and cold-start notes.
208
+ Base Sepolia only. The circuit is experimental and not independently audited.
209
+ Development proving material is not suitable for mainnet; the repository's
210
+ 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;