zk-credits 0.2.2 → 0.2.3

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 CHANGED
@@ -7,7 +7,7 @@ are founder-provisioned test credits, and the circuit is not independently
7
7
  audited.
8
8
 
9
9
  ```bash
10
- npm install --global zk-credits@latest
10
+ npm install --global zk-credits@0.2.3
11
11
  ```
12
12
 
13
13
  ## Pinned pilot versions
@@ -17,19 +17,17 @@ not mix versions between the sidecar, the adapter package, and the gateway:
17
17
 
18
18
  | Package | Version |
19
19
  | --- | --- |
20
- | `zk-credits` (this sidecar) | `0.2.1` |
20
+ | `zk-credits` (this sidecar) | `0.2.3` |
21
21
  | `@zk-credits/x402-zk-prepaid` (adapter) | `0.1.0` |
22
22
  | `@zk-credits/shared` | `0.1.0` |
23
23
 
24
- `zk-credits@0.2.1` is the currently published Base Sepolia pilot version. It
25
- uses the non-streaming `POST /v1/chat/completions` path below and includes the
26
- loopback Responses bridge for Codex. The local `0.2.2` candidate adds local
27
- discovery of the pinned proving bundle and witness, validates them against the
28
- activated credential, and saves their paths for later runs. It is not yet
29
- published or part of the pinned pilot set. Neither version carries a Stellar
30
- or evaluation path. The proving artifacts
31
- (`private-credit-spend-bn254-dev-sepolia-v1`) are delivered directly through
32
- the invite channel, never from a public host.
24
+ The pinned Base Sepolia trial version is `0.2.3`. It uses the non-streaming
25
+ `POST /v1/chat/completions` path below, includes the loopback Responses bridge
26
+ for Codex, and acquires the pinned private proving bundle through the local
27
+ GitHub CLI session. It also synchronizes V2 Base events and checks the gateway
28
+ known root before local setup completes. Neither this version nor the adapter
29
+ supports a Stellar or evaluation path. The proving artifacts are available
30
+ only from the private immutable release pinned by `circuits/manifest.json`.
33
31
 
34
32
  Verify the shipped artifacts before your first prove. The package's
35
33
  `circuits/manifest.json` fixes the SHA-256 of the frozen
@@ -59,46 +57,45 @@ still receives the class. A rejected request consumes no credit.
59
57
 
60
58
  ## First run
61
59
 
62
- 1. Redeem an invite and download the encrypted credential. Credits are
63
- founder-provisioned test credits; there is no payment step.
64
- 2. Install `zk-credits` and ask your pilot contact for the pinned proving
65
- bundle and public Base tree file. Put them in
66
- `~/.zk-credits/artifacts/base-sepolia` and `~/.zk-credits/witnesses`, or
67
- place them in `~/Downloads`. The package ships
68
- `circuits/manifest.json`, which fixes the SHA-256 of the frozen
69
- `private_credit_spend.wasm`, `private_credit_spend.zkey`, and
70
- `verification_key_private_credit.json`. The sidecar never fetches proving
71
- material at runtime. Set the credential and gateway:
60
+ 1. Redeem an invite and download the encrypted V2 credential export. Credits
61
+ are founder-provisioned test credits; there is no payment step.
62
+ 2. Ask the pilot contact for read access to the private bundle repository
63
+ `mangekyou-labs/zk-credits-base-sepolia-v2-bundle`, then authenticate the
64
+ GitHub CLI on this machine with `gh auth login`. The sidecar uses that local
65
+ GitHub CLI session to read the immutable release identified in
66
+ `circuits/manifest.json`; it never receives or stores your GitHub token.
67
+ Set the credential, gateway, and Base Sepolia RPC endpoint:
72
68
 
73
69
  ```bash
74
70
  export ZK_CREDITS_CREDENTIAL_PATH="$HOME/Downloads/zk-credits-credential.json"
75
71
  export ZK_CREDITS_GATEWAY_URL="https://zk-credits-gateway.onrender.com"
72
+ export BASE_RPC_URL="https://sepolia.base.org"
76
73
  ```
77
74
 
