@onchaindiligence/sdk 0.3.1 → 0.4.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.
- package/README.md +2 -1
- package/dist/commerce/client.d.ts +20 -0
- package/dist/commerce/client.js +61 -1
- package/dist/commerce/executor.d.ts +14 -0
- package/dist/commerce/index.d.ts +1 -0
- package/dist/commerce/index.js +1 -0
- package/dist/commerce/node.d.ts +9 -0
- package/dist/commerce/node.js +9 -0
- package/dist/commerce/nodeFilePayboxRequestStore.d.ts +12 -0
- package/dist/commerce/nodeFilePayboxRequestStore.js +100 -0
- package/dist/commerce/payboxExecutor.d.ts +159 -0
- package/dist/commerce/payboxExecutor.js +374 -0
- package/dist/commerce/recoveryStore.d.ts +12 -0
- package/dist/commerce/x402Challenge.d.ts +44 -0
- package/dist/commerce/x402Challenge.js +86 -0
- package/dist/commerce/x402Executor.d.ts +2 -0
- package/dist/commerce/x402Executor.js +2 -75
- package/package.json +16 -8
package/README.md
CHANGED
|
@@ -122,7 +122,8 @@ Key pieces:
|
|
|
122
122
|
|---|---|
|
|
123
123
|
| `createCommerceClient` / `CommerceOperation` | Orchestrates open → preflight → execute → observe/finalize. |
|
|
124
124
|
| `CommerceExecutor` | The contract your wallet/payment provider implements: `prepare` → `submit` → `resume`. Independent of OCD's policy decision by construction. |
|
|
125
|
-
| `X402BaseUsdcExecutor` | The one production executor: Base mainnet, USDC, x402 v2 exact. Its `recoveryMode` is honestly `'manual'` — see the file's own header for why. |
|
|
125
|
+
| `X402BaseUsdcExecutor` | The one production executor for a local signer: Base mainnet, USDC, x402 v2 exact. Its `recoveryMode` is honestly `'manual'` — see the file's own header for why. |
|
|
126
|
+
| `PayBoxCommerceExecutor` | The one production executor for [PayBox](https://paybox.sh) (an independent, non-custodial agent payment vault): Base mainnet, USDC, one wallet credential. PayBox's own grant/approval rules stay fully independent from OCD's policy decision — an OCD `ALLOW` never overrides a PayBox denial, and a PayBox approval never implies OCD `ALLOW`. Its `recoveryMode` is `'stable-payment-identity'` (PayBox's `get_request` can be polled/resumed against a stable `request_id`, but its `pay_x402` has no idempotency key — see the file's own header). Needs its own small `PayBoxRequestStore` (`InMemoryPayBoxRequestStore` is test-only) because PayBox's request id isn't part of `CommerceRecoveryRecord`. |
|
|
126
127
|
| `MockCommerceExecutor` | Deterministic, no-network executor for tests/docs. |
|
|
127
128
|
| `CommerceRecoveryStore` | Durable identity storage — required, no safe default. `NodeFileRecoveryStore` (`@onchaindiligence/sdk/commerce/node` — Node-only, wraps `node:fs`) survives a restart; implement the interface against your own database for a multi-instance deployment, or proxy it through your own local server for a browser UI (never store the secret fields in browser storage). `InMemoryRecoveryStore` is test-only. |
|
|
128
129
|
| `apiPurchasePolicy` / `approvalAboveThresholdPolicy` / `fixedRecipientPolicy` | Three starter policy templates — ordinary strict policy objects, no new semantics. |
|
|
@@ -19,6 +19,17 @@ import type { CommerceExecutor } from './executor.js';
|
|
|
19
19
|
import type { CommerceRecoveryStore, CommerceRecoveryRecord } from './recoveryStore.js';
|
|
20
20
|
import { type PreflightEvaluation, type ExecutionRecord, type FinalizeResult, type ResumeResult } from './results.js';
|
|
21
21
|
import { type EvidenceExportManifest } from './evidenceExport.js';
|
|
22
|
+
/**
|
|
23
|
+
* D2.6 review fix #1: thrown by execute() whenever the operation's
|
|
24
|
+
* authoritative stored PREFLIGHT decision is not ALLOW (BLOCK,
|
|
25
|
+
* REQUIRE_APPROVAL, UNKNOWN, or — defensively — undeterminable). This is the
|
|
26
|
+
* generic, executor-independent enforcement point: no executor (PayBox,
|
|
27
|
+
* X402BaseUsdcExecutor, a custom one) is ever reachable from execute() for
|
|
28
|
+
* an operation that didn't authoritatively reach ALLOW.
|
|
29
|
+
*/
|
|
30
|
+
export declare class PreflightNotAllowedError extends Error {
|
|
31
|
+
constructor(operationId: string, status: string | null);
|
|
32
|
+
}
|
|
22
33
|
export declare class RecoveryRequiredError extends Error {
|
|
23
34
|
constructor(operationId: string);
|
|
24
35
|
}
|
|
@@ -89,6 +100,15 @@ export declare class CommerceOperation {
|
|
|
89
100
|
/** @internal -- exposed for evidence export and tests. */
|
|
90
101
|
currentRecord(): CommerceRecoveryRecord;
|
|
91
102
|
private reload;
|
|
103
|
+
/**
|
|
104
|
+
* Fail-closed gate (D2.6 review fix #1): execute() calls this before ANY
|
|
105
|
+
* executor method is reachable. Prefers the locally cached
|
|
106
|
+
* `preflightDecisionStatus` (set the moment preflight() itself received an
|
|
107
|
+
* authoritative decision) — falls back to re-fetching the signed receipt
|
|
108
|
+
* itself for a record that predates this field, or whose cache write
|
|
109
|
+
* never landed, rather than ever assuming ALLOW.
|
|
110
|
+
*/
|
|
111
|
+
private assertPreflightAllowed;
|
|
92
112
|
private casUpdate;
|
|
93
113
|
/**
|
|
94
114
|
* Claims `clientSubmissionKey` for a fresh submission attempt -- but
|
package/dist/commerce/client.js
CHANGED
|
@@ -4,6 +4,20 @@ import { buildEvidenceExport } from './evidenceExport.js';
|
|
|
4
4
|
const DEFAULT_ENDPOINT = 'https://mcp.onchaindiligence.com';
|
|
5
5
|
const OPERATION_HEADER = 'x-ocd-operation-id';
|
|
6
6
|
const RECOVERY_HEADER = 'x-ocd-recovery-credential';
|
|
7
|
+
/**
|
|
8
|
+
* D2.6 review fix #1: thrown by execute() whenever the operation's
|
|
9
|
+
* authoritative stored PREFLIGHT decision is not ALLOW (BLOCK,
|
|
10
|
+
* REQUIRE_APPROVAL, UNKNOWN, or — defensively — undeterminable). This is the
|
|
11
|
+
* generic, executor-independent enforcement point: no executor (PayBox,
|
|
12
|
+
* X402BaseUsdcExecutor, a custom one) is ever reachable from execute() for
|
|
13
|
+
* an operation that didn't authoritatively reach ALLOW.
|
|
14
|
+
*/
|
|
15
|
+
export class PreflightNotAllowedError extends Error {
|
|
16
|
+
constructor(operationId, status) {
|
|
17
|
+
super(`cannot execute operation ${operationId}: its stored PREFLIGHT decision is "${status ?? 'unknown'}", not ALLOW -- no executor may be invoked`);
|
|
18
|
+
this.name = 'PreflightNotAllowedError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
export class RecoveryRequiredError extends Error {
|
|
8
22
|
constructor(operationId) {
|
|
9
23
|
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.`);
|
|
@@ -13,6 +27,8 @@ export class RecoveryRequiredError extends Error {
|
|
|
13
27
|
function mapExecutorIdToProvider(executorId) {
|
|
14
28
|
if (executorId === 'x402-base-usdc-exact')
|
|
15
29
|
return 'x402';
|
|
30
|
+
if (executorId === 'paybox-x402-base-usdc')
|
|
31
|
+
return 'paybox';
|
|
16
32
|
return 'other';
|
|
17
33
|
}
|
|
18
34
|
/**
|
|
@@ -94,6 +110,7 @@ export class OnchainDiligenceCommerceClient {
|
|
|
94
110
|
operationId: created.operation_id,
|
|
95
111
|
recoveryCredential: created.recovery_credential,
|
|
96
112
|
preflightReceiptId: null,
|
|
113
|
+
preflightDecisionStatus: null,
|
|
97
114
|
finalizationCapability: null,
|
|
98
115
|
finalizationCapabilityExpiresAt: null,
|
|
99
116
|
executionRequestId: null,
|
|
@@ -121,6 +138,9 @@ export class OnchainDiligenceCommerceClient {
|
|
|
121
138
|
operationId,
|
|
122
139
|
recoveryCredential,
|
|
123
140
|
preflightReceiptId: status.preflight_receipt_id,
|
|
141
|
+
// Not known from OperationStatus alone -- execute() re-fetches the
|
|
142
|
+
// authoritative receipt when this is null (see assertPreflightAllowed()).
|
|
143
|
+
preflightDecisionStatus: null,
|
|
124
144
|
finalizationCapability: null,
|
|
125
145
|
finalizationCapabilityExpiresAt: null,
|
|
126
146
|
executionRequestId: null,
|
|
@@ -188,6 +208,27 @@ export class CommerceOperation {
|
|
|
188
208
|
if (fresh)
|
|
189
209
|
this.record = fresh;
|
|
190
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Fail-closed gate (D2.6 review fix #1): execute() calls this before ANY
|
|
213
|
+
* executor method is reachable. Prefers the locally cached
|
|
214
|
+
* `preflightDecisionStatus` (set the moment preflight() itself received an
|
|
215
|
+
* authoritative decision) — falls back to re-fetching the signed receipt
|
|
216
|
+
* itself for a record that predates this field, or whose cache write
|
|
217
|
+
* never landed, rather than ever assuming ALLOW.
|
|
218
|
+
*/
|
|
219
|
+
async assertPreflightAllowed() {
|
|
220
|
+
if (!this.record.preflightReceiptId) {
|
|
221
|
+
throw new Error('cannot execute before a READY preflight -- call op.preflight() first and confirm evaluation.kind === "ready"');
|
|
222
|
+
}
|
|
223
|
+
let status = this.record.preflightDecisionStatus;
|
|
224
|
+
if (!status) {
|
|
225
|
+
const receipt = await this.client.getReceipt(this.record.preflightReceiptId);
|
|
226
|
+
status = receipt?.receipt.decision.status ?? null;
|
|
227
|
+
}
|
|
228
|
+
if (status !== 'ALLOW') {
|
|
229
|
+
throw new PreflightNotAllowedError(this.operationId, status);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
191
232
|
async casUpdate(patch) {
|
|
192
233
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
193
234
|
try {
|
|
@@ -253,8 +294,12 @@ export class CommerceOperation {
|
|
|
253
294
|
// the record and there is nothing left to evaluate.
|
|
254
295
|
if (this.record.preflightReceiptId) {
|
|
255
296
|
const receipt = await this.client.getReceipt(this.record.preflightReceiptId);
|
|
256
|
-
if (receipt)
|
|
297
|
+
if (receipt) {
|
|
298
|
+
if (this.record.preflightDecisionStatus !== receipt.receipt.decision.status) {
|
|
299
|
+
await this.casUpdate({ preflightDecisionStatus: receipt.receipt.decision.status }).catch(() => { }); // best-effort cache fill; assertPreflightAllowed() re-fetches if this never lands
|
|
300
|
+
}
|
|
257
301
|
return this.evaluationFromReceipt(receipt, null);
|
|
302
|
+
}
|
|
258
303
|
}
|
|
259
304
|
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
305
|
}
|
|
@@ -297,6 +342,7 @@ export class CommerceOperation {
|
|
|
297
342
|
const result = (await res.json());
|
|
298
343
|
await this.casUpdate({
|
|
299
344
|
preflightReceiptId: result.receipt.receipt.receipt_id,
|
|
345
|
+
preflightDecisionStatus: result.receipt.receipt.decision.status,
|
|
300
346
|
finalizationCapability: result.finalization.capability,
|
|
301
347
|
finalizationCapabilityExpiresAt: result.finalization.expires_at,
|
|
302
348
|
localPhase: 'preflight-complete',
|
|
@@ -358,6 +404,15 @@ export class CommerceOperation {
|
|
|
358
404
|
// this one sharing the same durable store) may have already claimed or
|
|
359
405
|
// advanced this operation since we last loaded it.
|
|
360
406
|
await this.reload();
|
|
407
|
+
// D2.6 review fix #1: the ONLY gate that matters is the operation's
|
|
408
|
+
// actual stored PREFLIGHT decision -- not a caller's evaluation.kind
|
|
409
|
+
// check, which is application-level discipline this class cannot see or
|
|
410
|
+
// enforce. preflightReceiptId alone is not sufficient: it is set for
|
|
411
|
+
// BLOCK and REQUIRE_APPROVAL exactly as it is for ALLOW. Fail closed on
|
|
412
|
+
// anything that isn't an authoritative ALLOW, before prepare()/submit()/
|
|
413
|
+
// resume() is ever reachable -- this runs on EVERY execute() call
|
|
414
|
+
// (including a resumed in-flight submission), not just the first.
|
|
415
|
+
await this.assertPreflightAllowed();
|
|
361
416
|
// Resuming an in-flight submission: never re-prepare/re-submit.
|
|
362
417
|
if (this.record.clientSubmissionKey && this.record.executionRequestId) {
|
|
363
418
|
if (this.record.transactionHash) {
|
|
@@ -393,6 +448,11 @@ export class CommerceOperation {
|
|
|
393
448
|
executor_version: executor.version,
|
|
394
449
|
recovery_capability_class: toRecoveryCapabilityClass(executor.recoveryMode),
|
|
395
450
|
expected_payer: null,
|
|
451
|
+
// D2.6: forwarded verbatim into the D2.4 execution binding (and from
|
|
452
|
+
// there, the lifecycle evidence bundle) so a third-party executor's
|
|
453
|
+
// own request identity (e.g. a PayBox request id) is durably
|
|
454
|
+
// correlated to this OCD operation — see PrepareResult.providerReference.
|
|
455
|
+
provider_reference: prepared.providerReference ?? null,
|
|
396
456
|
}),
|
|
397
457
|
});
|
|
398
458
|
if (!bindingRes.ok) {
|
|
@@ -35,6 +35,20 @@ export interface PrepareResult {
|
|
|
35
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
36
|
reference: unknown;
|
|
37
37
|
preparedAt: string;
|
|
38
|
+
/**
|
|
39
|
+
* Optional third-party execution-provider reference established at
|
|
40
|
+
* prepare() time (e.g. a PayBox request id, a payment processor's
|
|
41
|
+
* transaction reference) — UNLIKE `reference`, this is NOT opaque: the
|
|
42
|
+
* orchestrator forwards it verbatim as `provider_reference` on the
|
|
43
|
+
* execution-bindings call (D2.6), so it becomes part of the durable
|
|
44
|
+
* server-side binding and, from there, the D2.4 lifecycle evidence bundle
|
|
45
|
+
* — letting a receipt reader say "provider request X was associated with
|
|
46
|
+
* this OCD lifecycle" without OCD ever needing to understand what that
|
|
47
|
+
* provider's reference means. Omit when the executor has no such
|
|
48
|
+
* provider-issued identity (e.g. a local wallet signer like
|
|
49
|
+
* X402BaseUsdcExecutor).
|
|
50
|
+
*/
|
|
51
|
+
providerReference?: string | null;
|
|
38
52
|
}
|
|
39
53
|
export type ExecutionOutcome = {
|
|
40
54
|
status: 'transaction-known';
|
package/dist/commerce/index.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export * from './executor.js';
|
|
|
24
24
|
export * from './recoveryStore.js';
|
|
25
25
|
export * from './mockExecutor.js';
|
|
26
26
|
export * from './x402Executor.js';
|
|
27
|
+
export * from './payboxExecutor.js';
|
|
27
28
|
export * from './client.js';
|
|
28
29
|
export * from './policyTemplates.js';
|
|
29
30
|
export * from './evidenceExport.js';
|
package/dist/commerce/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export * from './executor.js';
|
|
|
24
24
|
export * from './recoveryStore.js';
|
|
25
25
|
export * from './mockExecutor.js';
|
|
26
26
|
export * from './x402Executor.js';
|
|
27
|
+
export * from './payboxExecutor.js';
|
|
27
28
|
export * from './client.js';
|
|
28
29
|
export * from './policyTemplates.js';
|
|
29
30
|
export * from './evidenceExport.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @onchaindiligence/sdk/commerce/node — Node-only durable store
|
|
3
|
+
* implementations (wrap `node:fs`), kept out of the main `./commerce`
|
|
4
|
+
* barrel so importing that barrel from a browser bundle can never
|
|
5
|
+
* accidentally pull in Node built-ins. See client.ts's own header for why
|
|
6
|
+
* this separation exists.
|
|
7
|
+
*/
|
|
8
|
+
export * from './nodeFileRecoveryStore.js';
|
|
9
|
+
export * from './nodeFilePayboxRequestStore.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @onchaindiligence/sdk/commerce/node — Node-only durable store
|
|
3
|
+
* implementations (wrap `node:fs`), kept out of the main `./commerce`
|
|
4
|
+
* barrel so importing that barrel from a browser bundle can never
|
|
5
|
+
* accidentally pull in Node built-ins. See client.ts's own header for why
|
|
6
|
+
* this separation exists.
|
|
7
|
+
*/
|
|
8
|
+
export * from './nodeFileRecoveryStore.js';
|
|
9
|
+
export * from './nodeFilePayboxRequestStore.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PayBoxRequestStore, PayBoxRequestRecord } from './payboxExecutor.js';
|
|
2
|
+
export declare class NodeFilePayboxRequestStore implements PayBoxRequestStore {
|
|
3
|
+
private readonly directory;
|
|
4
|
+
constructor(directory: string);
|
|
5
|
+
private pathFor;
|
|
6
|
+
get(clientSubmissionKey: string): Promise<PayBoxRequestRecord | null>;
|
|
7
|
+
set(record: PayBoxRequestRecord): Promise<void>;
|
|
8
|
+
claim(clientSubmissionKey: string, placeholder: PayBoxRequestRecord): Promise<{
|
|
9
|
+
claimed: boolean;
|
|
10
|
+
record: PayBoxRequestRecord;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nodeFilePayboxRequestStore.ts — a real, restart-surviving, single-machine
|
|
3
|
+
* PayBoxRequestStore for Node (D2.6 live reference preparation).
|
|
4
|
+
*
|
|
5
|
+
* Storage: one JSON file per clientSubmissionKey under `directory`, named
|
|
6
|
+
* `<clientSubmissionKey>.json` — same layout discipline as
|
|
7
|
+
* nodeFileRecoveryStore.ts.
|
|
8
|
+
*
|
|
9
|
+
* ATOMIC CLAIM, GENUINELY CROSS-PROCESS: `claim()` creates the file with
|
|
10
|
+
* Node's `'wx'` flag (`O_WRONLY|O_CREAT|O_EXCL` at the OS level) — the
|
|
11
|
+
* filesystem itself guarantees that of two processes racing to create the
|
|
12
|
+
* SAME path, exactly one `open()` call succeeds and the other fails with
|
|
13
|
+
* `EEXIST`. This is a STRONGER guarantee than nodeFileRecoveryStore.ts's own
|
|
14
|
+
* in-process lock queue (which that file's own header honestly documents as
|
|
15
|
+
* "NOT a multi-process/multi-machine lock") — PayBoxRequestStore.claim()'s
|
|
16
|
+
* own contract specifically requires genuine cross-process atomicity (D2.6
|
|
17
|
+
* review fix #3: "exactly one claimant may call pay_x402"), so this
|
|
18
|
+
* implementation earns that requirement at the filesystem level rather than
|
|
19
|
+
* only within one Node process.
|
|
20
|
+
*
|
|
21
|
+
* `set()` (used only by the winning claimant, to record the real
|
|
22
|
+
* `payboxRequestId` once known, and later `transactionHash`) uses the same
|
|
23
|
+
* write-to-temp-then-rename pattern as nodeFileRecoveryStore.ts so a crash
|
|
24
|
+
* mid-write can never leave a half-written, corrupt record.
|
|
25
|
+
*
|
|
26
|
+
* Single-machine, shared-filesystem scope only (same as
|
|
27
|
+
* NodeFileRecoveryStore) — a multi-machine deployment needs a real database
|
|
28
|
+
* with a native atomic insert-if-absent (e.g. `INSERT ... ON CONFLICT DO
|
|
29
|
+
* NOTHING`) instead.
|
|
30
|
+
*/
|
|
31
|
+
import { mkdir, readFile, writeFile, rename, open as openFile } from 'node:fs/promises';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { randomBytes } from 'node:crypto';
|
|
34
|
+
/** Matches the shape onchaindiligence-mcp/onchaindiligence-sdk generate for clientSubmissionKey (operationId:timestamp:random, see client.ts's claimSubmissionSlot) -- rejected characters could otherwise be used to escape `directory`. */
|
|
35
|
+
function isValidClientSubmissionKey(key) {
|
|
36
|
+
return /^[A-Za-z0-9_:.-]{1,200}$/.test(key);
|
|
37
|
+
}
|
|
38
|
+
export class NodeFilePayboxRequestStore {
|
|
39
|
+
directory;
|
|
40
|
+
constructor(directory) {
|
|
41
|
+
this.directory = directory;
|
|
42
|
+
}
|
|
43
|
+
pathFor(clientSubmissionKey) {
|
|
44
|
+
if (!isValidClientSubmissionKey(clientSubmissionKey)) {
|
|
45
|
+
throw new TypeError(`invalid clientSubmissionKey for file storage: ${clientSubmissionKey}`);
|
|
46
|
+
}
|
|
47
|
+
// ':' is valid in a clientSubmissionKey but not portable in a filename on
|
|
48
|
+
// every filesystem -- encode it rather than reject otherwise-valid keys.
|
|
49
|
+
const safeName = encodeURIComponent(clientSubmissionKey);
|
|
50
|
+
return join(this.directory, `${safeName}.json`);
|
|
51
|
+
}
|
|
52
|
+
async get(clientSubmissionKey) {
|
|
53
|
+
try {
|
|
54
|
+
const text = await readFile(this.pathFor(clientSubmissionKey), 'utf8');
|
|
55
|
+
return JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (err?.code === 'ENOENT')
|
|
59
|
+
return null;
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async set(record) {
|
|
64
|
+
await mkdir(this.directory, { recursive: true });
|
|
65
|
+
const finalPath = this.pathFor(record.clientSubmissionKey);
|
|
66
|
+
const tempPath = join(this.directory, `.${encodeURIComponent(record.clientSubmissionKey)}.${randomBytes(4).toString('hex')}.tmp`);
|
|
67
|
+
await writeFile(tempPath, JSON.stringify(record, null, 2), 'utf8');
|
|
68
|
+
await rename(tempPath, finalPath); // atomic on the same filesystem
|
|
69
|
+
}
|
|
70
|
+
async claim(clientSubmissionKey, placeholder) {
|
|
71
|
+
await mkdir(this.directory, { recursive: true });
|
|
72
|
+
const finalPath = this.pathFor(clientSubmissionKey);
|
|
73
|
+
let handle;
|
|
74
|
+
try {
|
|
75
|
+
// 'wx' = O_WRONLY | O_CREAT | O_EXCL -- the OS guarantees exactly one
|
|
76
|
+
// concurrent caller (in this process or another) wins this open().
|
|
77
|
+
handle = await openFile(finalPath, 'wx');
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
if (err?.code === 'EEXIST') {
|
|
81
|
+
const existing = await this.get(clientSubmissionKey);
|
|
82
|
+
if (existing)
|
|
83
|
+
return { claimed: false, record: existing };
|
|
84
|
+
// TOCTOU sliver: the file existed a moment ago (EEXIST) but is
|
|
85
|
+
// unreadable/gone now -- this store never deletes records, so this
|
|
86
|
+
// should not happen in practice; retry once rather than fail closed
|
|
87
|
+
// on a transient race.
|
|
88
|
+
return this.claim(clientSubmissionKey, placeholder);
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
await handle.writeFile(JSON.stringify(placeholder, null, 2), 'utf8');
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await handle.close();
|
|
97
|
+
}
|
|
98
|
+
return { claimed: true, record: { ...placeholder } };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { CommerceExecutor, PrepareContext, PrepareResult, ExecutionResult, ExecutorRecoveryMode } from './executor.js';
|
|
2
|
+
import { BASE_NETWORK, BASE_USDC, type MinimalResumeClient } from './x402Executor.js';
|
|
3
|
+
export { BASE_NETWORK as PAYBOX_BASE_NETWORK, BASE_USDC as PAYBOX_BASE_USDC };
|
|
4
|
+
export type PayBoxRequestStatus = 'pending_approval' | 'pending_signature' | 'success' | 'denied' | 'error';
|
|
5
|
+
export interface PayBoxRequestEnvelope {
|
|
6
|
+
request_id: string;
|
|
7
|
+
status: PayBoxRequestStatus;
|
|
8
|
+
/** Present on `success`. For pay_x402, carries `{ x_payment: { header, value } }` -- the signed payment header to present to the paid resource. PayBox does not itself call the resource. */
|
|
9
|
+
output?: {
|
|
10
|
+
value?: {
|
|
11
|
+
x_payment?: {
|
|
12
|
+
header: string;
|
|
13
|
+
value: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
} & Record<string, unknown>;
|
|
17
|
+
/** Present on `denied`. */
|
|
18
|
+
reason?: string;
|
|
19
|
+
/** Present on `error`. */
|
|
20
|
+
message?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PayBoxPayX402Input {
|
|
23
|
+
/** A wallet-kind credential id. */
|
|
24
|
+
credential_id: string;
|
|
25
|
+
/** The 402's `accepts` PaymentRequirements array, verbatim. */
|
|
26
|
+
accepts: unknown[];
|
|
27
|
+
/** The paid resource URL, for audit/display. */
|
|
28
|
+
resource_url: string;
|
|
29
|
+
/** 1 for JSON-body requirements, 2 for header requirements. */
|
|
30
|
+
x402_version?: 1 | 2;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The minimal PayBox public surface this executor depends on -- exactly
|
|
34
|
+
* `pay_x402` and `get_request`, structurally typed so any transport (the
|
|
35
|
+
* real `@paybox-sh/sdk` PayboxClient, an MCP tool-call wrapper, or a test
|
|
36
|
+
* double) can satisfy it without this package taking a hard dependency on
|
|
37
|
+
* a specific vendor SDK version.
|
|
38
|
+
*/
|
|
39
|
+
export interface PayBoxClient {
|
|
40
|
+
payX402(input: PayBoxPayX402Input): Promise<PayBoxRequestEnvelope>;
|
|
41
|
+
getRequest(requestId: string): Promise<PayBoxRequestEnvelope>;
|
|
42
|
+
}
|
|
43
|
+
export interface PayBoxRequestRecord {
|
|
44
|
+
clientSubmissionKey: string;
|
|
45
|
+
/** null only in the narrow window between "we called pay_x402" and "we recorded its request_id" -- see prepare(). */
|
|
46
|
+
payboxRequestId: string | null;
|
|
47
|
+
resourceUrl: string;
|
|
48
|
+
network: string;
|
|
49
|
+
asset: string;
|
|
50
|
+
atomicAmount: string;
|
|
51
|
+
recipient: string;
|
|
52
|
+
/** Set once the merchant resource confirms the payment and returns a hash -- once set, this adapter never re-presents the PayBox-signed header to the merchant again. */
|
|
53
|
+
transactionHash: string | null;
|
|
54
|
+
}
|
|
55
|
+
export interface PayBoxRequestStore {
|
|
56
|
+
get(clientSubmissionKey: string): Promise<PayBoxRequestRecord | null>;
|
|
57
|
+
set(record: PayBoxRequestRecord): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Atomically claims the attempt slot for `clientSubmissionKey` (D2.6
|
|
60
|
+
* review fix #3): if no record exists yet, stores `placeholder` (which
|
|
61
|
+
* MUST have `payboxRequestId: null`) and returns `{ claimed: true, record:
|
|
62
|
+
* placeholder }` -- the caller, and ONLY the caller, may now call
|
|
63
|
+
* `pay_x402` for this key. If a record already exists (whether still in
|
|
64
|
+
* the ambiguous pre-request-id window, or already holding a
|
|
65
|
+
* `payboxRequestId`), returns `{ claimed: false, record: <the existing
|
|
66
|
+
* record, unmodified> }` and the caller MUST NOT call `pay_x402`.
|
|
67
|
+
*
|
|
68
|
+
* This is the ONE operation in this interface that a real, multi-process
|
|
69
|
+
* implementation MUST make genuinely atomic (e.g. a SQL `INSERT ... ON
|
|
70
|
+
* CONFLICT DO NOTHING` followed by a `SELECT`, or an equivalent
|
|
71
|
+
* conditional-put) -- the same discipline as `CommerceRecoveryStore.create()`
|
|
72
|
+
* throwing `RecoveryRecordExistsError` rather than silently overwriting.
|
|
73
|
+
* `InMemoryPayBoxRequestStore`'s implementation is atomic only because a
|
|
74
|
+
* single JS `Map` access with no `await` in between can never interleave
|
|
75
|
+
* with another call in the same process.
|
|
76
|
+
*/
|
|
77
|
+
claim(clientSubmissionKey: string, placeholder: PayBoxRequestRecord): Promise<{
|
|
78
|
+
claimed: boolean;
|
|
79
|
+
record: PayBoxRequestRecord;
|
|
80
|
+
}>;
|
|
81
|
+
}
|
|
82
|
+
/** Volatile, single-process, TEST/EXAMPLE-ONLY implementation -- same discipline as recoveryStore.ts's InMemoryRecoveryStore. Does not survive a restart; a real deployment must implement this against durable storage. */
|
|
83
|
+
export declare class InMemoryPayBoxRequestStore implements PayBoxRequestStore {
|
|
84
|
+
private readonly records;
|
|
85
|
+
get(clientSubmissionKey: string): Promise<PayBoxRequestRecord | null>;
|
|
86
|
+
set(record: PayBoxRequestRecord): Promise<void>;
|
|
87
|
+
claim(clientSubmissionKey: string, placeholder: PayBoxRequestRecord): Promise<{
|
|
88
|
+
claimed: boolean;
|
|
89
|
+
record: PayBoxRequestRecord;
|
|
90
|
+
}>;
|
|
91
|
+
}
|
|
92
|
+
/** Thrown by the PayBoxCommerceExecutor constructor when no durable store was supplied (D2.6 review fix #2). */
|
|
93
|
+
export declare class PayBoxStoreRequiredError extends Error {
|
|
94
|
+
constructor();
|
|
95
|
+
}
|
|
96
|
+
/** Thrown by prepare() when a PRIOR attempt for this exact clientSubmissionKey called pay_x402 but this process never learned the outcome -- see this file's header for why this cannot be silently retried. */
|
|
97
|
+
export declare class PayBoxAmbiguousPrepareError extends Error {
|
|
98
|
+
constructor(clientSubmissionKey: string);
|
|
99
|
+
}
|
|
100
|
+
export interface PayBoxExecutorOptions {
|
|
101
|
+
/** The PayBox client used to call pay_x402/get_request. Never logged, never persisted by this class. */
|
|
102
|
+
paybox: PayBoxClient;
|
|
103
|
+
/** The single PayBox wallet-kind credential this executor pays from (Section 2: one credential, one operation at a time for the reference flow). */
|
|
104
|
+
credentialId: string;
|
|
105
|
+
/**
|
|
106
|
+
* Durable store for this adapter's own request identity -- REQUIRED, no
|
|
107
|
+
* default (D2.6 review fix #2). This class advertises
|
|
108
|
+
* `recoveryMode: 'stable-payment-identity'`, which is only true if the
|
|
109
|
+
* PayBox request_id genuinely survives a restart; silently defaulting to
|
|
110
|
+
* `InMemoryPayBoxRequestStore` would make that claim false the moment the
|
|
111
|
+
* process restarts. `InMemoryPayBoxRequestStore` remains available for
|
|
112
|
+
* tests/examples but must be passed explicitly, exactly like
|
|
113
|
+
* `CommerceRecoveryStore` has no safe default either.
|
|
114
|
+
*/
|
|
115
|
+
store: PayBoxRequestStore;
|
|
116
|
+
fetch?: typeof globalThis.fetch;
|
|
117
|
+
/** Base RPC used ONLY for read-only resume confirmation of an already-known transaction hash. Defaults to the public Base RPC. */
|
|
118
|
+
rpcUrl?: string;
|
|
119
|
+
/** Test seam: inject a fake read-only client instead of connecting to rpcUrl. */
|
|
120
|
+
publicClient?: MinimalResumeClient;
|
|
121
|
+
}
|
|
122
|
+
export declare class PayBoxCommerceExecutor implements CommerceExecutor {
|
|
123
|
+
readonly id = "paybox-x402-base-usdc";
|
|
124
|
+
readonly version = "v1";
|
|
125
|
+
readonly recoveryMode: ExecutorRecoveryMode;
|
|
126
|
+
private readonly paybox;
|
|
127
|
+
private readonly credentialId;
|
|
128
|
+
private readonly store;
|
|
129
|
+
private readonly fetchImpl;
|
|
130
|
+
private readonly rpcUrl;
|
|
131
|
+
private readonly injectedPublicClient?;
|
|
132
|
+
constructor(options: PayBoxExecutorOptions);
|
|
133
|
+
prepare(context: PrepareContext): Promise<PrepareResult>;
|
|
134
|
+
private toPrepareResult;
|
|
135
|
+
submit(prepared: PrepareResult): Promise<ExecutionResult>;
|
|
136
|
+
resume(prepared: PrepareResult, priorOutcome?: ExecutionResult): Promise<ExecutionResult>;
|
|
137
|
+
/**
|
|
138
|
+
* Shared by submit() (first check, right after prepare()) and resume()
|
|
139
|
+
* (every later check) -- ONE `get_request` poll, then an honest mapping of
|
|
140
|
+
* PayBox's current status. This is the entire "polling" mechanism: neither
|
|
141
|
+
* method loops internally. A still-pending request returns
|
|
142
|
+
* 'submission-ambiguous' with a retry hint, and the EXISTING orchestrator
|
|
143
|
+
* retry pattern (the developer calling op.execute() again, which calls
|
|
144
|
+
* resume(), never submit(), for an already-claimed identity) is what
|
|
145
|
+
* drives the next poll -- see Section 11: no parallel polling API.
|
|
146
|
+
*/
|
|
147
|
+
private resolve;
|
|
148
|
+
/**
|
|
149
|
+
* Attaches the PayBox-signed x402 payment header and calls the merchant
|
|
150
|
+
* resource exactly once per invocation. Safe to call again if a PRIOR
|
|
151
|
+
* attempt never reached a response (the underlying x402 "exact" scheme
|
|
152
|
+
* authorization is a single-use EIP-3009 `transferWithAuthorization` --
|
|
153
|
+
* the merchant/facilitator re-broadcasting the SAME authorization a second
|
|
154
|
+
* time reverts on-chain rather than double-charging; this adapter still
|
|
155
|
+
* avoids that path whenever possible by checking `record.transactionHash`
|
|
156
|
+
* first in resolve()).
|
|
157
|
+
*/
|
|
158
|
+
private presentPaymentToMerchant;
|
|
159
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* payboxExecutor.ts — the one narrow PayBoxCommerceExecutor adapter (D2.6).
|
|
3
|
+
*
|
|
4
|
+
* PROVES: OCD works alongside an INDEPENDENT execution-control system.
|
|
5
|
+
* PayBox (https://paybox.sh, MoonPay's non-custodial agent payment vault)
|
|
6
|
+
* independently evaluates its own grant/approval rules and signs the
|
|
7
|
+
* payment; OCD independently evaluates policy beforehand and independently
|
|
8
|
+
* observes settlement afterward. Neither system's decision overrides the
|
|
9
|
+
* other -- see client.ts's execute()/preflight() split, which is what
|
|
10
|
+
* actually enforces this (the developer checks `preflight.kind === 'ready'`
|
|
11
|
+
* BEFORE ever calling execute(), and execute() never runs PayBox logic for
|
|
12
|
+
* an operation that never reached execute()).
|
|
13
|
+
*
|
|
14
|
+
* PUBLIC CONTRACT USED (docs.paybox.sh, inspected live 2026-09-06):
|
|
15
|
+
* - `pay_x402` (reference/mcp-tools): signs an x402 v2 "exact" payment
|
|
16
|
+
* authorization for a wallet-kind credential. Returns a `request_id`
|
|
17
|
+
* immediately; on eventual `success`, `output.value.x_payment` carries
|
|
18
|
+
* the header name/value to present to the paid resource. PayBox does
|
|
19
|
+
* NOT itself call the resource or broadcast anything on-chain for this
|
|
20
|
+
* tool -- broadcasting happens when the caller attaches that header to
|
|
21
|
+
* an HTTP request to the resource, exactly like x402Executor.ts's own
|
|
22
|
+
* `wrapFetchWithPayment` step, except the SIGNING half now happens
|
|
23
|
+
* inside PayBox's vault instead of a local private key.
|
|
24
|
+
* - `get_request` (reference/mcp-tools): polls the CURRENT status of a
|
|
25
|
+
* request by `request_id`. Docs (concepts/requests) state the request
|
|
26
|
+
* lifecycle explicitly: non-terminal `pending_approval` ->
|
|
27
|
+
* `pending_signature` -> terminal `success` | `denied` | `error`, and
|
|
28
|
+
* the "critical rule": "submit once, then poll -- never re-issue the
|
|
29
|
+
* original tool call to 'finish' it. Resubmission creates a duplicate
|
|
30
|
+
* operation." This adapter follows that rule exactly: `pay_x402` is
|
|
31
|
+
* called AT MOST ONCE per clientSubmissionKey (from prepare()); every
|
|
32
|
+
* subsequent check, in submit() or resume(), calls `get_request` only.
|
|
33
|
+
*
|
|
34
|
+
* THIS FILE DEPENDS ON NO SPECIFIC PAYBOX SDK VERSION: `PayBoxClient` below
|
|
35
|
+
* is a minimal structural interface mirroring exactly the two documented
|
|
36
|
+
* tool contracts above (same discipline as x402Executor.ts's `ClientEvmSigner`
|
|
37
|
+
* -- narrow enough that the real `@paybox-sh/sdk`'s `PayboxClient`, a direct
|
|
38
|
+
* MCP tool-call wrapper, or a test double can all satisfy it unmodified).
|
|
39
|
+
*
|
|
40
|
+
* WHY A SEPARATE PayBoxRequestStore (Section 11's "small adapter-specific
|
|
41
|
+
* helper" exception): the orchestrator (client.ts's CommerceOperation)
|
|
42
|
+
* persists only clientSubmissionKey/executorId/executionRequestId/
|
|
43
|
+
* transactionHash across a restart -- on resume, it reconstructs a GENERIC
|
|
44
|
+
* `PrepareResult.reference = { action }`, not whatever an individual
|
|
45
|
+
* executor's own prepare() returned (see client.ts's executeLocked(),
|
|
46
|
+
* both resume branches). X402BaseUsdcExecutor never needed its own store
|
|
47
|
+
* because its only durable identity IS the eventual transaction hash, which
|
|
48
|
+
* the orchestrator already persists. PayBox is different: `pay_x402` has NO
|
|
49
|
+
* documented idempotency key, so this adapter's OWN durable request_id
|
|
50
|
+
* (keyed by clientSubmissionKey, which the orchestrator DOES reliably pass
|
|
51
|
+
* to both prepare() and resume()) is the only thing standing between a lost
|
|
52
|
+
* response and a duplicate PayBox request. This is the genuinely-required
|
|
53
|
+
* small helper Section 11 anticipates -- not a parallel lifecycle API.
|
|
54
|
+
*
|
|
55
|
+
* RECOVERY MODE: 'stable-payment-identity', not 'provider-idempotent' and
|
|
56
|
+
* not 'manual'. Chosen by what PayBox's OWN public contract actually
|
|
57
|
+
* guarantees, not for the strongest-sounding label (Section 5):
|
|
58
|
+
* - NOT 'provider-idempotent': that would claim calling `pay_x402` twice
|
|
59
|
+
* with the same intent is safe (PayBox itself would dedupe). Docs say
|
|
60
|
+
* the opposite -- resubmitting creates a duplicate request. False if
|
|
61
|
+
* claimed.
|
|
62
|
+
* - IS 'stable-payment-identity': once a request_id exists, `get_request`
|
|
63
|
+
* can be polled/resumed against that SAME id indefinitely, deterministically,
|
|
64
|
+
* with no risk of creating a new operation -- exactly the definition
|
|
65
|
+
* Section 5 gives for this label. The identity is the PayBox request_id
|
|
66
|
+
* itself, established once in prepare() and never re-created.
|
|
67
|
+
* - The one honest gap: if `pay_x402` is called but the process dies
|
|
68
|
+
* before learning whether PayBox ever created a request (no response at
|
|
69
|
+
* all), there is no stable identity to resume -- see prepare()'s
|
|
70
|
+
* PayBoxAmbiguousPrepareError, which surfaces this narrow window
|
|
71
|
+
* explicitly rather than silently retrying pay_x402 a second time.
|
|
72
|
+
*/
|
|
73
|
+
import { createPublicClient, http } from 'viem';
|
|
74
|
+
import { base } from 'viem/chains';
|
|
75
|
+
import { BASE_NETWORK, BASE_USDC } from './x402Executor.js';
|
|
76
|
+
import { X402ChallengeError, decodeChallenge, validateChallenge, decodeSettlementResponse, decimalToAtomic6 } from './x402Challenge.js';
|
|
77
|
+
export { BASE_NETWORK as PAYBOX_BASE_NETWORK, BASE_USDC as PAYBOX_BASE_USDC };
|
|
78
|
+
/** Volatile, single-process, TEST/EXAMPLE-ONLY implementation -- same discipline as recoveryStore.ts's InMemoryRecoveryStore. Does not survive a restart; a real deployment must implement this against durable storage. */
|
|
79
|
+
export class InMemoryPayBoxRequestStore {
|
|
80
|
+
records = new Map();
|
|
81
|
+
async get(clientSubmissionKey) {
|
|
82
|
+
return this.records.get(clientSubmissionKey) ?? null;
|
|
83
|
+
}
|
|
84
|
+
async set(record) {
|
|
85
|
+
this.records.set(record.clientSubmissionKey, { ...record });
|
|
86
|
+
}
|
|
87
|
+
async claim(clientSubmissionKey, placeholder) {
|
|
88
|
+
// No `await` between the check and the write -- this is what makes this
|
|
89
|
+
// specific implementation race-free for concurrent callers IN THIS PROCESS.
|
|
90
|
+
const existing = this.records.get(clientSubmissionKey);
|
|
91
|
+
if (existing)
|
|
92
|
+
return { claimed: false, record: { ...existing } };
|
|
93
|
+
const stored = { ...placeholder };
|
|
94
|
+
this.records.set(clientSubmissionKey, stored);
|
|
95
|
+
return { claimed: true, record: { ...stored } };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Thrown by the PayBoxCommerceExecutor constructor when no durable store was supplied (D2.6 review fix #2). */
|
|
99
|
+
export class PayBoxStoreRequiredError extends Error {
|
|
100
|
+
constructor() {
|
|
101
|
+
super("PayBoxCommerceExecutor requires an explicit, durable `store` (PayBoxRequestStore) -- InMemoryPayBoxRequestStore is test/example-only and does not survive a restart, which would silently make this executor's advertised recoveryMode ('stable-payment-identity') false. Pass a durable implementation in production; InMemoryPayBoxRequestStore only in tests/examples.");
|
|
102
|
+
this.name = 'PayBoxStoreRequiredError';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Thrown by prepare() when a PRIOR attempt for this exact clientSubmissionKey called pay_x402 but this process never learned the outcome -- see this file's header for why this cannot be silently retried. */
|
|
106
|
+
export class PayBoxAmbiguousPrepareError extends Error {
|
|
107
|
+
constructor(clientSubmissionKey) {
|
|
108
|
+
super(`a prior prepare() for clientSubmissionKey "${clientSubmissionKey}" called PayBox's pay_x402 but this process never learned whether PayBox created a request -- pay_x402 has no idempotency key, so calling it again here could create a SECOND PayBox request for the same intended payment. Check PayBox directly (dashboard or "paybox request --list") for an orphaned request tied to this payment before retrying.`);
|
|
109
|
+
this.name = 'PayBoxAmbiguousPrepareError';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export class PayBoxCommerceExecutor {
|
|
113
|
+
id = 'paybox-x402-base-usdc';
|
|
114
|
+
version = 'v1';
|
|
115
|
+
recoveryMode = 'stable-payment-identity';
|
|
116
|
+
paybox;
|
|
117
|
+
credentialId;
|
|
118
|
+
store;
|
|
119
|
+
fetchImpl;
|
|
120
|
+
rpcUrl;
|
|
121
|
+
injectedPublicClient;
|
|
122
|
+
constructor(options) {
|
|
123
|
+
if (!options.store)
|
|
124
|
+
throw new PayBoxStoreRequiredError();
|
|
125
|
+
this.paybox = options.paybox;
|
|
126
|
+
this.credentialId = options.credentialId;
|
|
127
|
+
this.store = options.store;
|
|
128
|
+
// See client.ts's constructor comment: binding here is what keeps a bare
|
|
129
|
+
// `globalThis.fetch` reference safe to call as `this.fetchImpl(...)` in
|
|
130
|
+
// a real browser.
|
|
131
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
132
|
+
this.rpcUrl = options.rpcUrl ?? 'https://mainnet.base.org';
|
|
133
|
+
this.injectedPublicClient = options.publicClient;
|
|
134
|
+
}
|
|
135
|
+
async prepare(context) {
|
|
136
|
+
if (context.action.network !== BASE_NETWORK)
|
|
137
|
+
throw new X402ChallengeError(`this executor only supports ${BASE_NETWORK}, got "${context.action.network}"`);
|
|
138
|
+
if (context.action.asset.toLowerCase() !== BASE_USDC.toLowerCase()) {
|
|
139
|
+
throw new X402ChallengeError(`this executor only supports USDC (${BASE_USDC}), got "${context.action.asset}"`);
|
|
140
|
+
}
|
|
141
|
+
if (!context.action.resource)
|
|
142
|
+
throw new X402ChallengeError('action.resource (the x402 resource URL) is required to prepare a submission');
|
|
143
|
+
// Read-only probe -- no PayBox call, no signing, no payment. Establishes
|
|
144
|
+
// exactly what would be agreed to pay BEFORE any authorization exists,
|
|
145
|
+
// identical in spirit to X402BaseUsdcExecutor.prepare(). Safe to run
|
|
146
|
+
// more than once even under a concurrent race (it has no side effects),
|
|
147
|
+
// so it happens BEFORE the atomic claim below.
|
|
148
|
+
const probe = await this.fetchImpl(context.action.resource);
|
|
149
|
+
if (probe.status !== 402)
|
|
150
|
+
throw new X402ChallengeError(`expected HTTP 402 from ${context.action.resource}, got ${probe.status}`);
|
|
151
|
+
const challenge = decodeChallenge(probe);
|
|
152
|
+
const atomicAmount = decimalToAtomic6(context.action.amount);
|
|
153
|
+
validateChallenge(challenge, { network: context.action.network, asset: context.action.asset, amount: atomicAmount, recipient: context.action.recipient });
|
|
154
|
+
// D2.6 review fix #3: atomically claim the attempt slot for this
|
|
155
|
+
// clientSubmissionKey -- exactly one concurrent caller may proceed to
|
|
156
|
+
// call pay_x402. A loser either reuses the winner's already-established
|
|
157
|
+
// request (if it finished first) or, if the winner is still in the
|
|
158
|
+
// ambiguous pre-request-id window, stops safely rather than racing to
|
|
159
|
+
// call pay_x402 itself.
|
|
160
|
+
const placeholder = {
|
|
161
|
+
clientSubmissionKey: context.clientSubmissionKey,
|
|
162
|
+
payboxRequestId: null,
|
|
163
|
+
resourceUrl: context.action.resource,
|
|
164
|
+
network: context.action.network,
|
|
165
|
+
asset: context.action.asset,
|
|
166
|
+
atomicAmount,
|
|
167
|
+
recipient: context.action.recipient,
|
|
168
|
+
transactionHash: null,
|
|
169
|
+
};
|
|
170
|
+
const { claimed, record: claimedRecord } = await this.store.claim(context.clientSubmissionKey, placeholder);
|
|
171
|
+
if (!claimed) {
|
|
172
|
+
if (claimedRecord.payboxRequestId) {
|
|
173
|
+
// Another attempt already established (or is finishing establishing)
|
|
174
|
+
// a PayBox request for this exact key -- never call pay_x402 again.
|
|
175
|
+
return this.toPrepareResult(context.clientSubmissionKey, claimedRecord);
|
|
176
|
+
}
|
|
177
|
+
// The winner called pay_x402 but this process never learned the
|
|
178
|
+
// outcome (crash, or the winner is still in flight). Known,
|
|
179
|
+
// unavoidable crash window (see this file's header) -- surface it
|
|
180
|
+
// honestly rather than racing to call pay_x402 ourselves.
|
|
181
|
+
throw new PayBoxAmbiguousPrepareError(context.clientSubmissionKey);
|
|
182
|
+
}
|
|
183
|
+
// We won the claim -- exactly this call may proceed to PayBox. This is
|
|
184
|
+
// PayBox's OWN independent grant/authorization check -- pay_x402
|
|
185
|
+
// internally applies the credential's approval mode ("Always Ask" vs
|
|
186
|
+
// autonomous-within-limits per docs.paybox.sh/concepts/model) before
|
|
187
|
+
// ever producing a signature. Does NOT broadcast anything on-chain (see
|
|
188
|
+
// header) -- only establishes the durable request_id.
|
|
189
|
+
const envelope = await this.paybox.payX402({
|
|
190
|
+
credential_id: this.credentialId,
|
|
191
|
+
accepts: challenge.accepts,
|
|
192
|
+
resource_url: context.action.resource,
|
|
193
|
+
x402_version: challenge.x402Version,
|
|
194
|
+
});
|
|
195
|
+
const record = { ...claimedRecord, payboxRequestId: envelope.request_id };
|
|
196
|
+
await this.store.set(record);
|
|
197
|
+
return this.toPrepareResult(context.clientSubmissionKey, record);
|
|
198
|
+
}
|
|
199
|
+
toPrepareResult(clientSubmissionKey, record) {
|
|
200
|
+
const reference = {
|
|
201
|
+
payboxRequestId: record.payboxRequestId,
|
|
202
|
+
resourceUrl: record.resourceUrl,
|
|
203
|
+
network: record.network,
|
|
204
|
+
asset: record.asset,
|
|
205
|
+
atomicAmount: record.atomicAmount,
|
|
206
|
+
recipient: record.recipient,
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
clientSubmissionKey,
|
|
210
|
+
reference,
|
|
211
|
+
preparedAt: new Date().toISOString(),
|
|
212
|
+
// Section 7: correlated into the D2.4 execution binding, and from
|
|
213
|
+
// there the lifecycle evidence bundle, by client.ts.
|
|
214
|
+
providerReference: record.payboxRequestId ? `paybox:${record.payboxRequestId}` : null,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
async submit(prepared) {
|
|
218
|
+
const ref = prepared.reference;
|
|
219
|
+
return this.resolve(prepared.clientSubmissionKey, ref);
|
|
220
|
+
}
|
|
221
|
+
async resume(prepared, priorOutcome) {
|
|
222
|
+
// Mirrors X402BaseUsdcExecutor's own resume(): if a transaction hash is
|
|
223
|
+
// ALREADY known from a prior call, independently re-confirm it read-only
|
|
224
|
+
// on-chain -- never guess a new one, never re-present the payment header.
|
|
225
|
+
if (priorOutcome?.status === 'transaction-known') {
|
|
226
|
+
const client = this.injectedPublicClient ?? createPublicClient({ chain: base, transport: http(this.rpcUrl) });
|
|
227
|
+
try {
|
|
228
|
+
await client.getTransactionReceipt({ hash: priorOutcome.transactionHash });
|
|
229
|
+
return priorOutcome;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return { clientSubmissionKey: prepared.clientSubmissionKey, status: 'submission-ambiguous', reason: 'previously reported transaction hash was not found on Base mainnet (may still be propagating)' };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// The orchestrator reconstructs a GENERIC `prepared.reference = { action }`
|
|
236
|
+
// on resume (see this file's header) -- the PayBox request_id can only be
|
|
237
|
+
// found via this adapter's OWN durable store, keyed by the one field the
|
|
238
|
+
// orchestrator DOES reliably pass through: clientSubmissionKey.
|
|
239
|
+
const record = await this.store.get(prepared.clientSubmissionKey);
|
|
240
|
+
if (!record || !record.payboxRequestId) {
|
|
241
|
+
return {
|
|
242
|
+
clientSubmissionKey: prepared.clientSubmissionKey,
|
|
243
|
+
status: 'manual-recovery-required',
|
|
244
|
+
reason: 'no PayBox request is on record for this submission attempt -- check PayBox directly (dashboard or CLI) before retrying',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return this.resolve(prepared.clientSubmissionKey, {
|
|
248
|
+
payboxRequestId: record.payboxRequestId,
|
|
249
|
+
resourceUrl: record.resourceUrl,
|
|
250
|
+
network: record.network,
|
|
251
|
+
asset: record.asset,
|
|
252
|
+
atomicAmount: record.atomicAmount,
|
|
253
|
+
recipient: record.recipient,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Shared by submit() (first check, right after prepare()) and resume()
|
|
258
|
+
* (every later check) -- ONE `get_request` poll, then an honest mapping of
|
|
259
|
+
* PayBox's current status. This is the entire "polling" mechanism: neither
|
|
260
|
+
* method loops internally. A still-pending request returns
|
|
261
|
+
* 'submission-ambiguous' with a retry hint, and the EXISTING orchestrator
|
|
262
|
+
* retry pattern (the developer calling op.execute() again, which calls
|
|
263
|
+
* resume(), never submit(), for an already-claimed identity) is what
|
|
264
|
+
* drives the next poll -- see Section 11: no parallel polling API.
|
|
265
|
+
*/
|
|
266
|
+
async resolve(clientSubmissionKey, ref) {
|
|
267
|
+
const record = await this.store.get(clientSubmissionKey);
|
|
268
|
+
if (record?.transactionHash) {
|
|
269
|
+
// Already confirmed by the merchant in a prior call -- never re-present
|
|
270
|
+
// the PayBox-signed payment header again.
|
|
271
|
+
return { clientSubmissionKey, status: 'transaction-known', transactionHash: record.transactionHash, providerReference: `paybox:${ref.payboxRequestId}` };
|
|
272
|
+
}
|
|
273
|
+
if (!ref.payboxRequestId) {
|
|
274
|
+
return { clientSubmissionKey, status: 'manual-recovery-required', reason: 'no PayBox request_id is available for this submission attempt' };
|
|
275
|
+
}
|
|
276
|
+
let envelope;
|
|
277
|
+
try {
|
|
278
|
+
envelope = await this.paybox.getRequest(ref.payboxRequestId);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
// get_request is a read-only status check -- a failure here is
|
|
282
|
+
// ambiguous about PayBox's OWN reachability, never about whether the
|
|
283
|
+
// request itself changed state. Safe to just try again later.
|
|
284
|
+
return { clientSubmissionKey, status: 'submission-ambiguous', reason: `could not reach PayBox to check request ${ref.payboxRequestId}: ${err?.message || 'no response'}`, retryAfterSeconds: 5 };
|
|
285
|
+
}
|
|
286
|
+
if (envelope.status === 'pending_approval' || envelope.status === 'pending_signature') {
|
|
287
|
+
return {
|
|
288
|
+
clientSubmissionKey,
|
|
289
|
+
status: 'submission-ambiguous',
|
|
290
|
+
reason: `PayBox request ${ref.payboxRequestId} is ${envelope.status} -- poll again, do not resubmit`,
|
|
291
|
+
retryAfterSeconds: envelope.status === 'pending_approval' ? 15 : 5,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (envelope.status === 'denied') {
|
|
295
|
+
// Terminal and definitive: PayBox's OWN grant/approval rules rejected
|
|
296
|
+
// this payment. This is NOT an OCD outcome and NOT ambiguous -- no
|
|
297
|
+
// merchant payment occurred and none will for this request. Mapped to
|
|
298
|
+
// manual-recovery-required (the closest of the three ExecutionOutcome
|
|
299
|
+
// states to "no execution, a human should see why and decide next
|
|
300
|
+
// steps") rather than submission-ambiguous, so the developer is NOT
|
|
301
|
+
// told to just keep retrying a denial that will never change.
|
|
302
|
+
return {
|
|
303
|
+
clientSubmissionKey,
|
|
304
|
+
status: 'manual-recovery-required',
|
|
305
|
+
reason: `PayBox denied request ${ref.payboxRequestId}${envelope.reason ? `: ${envelope.reason}` : ''} -- no merchant payment was made`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (envelope.status === 'error') {
|
|
309
|
+
// D2.6 review fix #4: docs.paybox.sh/concepts/requests lists `error`
|
|
310
|
+
// under "Terminal (polling stops)" -- treating it as retryable
|
|
311
|
+
// submission-ambiguous would poll the SAME terminal envelope forever
|
|
312
|
+
// and, worse, invites a caller to eventually give up and start a NEW
|
|
313
|
+
// PayBox request for the same intent. Terminal and definitive, exactly
|
|
314
|
+
// like `denied`: no merchant payment occurred and this request will
|
|
315
|
+
// never resolve differently. A genuinely NEW attempt requires a NEW
|
|
316
|
+
// operation/clientSubmissionKey, never a retry of this one.
|
|
317
|
+
return {
|
|
318
|
+
clientSubmissionKey,
|
|
319
|
+
status: 'manual-recovery-required',
|
|
320
|
+
reason: `PayBox reported a terminal error for request ${ref.payboxRequestId}${envelope.message ? `: ${envelope.message}` : ''} -- no merchant payment was made; this request will not resolve differently on retry`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
// status === 'success': PayBox signed the payment. It did NOT submit it
|
|
324
|
+
// to the merchant -- that's this adapter's job now, exactly like
|
|
325
|
+
// X402BaseUsdcExecutor.submit()'s own post-signing half.
|
|
326
|
+
const xPayment = envelope.output?.value?.x_payment;
|
|
327
|
+
if (!xPayment?.header || !xPayment?.value) {
|
|
328
|
+
// `success` is ALSO terminal (per docs) -- polling get_request again
|
|
329
|
+
// would return this exact same envelope forever, so this must stop
|
|
330
|
+
// safely rather than being reported as retryable.
|
|
331
|
+
return {
|
|
332
|
+
clientSubmissionKey,
|
|
333
|
+
status: 'manual-recovery-required',
|
|
334
|
+
reason: `PayBox request ${ref.payboxRequestId} reached terminal status "success" but no x_payment header could be read from its output -- check PayBox directly before retrying`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
return this.presentPaymentToMerchant(clientSubmissionKey, ref, xPayment);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Attaches the PayBox-signed x402 payment header and calls the merchant
|
|
341
|
+
* resource exactly once per invocation. Safe to call again if a PRIOR
|
|
342
|
+
* attempt never reached a response (the underlying x402 "exact" scheme
|
|
343
|
+
* authorization is a single-use EIP-3009 `transferWithAuthorization` --
|
|
344
|
+
* the merchant/facilitator re-broadcasting the SAME authorization a second
|
|
345
|
+
* time reverts on-chain rather than double-charging; this adapter still
|
|
346
|
+
* avoids that path whenever possible by checking `record.transactionHash`
|
|
347
|
+
* first in resolve()).
|
|
348
|
+
*/
|
|
349
|
+
async presentPaymentToMerchant(clientSubmissionKey, ref, xPayment) {
|
|
350
|
+
let res;
|
|
351
|
+
try {
|
|
352
|
+
res = await this.fetchImpl(ref.resourceUrl, { headers: { [xPayment.header]: xPayment.value } });
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
return { clientSubmissionKey, status: 'submission-ambiguous', reason: err?.message || 'no response from the resource after presenting the PayBox-signed payment', retryAfterSeconds: 5 };
|
|
356
|
+
}
|
|
357
|
+
if (res.status === 402) {
|
|
358
|
+
return { clientSubmissionKey, status: 'submission-ambiguous', reason: `resource still returned 402 after presenting the PayBox-signed payment (status ${res.status})` };
|
|
359
|
+
}
|
|
360
|
+
if (!res.ok) {
|
|
361
|
+
return { clientSubmissionKey, status: 'submission-ambiguous', reason: `resource returned HTTP ${res.status} after presenting the PayBox-signed payment -- outcome unknown` };
|
|
362
|
+
}
|
|
363
|
+
const { transactionHash } = decodeSettlementResponse(res);
|
|
364
|
+
if (!transactionHash) {
|
|
365
|
+
return { clientSubmissionKey, status: 'submission-ambiguous', reason: 'resource responded successfully but no transaction hash could be parsed from the settlement response' };
|
|
366
|
+
}
|
|
367
|
+
// Durable BEFORE returning -- resolve() must never re-present this
|
|
368
|
+
// payment header to the merchant again once a hash is known.
|
|
369
|
+
const record = await this.store.get(clientSubmissionKey);
|
|
370
|
+
if (record)
|
|
371
|
+
await this.store.set({ ...record, transactionHash });
|
|
372
|
+
return { clientSubmissionKey, status: 'transaction-known', transactionHash, providerReference: `paybox:${ref.payboxRequestId}` };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -29,6 +29,18 @@ export interface CommerceRecoveryRecord {
|
|
|
29
29
|
createdAt: string;
|
|
30
30
|
updatedAt: string;
|
|
31
31
|
preflightReceiptId: string | null;
|
|
32
|
+
/**
|
|
33
|
+
* The stored PREFLIGHT receipt's `decision.status`, exactly as returned by
|
|
34
|
+
* the server, recorded the moment preflight() succeeds — this is what
|
|
35
|
+
* CommerceOperation.execute() checks (fail-closed) before ever calling an
|
|
36
|
+
* executor, so BLOCK/REQUIRE_APPROVAL/UNKNOWN can never reach
|
|
37
|
+
* prepare()/submit()/resume() merely because a caller forgot to check
|
|
38
|
+
* evaluation.kind themselves (D2.6 review fix #1). `null` until a
|
|
39
|
+
* preflight decision is known, or for a record created before this field
|
|
40
|
+
* existed — execute() re-fetches the authoritative receipt in that case
|
|
41
|
+
* rather than assuming ALLOW.
|
|
42
|
+
*/
|
|
43
|
+
preflightDecisionStatus: 'ALLOW' | 'REQUIRE_APPROVAL' | 'BLOCK' | 'UNKNOWN' | null;
|
|
32
44
|
/** The one-time finalization capability token, when known. Never log this. */
|
|
33
45
|
finalizationCapability: string | null;
|
|
34
46
|
finalizationCapabilityExpiresAt: string | null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402Challenge.ts — shared x402 v2 "exact" challenge helpers (D2.6).
|
|
3
|
+
*
|
|
4
|
+
* Extracted verbatim from x402Executor.ts (D2.5) so payboxExecutor.ts (D2.6)
|
|
5
|
+
* can reuse the SAME proven challenge-decoding/validation logic instead of
|
|
6
|
+
* maintaining a second copy of security-critical checks (wrong recipient,
|
|
7
|
+
* wrong amount, wrong network/asset) that protect real money. Behavior is
|
|
8
|
+
* byte-for-byte unchanged — see liveMerchantChallengeDecoding.test.mjs and
|
|
9
|
+
* x402Executor.test.mjs, both of which exercise this code through
|
|
10
|
+
* X402BaseUsdcExecutor and still pass unmodified after this extraction.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* `Buffer` is a Node global, not a browser one -- calling `Buffer.from(...)`
|
|
14
|
+
* here used to throw `ReferenceError: Buffer is not defined` in a real
|
|
15
|
+
* browser (confirmed live, D2.5A: a real OneSource 402 challenge, valid and
|
|
16
|
+
* byte-identical through the local proxy, failed to decode). That
|
|
17
|
+
* ReferenceError was thrown INSIDE decodeChallenge()'s try/catch below and
|
|
18
|
+
* silently relabeled as "Payment-Required header was not base64-encoded
|
|
19
|
+
* JSON" -- a misleading error that looks like a merchant-format problem but
|
|
20
|
+
* isn't one. `atob`/`TextDecoder` are the browser-safe equivalents (both
|
|
21
|
+
* also globally available in Node), mirroring lifecycleCore.ts's own
|
|
22
|
+
* isomorphic decodeChallenge in onchaindiligence-mcp exactly.
|
|
23
|
+
*/
|
|
24
|
+
export declare function base64ToUtf8(base64: string): string;
|
|
25
|
+
export declare class X402ChallengeError extends Error {
|
|
26
|
+
}
|
|
27
|
+
export declare function decodeChallenge(res: Response): any;
|
|
28
|
+
/** Validates a decoded x402 v2 "exact" challenge against the frozen preflighted action. Never mutates anything; throws before any signing could occur. */
|
|
29
|
+
export declare function validateChallenge(challenge: any, expected: {
|
|
30
|
+
network: string;
|
|
31
|
+
asset: string;
|
|
32
|
+
amount: string;
|
|
33
|
+
recipient: string;
|
|
34
|
+
}): void;
|
|
35
|
+
export declare function decodeSettlementResponse(res: Response): {
|
|
36
|
+
transactionHash: string | null;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Converts a canonical decimal amount (e.g. "1.00") into USDC's 6-decimal
|
|
40
|
+
* atomic unit string, WITHOUT floating point — mirrors
|
|
41
|
+
* onchaindiligence-mcp's src/money.ts exactly (kept independent here since
|
|
42
|
+
* this package does not depend on that server-side module).
|
|
43
|
+
*/
|
|
44
|
+
export declare function decimalToAtomic6(amount: string): string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402Challenge.ts — shared x402 v2 "exact" challenge helpers (D2.6).
|
|
3
|
+
*
|
|
4
|
+
* Extracted verbatim from x402Executor.ts (D2.5) so payboxExecutor.ts (D2.6)
|
|
5
|
+
* can reuse the SAME proven challenge-decoding/validation logic instead of
|
|
6
|
+
* maintaining a second copy of security-critical checks (wrong recipient,
|
|
7
|
+
* wrong amount, wrong network/asset) that protect real money. Behavior is
|
|
8
|
+
* byte-for-byte unchanged — see liveMerchantChallengeDecoding.test.mjs and
|
|
9
|
+
* x402Executor.test.mjs, both of which exercise this code through
|
|
10
|
+
* X402BaseUsdcExecutor and still pass unmodified after this extraction.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* `Buffer` is a Node global, not a browser one -- calling `Buffer.from(...)`
|
|
14
|
+
* here used to throw `ReferenceError: Buffer is not defined` in a real
|
|
15
|
+
* browser (confirmed live, D2.5A: a real OneSource 402 challenge, valid and
|
|
16
|
+
* byte-identical through the local proxy, failed to decode). That
|
|
17
|
+
* ReferenceError was thrown INSIDE decodeChallenge()'s try/catch below and
|
|
18
|
+
* silently relabeled as "Payment-Required header was not base64-encoded
|
|
19
|
+
* JSON" -- a misleading error that looks like a merchant-format problem but
|
|
20
|
+
* isn't one. `atob`/`TextDecoder` are the browser-safe equivalents (both
|
|
21
|
+
* also globally available in Node), mirroring lifecycleCore.ts's own
|
|
22
|
+
* isomorphic decodeChallenge in onchaindiligence-mcp exactly.
|
|
23
|
+
*/
|
|
24
|
+
export function base64ToUtf8(base64) {
|
|
25
|
+
const binary = atob(base64);
|
|
26
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
27
|
+
return new TextDecoder().decode(bytes);
|
|
28
|
+
}
|
|
29
|
+
export class X402ChallengeError extends Error {
|
|
30
|
+
}
|
|
31
|
+
export function decodeChallenge(res) {
|
|
32
|
+
const header = res.headers.get('payment-required');
|
|
33
|
+
if (!header)
|
|
34
|
+
throw new X402ChallengeError(`${res.url}: 402 response carried no Payment-Required header`);
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(base64ToUtf8(header));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new X402ChallengeError(`${res.url}: Payment-Required header was not base64-encoded JSON`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Validates a decoded x402 v2 "exact" challenge against the frozen preflighted action. Never mutates anything; throws before any signing could occur. */
|
|
43
|
+
export function validateChallenge(challenge, expected) {
|
|
44
|
+
if (challenge?.x402Version !== 2)
|
|
45
|
+
throw new X402ChallengeError(`unexpected x402 version ${challenge?.x402Version} (expected 2)`);
|
|
46
|
+
const accepts = challenge?.accepts?.[0];
|
|
47
|
+
if (!accepts)
|
|
48
|
+
throw new X402ChallengeError('challenge contained no accepts entry');
|
|
49
|
+
if (accepts.scheme !== 'exact')
|
|
50
|
+
throw new X402ChallengeError(`unexpected scheme "${accepts.scheme}" (expected "exact")`);
|
|
51
|
+
if (accepts.network !== expected.network)
|
|
52
|
+
throw new X402ChallengeError(`network mismatch: quoted "${accepts.network}", expected "${expected.network}"`);
|
|
53
|
+
if (String(accepts.asset).toLowerCase() !== expected.asset.toLowerCase()) {
|
|
54
|
+
throw new X402ChallengeError(`asset mismatch: quoted "${accepts.asset}", expected "${expected.asset}"`);
|
|
55
|
+
}
|
|
56
|
+
if (String(accepts.payTo).toLowerCase() !== expected.recipient.toLowerCase()) {
|
|
57
|
+
throw new X402ChallengeError(`recipient mismatch: quoted "${accepts.payTo}", expected "${expected.recipient}" -- refusing to pay an unexpected address`);
|
|
58
|
+
}
|
|
59
|
+
if (String(accepts.amount) !== expected.amount) {
|
|
60
|
+
throw new X402ChallengeError(`amount mismatch: quoted "${accepts.amount}", expected exactly "${expected.amount}" atomic units`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function decodeSettlementResponse(res) {
|
|
64
|
+
const header = res.headers.get('x-payment-response') ?? res.headers.get('payment-response');
|
|
65
|
+
if (!header)
|
|
66
|
+
return { transactionHash: null };
|
|
67
|
+
try {
|
|
68
|
+
const decoded = JSON.parse(base64ToUtf8(header));
|
|
69
|
+
return { transactionHash: typeof decoded?.transaction === 'string' ? decoded.transaction : null };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { transactionHash: null };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Converts a canonical decimal amount (e.g. "1.00") into USDC's 6-decimal
|
|
77
|
+
* atomic unit string, WITHOUT floating point — mirrors
|
|
78
|
+
* onchaindiligence-mcp's src/money.ts exactly (kept independent here since
|
|
79
|
+
* this package does not depend on that server-side module).
|
|
80
|
+
*/
|
|
81
|
+
export function decimalToAtomic6(amount) {
|
|
82
|
+
const [intPart, fracPart = ''] = amount.split('.');
|
|
83
|
+
if (fracPart.length > 6)
|
|
84
|
+
throw new X402ChallengeError(`amount "${amount}" has more precision than USDC's 6 decimals support`);
|
|
85
|
+
return BigInt(intPart + fracPart.padEnd(6, '0')).toString();
|
|
86
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { toClientEvmSigner, type ClientEvmSigner } from '@x402/evm';
|
|
2
2
|
import type { CommerceExecutor, PrepareContext, PrepareResult, ExecutionResult, ExecutorRecoveryMode } from './executor.js';
|
|
3
|
+
import { X402ChallengeError } from './x402Challenge.js';
|
|
3
4
|
export type { ClientEvmSigner };
|
|
4
5
|
export { toClientEvmSigner };
|
|
6
|
+
export { X402ChallengeError };
|
|
5
7
|
export declare const BASE_NETWORK = "eip155:8453";
|
|
6
8
|
export declare const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
7
9
|
/** The subset of viem's PublicClient resume() actually calls -- narrowed so tests can inject a minimal fake instead of a real RPC connection. */
|
|
@@ -36,84 +36,11 @@ import { wrapFetchWithPayment } from '@x402/fetch';
|
|
|
36
36
|
import { x402Client } from '@x402/core/client';
|
|
37
37
|
import { ExactEvmScheme } from '@x402/evm/exact/client';
|
|
38
38
|
import { toClientEvmSigner } from '@x402/evm';
|
|
39
|
+
import { X402ChallengeError, decodeChallenge, validateChallenge, decodeSettlementResponse, decimalToAtomic6 } from './x402Challenge.js';
|
|
39
40
|
export { toClientEvmSigner };
|
|
41
|
+
export { X402ChallengeError };
|
|
40
42
|
export const BASE_NETWORK = 'eip155:8453';
|
|
41
43
|
export const BASE_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
|
|
42
|
-
/**
|
|
43
|
-
* `Buffer` is a Node global, not a browser one -- calling `Buffer.from(...)`
|
|
44
|
-
* here used to throw `ReferenceError: Buffer is not defined` in a real
|
|
45
|
-
* browser (confirmed live, D2.5A: a real OneSource 402 challenge, valid and
|
|
46
|
-
* byte-identical through the local proxy, failed to decode). That
|
|
47
|
-
* ReferenceError was thrown INSIDE decodeChallenge()'s try/catch below and
|
|
48
|
-
* silently relabeled as "Payment-Required header was not base64-encoded
|
|
49
|
-
* JSON" -- a misleading error that looks like a merchant-format problem but
|
|
50
|
-
* isn't one. `atob`/`TextDecoder` are the browser-safe equivalents (both
|
|
51
|
-
* also globally available in Node), mirroring lifecycleCore.ts's own
|
|
52
|
-
* isomorphic decodeChallenge in onchaindiligence-mcp exactly.
|
|
53
|
-
*/
|
|
54
|
-
function base64ToUtf8(base64) {
|
|
55
|
-
const binary = atob(base64);
|
|
56
|
-
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
57
|
-
return new TextDecoder().decode(bytes);
|
|
58
|
-
}
|
|
59
|
-
class X402ChallengeError extends Error {
|
|
60
|
-
}
|
|
61
|
-
function decodeChallenge(res) {
|
|
62
|
-
const header = res.headers.get('payment-required');
|
|
63
|
-
if (!header)
|
|
64
|
-
throw new X402ChallengeError(`${res.url}: 402 response carried no Payment-Required header`);
|
|
65
|
-
try {
|
|
66
|
-
return JSON.parse(base64ToUtf8(header));
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
throw new X402ChallengeError(`${res.url}: Payment-Required header was not base64-encoded JSON`);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
/** Validates a decoded x402 v2 "exact" challenge against the frozen preflighted action. Never mutates anything; throws before any signing could occur. */
|
|
73
|
-
function validateChallenge(challenge, expected) {
|
|
74
|
-
if (challenge?.x402Version !== 2)
|
|
75
|
-
throw new X402ChallengeError(`unexpected x402 version ${challenge?.x402Version} (expected 2)`);
|
|
76
|
-
const accepts = challenge?.accepts?.[0];
|
|
77
|
-
if (!accepts)
|
|
78
|
-
throw new X402ChallengeError('challenge contained no accepts entry');
|
|
79
|
-
if (accepts.scheme !== 'exact')
|
|
80
|
-
throw new X402ChallengeError(`unexpected scheme "${accepts.scheme}" (expected "exact")`);
|
|
81
|
-
if (accepts.network !== expected.network)
|
|
82
|
-
throw new X402ChallengeError(`network mismatch: quoted "${accepts.network}", expected "${expected.network}"`);
|
|
83
|
-
if (String(accepts.asset).toLowerCase() !== expected.asset.toLowerCase()) {
|
|
84
|
-
throw new X402ChallengeError(`asset mismatch: quoted "${accepts.asset}", expected "${expected.asset}"`);
|
|
85
|
-
}
|
|
86
|
-
if (String(accepts.payTo).toLowerCase() !== expected.recipient.toLowerCase()) {
|
|
87
|
-
throw new X402ChallengeError(`recipient mismatch: quoted "${accepts.payTo}", expected "${expected.recipient}" -- refusing to pay an unexpected address`);
|
|
88
|
-
}
|
|
89
|
-
if (String(accepts.amount) !== expected.amount) {
|
|
90
|
-
throw new X402ChallengeError(`amount mismatch: quoted "${accepts.amount}", expected exactly "${expected.amount}" atomic units`);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
function decodeSettlementResponse(res) {
|
|
94
|
-
const header = res.headers.get('x-payment-response') ?? res.headers.get('payment-response');
|
|
95
|
-
if (!header)
|
|
96
|
-
return { transactionHash: null };
|
|
97
|
-
try {
|
|
98
|
-
const decoded = JSON.parse(base64ToUtf8(header));
|
|
99
|
-
return { transactionHash: typeof decoded?.transaction === 'string' ? decoded.transaction : null };
|
|
100
|
-
}
|
|
101
|
-
catch {
|
|
102
|
-
return { transactionHash: null };
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* Converts a canonical decimal amount (e.g. "1.00") into USDC's 6-decimal
|
|
107
|
-
* atomic unit string, WITHOUT floating point — mirrors
|
|
108
|
-
* onchaindiligence-mcp's src/money.ts exactly (kept independent here since
|
|
109
|
-
* this package does not depend on that server-side module).
|
|
110
|
-
*/
|
|
111
|
-
function decimalToAtomic6(amount) {
|
|
112
|
-
const [intPart, fracPart = ''] = amount.split('.');
|
|
113
|
-
if (fracPart.length > 6)
|
|
114
|
-
throw new X402ChallengeError(`amount "${amount}" has more precision than USDC's 6 decimals support`);
|
|
115
|
-
return BigInt(intPart + fracPart.padEnd(6, '0')).toString();
|
|
116
|
-
}
|
|
117
44
|
export class X402BaseUsdcExecutor {
|
|
118
45
|
id = 'x402-base-usdc-exact';
|
|
119
46
|
version = 'v1';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onchaindiligence/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "TypeScript SDK for OnChainDiligence — policy preflight, recoverable agent payment lifecycles, independent settlement observation, and verifiable receipts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"import": "./dist/commerce/index.js"
|
|
17
17
|
},
|
|
18
18
|
"./commerce/node": {
|
|
19
|
-
"types": "./dist/commerce/
|
|
20
|
-
"import": "./dist/commerce/
|
|
19
|
+
"types": "./dist/commerce/node.d.ts",
|
|
20
|
+
"import": "./dist/commerce/node.js"
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
@@ -30,15 +30,23 @@
|
|
|
30
30
|
"prepublishOnly": "npm run build"
|
|
31
31
|
},
|
|
32
32
|
"keywords": [
|
|
33
|
+
"agent-payments",
|
|
34
|
+
"ai-agents",
|
|
35
|
+
"x402",
|
|
36
|
+
"402",
|
|
37
|
+
"payments",
|
|
38
|
+
"receipts",
|
|
39
|
+
"verification",
|
|
40
|
+
"mcp",
|
|
41
|
+
"base",
|
|
42
|
+
"usdc",
|
|
43
|
+
"web3",
|
|
33
44
|
"compliance",
|
|
34
45
|
"sanctions",
|
|
35
46
|
"ofac",
|
|
36
|
-
"x402",
|
|
37
|
-
"402",
|
|
38
47
|
"mppx",
|
|
39
48
|
"tempo",
|
|
40
|
-
"agent"
|
|
41
|
-
"web3"
|
|
49
|
+
"agent"
|
|
42
50
|
],
|
|
43
51
|
"license": "MIT",
|
|
44
52
|
"peerDependencies": {
|