@onchaindiligence/sdk 0.2.0 → 0.3.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 +95 -8
- package/conformance/attestation-v1-v2-vectors.json +98 -0
- package/conformance/rfc8785-vectors.json +31 -0
- package/dist/commerce/client.d.ts +134 -0
- package/dist/commerce/client.js +511 -0
- package/dist/commerce/evidenceExport.d.ts +51 -0
- package/dist/commerce/evidenceExport.js +44 -0
- package/dist/commerce/executor.d.ts +64 -0
- package/dist/commerce/executor.js +19 -0
- package/dist/commerce/index.d.ts +29 -0
- package/dist/commerce/index.js +29 -0
- package/dist/commerce/mockExecutor.d.ts +41 -0
- package/dist/commerce/mockExecutor.js +74 -0
- package/dist/commerce/nodeFileRecoveryStore.d.ts +17 -0
- package/dist/commerce/nodeFileRecoveryStore.js +126 -0
- package/dist/commerce/policyTemplates.d.ts +48 -0
- package/dist/commerce/policyTemplates.js +54 -0
- package/dist/commerce/recoveryStore.d.ts +73 -0
- package/dist/commerce/recoveryStore.js +77 -0
- package/dist/commerce/results.d.ts +90 -0
- package/dist/commerce/results.js +3 -0
- package/dist/commerce/types.d.ts +147 -0
- package/dist/commerce/types.js +13 -0
- package/dist/commerce/x402Executor.d.ts +43 -0
- package/dist/commerce/x402Executor.js +222 -0
- package/dist/index.d.ts +36 -32
- package/dist/index.js +155 -86
- package/dist/verification.d.ts +109 -0
- package/dist/verification.js +621 -0
- package/package.json +19 -3
|
@@ -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
|
+
}
|