zk-credits 0.2.0 → 0.2.1

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
@@ -21,9 +21,11 @@ not mix versions between the sidecar, the adapter package, and the gateway:
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.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
27
29
  (`private-credit-spend-bn254-dev-sepolia-v1`) are delivered directly through
28
30
  the invite channel, never from a public host.
29
31
 
@@ -43,9 +45,9 @@ an arbitrary API call, and the gateway enforces the class before it reserves
43
45
  the credit:
44
46
 
45
47
  - text messages, tool definitions, and tool calls, with one generated choice;
46
- - no streaming, images, files, audio, web plugins, model fallback,
48
+ - no provider streaming, images, files, audio, web search, model fallback,
47
49
  client-selected routing, or unknown cost-affecting fields;
48
- - input capped at 16,000 UTF-8 bytes (counted conservatively, never below the
50
+ - input capped at 128,000 UTF-8 byte units (counted conservatively, never below the
49
51
  provider's token count), output at 4,000 tokens, request body at 256 KiB,
50
52
  encrypted replay at 1 MiB, and upstream timeout at 120 seconds.
51
53
 
@@ -55,32 +57,27 @@ still receives the class. A rejected request consumes no credit.
55
57
 
56
58
  ## First run
57
59
 
58
- 1. Redeem an invite to the unpaid, experimental Base Sepolia pilot and
59
- download the password-encrypted credential backup. Credits are
60
+ 1. Redeem an invite and download the encrypted credential. Credits are
60
61
  founder-provisioned test credits; there is no payment step.
61
- 2. Install the pinned proving artifacts. The package ships
62
+ 2. Install `zk-credits` and ask your pilot contact for the pinned proving
63
+ bundle and public Base tree file. The package ships
62
64
  `circuits/manifest.json`, which fixes the SHA-256 of the frozen
63
65
  `private_credit_spend.wasm`, `private_credit_spend.zkey`, and
64
- `verification_key_private_credit.json`. The bytes are installed out of
65
- band; the sidecar never fetches proving material at runtime:
66
+ `verification_key_private_credit.json`. The sidecar never fetches proving
67
+ material at runtime. Set the paths to the downloaded files and gateway:
66
68
 
67
69
  ```bash
68
- export ZK_CREDITS_ARTIFACT_DIR="$PWD/private-credit-bundle"
69
- ls "$ZK_CREDITS_ARTIFACT_DIR" # the three pinned files
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
+ export ZK_CREDITS_GATEWAY_URL="https://zk-credits-gateway.onrender.com"
70
74
  ```
71
75
 
72
76
  A missing, relocated, symlinked-out, or altered artifact fails closed
73
77
  before the first prove. A hash mismatch is a proof failure: no payment
74
78
  leaves, and the local slot stays reusable.
75
79
 
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
80
+ `ZK_CREDITS_WITNESS_PATH` points to a local witness artifact — either a
84
81
  prepared witness (`root`, `pathElements`, `pathIndices`) or a public tree
85
82
  (`leaves: [{ index, leaf, expiry? }]`, optional `root`) from which the
86
83
  sidecar derives the depth-20 path — or configure `BASE_RPC_URL`,
@@ -89,24 +86,29 @@ still receives the class. A rejected request consumes no credit.
89
86
  events and builds a local Merkle witness. The gateway never serves a path
90
87
  for a named leaf or commitment.
91
88
 
92
- 4. Run:
89
+ 3. Start setup. It verifies the credential, pinned artifact hashes, and
90
+ witness locally, then prompts for the backup password without echo:
93
91
 
94
92
  ```bash
95
- zk-credits cline "summarize this repository"
96
- zk-credits setup codex && zk-credits codex "summarize this repository"
93
+ zk-credits setup codex
97
94
  ```
98
95
 
96
+ Then run `zk-credits codex` to open Codex with the local sidecar.
97
+
99
98
  The sidecar binds `127.0.0.1:3210` only. It does not modify `~/.cline` or
100
99
  `~/.codex`.
101
100
 
102
101
  ## Supported route
103
102
 
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.
103
+ The pilot serves one bounded upstream spend path: non-streaming
104
+ `POST /v1/chat/completions`, either through this sidecar or through an
105
+ x402-native agent that explicitly registers the project `zk-prepaid` adapter.
106
+ The sidecar also accepts Codex `POST /v1/responses` requests with text input
107
+ and function tools, translates them to that bounded chat request, and converts
108
+ the committed result into Responses SSE events. It rejects unsupported fields
109
+ before starting a proof. Generic x402 clients, public facilitators, Bazaar,
110
+ MCP, and the standard `exact` rail are unsupported; the sidecar never falls
111
+ back to another rail.
110
112
 
111
113
  | Route | Auth | Purpose |
112
114
  | --- | --- | --- |
@@ -114,10 +116,14 @@ unsupported; the sidecar never falls back to another rail.
114
116
  | `GET /v1/models` | local token | Codex model discovery |
115
117
  | `GET /metrics` | local token | Aggregate proof metrics (below) |
116
118
  | `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
119
+ | `POST /v1/responses` | local token | Codex bridge to the bounded chat spend path |
120
+
121
+ The chat route rejects streaming bodies (`stream`, `stream_options`) and a
122
+ missing `model` with a `4xx` before any proof. The Responses route requires
123
+ `stream: true`, text-only message/function-call input, and the known Codex
124
+ metadata fields; it rejects images, unsupported tools, and unknown controls
125
+ before any proof. Its upstream request is always `stream: false`. Anthropic
126
+ `/v1/messages` and unknown paths return `404 unsupported_openai_path`. The
121
127
  sidecar never substitutes a model and never falls back to another rail.
122
128
 
123
129
  ### Gateway responses and retries
@@ -179,9 +185,21 @@ zk-credits setup codex [--model <model>]
179
185
  zk-credits codex [codex arguments...]
180
186
  zk-credits status
181
187
  zk-credits serve [--port <port>]
188
+ zk-credits trial-registered-adapter
182
189
  eval "$(zk-credits env)"
183
190
  ```
184
191
 
192
+ `trial-registered-adapter` is a maintainer-only internal trial command. It
193
+ requires the locally recovered credential and pinned proving bundle, prompts
194
+ for the credential password and gateway admin token without echo when they are
195
+ not already configured, and requires an explicit confirmation before sending
196
+ one billable request. It checks gateway readiness, authenticated admin status,
197
+ Base root agreement, Codex profile/sidecar status, and remaining local slot
198
+ capacity, then reports only the exchange phases and aggregate counter deltas.
199
+ Run it only after recovery is complete and with the same `ZK_CREDITS_HOME`
200
+ used by the sidecar; it registers `zk-prepaid` directly through the official
201
+ x402 client adapter and reuses the durable slot ledger.
202
+
185
203
  Codex SDK:
186
204
 
187
205
  ```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;