@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 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
@@ -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';
@@ -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';
@@ -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.3.1",
4
- "description": "Typed client for the OnchainDiligence compliance API pay-per-call sanctions, OFAC name, and UK company checks, with the 402 payment flow handled for you.",
3
+ "version": "0.4.0",
4
+ "description": "TypeScript SDK for OnChainDiligencepolicy 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/nodeFileRecoveryStore.d.ts",
20
- "import": "./dist/commerce/nodeFileRecoveryStore.js"
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": {