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 +74 -42
- package/dist/base-registered-trial.d.ts +33 -0
- package/dist/base-registered-trial.js +168 -0
- package/dist/base-sidecar.d.ts +14 -1
- package/dist/base-sidecar.js +82 -72
- package/dist/cli-runtime.d.ts +1 -0
- package/dist/cli-runtime.js +2 -1
- package/dist/cli.js +418 -16
- package/dist/node-sidecar-lifecycle.d.ts +2 -0
- package/dist/node-sidecar-lifecycle.js +29 -7
- package/dist/responses-bridge.d.ts +14 -0
- package/dist/responses-bridge.js +328 -0
- package/dist/setup-config.d.ts +36 -0
- package/dist/setup-config.js +320 -0
- package/dist/sidecar.js +52 -8
- package/dist/zk-credits.js +1409 -189
- package/package.json +2 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
function hasOnlyKeys(value, allowed) {
|
|
6
|
+
return Object.keys(value).every((key) => allowed.includes(key));
|
|
7
|
+
}
|
|
8
|
+
function textContent(value) {
|
|
9
|
+
if (typeof value === 'string')
|
|
10
|
+
return value;
|
|
11
|
+
if (!Array.isArray(value))
|
|
12
|
+
return undefined;
|
|
13
|
+
const parts = [];
|
|
14
|
+
for (const part of value) {
|
|
15
|
+
if (!isRecord(part) || !hasOnlyKeys(part, ['type', 'text'])
|
|
16
|
+
|| (part.type !== 'input_text' && part.type !== 'output_text')
|
|
17
|
+
|| typeof part.text !== 'string')
|
|
18
|
+
return undefined;
|
|
19
|
+
parts.push({ type: 'text', text: part.text });
|
|
20
|
+
}
|
|
21
|
+
return parts.length === 1 ? parts[0].text : parts;
|
|
22
|
+
}
|
|
23
|
+
function responseInputMessages(input) {
|
|
24
|
+
if (typeof input === 'string')
|
|
25
|
+
return [{ role: 'user', content: input }];
|
|
26
|
+
if (!Array.isArray(input))
|
|
27
|
+
return undefined;
|
|
28
|
+
const messages = [];
|
|
29
|
+
for (const item of input) {
|
|
30
|
+
if (!isRecord(item) || typeof item.type !== 'string')
|
|
31
|
+
return undefined;
|
|
32
|
+
if (item.type === 'message') {
|
|
33
|
+
if (!hasOnlyKeys(item, ['type', 'id', 'role', 'content'])
|
|
34
|
+
|| (item.id !== undefined && typeof item.id !== 'string')
|
|
35
|
+
|| !['system', 'developer', 'user', 'assistant'].includes(String(item.role)))
|
|
36
|
+
return undefined;
|
|
37
|
+
const content = textContent(item.content);
|
|
38
|
+
if (content === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
messages.push({ role: item.role, content });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (item.type === 'function_call') {
|
|
44
|
+
if (!hasOnlyKeys(item, ['type', 'id', 'status', 'call_id', 'name', 'arguments'])
|
|
45
|
+
|| typeof item.call_id !== 'string' || typeof item.name !== 'string'
|
|
46
|
+
|| typeof item.arguments !== 'string'
|
|
47
|
+
|| (item.id !== undefined && typeof item.id !== 'string')
|
|
48
|
+
|| (item.status !== undefined && typeof item.status !== 'string'))
|
|
49
|
+
return undefined;
|
|
50
|
+
messages.push({
|
|
51
|
+
role: 'assistant',
|
|
52
|
+
content: null,
|
|
53
|
+
tool_calls: [{ id: item.call_id, type: 'function', function: { name: item.name, arguments: item.arguments } }],
|
|
54
|
+
});
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (item.type === 'function_call_output') {
|
|
58
|
+
if (!hasOnlyKeys(item, ['type', 'id', 'status', 'call_id', 'output'])
|
|
59
|
+
|| typeof item.call_id !== 'string' || typeof item.output !== 'string'
|
|
60
|
+
|| (item.id !== undefined && typeof item.id !== 'string')
|
|
61
|
+
|| (item.status !== undefined && typeof item.status !== 'string'))
|
|
62
|
+
return undefined;
|
|
63
|
+
messages.push({ role: 'tool', tool_call_id: item.call_id, content: item.output });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
return messages;
|
|
69
|
+
}
|
|
70
|
+
function responseTools(value) {
|
|
71
|
+
if (!Array.isArray(value))
|
|
72
|
+
return undefined;
|
|
73
|
+
const tools = [];
|
|
74
|
+
for (const tool of value) {
|
|
75
|
+
if (!isRecord(tool) || typeof tool.type !== 'string')
|
|
76
|
+
return undefined;
|
|
77
|
+
if (tool.type === 'function') {
|
|
78
|
+
if (!hasOnlyKeys(tool, ['type', 'name', 'description', 'parameters', 'strict'])
|
|
79
|
+
|| typeof tool.name !== 'string' || tool.name.length === 0
|
|
80
|
+
|| (tool.description !== undefined && typeof tool.description !== 'string')
|
|
81
|
+
|| (tool.parameters !== undefined && !isRecord(tool.parameters))
|
|
82
|
+
|| (tool.strict !== undefined && typeof tool.strict !== 'boolean'))
|
|
83
|
+
return undefined;
|
|
84
|
+
const definition = { name: tool.name };
|
|
85
|
+
if (tool.description !== undefined)
|
|
86
|
+
definition.description = tool.description;
|
|
87
|
+
if (tool.parameters !== undefined)
|
|
88
|
+
definition.parameters = tool.parameters;
|
|
89
|
+
if (tool.strict !== undefined)
|
|
90
|
+
definition.strict = tool.strict;
|
|
91
|
+
tools.push({ type: 'function', function: definition });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Codex includes these tool declarations in each Responses request. The
|
|
95
|
+
// fixed service class has no web-search executor and accepts only direct
|
|
96
|
+
// function tools, so validate their known shape and drop them.
|
|
97
|
+
if (tool.type === 'web_search') {
|
|
98
|
+
if (!hasOnlyKeys(tool, ['type', 'external_web_access'])
|
|
99
|
+
|| (tool.external_web_access !== undefined && typeof tool.external_web_access !== 'boolean'))
|
|
100
|
+
return undefined;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (tool.type === 'namespace') {
|
|
104
|
+
if (!hasOnlyKeys(tool, ['type', 'name', 'description', 'tools'])
|
|
105
|
+
|| typeof tool.name !== 'string'
|
|
106
|
+
|| (tool.description !== undefined && typeof tool.description !== 'string')
|
|
107
|
+
|| !Array.isArray(tool.tools)
|
|
108
|
+
|| tool.tools.some((nested) => !isRecord(nested) || nested.type !== 'function' || typeof nested.name !== 'string'))
|
|
109
|
+
return undefined;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
return tools;
|
|
115
|
+
}
|
|
116
|
+
function translatedToolChoice(value) {
|
|
117
|
+
if (value === 'none' || value === 'auto' || value === 'required')
|
|
118
|
+
return value;
|
|
119
|
+
if (!isRecord(value) || value.type !== 'function' || !hasOnlyKeys(value, ['type', 'name'])
|
|
120
|
+
|| typeof value.name !== 'string')
|
|
121
|
+
return undefined;
|
|
122
|
+
return { type: 'function', function: { name: value.name } };
|
|
123
|
+
}
|
|
124
|
+
/** Converts the small Responses surface emitted by Codex into the fixed Chat Completions request. */
|
|
125
|
+
export function translateResponsesRequest(body) {
|
|
126
|
+
if (!isRecord(body))
|
|
127
|
+
return { ok: false, error: 'invalid_request_body' };
|
|
128
|
+
const allowed = [
|
|
129
|
+
'model', 'instructions', 'input', 'stream', 'tools', 'tool_choice', 'max_output_tokens',
|
|
130
|
+
'parallel_tool_calls', 'store', 'client_metadata', 'prompt_cache_key', 'include', 'reasoning',
|
|
131
|
+
'temperature', 'top_p', 'stop', 'seed', 'presence_penalty', 'frequency_penalty', 'logit_bias',
|
|
132
|
+
];
|
|
133
|
+
if (!hasOnlyKeys(body, allowed))
|
|
134
|
+
return { ok: false, error: 'unsupported_request_field' };
|
|
135
|
+
if (typeof body.model !== 'string' || body.model.length === 0)
|
|
136
|
+
return { ok: false, error: 'model_required' };
|
|
137
|
+
if (body.stream !== true)
|
|
138
|
+
return { ok: false, error: 'streaming_required' };
|
|
139
|
+
if (body.store !== undefined && body.store !== false)
|
|
140
|
+
return { ok: false, error: 'stored_responses_unsupported' };
|
|
141
|
+
if (body.instructions !== undefined && typeof body.instructions !== 'string')
|
|
142
|
+
return { ok: false, error: 'invalid_instructions' };
|
|
143
|
+
if (body.prompt_cache_key !== undefined && typeof body.prompt_cache_key !== 'string')
|
|
144
|
+
return { ok: false, error: 'invalid_prompt_cache_key' };
|
|
145
|
+
if (body.client_metadata !== undefined
|
|
146
|
+
&& (!isRecord(body.client_metadata) || Object.values(body.client_metadata).some((value) => typeof value !== 'string'))) {
|
|
147
|
+
return { ok: false, error: 'invalid_client_metadata' };
|
|
148
|
+
}
|
|
149
|
+
if (body.include !== undefined
|
|
150
|
+
&& (!Array.isArray(body.include) || body.include.some((value) => value !== 'reasoning.encrypted_content'))) {
|
|
151
|
+
return { ok: false, error: 'unsupported_include' };
|
|
152
|
+
}
|
|
153
|
+
if (body.reasoning !== undefined
|
|
154
|
+
&& (!isRecord(body.reasoning) || !hasOnlyKeys(body.reasoning, ['summary'])
|
|
155
|
+
|| (body.reasoning.summary !== undefined && !['auto', 'concise', 'detailed'].includes(String(body.reasoning.summary))))) {
|
|
156
|
+
return { ok: false, error: 'unsupported_reasoning_controls' };
|
|
157
|
+
}
|
|
158
|
+
if (body.parallel_tool_calls !== undefined && typeof body.parallel_tool_calls !== 'boolean') {
|
|
159
|
+
return { ok: false, error: 'invalid_parallel_tool_calls' };
|
|
160
|
+
}
|
|
161
|
+
const messages = [];
|
|
162
|
+
if (body.instructions !== undefined)
|
|
163
|
+
messages.push({ role: 'developer', content: body.instructions });
|
|
164
|
+
const inputMessages = responseInputMessages(body.input);
|
|
165
|
+
if (!inputMessages)
|
|
166
|
+
return { ok: false, error: 'unsupported_input_item' };
|
|
167
|
+
messages.push(...inputMessages);
|
|
168
|
+
// The incoming stream:true is a Codex wire contract. The gateway service
|
|
169
|
+
// class fixes stream=false in its normalized provider request.
|
|
170
|
+
const chatRequest = { model: body.model, messages };
|
|
171
|
+
if (body.tools !== undefined) {
|
|
172
|
+
const tools = responseTools(body.tools);
|
|
173
|
+
if (!tools)
|
|
174
|
+
return { ok: false, error: 'unsupported_tool' };
|
|
175
|
+
if (tools.length)
|
|
176
|
+
chatRequest.tools = tools;
|
|
177
|
+
}
|
|
178
|
+
if (body.tool_choice !== undefined) {
|
|
179
|
+
const toolChoice = translatedToolChoice(body.tool_choice);
|
|
180
|
+
if (toolChoice === undefined)
|
|
181
|
+
return { ok: false, error: 'invalid_tool_choice' };
|
|
182
|
+
chatRequest.tool_choice = toolChoice;
|
|
183
|
+
}
|
|
184
|
+
if (body.max_output_tokens !== undefined) {
|
|
185
|
+
if (!Number.isInteger(body.max_output_tokens) || body.max_output_tokens <= 0) {
|
|
186
|
+
return { ok: false, error: 'invalid_max_output_tokens' };
|
|
187
|
+
}
|
|
188
|
+
chatRequest.max_completion_tokens = body.max_output_tokens;
|
|
189
|
+
}
|
|
190
|
+
if (body.parallel_tool_calls !== undefined)
|
|
191
|
+
chatRequest.parallel_tool_calls = body.parallel_tool_calls;
|
|
192
|
+
for (const key of ['temperature', 'top_p', 'stop', 'seed', 'presence_penalty', 'frequency_penalty', 'logit_bias']) {
|
|
193
|
+
if (body[key] !== undefined)
|
|
194
|
+
chatRequest[key] = body[key];
|
|
195
|
+
}
|
|
196
|
+
return { ok: true, chatRequest, responseModel: body.model };
|
|
197
|
+
}
|
|
198
|
+
function usageFor(chat) {
|
|
199
|
+
if (!isRecord(chat.usage))
|
|
200
|
+
return null;
|
|
201
|
+
const promptTokens = chat.usage.prompt_tokens;
|
|
202
|
+
const completionTokens = chat.usage.completion_tokens;
|
|
203
|
+
const totalTokens = chat.usage.total_tokens;
|
|
204
|
+
if (![promptTokens, completionTokens, totalTokens].every((value) => Number.isInteger(value) && value >= 0))
|
|
205
|
+
return null;
|
|
206
|
+
return {
|
|
207
|
+
input_tokens: promptTokens,
|
|
208
|
+
output_tokens: completionTokens,
|
|
209
|
+
total_tokens: totalTokens,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** Builds the Responses SSE event sequence from one committed Chat Completions result. */
|
|
213
|
+
export function responsesEventStream(chatValue, model) {
|
|
214
|
+
if (!isRecord(chatValue) || !Array.isArray(chatValue.choices) || !isRecord(chatValue.choices[0])) {
|
|
215
|
+
throw new Error('Gateway returned an invalid chat completion');
|
|
216
|
+
}
|
|
217
|
+
const choice = chatValue.choices[0];
|
|
218
|
+
if (!isRecord(choice.message))
|
|
219
|
+
throw new Error('Gateway returned an invalid chat message');
|
|
220
|
+
const message = choice.message;
|
|
221
|
+
const responseSuffix = randomUUID().replaceAll('-', '');
|
|
222
|
+
const responseId = `resp_${responseSuffix}`;
|
|
223
|
+
const events = [];
|
|
224
|
+
let sequence = 0;
|
|
225
|
+
const add = (event, data) => {
|
|
226
|
+
events.push({ event, data: { ...data, sequence_number: sequence++ } });
|
|
227
|
+
};
|
|
228
|
+
const createdAt = Number.isInteger(chatValue.created) ? chatValue.created : Math.floor(Date.now() / 1000);
|
|
229
|
+
const usage = usageFor(chatValue);
|
|
230
|
+
const base = {
|
|
231
|
+
id: responseId,
|
|
232
|
+
object: 'response',
|
|
233
|
+
created_at: createdAt,
|
|
234
|
+
status: 'in_progress',
|
|
235
|
+
completed_at: null,
|
|
236
|
+
error: null,
|
|
237
|
+
incomplete_details: null,
|
|
238
|
+
model,
|
|
239
|
+
output: [],
|
|
240
|
+
parallel_tool_calls: true,
|
|
241
|
+
metadata: {},
|
|
242
|
+
usage: null,
|
|
243
|
+
};
|
|
244
|
+
add('response.created', { type: 'response.created', response: base });
|
|
245
|
+
add('response.in_progress', { type: 'response.in_progress', response: base });
|
|
246
|
+
const output = [];
|
|
247
|
+
const text = message.content;
|
|
248
|
+
const refusal = message.refusal;
|
|
249
|
+
if (text !== null && text !== undefined && typeof text !== 'string') {
|
|
250
|
+
throw new Error('Gateway returned an unsupported chat content value');
|
|
251
|
+
}
|
|
252
|
+
if (refusal !== undefined && typeof refusal !== 'string')
|
|
253
|
+
throw new Error('Gateway returned an invalid refusal value');
|
|
254
|
+
if (typeof text === 'string' || typeof refusal === 'string') {
|
|
255
|
+
const itemId = `msg_${randomUUID().replaceAll('-', '')}`;
|
|
256
|
+
const isRefusal = typeof refusal === 'string';
|
|
257
|
+
const part = isRefusal
|
|
258
|
+
? { type: 'refusal', refusal }
|
|
259
|
+
: { type: 'output_text', text: text ?? '', annotations: [] };
|
|
260
|
+
const item = { id: itemId, type: 'message', status: 'completed', role: 'assistant', content: [part] };
|
|
261
|
+
add('response.output_item.added', {
|
|
262
|
+
type: 'response.output_item.added', output_index: output.length,
|
|
263
|
+
item: { id: itemId, type: 'message', status: 'in_progress', role: 'assistant', content: [] },
|
|
264
|
+
});
|
|
265
|
+
add('response.content_part.added', {
|
|
266
|
+
type: 'response.content_part.added', output_index: output.length, content_index: 0,
|
|
267
|
+
part: isRefusal ? { type: 'refusal', refusal: '' } : { type: 'output_text', text: '', annotations: [] },
|
|
268
|
+
});
|
|
269
|
+
if (isRefusal) {
|
|
270
|
+
add('response.refusal.delta', { type: 'response.refusal.delta', item_id: itemId, output_index: output.length, content_index: 0, delta: refusal });
|
|
271
|
+
add('response.refusal.done', { type: 'response.refusal.done', item_id: itemId, output_index: output.length, content_index: 0, refusal });
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
add('response.output_text.delta', { type: 'response.output_text.delta', item_id: itemId, output_index: output.length, content_index: 0, delta: text });
|
|
275
|
+
add('response.output_text.done', { type: 'response.output_text.done', item_id: itemId, output_index: output.length, content_index: 0, text });
|
|
276
|
+
}
|
|
277
|
+
add('response.content_part.done', { type: 'response.content_part.done', output_index: output.length, content_index: 0, part });
|
|
278
|
+
add('response.output_item.done', { type: 'response.output_item.done', output_index: output.length, item });
|
|
279
|
+
output.push(item);
|
|
280
|
+
}
|
|
281
|
+
if (message.tool_calls !== undefined) {
|
|
282
|
+
if (!Array.isArray(message.tool_calls))
|
|
283
|
+
throw new Error('Gateway returned invalid tool calls');
|
|
284
|
+
for (const callValue of message.tool_calls) {
|
|
285
|
+
if (!isRecord(callValue) || callValue.type !== 'function' || !isRecord(callValue.function)
|
|
286
|
+
|| typeof callValue.function.name !== 'string' || typeof callValue.function.arguments !== 'string') {
|
|
287
|
+
throw new Error('Gateway returned an invalid function call');
|
|
288
|
+
}
|
|
289
|
+
const callId = typeof callValue.id === 'string' ? callValue.id : `call_${randomUUID().replaceAll('-', '')}`;
|
|
290
|
+
const itemId = `fc_${randomUUID().replaceAll('-', '')}`;
|
|
291
|
+
const item = {
|
|
292
|
+
id: itemId,
|
|
293
|
+
type: 'function_call',
|
|
294
|
+
status: 'completed',
|
|
295
|
+
call_id: callId,
|
|
296
|
+
name: callValue.function.name,
|
|
297
|
+
arguments: callValue.function.arguments,
|
|
298
|
+
};
|
|
299
|
+
add('response.output_item.added', {
|
|
300
|
+
type: 'response.output_item.added', output_index: output.length,
|
|
301
|
+
item: { ...item, status: 'in_progress', arguments: '' },
|
|
302
|
+
});
|
|
303
|
+
add('response.function_call_arguments.delta', {
|
|
304
|
+
type: 'response.function_call_arguments.delta', item_id: itemId, output_index: output.length,
|
|
305
|
+
delta: callValue.function.arguments,
|
|
306
|
+
});
|
|
307
|
+
add('response.function_call_arguments.done', {
|
|
308
|
+
type: 'response.function_call_arguments.done', item_id: itemId, output_index: output.length,
|
|
309
|
+
arguments: callValue.function.arguments,
|
|
310
|
+
});
|
|
311
|
+
add('response.output_item.done', { type: 'response.output_item.done', output_index: output.length, item });
|
|
312
|
+
output.push(item);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const incomplete = choice.finish_reason === 'length';
|
|
316
|
+
const final = {
|
|
317
|
+
...base,
|
|
318
|
+
status: incomplete ? 'incomplete' : 'completed',
|
|
319
|
+
completed_at: Math.floor(Date.now() / 1000),
|
|
320
|
+
incomplete_details: incomplete ? { reason: 'max_output_tokens' } : null,
|
|
321
|
+
output,
|
|
322
|
+
...(usage ? { usage } : {}),
|
|
323
|
+
};
|
|
324
|
+
add(incomplete ? 'response.incomplete' : 'response.completed', {
|
|
325
|
+
type: incomplete ? 'response.incomplete' : 'response.completed', response: final,
|
|
326
|
+
});
|
|
327
|
+
return events.map(({ event, data }) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`).join('');
|
|
328
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type CircuitManifest } from './artifact-bundle.js';
|
|
2
|
+
export type SetupWitnessSource = {
|
|
3
|
+
kind: 'file';
|
|
4
|
+
path: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'base-events';
|
|
7
|
+
rpcUrl: string;
|
|
8
|
+
contractAddress: string;
|
|
9
|
+
deploymentBlock?: string;
|
|
10
|
+
confirmations?: string;
|
|
11
|
+
};
|
|
12
|
+
export interface SetupConfig {
|
|
13
|
+
version: 1;
|
|
14
|
+
artifactDirectory: string;
|
|
15
|
+
witness: SetupWitnessSource;
|
|
16
|
+
}
|
|
17
|
+
export interface ResolveSetupInputsOptions {
|
|
18
|
+
environment: NodeJS.ProcessEnv;
|
|
19
|
+
homeDirectory: string;
|
|
20
|
+
stateDirectory: string;
|
|
21
|
+
configPath: string;
|
|
22
|
+
manifest: CircuitManifest;
|
|
23
|
+
/** Must prove this source resolves a valid witness for the activated credential. */
|
|
24
|
+
validateWitness(source: SetupWitnessSource): Promise<void>;
|
|
25
|
+
/** Called only when more than one valid local candidate exists. */
|
|
26
|
+
chooseCandidate?(kind: 'proving bundle' | 'witness file', candidates: readonly string[]): Promise<string>;
|
|
27
|
+
workingDirectory?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Finds only known local locations and validates both sources before setup continues. */
|
|
30
|
+
export declare function resolveSetupInputs(options: ResolveSetupInputsOptions): Promise<SetupConfig>;
|
|
31
|
+
/** Reads the operator-local setup config. Missing config is normal on first run. */
|
|
32
|
+
export declare function readSetupConfig(path: string): Promise<SetupConfig | undefined>;
|
|
33
|
+
/** Atomically writes paths and witness configuration with owner-only permissions. */
|
|
34
|
+
export declare function writeSetupConfig(path: string, config: SetupConfig): Promise<void>;
|
|
35
|
+
/** Applies saved paths to a process environment while preserving explicit overrides. */
|
|
36
|
+
export declare function applySetupConfig(environment: NodeJS.ProcessEnv, config: SetupConfig): void;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { resolvePinnedArtifactBundle } from './artifact-bundle.js';
|
|
5
|
+
const WITNESS_FILENAMES = new Set([
|
|
6
|
+
'base-tree.json',
|
|
7
|
+
'base-sepolia-tree.json',
|
|
8
|
+
'base-tree-artifact.json',
|
|
9
|
+
'public-tree.json',
|
|
10
|
+
'base-witness.json',
|
|
11
|
+
'zk-credits-witness.json',
|
|
12
|
+
'witness.json',
|
|
13
|
+
'tree.json',
|
|
14
|
+
]);
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function absolutePath(value, label) {
|
|
19
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
20
|
+
throw new Error('Setup configuration ' + label + ' is required');
|
|
21
|
+
}
|
|
22
|
+
return resolve(value);
|
|
23
|
+
}
|
|
24
|
+
function optionalUnsignedString(value, label) {
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (typeof value !== 'string' || !/^\d+$/u.test(value)) {
|
|
28
|
+
throw new Error('Local setup config ' + label + ' must be a non-negative integer');
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function parseSetupConfig(value) {
|
|
33
|
+
if (!isRecord(value) || value.version !== 1 || !isRecord(value.witness)) {
|
|
34
|
+
throw new Error('Local setup config is malformed; remove ~/.zk-credits/config.json and run setup again.');
|
|
35
|
+
}
|
|
36
|
+
const artifactDirectory = absolutePath(value.artifactDirectory, 'artifactDirectory');
|
|
37
|
+
const witnessValue = value.witness;
|
|
38
|
+
let witness;
|
|
39
|
+
if (witnessValue.kind === 'file') {
|
|
40
|
+
witness = { kind: 'file', path: absolutePath(witnessValue.path, 'witness.path') };
|
|
41
|
+
}
|
|
42
|
+
else if (witnessValue.kind === 'base-events') {
|
|
43
|
+
if (typeof witnessValue.rpcUrl !== 'string' || !/^https?:\/\//u.test(witnessValue.rpcUrl)) {
|
|
44
|
+
throw new Error('Local setup config has an invalid Base RPC URL');
|
|
45
|
+
}
|
|
46
|
+
if (typeof witnessValue.contractAddress !== 'string' || !/^0x[0-9a-fA-F]{40}$/u.test(witnessValue.contractAddress)) {
|
|
47
|
+
throw new Error('Local setup config has an invalid Base bond contract address');
|
|
48
|
+
}
|
|
49
|
+
const deploymentBlock = optionalUnsignedString(witnessValue.deploymentBlock, 'deploymentBlock');
|
|
50
|
+
const confirmations = optionalUnsignedString(witnessValue.confirmations, 'confirmations');
|
|
51
|
+
witness = {
|
|
52
|
+
kind: 'base-events',
|
|
53
|
+
rpcUrl: witnessValue.rpcUrl,
|
|
54
|
+
contractAddress: witnessValue.contractAddress,
|
|
55
|
+
...(deploymentBlock === undefined ? {} : { deploymentBlock }),
|
|
56
|
+
...(confirmations === undefined ? {} : { confirmations }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
throw new Error('Local setup config has an unknown witness source');
|
|
61
|
+
}
|
|
62
|
+
return { version: 1, artifactDirectory, witness };
|
|
63
|
+
}
|
|
64
|
+
function optionalEnvironmentInteger(environment, name) {
|
|
65
|
+
const value = environment[name]?.trim();
|
|
66
|
+
if (!value)
|
|
67
|
+
return undefined;
|
|
68
|
+
if (!/^\d+$/u.test(value))
|
|
69
|
+
throw new Error(name + ' must be a non-negative integer');
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
async function candidateDirectories(homeDirectory, stateDirectory) {
|
|
73
|
+
const roots = [
|
|
74
|
+
join(stateDirectory, 'artifacts', 'base-sepolia'),
|
|
75
|
+
join(homeDirectory, '.zk-credits', 'artifacts', 'base-sepolia'),
|
|
76
|
+
join(homeDirectory, 'Downloads'),
|
|
77
|
+
];
|
|
78
|
+
const candidates = [...roots];
|
|
79
|
+
for (const root of roots) {
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.isDirectory())
|
|
89
|
+
candidates.push(join(root, entry.name));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [...new Set(candidates.map((path) => resolve(path)))];
|
|
93
|
+
}
|
|
94
|
+
async function chooseOne(kind, candidates, chooseCandidate) {
|
|
95
|
+
if (candidates.length === 1)
|
|
96
|
+
return candidates[0];
|
|
97
|
+
if (!chooseCandidate) {
|
|
98
|
+
const variable = kind === 'proving bundle' ? 'ZK_CREDITS_ARTIFACT_DIR' : 'ZK_CREDITS_WITNESS_PATH';
|
|
99
|
+
throw new Error('Found more than one valid ' + kind + '; set ' + variable + ' to choose one.');
|
|
100
|
+
}
|
|
101
|
+
const selected = await chooseCandidate(kind, candidates);
|
|
102
|
+
if (!candidates.includes(resolve(selected))) {
|
|
103
|
+
throw new Error('The selected ' + kind + ' is not one of the validated local candidates');
|
|
104
|
+
}
|
|
105
|
+
return resolve(selected);
|
|
106
|
+
}
|
|
107
|
+
async function resolveArtifactDirectory(options, saved) {
|
|
108
|
+
const explicit = options.environment.ZK_CREDITS_ARTIFACT_DIR?.trim();
|
|
109
|
+
const candidates = explicit
|
|
110
|
+
? [resolve(options.workingDirectory ?? process.cwd(), explicit)]
|
|
111
|
+
: [
|
|
112
|
+
...(saved ? [saved.artifactDirectory] : []),
|
|
113
|
+
...await candidateDirectories(options.homeDirectory, options.stateDirectory),
|
|
114
|
+
];
|
|
115
|
+
const valid = new Map();
|
|
116
|
+
let explicitError;
|
|
117
|
+
for (const candidate of candidates) {
|
|
118
|
+
try {
|
|
119
|
+
const bundle = await resolvePinnedArtifactBundle({
|
|
120
|
+
artifactDirectory: candidate,
|
|
121
|
+
manifest: options.manifest,
|
|
122
|
+
});
|
|
123
|
+
let identity = resolve(bundle.directory);
|
|
124
|
+
try {
|
|
125
|
+
identity = await realpath(bundle.directory);
|
|
126
|
+
}
|
|
127
|
+
catch { /* resolve already validated the directory */ }
|
|
128
|
+
valid.set(identity, bundle.directory);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (explicit)
|
|
132
|
+
explicitError = error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (explicit && valid.size === 0) {
|
|
136
|
+
const reason = explicitError instanceof Error ? ' ' + explicitError.message : '';
|
|
137
|
+
throw new Error('The configured proving bundle at ' + explicit + ' failed its pinned checks.' + reason);
|
|
138
|
+
}
|
|
139
|
+
if (valid.size === 0) {
|
|
140
|
+
throw new Error('Could not find a valid pinned proving bundle. Extract the bundle from your pilot contact into ~/.zk-credits/artifacts/base-sepolia or ~/Downloads, or set ZK_CREDITS_ARTIFACT_DIR.');
|
|
141
|
+
}
|
|
142
|
+
const selected = await chooseOne('proving bundle', [...valid.values()], options.chooseCandidate);
|
|
143
|
+
return resolve(selected);
|
|
144
|
+
}
|
|
145
|
+
function eventSourceFromEnvironment(environment) {
|
|
146
|
+
const rpcUrl = environment.BASE_RPC_URL?.trim();
|
|
147
|
+
const contractAddress = environment.BASE_PRIVATE_CREDIT_BOND_ADDRESS?.trim();
|
|
148
|
+
if (!rpcUrl || !contractAddress)
|
|
149
|
+
return undefined;
|
|
150
|
+
const deploymentBlock = optionalEnvironmentInteger(environment, 'BASE_DEPLOYMENT_BLOCK');
|
|
151
|
+
const confirmations = optionalEnvironmentInteger(environment, 'BASE_CONFIRMATIONS');
|
|
152
|
+
return {
|
|
153
|
+
kind: 'base-events',
|
|
154
|
+
rpcUrl,
|
|
155
|
+
contractAddress,
|
|
156
|
+
...(deploymentBlock === undefined ? {} : { deploymentBlock }),
|
|
157
|
+
...(confirmations === undefined ? {} : { confirmations }),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function witnessCandidates(homeDirectory, stateDirectory) {
|
|
161
|
+
const roots = [
|
|
162
|
+
join(stateDirectory, 'witnesses'),
|
|
163
|
+
join(stateDirectory, 'witness'),
|
|
164
|
+
join(homeDirectory, '.zk-credits', 'witnesses'),
|
|
165
|
+
join(homeDirectory, 'Downloads'),
|
|
166
|
+
];
|
|
167
|
+
return [...new Set(roots.flatMap((root) => [...WITNESS_FILENAMES].map((name) => resolve(root, name))))];
|
|
168
|
+
}
|
|
169
|
+
async function resolveWitnessSource(options, saved) {
|
|
170
|
+
const environment = options.environment;
|
|
171
|
+
const explicitPath = environment.ZK_CREDITS_WITNESS_PATH?.trim();
|
|
172
|
+
if (explicitPath) {
|
|
173
|
+
const source = {
|
|
174
|
+
kind: 'file',
|
|
175
|
+
path: resolve(options.workingDirectory ?? process.cwd(), explicitPath),
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
await options.validateWitness(source);
|
|
179
|
+
return source;
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
const reason = error instanceof Error ? ' ' + error.message : '';
|
|
183
|
+
throw new Error('The configured Base witness at ' + explicitPath + ' failed validation.' + reason);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const environmentSource = eventSourceFromEnvironment(environment);
|
|
187
|
+
if (environmentSource) {
|
|
188
|
+
try {
|
|
189
|
+
await options.validateWitness(environmentSource);
|
|
190
|
+
return environmentSource;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
const reason = error instanceof Error ? ' ' + error.message : '';
|
|
194
|
+
throw new Error('Base event witness sync failed.' + reason);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (saved?.witness.kind === 'base-events') {
|
|
198
|
+
try {
|
|
199
|
+
await options.validateWitness(saved.witness);
|
|
200
|
+
return saved.witness;
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
const reason = error instanceof Error ? ' ' + error.message : '';
|
|
204
|
+
throw new Error('Saved Base event witness source failed validation.' + reason);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (saved?.witness.kind === 'file') {
|
|
208
|
+
try {
|
|
209
|
+
await options.validateWitness(saved.witness);
|
|
210
|
+
return saved.witness;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// A saved local witness can become stale for a different activated credential.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const valid = [];
|
|
217
|
+
for (const path of witnessCandidates(options.homeDirectory, options.stateDirectory)) {
|
|
218
|
+
try {
|
|
219
|
+
const info = await lstat(path);
|
|
220
|
+
if (!info.isFile())
|
|
221
|
+
continue;
|
|
222
|
+
const source = { kind: 'file', path };
|
|
223
|
+
await options.validateWitness(source);
|
|
224
|
+
valid.push(path);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// Ignore absent, unrelated, and non-resolving local files while searching.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (valid.length > 0) {
|
|
231
|
+
return { kind: 'file', path: await chooseOne('witness file', valid, options.chooseCandidate) };
|
|
232
|
+
}
|
|
233
|
+
throw new Error('Could not find a public Base witness for this credential. Put base-tree.json or witness.json in ~/.zk-credits/witnesses or ~/Downloads, set ZK_CREDITS_WITNESS_PATH, or configure BASE_RPC_URL and BASE_PRIVATE_CREDIT_BOND_ADDRESS.');
|
|
234
|
+
}
|
|
235
|
+
/** Finds only known local locations and validates both sources before setup continues. */
|
|
236
|
+
export async function resolveSetupInputs(options) {
|
|
237
|
+
const saved = await readSetupConfig(options.configPath);
|
|
238
|
+
const artifactDirectory = await resolveArtifactDirectory(options, saved);
|
|
239
|
+
const witness = await resolveWitnessSource(options, saved);
|
|
240
|
+
return { version: 1, artifactDirectory, witness };
|
|
241
|
+
}
|
|
242
|
+
/** Reads the operator-local setup config. Missing config is normal on first run. */
|
|
243
|
+
export async function readSetupConfig(path) {
|
|
244
|
+
let raw;
|
|
245
|
+
try {
|
|
246
|
+
const info = await lstat(path);
|
|
247
|
+
if (!info.isFile())
|
|
248
|
+
throw new Error('Local setup config must be a regular file');
|
|
249
|
+
raw = await readFile(path, 'utf8');
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
253
|
+
return undefined;
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
return parseSetupConfig(JSON.parse(raw));
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (error instanceof SyntaxError) {
|
|
261
|
+
throw new Error('Local setup config is not valid JSON; remove ~/.zk-credits/config.json and run setup again.');
|
|
262
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Atomically writes paths and witness configuration with owner-only permissions. */
|
|
267
|
+
export async function writeSetupConfig(path, config) {
|
|
268
|
+
const normalized = parseSetupConfig(config);
|
|
269
|
+
const directory = dirname(resolve(path));
|
|
270
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
271
|
+
const destination = resolve(path);
|
|
272
|
+
try {
|
|
273
|
+
const existing = await lstat(destination);
|
|
274
|
+
if (existing.isSymbolicLink())
|
|
275
|
+
throw new Error('Local setup config cannot be a symbolic link');
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'))
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
const temporary = join(directory, '.config-' + randomUUID() + '.tmp');
|
|
282
|
+
try {
|
|
283
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
284
|
+
try {
|
|
285
|
+
await file.writeFile(JSON.stringify(normalized, null, 2) + '\n', 'utf8');
|
|
286
|
+
await file.sync();
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
await file.close();
|
|
290
|
+
}
|
|
291
|
+
await rename(temporary, destination);
|
|
292
|
+
await chmod(destination, 0o600);
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
await rm(temporary, { force: true });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/** Applies saved paths to a process environment while preserving explicit overrides. */
|
|
299
|
+
export function applySetupConfig(environment, config) {
|
|
300
|
+
if (!environment.ZK_CREDITS_ARTIFACT_DIR?.trim()) {
|
|
301
|
+
environment.ZK_CREDITS_ARTIFACT_DIR = config.artifactDirectory;
|
|
302
|
+
}
|
|
303
|
+
if (config.witness.kind === 'file') {
|
|
304
|
+
if (!environment.ZK_CREDITS_WITNESS_PATH?.trim()) {
|
|
305
|
+
environment.ZK_CREDITS_WITNESS_PATH = config.witness.path;
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!environment.BASE_RPC_URL?.trim())
|
|
310
|
+
environment.BASE_RPC_URL = config.witness.rpcUrl;
|
|
311
|
+
if (!environment.BASE_PRIVATE_CREDIT_BOND_ADDRESS?.trim()) {
|
|
312
|
+
environment.BASE_PRIVATE_CREDIT_BOND_ADDRESS = config.witness.contractAddress;
|
|
313
|
+
}
|
|
314
|
+
if (!environment.BASE_DEPLOYMENT_BLOCK?.trim() && config.witness.deploymentBlock !== undefined) {
|
|
315
|
+
environment.BASE_DEPLOYMENT_BLOCK = config.witness.deploymentBlock;
|
|
316
|
+
}
|
|
317
|
+
if (!environment.BASE_CONFIRMATIONS?.trim() && config.witness.confirmations !== undefined) {
|
|
318
|
+
environment.BASE_CONFIRMATIONS = config.witness.confirmations;
|
|
319
|
+
}
|
|
320
|
+
}
|