openpay-x402-sdk 0.3.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Add an opt-in persistent daily buyer limit with UTC signer/date keys, file and
6
+ injectable spend stores, quote-time visibility, and fail-closed reads.
7
+ - Record daily spend only after successful 2xx unlocks while isolating store
8
+ write failures from already completed payment responses.
9
+
3
10
  ## 0.3.0
4
11
 
5
12
  - Add `createJpycGate` for seller-side x402 gates backed by the OpenPay catalog,
package/README.md CHANGED
@@ -16,6 +16,7 @@ const client = createOpenPayClient({
16
16
  privateKey: process.env.BUYER_PRIVATE_KEY,
17
17
  maxPerCallJpyc: '10',
18
18
  maxSessionJpyc: '100',
19
+ maxDailyJpyc: '250',
19
20
  allowedHosts: 'open-pay.jp',
20
21
  });
21
22
 
@@ -99,6 +100,7 @@ gate; `createJpycGate` is its importable SDK counterpart with split settlement.
99
100
  |---|---:|---|
100
101
  | `maxPerCallJpyc` | `10` | Upper bound for the caller-provided `maxTotalJpyc`. |
101
102
  | `maxSessionJpyc` | `100` | Cumulative cap for successful payments made by this client instance. |
103
+ | `maxDailyJpyc` | Not set | Persistent cumulative cap per signer and UTC calendar date. |
102
104
  | `allowedHosts` | `open-pay.jp` | Comma-separated bare host allowlist. |
103
105
  | `catalogTrust` | `true` | Also allows catalog URLs after the live challenge matches the catalog challenge. |
104
106
  | `discoveryUrl` | `https://open-pay.jp/api/discovery` | Catalog and OpenPay origin used by the client. |
@@ -109,7 +111,21 @@ money-field verification. Exact query-bearing catalog entries remain exact-only.
109
111
  `pay(url, { maxTotalJpyc })` always requires `maxTotalJpyc`. It is the maximum
110
112
  total—including the resource price and x402 fee—that this individual call is
111
113
  authorized to pay. It does not disable or raise `maxPerCallJpyc` or
112
- `maxSessionJpyc`; all three limits must allow the payment.
114
+ `maxSessionJpyc`; every configured limit must allow the payment.
115
+
116
+ `maxDailyJpyc` is opt-in. When set, the client stores successful 2xx unlocks in
117
+ `~/.openpay-x402/spend.json`, keyed by the lower-cased signer address and UTC
118
+ date. A missing entry starts at zero. A corrupt/unreadable store or a custom
119
+ store returning `null` rejects quotes and payments with `daily_spend_unavailable`
120
+ (fail-closed). Use `spendStore` to inject another implementation of
121
+ `{ load(key), save(key, atomicString) }`; `MAX_DAILY_JPYC` is the equivalent
122
+ optional setting for the exported environment config readers.
123
+
124
+ The file store uses best-effort read-modify-write across processes: payments are
125
+ serialized within one client process, but separate processes can race and lose
126
+ an increment. Use an atomic shared store when multiple processes share a signer.
127
+ Persistence runs only after a successful unlock; a save failure cannot change an
128
+ already completed payment response.
113
129
 
114
130
  The client also rejects non-JPYC metadata, unsupported networks or schemes,
115
131
  non-OpenPay forwarder splits, amount inconsistencies, resource URL mismatches,
package/index.d.ts CHANGED
@@ -37,13 +37,41 @@ export interface PaymentSigner {
37
37
  signTypedData(typedData: PaymentTypedData): Hex | Promise<Hex>;
38
38
  }
39
39
 
