@envarai/envarpay 0.1.0-alpha.2 → 0.2.0-alpha.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.
Files changed (59) hide show
  1. package/README.md +56 -145
  2. package/dist/commerce/buyer-store.d.ts +93 -0
  3. package/dist/commerce/buyer-store.js +168 -0
  4. package/dist/commerce/buyer.d.ts +129 -0
  5. package/dist/commerce/buyer.js +631 -0
  6. package/dist/commerce/card.d.ts +5 -0
  7. package/dist/commerce/card.js +29 -0
  8. package/dist/commerce/chain.d.ts +24 -0
  9. package/dist/commerce/chain.js +146 -0
  10. package/dist/commerce/cli.d.ts +2 -0
  11. package/dist/commerce/cli.js +268 -0
  12. package/dist/commerce/client-runtime.d.ts +8 -0
  13. package/dist/commerce/client-runtime.js +7 -0
  14. package/dist/commerce/config.d.ts +7 -0
  15. package/dist/commerce/config.js +164 -0
  16. package/dist/commerce/envar-runtime.d.ts +46 -0
  17. package/dist/commerce/envar-runtime.js +256 -0
  18. package/dist/commerce/envar.d.ts +126 -0
  19. package/dist/commerce/envar.js +647 -0
  20. package/dist/commerce/http.d.ts +2 -0
  21. package/dist/commerce/http.js +34 -0
  22. package/dist/commerce/index.d.ts +4 -0
  23. package/dist/commerce/index.js +4 -0
  24. package/dist/commerce/management.d.ts +14 -0
  25. package/dist/commerce/management.js +138 -0
  26. package/dist/commerce/mpp-client.d.ts +59 -0
  27. package/dist/commerce/mpp-client.js +24 -0
  28. package/dist/commerce/mpp-runtime.d.ts +3 -0
  29. package/dist/commerce/mpp-runtime.js +3 -0
  30. package/dist/commerce/mpp.d.ts +53 -0
  31. package/dist/commerce/mpp.js +163 -0
  32. package/dist/commerce/peer-proxy.d.ts +44 -0
  33. package/dist/commerce/peer-proxy.js +667 -0
  34. package/dist/commerce/pricing.d.ts +12 -0
  35. package/dist/commerce/pricing.js +60 -0
  36. package/dist/commerce/runtime.d.ts +8 -0
  37. package/dist/commerce/runtime.js +8 -0
  38. package/dist/commerce/server.d.ts +58 -0
  39. package/dist/commerce/server.js +485 -0
  40. package/dist/commerce/store.d.ts +86 -0
  41. package/dist/commerce/store.js +466 -0
  42. package/dist/commerce/stripe-provider.d.ts +9 -0
  43. package/dist/commerce/stripe-provider.js +97 -0
  44. package/dist/commerce/types.d.ts +113 -0
  45. package/dist/commerce/types.js +8 -0
  46. package/dist/commerce/upstream.d.ts +6 -0
  47. package/dist/commerce/upstream.js +103 -0
  48. package/dist/commerce/vault.d.ts +8 -0
  49. package/dist/commerce/vault.js +55 -0
  50. package/dist/commerce/x402.d.ts +37 -0
  51. package/dist/commerce/x402.js +174 -0
  52. package/dist/index.js +3 -1
  53. package/examples/buyer-policy.json +18 -0
  54. package/examples/envar-integration-policy.json +18 -0
  55. package/examples/seller.json +52 -0
  56. package/package.json +75 -10
  57. package/schemas/buyer-policy.schema.json +148 -0
  58. package/schemas/envarpay.config.schema.json +376 -0
  59. package/schemas/quote.schema.json +91 -0
