@onchaindiligence/sdk 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * results.ts — discriminated unions for every lifecycle outcome (D2.5,
3
+ * Section 2). Ordinary lifecycle states are never thrown as exceptions —
4
+ * only genuinely exceptional conditions (bad input, a network the caller
5
+ * cannot reasonably plan around) do. `receipt-produced` deliberately does
6
+ * NOT mean "payment succeeded": the receipt may describe a confirmed
7
+ * payment, a failed execution, a mismatch, or an uncertain observation —
8
+ * read `receipt.receipt.execution.status` / `.settlement.status` /
9
+ * `.decision.status`, never infer success from a receipt merely existing.
10
+ */
11
+ import type { ReceiptEnvelope, OperationStatus } from './types.js';
12
+ /** Shared shape for every non-terminal "come back later" outcome. */
13
+ export interface PendingInfo {
14
+ /** Where in the lifecycle this operation currently sits. */
15
+ phase: 'preflight-in-progress' | 'awaiting-execution' | 'execution-ambiguous' | 'awaiting-observation' | 'observation-pending';
16
+ /** What the caller should do next, in plain language a UI can show directly. */
17
+ safeNextAction: string;
18
+ /** Seconds to wait before retrying, when known. */
19
+ retryAfterSeconds?: number;
20
+ /** True whenever OCD's fee (or, for execution, the merchant payment) may already have been taken — the caller must NEVER treat this as "safe to pay again". */
21
+ mayAlreadyHavePaid: boolean;
22
+ operationId: string;
23
+ executionRequestId?: string | null;
24
+ }
25
+ export type PreflightEvaluation = {
26
+ kind: 'ready';
27
+ operationId: string;
28
+ receipt: ReceiptEnvelope;
29
+ capabilityExpiresAt: string;
30
+ } | {
31
+ kind: 'blocked';
32
+ operationId: string;
33
+ receipt: ReceiptEnvelope;
34
+ reasons: string[];
35
+ } | {
36
+ kind: 'approval-required';
37
+ operationId: string;
38
+ receipt: ReceiptEnvelope;
39
+ reasons: string[];
40
+ } | ({
41
+ kind: 'pending';
42
+ } & PendingInfo) | {
43
+ kind: 'terminal-error';
44
+ operationId: string;
45
+ error: string;
46
+ };
47
+ export type ExecutionRecord = {
48
+ kind: 'execution-recorded';
49
+ operationId: string;
50
+ executionRequestId: string;
51
+ transactionHash: string;
52
+ providerReference?: string | null;
53
+ } | {
54
+ kind: 'manual-recovery-required';
55
+ operationId: string;
56
+ executionRequestId: string;
57
+ reason: string;
58
+ } | ({
59
+ kind: 'pending';
60
+ } & PendingInfo) | {
61
+ kind: 'terminal-error';
62
+ operationId: string;
63
+ error: string;
64
+ };
65
+ export type FinalizeResult = {
66
+ kind: 'receipt-produced';
67
+ operationId: string;
68
+ receipt: ReceiptEnvelope;
69
+ evidence: {
70
+ bundle_digest: string;
71
+ binding_strength: 'TRANSFER_MATCH_ONLY' | 'EXECUTOR_CORRELATED' | 'PAYMENT_IDENTITY_LINKED';
72
+ } | null;
73
+ } | ({
74
+ kind: 'pending';
75
+ } & PendingInfo) | {
76
+ kind: 'terminal-error';
77
+ operationId: string;
78
+ error: string;
79
+ };
80
+ export type ResumeResult = {
81
+ kind: 'resumed';
82
+ operationId: string;
83
+ status: OperationStatus;
84
+ } | {
85
+ kind: 'recovery-failed';
86
+ reason: string;
87
+ };
88
+ export declare function pending(operationId: string, info: Omit<PendingInfo, 'operationId'>): {
89
+ kind: 'pending';
90
+ } & PendingInfo;
@@ -0,0 +1,3 @@
1
+ export function pending(operationId, info) {
2
+ return { kind: 'pending', operationId, ...info };
3
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * types.ts — the commerce-lifecycle wire contract, ported by hand from
3
+ * onchaindiligence-mcp's src/preflight.ts / src/receipts.ts / src/lifecycleRoute.ts
4
+ * / src/lifecycleFinalizeRoute.ts (D2.1/D2.4).
5
+ *
6
+ * SOURCE OF TRUTH: the deployed mcp.onchaindiligence.com API. This file does
7
+ * not implement or re-verify anything — it exists so the commerce client has
8
+ * exact types for what it sends and receives, mirroring the same
9
+ * "ported minimal copy, kept in sync by hand" discipline
10
+ * onchaindiligence-mcp's own receipts.ts already documents for its
11
+ * relationship to the canonical packages/agent-evidence source.
12
+ */
13
+ export interface CommerceAction {
14
+ kind: 'PAYMENT';
15
+ /** URL of the resource/service the payment is for, if any. */
16
+ resource: string | null;
17
+ /** CAIP-2 network identifier, e.g. "eip155:8453" for Base mainnet. */
18
+ network: string;
19
+ /** Canonical ERC-20 token contract address — never a bare ticker. */
20
+ asset: string;
21
+ /** Canonical decimal string, e.g. "1.00". Never a float. */
22
+ amount: string;
23
+ sender: string | null;
24
+ recipient: string;
25
+ }
26
+ export interface CommercePolicy {
27
+ max_amount?: string | null;
28
+ allowed_networks?: string[] | null;
29
+ allowed_assets?: string[] | null;
30
+ expected_recipient?: string | null;
31
+ allowed_resource_origins?: string[] | null;
32
+ /** Structural confirmation that every field above being null/omitted is intentional, not an accident. */
33
+ acknowledge_unconstrained?: boolean;
34
+ /** A frozen commitment to the wallet expected to AUTHORIZE the eventual on-chain payment (D2.4) — decision-neutral. */
35
+ expected_payer?: string | null;
36
+ }
37
+ export interface CommercePublication {
38
+ preflight?: boolean;
39
+ commerce?: boolean;
40
+ }
41
+ export type DecisionStatus = 'ALLOW' | 'REQUIRE_APPROVAL' | 'BLOCK' | 'UNKNOWN';
42
+ export interface ReceiptDecision {
43
+ status: DecisionStatus;
44
+ authorized: boolean | null;
45
+ reasons: string[];
46
+ }
47
+ export interface ReceiptCheck {
48
+ id: string;
49
+ result: 'PASS' | 'FAIL' | 'UNKNOWN' | 'NOT_CHECKED';
50
+ summary: string;
51
+ evidence_digest: string | null;
52
+ }
53
+ export type ExecutionStatus = 'NOT_SUBMITTED' | 'SUBMITTED' | 'CONFIRMED' | 'FAILED' | 'UNKNOWN';
54
+ export type SettlementStatus = 'CONFIRMED' | 'NOT_CONFIRMED' | 'UNVERIFIED' | 'NOT_APPLICABLE';
55
+ export interface Receipt {
56
+ receipt_id: string;
57
+ receipt_digest: string;
58
+ receipt_type: 'ACTION' | 'PREFLIGHT' | 'COMMERCE';
59
+ issued_at: string;
60
+ action: CommerceAction & {
61
+ resource: string | null;
62
+ };
63
+ decision: ReceiptDecision;
64
+ execution: {
65
+ provider: string | null;
66
+ status: ExecutionStatus;
67
+ transaction_hash: string | null;
68
+ submitted_at: string | null;
69
+ confirmed_at: string | null;
70
+ };
71
+ settlement: {
72
+ status: SettlementStatus;
73
+ detail: string | null;
74
+ };
75
+ checks: ReceiptCheck[];
76
+ links: {
77
+ agent_evidence_bundle_digest: string | null;
78
+ preflight_receipt_id: string | null;
79
+ };
80
+ limitations: string[];
81
+ }
82
+ export interface ReceiptEnvelope {
83
+ schema: string;
84
+ receipt: Receipt;
85
+ proof: {
86
+ signed: boolean;
87
+ schema_version?: string;
88
+ issuer?: string;
89
+ purpose?: string;
90
+ issued_at?: string;
91
+ key_id?: string;
92
+ algorithm?: string;
93
+ canonicalization?: string;
94
+ signature?: string;
95
+ signing_input_hint?: string;
96
+ };
97
+ }
98
+ export interface PreflightFinalization {
99
+ capability: string;
100
+ expires_at: string;
101
+ endpoint: string;
102
+ }
103
+ export interface PreflightResponseBody {
104
+ decision: ReceiptDecision;
105
+ checks: ReceiptCheck[];
106
+ receipt: ReceiptEnvelope;
107
+ finalization: PreflightFinalization;
108
+ }
109
+ export interface CreatedOperation {
110
+ operation_id: string;
111
+ recovery_credential: string;
112
+ }
113
+ export type PreflightState = 'not_started' | 'in_progress' | 'completed';
114
+ export type ExecutionState = 'not_submitted' | 'prepared' | 'submission_ambiguous' | 'submitted' | 'outcome_unknown' | 'transaction_known' | 'manual_recovery_required';
115
+ export type ObservationState = 'none' | 'pending' | 'confirmed' | 'contradicted';
116
+ export type ReceiptState = 'none' | 'preflight_only' | 'commerce_issued';
117
+ export interface OperationStatus {
118
+ operation_id: string;
119
+ preflight_state: PreflightState;
120
+ execution_state: ExecutionState;
121
+ observation_state: ObservationState;
122
+ receipt_state: ReceiptState;
123
+ preflight_receipt_id: string | null;
124
+ }
125
+ export interface ExecutionBindingResponse {
126
+ execution_request_id: string;
127
+ submission_state: ExecutionState;
128
+ idempotent_replay: boolean;
129
+ }
130
+ export interface FinalizeExecutionInput {
131
+ transaction_hash: string;
132
+ execution_provider: 'x402' | 'paybox' | 'wallet' | 'other';
133
+ provider_reference?: string | null;
134
+ result_digest?: string | null;
135
+ execution_request_id?: string | null;
136
+ }
137
+ export interface OperationFinalizeResponseBody extends ReceiptEnvelope {
138
+ ocd_lifecycle_evidence: {
139
+ bundle_digest: string;
140
+ binding_strength: 'TRANSFER_MATCH_ONLY' | 'EXECUTOR_CORRELATED' | 'PAYMENT_IDENTITY_LINKED';
141
+ } | null;
142
+ }
143
+ /** The shape of an API error body across every endpoint used here. */
144
+ export interface ApiErrorBody {
145
+ error: string;
146
+ reason?: string;
147
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * types.ts — the commerce-lifecycle wire contract, ported by hand from
3
+ * onchaindiligence-mcp's src/preflight.ts / src/receipts.ts / src/lifecycleRoute.ts
4
+ * / src/lifecycleFinalizeRoute.ts (D2.1/D2.4).
5
+ *
6
+ * SOURCE OF TRUTH: the deployed mcp.onchaindiligence.com API. This file does
7
+ * not implement or re-verify anything — it exists so the commerce client has
8
+ * exact types for what it sends and receives, mirroring the same
9
+ * "ported minimal copy, kept in sync by hand" discipline
10
+ * onchaindiligence-mcp's own receipts.ts already documents for its
11
+ * relationship to the canonical packages/agent-evidence source.
12
+ */
13
+ export {};
@@ -0,0 +1,43 @@
1
+ import { toClientEvmSigner, type ClientEvmSigner } from '@x402/evm';
2
+ import type { CommerceExecutor, PrepareContext, PrepareResult, ExecutionResult, ExecutorRecoveryMode } from './executor.js';
3
+ export type { ClientEvmSigner };
4
+ export { toClientEvmSigner };
5
+ export declare const BASE_NETWORK = "eip155:8453";
6
+ export declare const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
7
+ /** The subset of viem's PublicClient resume() actually calls -- narrowed so tests can inject a minimal fake instead of a real RPC connection. */
8
+ export interface MinimalResumeClient {
9
+ getTransactionReceipt: (args: {
10
+ hash: `0x${string}`;
11
+ }) => Promise<unknown>;
12
+ }
13
+ export interface X402ExecutorOptions {
14
+ /**
15
+ * Signs the EIP-3009 payment authorization. Never logged, never persisted
16
+ * by this class. `@x402/evm`'s own ExactEvmScheme wants exactly this
17
+ * shape (address + signTypedData), NOT a full viem Account/WalletClient --
18
+ * that's deliberate: it's the one interface both a Node private-key
19
+ * signer (`toClientEvmSigner(privateKeyToAccount(pk))`, re-exported from
20
+ * this module) and a browser injected-wallet signer (hand-built around
21
+ * `walletClient.signTypedData`, since an injected wallet's viem account has
22
+ * no signing methods of its own) can equally satisfy.
23
+ */
24
+ signer: ClientEvmSigner;
25
+ /** Base RPC used ONLY for read-only resume confirmation. Defaults to the public Base RPC. */
26
+ rpcUrl?: string;
27
+ fetch?: typeof globalThis.fetch;
28
+ /** Test seam: inject a fake read-only client instead of connecting to rpcUrl. */
29
+ publicClient?: MinimalResumeClient;
30
+ }
31
+ export declare class X402BaseUsdcExecutor implements CommerceExecutor {
32
+ readonly id = "x402-base-usdc-exact";
33
+ readonly version = "v1";
34
+ readonly recoveryMode: ExecutorRecoveryMode;
35
+ private readonly signer;
36
+ private readonly fetchImpl;
37
+ private readonly rpcUrl;
38
+ private readonly injectedPublicClient?;
39
+ constructor(options: X402ExecutorOptions);
40
+ prepare(context: PrepareContext): Promise<PrepareResult>;
41
+ submit(prepared: PrepareResult): Promise<ExecutionResult>;
42
+ resume(prepared: PrepareResult, priorOutcome?: ExecutionResult): Promise<ExecutionResult>;
43
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * x402Executor.ts — the one narrow, production-quality executor adapter for
3
+ * the existing Base USDC x402 v2 "exact" flow (D2.5, Section 4).
4
+ *
5
+ * SCOPE, DELIBERATE: Base mainnet, USDC, x402 v2 exact scheme only — no
6
+ * other chain/asset/scheme. Reuses the EXACT same proven pattern as
7
+ * onchaindiligence-mcp's scripts/first-commerce-lifecycle.ts (D2.2A) and
8
+ * operator/src/main.ts (D2.2B), the only two places this integration has
9
+ * ever moved real money: `wrapFetchWithPayment` + `x402Client` +
10
+ * `ExactEvmScheme(signer)`, called through EXACTLY ONCE per submit().
11
+ *
12
+ * RECOVERY MODE IS HONESTLY 'manual', not 'stable-payment-identity':
13
+ * `wrapFetchWithPayment` is an atomic sign-and-pay primitive — it does not
14
+ * expose the ERC-3009 nonce it generates internally, so if `submit()` never
15
+ * receives a response (timeout, connection drop), this adapter has no
16
+ * independent identity to query "was this specific authorization consumed"
17
+ * against. A deeper integration COULD extract the nonce via
18
+ * `ExactEvmScheme.createPaymentPayload()` and later check it on-chain via
19
+ * USDC's own `authorizationState(authorizer, nonce)` view function — but
20
+ * that path is unproven against the real facilitator (verifying it would
21
+ * require a live payment, which no milestone through D2.5 permits) and is
22
+ * therefore NOT implemented here. Reporting 'manual' honestly, and resolving
23
+ * to manual-recovery-required on an ambiguous submit, is the correct choice
24
+ * over pretending a recovery guarantee this code cannot actually back up —
25
+ * see onchaindiligence-mcp's own D2.4 executionBinding.ts: "It is preferable
26
+ * to pretending exactly-once execution can be guaranteed."
27
+ *
28
+ * The ONE exception: if `submit()` DID receive a response before some LATER
29
+ * step failed (so a transaction hash is already known), `resume()` can and
30
+ * does independently re-confirm it read-only on-chain — no guessing, no new
31
+ * payment, just checking what's already claimed.
32
+ */
33
+ import { createPublicClient, http } from 'viem';
34
+ import { base } from 'viem/chains';
35
+ import { wrapFetchWithPayment } from '@x402/fetch';
36
+ import { x402Client } from '@x402/core/client';
37
+ import { ExactEvmScheme } from '@x402/evm/exact/client';
38
+ import { toClientEvmSigner } from '@x402/evm';
39
+ export { toClientEvmSigner };
40
+ export const BASE_NETWORK = 'eip155:8453';
41
+ 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
+ export class X402BaseUsdcExecutor {
118
+ id = 'x402-base-usdc-exact';
119
+ version = 'v1';
120
+ recoveryMode = 'manual';
121
+ signer;
122
+ fetchImpl;
123
+ rpcUrl;
124
+ injectedPublicClient;
125
+ constructor(options) {
126
+ this.signer = options.signer;
127
+ // See client.ts's constructor comment: a bare `globalThis.fetch`
128
+ // reference, later invoked as `this.fetchImpl(...)`, throws "Illegal
129
+ // invocation" in real browsers (detached from its required receiver) --
130
+ // invisible under Node, which is why this only surfaces against a real
131
+ // injected-wallet browser flow. Bind it here so this executor is safe to
132
+ // construct with no `fetch` option in either environment.
133
+ this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
134
+ this.rpcUrl = options.rpcUrl ?? 'https://mainnet.base.org';
135
+ this.injectedPublicClient = options.publicClient;
136
+ }
137
+ async prepare(context) {
138
+ if (context.action.network !== BASE_NETWORK)
139
+ throw new X402ChallengeError(`this executor only supports ${BASE_NETWORK}, got "${context.action.network}"`);
140
+ if (context.action.asset.toLowerCase() !== BASE_USDC.toLowerCase())
141
+ throw new X402ChallengeError(`this executor only supports USDC (${BASE_USDC}), got "${context.action.asset}"`);
142
+ if (!context.action.resource)
143
+ throw new X402ChallengeError('action.resource (the x402 resource URL) is required to prepare a submission');
144
+ // Provoke the 402 challenge with a plain, unauthenticated request --
145
+ // read-only, no signing, no payment. This is the ENTIRE point of
146
+ // prepare(): validate what we'd be agreeing to pay BEFORE any
147
+ // authorization exists.
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
+ }
152
+ const challenge = decodeChallenge(probe);
153
+ const atomicAmount = decimalToAtomic6(context.action.amount);
154
+ validateChallenge(challenge, { network: context.action.network, asset: context.action.asset, amount: atomicAmount, recipient: context.action.recipient });
155
+ const reference = {
156
+ resourceUrl: context.action.resource,
157
+ network: context.action.network,
158
+ asset: context.action.asset,
159
+ atomicAmount,
160
+ recipient: context.action.recipient,
161
+ };
162
+ return { clientSubmissionKey: context.clientSubmissionKey, reference, preparedAt: new Date().toISOString() };
163
+ }
164
+ async submit(prepared) {
165
+ const ref = prepared.reference;
166
+ const client = new x402Client().register(ref.network, new ExactEvmScheme(this.signer));
167
+ const payingFetch = wrapFetchWithPayment(this.fetchImpl, client);
168
+ let res;
169
+ try {
170
+ res = await payingFetch(ref.resourceUrl);
171
+ }
172
+ catch (err) {
173
+ // No response at all -- the single most dangerous case: we genuinely
174
+ // do not know whether the facilitator ever broadcast the
175
+ // authorization. Never retry automatically; report ambiguous.
176
+ return { clientSubmissionKey: prepared.clientSubmissionKey, status: 'submission-ambiguous', reason: err?.message || 'no response from resource/facilitator' };
177
+ }
178
+ if (res.status === 402) {
179
+ // The payment was rejected outright (e.g. facilitator declined the
180
+ // authorization before ever broadcasting) -- this is a DEFINITIVE
181
+ // non-payment, safe to report as ambiguous-for-manual-review rather
182
+ // than a silent failure, since we still can't be 100% sure nothing
183
+ // was ever submitted on-chain by a misbehaving facilitator.
184
+ return { clientSubmissionKey: prepared.clientSubmissionKey, status: 'submission-ambiguous', reason: `resource still returned 402 after payment attempt (status ${res.status})` };
185
+ }
186
+ if (!res.ok) {
187
+ return { clientSubmissionKey: prepared.clientSubmissionKey, status: 'submission-ambiguous', reason: `resource returned HTTP ${res.status} after a payment attempt was made -- outcome unknown` };
188
+ }
189
+ const { transactionHash } = decodeSettlementResponse(res);
190
+ if (!transactionHash) {
191
+ // Paid successfully per the resource's own 2xx, but we couldn't parse
192
+ // a transaction hash out of the settlement response -- still
193
+ // ambiguous from OCD's perspective (D2.4 finalize needs the hash).
194
+ return { clientSubmissionKey: prepared.clientSubmissionKey, status: 'submission-ambiguous', reason: 'resource responded successfully but no transaction hash could be parsed from the settlement response' };
195
+ }
196
+ return { clientSubmissionKey: prepared.clientSubmissionKey, status: 'transaction-known', transactionHash, providerReference: this.id };
197
+ }
198
+ async resume(prepared, priorOutcome) {
199
+ // The only safe resume this adapter can do: if a transaction hash is
200
+ // ALREADY known (from a prior submit() that got a response before some
201
+ // later step failed), independently re-confirm it exists on-chain --
202
+ // never guess a new one, never resubmit.
203
+ if (priorOutcome?.status === 'transaction-known') {
204
+ const client = this.injectedPublicClient ?? createPublicClient({ chain: base, transport: http(this.rpcUrl) });
205
+ try {
206
+ await client.getTransactionReceipt({ hash: priorOutcome.transactionHash });
207
+ return priorOutcome;
208
+ }
209
+ catch {
210
+ // Not found (yet, or ever) -- still don't fabricate a different
211
+ // outcome; report ambiguous so the caller keeps trying, mirroring
212
+ // observeTransaction's own "not-found is not the same as failed".
213
+ return { clientSubmissionKey: prepared.clientSubmissionKey, status: 'submission-ambiguous', reason: 'previously reported transaction hash was not found on Base mainnet (may still be propagating)' };
214
+ }
215
+ }
216
+ return {
217
+ clientSubmissionKey: prepared.clientSubmissionKey,
218
+ status: 'manual-recovery-required',
219
+ reason: 'this executor cannot independently identify a submitted-but-unconfirmed x402 payment authorization -- check the merchant/your wallet history manually before retrying',
220
+ };
221
+ }
222
+ }
package/dist/index.d.ts CHANGED
@@ -22,31 +22,29 @@
22
22
  * build a payment header, or retry a request by hand.
23
23
  */
24
24
  import type { Account } from 'viem';
25
+ import { type AttestationKeyRecord, type AttestationVerificationResult, type Signed, type VerifyAttestationOptions } from './verification.js';
26
+ export * from './verification.js';
25
27
  export interface OnchainDiligenceOptions {
26
28
  /** A viem account used to sign/settle payments (e.g. privateKeyToAccount). */
27
29
  account: Account;
28
30
  /** Base URL of the API. Defaults to production. */
29
31
  baseUrl?: string;
32
+ /** Expected issuer for versioned attestations. Defaults to the production issuer. */
33
+ expectedAttestationIssuer?: string;
30
34
  }
31
- /** The signed-attestation envelope every paid response carries. */
32
- export interface Attestation {
33
- signed: boolean;
34
- key_id?: string;
35
- algorithm?: string;
36
- signature?: string;
37
- issued_at?: string;
38
- }
39
- export interface Signed<T> {
40
- data: T;
41
- attestation: Attestation;
42
- }
43
- /** Result of locally verifying a signed attestation. */
44
- export interface VerifyResult {
45
- valid: boolean;
46
- /** The key_id the signature was checked against, when available. */
47
- keyId?: string;
48
- /** Human-readable reason when `valid` is false. */
49
- reason?: string;
35
+ /** @deprecated Use AttestationVerificationResult and inspect its state. */
36
+ export type VerifyResult = AttestationVerificationResult;
37
+ export interface OnlineAttestationVerificationOptions extends VerifyAttestationOptions {
38
+ baseUrl?: string;
39
+ fetch?: typeof globalThis.fetch;
40
+ /**
41
+ * Explicit policy decision to trust exact key records delivered by the
42
+ * configured HTTPS issuer registry. Without this, valid bytes remain
43
+ * UNVERIFIABLE for publisher identity.
44
+ */
45
+ trustRegistry?: boolean | ((record: AttestationKeyRecord, context: {
46
+ baseUrl: string;
47
+ }) => boolean | Promise<boolean>);
50
48
  }
51
49
  /** One address's outcome within a re-screen batch. */
52
50
  export interface RescreenItem {
@@ -168,10 +166,18 @@ export declare class OnchainDiligenceError extends Error {
168
166
  status: number;
169
167
  constructor(status: number, message: string);
170
168
  }
169
+ export declare function resolveAttestationKeyOnline(keyId: string, options?: {
170
+ baseUrl?: string;
171
+ fetch?: typeof globalThis.fetch;
172
+ }): Promise<AttestationKeyRecord>;
173
+ /** Validate and return the complete signed envelope required by `/anchor`. */
174
+ export declare function buildAnchorRequest(envelope: Signed<unknown>): Signed<unknown>;
175
+ /** Explicit online-discovery wrapper around the zero-network verifier core. */
176
+ export declare function verifyAttestationOnline(signed: unknown, options?: OnlineAttestationVerificationOptions): Promise<AttestationVerificationResult>;
171
177
  export declare class OnchainDiligence {
172
178
  private readonly baseUrl;
179
+ private readonly expectedAttestationIssuer;
173
180
  private readonly fetch;
174
- private attestationKeyPem;
175
181
  constructor(opts: OnchainDiligenceOptions);
176
182
  private get;
177
183
  private post;
@@ -207,8 +213,8 @@ export declare class OnchainDiligence {
207
213
  * bad address never sinks the whole batch.
208
214
  */
209
215
  rescreen(addresses: string[], opts?: RescreenOptions): Promise<RescreenReport>;
210
- /** Anchor an attestation's signature hash on Tempo (paid). */
211
- anchor(signature: string): Promise<Signed<AnchorResult>>;
216
+ /** Anchor a complete authentic attestation envelope on Tempo (paid). */
217
+ anchor(envelope: Signed<unknown>): Promise<Signed<AnchorResult>>;
212
218
  /** Check whether an attestation has been anchored on-chain (free). */
213
219
  anchored(signature: string): Promise<AnchorStatus>;
214
220
  /** Service health (free). */
@@ -219,20 +225,18 @@ export declare class OnchainDiligence {
219
225
  }>;
220
226
  /**
221
227
  * Verify a signed attestation locally. Fetches the server's published
222
- * Ed25519 public key once (cached), then checks the signature over the
223
- * canonical signing input `JSON.stringify({ data, issued_at, key_id })` —
224
- * the exact bytes the server signs. A `valid: true` result means the data
225
- * has not been altered since the server signed it, provable without trusting
226
- * this SDK.
228
+ * exact Ed25519 key identified by `key_id` (cached), then verifies either
229
+ * the domain-separated RFC 8785 version 2 input or the legacy version 1
230
+ * JSON.stringify input. Revoked/compromised keys remain cryptographically
231
+ * checkable but return `valid: false` and `trusted: false`.
227
232
  *
228
233
  * Uses WebCrypto (`globalThis.crypto.subtle`) so it runs dependency-free in
229
234
  * Node 18+, edge runtimes, and modern browsers.
230
235
  */
231
- verifyAttestation(signed: Signed<unknown>): Promise<VerifyResult>;
232
236
  /**
233
- * Fetch and cache the server's Ed25519 public key (PEM) from
234
- * `/.well-known/attestation-key`. Accepts a raw PEM body or a JSON wrapper
235
- * exposing the PEM under a common field name.
237
+ * Compatibility online wrapper. Prefer the standalone zero-network
238
+ * verifyAttestationOffline() with caller-supplied trust material.
236
239
  */
237
- private getAttestationKeyPem;
240
+ verifyAttestation(signed: Signed<unknown>): Promise<VerifyResult>;
241
+ verifyAttestationOnline(signed: Signed<unknown>, options?: Omit<OnlineAttestationVerificationOptions, 'baseUrl' | 'fetch'>): Promise<AttestationVerificationResult>;
238
242
  }