openpay-x402-sdk 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Add the ESM `createOpenPayClient` API for discovery, free shop lookup, quotes,
6
+ guarded x402 payment, and immutable session snapshots.
7
+ - Add local private-key, seven-field Steward, and custom signer options with an
8
+ exclusive startup contract.
9
+ - Export the payment, guard, signer, catalog, and serialized executor primitives
10
+ with TypeScript declarations.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # openpay-x402-sdk
2
+
3
+ Node.js 20+ SDK for discovering, quoting, and buying OpenPay x402 resources priced
4
+ in JPYC. It ships as plain ESM and has no build step.
5
+
6
+ ## Quick start
7
+
8
+ ```bash
9
+ npm install openpay-x402-sdk
10
+ ```
11
+
12
+ ```js
13
+ import { createOpenPayClient } from 'openpay-x402-sdk';
14
+
15
+ const client = createOpenPayClient({
16
+ privateKey: process.env.BUYER_PRIVATE_KEY,
17
+ maxPerCallJpyc: '10',
18
+ maxSessionJpyc: '100',
19
+ allowedHosts: 'open-pay.jp',
20
+ });
21
+
22
+ const catalog = await client.discover({ query: 'demo' });
23
+ const quote = await client.quote('https://open-pay.jp/api/paid/demo');
24
+ if (quote.ok) {
25
+ const result = await client.pay(quote.url, { maxTotalJpyc: '2' });
26
+ console.log(result.body, client.session);
27
+ }
28
+ ```
29
+
30
+ `discover()` and `findShops({ q?, limit? })` return the server response unchanged
31
+ inside `{ ok, status, body }`. `quote()` fetches and validates a 402 challenge but
32
+ does not need a signer and never pays. `pay()` requires a signer and serializes
33
+ concurrent calls so every call sees the latest session total.
34
+
35
+ ## Money guards
36
+
37
+ | Option | Default | Guard |
38
+ |---|---:|---|
39
+ | `maxPerCallJpyc` | `10` | Upper bound for the caller-provided `maxTotalJpyc`. |
40
+ | `maxSessionJpyc` | `100` | Cumulative cap for successful payments made by this client instance. |
41
+ | `allowedHosts` | `open-pay.jp` | Comma-separated bare host allowlist. |
42
+ | `catalogTrust` | `true` | Also allows exact URLs in the discovery catalog after the live challenge matches the catalog challenge. |
43
+ | `discoveryUrl` | `https://open-pay.jp/api/discovery` | Catalog and OpenPay origin used by the client. |
44
+
45
+ `pay(url, { maxTotalJpyc })` always requires `maxTotalJpyc`. It is the maximum
46
+ total—including the resource price and x402 fee—that this individual call is
47
+ authorized to pay. It does not disable or raise `maxPerCallJpyc` or
48
+ `maxSessionJpyc`; all three limits must allow the payment.
49
+
50
+ The client also rejects non-JPYC metadata, unsupported networks or schemes,
51
+ non-OpenPay forwarder splits, amount inconsistencies, resource URL mismatches,
52
+ and catalog bait-and-switches before requesting a signature.
53
+
54
+ ## Signers
55
+
56
+ Choose exactly one of `privateKey`, `steward`, or `signer`. Supplying more than
57
+ one is a startup error. A custom signer has an EVM `address` and an async
58
+ `signTypedData(typedData)` method.
59
+
60
+ Steward keeps signing outside the SDK process:
61
+
62
+ ```js
63
+ const client = createOpenPayClient({
64
+ steward: {
65
+ url: process.env.STEWARD_URL,
66
+ tenant: process.env.STEWARD_TENANT,
67
+ apiKey: process.env.STEWARD_API_KEY,
68
+ agentId: process.env.STEWARD_AGENT_ID,
69
+ agentAddress: process.env.STEWARD_AGENT_ADDRESS,
70
+ signerId: process.env.STEWARD_SIGNER_ID,
71
+ signerSecret: process.env.STEWARD_SIGNER_SECRET,
72
+ },
73
+ });
74
+ ```
75
+
76
+ The first Steward signature is verified locally against `agentAddress`. Steward
77
+ API keys, signer secrets, and local private keys are redacted from SDK-generated
78
+ errors and are not exposed as client properties.
79
+
80
+ ## Security
81
+
82
+ Payments can be irreversible. Use a dedicated low-balance wallet, keep private
83
+ keys and Steward credentials in a secret manager, and never put them in source
84
+ code or logs. Set `maxTotalJpyc` from the amount authorized for the current
85
+ operation, not from the wallet balance. Keep conservative per-call and session
86
+ limits even when Steward applies an additional signing policy.
87
+
88
+ `client.session` returns a new frozen snapshot on every read:
89
+
90
+ ```js
91
+ // { spentAtomic: 2000000000000000000n, spentJpyc: '2' }
92
+ console.log(client.session);
93
+ ```
94
+
95
+ Advanced consumers may import the named payment, guard, signer, catalog, and
96
+ executor helpers from the package root.
package/index.d.ts ADDED
@@ -0,0 +1,428 @@
1
+ import type { Address, Hex } from 'viem';
2
+
3
+ export type JpycAmount = string | number;
4
+
5
+ export interface StewardOptions {
6
+ url: string;
7
+ tenant: string;
8
+ apiKey: string;
9
+ agentId: string;
10
+ agentAddress: string;
11
+ signerId: string;
12
+ signerSecret: string;
13
+ }
14
+
15
+ export interface PaymentTypedData {
16
+ domain: {
17
+ name: string;
18
+ version: string;
19
+ chainId: number;
20
+ verifyingContract: Address;
21
+ };
22
+ types: Record<string, readonly { name: string; type: string }[]>;
23
+ primaryType: string;
24
+ message: {
25
+ from: Address;
26
+ to: Address;
27
+ value: bigint;
28
+ validAfter: bigint;
29
+ validBefore: bigint;
30
+ nonce: Hex;
31
+ };
32
+ }
33
+
34
+ export interface PaymentSigner {
35
+ readonly mode?: string;
36
+ readonly address: Address;
37
+ signTypedData(typedData: PaymentTypedData): Hex | Promise<Hex>;
38
+ }
39
+
40
+ interface ClientCommonOptions {
41
+ maxPerCallJpyc?: JpycAmount;
42
+ maxSessionJpyc?: JpycAmount;
43
+ allowedHosts?: string;
44
+ catalogTrust?: boolean;
45
+ discoveryUrl?: string;
46
+ fetchImpl?: typeof globalThis.fetch;
47
+ }
48
+
49
+ type NoSignerOptions = {
50
+ privateKey?: undefined;
51
+ steward?: undefined;
52
+ signer?: undefined;
53
+ };
54
+
55
+ type PrivateKeyOptions = {
56
+ privateKey: string;
57
+ steward?: never;
58
+ signer?: never;
59
+ };
60
+
61
+ type StewardSignerOptions = {
62
+ privateKey?: never;
63
+ steward: StewardOptions;
64
+ signer?: never;
65
+ };
66
+
67
+ type CustomSignerOptions = {
68
+ privateKey?: never;
69
+ steward?: never;
70
+ signer: PaymentSigner;
71
+ };
72
+
73
+ export type OpenPayClientOptions = ClientCommonOptions &
74
+ (
75
+ | NoSignerOptions
76
+ | PrivateKeyOptions
77
+ | StewardSignerOptions
78
+ | CustomSignerOptions
79
+ );
80
+
81
+ export interface RuntimeConfig {
82
+ signerMode: 'env-key' | 'steward';
83
+ buyerPrivateKey: string | null;
84
+ stewardApiKey: string | null;
85
+ stewardSignerSecret: string | null;
86
+ maxPerCallAtomic: bigint;
87
+ maxSessionAtomic: bigint;
88
+ allowedHosts: string[];
89
+ catalogTrust: boolean;
90
+ discoveryUrl: string;
91
+ }
92
+
93
+ export interface PaymentSession {
94
+ spentAtomic: bigint;
95
+ }
96
+
97
+ export interface OpenPaySession {
98
+ readonly spentAtomic: bigint;
99
+ readonly spentJpyc: string;
100
+ }
101
+
102
+ export interface DiscoveryItem {
103
+ resource: string;
104
+ description?: string;
105
+ category?: string;
106
+ priceJpyc?: string;
107
+ docsUrl?: string;
108
+ license?: string;
109
+ updatedAt?: string;
110
+ network?: string;
111
+ accepts: unknown[];
112
+ verifiedAt?: number | null;
113
+ [key: string]: unknown;
114
+ }
115
+
116
+ export interface DiscoveryEnvelope {
117
+ x402Version: number;
118
+ items: DiscoveryItem[];
119
+ [key: string]: unknown;
120
+ }
121
+
122
+ export interface ShopFindItem {
123
+ handle: string;
124
+ name: string;
125
+ mode: 'storefront' | 'preorder';
126
+ acceptingNow: boolean | null;
127
+ }
128
+
129
+ export interface ShopFindEnvelope {
130
+ schemaVersion: '1.0';
131
+ query: { q?: string; limit: number };
132
+ items: ShopFindItem[];
133
+ total: number;
134
+ generatedAt: string;
135
+ dataFreshness: {
136
+ oldestUpdatedAt: string | null;
137
+ newestUpdatedAt: string | null;
138
+ };
139
+ licenseNotice: string;
140
+ attribution: string[];
141
+ [key: string]: unknown;
142
+ }
143
+
144
+ export type FreeApiResult<T> =
145
+ | { ok: true; status: number; body: T }
146
+ | { ok: false; status: number; error: string; body: unknown };
147
+
148
+ export interface GuardedQuote {
149
+ url: string;
150
+ status: number;
151
+ ok: boolean;
152
+ reasons: string[];
153
+ priceJpyc: string | null;
154
+ feeJpyc: string | null;
155
+ totalJpyc: string | null;
156
+ network: string | null;
157
+ asset: Address | null;
158
+ description?: string;
159
+ }
160
+
161
+ export interface InvalidChallengeQuote {
162
+ url: string;
163
+ status: number;
164
+ ok: false;
165
+ reasons: ['expected_402_with_accepts'];
166
+ }
167
+
168
+ export type QuoteResult = GuardedQuote | InvalidChallengeQuote;
169
+
170
+ export interface PaymentResult {
171
+ status: number;
172
+ body: unknown;
173
+ receipt: unknown;
174
+ }
175
+
176
+ export interface OpenPayClient {
177
+ discover(options?: {
178
+ query?: string;
179
+ category?: string;
180
+ }): Promise<FreeApiResult<DiscoveryEnvelope>>;
181
+ findShops(options?: {
182
+ q?: string;
183
+ limit?: number;
184
+ }): Promise<FreeApiResult<ShopFindEnvelope>>;
185
+ quote(url: string): Promise<QuoteResult>;
186
+ pay(
187
+ url: string,
188
+ options: { maxTotalJpyc: JpycAmount },
189
+ ): Promise<QuoteResult | PaymentResult>;
190
+ readonly session: OpenPaySession;
191
+ }
192
+
193
+ export function createOpenPayClient(
194
+ options?: OpenPayClientOptions,
195
+ ): OpenPayClient;
196
+
197
+ export const RECEIVE_WITH_AUTHORIZATION_TYPES: {
198
+ ReceiveWithAuthorization: Array<{ name: string; type: string }>;
199
+ };
200
+
201
+ export interface NormalizedPaymentRequirements {
202
+ scheme: 'exact';
203
+ network: string;
204
+ chainId: number;
205
+ asset: Address;
206
+ maxTimeoutSeconds: number;
207
+ extra: {
208
+ name: string;
209
+ version: string;
210
+ openpay: {
211
+ forwarder: Address;
212
+ merchant: Address;
213
+ merchantValue: bigint;
214
+ feeReceiver: Address;
215
+ feeValue: bigint;
216
+ commitVersion: Hex;
217
+ };
218
+ };
219
+ }
220
+
221
+ export interface ForwarderSettleParams {
222
+ from: Address;
223
+ merchant: Address;
224
+ merchantValue: bigint;
225
+ feeReceiver: Address;
226
+ feeValue: bigint;
227
+ validAfter: bigint;
228
+ validBefore: bigint;
229
+ intentSalt: Hex;
230
+ }
231
+
232
+ export interface PaymentAuthorization {
233
+ from: Address;
234
+ validAfter: string;
235
+ validBefore: string;
236
+ intentSalt: Hex;
237
+ }
238
+
239
+ export interface PaymentPayload {
240
+ x402Version: 1;
241
+ scheme: string;
242
+ network: string;
243
+ payload: {
244
+ signature: Hex;
245
+ authorization: PaymentAuthorization;
246
+ };
247
+ }
248
+
249
+ export function chainIdFromNetwork(network: unknown): number;
250
+ export function normalizePaymentRequirements(
251
+ raw: unknown,
252
+ ): NormalizedPaymentRequirements;
253
+ export function buildForwarderNonce(
254
+ params: ForwarderSettleParams,
255
+ chainId: number,
256
+ forwarder: Address,
257
+ commitVersion: Hex,
258
+ ): Hex;
259
+ export function buildTypedDataFromPaymentRequirements(
260
+ rawAccept: unknown,
261
+ authorization: PaymentAuthorization,
262
+ ): {
263
+ accept: NormalizedPaymentRequirements;
264
+ params: ForwarderSettleParams;
265
+ typedData: PaymentTypedData;
266
+ };
267
+ export function createAuthorization(
268
+ from: Address,
269
+ maxTimeoutSeconds: number,
270
+ nowSec?: number,
271
+ ): PaymentAuthorization;
272
+ export function encodePaymentPayload(payload: PaymentPayload): string;
273
+ export function decodePaymentResponse(raw: string | null): unknown;
274
+ export function paymentPayloadFor(
275
+ accept: NormalizedPaymentRequirements,
276
+ authorization: PaymentAuthorization,
277
+ signature: Hex,
278
+ ): PaymentPayload;
279
+
280
+ export const JPYC_DECIMALS: 18;
281
+ export const DEFAULT_MAX_PER_CALL_JPYC: '10';
282
+ export const DEFAULT_MAX_SESSION_JPYC: '100';
283
+ export const DEFAULT_ALLOWED_HOSTS: 'open-pay.jp';
284
+ export const DEFAULT_CATALOG_TRUST: true;
285
+ export const DEFAULT_DISCOVERY_URL: 'https://open-pay.jp/api/discovery';
286
+ export const REASONS: {
287
+ invalidUrl: 'invalid_url';
288
+ hostNotAllowed: 'host_not_allowed';
289
+ unsupportedScheme: 'unsupported_scheme';
290
+ unsupportedNetwork: 'unsupported_network';
291
+ invalidOpenpayMode: 'invalid_openpay_mode';
292
+ amountMismatch: 'amount_mismatch';
293
+ invalidJpycAsset: 'invalid_jpyc_asset';
294
+ resourceMismatch: 'resource_mismatch';
295
+ invalidAccept: 'invalid_accept';
296
+ maxTotalRequired: 'max_total_required';
297
+ maxTotalInvalid: 'max_total_invalid';
298
+ totalExceedsMaxTotal: 'total_exceeds_max_total';
299
+ maxTotalAbovePerCallLimit: 'max_total_above_per_call_limit';
300
+ perCallLimitExceeded: 'per_call_limit_exceeded';
301
+ sessionLimitExceeded: 'session_limit_exceeded';
302
+ buyerPrivateKeyMissing: 'buyer_private_key_missing';
303
+ stewardSignerUnconfigured: 'steward_signer_unconfigured';
304
+ catalogAcceptMismatch: 'catalog_accept_mismatch';
305
+ };
306
+ export const SUPPORTED_NETWORKS: Set<string>;
307
+
308
+ export interface AcceptSummary {
309
+ priceAtomic: bigint;
310
+ feeAtomic: bigint;
311
+ totalAtomic: bigint;
312
+ priceJpyc: string;
313
+ feeJpyc: string;
314
+ totalJpyc: string;
315
+ network: string;
316
+ asset: Address;
317
+ description?: string;
318
+ }
319
+
320
+ export interface GuardResult {
321
+ ok: boolean;
322
+ reasons: string[];
323
+ accept: NormalizedPaymentRequirements | null;
324
+ summary: AcceptSummary | null;
325
+ }
326
+
327
+ export function parseJpycToAtomic(value: JpycAmount, label: string): bigint;
328
+ export function formatAtomicJpyc(value: bigint): string;
329
+ export function readMoneyConfig(
330
+ env?: Record<string, string | undefined>,
331
+ ): Omit<RuntimeConfig, 'discoveryUrl'>;
332
+ export function readRuntimeConfig(
333
+ env?: Record<string, string | undefined>,
334
+ ): RuntimeConfig;
335
+ export function parseClientOptions(
336
+ options?: OpenPayClientOptions,
337
+ ): RuntimeConfig;
338
+ export function createPaymentSession(initialSpentAtomic?: bigint): PaymentSession;
339
+ export function recordSuccessfulPayment(
340
+ session: PaymentSession,
341
+ amountAtomic: bigint,
342
+ ): bigint;
343
+ export function isHostAllowed(url: string, allowedHosts: string[]): boolean;
344
+ export function summarizeAccept(
345
+ accept: NormalizedPaymentRequirements,
346
+ ): AcceptSummary;
347
+ export function validateAcceptForPayment(
348
+ rawAccept: unknown,
349
+ requestUrl: string,
350
+ ): GuardResult;
351
+ export function evaluatePaymentGuards(options: {
352
+ url: string;
353
+ accept: unknown;
354
+ config: Omit<RuntimeConfig, 'discoveryUrl'> | RuntimeConfig;
355
+ sessionSpentAtomic?: bigint;
356
+ maxTotalJpyc?: JpycAmount;
357
+ requireMaxTotal?: boolean;
358
+ requirePrivateKey?: boolean;
359
+ requireSigner?: boolean;
360
+ signerAvailable?: boolean;
361
+ catalogListings?: Map<string, unknown> | null;
362
+ }): GuardResult;
363
+ export function redactSensitiveText(
364
+ text: unknown,
365
+ secrets?: Array<string | null | undefined>,
366
+ ): string;
367
+ export function safeErrorMessage(
368
+ error: unknown,
369
+ config?: Partial<RuntimeConfig>,
370
+ ): string;
371
+
372
+ export const SIGNER_MODES: {
373
+ envKey: 'env-key';
374
+ steward: 'steward';
375
+ };
376
+ export function readSignerMode(
377
+ env?: Record<string, string | undefined>,
378
+ ): 'env-key' | 'steward';
379
+ export function createSigner(
380
+ env?: Record<string, string | undefined>,
381
+ options?: { fetchImpl?: typeof globalThis.fetch },
382
+ ): PaymentSigner;
383
+ export type ParsedSignerOptions =
384
+ | { kind: 'none' }
385
+ | { kind: 'private-key'; privateKey: string }
386
+ | { kind: 'steward'; config: StewardOptions & { agentAddress: Address } }
387
+ | { kind: 'custom'; signer: PaymentSigner; address: Address };
388
+ export function parseSignerOptions(
389
+ options?: OpenPayClientOptions,
390
+ ): ParsedSignerOptions;
391
+ export function createSignerFromOptions(
392
+ options?: OpenPayClientOptions,
393
+ runtime?: { fetchImpl?: typeof globalThis.fetch },
394
+ ): PaymentSigner | null;
395
+
396
+ export const CATALOG_CACHE_MS: number;
397
+ export interface CatalogCache {
398
+ listings: Map<string, unknown> | null;
399
+ cachedAt: number;
400
+ }
401
+ export function createCatalogCache(): CatalogCache;
402
+ export function resolveCatalogListings(options: {
403
+ config: Pick<RuntimeConfig, 'catalogTrust' | 'discoveryUrl'>;
404
+ fetchImpl?: typeof globalThis.fetch;
405
+ now?: () => number;
406
+ cache?: CatalogCache;
407
+ }): Promise<Map<string, unknown> | null>;
408
+ export function createCatalogResolver(options: {
409
+ config: Pick<RuntimeConfig, 'catalogTrust' | 'discoveryUrl'>;
410
+ fetchImpl?: typeof globalThis.fetch;
411
+ now?: () => number;
412
+ }): () => Promise<Map<string, unknown> | null>;
413
+
414
+ export interface PaymentExecutor {
415
+ quote(url: string): Promise<QuoteResult>;
416
+ pay(
417
+ url: string,
418
+ options: { maxTotalJpyc: JpycAmount },
419
+ ): Promise<QuoteResult | PaymentResult>;
420
+ }
421
+ export function createPaymentExecutor(options: {
422
+ config: RuntimeConfig;
423
+ session: PaymentSession;
424
+ signer?: PaymentSigner | null;
425
+ fetchImpl?: typeof globalThis.fetch;
426
+ nowSec?: () => number;
427
+ resolveCatalogListings?: () => Promise<Map<string, unknown> | null>;
428
+ }): PaymentExecutor;
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "openpay-x402-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "types": "./index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./src/index.mjs"
12
+ }
13
+ },
14
+ "files": [
15
+ "src",
16
+ "index.d.ts",
17
+ "README.md",
18
+ "CHANGELOG.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "dependencies": {
24
+ "viem": "^2.45.0"
25
+ }
26
+ }
@@ -0,0 +1,70 @@
1
+ export const CATALOG_CACHE_MS = 5 * 60_000;
2
+
3
+ function isObject(value) {
4
+ return typeof value === 'object' && value !== null;
5
+ }
6
+
7
+ async function readJson(response) {
8
+ const text = await response.text();
9
+ if (text.length === 0) return null;
10
+ try {
11
+ return JSON.parse(text);
12
+ } catch {
13
+ return text;
14
+ }
15
+ }
16
+
17
+ export function createCatalogCache() {
18
+ return { listings: null, cachedAt: 0 };
19
+ }
20
+
21
+ export async function resolveCatalogListings({
22
+ config,
23
+ fetchImpl = fetch,
24
+ now = Date.now,
25
+ cache = createCatalogCache(),
26
+ }) {
27
+ if (!config.catalogTrust) return null;
28
+ if (
29
+ cache.listings !== null &&
30
+ now() - cache.cachedAt < CATALOG_CACHE_MS
31
+ ) {
32
+ return cache.listings;
33
+ }
34
+
35
+ try {
36
+ const response = await fetchImpl(config.discoveryUrl, {
37
+ headers: { accept: 'application/json' },
38
+ });
39
+ const body = await readJson(response);
40
+ if (!response.ok || !isObject(body) || !Array.isArray(body.items)) {
41
+ return null;
42
+ }
43
+ const listings = new Map();
44
+ for (const item of body.items) {
45
+ if (
46
+ isObject(item) &&
47
+ typeof item.resource === 'string' &&
48
+ Array.isArray(item.accepts) &&
49
+ item.accepts.length > 0
50
+ ) {
51
+ try {
52
+ listings.set(new URL(item.resource).toString(), item.accepts[0]);
53
+ } catch {
54
+ // One malformed catalog entry must not hide otherwise valid listings.
55
+ }
56
+ }
57
+ }
58
+ cache.listings = listings;
59
+ cache.cachedAt = now();
60
+ return listings;
61
+ } catch {
62
+ // A discovery outage must not expand trust beyond the explicit host allowlist.
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export function createCatalogResolver({ config, fetchImpl = fetch, now = Date.now }) {
68
+ const cache = createCatalogCache();
69
+ return () => resolveCatalogListings({ config, fetchImpl, now, cache });
70
+ }