openpay-x402-sdk 0.4.0 → 0.5.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/src/gate.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
2
2
  const ACCEPTS_CACHE_MS = 5 * 60_000;
3
+ const DEFAULT_MAX_UPSTREAM_SECONDS = 60;
4
+ const DEFAULT_SETTLEMENT_GRACE_SECONDS = 30;
3
5
 
4
6
  function json402(accepts, error) {
5
7
  return new Response(JSON.stringify({ x402Version: 1, accepts, error }), {
@@ -21,15 +23,134 @@ function encodeBase64Json(value) {
21
23
  return btoa(binary);
22
24
  }
23
25
 
26
+ function isObject(value) {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
28
+ }
29
+
30
+ function canonicalAddress(value) {
31
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value)
32
+ ? value.toLowerCase()
33
+ : null;
34
+ }
35
+
36
+ function canonicalBytes32(value) {
37
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value)
38
+ ? value.toLowerCase()
39
+ : null;
40
+ }
41
+
42
+ function canonicalDecimal(value) {
43
+ return typeof value === 'string' && /^[0-9]+$/.test(value)
44
+ ? BigInt(value).toString()
45
+ : null;
46
+ }
47
+
48
+ function canonicalNetwork(value) {
49
+ if (typeof value !== 'string') return null;
50
+ const match = /^eip155:([0-9]+)$/.exec(value);
51
+ return match ? `eip155:${BigInt(match[1]).toString()}` : null;
52
+ }
53
+
54
+ function authorizationClaim(paymentPayload, paymentRequirements) {
55
+ if (!isObject(paymentPayload) || !isObject(paymentRequirements)) return null;
56
+ const inner = paymentPayload.payload;
57
+ const extra = paymentRequirements.extra;
58
+ if (!isObject(inner) || !isObject(extra)) return null;
59
+ const authorization = inner.authorization;
60
+ const openpay = extra.openpay;
61
+ if (!isObject(authorization) || !isObject(openpay)) return null;
62
+
63
+ const network = canonicalNetwork(paymentPayload.network);
64
+ const asset = canonicalAddress(paymentRequirements.asset);
65
+ const from = canonicalAddress(authorization.from);
66
+ const forwarder = canonicalAddress(openpay.forwarder);
67
+ const merchant = canonicalAddress(openpay.merchant);
68
+ const merchantValue = canonicalDecimal(openpay.merchantValue);
69
+ const feeReceiver = canonicalAddress(openpay.feeReceiver);
70
+ const feeValue = canonicalDecimal(openpay.feeValue);
71
+ const validAfter = canonicalDecimal(authorization.validAfter);
72
+ const validBefore = canonicalDecimal(authorization.validBefore);
73
+ const intentSalt = canonicalBytes32(authorization.intentSalt);
74
+ const parts = [
75
+ network,
76
+ asset,
77
+ from,
78
+ forwarder,
79
+ merchant,
80
+ merchantValue,
81
+ feeReceiver,
82
+ feeValue,
83
+ validAfter,
84
+ validBefore,
85
+ intentSalt,
86
+ ];
87
+ if (parts.some((part) => part === null)) return null;
88
+ return {
89
+ key: JSON.stringify(parts),
90
+ validBefore: BigInt(validBefore),
91
+ };
92
+ }
93
+
24
94
  export function createJpycGate({
25
95
  resourceUrl,
26
96
  openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
27
97
  fetchImpl = globalThis.fetch,
28
98
  now = Date.now,
99
+ maxUpstreamSeconds = DEFAULT_MAX_UPSTREAM_SECONDS,
100
+ settlementGraceSeconds = DEFAULT_SETTLEMENT_GRACE_SECONDS,
29
101
  }) {
102
+ if (!Number.isSafeInteger(maxUpstreamSeconds) || maxUpstreamSeconds < 0) {
103
+ throw new Error('maxUpstreamSeconds must be a non-negative integer');
104
+ }
105
+ if (
106
+ !Number.isSafeInteger(settlementGraceSeconds) ||
107
+ settlementGraceSeconds <= 0
108
+ ) {
109
+ throw new Error('settlementGraceSeconds must be a positive integer');
110
+ }
111
+ if (!Number.isSafeInteger(maxUpstreamSeconds + settlementGraceSeconds)) {
112
+ throw new Error('reservation validity window must be a safe integer');
113
+ }
30
114
  const origin = openpayOrigin.replace(/\/+$/, '');
31
115
  let acceptsCache = null;
32
116
  let acceptsCachedAt = 0;
117
+ const authorizationClaims = new Map();
118
+
119
+ function claimAuthorization(paymentPayload, paymentRequirements) {
120
+ const claim = authorizationClaim(paymentPayload, paymentRequirements);
121
+ if (claim === null) return { ok: true, claim: null };
122
+
123
+ const nowSec = BigInt(Math.ceil(now() / 1000));
124
+ for (const [key, existing] of authorizationClaims) {
125
+ if (existing.validBefore <= nowSec) authorizationClaims.delete(key);
126
+ }
127
+
128
+ const requiredValidity = BigInt(
129
+ maxUpstreamSeconds + settlementGraceSeconds,
130
+ );
131
+ if (claim.validBefore - nowSec < requiredValidity) {
132
+ return { ok: false, reason: 'insufficient_validity_window' };
133
+ }
134
+ if (authorizationClaims.has(claim.key)) {
135
+ return { ok: false, reason: 'authorization_reserved' };
136
+ }
137
+
138
+ const owner = Symbol();
139
+ authorizationClaims.set(claim.key, {
140
+ owner,
141
+ validBefore: claim.validBefore,
142
+ });
143
+ return { ok: true, claim: { key: claim.key, owner } };
144
+ }
145
+
146
+ function releaseAuthorization(claim) {
147
+ if (
148
+ claim !== null &&
149
+ authorizationClaims.get(claim.key)?.owner === claim.owner
150
+ ) {
151
+ authorizationClaims.delete(claim.key);
152
+ }
153
+ }
33
154
 
34
155
  async function catalogAccepts() {
35
156
  if (
@@ -50,7 +171,12 @@ export function createJpycGate({
50
171
  return acceptsCache;
51
172
  }
52
173
 
53
- async function facilitator(path, paymentPayload, paymentRequirements) {
174
+ async function facilitator(
175
+ path,
176
+ paymentPayload,
177
+ paymentRequirements,
178
+ reservationToken,
179
+ ) {
54
180
  const response = await fetchImpl(`${origin}/api/facilitator/${path}`, {
55
181
  method: 'POST',
56
182
  headers: { 'content-type': 'application/json' },
@@ -58,6 +184,7 @@ export function createJpycGate({
58
184
  x402Version: 1,
59
185
  paymentPayload,
60
186
  paymentRequirements,
187
+ ...(reservationToken === undefined ? {} : { reservationToken }),
61
188
  }),
62
189
  });
63
190
  return response.json();
@@ -79,17 +206,34 @@ export function createJpycGate({
79
206
  }
80
207
 
81
208
  const paymentRequirements = accepts[0];
82
- const verification = await facilitator(
83
- 'verify',
84
- paymentPayload,
85
- paymentRequirements,
86
- );
209
+ const claimed = claimAuthorization(paymentPayload, paymentRequirements);
210
+ if (!claimed.ok) return json402(accepts, claimed.reason);
211
+
212
+ let verification;
213
+ try {
214
+ verification = await facilitator(
215
+ 'verify',
216
+ paymentPayload,
217
+ paymentRequirements,
218
+ );
219
+ } catch (error) {
220
+ releaseAuthorization(claimed.claim);
221
+ throw error;
222
+ }
87
223
  if (verification.isValid !== true) {
224
+ releaseAuthorization(claimed.claim);
88
225
  return json402(
89
226
  accepts,
90
227
  verification.invalidReason || 'payment_invalid',
91
228
  );
92
229
  }
230
+ // 検証成功後は settle の結果にかかわらず期限まで claim を残し、結果不明や再試行が
231
+ // 同じ authorization で別の upstream 実行へ波及するのを断つ。
232
+ const reservationToken =
233
+ typeof verification.reservationToken === 'string' &&
234
+ verification.reservationToken.length > 0
235
+ ? verification.reservationToken
236
+ : undefined;
93
237
 
94
238
  return {
95
239
  async settle() {
@@ -97,6 +241,7 @@ export function createJpycGate({
97
241
  'settle',
98
242
  paymentPayload,
99
243
  paymentRequirements,
244
+ reservationToken,
100
245
  );
101
246
  if (settlement.success !== true) {
102
247
  return json402(
package/src/guards.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  import { formatUnits, isHex } from 'viem';
2
- import { normalizePaymentRequirements } from './payment.mjs';
2
+ import {
3
+ MAX_AUTHORIZATION_TIMEOUT_SECONDS,
4
+ normalizePaymentRequirements,
5
+ } from './payment.mjs';
3
6
  import {
4
7
  parseSignerOptions,
5
8
  readSignerMode,
@@ -9,6 +12,10 @@ import {
9
12
  export const JPYC_DECIMALS = 18;
10
13
  export const DEFAULT_MAX_PER_CALL_JPYC = '10';
11
14
  export const DEFAULT_MAX_SESSION_JPYC = '100';
15
+ export const DEFAULT_MAX_TIMEOUT_SECONDS = 600;
16
+ // OpenPay facilitator の有効期限上限 (20分) より長い authorization を設定で
17
+ // 許可すると、売り手が facilitator を迂回して後日直接 settle できる波及を残す。
18
+ export const MAX_SUPPORTED_TIMEOUT_SECONDS = MAX_AUTHORIZATION_TIMEOUT_SECONDS;
12
19
  export const DEFAULT_ALLOWED_HOSTS = 'open-pay.jp';
13
20
  // カタログ信頼 (既定 ON): OpenPay の審査済みカタログ (/api/discovery) に載っている URL への
14
21
  // 支払いを、ALLOWED_HOSTS への手動追加なしで許可する。掲載は 402 ゲート実在 + OpenPay 方式
@@ -23,8 +30,10 @@ export const REASONS = {
23
30
  unsupportedScheme: 'unsupported_scheme',
24
31
  unsupportedNetwork: 'unsupported_network',
25
32
  invalidOpenpayMode: 'invalid_openpay_mode',
33
+ invalidOpenpayForwarder: 'invalid_openpay_forwarder',
26
34
  amountMismatch: 'amount_mismatch',
27
35
  invalidJpycAsset: 'invalid_jpyc_asset',
36
+ timeoutTooLong: 'timeout_too_long',
28
37
  resourceMismatch: 'resource_mismatch',
29
38
  invalidAccept: 'invalid_accept',
30
39
  maxTotalRequired: 'max_total_required',
@@ -35,6 +44,7 @@ export const REASONS = {
35
44
  sessionLimitExceeded: 'session_limit_exceeded',
36
45
  dailyLimitExceeded: 'daily_limit_exceeded',
37
46
  dailySpendUnavailable: 'daily_spend_unavailable',
47
+ dailyAuthorizationCrossesUtcDay: 'daily_authorization_crosses_utc_day',
38
48
  buyerPrivateKeyMissing: 'buyer_private_key_missing',
39
49
  stewardSignerUnconfigured: 'steward_signer_unconfigured',
40
50
  // catalog trust 経由 (第三者ドメイン) の URL で、支払い時にライブ fetch した accept が
@@ -42,14 +52,35 @@ export const REASONS = {
42
52
  catalogAcceptMismatch: 'catalog_accept_mismatch',
43
53
  };
44
54
 
45
- export const SUPPORTED_NETWORKS = new Set([
46
- 'eip155:137',
47
- 'eip155:80002',
48
- 'eip155:8217',
49
- 'eip155:1001',
50
- 'eip155:43114',
51
- 'eip155:43113',
52
- ]);
55
+ const JPYC_V3_ADDRESS = '0xE7C3D8C9a439feDe00D2600032D5dB0Be71C3c29';
56
+ const JPYC_V3_DEFINITION = Object.freeze({
57
+ address: JPYC_V3_ADDRESS,
58
+ name: 'JPY Coin',
59
+ version: '1',
60
+ decimals: JPYC_DECIMALS,
61
+ });
62
+
63
+ // Buyer が署名してよい JPYC v3 domain の単一情報源。asset と自己申告 metadata を
64
+ // network ごとの既知値へ束縛し、allowlist 済み売り手が別 token の署名を得る波及を断つ。
65
+ export const SUPPORTED_JPYC_ASSETS = Object.freeze({
66
+ 'eip155:137': JPYC_V3_DEFINITION,
67
+ 'eip155:80002': JPYC_V3_DEFINITION,
68
+ 'eip155:8217': JPYC_V3_DEFINITION,
69
+ 'eip155:1001': JPYC_V3_DEFINITION,
70
+ 'eip155:43114': JPYC_V3_DEFINITION,
71
+ 'eip155:43113': JPYC_V3_DEFINITION,
72
+ });
73
+
74
+ export const SUPPORTED_NETWORKS = new Set(Object.keys(SUPPORTED_JPYC_ASSETS));
75
+
76
+ // OpenPay が deploy 済みとして公開している Eip3009Forwarder。未掲載 chain の
77
+ // forwarder は審査済み catalog accept との完全一致が取れた場合だけ許可する。
78
+ // ホスト allowlist だけで署名の `to` を攻撃者 EOA へ差し替え、表示した分配を
79
+ // 迂回して総額を直接受領する波及を断つ。
80
+ export const SUPPORTED_JPYC_FORWARDERS = Object.freeze({
81
+ 'eip155:137': '0x0F4560a777415580F0680F8B56a79B0022C6B848',
82
+ 'eip155:80002': '0x752B7AaD0089286EB7b553d84D05233d80c9FCB4',
83
+ });
53
84
 
54
85
  function nonEmpty(raw) {
55
86
  return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
@@ -63,6 +94,21 @@ function isObject(value) {
63
94
  return typeof value === 'object' && value !== null;
64
95
  }
65
96
 
97
+ function parseMaxTimeoutSeconds(value, label) {
98
+ const parsed =
99
+ typeof value === 'string' && /^[0-9]+$/.test(value) ? Number(value) : value;
100
+ if (
101
+ !Number.isSafeInteger(parsed) ||
102
+ parsed <= 0 ||
103
+ parsed > MAX_SUPPORTED_TIMEOUT_SECONDS
104
+ ) {
105
+ throw new Error(
106
+ `${label} must be an integer between 1 and ${MAX_SUPPORTED_TIMEOUT_SECONDS}`,
107
+ );
108
+ }
109
+ return parsed;
110
+ }
111
+
66
112
  export function parseJpycToAtomic(value, label) {
67
113
  const raw = typeof value === 'number' ? String(value) : value;
68
114
  if (typeof raw !== 'string' || !/^[0-9]+(?:\.[0-9]{1,18})?$/.test(raw)) {
@@ -141,6 +187,10 @@ export function readMoneyConfig(env = process.env) {
141
187
  nonEmpty(env.MAX_SESSION_JPYC) ?? DEFAULT_MAX_SESSION_JPYC,
142
188
  'MAX_SESSION_JPYC',
143
189
  ),
190
+ maxTimeoutSeconds: parseMaxTimeoutSeconds(
191
+ nonEmpty(env.MAX_TIMEOUT_SECONDS) ?? DEFAULT_MAX_TIMEOUT_SECONDS,
192
+ 'MAX_TIMEOUT_SECONDS',
193
+ ),
144
194
  maxDailyAtomic:
145
195
  nonEmpty(env.MAX_DAILY_JPYC) === undefined
146
196
  ? null
@@ -204,6 +254,10 @@ export function parseClientOptions(options = {}) {
204
254
  optionAmount(options.maxSessionJpyc, DEFAULT_MAX_SESSION_JPYC),
205
255
  'MAX_SESSION_JPYC',
206
256
  ),
257
+ maxTimeoutSeconds: parseMaxTimeoutSeconds(
258
+ optionAmount(options.maxTimeoutSeconds, DEFAULT_MAX_TIMEOUT_SECONDS),
259
+ 'maxTimeoutSeconds',
260
+ ),
207
261
  maxDailyAtomic:
208
262
  options.maxDailyJpyc === undefined || options.maxDailyJpyc === ''
209
263
  ? null
@@ -250,12 +304,17 @@ function reasonFromNormalizeError(error) {
250
304
  return REASONS.invalidAccept;
251
305
  }
252
306
 
253
- function addAssetReasons(reasons, rawAccept) {
307
+ function addAssetReasons(reasons, rawAccept, accept) {
254
308
  const extra = isObject(rawAccept) ? rawAccept.extra : undefined;
309
+ const expected =
310
+ accept === null ? undefined : SUPPORTED_JPYC_ASSETS[accept.network];
255
311
  if (
312
+ expected === undefined ||
313
+ accept.asset !== expected.address ||
256
314
  !isObject(extra) ||
257
- extra.name !== 'JPY Coin' ||
258
- extra.decimals !== JPYC_DECIMALS
315
+ extra.name !== expected.name ||
316
+ extra.version !== expected.version ||
317
+ extra.decimals !== expected.decimals
259
318
  ) {
260
319
  reasons.push(REASONS.invalidJpycAsset);
261
320
  }
@@ -326,7 +385,7 @@ export function validateAcceptForPayment(rawAccept, requestUrl) {
326
385
  if (accept !== null && !SUPPORTED_NETWORKS.has(accept.network)) {
327
386
  reasons.push(REASONS.unsupportedNetwork);
328
387
  }
329
- addAssetReasons(reasons, rawAccept);
388
+ addAssetReasons(reasons, rawAccept, accept);
330
389
  addResourceReason(reasons, rawAccept, requestUrl);
331
390
 
332
391
  return {
@@ -338,7 +397,7 @@ export function validateAcceptForPayment(rawAccept, requestUrl) {
338
397
  }
339
398
 
340
399
  // catalog trust の掲載 accept (discovery = OpenPay サーバー権威) とライブ accept が、金銭に効く
341
- // 全フィールド (asset/forwarder/受取先/各金額/commit) まで一致するかを照合する。第三者ドメインが
400
+ // 全フィールド (asset/有効期限/forwarder/受取先/各金額/commit) まで一致するかを照合する。第三者ドメインが
342
401
  // 掲載時と別の forwarder/asset を bait-and-switch して buyer に攻撃者宛の署名を作らせる P0 を塞ぐ。
343
402
  // どちらかが正規化不能なら不一致 (fail-close)。
344
403
  function catalogAcceptConsistent(liveRawAccept, listedRawAccept) {
@@ -348,6 +407,7 @@ function catalogAcceptConsistent(liveRawAccept, listedRawAccept) {
348
407
  return (
349
408
  a.network === b.network &&
350
409
  a.asset === b.asset &&
410
+ a.maxTimeoutSeconds === b.maxTimeoutSeconds &&
351
411
  a.extra.openpay.forwarder === b.extra.openpay.forwarder &&
352
412
  a.extra.openpay.merchant === b.extra.openpay.merchant &&
353
413
  a.extra.openpay.merchantValue === b.extra.openpay.merchantValue &&
@@ -390,31 +450,39 @@ export function evaluatePaymentGuards({
390
450
  }) {
391
451
  const reasons = [];
392
452
  const maxDailyAtomic = config.maxDailyAtomic ?? null;
453
+ const configuredMaxTimeoutSeconds =
454
+ config.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS;
455
+ // 公開 primitive では config を手組みできるため、parser を迂回した不正な上限値が
456
+ // authorization の絶対上限を広げる波及を断ち、無効値は 0 扱いで fail-close する。
457
+ const maxTimeoutSeconds =
458
+ Number.isSafeInteger(configuredMaxTimeoutSeconds) &&
459
+ configuredMaxTimeoutSeconds > 0
460
+ ? Math.min(
461
+ configuredMaxTimeoutSeconds,
462
+ MAX_SUPPORTED_TIMEOUT_SECONDS,
463
+ )
464
+ : 0;
393
465
  const parsedUrl = parseHttpUrl(url, 'url');
466
+ let catalogAcceptMatches = false;
394
467
  if (parsedUrl === null) {
395
468
  reasons.push(REASONS.invalidUrl);
396
469
  } else {
397
470
  const hostAllowed = config.allowedHosts.includes(parsedUrl.hostname.toLowerCase());
398
- // 完全一致を先に維持し、miss 時だけ query/hash を除いた同一 origin+pathname を引く。
399
- // userinfo は origin に含まれないため、query variant ではない credential 付き URL へは緩和しない。
471
+ // catalog admission は完全一致だけ。query-free 掲載を任意 query へ広げると、
472
+ // GET 副作用を持つ未審査 URL へ buyer を到達させる波及が残る。
400
473
  let listedAccept;
401
474
  if (config.catalogTrust && catalogListings instanceof Map) {
402
475
  listedAccept = catalogListings.get(parsedUrl.toString());
403
- if (
404
- listedAccept === undefined &&
405
- parsedUrl.username === '' &&
406
- parsedUrl.password === ''
407
- ) {
408
- listedAccept = catalogListings.get(`${parsedUrl.origin}${parsedUrl.pathname}`);
409
- }
410
476
  }
411
477
  const catalogListed = listedAccept !== undefined;
478
+ catalogAcceptMatches =
479
+ catalogListed && catalogAcceptConsistent(accept, listedAccept);
412
480
  if (!hostAllowed && !catalogListed) {
413
481
  reasons.push(REASONS.hostNotAllowed);
414
482
  } else if (!hostAllowed && catalogListed) {
415
483
  // ALLOWED_HOSTS 直 (open-pay.jp) はサーバー権威ゆえ照合不要。catalog trust 経由 (第三者
416
484
  // ドメイン) でのみ、ライブ accept が掲載 accept と金銭フィールドまで一致するか照合する。
417
- if (!catalogAcceptConsistent(accept, listedAccept)) {
485
+ if (!catalogAcceptMatches) {
418
486
  reasons.push(REASONS.catalogAcceptMismatch);
419
487
  }
420
488
  }
@@ -422,6 +490,16 @@ export function evaluatePaymentGuards({
422
490
 
423
491
  const acceptValidation = validateAcceptForPayment(accept, url);
424
492
  reasons.push(...acceptValidation.reasons);
493
+ if (acceptValidation.accept !== null) {
494
+ const knownForwarder =
495
+ SUPPORTED_JPYC_FORWARDERS[acceptValidation.accept.network];
496
+ if (
497
+ acceptValidation.accept.extra.openpay.forwarder !== knownForwarder &&
498
+ !catalogAcceptMatches
499
+ ) {
500
+ reasons.push(REASONS.invalidOpenpayForwarder);
501
+ }
502
+ }
425
503
 
426
504
  let maxTotalAtomic = null;
427
505
  if (requireMaxTotal) {
@@ -451,6 +529,13 @@ export function evaluatePaymentGuards({
451
529
  }
452
530
  }
453
531
 
532
+ if (
533
+ acceptValidation.accept !== null &&
534
+ acceptValidation.accept.maxTimeoutSeconds > maxTimeoutSeconds
535
+ ) {
536
+ reasons.push(REASONS.timeoutTooLong);
537
+ }
538
+
454
539
  if (maxDailyAtomic !== null && dailySpentAtomic === null) {
455
540
  reasons.push(REASONS.dailySpendUnavailable);
456
541
  }
package/src/index.mjs CHANGED
@@ -3,6 +3,8 @@ export * from './client.mjs';
3
3
  export * from './executor.mjs';
4
4
  export * from './gate.mjs';
5
5
  export * from './guards.mjs';
6
+ export * from './network.mjs';
6
7
  export * from './payment.mjs';
8
+ export * from './receipt.mjs';
7
9
  export * from './signer.mjs';
8
10
  export * from './spendStore.mjs';