@@ -0,0 +1,97 @@
1
+ import { CommerceError } from './types.js';
2
+ /** Stripe REST transport used by the official mppx Stripe method. No SPT creation. */
3
+ export function stripeProvider(config, fetchImpl = fetch) {
4
+ if (!/^acct_[A-Za-z0-9]+$/.test(config.accountId) || !/^profile_[A-Za-z0-9]+$/.test(config.merchantProfile) ||
5
+ !config.secretKey.startsWith(config.mode === 'live' ? 'sk_live_' : 'sk_test_')) {
6
+ throw new CommerceError('stripe_configuration', 'Explicit matching Stripe account, profile and test/live credentials are required');
7
+ }
8
+ const call = async (path, method = 'GET', params, options) => {
9
+ const body = new URLSearchParams();
10
+ const add = (key, value) => {
11
+ if (value === undefined || value === null)
12
+ return;
13
+ if (Array.isArray(value)) {
14
+ value.forEach((v, i) => add(`${key}[${i}]`, v));
15
+ return;
16
+ }
17
+ if (typeof value === 'object') {
18
+ for (const [k, v] of Object.entries(value))
19
+ add(key ? `${key}[${k}]` : k, v);
20
+ return;
21
+ }
22
+ if (!['boolean', 'number', 'string'].includes(typeof value))
23
+ throw new CommerceError('stripe_request', 'Unsupported Stripe value');
24
+ body.set(key, String(value));
25
+ };
26
+ for (const [key, value] of Object.entries(params ?? {}))
27
+ add(key, value);
28
+ const headers = new Headers({ Authorization: `Bearer ${config.secretKey}` });
29
+ if (options?.apiVersion)
30
+ headers.set('Stripe-Version', options.apiVersion);
31
+ if (options?.idempotencyKey)
32
+ headers.set('Idempotency-Key', options.idempotencyKey);
33
+ if (method !== 'GET')
34
+ headers.set('Content-Type', 'application/x-www-form-urlencoded');
35
+ const response = await fetchImpl(`https://api.stripe.com${path}${method === 'GET' && body.size ? '?' + body.toString() : ''}`, {
36
+ method, headers, ...(method === 'GET' ? {} : { body }), redirect: 'error', signal: AbortSignal.timeout(20000),
37
+ });
38
+ const reader = response.body?.getReader();
39
+ let size = 0;
40
+ const chunks = [];
41
+ if (reader)
42
+ for (;;) {
43
+ const { value, done } = await reader.read();
44
+ if (done)
45
+ break;
46
+ size += value.byteLength;
47
+ if (size > 1024 * 1024) {
48
+ await reader.cancel();
49
+ throw new CommerceError('stripe_response_size', 'Stripe response exceeds limit');
50
+ }
51
+ chunks.push(value);
52
+ }
53
+ let result;
54
+ try {
55
+ result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
56
+ }
57
+ catch {
58
+ throw new CommerceError('stripe_response', 'Stripe returned invalid JSON');
59
+ }
60
+ // Do not surface provider error text that might echo SPT or customer details.
61
+ if (!response.ok)
62
+ throw new CommerceError(response.status === 429 ? 'stripe_rate_limit' : 'stripe_request_failed', `Stripe request failed (HTTP ${response.status})`);
63
+ return { ...result, lastResponse: { headers: { 'idempotent-replayed': response.headers.get('idempotent-replayed') ?? 'false' } } };
64
+ };
65
+ const validateIntent = (value) => {
66
+ if (typeof value.id !== 'string' || !/^pi_[A-Za-z0-9]+$/.test(value.id) || typeof value.status !== 'string' ||
67
+ !Number.isSafeInteger(value.amount) || typeof value.livemode !== 'boolean' || !value.metadata || typeof value.metadata !== 'object') {
68
+ throw new CommerceError('stripe_response', 'Stripe returned an invalid PaymentIntent');
69
+ }
70
+ return value;
71
+ };
72
+ return {
73
+ accountId: config.accountId, merchantProfile: config.merchantProfile, mode: config.mode,
74
+ async assertReady() {
75
+ const account = await call('/v1/account');
76
+ if (account.id !== config.accountId)
77
+ throw new CommerceError('stripe_account_mismatch', 'Stripe key belongs to another account');
78
+ if (config.mode === 'live' && account.charges_enabled !== true)
79
+ throw new CommerceError('stripe_charges_disabled', 'Live merchant is not enabled for charges');
80
+ },
81
+ async create(params, options) { return validateIntent(await call('/v1/payment_intents', 'POST', params, options)); },
82
+ async retrieve(id) {
83
+ if (!/^pi_[A-Za-z0-9]+$/.test(id))
84
+ throw new CommerceError('stripe_reference', 'Invalid original PaymentIntent');
85
+ return validateIntent(await call(`/v1/payment_intents/${id}`));
86
+ },
87
+ async findOriginal(orderId) {
88
+ if (!/^[0-9a-f-]{36}$/.test(orderId))
89
+ throw new CommerceError('order_reference', 'Invalid original order');
90
+ const result = await call('/v1/payment_intents/search', 'GET', { query: `metadata['envarpay_order']:'${orderId}'`, limit: 2 });
91
+ if (!Array.isArray(result.data) || result.has_more || result.data.length > 1)
92
+ throw new CommerceError('ambiguous_payment', 'Multiple intents require original-operation reconciliation');
93
+ // Stripe search may be eventually consistent. No match remains unknown.
94
+ return result.data.length ? validateIntent(result.data[0]) : undefined;
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,113 @@
1
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
2
+ [key: string]: JsonValue;
3
+ };
4
+ export type Input = Record<string, JsonValue>;
5
+ export type Pricing = {
6
+ kind: 'free';
7
+ } | {
8
+ kind: 'fixed';
9
+ amount: string;
10
+ } | {
11
+ kind: 'quantity';
12
+ unit: string;
13
+ unitAmount: string;
14
+ quantity: {
15
+ pointer: string;
16
+ measure: 'count';
17
+ min: number;
18
+ max: number;
19
+ };
20
+ };
21
+ export interface Offer {
22
+ id: string;
23
+ pricing: Pricing;
24
+ collection: {
25
+ kind: 'none' | 'upfront';
26
+ };
27
+ paymentProfile?: string;
28
+ }
29
+ export type PaymentProfile = {
30
+ adapter: 'x402';
31
+ scheme: 'exact';
32
+ network: string;
33
+ asset: string;
34
+ payTo: string;
35
+ facilitatorUrl: string;
36
+ } | {
37
+ adapter: 'mpp';
38
+ method: 'stripe';
39
+ intent: 'charge';
40
+ currency: 'usd';
41
+ accountRef: string;
42
+ };
43
+ export interface Service {
44
+ id: string;
45
+ name: string;
46
+ revision: number;
47
+ execution: {
48
+ type: 'a2a';
49
+ cardUrl: string;
50
+ };
51
+ contract: {
52
+ inputSchema: Record<string, unknown>;
53
+ deliverables: string[];
54
+ targetDurationSeconds: number;
55
+ includedRevisions: 0;
56
+ };
57
+ offers: Offer[];
58
+ }
59
+ export interface CommerceConfig {
60
+ configVersion: 1;
61
+ agent: {
62
+ id: string;
63
+ name: string;
64
+ };
65
+ paymentProfiles: Record<string, PaymentProfile>;
66
+ services: Service[];
67
+ }
68
+ export interface BuyerPolicy {
69
+ policyVersion: 1;
70
+ paymentsEnabled: boolean;
71
+ approval: 'per_purchase' | 'within_preapproved_limits';
72
+ peers: ({
73
+ id: string;
74
+ cardUrl: string;
75
+ protocol: 'x402' | 'mpp';
76
+ currency: string;
77
+ recipient: string;
78
+ maxPerPurchase: string;
79
+ } | {
80
+ id: string;
81
+ cardUrl: string;
82
+ protocol: 'free';
83
+ currency: null;
84
+ recipient: null;
85
+ maxPerPurchase: '0';
86
+ })[];
87
+ budgets: {
88
+ currency: string;
89
+ maxTotal: string;
90
+ period: 'cumulative';
91
+ }[];
92
+ }
93
+ export interface PriceQuote {
94
+ quoteVersion: 1;
95
+ quoteId: string;
96
+ caller: string;
97
+ serviceId: string;
98
+ serviceRevision: number;
99
+ offerId: string;
100
+ messageId: string;
101
+ inputDigest: string;
102
+ termsDigest: string;
103
+ issuedAt: string;
104
+ expiresAt: string;
105
+ amount: string;
106
+ currency: string | null;
107
+ recipient: string | null;
108
+ paymentProfile: PaymentProfile | null;
109
+ }
110
+ export declare class CommerceError extends Error {
111
+ readonly code: string;
112
+ constructor(code: string, message: string);
113
+ }
@@ -0,0 +1,8 @@
1
+ export class CommerceError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = 'CommerceError';
7
+ }
8
+ }
@@ -0,0 +1,6 @@
1
+ import type { ExecuteOrder } from './server.js';
2
+ /** Official A2A calls, pinned to the configured origin and original remote interface. */
3
+ export interface NativeA2AOptions {
4
+ inputEncoding?: Readonly<Record<string, 'data' | 'json-text'>>;
5
+ }
6
+ export declare function nativeA2AExecutor(tokens: Readonly<Record<string, string>>, fetchImpl?: typeof fetch, signal?: AbortSignal, options?: NativeA2AOptions): ExecuteOrder;
@@ -0,0 +1,103 @@
1
+ import { AgentCard, GetTaskRequest, SendMessageRequest, TaskState } from '@a2a-js/sdk';
2
+ import { ClientFactory, JsonRpcTransportFactory } from '@a2a-js/sdk/client';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { CommerceError } from './types.js';
5
+ import { validateUrl } from './config.js';
6
+ export function nativeA2AExecutor(tokens, fetchImpl = fetch, signal, options = {}) {
7
+ const inputEncoding = Object.freeze({ ...options.inputEncoding });
8
+ if (Object.values(inputEncoding).some(value => value !== 'data' && value !== 'json-text'))
9
+ throw new CommerceError('invalid_upstream_encoding', 'Choose an explicit data or json-text input encoding');
10
+ const parts = (service, input) => inputEncoding[service.id] === 'json-text' ? [{ text: JSON.stringify(input), mediaType: 'application/json' }] : [{ data: input }];
11
+ const interfaces = new WeakMap();
12
+ async function prepare(service, remote) {
13
+ const cardUrl = validateUrl(service.execution.cardUrl, true), token = tokens[`${service.id}:${service.revision}`] ?? tokens[service.id];
14
+ if (!token || token.length < 16)
15
+ throw new CommerceError('upstream_credentials', 'Configured runtime authentication is missing');
16
+ if (remote && remote.origin !== service.execution.cardUrl)
17
+ throw new CommerceError('upstream_origin', 'Original runtime origin differs from the frozen service');
18
+ const scopedFetch = async (input, init) => {
19
+ const request = new Request(input, init);
20
+ if (new URL(request.url).origin !== cardUrl.origin)
21
+ throw new CommerceError('upstream_origin', 'Runtime Card cannot redirect credentials to another origin');
22
+ const headers = new Headers(request.headers);
23
+ headers.set('Authorization', 'Bearer ' + token);
24
+ headers.set('A2A-Version', '1.0');
25
+ const signals = [request.signal, AbortSignal.timeout(120000), ...(signal ? [signal] : [])];
26
+ const response = await fetchImpl(new Request(request, { headers, redirect: 'error', signal: AbortSignal.any(signals) }));
27
+ if (Number(response.headers.get('Content-Length') ?? 0) > 4 * 1024 * 1024)
28
+ throw new CommerceError('upstream_size', 'Runtime response exceeds limit');
29
+ if (response.body) {
30
+ const reader = response.body.getReader(), chunks = [];
31
+ let length = 0;
32
+ for (;;) {
33
+ const next = await reader.read();
34
+ if (next.done)
35
+ break;
36
+ length += next.value.byteLength;
37
+ if (length > 4 * 1024 * 1024) {
38
+ await reader.cancel();
39
+ throw new CommerceError('upstream_size', 'Runtime response exceeds limit');
40
+ }
41
+ chunks.push(next.value);
42
+ }
43
+ return new Response(Buffer.concat(chunks), { status: response.status, statusText: response.statusText, headers: response.headers });
44
+ }
45
+ return response;
46
+ };
47
+ const response = await scopedFetch(cardUrl);
48
+ if (!response.ok)
49
+ throw new CommerceError('upstream_card', 'Unable to read runtime Agent Card');
50
+ const card = AgentCard.fromJSON(await response.json());
51
+ const selected = card.supportedInterfaces.find(x => x.protocolVersion === '1.0' && x.protocolBinding === 'JSONRPC');
52
+ if (!selected)
53
+ throw new CommerceError('upstream_version', 'Runtime must expose A2A 1.0 JSONRPC');
54
+ for (const endpoint of card.supportedInterfaces)
55
+ if (validateUrl(endpoint.url, true).origin !== cardUrl.origin)
56
+ throw new CommerceError('upstream_origin', 'Runtime interface must share configured Card origin');
57
+ if (remote?.interfaceUrl && selected.url !== remote.interfaceUrl)
58
+ throw new CommerceError('upstream_endpoint_changed', 'Original Task interface changed; restore it before recovery');
59
+ const factory = new ClientFactory({ transports: [new JsonRpcTransportFactory({ fetchImpl: scopedFetch })] });
60
+ const client = await factory.createFromAgentCard({ ...card, supportedInterfaces: [selected] });
61
+ return { client, interfaceUrl: selected.url };
62
+ }
63
+ function observed(task, interfaceUrl, expectedId) {
64
+ if (!task || typeof task !== 'object' || !('status' in task) || !('id' in task) || !task.id)
65
+ throw new CommerceError('upstream_task_required', 'Runtime returned no durable Task handle');
66
+ const result = task;
67
+ if (expectedId && result.id !== expectedId)
68
+ throw new CommerceError('remote_task_conflict', 'Runtime returned a different Task');
69
+ interfaces.set(result, interfaceUrl);
70
+ return result;
71
+ }
72
+ async function* observe(task, prepared, service) {
73
+ const deadline = Date.now() + service.contract.targetDurationSeconds * 1000, id = task.id;
74
+ for (;;) {
75
+ yield task;
76
+ if ([TaskState.TASK_STATE_COMPLETED, TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_CANCELED, TaskState.TASK_STATE_REJECTED, TaskState.TASK_STATE_INPUT_REQUIRED, TaskState.TASK_STATE_AUTH_REQUIRED].includes(task.status?.state ?? 0))
77
+ return;
78
+ if (Date.now() >= deadline)
79
+ throw new CommerceError('execution_pending', 'Read the original Task to continue observing execution');
80
+ await sleep(1000, undefined, { signal });
81
+ task = observed(await prepared.client.getTask(GetTaskRequest.fromJSON({ id })), prepared.interfaceUrl, id);
82
+ }
83
+ }
84
+ const execute = async function* (order, service) {
85
+ const prepared = await prepare(service);
86
+ const request = SendMessageRequest.fromJSON({ message: { messageId: order.messageId, role: 'ROLE_USER', parts: parts(service, order.input) }, configuration: { returnImmediately: true } });
87
+ const task = observed(await prepared.client.sendMessage(request), prepared.interfaceUrl);
88
+ yield* observe(task, prepared, service);
89
+ };
90
+ execute.continue = async function* (_order, service, continuation, remote) {
91
+ const prepared = await prepare(service, remote);
92
+ const request = SendMessageRequest.fromJSON({ message: { messageId: continuation.messageId, taskId: remote.taskId, ...(remote.contextId ? { contextId: remote.contextId } : {}), role: 'ROLE_USER', parts: parts(service, continuation.input) }, configuration: { returnImmediately: true } });
93
+ const task = observed(await prepared.client.sendMessage(request), prepared.interfaceUrl, remote.taskId);
94
+ yield* observe(task, prepared, service);
95
+ };
96
+ execute.recover = async (_order, service, remote) => {
97
+ const prepared = await prepare(service, remote);
98
+ // Recovery never calls SendMessage, creates a new Task, or retries a dispatch.
99
+ return observed(await prepared.client.getTask(GetTaskRequest.fromJSON({ id: remote.taskId })), prepared.interfaceUrl, remote.taskId);
100
+ };
101
+ execute.remoteInterface = task => interfaces.get(task);
102
+ return execute;
103
+ }
@@ -0,0 +1,8 @@
1
+ /** Original authorizations are encrypted before any external settlement attempt. */
2
+ export declare class CredentialVault {
3
+ private directory;
4
+ private readonly key;
5
+ constructor(directory: string, key: Buffer);
6
+ put(value: unknown): string;
7
+ get(id: string): unknown;
8
+ }
@@ -0,0 +1,55 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync, openSync, closeSync, fsyncSync, lstatSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { CommerceError } from './types.js';
5
+ /** Original authorizations are encrypted before any external settlement attempt. */
6
+ export class CredentialVault {
7
+ directory;
8
+ key;
9
+ constructor(directory, key) {
10
+ this.directory = directory;
11
+ if (key.length !== 32)
12
+ throw new CommerceError('vault_key', 'A 32-byte vault encryption key is required');
13
+ this.key = Buffer.from(key);
14
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
15
+ if (!lstatSync(directory).isDirectory())
16
+ throw new CommerceError('vault_directory', 'Vault must be a real directory');
17
+ chmodSync(directory, 0o700);
18
+ }
19
+ put(value) {
20
+ const serialized = JSON.stringify(value);
21
+ if (!serialized || Buffer.byteLength(serialized) > 65536)
22
+ throw new CommerceError('vault_size', 'Authorization exceeds vault limit');
23
+ const id = randomUUID(), nonce = randomBytes(12);
24
+ const cipher = createCipheriv('aes-256-gcm', this.key, nonce);
25
+ cipher.setAAD(Buffer.from(id));
26
+ const encrypted = Buffer.concat([cipher.update(serialized, 'utf8'), cipher.final()]);
27
+ writeFileSync(join(this.directory, id), Buffer.concat([nonce, cipher.getAuthTag(), encrypted]), { mode: 0o600, flag: 'wx', flush: true });
28
+ // Persist the directory entry before returning a reference to the ledger.
29
+ const directoryFd = openSync(this.directory, 'r');
30
+ try {
31
+ fsyncSync(directoryFd);
32
+ }
33
+ finally {
34
+ closeSync(directoryFd);
35
+ }
36
+ return id;
37
+ }
38
+ get(id) {
39
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id))
40
+ throw new CommerceError('vault_reference', 'Invalid credential reference');
41
+ const path = join(this.directory, id), stat = lstatSync(path);
42
+ if (!stat.isFile() || stat.size < 29 || stat.size > 65564 || (stat.mode & 0o077) !== 0)
43
+ throw new CommerceError('vault_file', 'Invalid private authorization file');
44
+ const bytes = readFileSync(path);
45
+ const cipher = createDecipheriv('aes-256-gcm', this.key, bytes.subarray(0, 12));
46
+ cipher.setAAD(Buffer.from(id));
47
+ cipher.setAuthTag(bytes.subarray(12, 28));
48
+ try {
49
+ return JSON.parse(Buffer.concat([cipher.update(bytes.subarray(28)), cipher.final()]).toString('utf8'));
50
+ }
51
+ catch {
52
+ throw new CommerceError('vault_integrity', 'Original authorization failed integrity verification');
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,37 @@
1
+ import { type FacilitatorClient } from '@x402/core/server';
2
+ import type { PaymentPayload, PaymentRequirements, SettleResponse } from '@x402/core/types';
3
+ import { type PaymentProfile } from './types.js';
4
+ import { CommerceStore, type OrderRecord } from './store.js';
5
+ import { CredentialVault } from './vault.js';
6
+ type X402Profile = Extract<PaymentProfile, {
7
+ adapter: 'x402';
8
+ }>;
9
+ export interface X402GateOptions {
10
+ store: CommerceStore;
11
+ vault: CredentialVault;
12
+ payerFor: (caller: string) => string | undefined;
13
+ facilitator?: (profile: X402Profile) => FacilitatorClient;
14
+ verifyReceipt: (receipt: SettleResponse, payload: PaymentPayload, requirements: PaymentRequirements) => Promise<boolean>;
15
+ /** Read chain before broadcast, so a lost facilitator response remains recoverable. */
16
+ checkpoint?: (requirements: PaymentRequirements) => Promise<string>;
17
+ findOriginalReceipt?: (payload: PaymentPayload, requirements: PaymentRequirements, fromBlock: string) => Promise<SettleResponse | undefined>;
18
+ }
19
+ export type GateResult = {
20
+ response: Response;
21
+ } | {
22
+ headers: Record<string, string>;
23
+ };
24
+ /** Official x402 v2 exact settlement, with independent chain evidence before work. */
25
+ export declare class X402Gate {
26
+ private options;
27
+ private servers;
28
+ constructor(options: X402GateOptions);
29
+ check(profiles: readonly X402Profile[]): Promise<void>;
30
+ recover(orderId: string, caller: string): Promise<{
31
+ state: string;
32
+ taskId: string;
33
+ }>;
34
+ private resource;
35
+ handle(request: Request, order: OrderRecord): Promise<GateResult>;
36
+ }
37
+ export {};
@@ -0,0 +1,174 @@
1
+ import { findDefaultAsset } from '@x402/evm';
2
+ import { x402ResourceServer, HTTPFacilitatorClient } from '@x402/core/server';
3
+ import { decodePaymentSignatureHeader, encodePaymentRequiredHeader, encodePaymentResponseHeader } from '@x402/core/http';
4
+ import { ExactEvmScheme } from '@x402/evm/exact/server';
5
+ import { PAYMENT_IDENTIFIER, declarePaymentIdentifierExtension, extractPaymentIdentifier, validatePaymentIdentifier, paymentIdentifierResourceServerExtension, } from '@x402/extensions/payment-identifier';
6
+ import { CommerceError } from './types.js';
7
+ import { digest } from './config.js';
8
+ /** Official x402 v2 exact settlement, with independent chain evidence before work. */
9
+ export class X402Gate {
10
+ options;
11
+ servers = new Map();
12
+ constructor(options) {
13
+ this.options = options;
14
+ }
15
+ async check(profiles) {
16
+ for (const profile of profiles) {
17
+ const { facilitator } = await this.resource(profile);
18
+ const supported = await facilitator.getSupported();
19
+ if (!supported.kinds.some(k => k.x402Version === 2 && k.scheme === 'exact' && k.network === profile.network))
20
+ throw new CommerceError('facilitator_network', 'Configured facilitator does not support exact payments on this network');
21
+ if (this.options.checkpoint)
22
+ await this.options.checkpoint({ scheme: 'exact', network: profile.network, asset: profile.asset, payTo: profile.payTo, amount: '1', maxTimeoutSeconds: 300, extra: {} });
23
+ }
24
+ }
25
+ async recover(orderId, caller) {
26
+ const order = this.options.store.getOrder(orderId, caller);
27
+ if (!order)
28
+ throw new CommerceError('order_not_found', 'Order not found for this buyer');
29
+ const attempt = this.options.store.paymentForOrder(order.id);
30
+ if (!attempt)
31
+ throw new CommerceError('attempt_not_found', 'No original payment to recover');
32
+ if (attempt.state === 'confirmed')
33
+ return { state: 'confirmed', taskId: order.taskId };
34
+ if (attempt.state === 'rejected')
35
+ return { state: 'rejected', taskId: order.taskId };
36
+ const saved = this.options.vault.get(attempt.credentialRef);
37
+ const required = this.options.store.paymentChallenge(order.id);
38
+ if (saved.quoteId !== order.quote.quoteId || !required?.accepts.some(r => digest(r) === digest(saved.requirements))) {
39
+ throw new CommerceError('recovery_conflict', 'Stored authorization differs from the frozen challenge');
40
+ }
41
+ let receipt = attempt.receipt;
42
+ if (!receipt?.transaction && saved.fromBlock && this.options.findOriginalReceipt) {
43
+ receipt = await this.options.findOriginalReceipt(saved.payload, saved.requirements, saved.fromBlock) ?? null;
44
+ }
45
+ if (!receipt?.transaction)
46
+ return { state: 'unknown', taskId: order.taskId };
47
+ let proven = false;
48
+ try {
49
+ proven = await this.options.verifyReceipt(receipt, saved.payload, saved.requirements);
50
+ }
51
+ catch { }
52
+ // Persist the discovered transaction even if confirmations are still pending.
53
+ this.options.store.recordSettlement(attempt.id, proven ? 'confirmed' : 'unknown', receipt);
54
+ return { state: proven ? 'confirmed' : 'unknown', taskId: order.taskId };
55
+ }
56
+ async resource(profile) {
57
+ const key = digest(profile);
58
+ let promise = this.servers.get(key);
59
+ if (!promise) {
60
+ promise = (async () => {
61
+ const facilitator = this.options.facilitator?.(profile) ?? new HTTPFacilitatorClient({ url: profile.facilitatorUrl });
62
+ const server = new x402ResourceServer(facilitator)
63
+ .register(profile.network, new ExactEvmScheme())
64
+ .registerExtension(paymentIdentifierResourceServerExtension);
65
+ await server.initialize();
66
+ return { server, facilitator };
67
+ })();
68
+ this.servers.set(key, promise);
69
+ void promise.catch(() => this.servers.delete(key));
70
+ }
71
+ return promise;
72
+ }
73
+ async handle(request, order) {
74
+ const profile = order.quote.paymentProfile;
75
+ if (profile?.adapter !== 'x402')
76
+ throw new CommerceError('payment_method_unavailable', 'This offer requires another installed payment adapter');
77
+ const previous = this.options.store.paymentForOrder(order.id);
78
+ if (order.paymentState === 'confirmed' && previous?.receipt) {
79
+ return { headers: { 'PAYMENT-RESPONSE': encodePaymentResponseHeader(previous.receipt) } };
80
+ }
81
+ if (['settling', 'unknown'].includes(order.paymentState)) {
82
+ const recovered = await this.recover(order.id, order.caller);
83
+ if (recovered.state === 'confirmed') {
84
+ const receipt = this.options.store.paymentForOrder(order.id).receipt;
85
+ return { headers: { 'PAYMENT-RESPONSE': encodePaymentResponseHeader(receipt) } };
86
+ }
87
+ return { response: Response.json({ error: 'payment_reconciliation_required', taskId: order.taskId }, { status: 503, headers: { 'Cache-Control': 'no-store' } }) };
88
+ }
89
+ if (Date.now() >= Date.parse(order.quote.expiresAt))
90
+ return { response: Response.json({ error: 'quote_expired' }, { status: 409 }) };
91
+ const { server, facilitator } = await this.resource(profile);
92
+ let required = this.options.store.paymentChallenge(order.id);
93
+ if (!required) {
94
+ const token = findDefaultAsset(profile.asset, profile.network);
95
+ if (!token)
96
+ throw new CommerceError('unsupported_asset', 'Official token signing domain is unavailable');
97
+ const requirements = await server.buildPaymentRequirements({
98
+ scheme: 'exact', network: profile.network, payTo: profile.payTo,
99
+ price: { asset: profile.asset, amount: order.quote.amount, extra: { name: token.name, version: token.version } },
100
+ maxTimeoutSeconds: 300, extra: { paymentFlow: 'upfront', assetTransferMethod: 'eip3009' },
101
+ });
102
+ const resource = new URL(request.url);
103
+ resource.search = '';
104
+ resource.searchParams.set('quote', order.quote.quoteId);
105
+ required = this.options.store.freezePaymentChallenge(order.id, await server.createPaymentRequiredResponse(requirements, { url: resource.href, description: order.quote.serviceId, mimeType: 'application/json' }, undefined, { [PAYMENT_IDENTIFIER]: declarePaymentIdentifierExtension(false), 'urn:envarpay:quote:1': { info: { quoteId: order.quote.quoteId, messageId: order.messageId, serviceId: order.quote.serviceId, serviceRevision: order.quote.serviceRevision, offerId: order.offerId, inputDigest: order.inputDigest, termsDigest: order.quote.termsDigest, expiresAt: order.quote.expiresAt, amount: order.quote.amount, currency: order.quote.currency, recipient: order.quote.recipient } } }));
106
+ }
107
+ // Identifier is optional for unextended standard clients; nonce dedup is mandatory.
108
+ const challenge = () => ({ response: new Response('{}', {
109
+ status: 402, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'PAYMENT-REQUIRED': encodePaymentRequiredHeader(required) },
110
+ }) });
111
+ const header = request.headers.get('PAYMENT-SIGNATURE');
112
+ if (!header)
113
+ return challenge();
114
+ if (header.length > 32768)
115
+ throw new CommerceError('payment_payload_size', 'Payment credential exceeds limit');
116
+ let payload;
117
+ try {
118
+ payload = decodePaymentSignatureHeader(header);
119
+ }
120
+ catch {
121
+ throw new CommerceError('invalid_payment', 'Malformed payment credential');
122
+ }
123
+ const matched = server.findMatchingRequirements(required.accepts, payload);
124
+ if (!matched || payload.x402Version !== 2 || payload.resource?.url !== required.resource.url || digest(payload.accepted) !== digest(matched)) {
125
+ throw new CommerceError('quote_mismatch', 'Payment does not match the frozen quote');
126
+ }
127
+ const extension = payload.extensions?.[PAYMENT_IDENTIFIER];
128
+ // Validate against our schema; never compile a schema supplied by the buyer.
129
+ if (extension !== undefined && !validatePaymentIdentifier({ info: extension?.info, schema: declarePaymentIdentifierExtension(false).schema }).valid) {
130
+ throw new CommerceError('invalid_payment_identifier', 'Invalid standard payment identifier');
131
+ }
132
+ const identifier = extractPaymentIdentifier(payload, false);
133
+ const native = payload.payload;
134
+ const auth = native.authorization;
135
+ const payer = this.options.payerFor(order.caller);
136
+ if (!payer || !auth?.from || auth.from.toLowerCase() !== payer.toLowerCase() || auth.to?.toLowerCase() !== profile.payTo.toLowerCase() || auth.value !== order.quote.amount || !/^0x[0-9a-fA-F]{64}$/.test(auth.nonce ?? '') || !native.signature) {
137
+ throw new CommerceError('payment_identity', 'Signed payer, recipient, amount and nonce must match authorized buyer and quote');
138
+ }
139
+ // Quote UI metadata is locally checked above. It is not a facilitator
140
+ // settlement extension; do not require third-party facilitators to implement it.
141
+ const settlementPayload = { ...payload, extensions: Object.fromEntries(Object.entries(payload.extensions ?? {}).filter(([key]) => key !== 'urn:envarpay:quote:1')) };
142
+ // Upfront SDK verification may skip /verify; explicitly perform it before reserve.
143
+ const verified = await facilitator.verify(settlementPayload, matched);
144
+ if (!verified.isValid)
145
+ return challenge();
146
+ if (verified.payer && verified.payer.toLowerCase() !== payer.toLowerCase())
147
+ throw new CommerceError('payment_identity', 'Verified payer differs from authorized buyer');
148
+ const economicKey = digest({ network: profile.network, asset: profile.asset.toLowerCase(), payer: payer.toLowerCase(), nonce: auth.nonce.toLowerCase() });
149
+ const fromBlock = await this.options.checkpoint?.(matched);
150
+ const saved = { payload, requirements: matched, quoteId: order.quote.quoteId, ...(fromBlock !== undefined ? { fromBlock } : {}) };
151
+ const credentialRef = this.options.vault.put(saved);
152
+ const attempt = this.options.store.reservePayment(order.id, order.caller, economicKey, credentialRef, identifier ?? undefined);
153
+ let result;
154
+ try {
155
+ result = await server.settlePayment(settlementPayload, matched, undefined, undefined, undefined, 'before-handler');
156
+ }
157
+ catch {
158
+ this.options.store.recordSettlement(attempt, 'unknown', {});
159
+ return { response: Response.json({ error: 'payment_outcome_unknown' }, { status: 503 }) };
160
+ }
161
+ // A failure can follow broadcast. Never create a second economic authorization.
162
+ let proven = false;
163
+ if (result.success) {
164
+ try {
165
+ proven = await this.options.verifyReceipt(result, payload, matched);
166
+ }
167
+ catch { }
168
+ }
169
+ this.options.store.recordSettlement(attempt, proven ? 'confirmed' : 'unknown', result);
170
+ if (!proven)
171
+ return { response: Response.json({ error: result.success ? 'receipt_verification_pending' : 'payment_outcome_unknown' }, { status: 503 }) };
172
+ return { headers: { 'PAYMENT-RESPONSE': encodePaymentResponseHeader(result) } };
173
+ }
174
+ }
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { createRequire } from 'node:module';
1
2
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
3
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
+ const packageIdentity = createRequire(import.meta.url)('../package.json');
3
5
  /** A wallet refusal or execution error, preserved from the MCP tool response. */
