zk-credits 0.2.1 → 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,17 +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.0` |
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.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.
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`.
31
31
 
32
32
  Verify the shipped artifacts before your first prove. The package's
33
33
  `circuits/manifest.json` fixes the SHA-256 of the frozen
@@ -57,33 +57,44 @@ still receives the class. A rejected request consumes no credit.
57
57
 
58
58
  ## First run
59
59
 
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:
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:
68
68
 
69
69
  ```bash
70
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
71
  export ZK_CREDITS_GATEWAY_URL="https://zk-credits-gateway.onrender.com"
72
+ export BASE_RPC_URL="https://sepolia.base.org"
74
73
  ```
75
74
 
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.
94
+
76
95
  A missing, relocated, symlinked-out, or altered artifact fails closed
77
96
  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
97
+ leaves, and the local slot stays reusable. The gateway never serves a path
87
98
  for a named leaf or commitment.
88
99
 
89
100
  3. Start setup. It verifies the credential, pinned artifact hashes, and
@@ -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
+ }