78
- `zk-credits setup codex` searches those local folders, validates the bundle
79
- hashes and witness for the activated credential, then saves the selected
80
- local paths in `~/.zk-credits/config.json`. Set
81
- `ZK_CREDITS_ARTIFACT_DIR` or `ZK_CREDITS_WITNESS_PATH` to select explicit
82
- paths. Alternatively, set `BASE_RPC_URL` and
83
- `BASE_PRIVATE_CREDIT_BOND_ADDRESS` (and optionally
84
- `BASE_DEPLOYMENT_BLOCK`) to derive a witness from public Base events. Event
85
- sync requires RPC access and is validated locally before setup completes.
86
- The config file is owner-readable only and never stores the credential
87
- password. If an RPC URL contains a provider key, it remains in that local
88
- config file.
75
+ `zk-credits setup codex` searches the standard local folders first. If it
76
+ finds no valid bundle, it downloads the pinned release, verifies the
77
+ release id, tag, immutability, archive digest, and all three file digests,
78
+ then atomically installs the bundle under `~/.zk-credits/artifacts` with
79
+ owner-only permissions. Missing GitHub access or any mismatch stops setup.
80
+ It syncs public Base events from the pinned V2 bond and deployment block,
81
+ validates that the resulting witness resolves the activated credential,
82
+ and asks the gateway whether the resulting root is known. The witness and
83
+ event cache remain local. The package does not accept a supplied witness
84
+ file for this V2 deployment.
85
+
86
+ The package ships `circuits/manifest.json`, which fixes the immutable
87
+ release identity, the SHA-256 of the archive and each proving artifact, and
88
+ the V2 Base deployment pins. `BASE_PRIVATE_CREDIT_BOND_ADDRESS` and
89
+ `BASE_DEPLOYMENT_BLOCK` are optional checks; if set, they must match the
90
+ manifest. V2 setup rejects `ZK_CREDITS_WITNESS_PATH` because it builds the
91
+ witness from the pinned public Base events. The local setup config is
92
+ owner-readable only and never stores the credential password. If the RPC
93
+ URL contains a provider key, it remains in that local config file.
89
94
 
90
95
  A missing, relocated, symlinked-out, or altered artifact fails closed
91
96
  before the first prove. A hash mismatch is a proof failure: no payment
92
- leaves, and the local slot stays reusable.
93
-
94
- An explicit `ZK_CREDITS_WITNESS_PATH` points to a local witness artifact —
95
- either a prepared witness (`root`, `pathElements`, `pathIndices`) or a
96
- public tree (`leaves: [{ index, leaf, expiry? }]`, optional `root`) from
97
- which the sidecar derives the depth-20 path — or configure `BASE_RPC_URL`,
98
- `BASE_PRIVATE_CREDIT_BOND_ADDRESS`, and optionally `BASE_DEPLOYMENT_BLOCK`
99
- so the sidecar synchronizes public `BundleFunded` events and builds a local
100
- Merkle witness. The gateway never serves a path for a named leaf or
101
- commitment.
97
+ leaves, and the local slot stays reusable. The gateway never serves a path
98
+ for a named leaf or commitment.
102
99
 
103
100
  3. Start setup. It verifies the credential, pinned artifact hashes, and
104
101
  witness locally, then prompts for the backup password without echo:
@@ -16,13 +16,30 @@
16
16
  },
17
17
  {
18
18
  "file": "private_credit_spend.zkey",
19
- "sha256": "3afb378d832d646a7d207b7eecbbd33cf3b041b99274cb074bbe314ac0b79291"
19
+ "sha256": "7cc83e6df3f0c805d8b49d4063ee56a93123ff19acd49d639ff263588ef7a144"
20
20
  },
21
21
  {
22
22
  "file": "verification_key_private_credit.json",
23
- "sha256": "eb0068396ae6a859e95af001589936b6a1cbcdf18947dccdbade6af0099487c2"
23
+ "sha256": "4844820509c32722c0c867f028a713df8a1b6478ff7376a80861e7dcbc9c51fc"
24
24
  }
25
25
  ],