4
6
  export class WalletToolError extends Error {
5
7
  tool;
@@ -55,7 +57,7 @@ export class WalletClient {
55
57
  const timeoutMs = options.timeoutMs ?? 300_000;
56
58
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
57
59
  throw new TypeError('timeoutMs must be positive');
58
- const client = new Client({ name: '@envarai/envarpay', version: '0.1.0-alpha.2' });
60
+ const client = new Client({ name: packageIdentity.name, version: packageIdentity.version });
59
61
  const transport = new StreamableHTTPClientTransport(url, {
60
62
  requestInit: { headers: { Authorization: `Bearer ${options.token}` }, redirect: 'error' },
61
63
  });
@@ -0,0 +1,18 @@
1
+ {
2
+ "policyVersion":1,
3
+ "paymentsEnabled":false,
4
+ "approval":"per_purchase",
5
+ "peers":[{
6
+ "id":"research-seller",
7
+ "cardUrl":"https://seller.example/services/research/v1/offers/usdc-once/agent-card.json",
8
+ "protocol":"x402",
9
+ "currency":"eip155:84532/erc20:0x036CbD53842c5426634e7929541eC2318f3dCF7e",
10
+ "recipient":"0x2222222222222222222222222222222222222222",
11
+ "maxPerPurchase":"3000000"
12
+ }],
13
+ "budgets":[{
14
+ "currency":"eip155:84532/erc20:0x036CbD53842c5426634e7929541eC2318f3dCF7e",
15
+ "maxTotal":"10000000",
16
+ "period":"cumulative"
17
+ }]
18
+ }