zk-credits 0.1.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.
@@ -0,0 +1,32 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { generateRlnProofSelfVerified, requestDigestToField, skToField, } from '@zk-credits/shared';
4
+ import { verifyArtifactManifest } from './artifact-manifest.js';
5
+ /**
6
+ * Builds the sidecar's local prover after checking every installed circuit
7
+ * byte against its release manifest. No proving asset is fetched at runtime.
8
+ */
9
+ export async function createLocalProofGenerator(options) {
10
+ const artifactDirectory = options.artifactDirectory ?? resolve(import.meta.dirname, '..', 'circuits');
11
+ await verifyArtifactManifest(artifactDirectory);
12
+ const verificationKey = JSON.parse(await readFile(resolve(artifactDirectory, 'verification_key_rln.json'), 'utf8'));
13
+ const prove = options.prove ?? generateRlnProofSelfVerified;
14
+ return async ({ ticketIndex, request }) => {
15
+ const [requestDigest, witness] = await Promise.all([
16
+ requestDigestToField(request),
17
+ options.membershipClient.witnessForSecret(options.secretK),
18
+ ]);
19
+ const result = await prove({
20
+ secret_k: skToField(options.secretK),
21
+ ticket_index: ticketIndex.toString(),
22
+ request_digest: requestDigest.field,
23
+ merkle_path_elements: witness.merklePathElements,
24
+ merkle_path_indices: witness.merklePathIndices,
25
+ }, {
26
+ rlnWasm: resolve(artifactDirectory, 'rln_nullifier.wasm'),
27
+ rlnZkey: resolve(artifactDirectory, 'rln_nullifier_final.zkey'),
28
+ rlnVk: verificationKey,
29
+ });
30
+ return { proof: result.proof, pubSignals: result.publicSignals };
31
+ };
32
+ }
@@ -0,0 +1,14 @@
1
+ import { type MembershipWitness } from '@zk-credits/shared';
2
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
3
+ /**
4
+ * Reads only public gateway state. The user's commitment is located locally
5
+ * in the complete snapshot and is never sent in a path or query parameter.
6
+ */
7
+ export declare class MembershipClient {
8
+ private readonly fetcher;
9
+ private readonly baseUrl;
10
+ constructor(gatewayBaseUrl: string, fetcher?: FetchLike);
11
+ witnessForSecret(secretK: Uint8Array): Promise<MembershipWitness>;
12
+ private getCurrentChainRoot;
13
+ private getSnapshot;
14
+ }
@@ -0,0 +1,66 @@
1
+ import { deriveMembershipWitness, mimcHash, skToField, } from '@zk-credits/shared';
2
+ const MAX_SNAPSHOT_AGE_MS = 60_000;
3
+ function parseSnapshot(value) {
4
+ if (!value || typeof value !== 'object')
5
+ throw new Error('Membership snapshot is malformed');
6
+ const snapshot = value;
7
+ if (typeof snapshot.root !== 'string'
8
+ || typeof snapshot.depth !== 'number'
9
+ || !Array.isArray(snapshot.leaves)
10
+ || !snapshot.leaves.every((leaf) => typeof leaf === 'string')
11
+ || !Array.isArray(snapshot.layers)
12
+ || !snapshot.layers.every((layer) => Array.isArray(layer) && layer.every((node) => typeof node === 'string'))
13
+ || typeof snapshot.generatedAt !== 'string') {
14
+ throw new Error('Membership snapshot is malformed');
15
+ }
16
+ const generatedAt = Date.parse(snapshot.generatedAt);
17
+ if (!Number.isFinite(generatedAt) || Date.now() - generatedAt > MAX_SNAPSHOT_AGE_MS || generatedAt - Date.now() > 5_000) {
18
+ throw new Error('Membership snapshot is stale');
19
+ }
20
+ return {
21
+ root: snapshot.root,
22
+ depth: snapshot.depth,
23
+ leaves: snapshot.leaves,
24
+ layers: snapshot.layers,
25
+ generatedAt: snapshot.generatedAt,
26
+ };
27
+ }
28
+ /**
29
+ * Reads only public gateway state. The user's commitment is located locally
30
+ * in the complete snapshot and is never sent in a path or query parameter.
31
+ */
32
+ export class MembershipClient {
33
+ fetcher;
34
+ baseUrl;
35
+ constructor(gatewayBaseUrl, fetcher = fetch) {
36
+ this.fetcher = fetcher;
37
+ this.baseUrl = gatewayBaseUrl.replace(/\/$/, '');
38
+ }
39
+ async witnessForSecret(secretK) {
40
+ const [chainRoot, snapshot] = await Promise.all([
41
+ this.getCurrentChainRoot(),
42
+ this.getSnapshot(),
43
+ ]);
44
+ if (snapshot.root !== chainRoot) {
45
+ throw new Error('Membership snapshot does not match the current chain root');
46
+ }
47
+ const commitment = await mimcHash([BigInt(skToField(secretK))]);
48
+ return deriveMembershipWitness(commitment, snapshot);
49
+ }
50
+ async getCurrentChainRoot() {
51
+ const response = await this.fetcher(`${this.baseUrl}/v1/contract-status`, { cache: 'no-store' });
52
+ if (!response.ok)
53
+ throw new Error('Current chain root could not be loaded');
54
+ const payload = await response.json();
55
+ if (!payload || typeof payload !== 'object' || typeof payload.currentRoot !== 'string') {
56
+ throw new Error('Current chain root is unavailable');
57
+ }
58
+ return payload.currentRoot;
59
+ }
60
+ async getSnapshot() {
61
+ const response = await this.fetcher(`${this.baseUrl}/v1/membership-tree`, { cache: 'no-store' });
62
+ if (!response.ok)
63
+ throw new Error('Membership snapshot could not be loaded');
64
+ return parseSnapshot(await response.json());
65
+ }
66
+ }
@@ -0,0 +1,21 @@
1
+ import type { SidecarLifecycleDependencies } from './sidecar-lifecycle.js';
2
+ export interface DetachedSidecarProcess {
3
+ executable: string;
4
+ args: readonly string[];
5
+ env: NodeJS.ProcessEnv;
6
+ logPath: string;
7
+ }
8
+ export interface NodeSidecarLifecycleOptions {
9
+ loopbackBaseUrl: string;
10
+ stateDirectory: string;
11
+ tokenPath: string;
12
+ logPath: string;
13
+ cliEntryPath: string;
14
+ }
15
+ export interface NodeSidecarLifecycleRuntime {
16
+ fetchHealth(url: string): Promise<Response>;
17
+ startDetachedProcess(specification: DetachedSidecarProcess): Promise<void>;
18
+ wait(milliseconds: number): Promise<void>;
19
+ }
20
+ /** Connects lifecycle orchestration to Node fetch, process, and file APIs. */
21
+ export declare function createNodeSidecarLifecycle(options: NodeSidecarLifecycleOptions, runtime?: NodeSidecarLifecycleRuntime): SidecarLifecycleDependencies;
@@ -0,0 +1,65 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { chmod, mkdir, open } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ import { readLoopbackToken } from './sidecar-state.js';
5
+ async function startDetachedProcess(specification) {
6
+ await mkdir(dirname(specification.logPath), { recursive: true, mode: 0o700 });
7
+ const log = await open(specification.logPath, 'a', 0o600);
8
+ await chmod(specification.logPath, 0o600);
9
+ try {
10
+ const child = spawn(specification.executable, specification.args, {
11
+ detached: true,
12
+ env: specification.env,
13
+ stdio: ['ignore', log.fd, log.fd],
14
+ });
15
+ await new Promise((resolve, reject) => {
16
+ child.once('spawn', resolve);
17
+ child.once('error', reject);
18
+ });
19
+ child.unref();
20
+ }
21
+ finally {
22
+ await log.close();
23
+ }
24
+ }
25
+ const defaultRuntime = {
26
+ fetchHealth: async (url) => fetch(url, { signal: AbortSignal.timeout(500) }),
27
+ startDetachedProcess,
28
+ wait: async (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
29
+ };
30
+ /** Connects lifecycle orchestration to Node fetch, process, and file APIs. */
31
+ export function createNodeSidecarLifecycle(options, runtime = defaultRuntime) {
32
+ const url = new URL(options.loopbackBaseUrl);
33
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port) {
34
+ throw new Error('Sidecar lifecycle requires a 127.0.0.1 HTTP URL with an explicit port');
35
+ }
36
+ return {
37
+ logPath: options.logPath,
38
+ async isHealthy() {
39
+ try {
40
+ const response = await runtime.fetchHealth(`${url.origin}/health`);
41
+ if (!response.ok)
42
+ return false;
43
+ const body = await response.json();
44
+ return body.service === 'zk-credits-sidecar' && body.status === 'ok';
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ },
50
+ async startDetached() {
51
+ await runtime.startDetachedProcess({
52
+ executable: process.execPath,
53
+ args: [options.cliEntryPath, 'serve', '--port', url.port],
54
+ env: {
55
+ ...process.env,
56
+ ZK_CREDITS_HOME: options.stateDirectory,
57
+ ZK_CREDITS_SIDECAR_PORT: url.port,
58
+ },
59
+ logPath: options.logPath,
60
+ });
61
+ },
62
+ readToken: () => readLoopbackToken(options.tokenPath),
63
+ wait: runtime.wait,
64
+ };
65
+ }
@@ -0,0 +1,6 @@
1
+ export interface SidecarServerActivation {
2
+ listen(): Promise<string>;
3
+ publishToken(): Promise<void>;
4
+ }
5
+ /** Makes the bearer discoverable only after this process owns the port. */
6
+ export declare function activateSidecarServer(activation: SidecarServerActivation): Promise<string>;
@@ -0,0 +1,6 @@
1
+ /** Makes the bearer discoverable only after this process owns the port. */
2
+ export async function activateSidecarServer(activation) {
3
+ const address = await activation.listen();
4
+ await activation.publishToken();
5
+ return address;
6
+ }
@@ -0,0 +1,9 @@
1
+ export interface SidecarStatePaths {
2
+ ledgerPath: string;
3
+ tokenPath: string;
4
+ logPath: string;
5
+ }
6
+ export declare function createLoopbackToken(): string;
7
+ export declare function sidecarStatePaths(stateDirectory: string): SidecarStatePaths;
8
+ /** Shell-safe environment lines for clients with OpenAI-compatible settings. */
9
+ export declare function formatOpenAiEnvironment(loopbackBaseUrl: string, localToken: string): string;
@@ -0,0 +1,23 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ export function createLoopbackToken() {
4
+ return randomBytes(32).toString('base64url');
5
+ }
6
+ export function sidecarStatePaths(stateDirectory) {
7
+ return {
8
+ ledgerPath: join(stateDirectory, 'tickets.json'),
9
+ tokenPath: join(stateDirectory, 'loopback-token'),
10
+ logPath: join(stateDirectory, 'sidecar.log'),
11
+ };
12
+ }
13
+ /** Shell-safe environment lines for clients with OpenAI-compatible settings. */
14
+ export function formatOpenAiEnvironment(loopbackBaseUrl, localToken) {
15
+ const url = new URL(loopbackBaseUrl);
16
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !/^[A-Za-z0-9_-]+$/.test(localToken)) {
17
+ throw new Error('Sidecar environment must use a valid 127.0.0.1 URL and local token');
18
+ }
19
+ return [
20
+ `export OPENAI_BASE_URL=${url.origin}/v1`,
21
+ `export OPENAI_API_KEY=${localToken}`,
22
+ ].join('\n');
23
+ }
@@ -0,0 +1,13 @@
1
+ export interface SidecarLifecycleDependencies {
2
+ isHealthy(): Promise<boolean>;
3
+ startDetached(): Promise<void>;
4
+ readToken(): Promise<string>;
5
+ wait(milliseconds: number): Promise<void>;
6
+ logPath: string;
7
+ }
8
+ export interface SidecarReadinessOptions {
9
+ attempts?: number;
10
+ intervalMs?: number;
11
+ }
12
+ /** Ensures one loopback sidecar is ready before exposing its local bearer. */
13
+ export declare function ensureSidecarReady(dependencies: SidecarLifecycleDependencies, options?: SidecarReadinessOptions): Promise<string>;
@@ -0,0 +1,14 @@
1
+ /** Ensures one loopback sidecar is ready before exposing its local bearer. */
2
+ export async function ensureSidecarReady(dependencies, options = {}) {
3
+ if (await dependencies.isHealthy())
4
+ return dependencies.readToken();
5
+ await dependencies.startDetached();
6
+ const attempts = options.attempts ?? 50;
7
+ const intervalMs = options.intervalMs ?? 100;
8
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
9
+ await dependencies.wait(intervalMs);
10
+ if (await dependencies.isHealthy())
11
+ return dependencies.readToken();
12
+ }
13
+ throw new Error(`ZK Credits sidecar did not become ready; inspect ${dependencies.logPath}`);
14
+ }
@@ -0,0 +1,2 @@
1
+ export declare function writeLoopbackToken(tokenPath: string, token: string): Promise<void>;
2
+ export declare function readLoopbackToken(tokenPath: string): Promise<string>;
@@ -0,0 +1,18 @@
1
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ export async function writeLoopbackToken(tokenPath, token) {
4
+ if (!token)
5
+ throw new Error('Loopback token is required');
6
+ await mkdir(dirname(tokenPath), { recursive: true, mode: 0o700 });
7
+ const temporaryPath = `${tokenPath}.tmp`;
8
+ await writeFile(temporaryPath, `${token}\n`, { encoding: 'utf8', mode: 0o600 });
9
+ await chmod(temporaryPath, 0o600);
10
+ await rename(temporaryPath, tokenPath);
11
+ await chmod(tokenPath, 0o600);
12
+ }
13
+ export async function readLoopbackToken(tokenPath) {
14
+ const token = (await readFile(tokenPath, 'utf8')).trim();
15
+ if (!token)
16
+ throw new Error('No active ZK Credits sidecar token; start zk-credits serve first');
17
+ return token;
18
+ }
@@ -0,0 +1,27 @@
1
+ import { TicketLedger } from './ticket-ledger.js';
2
+ export interface ProofGeneratorInput {
3
+ ticketIndex: number;
4
+ request: unknown;
5
+ }
6
+ export type ProofGenerator = (input: ProofGeneratorInput) => Promise<{
7
+ proof: object;
8
+ pubSignals: string[];
9
+ }>;
10
+ export type GatewayFetch = (input: string, init: RequestInit) => Promise<Response>;
11
+ export interface SidecarOptions {
12
+ localToken: string;
13
+ gatewayBaseUrl: string;
14
+ compatibilityKey: string;
15
+ ledger: TicketLedger;
16
+ proofGenerator: ProofGenerator;
17
+ gatewayFetch?: GatewayFetch;
18
+ }
19
+ export interface RunningSidecar {
20
+ listen(port?: number): Promise<string>;
21
+ close(): Promise<void>;
22
+ }
23
+ /**
24
+ * Creates a loopback-only OpenAI-compatible proxy. The local bearer never
25
+ * reaches Render; only the shared compatibility bearer and a fresh proof do.
26
+ */
27
+ export declare function createSidecarServer(options: SidecarOptions): RunningSidecar;
@@ -0,0 +1,153 @@
1
+ import { createServer } from 'node:http';
2
+ import { codexModelsResponse } from './codex-profile.js';
3
+ const MAX_REQUEST_BYTES = 2_000_000;
4
+ function isLoopbackAddress(address) {
5
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
6
+ }
7
+ function sendJson(res, status, body) {
8
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
9
+ res.end(JSON.stringify(body));
10
+ }
11
+ async function readJsonBody(req) {
12
+ const chunks = [];
13
+ let total = 0;
14
+ for await (const chunk of req) {
15
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
16
+ total += bytes.length;
17
+ if (total > MAX_REQUEST_BYTES)
18
+ throw new Error('OpenAI request exceeds the loopback size limit');
19
+ chunks.push(bytes);
20
+ }
21
+ const raw = Buffer.concat(chunks).toString('utf8');
22
+ if (!raw)
23
+ throw new Error('OpenAI request body is required');
24
+ try {
25
+ return { raw, parsed: JSON.parse(raw) };
26
+ }
27
+ catch {
28
+ throw new Error('OpenAI request body must be valid JSON');
29
+ }
30
+ }
31
+ async function relayGatewayResponse(res, upstream) {
32
+ const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8';
33
+ res.writeHead(upstream.status, { 'Content-Type': contentType });
34
+ if (!upstream.body) {
35
+ res.end();
36
+ return;
37
+ }
38
+ const reader = upstream.body.getReader();
39
+ try {
40
+ while (true) {
41
+ const { done, value } = await reader.read();
42
+ if (done)
43
+ break;
44
+ res.write(Buffer.from(value));
45
+ }
46
+ res.end();
47
+ }
48
+ finally {
49
+ reader.releaseLock();
50
+ }
51
+ }
52
+ function proofHeader(proof) {
53
+ return Buffer.from(JSON.stringify(proof)).toString('base64');
54
+ }
55
+ /**
56
+ * Creates a loopback-only OpenAI-compatible proxy. The local bearer never
57
+ * reaches Render; only the shared compatibility bearer and a fresh proof do.
58
+ */
59
+ export function createSidecarServer(options) {
60
+ const gatewayBaseUrl = options.gatewayBaseUrl.replace(/\/$/, '');
61
+ const gatewayFetch = options.gatewayFetch ?? fetch;
62
+ let server = null;
63
+ const inFlight = new Set();
64
+ const handler = async (req, res) => {
65
+ const pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname;
66
+ if (!isLoopbackAddress(req.socket.remoteAddress)) {
67
+ sendJson(res, 403, { error: 'loopback_only' });
68
+ return;
69
+ }
70
+ if (req.method === 'GET' && pathname === '/health') {
71
+ sendJson(res, 200, { service: 'zk-credits-sidecar', status: 'ok' });
72
+ return;
73
+ }
74
+ if (req.method === 'GET' && pathname === '/v1/models') {
75
+ if (req.headers.authorization !== `Bearer ${options.localToken}`) {
76
+ sendJson(res, 401, { error: 'invalid_local_token' });
77
+ return;
78
+ }
79
+ sendJson(res, 200, codexModelsResponse());
80
+ return;
81
+ }
82
+ if (req.method !== 'POST' || (pathname !== '/v1/chat/completions' && pathname !== '/v1/responses')) {
83
+ sendJson(res, 404, { error: 'unsupported_openai_path' });
84
+ return;
85
+ }
86
+ if (req.headers.authorization !== `Bearer ${options.localToken}`) {
87
+ sendJson(res, 401, { error: 'invalid_local_token' });
88
+ return;
89
+ }
90
+ try {
91
+ const body = await readJsonBody(req);
92
+ const reservation = await options.ledger.reserve(body.parsed);
93
+ const proof = await options.proofGenerator({
94
+ ticketIndex: reservation.index,
95
+ request: body.parsed,
96
+ });
97
+ const upstream = await gatewayFetch(`${gatewayBaseUrl}${pathname}`, {
98
+ method: 'POST',
99
+ headers: {
100
+ Authorization: `Bearer ${options.compatibilityKey}`,
101
+ 'Content-Type': 'application/json',
102
+ 'X-ZK-Proof': proofHeader(proof),
103
+ },
104
+ body: body.raw,
105
+ });
106
+ await relayGatewayResponse(res, upstream);
107
+ if (upstream.status >= 200 && upstream.status < 300) {
108
+ await options.ledger.consume(reservation.requestDigest);
109
+ }
110
+ }
111
+ catch (error) {
112
+ if (!res.headersSent) {
113
+ const message = error instanceof Error ? error.message : 'Loopback request failed';
114
+ sendJson(res, 502, { error: 'sidecar_request_failed', message });
115
+ }
116
+ else if (!res.writableEnded) {
117
+ res.end();
118
+ }
119
+ }
120
+ };
121
+ return {
122
+ async listen(port = 0) {
123
+ if (server)
124
+ throw new Error('ZK Credits sidecar is already running');
125
+ server = createServer((req, res) => {
126
+ const request = handler(req, res).finally(() => inFlight.delete(request));
127
+ inFlight.add(request);
128
+ });
129
+ await new Promise((resolve, reject) => {
130
+ server.once('error', reject);
131
+ server.listen(port, '127.0.0.1', () => {
132
+ server.off('error', reject);
133
+ resolve();
134
+ });
135
+ });
136
+ const address = server.address();
137
+ return `http://127.0.0.1:${address.port}`;
138
+ },
139
+ async close() {
140
+ if (!server)
141
+ return;
142
+ const activeServer = server;
143
+ server = null;
144
+ await new Promise((resolve, reject) => activeServer.close((error) => {
145
+ if (error)
146
+ reject(error);
147
+ else
148
+ resolve();
149
+ }));
150
+ await Promise.all(inFlight);
151
+ },
152
+ };
153
+ }
@@ -0,0 +1,22 @@
1
+ export type TicketState = 'reserved' | 'consumed';
2
+ export interface TicketReservation {
3
+ index: number;
4
+ requestDigest: string;
5
+ state: TicketState;
6
+ }
7
+ /**
8
+ * Durable, serialized local ticket allocation. It never reassigns a ticket
9
+ * after an ambiguous request: only an exact canonical request digest may
10
+ * reuse its prior reservation for gateway idempotency.
11
+ */
12
+ export declare class TicketLedger {
13
+ private readonly filePath;
14
+ private readonly capacity;
15
+ private queue;
16
+ constructor(filePath: string, capacity?: number);
17
+ reserve(request: unknown): Promise<TicketReservation>;
18
+ consume(requestDigest: string): Promise<void>;
19
+ private serialized;
20
+ private read;
21
+ private write;
22
+ }
@@ -0,0 +1,91 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { requestDigestToField } from '@zk-credits/shared';
4
+ function parseLedger(raw) {
5
+ const parsed = JSON.parse(raw);
6
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
7
+ throw new Error('Sidecar ticket ledger is malformed');
8
+ }
9
+ const entries = parsed.entries.map((entry) => {
10
+ if (!entry || typeof entry !== 'object'
11
+ || !Number.isInteger(entry.index)
12
+ || typeof entry.requestDigest !== 'string'
13
+ || !['reserved', 'consumed'].includes(entry.state)) {
14
+ throw new Error('Sidecar ticket ledger is malformed');
15
+ }
16
+ return entry;
17
+ });
18
+ return { entries };
19
+ }
20
+ /**
21
+ * Durable, serialized local ticket allocation. It never reassigns a ticket
22
+ * after an ambiguous request: only an exact canonical request digest may
23
+ * reuse its prior reservation for gateway idempotency.
24
+ */
25
+ export class TicketLedger {
26
+ filePath;
27
+ capacity;
28
+ queue = Promise.resolve();
29
+ constructor(filePath, capacity = 100) {
30
+ this.filePath = filePath;
31
+ this.capacity = capacity;
32
+ }
33
+ async reserve(request) {
34
+ const digest = (await requestDigestToField(request)).digest;
35
+ return this.serialized(async () => {
36
+ const ledger = await this.read();
37
+ const existing = ledger.entries.find((entry) => entry.requestDigest === digest);
38
+ if (existing)
39
+ return { ...existing };
40
+ const used = new Set(ledger.entries.map((entry) => entry.index));
41
+ const index = Array.from({ length: this.capacity }, (_, candidate) => candidate)
42
+ .find((candidate) => !used.has(candidate));
43
+ if (index === undefined)
44
+ throw new Error('All ZK Credit tickets have been reserved');
45
+ const reservation = { index, requestDigest: digest, state: 'reserved' };
46
+ ledger.entries.push(reservation);
47
+ await this.write(ledger);
48
+ return reservation;
49
+ });
50
+ }
51
+ async consume(requestDigest) {
52
+ await this.serialized(async () => {
53
+ const ledger = await this.read();
54
+ const entry = ledger.entries.find((candidate) => candidate.requestDigest === requestDigest);
55
+ if (!entry)
56
+ throw new Error('Unknown sidecar ticket reservation');
57
+ entry.state = 'consumed';
58
+ await this.write(ledger);
59
+ });
60
+ }
61
+ async serialized(operation) {
62
+ const previous = this.queue;
63
+ let release;
64
+ this.queue = new Promise((resolve) => {
65
+ release = resolve;
66
+ });
67
+ await previous;
68
+ try {
69
+ return await operation();
70
+ }
71
+ finally {
72
+ release();
73
+ }
74
+ }
75
+ async read() {
76
+ try {
77
+ return parseLedger(await readFile(this.filePath, 'utf8'));
78
+ }
79
+ catch (error) {
80
+ if (error.code === 'ENOENT')
81
+ return { entries: [] };
82
+ throw error;
83
+ }
84
+ }
85
+ async write(ledger) {
86
+ await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
87
+ const temporaryPath = `${this.filePath}.tmp`;
88
+ await writeFile(temporaryPath, JSON.stringify(ledger), { encoding: 'utf8', mode: 0o600 });
89
+ await rename(temporaryPath, this.filePath);
90
+ }
91
+ }