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.
@@ -0,0 +1,254 @@
1
+ import { isIP } from 'node:net';
2
+
3
+ export const DEFAULT_PAYMENT_FETCH_TIMEOUT_MS = 15_000;
4
+
5
+ function normalizeHost(hostname) {
6
+ return hostname.replace(/^\[|\]$/g, '').replace(/\.+$/, '').toLowerCase();
7
+ }
8
+
9
+ function isPrivateIpv4(a, b) {
10
+ if (a === 0 || a === 10 || a === 127) return true;
11
+ if (a === 100 && b >= 64 && b <= 127) return true;
12
+ if (a === 169 && b === 254) return true;
13
+ if (a === 172 && b >= 16 && b <= 31) return true;
14
+ if (a === 192 && b === 168) return true;
15
+ return a >= 224;
16
+ }
17
+
18
+ function expandIpv6(hostname) {
19
+ let value = hostname;
20
+ const embeddedV4 = value.match(
21
+ /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/,
22
+ );
23
+ if (embeddedV4) {
24
+ const octets = embeddedV4[2].split('.').map(Number);
25
+ if (octets.some((octet) => octet > 255)) return null;
26
+ value = `${embeddedV4[1]}${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
27
+ }
28
+ const halves = value.split('::');
29
+ if (halves.length > 2) return null;
30
+ const head = halves[0] ? halves[0].split(':') : [];
31
+ const tail =
32
+ halves.length === 2 ? (halves[1] ? halves[1].split(':') : []) : null;
33
+ let groups;
34
+ if (tail === null) {
35
+ groups = head;
36
+ } else {
37
+ const fill = 8 - head.length - tail.length;
38
+ if (fill < 0) return null;
39
+ groups = [...head, ...Array(fill).fill('0'), ...tail];
40
+ }
41
+ if (groups.length !== 8) return null;
42
+ const numbers = groups.map((group) =>
43
+ /^[0-9a-f]{1,4}$/i.test(group) ? parseInt(group, 16) : -1,
44
+ );
45
+ return numbers.some((number) => number < 0) ? null : numbers;
46
+ }
47
+
48
+ export function isPrivatePaymentHost(hostname) {
49
+ const host = normalizeHost(hostname);
50
+ if (
51
+ host === 'localhost' ||
52
+ host.endsWith('.localhost') ||
53
+ host.endsWith('.local') ||
54
+ host.endsWith('.internal')
55
+ ) {
56
+ return true;
57
+ }
58
+
59
+ if (host.includes(':')) {
60
+ const groups = expandIpv6(host);
61
+ if (groups === null) return true;
62
+ if (groups.every((group) => group === 0)) return true;
63
+ if (
64
+ groups.slice(0, 7).every((group) => group === 0) &&
65
+ groups[7] === 1
66
+ ) {
67
+ return true;
68
+ }
69
+ if (
70
+ groups.slice(0, 5).every((group) => group === 0) &&
71
+ (groups[5] === 0xffff || groups[5] === 0)
72
+ ) {
73
+ return isPrivateIpv4(
74
+ (groups[6] >> 8) & 0xff,
75
+ groups[6] & 0xff,
76
+ );
77
+ }
78
+ if (groups[0] >= 0xfc00 && groups[0] <= 0xfdff) return true;
79
+ if (groups[0] >= 0xfe80 && groups[0] <= 0xfebf) return true;
80
+ return groups[0] >= 0xff00;
81
+ }
82
+
83
+ const ipv4 = host.match(
84
+ /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
85
+ );
86
+ if (!ipv4) return false;
87
+ const octets = ipv4.slice(1).map(Number);
88
+ if (octets.some((octet) => octet > 255)) return true;
89
+ return isPrivateIpv4(octets[0], octets[1]);
90
+ }
91
+
92
+ export function parseSafePaymentUrl(raw) {
93
+ if (typeof raw !== 'string') return null;
94
+ let parsed;
95
+ try {
96
+ parsed = new URL(raw);
97
+ } catch {
98
+ return null;
99
+ }
100
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null;
101
+ if (parsed.username !== '' || parsed.password !== '') return null;
102
+ if (isPrivatePaymentHost(parsed.hostname)) return null;
103
+ return parsed;
104
+ }
105
+
106
+ async function defaultLookup(hostname) {
107
+ const dns = await import('node:dns/promises');
108
+ return dns.lookup(normalizeHost(hostname), { all: true, verbatim: true });
109
+ }
110
+
111
+ function normalizedAddresses(addresses) {
112
+ if (!Array.isArray(addresses) || addresses.length === 0) {
113
+ throw new Error('payment_target_dns_unavailable');
114
+ }
115
+ return addresses.map((entry) => {
116
+ const address = typeof entry === 'string' ? entry : entry?.address;
117
+ const family =
118
+ typeof entry === 'object' && entry !== null && entry.family !== undefined
119
+ ? Number(entry.family)
120
+ : isIP(address);
121
+ if (
122
+ typeof address !== 'string' ||
123
+ (family !== 4 && family !== 6) ||
124
+ isPrivatePaymentHost(address)
125
+ ) {
126
+ throw new Error('payment_target_private_address');
127
+ }
128
+ return { address, family };
129
+ });
130
+ }
131
+
132
+ function lookupWithSignal(lookup, hostname, signal) {
133
+ return new Promise((resolve, reject) => {
134
+ if (signal.aborted) {
135
+ reject(signal.reason);
136
+ return;
137
+ }
138
+ const abort = () => reject(signal.reason);
139
+ signal.addEventListener('abort', abort, { once: true });
140
+ lookup(normalizeHost(hostname)).then(
141
+ (addresses) => {
142
+ signal.removeEventListener('abort', abort);
143
+ resolve(addresses);
144
+ },
145
+ (error) => {
146
+ signal.removeEventListener('abort', abort);
147
+ reject(error);
148
+ },
149
+ );
150
+ });
151
+ }
152
+
153
+ function safeConnectLookup(lookup, signal) {
154
+ return (hostname, options, callback) => {
155
+ lookupWithSignal(lookup, hostname, signal)
156
+ .then(normalizedAddresses)
157
+ .then((addresses) => {
158
+ if (options?.all) {
159
+ callback(null, addresses);
160
+ return;
161
+ }
162
+ callback(null, addresses[0].address, addresses[0].family);
163
+ })
164
+ .catch((error) => callback(error));
165
+ };
166
+ }
167
+
168
+ async function nodeFetchWithSafeLookup(parsed, init, lookup) {
169
+ const transport =
170
+ parsed.protocol === 'https:'
171
+ ? await import('node:https')
172
+ : await import('node:http');
173
+ const { Readable } = await import('node:stream');
174
+ return new Promise((resolve, reject) => {
175
+ const request = transport.request(
176
+ parsed,
177
+ {
178
+ agent: false,
179
+ headers: init.headers,
180
+ lookup: safeConnectLookup(lookup, init.signal),
181
+ method: 'GET',
182
+ signal: init.signal,
183
+ },
184
+ (response) => {
185
+ try {
186
+ const headers = new Headers();
187
+ for (let index = 0; index < response.rawHeaders.length; index += 2) {
188
+ headers.append(
189
+ response.rawHeaders[index],
190
+ response.rawHeaders[index + 1],
191
+ );
192
+ }
193
+ const status = response.statusCode;
194
+ if (!Number.isInteger(status) || status < 200 || status > 599) {
195
+ response.destroy();
196
+ reject(new Error(`unsupported payment response status: ${status}`));
197
+ return;
198
+ }
199
+ const body =
200
+ status === 204 || status === 205 || status === 304
201
+ ? null
202
+ : Readable.toWeb(response);
203
+ resolve(
204
+ new Response(body, {
205
+ status,
206
+ statusText: response.statusMessage,
207
+ headers,
208
+ }),
209
+ );
210
+ } catch (error) {
211
+ response.destroy();
212
+ reject(error);
213
+ }
214
+ },
215
+ );
216
+ request.on('error', reject);
217
+ request.end();
218
+ });
219
+ }
220
+
221
+ export async function fetchPaymentTarget(
222
+ rawUrl,
223
+ {
224
+ fetchImpl = globalThis.fetch,
225
+ headers,
226
+ lookup,
227
+ timeoutMs = DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
228
+ } = {},
229
+ ) {
230
+ const parsed = parseSafePaymentUrl(rawUrl);
231
+ if (parsed === null) throw new Error('payment_target_not_allowed');
232
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
233
+ throw new Error('payment fetch timeout must be a positive integer');
234
+ }
235
+
236
+ const resolver = lookup ?? defaultLookup;
237
+ const useSafeNodeTransport = fetchImpl === globalThis.fetch;
238
+ const signal = AbortSignal.timeout(timeoutMs);
239
+ if (useSafeNodeTransport || lookup !== undefined) {
240
+ normalizedAddresses(
241
+ await lookupWithSignal(resolver, parsed.hostname, signal),
242
+ );
243
+ }
244
+
245
+ const init = {
246
+ headers,
247
+ redirect: 'manual',
248
+ signal,
249
+ };
250
+ if (useSafeNodeTransport) {
251
+ return nodeFetchWithSafeLookup(parsed, init, resolver);
252
+ }
253
+ return fetchImpl(parsed.toString(), init);
254
+ }
package/src/payment.mjs CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  keccak256,
8
8
  } from 'viem';
9
9
 
10
+ export const MAX_AUTHORIZATION_TIMEOUT_SECONDS = 1200;
11
+
10
12
  export const RECEIVE_WITH_AUTHORIZATION_TYPES = {
11
13
  ReceiveWithAuthorization: [
12
14
  { name: 'from', type: 'address' },
@@ -202,10 +204,27 @@ export function createAuthorization(
202
204
  maxTimeoutSeconds,
203
205
  nowSec = Math.floor(Date.now() / 1000),
204
206
  ) {
207
+ const timeoutSeconds = requirePositiveInteger(
208
+ maxTimeoutSeconds,
209
+ 'maxTimeoutSeconds',
210
+ );
211
+ // 公開 low-level helper の直接利用が buyer guard を迂回しても、長期署名を
212
+ // 生成できないよう絶対上限を共有し、将来残高への引き落とし波及を断つ。
213
+ if (timeoutSeconds > MAX_AUTHORIZATION_TIMEOUT_SECONDS) {
214
+ throw new Error(
215
+ `maxTimeoutSeconds must be <= ${MAX_AUTHORIZATION_TIMEOUT_SECONDS}`,
216
+ );
217
+ }
218
+ if (!Number.isSafeInteger(nowSec) || nowSec < 0) {
219
+ throw new Error('nowSec must be a non-negative safe integer');
220
+ }
221
+ if (nowSec > Number.MAX_SAFE_INTEGER - timeoutSeconds) {
222
+ throw new Error('nowSec + maxTimeoutSeconds must be a safe integer');
223
+ }
205
224
  return {
206
225
  from: getAddress(from),
207
226
  validAfter: '0',
208
- validBefore: String(nowSec + maxTimeoutSeconds),
227
+ validBefore: String(nowSec + timeoutSeconds),
209
228
  intentSalt: bytesToHex(randomBytes(32)),
210
229
  };
211
230
  }
@@ -0,0 +1,201 @@
1
+ import {
2
+ getAddress,
3
+ isAddress,
4
+ recoverTypedDataAddress,
5
+ } from 'viem';
6
+ import {
7
+ DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
8
+ fetchPaymentTarget,
9
+ } from './network.mjs';
10
+
11
+ const RECEIPT_DOMAIN = {
12
+ name: 'OpenPay x402 Facilitator',
13
+ version: '1',
14
+ };
15
+
16
+ const RECEIPT_TYPES = {
17
+ Receipt: [
18
+ { name: 'txHash', type: 'bytes32' },
19
+ { name: 'payer', type: 'address' },
20
+ { name: 'payTo', type: 'address' },
21
+ { name: 'amount', type: 'uint256' },
22
+ { name: 'fee', type: 'uint256' },
23
+ { name: 'asset', type: 'address' },
24
+ { name: 'chainId', type: 'uint256' },
25
+ { name: 'timestamp', type: 'uint256' },
26
+ { name: 'nonce', type: 'bytes32' },
27
+ ],
28
+ };
29
+
30
+ function isObject(value) {
31
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
32
+ }
33
+
34
+ function isBytes32(value) {
35
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value);
36
+ }
37
+
38
+ function isSignature(value) {
39
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{130}$/.test(value);
40
+ }
41
+
42
+ function isUintString(value) {
43
+ return typeof value === 'string' && /^[0-9]{1,78}$/.test(value);
44
+ }
45
+
46
+ async function readJson(response) {
47
+ const text = await response.text();
48
+ if (text.length === 0) return null;
49
+ try {
50
+ return JSON.parse(text);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export function createReceiptSignerResolver({
57
+ discoveryUrl,
58
+ fetchImpl = globalThis.fetch,
59
+ lookup,
60
+ requestTimeoutMs = DEFAULT_PAYMENT_FETCH_TIMEOUT_MS,
61
+ }) {
62
+ const supportedUrl = new URL(
63
+ '/api/facilitator/supported',
64
+ new URL(discoveryUrl).origin,
65
+ ).toString();
66
+ let cachedSigner = null;
67
+ let pending = null;
68
+
69
+ return async function resolveReceiptSigner() {
70
+ if (cachedSigner !== null) return cachedSigner;
71
+ if (pending !== null) return pending;
72
+ pending = (async () => {
73
+ try {
74
+ const response = await fetchPaymentTarget(supportedUrl, {
75
+ fetchImpl,
76
+ lookup,
77
+ timeoutMs: requestTimeoutMs,
78
+ headers: { accept: 'application/json' },
79
+ });
80
+ const body = await readJson(response);
81
+ if (
82
+ !response.ok ||
83
+ !isObject(body) ||
84
+ !isAddress(body.receiptSigner)
85
+ ) {
86
+ return null;
87
+ }
88
+ cachedSigner = getAddress(body.receiptSigner);
89
+ return cachedSigner;
90
+ } catch {
91
+ return null;
92
+ } finally {
93
+ pending = null;
94
+ }
95
+ })();
96
+ return pending;
97
+ };
98
+ }
99
+
100
+ function parsedSignedReceipt(paymentResponse) {
101
+ if (
102
+ !isObject(paymentResponse) ||
103
+ paymentResponse.success !== true ||
104
+ !isBytes32(paymentResponse.transaction) ||
105
+ typeof paymentResponse.network !== 'string' ||
106
+ !isAddress(paymentResponse.payer) ||
107
+ !isObject(paymentResponse.receipt)
108
+ ) {
109
+ return null;
110
+ }
111
+ const receipt = paymentResponse.receipt;
112
+ if (
113
+ !isBytes32(receipt.txHash) ||
114
+ !isAddress(receipt.payer) ||
115
+ !isAddress(receipt.payTo) ||
116
+ !isUintString(receipt.amount) ||
117
+ !isUintString(receipt.fee) ||
118
+ !isAddress(receipt.asset) ||
119
+ !Number.isSafeInteger(receipt.chainId) ||
120
+ receipt.chainId <= 0 ||
121
+ !Number.isSafeInteger(receipt.timestamp) ||
122
+ receipt.timestamp < 0 ||
123
+ !isBytes32(receipt.nonce) ||
124
+ !isSignature(receipt.signature)
125
+ ) {
126
+ return null;
127
+ }
128
+ return {
129
+ paymentResponse,
130
+ receipt: {
131
+ txHash: receipt.txHash,
132
+ payer: getAddress(receipt.payer),
133
+ payTo: getAddress(receipt.payTo),
134
+ amount: receipt.amount,
135
+ fee: receipt.fee,
136
+ asset: getAddress(receipt.asset),
137
+ chainId: receipt.chainId,
138
+ timestamp: receipt.timestamp,
139
+ nonce: receipt.nonce,
140
+ signature: receipt.signature,
141
+ },
142
+ };
143
+ }
144
+
145
+ export async function verifyBoundPaymentResponse(
146
+ paymentResponse,
147
+ {
148
+ expectedSigner,
149
+ payer,
150
+ network,
151
+ asset,
152
+ chainId,
153
+ merchant,
154
+ merchantValue,
155
+ feeValue,
156
+ nonce,
157
+ },
158
+ ) {
159
+ if (!isAddress(expectedSigner)) return false;
160
+ const parsed = parsedSignedReceipt(paymentResponse);
161
+ if (parsed === null) return false;
162
+ const { receipt } = parsed;
163
+ if (
164
+ parsed.paymentResponse.transaction.toLowerCase() !==
165
+ receipt.txHash.toLowerCase() ||
166
+ parsed.paymentResponse.network !== network ||
167
+ getAddress(parsed.paymentResponse.payer) !== getAddress(payer) ||
168
+ receipt.payer !== getAddress(payer) ||
169
+ receipt.payTo !== getAddress(merchant) ||
170
+ receipt.amount !== merchantValue.toString() ||
171
+ receipt.fee !== feeValue.toString() ||
172
+ receipt.asset !== getAddress(asset) ||
173
+ receipt.chainId !== chainId ||
174
+ receipt.nonce.toLowerCase() !== nonce.toLowerCase()
175
+ ) {
176
+ return false;
177
+ }
178
+
179
+ try {
180
+ const recovered = await recoverTypedDataAddress({
181
+ domain: RECEIPT_DOMAIN,
182
+ types: RECEIPT_TYPES,
183
+ primaryType: 'Receipt',
184
+ message: {
185
+ txHash: receipt.txHash,
186
+ payer: receipt.payer,
187
+ payTo: receipt.payTo,
188
+ amount: BigInt(receipt.amount),
189
+ fee: BigInt(receipt.fee),
190
+ asset: receipt.asset,
191
+ chainId: BigInt(receipt.chainId),
192
+ timestamp: BigInt(receipt.timestamp),
193
+ nonce: receipt.nonce,
194
+ },
195
+ signature: receipt.signature,
196
+ });
197
+ return getAddress(recovered) === getAddress(expectedSigner);
198
+ } catch {
199
+ return false;
200
+ }
201
+ }