26
+ "release": {
27
+ "repository": "mangekyou-labs/zk-credits-base-sepolia-v2-bundle",
28
+ "tag": "v2.0.0",
29
+ "releaseId": 397705951,
30
+ "assetName": "base-sepolia-v2.tar.gz",
31
+ "sha256": "dc60b3109b3d907e2b9e8c4253fb52e77400d763eb443968e8de3af6b7e0b709"
32
+ },
33
+ "deployment": {
34
+ "chainId": 84532,
35
+ "bondAddress": "0xcc1909dD30485d6D9a46e3d33cf04C1378b586Ab",
36
+ "deploymentBlock": "47372040",
37
+ "deploymentDomain": "84532",
38
+ "circuitId": "private-credit-spend-bn254-dev",
39
+ "verifyingKeyId": "private-credit-spend-vk-dev-sepolia-v2",
40
+ "spendVerifierAddress": "0x37663bC461AB8EccD3b7D69Bde7eeF13D489723e",
41
+ "groth16VerifierAddress": "0x5870117EA7ACb28B3f36fb64284b77c4eD2B7148"
42
+ },
26
43
  "publicSignals": ["root", "timestamp", "domain", "requestSignal", "nullifier", "share"],
27
- "provingMaterial": "Development-only Sepolia artifacts. Complete an external production ceremony before mainnet."
44
+ "provingMaterial": "Development-only Sepolia artifacts, with one local phase-2 contribution. Complete an external production ceremony before mainnet."
28
45
  }
@@ -19,6 +19,23 @@ export interface PinnedArtifact {
19
19
  file: string;
20
20
  sha256: string;
21
21
  }
22
+ export interface PinnedArtifactRelease {
23
+ repository: string;
24
+ tag: string;
25
+ releaseId: number;
26
+ assetName: string;
27
+ sha256: string;
28
+ }
29
+ export interface PinnedDeployment {
30
+ chainId: number;
31
+ bondAddress: string;
32
+ deploymentBlock: string;
33
+ deploymentDomain: string;
34
+ circuitId: string;
35
+ verifyingKeyId: string;
36
+ spendVerifierAddress: string;
37
+ groth16VerifierAddress: string;
38
+ }
22
39
  export interface CircuitManifest {
23
40
  version: number;
24
41
  scheme: string;
@@ -31,6 +48,8 @@ export interface CircuitManifest {
31
48
  verificationKey: string;
32
49
  };
33
50
  artifacts: PinnedArtifact[];
51
+ release?: PinnedArtifactRelease;
52
+ deployment?: PinnedDeployment;
34
53
  }
