zk-credits 0.1.2 → 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.
- package/README.md +210 -0
- package/circuits/manifest.json +19 -7
- package/dist/artifact-bundle.d.ts +58 -0
- package/dist/artifact-bundle.js +201 -0
- package/dist/base-event-sync.d.ts +32 -0
- package/dist/base-event-sync.js +159 -0
- package/dist/base-sidecar.d.ts +79 -0
- package/dist/base-sidecar.js +416 -0
- package/dist/cli-runtime.d.ts +1 -4
- package/dist/cli-runtime.js +6 -23
- package/dist/cli.js +76 -35
- package/dist/proof-child.d.ts +23 -0
- package/dist/proof-child.js +31 -0
- package/dist/proof-coordinator.d.ts +54 -0
- package/dist/proof-coordinator.js +211 -0
- package/dist/proof-metrics.d.ts +47 -0
- package/dist/proof-metrics.js +89 -0
- package/dist/sidecar-config.d.ts +0 -1
- package/dist/sidecar-config.js +0 -1
- package/dist/sidecar.d.ts +16 -17
- package/dist/sidecar.js +47 -151
- package/dist/slot-ledger.d.ts +48 -0
- package/dist/slot-ledger.js +146 -0
- package/dist/zk-credits.js +81821 -866
- package/dist/zk-credits.js.LEGAL.txt +22 -0
- package/package.json +13 -7
- package/circuits/rln_nullifier.wasm +0 -0
- package/circuits/rln_nullifier_final.zkey +0 -0
- package/circuits/verification_key_rln.json +0 -109
- package/dist/anthropic-messages.d.ts +0 -81
- package/dist/anthropic-messages.js +0 -192
- package/dist/artifact-manifest.d.ts +0 -8
- package/dist/artifact-manifest.js +0 -29
- package/dist/claude-launcher.d.ts +0 -17
- package/dist/claude-launcher.js +0 -47
- package/dist/identity.d.ts +0 -18
- package/dist/identity.js +0 -48
- package/dist/local-prover.d.ts +0 -18
- package/dist/local-prover.js +0 -32
- package/dist/membership-client.d.ts +0 -14
- package/dist/membership-client.js +0 -66
- package/dist/ticket-ledger.d.ts +0 -22
- package/dist/ticket-ledger.js +0 -91
package/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# zk-credits
|
|
2
|
+
|
|
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.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install --global zk-credits
|
|
11
|
+
```
|
|
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
|
+
|
|
56
|
+
## First run
|
|
57
|
+
|
|
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:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
export ZK_CREDITS_ARTIFACT_DIR="$PWD/private-credit-bundle"
|
|
69
|
+
ls "$ZK_CREDITS_ARTIFACT_DIR" # the three pinned files
|
|
70
|
+
```
|
|
71
|
+
|
|
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:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
zk-credits cline "summarize this repository"
|
|
96
|
+
zk-credits setup codex && zk-credits codex "summarize this repository"
|
|
97
|
+
```
|
|
98
|
+
|
|
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.
|
|
173
|
+
|
|
174
|
+
## Commands
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
zk-credits cline [cline arguments...]
|
|
178
|
+
zk-credits setup codex [--model <model>]
|
|
179
|
+
zk-credits codex [codex arguments...]
|
|
180
|
+
zk-credits status
|
|
181
|
+
zk-credits serve [--port <port>]
|
|
182
|
+
eval "$(zk-credits env)"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Codex SDK:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { buildCodexSdkOptions, buildCodexThreadOptions } from 'zk-credits/codex';
|
|
189
|
+
```
|
|
190
|
+
|
|
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.
|
|
207
|
+
|
|
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.
|
package/circuits/manifest.json
CHANGED
|
@@ -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": "
|
|
5
|
-
"sha256": "
|
|
14
|
+
"file": "private_credit_spend.wasm",
|
|
15
|
+
"sha256": "64da977cdbf3105a5948a6eb82cac610979e589558ddb1cd0419c460e7735c56"
|
|
6
16
|
},
|
|
7
17
|
{
|
|
8
|
-
"file": "
|
|
9
|
-
"sha256": "
|
|
18
|
+
"file": "private_credit_spend.zkey",
|
|
19
|
+
"sha256": "3afb378d832d646a7d207b7eecbbd33cf3b041b99274cb074bbe314ac0b79291"
|
|
10
20
|
},
|
|
11
21
|
{
|
|
12
|
-
"file": "
|
|
13
|
-
"sha256": "
|
|
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;
|