40
+ export interface SpendStore {
41
+ /**
42
+ * Return the atomic JPYC spent for the key, `'0'` when no record exists, or
43
+ * `null` only on read failure. `null` fails closed (payments are refused),
44
+ * so an absent entry must be reported as `'0'`, never `null`.
45
+ */
46
+ load(key: string): Promise<string | null>;
47
+ /** Persist the new cumulative atomic amount. Failures must not throw. */
48
+ save(key: string, atomicString: string): Promise<void>;
49
+ }
50
+
51
+ export interface FileSpendStoreOptions {
52
+ path?: string;
53
+ fsImpl?: {
54
+ readFile(path: string, encoding: 'utf8'): Promise<string>;
55
+ mkdir(path: string, options: { recursive: true }): Promise<unknown>;
56
+ writeFile(
57
+ path: string,
58
+ data: string,
59
+ encoding: 'utf8',
60
+ ): Promise<unknown>;
61
+ };
62
+ }
63
+
40
64
  interface ClientCommonOptions {
41
65
  maxPerCallJpyc?: JpycAmount;
42
66
  maxSessionJpyc?: JpycAmount;
67
+ maxDailyJpyc?: JpycAmount;
68
+ spendStore?: SpendStore;
43
69
  allowedHosts?: string;
44
70
  catalogTrust?: boolean;
45
71
  discoveryUrl?: string;
46
72
  fetchImpl?: typeof globalThis.fetch;
73
+ nowSec?: () => number;
74
+ now?: () => Date | number;
47
75
  }
48
76
 
