@onchaindiligence/sdk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,511 @@
1
+ import { VersionConflictError } from './recoveryStore.js';
2
+ import { pending } from './results.js';
3
+ import { buildEvidenceExport } from './evidenceExport.js';
4
+ const DEFAULT_ENDPOINT = 'https://mcp.onchaindiligence.com';
5
+ const OPERATION_HEADER = 'x-ocd-operation-id';
6
+ const RECOVERY_HEADER = 'x-ocd-recovery-credential';
7
+ export class RecoveryRequiredError extends Error {
8
+ constructor(operationId) {
9
+ super(`no local recovery record for operation ${operationId} -- call client.resume(operationId, recoveryCredential) with the credential you saved when this operation was opened. Never silently start a replacement purchase.`);
10
+ this.name = 'RecoveryRequiredError';
11
+ }
12
+ }
13
+ function mapExecutorIdToProvider(executorId) {
14
+ if (executorId === 'x402-base-usdc-exact')
15
+ return 'x402';
16
+ return 'other';
17
+ }
18
+ /**
19
+ * CommerceExecutor's public `recoveryMode` ('provider-idempotent' |
20
+ * 'stable-payment-identity' | 'manual') and D2.4's persisted
21
+ * `recovery_capability_class` ('provider-idempotent' |
22
+ * 'stable-payment-identity' | 'none') name the SAME third state with
23
+ * different words -- the executor-facing API says 'manual' (there IS no
24
+ * automatic recovery capability, a human must look), the server's own
25
+ * enum says 'none' (neither of the two capability classes applies). Both
26
+ * carry the identical consequence (executionBinding.ts's own header:
27
+ * "an ambiguous outcome for this binding MUST resolve to
28
+ * 'manual-recovery-required' rather than a silent resubmission"). This is
29
+ * a narrow vocabulary adaptation at the SDK/API boundary, not a
30
+ * conflict -- translate here rather than renaming either persisted enum.
31
+ */
32
+ function toRecoveryCapabilityClass(mode) {
33
+ return mode === 'manual' ? 'none' : mode;
34
+ }
35
+ export class OnchainDiligenceCommerceClient {
36
+ endpoint;
37
+ recovery;
38
+ fetchImpl;
39
+ trust;
40
+ constructor(options) {
41
+ this.endpoint = (options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, '');
42
+ this.recovery = options.recovery;
43
+ // `globalThis.fetch` is a WebIDL operation on the global object -- storing
44
+ // the bare reference and later invoking it as `this.fetchImpl(...)`
45
+ // detaches it from its required receiver, which throws "Illegal
46
+ // invocation" in real browsers (Node's implementation does not enforce
47
+ // this, so this bug is invisible to every Node-based test). Binding to
48
+ // globalThis here is what makes this constructor safe to use unmodified
49
+ // in both a browser and Node without the caller ever having to know.
50
+ this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
51
+ this.trust = options.trust ?? {};
52
+ }
53
+ /** @internal */
54
+ async apiFetch(path, init = {}) {
55
+ return this.fetchImpl(`${this.endpoint}${path}`, init);
56
+ }
57
+ /** @internal */
58
+ async readError(res) {
59
+ try {
60
+ const body = (await res.json());
61
+ return body.error || `HTTP ${res.status}`;
62
+ }
63
+ catch {
64
+ return `HTTP ${res.status}`;
65
+ }
66
+ }
67
+ /** @internal */
68
+ recoveryStore() {
69
+ return this.recovery;
70
+ }
71
+ /** @internal */
72
+ trustOptions() {
73
+ return this.trust;
74
+ }
75
+ /** Opens a new operation, or resumes one already known locally by operationId. Never silently creates a second operation for an id that exists locally with different intent. */
76
+ async open(params) {
77
+ if (params.operationId) {
78
+ const existing = await this.recovery.load(params.operationId);
79
+ if (existing) {
80
+ const op = new CommerceOperation(this, existing);
81
+ op.setPendingPreflightInput(params.action, params.policy, params.publication);
82
+ return op;
83
+ }
84
+ throw new RecoveryRequiredError(params.operationId);
85
+ }
86
+ const res = await this.apiFetch('/operations', { method: 'POST' });
87
+ if (!res.ok)
88
+ throw new Error(`failed to create operation: ${await this.readError(res)}`);
89
+ const created = (await res.json());
90
+ // Persist BEFORE returning: the (operation_id, recovery_credential) pair
91
+ // is otherwise unrecoverable if the process dies right after this call
92
+ // -- the server has no way to hand the credential back without it.
93
+ const record = await this.recovery.create({
94
+ operationId: created.operation_id,
95
+ recoveryCredential: created.recovery_credential,
96
+ preflightReceiptId: null,
97
+ finalizationCapability: null,
98
+ finalizationCapabilityExpiresAt: null,
99
+ executionRequestId: null,
100
+ clientSubmissionKey: null,
101
+ transactionHash: null,
102
+ executorId: null,
103
+ localPhase: 'opened',
104
+ });
105
+ const op = new CommerceOperation(this, record);
106
+ op.setPendingPreflightInput(params.action, params.policy, params.publication);
107
+ return op;
108
+ }
109
+ /** Explicit resume after restart/lost-response, per D2.5 Section 6. Returns recovery-failed rather than throwing, since "the credential turned out to be wrong" is an expected, handleable outcome, not a programming error. */
110
+ async resume(operationId, recoveryCredential) {
111
+ const res = await this.apiFetch(`/operations/${encodeURIComponent(operationId)}`, {
112
+ headers: { [RECOVERY_HEADER]: recoveryCredential },
113
+ });
114
+ if (!res.ok) {
115
+ return { kind: 'recovery-failed', reason: res.status === 401 ? 'unknown operation or invalid recovery credential' : await this.readError(res) };
116
+ }
117
+ const status = (await res.json());
118
+ const existing = await this.recovery.load(operationId);
119
+ if (!existing) {
120
+ await this.recovery.create({
121
+ operationId,
122
+ recoveryCredential,
123
+ preflightReceiptId: status.preflight_receipt_id,
124
+ finalizationCapability: null,
125
+ finalizationCapabilityExpiresAt: null,
126
+ executionRequestId: null,
127
+ clientSubmissionKey: null,
128
+ transactionHash: null,
129
+ executorId: null,
130
+ localPhase: 'resumed',
131
+ });
132
+ }
133
+ return { kind: 'resumed', operationId, status };
134
+ }
135
+ /** Returns a CommerceOperation for an operation already known to the recovery store, without any network call. Use after resume() or across a process restart. */
136
+ async load(operationId) {
137
+ const record = await this.recovery.load(operationId);
138
+ return record ? new CommerceOperation(this, record) : null;
139
+ }
140
+ /** D2.5 Section 7: free, structured, reuses the server's converged verifier -- no local re-implementation. */
141
+ async verifyReceipt(receiptIdOrEnvelope) {
142
+ const res = await this.apiFetch('/verify-receipt', {
143
+ method: 'POST',
144
+ headers: { 'content-type': 'application/json' },
145
+ body: JSON.stringify(typeof receiptIdOrEnvelope === 'string' ? { receipt_id: receiptIdOrEnvelope } : { envelope: receiptIdOrEnvelope }),
146
+ });
147
+ if (!res.ok)
148
+ throw new Error(`verify-receipt failed: ${await this.readError(res)}`);
149
+ return (await res.json());
150
+ }
151
+ /** D2.5 Section 7: free, structured lookup by exact receipt id. */
152
+ async getReceipt(receiptId) {
153
+ const res = await this.apiFetch(`/receipts/${encodeURIComponent(receiptId)}`);
154
+ if (res.status === 404)
155
+ return null;
156
+ if (!res.ok)
157
+ throw new Error(`get-receipt failed: ${await this.readError(res)}`);
158
+ return (await res.json());
159
+ }
160
+ }
161
+ export function createCommerceClient(options) {
162
+ return new OnchainDiligenceCommerceClient(options);
163
+ }
164
+ export class CommerceOperation {
165
+ operationId;
166
+ client;
167
+ record;
168
+ pendingPreflightInput = null;
169
+ lastCommerceReceiptId = null;
170
+ lastLifecycleEvidence = null;
171
+ /** Serializes execute() calls against THIS operation instance -- see execute()'s header comment for why. */
172
+ executeQueue = Promise.resolve();
173
+ constructor(client, record) {
174
+ this.client = client;
175
+ this.record = record;
176
+ this.operationId = record.operationId;
177
+ }
178
+ /** @internal */
179
+ setPendingPreflightInput(action, policy, publication) {
180
+ this.pendingPreflightInput = { action, policy, publication };
181
+ }
182
+ /** @internal -- exposed for evidence export and tests. */
183
+ currentRecord() {
184
+ return this.record;
185
+ }
186
+ async reload() {
187
+ const fresh = await this.client.recoveryStore().load(this.operationId);
188
+ if (fresh)
189
+ this.record = fresh;
190
+ }
191
+ async casUpdate(patch) {
192
+ for (let attempt = 0; attempt < 3; attempt++) {
193
+ try {
194
+ this.record = await this.client.recoveryStore().update(this.operationId, patch, this.record.version);
195
+ return;
196
+ }
197
+ catch (err) {
198
+ if (err instanceof VersionConflictError && attempt < 2) {
199
+ await this.reload();
200
+ continue;
201
+ }
202
+ throw err;
203
+ }
204
+ }
205
+ }
206
+ /**
207
+ * Claims `clientSubmissionKey` for a fresh submission attempt -- but
208
+ * NEVER by blindly overwriting a value a concurrent claimant already won.
209
+ * Unlike casUpdate (which re-applies the SAME patch after a conflict,
210
+ * correct for "set this field to this exact value regardless"), a claim
211
+ * is "set this field to MY value ONLY IF NO ONE ELSE HAS ALREADY SET IT"
212
+ * -- so a conflict here means re-reading and checking on which value
213
+ * actually won, not retrying with a new one. This is what closes the race
214
+ * two concurrent execute() calls (in-process, via a shared store across
215
+ * processes, or across a restart) would otherwise have on this field.
216
+ */
217
+ async claimSubmissionSlot(executorId) {
218
+ if (this.record.clientSubmissionKey)
219
+ return this.record.clientSubmissionKey;
220
+ const candidate = `${this.operationId}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;
221
+ for (let attempt = 0; attempt < 5; attempt++) {
222
+ if (this.record.clientSubmissionKey)
223
+ return this.record.clientSubmissionKey;
224
+ try {
225
+ this.record = await this.client
226
+ .recoveryStore()
227
+ .update(this.operationId, { clientSubmissionKey: candidate, executorId, localPhase: 'execution-preparing' }, this.record.version);
228
+ return candidate;
229
+ }
230
+ catch (err) {
231
+ if (err instanceof VersionConflictError) {
232
+ await this.reload();
233
+ continue;
234
+ }
235
+ throw err;
236
+ }
237
+ }
238
+ throw new Error(`could not claim a submission slot for operation ${this.operationId} after repeated concurrent conflicts`);
239
+ }
240
+ async status() {
241
+ const res = await this.client.apiFetch(`/operations/${encodeURIComponent(this.operationId)}`, {
242
+ headers: { [RECOVERY_HEADER]: this.record.recoveryCredential },
243
+ });
244
+ if (!res.ok)
245
+ throw new Error(`failed to fetch operation status: ${await this.client.readError(res)}`);
246
+ return (await res.json());
247
+ }
248
+ // --- preflight -----------------------------------------------------------
249
+ async preflight() {
250
+ if (!this.pendingPreflightInput) {
251
+ // Resumed from a restart with no in-memory action/policy -- if the
252
+ // preflight step already completed server-side, its receipt id is on
253
+ // the record and there is nothing left to evaluate.
254
+ if (this.record.preflightReceiptId) {
255
+ const receipt = await this.client.getReceipt(this.record.preflightReceiptId);
256
+ if (receipt)
257
+ return this.evaluationFromReceipt(receipt, null);
258
+ }
259
+ throw new Error('no pending preflight input for this operation -- after a restart, call client.open({operationId, action, policy}) to re-supply it before calling preflight() again');
260
+ }
261
+ const { action, policy, publication } = this.pendingPreflightInput;
262
+ const body = JSON.stringify({ action, policy, options: {}, references: {}, publication: publication ?? {} });
263
+ let res;
264
+ try {
265
+ res = await this.client.apiFetch('/x402/lifecycle/preflight-payment', {
266
+ method: 'POST',
267
+ headers: {
268
+ 'content-type': 'application/json',
269
+ [OPERATION_HEADER]: this.operationId,
270
+ [RECOVERY_HEADER]: this.record.recoveryCredential,
271
+ },
272
+ body,
273
+ });
274
+ }
275
+ catch {
276
+ // No response at all -- per D2.4, retrying this EXACT call is safe
277
+ // (same operation + same input digest never pays twice), so the safe
278
+ // next action is genuinely "retry", not just "wait".
279
+ return pending(this.operationId, {
280
+ phase: 'preflight-in-progress',
281
+ safeNextAction: 'retry op.preflight() with the identical action/policy -- the server deduplicates by operation and input, so this cannot pay twice',
282
+ mayAlreadyHavePaid: true,
283
+ });
284
+ }
285
+ if (res.status === 425 || res.status === 503) {
286
+ const retryAfter = Number(res.headers.get('retry-after')) || undefined;
287
+ return pending(this.operationId, {
288
+ phase: 'preflight-in-progress',
289
+ safeNextAction: 'retry op.preflight() shortly -- do not attempt payment through any other path',
290
+ retryAfterSeconds: retryAfter,
291
+ mayAlreadyHavePaid: true,
292
+ });
293
+ }
294
+ if (!res.ok) {
295
+ return { kind: 'terminal-error', operationId: this.operationId, error: await this.client.readError(res) };
296
+ }
297
+ const result = (await res.json());
298
+ await this.casUpdate({
299
+ preflightReceiptId: result.receipt.receipt.receipt_id,
300
+ finalizationCapability: result.finalization.capability,
301
+ finalizationCapabilityExpiresAt: result.finalization.expires_at,
302
+ localPhase: 'preflight-complete',
303
+ });
304
+ return this.evaluationFromReceipt(result.receipt, { token: result.finalization.capability, expiresAt: result.finalization.expires_at });
305
+ }
306
+ async evaluationFromReceipt(receipt, capability) {
307
+ if (this.client.trustOptions().verifyReceipts) {
308
+ await this.client.verifyReceipt(receipt).catch(() => { }); // best-effort -- never blocks surfacing the decision itself
309
+ }
310
+ const status = receipt.receipt.decision.status;
311
+ if (status === 'BLOCK')
312
+ return { kind: 'blocked', operationId: this.operationId, receipt, reasons: receipt.receipt.decision.reasons };
313
+ if (status === 'REQUIRE_APPROVAL' || status === 'UNKNOWN') {
314
+ return { kind: 'approval-required', operationId: this.operationId, receipt, reasons: receipt.receipt.decision.reasons };
315
+ }
316
+ if (!capability) {
317
+ // ALLOW, but this evaluation came from a re-fetched historical
318
+ // receipt (post-restart) with no live capability to hand back --
319
+ // report approval-required rather than a false 'ready'.
320
+ return {
321
+ kind: 'approval-required',
322
+ operationId: this.operationId,
323
+ receipt,
324
+ reasons: ['this decision was ALLOW, but no live finalization capability is available in this process -- re-run preflight to obtain one'],
325
+ };
326
+ }
327
+ return { kind: 'ready', operationId: this.operationId, receipt, capabilityExpiresAt: capability.expiresAt };
328
+ }
329
+ // --- execute ---------------------------------------------------------------
330
+ /**
331
+ * Serialized per operation instance (Section 15 test #9: "concurrent
332
+ * calls cannot cause duplicate submit"). Two overlapping execute() calls
333
+ * against the SAME CommerceOperation object run one after the other, so
334
+ * the second always observes the first's already-persisted
335
+ * clientSubmissionKey/executionRequestId/transactionHash and resumes
336
+ * instead of racing to claim a fresh identity. Cross-PROCESS concurrency
337
+ * is a different, already-covered case: the server's execution-bindings
338
+ * endpoint is idempotent by client_submission_key (D2.4), and a correctly
339
+ * implemented executor (see MockCommerceExecutor, X402BaseUsdcExecutor)
340
+ * refuses to submit twice for the same key on its own.
341
+ */
342
+ async execute(params) {
343
+ const run = this.executeQueue.then(() => this.executeLocked(params), () => this.executeLocked(params));
344
+ this.executeQueue = run.catch(() => { });
345
+ return run;
346
+ }
347
+ async executeLocked(params) {
348
+ if (!this.record.preflightReceiptId) {
349
+ throw new Error('cannot execute before a READY preflight -- call op.preflight() first and confirm evaluation.kind === "ready"');
350
+ }
351
+ if (!this.pendingPreflightInput) {
352
+ throw new Error('execute() needs the original action -- re-supply it via client.open({operationId, action, policy}) after a restart before calling execute()');
353
+ }
354
+ const { action } = this.pendingPreflightInput;
355
+ const { executor } = params;
356
+ // Re-read the durable record before deciding anything -- a DIFFERENT
357
+ // CommerceOperation instance (another process, or another instance in
358
+ // this one sharing the same durable store) may have already claimed or
359
+ // advanced this operation since we last loaded it.
360
+ await this.reload();
361
+ // Resuming an in-flight submission: never re-prepare/re-submit.
362
+ if (this.record.clientSubmissionKey && this.record.executionRequestId) {
363
+ if (this.record.transactionHash) {
364
+ return { kind: 'execution-recorded', operationId: this.operationId, executionRequestId: this.record.executionRequestId, transactionHash: this.record.transactionHash };
365
+ }
366
+ const prepared = { clientSubmissionKey: this.record.clientSubmissionKey, reference: { action }, preparedAt: this.record.updatedAt };
367
+ const resumed = await executor.resume(prepared);
368
+ return this.applyExecutionOutcome(resumed);
369
+ }
370
+ // Persist the identity seed BEFORE calling prepare() -- Section 3: "MUST
371
+ // NOT broadcast payment" during prepare(), but the durable identity must
372
+ // exist before ANY executor call, prepare included. claimSubmissionSlot
373
+ // never lets a concurrent claimant's key be overwritten by ours -- see
374
+ // its own comment.
375
+ const clientSubmissionKey = await this.claimSubmissionSlot(executor.id);
376
+ // The winning slot (ours or a concurrent claimant's) may have already
377
+ // advanced further than "just claimed" by the time we get here.
378
+ if (this.record.executionRequestId) {
379
+ if (this.record.transactionHash) {
380
+ return { kind: 'execution-recorded', operationId: this.operationId, executionRequestId: this.record.executionRequestId, transactionHash: this.record.transactionHash };
381
+ }
382
+ const prepared = { clientSubmissionKey, reference: { action }, preparedAt: this.record.updatedAt };
383
+ const resumed = await executor.resume(prepared);
384
+ return this.applyExecutionOutcome(resumed);
385
+ }
386
+ const prepared = await executor.prepare({ clientSubmissionKey, action });
387
+ const bindingRes = await this.client.apiFetch(`/operations/${encodeURIComponent(this.operationId)}/execution-bindings`, {
388
+ method: 'POST',
389
+ headers: { 'content-type': 'application/json', [RECOVERY_HEADER]: this.record.recoveryCredential },
390
+ body: JSON.stringify({
391
+ client_submission_key: clientSubmissionKey,
392
+ executor_identity: executor.id,
393
+ executor_version: executor.version,
394
+ recovery_capability_class: toRecoveryCapabilityClass(executor.recoveryMode),
395
+ expected_payer: null,
396
+ }),
397
+ });
398
+ if (!bindingRes.ok) {
399
+ return { kind: 'terminal-error', operationId: this.operationId, error: `failed to register execution binding: ${await this.client.readError(bindingRes)}` };
400
+ }
401
+ const binding = (await bindingRes.json());
402
+ await this.casUpdate({ executionRequestId: binding.execution_request_id, localPhase: 'execution-submitting' });
403
+ const outcome = await executor.submit(prepared);
404
+ return this.applyExecutionOutcome(outcome);
405
+ }
406
+ async applyExecutionOutcome(outcome) {
407
+ const executionRequestId = this.record.executionRequestId;
408
+ if (outcome.status === 'transaction-known') {
409
+ await this.casUpdate({ transactionHash: outcome.transactionHash, localPhase: 'execution-complete' });
410
+ await this.updateBindingState(executionRequestId, 'transaction_known');
411
+ return { kind: 'execution-recorded', operationId: this.operationId, executionRequestId, transactionHash: outcome.transactionHash, providerReference: outcome.providerReference };
412
+ }
413
+ if (outcome.status === 'manual-recovery-required') {
414
+ await this.casUpdate({ localPhase: 'manual-recovery-required' });
415
+ await this.updateBindingState(executionRequestId, 'manual_recovery_required');
416
+ return { kind: 'manual-recovery-required', operationId: this.operationId, executionRequestId, reason: outcome.reason };
417
+ }
418
+ // submission-ambiguous
419
+ await this.casUpdate({ localPhase: 'execution-ambiguous' });
420
+ await this.updateBindingState(executionRequestId, 'submission_ambiguous');
421
+ return pending(this.operationId, {
422
+ phase: 'execution-ambiguous',
423
+ safeNextAction: 'call op.execute() again -- it will call executor.resume(), never submit() a second time, for this execution',
424
+ retryAfterSeconds: outcome.retryAfterSeconds,
425
+ mayAlreadyHavePaid: true,
426
+ executionRequestId,
427
+ });
428
+ }
429
+ async updateBindingState(executionRequestId, state) {
430
+ await this.client
431
+ .apiFetch(`/operations/${encodeURIComponent(this.operationId)}/execution-bindings/${encodeURIComponent(executionRequestId)}/state`, {
432
+ method: 'POST',
433
+ headers: { 'content-type': 'application/json', [RECOVERY_HEADER]: this.record.recoveryCredential },
434
+ body: JSON.stringify({ state }),
435
+ })
436
+ .catch(() => { }); // best-effort mirror; the LOCAL record + the binding's OWN prior state remain authoritative for resume logic
437
+ }
438
+ // --- observe / finalize ------------------------------------------------
439
+ async observeAndFinalize() {
440
+ if (!this.record.transactionHash) {
441
+ return pending(this.operationId, {
442
+ phase: 'awaiting-execution',
443
+ safeNextAction: 'call op.execute() first and reach execution-recorded before finalizing',
444
+ mayAlreadyHavePaid: false,
445
+ });
446
+ }
447
+ if (!this.record.finalizationCapability) {
448
+ throw new Error('no finalization capability on record -- this operation did not complete preflight in this recovery store');
449
+ }
450
+ const res = await this.client.apiFetch(`/operations/${encodeURIComponent(this.operationId)}/finalize`, {
451
+ method: 'POST',
452
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${this.record.finalizationCapability}` },
453
+ body: JSON.stringify({
454
+ transaction_hash: this.record.transactionHash,
455
+ execution_provider: mapExecutorIdToProvider(this.record.executorId),
456
+ execution_request_id: this.record.executionRequestId,
457
+ }),
458
+ });
459
+ if (res.status === 425 || res.status === 503) {
460
+ const retryAfter = Number(res.headers.get('retry-after')) || undefined;
461
+ return pending(this.operationId, {
462
+ phase: 'observation-pending',
463
+ safeNextAction: 'retry op.observeAndFinalize() shortly -- the transaction was submitted but is not yet definitively observed',
464
+ retryAfterSeconds: retryAfter,
465
+ mayAlreadyHavePaid: true,
466
+ executionRequestId: this.record.executionRequestId ?? undefined,
467
+ });
468
+ }
469
+ if (!res.ok) {
470
+ return { kind: 'terminal-error', operationId: this.operationId, error: await this.client.readError(res) };
471
+ }
472
+ const body = (await res.json());
473
+ // The server's finalize response is `{...envelope, ocd_lifecycle_evidence}`
474
+ // -- a convenience shape for THIS transport, never the canonical Public
475
+ // Action Receipt v1 envelope itself. Extract exactly {schema, receipt,
476
+ // proof} before this touches anything that expects that canonical shape
477
+ // (verifyReceipt(), and whatever the caller does with the returned
478
+ // receipt) -- passing the enriched `body` through unchanged made the
479
+ // canonical verifier reject a genuinely valid, correctly-signed receipt
480
+ // as schema-invalid (confirmed live, D2.5A).
481
+ const envelope = { schema: body.schema, receipt: body.receipt, proof: body.proof };
482
+ await this.casUpdate({ localPhase: 'finalized' });
483
+ if (this.client.trustOptions().verifyReceipts) {
484
+ await this.client.verifyReceipt(envelope).catch(() => { });
485
+ }
486
+ this.lastCommerceReceiptId = envelope.receipt.receipt_id;
487
+ this.lastLifecycleEvidence = body.ocd_lifecycle_evidence;
488
+ return { kind: 'receipt-produced', operationId: this.operationId, receipt: envelope, evidence: body.ocd_lifecycle_evidence };
489
+ }
490
+ // --- evidence export (D2.5 Section 10) ----------------------------------
491
+ /**
492
+ * Builds a minimal, deterministic evidence manifest from PUBLIC artifacts
493
+ * only (fetched fresh via the client's public receipt/status calls) —
494
+ * never touches this.record's secret fields (recoveryCredential,
495
+ * finalizationCapability), so there is no field here to forget to redact.
496
+ */
497
+ async exportEvidence() {
498
+ const [preflightReceipt, commerceReceipt, status] = await Promise.all([
499
+ this.record.preflightReceiptId ? this.client.getReceipt(this.record.preflightReceiptId) : Promise.resolve(null),
500
+ this.lastCommerceReceiptId ? this.client.getReceipt(this.lastCommerceReceiptId) : Promise.resolve(null),
501
+ this.status().catch(() => null),
502
+ ]);
503
+ return buildEvidenceExport({
504
+ operationId: this.operationId,
505
+ preflightReceipt,
506
+ commerceReceipt,
507
+ operationStatus: status,
508
+ lifecycleEvidence: this.lastLifecycleEvidence,
509
+ });
510
+ }
511
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * evidenceExport.ts — a MINIMAL developer-facing evidence export (D2.5,
3
+ * Section 10). Deliberately not the full investigation package: a
4
+ * deterministic JSON manifest bundling the PUBLIC artifacts for one
5
+ * operation, suitable for handing to a counterparty, auditor, or support
6
+ * ticket.
7
+ *
8
+ * Preserves signed/original artifacts unchanged (receipts are embedded
9
+ * verbatim — never re-serialized field-by-field, which could silently drop
10
+ * or reorder something material). Computes one digest over the manifest
11
+ * contents (excluding the digest field itself) using the same
12
+ * canonicalize-then-SHA-256 approach already used elsewhere in this
13
+ * ecosystem (RFC 8785-style sorted-key JSON).
14
+ *
15
+ * NEVER accepts a CommerceRecoveryRecord or any executor/authorization
16
+ * secret as input — only public receipts and status. This is enforced by
17
+ * the function's own parameter types, not by a runtime filter, so there is
18
+ * no field to "forget" to redact.
19
+ */
20
+ import type { ReceiptEnvelope, OperationStatus } from './types.js';
21
+ export declare const EVIDENCE_EXPORT_VERSION = "onchaindiligence.evidence-export.v1";
22
+ export interface EvidenceExportInput {
23
+ operationId: string;
24
+ preflightReceipt?: ReceiptEnvelope | null;
25
+ commerceReceipt?: ReceiptEnvelope | null;
26
+ operationStatus?: OperationStatus | null;
27
+ lifecycleEvidence?: {
28
+ bundle_digest: string;
29
+ binding_strength: string;
30
+ } | null;
31
+ /** Free-form, non-secret developer notes (e.g. an order reference). Never put a credential or token here — this whole object is written to disk/handed to a third party. */
32
+ notes?: Record<string, string | number | boolean | null>;
33
+ }
34
+ export interface EvidenceExportManifest {
35
+ manifest_version: typeof EVIDENCE_EXPORT_VERSION;
36
+ operation_id: string;
37
+ exported_at: string;
38
+ artifacts: {
39
+ preflight_receipt: ReceiptEnvelope | null;
40
+ commerce_receipt: ReceiptEnvelope | null;
41
+ operation_status: OperationStatus | null;
42
+ lifecycle_evidence: {
43
+ bundle_digest: string;
44
+ binding_strength: string;
45
+ } | null;
46
+ };
47
+ notes: Record<string, string | number | boolean | null>;
48
+ manifest_digest: string;
49
+ }
50
+ /** Builds the manifest and computes its digest. Pure aside from the digest computation itself; never makes a network call. */
51
+ export declare function buildEvidenceExport(input: EvidenceExportInput): Promise<EvidenceExportManifest>;
@@ -0,0 +1,44 @@
1
+ export const EVIDENCE_EXPORT_VERSION = 'onchaindiligence.evidence-export.v1';
2
+ function canonicalizeJson(value) {
3
+ if (value === null || typeof value === 'boolean' || typeof value === 'string' || typeof value === 'number')
4
+ return JSON.stringify(value);
5
+ if (Array.isArray(value))
6
+ return `[${value.map(canonicalizeJson).join(',')}]`;
7
+ if (typeof value === 'object') {
8
+ const record = value;
9
+ return `{${Object.keys(record)
10
+ .sort()
11
+ .map((k) => `${JSON.stringify(k)}:${canonicalizeJson(record[k])}`)
12
+ .join(',')}}`;
13
+ }
14
+ throw new TypeError(`value of type ${typeof value} is not valid JSON`);
15
+ }
16
+ async function sha256Digest(canonicalJson) {
17
+ const subtle = globalThis.crypto?.subtle;
18
+ if (!subtle)
19
+ throw new Error('WebCrypto (crypto.subtle) is unavailable in this runtime');
20
+ const bytes = new TextEncoder().encode(canonicalJson);
21
+ const digestBuffer = await subtle.digest('SHA-256', bytes);
22
+ const base64url = btoa(String.fromCharCode(...new Uint8Array(digestBuffer)))
23
+ .replace(/\+/g, '-')
24
+ .replace(/\//g, '_')
25
+ .replace(/=+$/, '');
26
+ return `sha256:${base64url}`;
27
+ }
28
+ /** Builds the manifest and computes its digest. Pure aside from the digest computation itself; never makes a network call. */
29
+ export async function buildEvidenceExport(input) {
30
+ const withoutDigest = {
31
+ manifest_version: EVIDENCE_EXPORT_VERSION,
32
+ operation_id: input.operationId,
33
+ exported_at: new Date().toISOString(),
34
+ artifacts: {
35
+ preflight_receipt: input.preflightReceipt ?? null,
36
+ commerce_receipt: input.commerceReceipt ?? null,
37
+ operation_status: input.operationStatus ?? null,
38
+ lifecycle_evidence: input.lifecycleEvidence ?? null,
39
+ },
40
+ notes: input.notes ?? {},
41
+ };
42
+ const manifest_digest = await sha256Digest(canonicalizeJson(withoutDigest));
43
+ return { ...withoutDigest, manifest_digest };
44
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * executor.ts — the CommerceExecutor contract (D2.5, Section 3).
3
+ *
4
+ * OCD evaluates. The executor authorizes and submits. These are DELIBERATELY
5
+ * independent: an OCD ALLOW is a policy opinion, never a grant of wallet
6
+ * authority, and this interface exists precisely so a developer can swap
7
+ * wallets/providers without OCD code ever touching a private key or a
8
+ * payment authorization it didn't need to see.
9
+ *
10
+ * `prepare()` MUST NOT broadcast anything — it is the point where a durable
11
+ * execution/payment identity is created (and, in the commerce client's
12
+ * orchestration, persisted to the recovery store and registered with OCD's
13
+ * execution-bindings endpoint) BEFORE any state-changing network call.
14
+ * `submit()` is called AT MOST ONCE per prepared identity by the orchestrator
15
+ * — an executor must never invent a second identity/authorization on its
16
+ * own initiative. `resume()` must query/resume the SAME prepared identity,
17
+ * never fabricate a new payment.
18
+ */
19
+ export type ExecutorRecoveryMode = 'provider-idempotent' | 'stable-payment-identity' | 'manual';
20
+ export interface PrepareContext {
21
+ /** Opaque, caller-chosen key identifying THIS submission attempt — reused verbatim across retries of the same attempt so prepare() stays idempotent from the orchestrator's point of view. */
22
+ clientSubmissionKey: string;
23
+ /** The exact, frozen action this execution must satisfy (from the bound PREFLIGHT receipt) — never re-negotiated by the executor. */
24
+ action: {
25
+ network: string;
26
+ asset: string;
27
+ amount: string;
28
+ recipient: string;
29
+ resource: string | null;
30
+ sender: string | null;
31
+ };
32
+ }
33
+ export interface PrepareResult {
34
+ clientSubmissionKey: string;
35
+ /** Executor-specific durable reference to what was prepared (e.g. a validated 402 challenge) — opaque to the orchestrator, round-tripped back into submit()/resume() unchanged. */
36
+ reference: unknown;
37
+ preparedAt: string;
38
+ }
39
+ export type ExecutionOutcome = {
40
+ status: 'transaction-known';
41
+ transactionHash: string;
42
+ providerReference?: string | null;
43
+ } | {
44
+ status: 'submission-ambiguous';
45
+ reason: string;
46
+ retryAfterSeconds?: number;
47
+ } | {
48
+ status: 'manual-recovery-required';
49
+ reason: string;
50
+ };
51
+ export type ExecutionResult = ExecutionOutcome & {
52
+ clientSubmissionKey: string;
53
+ };
54
+ export interface CommerceExecutor {
55
+ readonly id: string;
56
+ readonly version: string;
57
+ readonly recoveryMode: ExecutorRecoveryMode;
58
+ /** Performs executor-specific authorization/grant checks and creates the durable execution/payment identity. MUST NOT broadcast payment. */
59
+ prepare(context: PrepareContext): Promise<PrepareResult>;
60
+ /** Submits EXACTLY the prepared payment identity. Must not generate a new authorization on retry — the orchestrator calls this at most once per prepared identity. */
61
+ submit(prepared: PrepareResult): Promise<ExecutionResult>;
62
+ /** Queries/resumes the existing execution referenced by `prepared`. Must NEVER create a new merchant payment — if this executor's recoveryMode is 'manual' and no independent evidence exists, this must return manual-recovery-required rather than guess. */
63
+ resume(prepared: PrepareResult, priorOutcome?: ExecutionResult): Promise<ExecutionResult>;
64
+ }