zk-credits 0.2.0 → 0.2.2

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
10
+ npm install --global zk-credits@latest
11
11
  ```
12
12
 
13
13
  ## Pinned pilot versions
@@ -17,13 +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.1` |
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 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
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
27
31
  (`private-credit-spend-bn254-dev-sepolia-v1`) are delivered directly through
28
32
  the invite channel, never from a public host.
29
33
 
@@ -43,9 +47,9 @@ an arbitrary API call, and the gateway enforces the class before it reserves
43
47
  the credit:
44
48
 
45
49
  - text messages, tool definitions, and tool calls, with one generated choice;
46
- - no streaming, images, files, audio, web plugins, model fallback,
50
+ - no provider streaming, images, files, audio, web search, model fallback,
47
51
  client-selected routing, or unknown cost-affecting fields;
48
- - input capped at 16,000 UTF-8 bytes (counted conservatively, never below the
52
+ - input capped at 128,000 UTF-8 byte units (counted conservatively, never below the
49
53
  provider's token count), output at 4,000 tokens, request body at 256 KiB,
50
54
  encrypted replay at 1 MiB, and upstream timeout at 120 seconds.
51
55
 
@@ -55,58 +59,70 @@ still receives the class. A rejected request consumes no credit.
55
59
 
56
60
  ## First run
57
61
 
58
- 1. Redeem an invite to the unpaid, experimental Base Sepolia pilot and
59
- download the password-encrypted credential backup. Credits are
62
+ 1. Redeem an invite and download the encrypted credential. Credits are
60
63
  founder-provisioned test credits; there is no payment step.
61
- 2. Install the pinned proving artifacts. The package ships
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
62
68
  `circuits/manifest.json`, which fixes the SHA-256 of the frozen
63
69
  `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:
70
+ `verification_key_private_credit.json`. The sidecar never fetches proving
71
+ material at runtime. Set the credential and gateway:
66
72
 
67
73
  ```bash
68
- export ZK_CREDITS_ARTIFACT_DIR="$PWD/private-credit-bundle"
69
- ls "$ZK_CREDITS_ARTIFACT_DIR" # the three pinned files
74
+ export ZK_CREDITS_CREDENTIAL_PATH="$HOME/Downloads/zk-credits-credential.json"
75
+ export ZK_CREDITS_GATEWAY_URL="https://zk-credits-gateway.onrender.com"
70
76
  ```
71
77
 
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.
89
+
72
90
  A missing, relocated, symlinked-out, or altered artifact fails closed
73
91
  before the first prove. A hash mismatch is a proof failure: no payment
74
92
  leaves, and the local slot stays reusable.
75
93
 
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.
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.
91
102
 
92
- 4. Run:
103
+ 3. Start setup. It verifies the credential, pinned artifact hashes, and
104
+ witness locally, then prompts for the backup password without echo:
93
105
 
94
106
  ```bash
95
- zk-credits cline "summarize this repository"
96
- zk-credits setup codex && zk-credits codex "summarize this repository"
107
+ zk-credits setup codex
97
108
  ```
98
109
 
110
+ Then run `zk-credits codex` to open Codex with the local sidecar.
111
+
99
112
  The sidecar binds `127.0.0.1:3210` only. It does not modify `~/.cline` or
100
113
  `~/.codex`.
101
114
 
102
115
  ## Supported route
103
116
 
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.
117
+ The pilot serves one bounded upstream spend path: non-streaming
118
+ `POST /v1/chat/completions`, either through this sidecar or through an
119
+ x402-native agent that explicitly registers the project `zk-prepaid` adapter.
120
+ The sidecar also accepts Codex `POST /v1/responses` requests with text input
121
+ and function tools, translates them to that bounded chat request, and converts
122
+ the committed result into Responses SSE events. It rejects unsupported fields
123
+ before starting a proof. Generic x402 clients, public facilitators, Bazaar,
124
+ MCP, and the standard `exact` rail are unsupported; the sidecar never falls
125
+ back to another rail.
110
126
 
111
127
  | Route | Auth | Purpose |
112
128
  | --- | --- | --- |
@@ -114,10 +130,14 @@ unsupported; the sidecar never falls back to another rail.
114
130
  | `GET /v1/models` | local token | Codex model discovery |
115
131
  | `GET /metrics` | local token | Aggregate proof metrics (below) |
116
132
  | `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
133
+ | `POST /v1/responses` | local token | Codex bridge to the bounded chat spend path |
134
+
135
+ The chat route rejects streaming bodies (`stream`, `stream_options`) and a
136
+ missing `model` with a `4xx` before any proof. The Responses route requires
137
+ `stream: true`, text-only message/function-call input, and the known Codex
138
+ metadata fields; it rejects images, unsupported tools, and unknown controls
139
+ before any proof. Its upstream request is always `stream: false`. Anthropic
140
+ `/v1/messages` and unknown paths return `404 unsupported_openai_path`. The
121
141
  sidecar never substitutes a model and never falls back to another rail.
122
142
 
123
143
  ### Gateway responses and retries
@@ -179,9 +199,21 @@ zk-credits setup codex [--model <model>]
179
199
  zk-credits codex [codex arguments...]
180
200
  zk-credits status
181
201
  zk-credits serve [--port <port>]
202
+ zk-credits trial-registered-adapter
182
203
  eval "$(zk-credits env)"
183
204
  ```
184
205
 
206
+ `trial-registered-adapter` is a maintainer-only internal trial command. It
207
+ requires the locally recovered credential and pinned proving bundle, prompts
208
+ for the credential password and gateway admin token without echo when they are
209
+ not already configured, and requires an explicit confirmation before sending
210
+ one billable request. It checks gateway readiness, authenticated admin status,
211
+ Base root agreement, Codex profile/sidecar status, and remaining local slot
212
+ capacity, then reports only the exchange phases and aggregate counter deltas.
213
+ Run it only after recovery is complete and with the same `ZK_CREDITS_HOME`
214
+ used by the sidecar; it registers `zk-prepaid` directly through the official
215
+ x402 client adapter and reuses the durable slot ledger.
216
+
185
217
  Codex SDK:
186
218
 
187
219
  ```ts
@@ -0,0 +1,33 @@
1
+ /**
2
+ * One-shot local transport for exercising the official @x402/core adapter.
3
+ *
4
+ * @x402/core creates scheme payloads from PaymentRequired and does not pass
5
+ * HTTP method/body context to a scheme client. This harness captures that
6
+ * request context locally, then binds it into the same Base proof factory
7
+ * before invoking the registered `zk-prepaid` scheme. It is intended only for
8
+ * the internal Base Sepolia trial, not as a general HTTP client.
9
+ *
10
+ * SPDX-License-Identifier: AGPL-3.0-or-later
11
+ */
12
+ import { type BasePrepaidClientOptions } from './base-sidecar.js';
13
+ export type BaseRegisteredTrialFailurePhase = 'challenge_transport' | 'challenge_status' | 'challenge_header' | 'challenge_invalid' | 'challenge_unsupported' | 'challenge_request_mismatch' | 'proof_generation' | 'payment_transport' | 'payment_rejected' | 'payment_response_missing' | 'payment_response_invalid' | 'response_not_ok';
14
+ export interface BaseRegisteredTrialResult {
15
+ challengeStatus: number | null;
16
+ challengeReceived: boolean;
17
+ proofPrepared: boolean;
18
+ paymentSignatureSent: boolean;
19
+ paymentStatus: number | null;
20
+ paymentResponseConfirmed: boolean;
21
+ responseStatus: number | null;
22
+ responseSucceeded: boolean;
23
+ committedSlotDelta: number;
24
+ failurePhase?: BaseRegisteredTrialFailurePhase;
25
+ }
26
+ export interface BaseRegisteredTrialOptions extends BasePrepaidClientOptions {
27
+ url: string;
28
+ body: string;
29
+ headers?: HeadersInit;
30
+ fetch?: typeof fetch;
31
+ }
32
+ /** Runs one POST to the pilot chat route through a directly registered adapter. */
33
+ export declare function runBaseRegisteredTrialExchange(options: BaseRegisteredTrialOptions): Promise<BaseRegisteredTrialResult>;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * One-shot local transport for exercising the official @x402/core adapter.
3
+ *
4
+ * @x402/core creates scheme payloads from PaymentRequired and does not pass
5
+ * HTTP method/body context to a scheme client. This harness captures that
6
+ * request context locally, then binds it into the same Base proof factory
7
+ * before invoking the registered `zk-prepaid` scheme. It is intended only for
8
+ * the internal Base Sepolia trial, not as a general HTTP client.
9
+ *
10
+ * SPDX-License-Identifier: AGPL-3.0-or-later
11
+ */
12
+ import { x402Client } from '@x402/core/client';
13
+ import { BASE_SEPOLIA_NETWORK, decodeHeader, encodeHeader, lifecycleFailure, lifecycleStage, PAYMENT_REQUIRED_HEADER, PAYMENT_RESPONSE_HEADER, PAYMENT_SIGNATURE_HEADER, registerZkPrepaidClient, ZK_PREPAID_SCHEME, } from '@zk-credits/x402-zk-prepaid';
14
+ import { createBasePaymentFactory } from './base-sidecar.js';
15
+ function observe(options, event) {
16
+ try {
17
+ options.lifecycle?.(event);
18
+ }
19
+ catch {
20
+ // An observer must never change an exchange.
21
+ }
22
+ }
23
+ function withFailure(options, result, startingCommittedSlots, failurePhase, failure) {
24
+ result.failurePhase = failurePhase;
25
+ if (failure)
26
+ observe(options, lifecycleFailure(failure));
27
+ result.committedSlotDelta = (options.slotLedger?.snapshot().committed ?? startingCommittedSlots) - startingCommittedSlots;
28
+ return result;
29
+ }
30
+ function requestHeaders(headers) {
31
+ return new Headers(headers);
32
+ }
33
+ /** Runs one POST to the pilot chat route through a directly registered adapter. */
34
+ export async function runBaseRegisteredTrialExchange(options) {
35
+ const ledger = options.slotLedger;
36
+ if (!ledger)
37
+ throw new Error('A durable slot ledger is required for the Base pilot');
38
+ const startingCommittedSlots = ledger.snapshot().committed;
39
+ const result = {
40
+ challengeStatus: null,
41
+ challengeReceived: false,
42
+ proofPrepared: false,
43
+ paymentSignatureSent: false,
44
+ paymentStatus: null,
45
+ paymentResponseConfirmed: false,
46
+ responseStatus: null,
47
+ responseSucceeded: false,
48
+ committedSlotDelta: 0,
49
+ };
50
+ let target;
51
+ try {
52
+ target = new URL(options.url);
53
+ }
54
+ catch {
55
+ return withFailure(options, result, startingCommittedSlots, 'challenge_request_mismatch');
56
+ }
57
+ if (target.protocol !== 'https:'
58
+ || target.username !== ''
59
+ || target.password !== ''
60
+ || target.search !== ''
61
+ || target.hash !== ''
62
+ || target.pathname !== '/v1/chat/completions') {
63
+ return withFailure(options, result, startingCommittedSlots, 'challenge_request_mismatch');
64
+ }
65
+ const fetcher = options.fetch ?? fetch;
66
+ const requestInit = {
67
+ method: 'POST',
68
+ body: options.body,
69
+ headers: requestHeaders(options.headers),
70
+ redirect: 'error',
71
+ };
72
+ let challengeResponse;
73
+ try {
74
+ challengeResponse = await fetcher(target, requestInit);
75
+ }
76
+ catch {
77
+ return withFailure(options, result, startingCommittedSlots, 'challenge_transport', 'transport_failed');
78
+ }
79
+ result.challengeStatus = challengeResponse.status;
80
+ if (challengeResponse.status !== 402) {
81
+ return withFailure(options, result, startingCommittedSlots, 'challenge_status', 'payment_rejected');
82
+ }
83
+ const encodedRequired = challengeResponse.headers.get(PAYMENT_REQUIRED_HEADER);
84
+ if (!encodedRequired) {
85
+ return withFailure(options, result, startingCommittedSlots, 'challenge_header', 'challenge_unsupported');
86
+ }
87
+ let required;
88
+ try {
89
+ required = decodeHeader(encodedRequired);
90
+ }
91
+ catch {
92
+ return withFailure(options, result, startingCommittedSlots, 'challenge_invalid', 'challenge_unreadable');
93
+ }
94
+ if (required.x402Version !== 2
95
+ || !Array.isArray(required.accepts)
96
+ || !required.accepts.some((accepted) => accepted.scheme === ZK_PREPAID_SCHEME && accepted.network === BASE_SEPOLIA_NETWORK)) {
97
+ return withFailure(options, result, startingCommittedSlots, 'challenge_unsupported', 'challenge_unsupported');
98
+ }
99
+ const challengeUrl = required.resource?.url;
100
+ if (challengeUrl && challengeUrl !== target.toString()) {
101
+ return withFailure(options, result, startingCommittedSlots, 'challenge_request_mismatch', 'challenge_unsupported');
102
+ }
103
+ result.challengeReceived = true;
104
+ observe(options, lifecycleStage('challenge_received'));
105
+ const createPayment = createBasePaymentFactory(options);
106
+ const directAdapter = registerZkPrepaidClient(new x402Client((_version, accepts) => {
107
+ const selected = accepts.find((accepted) => accepted.scheme === ZK_PREPAID_SCHEME && accepted.network === BASE_SEPOLIA_NETWORK);
108
+ if (!selected)
109
+ throw new Error('zk_prepaid_challenge_missing');
110
+ return selected;
111
+ }).setSpendControls(false), async ({ requirements }) => {
112
+ const prepared = await createPayment({
113
+ url: target.toString(),
114
+ method: 'POST',
115
+ body: options.body,
116
+ requirements,
117
+ });
118
+ return prepared.payment;
119
+ });
120
+ let payment;
121
+ try {
122
+ payment = await directAdapter.createPaymentPayload(required);
123
+ result.proofPrepared = true;
124
+ observe(options, lifecycleStage('payment_prepared'));
125
+ }
126
+ catch {
127
+ return withFailure(options, result, startingCommittedSlots, 'proof_generation', 'payment_preparation_failed');
128
+ }
129
+ const headers = requestHeaders(options.headers);
130
+ headers.set(PAYMENT_SIGNATURE_HEADER, encodeHeader(payment));
131
+ result.paymentSignatureSent = true;
132
+ let paidResponse;
133
+ try {
134
+ paidResponse = await fetcher(target, { ...requestInit, headers });
135
+ }
136
+ catch {
137
+ return withFailure(options, result, startingCommittedSlots, 'payment_transport', 'transport_failed');
138
+ }
139
+ result.paymentStatus = paidResponse.status;
140
+ result.responseStatus = paidResponse.status;
141
+ if (paidResponse.status === 402) {
142
+ return withFailure(options, result, startingCommittedSlots, 'payment_rejected', 'payment_rejected');
143
+ }
144
+ const encodedPaymentResponse = paidResponse.headers.get(PAYMENT_RESPONSE_HEADER);
145
+ if (!encodedPaymentResponse) {
146
+ return withFailure(options, result, startingCommittedSlots, 'payment_response_missing', 'settlement_failed');
147
+ }
148
+ try {
149
+ const settlement = decodeHeader(encodedPaymentResponse);
150
+ if (settlement.success !== true
151
+ || settlement.network !== BASE_SEPOLIA_NETWORK
152
+ || settlement.transaction !== '') {
153
+ return withFailure(options, result, startingCommittedSlots, 'payment_response_invalid', 'settlement_failed');
154
+ }
155
+ }
156
+ catch {
157
+ return withFailure(options, result, startingCommittedSlots, 'payment_response_invalid', 'settlement_failed');
158
+ }
159
+ result.paymentResponseConfirmed = true;
160
+ observe(options, lifecycleStage('settlement_confirmed'));
161
+ if (!paidResponse.ok) {
162
+ return withFailure(options, result, startingCommittedSlots, 'response_not_ok', 'payment_rejected');
163
+ }
164
+ result.responseSucceeded = true;
165
+ observe(options, lifecycleStage('exchange_succeeded'));
166
+ result.committedSlotDelta = ledger.snapshot().committed - startingCommittedSlots;
167
+ return result;
168
+ }
@@ -8,7 +8,7 @@
8
8
  * SPDX-License-Identifier: AGPL-3.0-or-later
9
9
  */
10
10
  import { type CreditCredential } from '@zk-credits/shared/base';
11
- import { type PaymentRequirements, type ZkPrepaidClient, type ZkPrepaidLifecycleObserver } from '@zk-credits/x402-zk-prepaid';
11
+ import { type PaymentPayload, type PaymentRequirements, type ZkPrepaidClient, type ZkPrepaidLifecycleObserver } from '@zk-credits/x402-zk-prepaid';
12
12
  import type { BaseSlotLedger } from './slot-ledger.js';
13
13
  export interface BaseCreditWitness {
14
14
  root: string;
@@ -69,6 +69,19 @@ export interface BasePrepaidClient {
69
69
  /** Slots committed locally; a committed slot is never reused. */
70
70
  committedSlots(): number[];
71
71
  }
72
+ export interface BasePaymentContext {
73
+ url: string;
74
+ method: string;
75
+ body: unknown;
76
+ requirements: PaymentRequirements;
77
+ }
78
+ export interface BasePreparedPayment {
79
+ payment: PaymentPayload;
80
+ responsePrivateKey: string;
81
+ }
82
+ export declare function validateBaseCreditWitness(witness: BaseCreditWitness): BaseCreditWitness;
83
+ /** Creates the shared local proof factory used by both client transports. */
84
+ export declare function createBasePaymentFactory(options: BasePrepaidClientOptions): (context: BasePaymentContext) => Promise<BasePreparedPayment>;
72
85
  /** Builds the reusable client adapter used by the loopback proxy. */
73
86
  export declare function createBasePrepaidClient(options: BasePrepaidClientOptions): BasePrepaidClient;
74
87
  /**
@@ -24,7 +24,7 @@ function field(value, label) {
24
24
  throw new Error(`Invalid ${label}`);
25
25
  return parsed.toString();
26
26
  }
27
- function assertWitness(witness) {
27
+ export function validateBaseCreditWitness(witness) {
28
28
  if (typeof witness.root !== 'string'
29
29
  || witness.pathElements.length !== TREE_DEPTH
30
30
  || witness.pathIndices.length !== TREE_DEPTH
@@ -60,6 +60,77 @@ function requestBodyBytes(body) {
60
60
  return new Uint8Array();
61
61
  throw new Error('The Base x402 sidecar requires the exact request-body bytes');
62
62
  }
63
+ /** Creates the shared local proof factory used by both client transports. */
64
+ export function createBasePaymentFactory(options) {
65
+ const secret = secretFromBase64Url(options.credential.secret);
66
+ return async ({ method, url, body, requirements }) => {
67
+ if (requirements.extra.deploymentDomain !== options.credential.deploymentDomain) {
68
+ throw new Error('Credential deployment domain does not match the gateway challenge');
69
+ }
70
+ const slotLedger = options.slotLedger;
71
+ if (!slotLedger)
72
+ throw new Error('A durable slot ledger is required for the Base pilot');
73
+ const selectedSlot = slotLedger.allocateProvisional();
74
+ try {
75
+ const nonce = randomNonce();
76
+ const responseKeys = await responseKey();
77
+ const requestSignal = await deriveRequestSignal({
78
+ method,
79
+ url,
80
+ body: requestBodyBytes(body),
81
+ requirements,
82
+ nonce,
83
+ responseKey: responseKeys.publicKey,
84
+ });
85
+ const witness = validateBaseCreditWitness(await options.witnessProvider.witnessForCredential(options.credential));
86
+ const expiry = witness.expiry ?? options.credential.expiry;
87
+ if (!Number.isSafeInteger(expiry) || expiry <= 0)
88
+ throw new Error('Invalid Base credential expiry');
89
+ const slotBlinding = await computeSlotBlinding(secret, selectedSlot, options.credential.deploymentDomain);
90
+ const nullifier = await computeNullifier(slotBlinding);
91
+ const share = await computeShare(secret, requestSignal.field, slotBlinding);
92
+ const timestamp = String(requirements.extra.issuedAt);
93
+ const expectedPublicSignals = [
94
+ witness.root,
95
+ timestamp,
96
+ options.credential.deploymentDomain,
97
+ requestSignal.field,
98
+ nullifier,
99
+ share,
100
+ ];
101
+ const proof = await options.prove({
102
+ secret: secretToField(secret),
103
+ tier_id: String(options.credential.tierId),
104
+ expiry: String(expiry),
105
+ slot: String(selectedSlot),
106
+ merkle_path_elements: witness.pathElements,
107
+ merkle_path_indices: witness.pathIndices.map(String),
108
+ root_in: witness.root,
109
+ timestamp_in: timestamp,
110
+ domain_in: options.credential.deploymentDomain,
111
+ request_signal_in: requestSignal.field,
112
+ }, { requirements, credential: options.credential, expectedPublicSignals });
113
+ if (!Array.isArray(proof.publicSignals)
114
+ || proof.publicSignals.length !== expectedPublicSignals.length
115
+ || !proof.publicSignals.every((value, index) => value === expectedPublicSignals[index])) {
116
+ throw new Error('Proof public signals do not match the canonical statement');
117
+ }
118
+ await slotLedger.commit(selectedSlot);
119
+ const payment = buildPaymentPayload({
120
+ requirements,
121
+ proof: proof.proof,
122
+ publicSignals: [...proof.publicSignals],
123
+ nonce,
124
+ responseKey: responseKeys.publicKey,
125
+ });
126
+ return { payment, responsePrivateKey: responseKeys.privateKey };
127
+ }
128
+ catch (error) {
129
+ slotLedger.release(selectedSlot);
130
+ throw error;
131
+ }
132
+ };
133
+ }
63
134
  function decryptReplay(envelopeValue, privateKey, paymentResponse) {
64
135
  try {
65
136
  const envelope = JSON.parse(envelopeValue);
@@ -89,7 +160,7 @@ function decryptReplay(envelopeValue, privateKey, paymentResponse) {
89
160
  }
90
161
  /** Builds the reusable client adapter used by the loopback proxy. */
91
162
  export function createBasePrepaidClient(options) {
92
- const secret = secretFromBase64Url(options.credential.secret);
163
+ const createPayment = createBasePaymentFactory(options);
93
164
  const fetcher = options.fetch ?? fetch;
94
165
  const replayKeys = new Map();
95
166
  const pendingPayments = new Map();
@@ -244,9 +315,6 @@ export function createBasePrepaidClient(options) {
244
315
  fetch: fetchWithReplay,
245
316
  ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
246
317
  createPayload: async ({ method, url, body, requirements }) => {
247
- if (requirements.extra.deploymentDomain !== options.credential.deploymentDomain) {
248
- throw new Error('Credential deployment domain does not match the gateway challenge');
249
- }
250
318
  prunePending();
251
319
  const key = requestKey(method, url, body, requirements);
252
320
  const cached = pendingPayments.get(key);
@@ -258,73 +326,15 @@ export function createBasePrepaidClient(options) {
258
326
  if (inFlight)
259
327
  return inFlight;
260
328
  const paymentPromise = (async () => {
261
- const slotLedger = options.slotLedger;
262
- if (!slotLedger)
263
- throw new Error('A durable slot ledger is required for the Base pilot');
264
- const selectedSlot = slotLedger.allocateProvisional();
265
- try {
266
- const nonce = randomNonce();
267
- const responseKeys = await responseKey();
268
- const requestSignal = await deriveRequestSignal({
269
- method,
270
- url,
271
- body: requestBodyBytes(body),
272
- requirements,
273
- nonce,
274
- responseKey: responseKeys.publicKey,
275
- });
276
- const witness = assertWitness(await options.witnessProvider.witnessForCredential(options.credential));
277
- const expiry = witness.expiry ?? options.credential.expiry;
278
- if (!Number.isSafeInteger(expiry) || expiry <= 0)
279
- throw new Error('Invalid Base credential expiry');
280
- const slotBlinding = await computeSlotBlinding(secret, selectedSlot, options.credential.deploymentDomain);
281
- const nullifier = await computeNullifier(slotBlinding);
282
- const share = await computeShare(secret, requestSignal.field, slotBlinding);
283
- const timestamp = String(requirements.extra.issuedAt);
284
- const expectedPublicSignals = [
285
- witness.root,
286
- timestamp,
287
- options.credential.deploymentDomain,
288
- requestSignal.field,
289
- nullifier,
290
- share,
291
- ];
292
- const proof = await options.prove({
293
- secret: secretToField(secret),
294
- tier_id: String(options.credential.tierId),
295
- expiry: String(expiry),
296
- slot: String(selectedSlot),
297
- merkle_path_elements: witness.pathElements,
298
- merkle_path_indices: witness.pathIndices.map(String),
299
- root_in: witness.root,
300
- timestamp_in: timestamp,
301
- domain_in: options.credential.deploymentDomain,
302
- request_signal_in: requestSignal.field,
303
- }, { requirements, credential: options.credential, expectedPublicSignals });
304
- if (!Array.isArray(proof.publicSignals)
305
- || proof.publicSignals.length !== expectedPublicSignals.length
306
- || !proof.publicSignals.every((value, index) => value === expectedPublicSignals[index])) {
307
- throw new Error('Proof public signals do not match the canonical statement');
308
- }
309
- await slotLedger.commit(selectedSlot);
310
- const payment = buildPaymentPayload({
311
- requirements,
312
- proof: proof.proof,
313
- publicSignals: [...proof.publicSignals],
314
- nonce,
315
- responseKey: responseKeys.publicKey,
316
- });
317
- const expiresAt = Date.now() + REPLAY_TTL_MS;
318
- replayKeys.set(nonce, { privateKey: responseKeys.privateKey, expiresAt });
319
- pendingPayments.set(key, { payment, expiresAt });
320
- pendingByNonce.set(nonce, key);
321
- prunePending();
322
- return payment;
323
- }
324
- catch (error) {
325
- slotLedger.release(selectedSlot);
326
- throw error;
327
- }
329
+ const prepared = await createPayment({ method, url, body, requirements });
330
+ const payment = prepared.payment;
331
+ const nonce = payment.payload.nonce;
332
+ const expiresAt = Date.now() + REPLAY_TTL_MS;
333
+ replayKeys.set(nonce, { privateKey: prepared.responsePrivateKey, expiresAt });
334
+ pendingPayments.set(key, { payment, expiresAt });
335
+ pendingByNonce.set(nonce, key);
336
+ prunePending();
337
+ return payment;
328
338
  })();
329
339
  paymentPromises.set(key, paymentPromise);
330
340
  try {
@@ -3,6 +3,7 @@ export interface CliCommandDependencies {
3
3
  readToken(): Promise<string>;
4
4
  write(line: string): void;
5
5
  isCredentialConfigured(): Promise<boolean>;
6
+ validateSetupPrerequisites(): Promise<void>;
6
7
  configureCodex(model?: string): Promise<void>;
7
8
  ensureSidecar(): Promise<string>;
8
9
  isCodexProfileInstalled(): Promise<boolean>;
@@ -19,8 +19,9 @@ export async function runCliCommand(args, dependencies) {
19
19
  const model = setupModel(args);
20
20
  if (!await dependencies.isCredentialConfigured())
21
21
  throw new Error('Set ZK_CREDITS_CREDENTIAL_PATH before running zk-credits setup');
22
- await dependencies.configureCodex(model);
22
+ await dependencies.validateSetupPrerequisites();
23
23
  await dependencies.ensureSidecar();
24
+ await dependencies.configureCodex(model);
24
25
  dependencies.write('ZK Credits is ready for Codex.');
25
26
  dependencies.write('Run: zk-credits codex');
26
27
  return 0;