49
77
  type NoSignerOptions = {
@@ -85,6 +113,7 @@ export interface RuntimeConfig {
85
113
  stewardSignerSecret: string | null;
86
114
  maxPerCallAtomic: bigint;
87
115
  maxSessionAtomic: bigint;
116
+ maxDailyAtomic: bigint | null;
88
117
  allowedHosts: string[];
89
118
  catalogTrust: boolean;
90
119
  discoveryUrl: string;
@@ -321,6 +350,8 @@ export const REASONS: {
321
350
  maxTotalAbovePerCallLimit: 'max_total_above_per_call_limit';
322
351
  perCallLimitExceeded: 'per_call_limit_exceeded';
323
352
  sessionLimitExceeded: 'session_limit_exceeded';
353
+ dailyLimitExceeded: 'daily_limit_exceeded';
354
+ dailySpendUnavailable: 'daily_spend_unavailable';
324
355
  buyerPrivateKeyMissing: 'buyer_private_key_missing';
325
356
  stewardSignerUnconfigured: 'steward_signer_unconfigured';
326
357
  catalogAcceptMismatch: 'catalog_accept_mismatch';
@@ -358,6 +389,9 @@ export function parseClientOptions(
358
389
  options?: OpenPayClientOptions,
359
390
  ): RuntimeConfig;
360
391
  export function createPaymentSession(initialSpentAtomic?: bigint): PaymentSession;
392
+ export function createFileSpendStore(
393
+ options?: FileSpendStoreOptions,
394
+ ): SpendStore;
361
395
  export function recordSuccessfulPayment(
362
396
  session: PaymentSession,
363
397
  amountAtomic: bigint,
@@ -375,6 +409,7 @@ export function evaluatePaymentGuards(options: {
375
409
  accept: unknown;
376
410
  config: Omit<RuntimeConfig, 'discoveryUrl'> | RuntimeConfig;
377
411
  sessionSpentAtomic?: bigint;
412
+ dailySpentAtomic?: bigint | null;
378
413
  maxTotalJpyc?: JpycAmount;
379
414
  requireMaxTotal?: boolean;
380
415
  requirePrivateKey?: boolean;
@@ -444,7 +479,10 @@ export function createPaymentExecutor(options: {
444
479
  config: RuntimeConfig;
445
480
  session: PaymentSession;
446
481
  signer?: PaymentSigner | null;
482
+ signerAddress?: Address | null;
483
+ spendStore?: SpendStore | null;
447
484
  fetchImpl?: typeof globalThis.fetch;
448
485
  nowSec?: () => number;
486
+ now?: () => Date | number;
449
487
  resolveCatalogListings?: () => Promise<Map<string, unknown> | null>;
450
488
  }): PaymentExecutor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/client.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  safeErrorMessage,
8
8
  } from './guards.mjs';
9
9
  import { createSignerFromOptions } from './signer.mjs';
10
+ import { createFileSpendStore } from './spendStore.mjs';
10
11
 
11
12
  function isObject(value) {
12
13
  return typeof value === 'object' && value !== null;
@@ -63,13 +64,20 @@ export function createOpenPayClient(options = {}) {
63
64
  }
64
65
  const signer = createSignerFromOptions(options, { fetchImpl });
65
66
  const session = createPaymentSession();
67
+ const spendStore =
68
+ config.maxDailyAtomic === null
69
+ ? null
70
+ : options.spendStore ?? createFileSpendStore();
66
71
  const resolveCatalogListings = createCatalogResolver({ config, fetchImpl });
67
72
  const executor = createPaymentExecutor({
68
73
  config,
69
74
  session,
70
75
  signer,
76
+ signerAddress: signer?.address ?? null,
77
+ spendStore,
71
78
  fetchImpl,
72
79
  nowSec: options.nowSec,
80
+ now: options.now,
73
81
  resolveCatalogListings,
74
82
  });
75
83
 
package/src/executor.mjs CHANGED
@@ -55,10 +55,54 @@ export function createPaymentExecutor({
55
55
  config,
56
56
  session,
57
57
  signer = null,
58
+ signerAddress = signer?.address ?? null,
59
+ spendStore = null,
58
60
  fetchImpl = fetch,
59
61
  nowSec = () => Math.floor(Date.now() / 1000),
62
+ now = () => new Date(),
60
63
  resolveCatalogListings = async () => null,
61
64
  }) {
65
+ const dailyLimitEnabled =
66
+ config.maxDailyAtomic !== null &&
67
+ config.maxDailyAtomic !== undefined &&
68
+ spendStore !== null;
69
+ const guardConfig =
70
+ !dailyLimitEnabled && config.maxDailyAtomic != null
71
+ ? { ...config, maxDailyAtomic: null }
72
+ : config;
73
+
74
+ async function loadDailySpend() {
75
+ if (!dailyLimitEnabled || typeof signerAddress !== 'string') {
76
+ return { key: null, spentAtomic: null };
77
+ }
78
+ const current = now();
79
+ const date = (current instanceof Date ? current : new Date(current))
80
+ .toISOString()
81
+ .slice(0, 10);
82
+ const key = `${signerAddress.toLowerCase()}:${date}`;
83
+ try {
84
+ const stored = await spendStore.load(key);
85
+ if (typeof stored !== 'string' || !/^[0-9]+$/.test(stored)) {
86
+ return { key, spentAtomic: null };
87
+ }
88
+ return { key, spentAtomic: BigInt(stored) };
89
+ } catch {
90
+ return { key, spentAtomic: null };
91
+ }
92
+ }
93
+
94
+ async function saveDailySpend(dailySpend, amountAtomic) {
95
+ if (dailySpend.key === null || dailySpend.spentAtomic === null) return;
96
+ try {
97
+ await spendStore.save(
98
+ dailySpend.key,
99
+ (dailySpend.spentAtomic + amountAtomic).toString(),
100
+ );
101
+ } catch {
102
+ // The unlock already succeeded; store failure must not replace the payment response.
103
+ }
104
+ }
105
+
62
106
  async function quoteImpl(url) {
63
107
  if (typeof url !== 'string') throw new Error('url is required');
64
108
  const response = await fetchImpl(url, {
@@ -74,11 +118,13 @@ export function createPaymentExecutor({
74
118
  reasons: ['expected_402_with_accepts'],
75
119
  };
76
120
  }
121
+ const dailySpend = dailyLimitEnabled ? await loadDailySpend() : null;
77
122
  const guard = evaluatePaymentGuards({
78
123
  url,
79
124
  accept,
80
- config,
125
+ config: guardConfig,
81
126
  sessionSpentAtomic: session.spentAtomic,
127
+ dailySpentAtomic: dailySpend?.spentAtomic ?? null,
82
128
  catalogListings: await resolveCatalogListings(),
83
129
  });
84
130
  return quoteShape(url, response.status, guard);
@@ -107,11 +153,13 @@ export function createPaymentExecutor({
107
153
  reasons: ['expected_402_with_accepts'],
108
154
  };
109
155
  }
156
+ const dailySpend = dailyLimitEnabled ? await loadDailySpend() : null;
110
157
  const guard = evaluatePaymentGuards({
111
158
  url,
112
159
  accept,
113
- config,
160
+ config: guardConfig,
114
161
  sessionSpentAtomic: session.spentAtomic,
162
+ dailySpentAtomic: dailySpend?.spentAtomic ?? null,
115
163
  maxTotalJpyc,
116
164
  requireMaxTotal: true,
117
165
  requireSigner: true,
@@ -142,6 +190,9 @@ export function createPaymentExecutor({
142
190
  const unlockedBody = await readJson(unlocked);
143
191
  if (unlocked.status >= 200 && unlocked.status < 300) {
144
192
  recordSuccessfulPayment(session, guard.summary.totalAtomic);
193
+ if (dailySpend !== null) {
194
+ await saveDailySpend(dailySpend, guard.summary.totalAtomic);
195
+ }
145
196
  }
146
197
  const receipt = decodePaymentResponse(
147
198
  unlocked.headers.get('x-payment-response'),
package/src/guards.mjs CHANGED
@@ -33,6 +33,8 @@ export const REASONS = {
33
33
  maxTotalAbovePerCallLimit: 'max_total_above_per_call_limit',
34
34
  perCallLimitExceeded: 'per_call_limit_exceeded',
35
35
  sessionLimitExceeded: 'session_limit_exceeded',
36
+ dailyLimitExceeded: 'daily_limit_exceeded',
37
+ dailySpendUnavailable: 'daily_spend_unavailable',
36
38
  buyerPrivateKeyMissing: 'buyer_private_key_missing',
37
39
  stewardSignerUnconfigured: 'steward_signer_unconfigured',
38
40
  // catalog trust 経由 (第三者ドメイン) の URL で、支払い時にライブ fetch した accept が
@@ -139,6 +141,10 @@ export function readMoneyConfig(env = process.env) {
139
141
  nonEmpty(env.MAX_SESSION_JPYC) ?? DEFAULT_MAX_SESSION_JPYC,
140
142
  'MAX_SESSION_JPYC',
141
143
  ),
144
+ maxDailyAtomic:
145
+ nonEmpty(env.MAX_DAILY_JPYC) === undefined
146
+ ? null
147
+ : parseJpycToAtomic(env.MAX_DAILY_JPYC, 'MAX_DAILY_JPYC'),
142
148
  allowedHosts: parseAllowedHosts(env.ALLOWED_HOSTS),
143
149
  catalogTrust:
144
150
  env.CATALOG_TRUST === undefined || env.CATALOG_TRUST === ''
@@ -198,6 +204,10 @@ export function parseClientOptions(options = {}) {
198
204
  optionAmount(options.maxSessionJpyc, DEFAULT_MAX_SESSION_JPYC),
199
205
  'MAX_SESSION_JPYC',
200
206
  ),
207
+ maxDailyAtomic:
208
+ options.maxDailyJpyc === undefined || options.maxDailyJpyc === ''
209
+ ? null
210
+ : parseJpycToAtomic(options.maxDailyJpyc, 'MAX_DAILY_JPYC'),
201
211
  allowedHosts: parseAllowedHosts(options.allowedHosts),
202
212
  catalogTrust: options.catalogTrust ?? DEFAULT_CATALOG_TRUST,
203
213
  discoveryUrl: requireHttpUrl(
@@ -368,6 +378,7 @@ export function evaluatePaymentGuards({
368
378
  accept,
369
379
  config,
370
380
  sessionSpentAtomic = 0n,
381
+ dailySpentAtomic = null,
371
382
  maxTotalJpyc,
372
383
  requireMaxTotal = false,
373
384
  requirePrivateKey = false,
@@ -378,6 +389,7 @@ export function evaluatePaymentGuards({
378
389
  catalogListings = null,
379
390
  }) {
380
391
  const reasons = [];
392
+ const maxDailyAtomic = config.maxDailyAtomic ?? null;
381
393
  const parsedUrl = parseHttpUrl(url, 'url');
382
394
  if (parsedUrl === null) {
383
395
  reasons.push(REASONS.invalidUrl);
@@ -430,6 +442,17 @@ export function evaluatePaymentGuards({
430
442
  if (sessionSpentAtomic + total > config.maxSessionAtomic) {
431
443
  reasons.push(REASONS.sessionLimitExceeded);
432
444
  }
445
+ if (
446
+ maxDailyAtomic !== null &&
447
+ dailySpentAtomic !== null &&
448
+ dailySpentAtomic + total > maxDailyAtomic
449
+ ) {
450
+ reasons.push(REASONS.dailyLimitExceeded);
451
+ }
452
+ }
453
+
454
+ if (maxDailyAtomic !== null && dailySpentAtomic === null) {
455
+ reasons.push(REASONS.dailySpendUnavailable);
433
456
  }
434
457
 
435
458
  if (requirePrivateKey || requireSigner) {
package/src/index.mjs CHANGED
@@ -5,3 +5,4 @@ export * from './gate.mjs';
5
5
  export * from './guards.mjs';
6
6
  export * from './payment.mjs';
7
7
  export * from './signer.mjs';
8
+ export * from './spendStore.mjs';
@@ -0,0 +1,86 @@
1
+ function isObject(value) {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ }
4
+
5
+ function isMissingFile(error) {
6
+ return isObject(error) && error.code === 'ENOENT';
7
+ }
8
+
9
+ function isAtomicString(value) {
10
+ return typeof value === 'string' && /^[0-9]+$/.test(value);
11
+ }
12
+
13
+ export function createFileSpendStore({ path, fsImpl } = {}) {
14
+ let runtimePromise;
15
+
16
+ async function resolveRuntime() {
17
+ runtimePromise ??= (async () => {
18
+ const fileSystem = fsImpl ?? (await import('node:fs/promises'));
19
+ const pathModule = await import('node:path');
20
+ const targetPath =
21
+ path ??
22
+ pathModule.join(
23
+ (await import('node:os')).homedir(),
24
+ '.openpay-x402',
25
+ 'spend.json',
26
+ );
27
+ return {
28
+ fileSystem,
29
+ targetPath,
30
+ directory: pathModule.dirname(targetPath),
31
+ };
32
+ })();
33
+ return runtimePromise;
34
+ }
35
+
36
+ async function load(key) {
37
+ try {
38
+ const { fileSystem, targetPath } = await resolveRuntime();
39
+ let raw;
40
+ try {
41
+ raw = await fileSystem.readFile(targetPath, 'utf8');
42
+ } catch (error) {
43
+ return isMissingFile(error) ? '0' : null;
44
+ }
45
+ const document = JSON.parse(raw);
46
+ if (!isObject(document)) return null;
47
+ const value = document[key];
48
+ return value === undefined ? '0' : isAtomicString(value) ? value : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ async function save(key, atomicString) {
55
+ try {
56
+ const { fileSystem, targetPath, directory } = await resolveRuntime();
57
+ let document = {};
58
+ try {
59
+ const raw = await fileSystem.readFile(targetPath, 'utf8');
60
+ const parsed = JSON.parse(raw);
61
+ if (isObject(parsed)) document = parsed;
62
+ } catch {
63
+ document = {};
64
+ }
65
+
66
+ const date = key.slice(-10);
67
+ const current = Object.fromEntries(
68
+ Object.entries(document).filter(
69
+ ([storedKey, value]) =>
70
+ storedKey.endsWith(`:${date}`) && isAtomicString(value),
71
+ ),
72
+ );
73
+ current[key] = atomicString;
74
+ await fileSystem.mkdir(directory, { recursive: true });
75
+ await fileSystem.writeFile(
76
+ targetPath,
77
+ `${JSON.stringify(current, null, 2)}\n`,
78
+ 'utf8',
79
+ );
80
+ } catch {
81
+ // The payment is already complete; persistence failure must not replace its response.
82
+ }
83
+ }
84
+
85
+ return { load, save };
86
+ }