openpay-x402-sdk 0.4.0 → 0.6.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/catalog.mjs CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
3
+ fetchPaymentTarget,
4
+ } from './network.mjs';
5
+
1
6
  export const CATALOG_CACHE_MS = 5 * 60_000;
2
7
 
3
8
  function isObject(value) {
@@ -21,6 +26,8 @@ export function createCatalogCache() {
21
26
  export async function resolveCatalogListings({
22
27
  config,
23
28
  fetchImpl = fetch,
29
+ lookup,
30
+ requestTimeoutMs = DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
24
31
  now = Date.now,
25
32
  cache = createCatalogCache(),
26
33
  }) {
@@ -33,7 +40,10 @@ export async function resolveCatalogListings({
33
40
  }
34
41
 
35
42
  try {
36
- const response = await fetchImpl(config.discoveryUrl, {
43
+ const response = await fetchPaymentTarget(config.discoveryUrl, {
44
+ fetchImpl,
45
+ lookup,
46
+ timeoutMs: requestTimeoutMs,
37
47
  headers: { accept: 'application/json' },
38
48
  });
39
49
  const body = await readJson(response);
@@ -64,7 +74,21 @@ export async function resolveCatalogListings({
64
74
  }
65
75
  }
66
76
 
67
- export function createCatalogResolver({ config, fetchImpl = fetch, now = Date.now }) {
77
+ export function createCatalogResolver({
78
+ config,
79
+ fetchImpl = fetch,
80
+ lookup,
81
+ requestTimeoutMs = DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
82
+ now = Date.now,
83
+ }) {
68
84
  const cache = createCatalogCache();
69
- return () => resolveCatalogListings({ config, fetchImpl, now, cache });
85
+ return () =>
86
+ resolveCatalogListings({
87
+ config,
88
+ fetchImpl,
89
+ lookup,
90
+ requestTimeoutMs,
91
+ now,
92
+ cache,
93
+ });
70
94
  }
package/src/client.mjs CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  } from './guards.mjs';
9
9
  import { createSignerFromOptions } from './signer.mjs';
10
10
  import { createFileSpendStore } from './spendStore.mjs';
11
+ import { createReceiptSignerResolver } from './receipt.mjs';
11
12
 
12
13
  function isObject(value) {
13
14
  return typeof value === 'object' && value !== null;
@@ -68,7 +69,18 @@ export function createOpenPayClient(options = {}) {
68
69
  config.maxDailyAtomic === null
69
70
  ? null
70
71
  : options.spendStore ?? createFileSpendStore();
71
- const resolveCatalogListings = createCatalogResolver({ config, fetchImpl });
72
+ const resolveCatalogListings = createCatalogResolver({
73
+ config,
74
+ fetchImpl,
75
+ lookup: options.lookup,
76
+ requestTimeoutMs: options.requestTimeoutMs,
77
+ });
78
+ const resolveReceiptSigner = createReceiptSignerResolver({
79
+ discoveryUrl: config.discoveryUrl,
80
+ fetchImpl,
81
+ lookup: options.lookup,
82
+ requestTimeoutMs: options.requestTimeoutMs,
83
+ });
72
84
  const executor = createPaymentExecutor({
73
85
  config,
74
86
  session,
@@ -76,9 +88,12 @@ export function createOpenPayClient(options = {}) {
76
88
  signerAddress: signer?.address ?? null,
77
89
  spendStore,
78
90
  fetchImpl,
91
+ lookup: options.lookup,
92
+ requestTimeoutMs: options.requestTimeoutMs,
79
93
  nowSec: options.nowSec,
80
94
  now: options.now,
81
95
  resolveCatalogListings,
96
+ resolveReceiptSigner,
82
97
  });
83
98
 
84
99
  async function withSafeErrors(operation) {
@@ -0,0 +1,196 @@
1
+ // dual-rail 出品者ゲート: createJpycGate (JPYC・Polygon) に USDC (Base・標準 x402) の面を重ねる。
2
+ //
3
+ // USDC 面は OpenPay のリレー (/api/x402/relay/*) から取得・中継する:
4
+ // - requirements: 402 に並記する完成形 (v1Accepts + PAYMENT-REQUIRED ヘッダ) を配布
5
+ // - verify/settle: CDP facilitator への中継 (支払いは購入者 → 出品者 payTo へ直接)
6
+ //
7
+ // 隔離 (最重要): USDC 面の取得失敗 (リレー未点灯・障害) は null に落とし、JPYC ゲートだけで
8
+ // 継続する — 付帯面 (USDC) の障害が決済本体 (JPYC) を止めない。
9
+ //
10
+ // レール振り分け: PAYMENT-SIGNATURE ヘッダ (v2 = USDC クライアント)、または x-payment (v1) の
11
+ // network が USDC 面と一致するときだけ USDC レール。その他は従来の JPYC ゲートへ委譲する。
12
+ // JPYC レールの 402 には USDC accepts を追記 (decorate) して両面を常に見せる。
13
+
14
+ import { createJpycGate } from './gate.mjs';
15
+
16
+ const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
17
+ const USDC_FACE_CACHE_MS = 5 * 60_000;
18
+
19
+ function decodeBase64Json(value) {
20
+ const binary = atob(value);
21
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
22
+ return JSON.parse(new TextDecoder().decode(bytes));
23
+ }
24
+
25
+ function encodeBase64Json(value) {
26
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
27
+ let binary = '';
28
+ for (const byte of bytes) binary += String.fromCharCode(byte);
29
+ return btoa(binary);
30
+ }
31
+
32
+ export function createDualGate({
33
+ resourceUrl,
34
+ resourceId,
35
+ openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
36
+ fetchImpl = globalThis.fetch,
37
+ now = Date.now,
38
+ maxUpstreamSeconds,
39
+ settlementGraceSeconds,
40
+ }) {
41
+ if (typeof resourceId !== 'string' || resourceId.length === 0) {
42
+ throw new Error('resourceId is required (shown as MY_RESOURCE_ID in your OpenPay listing)');
43
+ }
44
+ const jpyc = createJpycGate({
45
+ resourceUrl,
46
+ openpayOrigin,
47
+ fetchImpl,
48
+ now,
49
+ ...(maxUpstreamSeconds === undefined ? {} : { maxUpstreamSeconds }),
50
+ ...(settlementGraceSeconds === undefined ? {} : { settlementGraceSeconds }),
51
+ });
52
+ const origin = openpayOrigin.replace(/\/+$/, '');
53
+
54
+ let usdcCache = null;
55
+ let usdcCachedAt = 0;
56
+ async function usdcFace() {
57
+ if (usdcCache !== null && now() - usdcCachedAt < USDC_FACE_CACHE_MS) {
58
+ return usdcCache;
59
+ }
60
+ try {
61
+ const response = await fetchImpl(
62
+ `${origin}/api/x402/relay/requirements?resourceId=${encodeURIComponent(resourceId)}`,
63
+ );
64
+ if (!response.ok) return null;
65
+ const face = await response.json();
66
+ if (!face || typeof face !== 'object' || !face.v1Accepts) return null;
67
+ usdcCache = face;
68
+ usdcCachedAt = now();
69
+ return face;
70
+ } catch {
71
+ // リレー未点灯/障害 → USDC 面なしで継続 (JPYC 本体を止めない)。キャッシュしない
72
+ // (復旧したら次のリクエストで拾う)。
73
+ return null;
74
+ }
75
+ }
76
+
77
+ // JPYC ゲートが返した 402 に USDC 面 (accepts + PAYMENT-REQUIRED ヘッダ) を追記する。
78
+ // 402 以外・USDC 面なし・body が読めない場合はそのまま返す (壊さない)。
79
+ async function decorate402(response, usdc) {
80
+ if (!usdc || !(response instanceof Response) || response.status !== 402) {
81
+ return response;
82
+ }
83
+ let body;
84
+ try {
85
+ body = await response.clone().json();
86
+ } catch {
87
+ return response;
88
+ }
89
+ if (!body || !Array.isArray(body.accepts)) return response;
90
+ const headers = new Headers(response.headers);
91
+ headers.set('content-type', 'application/json');
92
+ if (typeof usdc.paymentRequiredHeader === 'string') {
93
+ headers.set('PAYMENT-REQUIRED', usdc.paymentRequiredHeader);
94
+ }
95
+ return new Response(
96
+ JSON.stringify({ ...body, accepts: [...body.accepts, usdc.v1Accepts] }),
97
+ { status: 402, headers },
98
+ );
99
+ }
100
+
101
+ // USDC レールの 402。JPYC accepts の取得失敗は握って USDC 面だけで返す
102
+ // (challenge の失敗で支払いエラーの伝達自体を落とさない)。
103
+ async function usdcChallenge(usdc, error) {
104
+ let jpycAccepts = [];
105
+ try {
106
+ const headerless = { url: resourceUrl, headers: { get: () => null } };
107
+ const challenge = await jpyc.verify(headerless);
108
+ if (challenge instanceof Response && challenge.status === 402) {
109
+ const body = await challenge.json();
110
+ if (Array.isArray(body.accepts)) jpycAccepts = body.accepts;
111
+ }
112
+ } catch {
113
+ /* JPYC カタログ未掲載などは USDC のみで継続 */
114
+ }
115
+ const headers = { 'content-type': 'application/json' };
116
+ if (typeof usdc.paymentRequiredHeader === 'string') {
117
+ headers['PAYMENT-REQUIRED'] = usdc.paymentRequiredHeader;
118
+ }
119
+ return new Response(
120
+ JSON.stringify({
121
+ x402Version: 1,
122
+ accepts: [...jpycAccepts, usdc.v1Accepts],
123
+ error,
124
+ }),
125
+ { status: 402, headers },
126
+ );
127
+ }
128
+
129
+ async function verify(request) {
130
+ const usdc = await usdcFace();
131
+ const signatureHeader = request.headers.get('payment-signature');
132
+ const v1Header = request.headers.get('x-payment');
133
+ let v1Network = null;
134
+ if (v1Header) {
135
+ try {
136
+ const decoded = decodeBase64Json(v1Header);
137
+ if (decoded && typeof decoded === 'object') v1Network = decoded.network;
138
+ } catch {
139
+ /* 不正 header は下のレール判定で JPYC 側に流し、そこで 402 になる */
140
+ }
141
+ }
142
+
143
+ const usdcRail =
144
+ usdc !== null &&
145
+ (Boolean(signatureHeader) ||
146
+ (typeof v1Network === 'string' && v1Network === usdc.v1Accepts.network));
147
+
148
+ if (usdcRail) {
149
+ const relay = async (path) => {
150
+ const response = await fetchImpl(`${origin}/api/x402/relay/${path}`, {
151
+ method: 'POST',
152
+ headers: { 'content-type': 'application/json' },
153
+ body: JSON.stringify({
154
+ resourceId,
155
+ ...(signatureHeader
156
+ ? { paymentSignatureHeader: signatureHeader }
157
+ : { paymentHeader: v1Header }),
158
+ }),
159
+ });
160
+ return response.json();
161
+ };
162
+ const verification = await relay('verify');
163
+ if (verification.isValid !== true) {
164
+ return usdcChallenge(usdc, verification.invalidReason || 'payment_invalid');
165
+ }
166
+ return {
167
+ async settle() {
168
+ const settlement = await relay('settle');
169
+ if (settlement.success !== true) {
170
+ return usdcChallenge(usdc, settlement.errorReason || 'settlement_failed');
171
+ }
172
+ return { paymentResponseHeader: encodeBase64Json(settlement) };
173
+ },
174
+ };
175
+ }
176
+
177
+ const result = await jpyc.verify(request);
178
+ if (result instanceof Response) return decorate402(result, usdc);
179
+ return {
180
+ async settle() {
181
+ const settlement = await result.settle();
182
+ return settlement instanceof Response
183
+ ? decorate402(settlement, usdc)
184
+ : settlement;
185
+ },
186
+ };
187
+ }
188
+
189
+ async function handle(request) {
190
+ const verification = await verify(request);
191
+ if (verification instanceof Response) return verification;
192
+ return verification.settle();
193
+ }
194
+
195
+ return { handle, verify };
196
+ }