35
54
  export interface PinnedArtifactBundle {
36
55
  directory: string;
@@ -26,6 +26,8 @@ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
26
26
  /** A pinned artifact is a bare file name, never a path or a remote location. */
27
27
  const SAFE_FILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
28
28
  const REMOTE_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u;
29
+ const ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/u;
30
+ const RELEASE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u;
29
31
  function isRecord(value) {
30
32
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
31
33
  }
@@ -51,6 +53,7 @@ export function parseCircuitManifest(value) {
51
53
  if (!isRecord(value.circuit))
52
54
  throw new ArtifactBundleError('artifact_manifest_malformed', 'Circuit manifest is missing the circuit block');
53
55
  const circuit = value.circuit;
56
+ const network = requiredString(value, 'network');
54
57
  const depth = circuit.depth;
55
58
  if (!Number.isSafeInteger(depth) || depth <= 0) {
56
59
  throw new ArtifactBundleError('artifact_manifest_malformed', 'Circuit depth must be a positive integer');
@@ -73,7 +76,7 @@ export function parseCircuitManifest(value) {
73
76
  const resolved = {
74
77
  version: 1,
75
78
  scheme: requiredString(value, 'scheme'),
76
- network: requiredString(value, 'network'),
79
+ network,
77
80
  circuit: {
78
81
  id: requiredString(circuit, 'id'),
79
82
  depth: depth,
@@ -82,7 +85,12 @@ export function parseCircuitManifest(value) {
82
85
  verificationKey: pinnedFile(circuit.verificationKey, 'verificationKey'),
83
86
  },
84
87
  artifacts,
88
+ ...(value.release === undefined ? {} : { release: parseRelease(value.release) }),
89
+ ...(value.deployment === undefined ? {} : { deployment: parseDeployment(value.deployment, requiredString(circuit, 'id')) }),
85
90
  };
91
+ if (resolved.deployment && network !== 'eip155:84532') {
92
+ throw new ArtifactBundleError('artifact_manifest_malformed', 'Pinned V2 deployment must use Base Sepolia');
93
+ }
86
94
  const pinned = new Set(artifacts.map((artifact) => artifact.file));
87
95
  for (const role of ARTIFACT_HASHES) {
88
96
  const file = resolved.circuit[role];
@@ -92,6 +100,58 @@ export function parseCircuitManifest(value) {
92
100
  }
93
101
  return resolved;
94
102
  }
103
+ function parseRelease(value) {
104
+ if (!isRecord(value)
105
+ || typeof value.repository !== 'string'
106
+ || !RELEASE_REPOSITORY_PATTERN.test(value.repository)
107
+ || typeof value.tag !== 'string'
108
+ || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value.tag)
109
+ || !Number.isSafeInteger(value.releaseId)
110
+ || value.releaseId <= 0
111
+ || typeof value.assetName !== 'string'
112
+ || !SAFE_FILE_PATTERN.test(value.assetName)
113
+ || value.assetName.includes('..')
114
+ || typeof value.sha256 !== 'string'
115
+ || !SHA256_PATTERN.test(value.sha256)) {
116
+ throw new ArtifactBundleError('artifact_manifest_malformed', 'Circuit manifest release pin is malformed');
117
+ }
118
+ return {
119
+ repository: value.repository,
120
+ tag: value.tag,
121
+ releaseId: value.releaseId,
122
+ assetName: value.assetName,
123
+ sha256: value.sha256,
124
+ };
125
+ }
126
+ function parseDeployment(value, circuitId) {
127
+ if (!isRecord(value)
128
+ || value.chainId !== 84532
129
+ || typeof value.bondAddress !== 'string'
130
+ || !ADDRESS_PATTERN.test(value.bondAddress)
131
+ || typeof value.deploymentBlock !== 'string'
132
+ || !/^\d+$/u.test(value.deploymentBlock)
133
+ || typeof value.deploymentDomain !== 'string'
134
+ || value.deploymentDomain !== '84532'
135
+ || value.circuitId !== circuitId
136
+ || typeof value.verifyingKeyId !== 'string'
137
+ || value.verifyingKeyId.length === 0
138
+ || typeof value.spendVerifierAddress !== 'string'
139
+ || !ADDRESS_PATTERN.test(value.spendVerifierAddress)
140
+ || typeof value.groth16VerifierAddress !== 'string'
141
+ || !ADDRESS_PATTERN.test(value.groth16VerifierAddress)) {
142
+ throw new ArtifactBundleError('artifact_manifest_malformed', 'Circuit manifest V2 deployment pin is malformed');
143
+ }
144
+ return {
145
+ chainId: 84532,
146
+ bondAddress: value.bondAddress,
147
+ deploymentBlock: value.deploymentBlock,
148
+ deploymentDomain: value.deploymentDomain,
149
+ circuitId,
150
+ verifyingKeyId: value.verifyingKeyId,
151
+ spendVerifierAddress: value.spendVerifierAddress,
152
+ groth16VerifierAddress: value.groth16VerifierAddress,
153
+ };
154
+ }
95
155
  /** Loads the manifest shipped inside this package unless the caller supplies one. */
96
156
  export async function loadCircuitManifest(manifestPath) {
97
157
  const path = manifestPath ?? resolve(dirname(fileURLToPath(import.meta.url)), '..', 'circuits', 'manifest.json');
@@ -0,0 +1,10 @@
1
+ /** Authenticated, immutable-release acquisition for local proving material. */
2
+ import type { CircuitManifest } from './artifact-bundle.js';
3
+ export type GithubCommand = (arguments_: readonly string[]) => Promise<string>;
4
+ export interface AcquirePinnedBundleOptions {
5
+ manifest: CircuitManifest;
6
+ targetDirectory: string;
7
+ runGithubCommand?: GithubCommand;
8
+ }
9
+ /** Fetches a pinned private GitHub release and atomically installs its safe USTAR bundle. */
10
+ export declare function acquirePinnedArtifactBundle(options: AcquirePinnedBundleOptions): Promise<string>;
@@ -0,0 +1,243 @@
1
+ /** Authenticated, immutable-release acquisition for local proving material. */
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { execFile as nodeExecFile } from 'node:child_process';
4
+ import { gunzipSync } from 'node:zlib';
5
+ import { promisify } from 'node:util';
6
+ import { chmod, lstat, mkdir, open, readFile, rename, rm, } from 'node:fs/promises';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { resolvePinnedArtifactBundle } from './artifact-bundle.js';
9
+ const execFile = promisify(nodeExecFile);
10
+ const MAX_COMPRESSED_BYTES = 20 * 1024 * 1024;
11
+ const MAX_EXPANDED_BYTES = 32 * 1024 * 1024;
12
+ const BLOCK_BYTES = 512;
13
+ const SAFE_TAR_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
14
+ function digest(bytes) {
15
+ return createHash('sha256').update(bytes).digest('hex');
16
+ }
17
+ function zeroBlock(block) {
18
+ return block.every((byte) => byte === 0);
19
+ }
20
+ function tarText(header, start, length, label) {
21
+ const bytes = header.subarray(start, start + length);
22
+ const terminator = bytes.indexOf(0);
23
+ const value = bytes.subarray(0, terminator < 0 ? bytes.length : terminator);
24
+ if (!value.every((byte) => byte >= 0x20 && byte <= 0x7e)) {
25
+ throw new Error('Pinned proving bundle archive contains an invalid ' + label);
26
+ }
27
+ return value.toString('ascii');
28
+ }
29
+ function tarOctal(header, start, length, label) {
30
+ const value = tarText(header, start, length, label).trim();
31
+ if (!value)
32
+ return 0;
33
+ if (!/^[0-7]+$/u.test(value))
34
+ throw new Error('Pinned proving bundle archive has an invalid ' + label);
35
+ const parsed = Number.parseInt(value, 8);
36
+ if (!Number.isSafeInteger(parsed) || parsed < 0)
37
+ throw new Error('Pinned proving bundle archive has an invalid ' + label);
38
+ return parsed;
39
+ }
40
+ function parseTar(archive, allowedNames) {
41
+ if (archive.length === 0 || archive.length > MAX_EXPANDED_BYTES || archive.length % BLOCK_BYTES !== 0) {
42
+ throw new Error('Pinned proving bundle archive has an invalid size');
43
+ }
44
+ const files = new Map();
45
+ let offset = 0;
46
+ let endBlocks = 0;
47
+ while (offset + BLOCK_BYTES <= archive.length) {
48
+ const header = archive.subarray(offset, offset + BLOCK_BYTES);
49
+ if (zeroBlock(header)) {
50
+ endBlocks += 1;
51
+ offset += BLOCK_BYTES;
52
+ if (endBlocks === 2)
53
+ break;
54
+ continue;
55
+ }
56
+ if (endBlocks > 0)
57
+ throw new Error('Pinned proving bundle archive has a malformed end marker');
58
+ const recordedChecksum = tarOctal(header, 148, 8, 'header checksum');
59
+ const checksumHeader = Buffer.from(header);
60
+ checksumHeader.fill(0x20, 148, 156);
61
+ const actualChecksum = checksumHeader.reduce((sum, byte) => sum + byte, 0);
62
+ if (recordedChecksum !== actualChecksum)
63
+ throw new Error('Pinned proving bundle archive failed its header checksum');
64
+ const magic = header.subarray(257, 263).toString('ascii');
65
+ if (magic !== 'ustar\0' && magic !== 'ustar ') {
66
+ throw new Error('Pinned proving bundle archive must use the USTAR format');
67
+ }
68
+ const name = tarText(header, 0, 100, 'entry name');
69
+ const prefix = tarText(header, 345, 155, 'entry prefix');
70
+ if (!name || prefix || !SAFE_TAR_NAME.test(name) || name.includes('..') || !allowedNames.has(name)) {
71
+ throw new Error('Pinned proving bundle archive contains an unexpected entry');
72
+ }
73
+ const type = header[156];
74
+ if (type !== 0 && type !== 0x30)
75
+ throw new Error('Pinned proving bundle archive contains a non-file entry');
76
+ if (tarText(header, 157, 100, 'link target'))
77
+ throw new Error('Pinned proving bundle archive contains a link');
78
+ if (files.has(name))
79
+ throw new Error('Pinned proving bundle archive contains a duplicate entry');
80
+ const size = tarOctal(header, 124, 12, 'entry size');
81
+ const dataStart = offset + BLOCK_BYTES;
82
+ const dataEnd = dataStart + size;
83
+ const paddedEnd = dataStart + Math.ceil(size / BLOCK_BYTES) * BLOCK_BYTES;
84
+ if (size <= 0 || dataEnd > archive.length || paddedEnd > archive.length) {
85
+ throw new Error('Pinned proving bundle archive entry exceeds its bounds');
86
+ }
87
+ if (!zeroBlock(archive.subarray(dataEnd, paddedEnd))) {
88
+ throw new Error('Pinned proving bundle archive has invalid entry padding');
89
+ }
90
+ files.set(name, Buffer.from(archive.subarray(dataStart, dataEnd)));
91
+ offset = paddedEnd;
92
+ }
93
+ if (endBlocks !== 2)
94
+ throw new Error('Pinned proving bundle archive is missing its end marker');
95
+ if (!zeroBlock(archive.subarray(offset)))
96
+ throw new Error('Pinned proving bundle archive has trailing data');
97
+ if (files.size !== allowedNames.size || [...allowedNames].some((name) => !files.has(name))) {
98
+ throw new Error('Pinned proving bundle archive is incomplete');
99
+ }
100
+ return files;
101
+ }
102
+ async function defaultGithubCommand(arguments_) {
103
+ try {
104
+ const { stdout } = await execFile('gh', [...arguments_], {
105
+ encoding: 'utf8',
106
+ timeout: 60_000,
107
+ maxBuffer: 2 * 1024 * 1024,
108
+ windowsHide: true,
109
+ });
110
+ return stdout;
111
+ }
112
+ catch {
113
+ throw new Error('Pinned proving release could not be read; authenticate with gh and confirm repository access.');
114
+ }
115
+ }
116
+ function pinnedAsset(release, raw) {
117
+ let value;
118
+ try {
119
+ value = JSON.parse(raw);
120
+ }
121
+ catch {
122
+ throw new Error('Pinned proving release metadata is invalid');
123
+ }
124
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
125
+ throw new Error('Pinned proving release metadata is invalid');
126
+ }
127
+ const record = value;
128
+ const assets = Array.isArray(record.assets) ? record.assets : [];
129
+ const asset = assets.find((entry) => entry && typeof entry === 'object' && !Array.isArray(entry)
130
+ && entry.name === release.assetName);
131
+ if (record.id !== release.releaseId
132
+ || record.tag_name !== release.tag
133
+ || record.draft !== false
134
+ || record.immutable !== true
135
+ || !asset
136
+ || (typeof asset.digest === 'string' && asset.digest !== 'sha256:' + release.sha256)) {
137
+ throw new Error('Pinned proving release identity or immutability check failed');
138
+ }
139
+ }
140
+ async function writeBundleFile(directory, name, bytes) {
141
+ const file = await open(join(directory, name), 'wx', 0o600);
142
+ try {
143
+ await file.writeFile(bytes);
144
+ await file.sync();
145
+ }
146
+ finally {
147
+ await file.close();
148
+ }
149
+ }
150
+ async function replaceDirectoryAtomically(stage, target) {
151
+ const parent = dirname(target);
152
+ await mkdir(parent, { recursive: true, mode: 0o700 });
153
+ const backup = join(parent, '.base-sepolia-old-' + randomUUID());
154
+ let movedOld = false;
155
+ try {
156
+ try {
157
+ const existing = await lstat(target);
158
+ if (existing.isSymbolicLink() || !existing.isDirectory()) {
159
+ throw new Error('Local proving bundle destination must be a regular directory');
160
+ }
161
+ await rename(target, backup);
162
+ movedOld = true;
163
+ }
164
+ catch (error) {
165
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
166
+ // No previous bundle is a normal first-run case.
167
+ }
168
+ else {
169
+ throw error;
170
+ }
171
+ }
172
+ try {
173
+ await rename(stage, target);
174
+ await chmod(target, 0o700);
175
+ }
176
+ catch (error) {
177
+ if (movedOld)
178
+ await rename(backup, target);
179
+ throw error;
180
+ }
181
+ if (movedOld)
182
+ await rm(backup, { recursive: true, force: true }).catch(() => undefined);
183
+ }
184
+ finally {
185
+ await rm(stage, { recursive: true, force: true });
186
+ }
187
+ }
188
+ /** Fetches a pinned private GitHub release and atomically installs its safe USTAR bundle. */
189
+ export async function acquirePinnedArtifactBundle(options) {
190
+ const release = options.manifest.release;
191
+ if (!release)
192
+ throw new Error('This sidecar manifest has no pinned artifact release');
193
+ const names = new Set(options.manifest.artifacts.map((artifact) => artifact.file));
194
+ const command = options.runGithubCommand ?? defaultGithubCommand;
195
+ const parent = dirname(resolve(options.targetDirectory));
196
+ const tempDirectory = join(parent, '.base-sepolia-download-' + randomUUID());
197
+ const stage = join(parent, '.base-sepolia-stage-' + randomUUID());
198
+ try {
199
+ await mkdir(parent, { recursive: true, mode: 0o700 });
200
+ await mkdir(tempDirectory, { recursive: false, mode: 0o700 });
201
+ await mkdir(stage, { recursive: false, mode: 0o700 });
202
+ const releaseJson = await command(['api', `repos/${release.repository}/releases/${release.releaseId}`]);
203
+ pinnedAsset(release, releaseJson);
204
+ await command([
205
+ 'release', 'download', release.tag,
206
+ '--repo', release.repository,
207
+ '--dir', tempDirectory,
208
+ '--pattern', release.assetName,
209
+ ]);
210
+ const archivePath = join(tempDirectory, release.assetName);
211
+ const archiveInfo = await lstat(archivePath);
212
+ if (archiveInfo.isSymbolicLink() || !archiveInfo.isFile() || archiveInfo.size > MAX_COMPRESSED_BYTES) {
213
+ throw new Error('Pinned proving bundle archive is missing or too large');
214
+ }
215
+ const compressed = await readFile(archivePath);
216
+ if (digest(compressed) !== release.sha256)
217
+ throw new Error('Pinned proving bundle archive failed its SHA-256 check');
218
+ let expanded;
219
+ try {
220
+ expanded = gunzipSync(compressed, { maxOutputLength: MAX_EXPANDED_BYTES });
221
+ }
222
+ catch {
223
+ throw new Error('Pinned proving bundle archive is not a bounded gzip tarball');
224
+ }
225
+ const files = parseTar(expanded, names);
226
+ for (const [name, bytes] of files)
227
+ await writeBundleFile(stage, name, bytes);
228
+ await chmod(stage, 0o700);
229
+ await resolvePinnedArtifactBundle({ artifactDirectory: stage, manifest: options.manifest });
230
+ await replaceDirectoryAtomically(stage, resolve(options.targetDirectory));
231
+ await resolvePinnedArtifactBundle({ artifactDirectory: resolve(options.targetDirectory), manifest: options.manifest });
232
+ return resolve(options.targetDirectory);
233
+ }
234
+ catch (error) {
235
+ if (error instanceof Error && /^(Pinned proving|This sidecar manifest|Local proving bundle)/u.test(error.message))
236
+ throw error;
237
+ throw new Error('Pinned proving bundle acquisition failed; verify gh access and the published release pin.');
238
+ }
239
+ finally {
240
+ await rm(tempDirectory, { recursive: true, force: true });
241
+ await rm(stage, { recursive: true, force: true });
242
+ }
243
+ }
package/dist/cli.js CHANGED
@@ -173,10 +173,11 @@ non-streaming POST /v1/chat/completions; Codex Responses requests pass through
173
173
  the sidecar's bounded local compatibility bridge.
174
174
  "zk-credits trial-registered-adapter" runs one confirmed, billable internal
175
175
  trial request through the directly registered official x402 client adapter.
176
- Set ZK_CREDITS_CREDENTIAL_PATH before setup. The wizard searches Downloads and
177
- ~/.zk-credits for the pinned bundle and local Base witness, validates both,
178
- then saves their paths locally. Alternatively, set ZK_CREDITS_ARTIFACT_DIR and
179
- ZK_CREDITS_WITNESS_PATH explicitly. Setup prompts for the credential password
176
+ Set ZK_CREDITS_CREDENTIAL_PATH and BASE_RPC_URL before setup. For Base Sepolia
177
+ V2, setup can download the immutable bundle pinned by the package manifest by
178
+ using this machine's authenticated GitHub CLI session, then synchronizes the
179
+ witness from the pinned public Base events and checks the resulting root with
180
+ the gateway. It saves local paths and prompts for the credential password
180
181
  without echo; the encrypted export is decrypted only in local memory.`);
181
182
  }
182
183
  /** Loads the same credential, witness, proof, and durable ledger used by serve. */
@@ -258,6 +259,38 @@ async function readGatewayJson(url, headers) {
258
259
  return { status: null };
259
260
  }
260
261
  }
262
+ async function requireGatewayKnownRoot(root) {
263
+ const origin = gatewayOrigin();
264
+ const token = process.env.BILLING_INTERNAL_TOKEN?.trim() || await readHiddenValue('Gateway admin token: ');
265
+ if (!token)
266
+ throw new Error('Base Sepolia V2 setup requires gateway admin access to check the activated credential root');
267
+ let response;
268
+ try {
269
+ response = await fetch(new URL('/v1/admin/root-known', origin), {
270
+ method: 'POST',
271
+ headers: {
272
+ accept: 'application/json',
273
+ authorization: `Bearer ${token}`,
274
+ 'content-type': 'application/json',
275
+ },
276
+ body: JSON.stringify({ root }),
277
+ signal: AbortSignal.timeout(10_000),
278
+ });
279
+ }
280
+ catch {
281
+ throw new Error('The gateway could not confirm that the activated credential root is known');
282
+ }
283
+ let body;
284
+ try {
285
+ body = await response.json();
286
+ }
287
+ catch {
288
+ body = undefined;
289
+ }
290
+ if (response.status !== 200 || !isRecord(body) || body.known !== true) {
291
+ throw new Error('The activated credential root is not in the gateway known-root set; stop setup and reconcile the V2 activation');
292
+ }
293
+ }
261
294
  async function gatewayReadiness(origin) {
262
295
  const response = await readGatewayJson(new URL('/ready', origin), { accept: 'application/json' });
263
296
  const report = isRecord(response.body) ? response.body : undefined;
@@ -589,7 +622,9 @@ async function main() {
589
622
  manifest,
590
623
  validateWitness: async (source) => {
591
624
  const provider = await witnessProviderForSource(source, join(stateDirectory(), 'base-event-sync.json'));
592
- validateBaseCreditWitness(await provider.witnessForCredential(credential));
625
+ const witness = validateBaseCreditWitness(await provider.witnessForCredential(credential));
626
+ if (manifest.deployment)
627
+ await requireGatewayKnownRoot(witness.root);
593
628
  },
594
629
  chooseCandidate: chooseSetupCandidate,
595
630
  });
@@ -24,6 +24,8 @@ export interface ResolveSetupInputsOptions {
24
24
  validateWitness(source: SetupWitnessSource): Promise<void>;
25
25
  /** Called only when more than one valid local candidate exists. */
26
26
  chooseCandidate?(kind: 'proving bundle' | 'witness file', candidates: readonly string[]): Promise<string>;
27
+ /** Test seam for clean-install acquisition; production uses gh-authenticated release download. */
28
+ acquireArtifactBundle?(targetDirectory: string, manifest: CircuitManifest): Promise<void>;
27
29
  workingDirectory?: string;
28
30
  }
29
31
  /** Finds only known local locations and validates both sources before setup continues. */