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 +10 -0
- package/README.md +96 -0
- package/index.d.ts +428 -0
- package/package.json +26 -0
- package/src/catalog.mjs +70 -0
- package/src/client.mjs +127 -0
- package/src/executor.mjs +177 -0
- package/src/guards.mjs +443 -0
- package/src/index.mjs +6 -0
- package/src/payment.mjs +232 -0
- package/src/signer.mjs +295 -0
package/src/guards.mjs
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { formatUnits, isHex } from 'viem';
|
|
2
|
+
import { normalizePaymentRequirements } from './payment.mjs';
|
|
3
|
+
import {
|
|
4
|
+
parseSignerOptions,
|
|
5
|
+
readSignerMode,
|
|
6
|
+
SIGNER_MODES,
|
|
7
|
+
} from './signer.mjs';
|
|
8
|
+
|
|
9
|
+
export const JPYC_DECIMALS = 18;
|
|
10
|
+
export const DEFAULT_MAX_PER_CALL_JPYC = '10';
|
|
11
|
+
export const DEFAULT_MAX_SESSION_JPYC = '100';
|
|
12
|
+
export const DEFAULT_ALLOWED_HOSTS = 'open-pay.jp';
|
|
13
|
+
// カタログ信頼 (既定 ON): OpenPay の審査済みカタログ (/api/discovery) に載っている URL への
|
|
14
|
+
// 支払いを、ALLOWED_HOSTS への手動追加なしで許可する。掲載は 402 ゲート実在 + OpenPay 方式
|
|
15
|
+
// (forwarder-split) の検証を通過したものだけで、金額の防御 (per-call/session/maxTotalJpyc) は
|
|
16
|
+
// 本設定と無関係に常に効く。CATALOG_TRUST=false で無効化できる。
|
|
17
|
+
export const DEFAULT_CATALOG_TRUST = true;
|
|
18
|
+
export const DEFAULT_DISCOVERY_URL = 'https://open-pay.jp/api/discovery';
|
|
19
|
+
|
|
20
|
+
export const REASONS = {
|
|
21
|
+
invalidUrl: 'invalid_url',
|
|
22
|
+
hostNotAllowed: 'host_not_allowed',
|
|
23
|
+
unsupportedScheme: 'unsupported_scheme',
|
|
24
|
+
unsupportedNetwork: 'unsupported_network',
|
|
25
|
+
invalidOpenpayMode: 'invalid_openpay_mode',
|
|
26
|
+
amountMismatch: 'amount_mismatch',
|
|
27
|
+
invalidJpycAsset: 'invalid_jpyc_asset',
|
|
28
|
+
resourceMismatch: 'resource_mismatch',
|
|
29
|
+
invalidAccept: 'invalid_accept',
|
|
30
|
+
maxTotalRequired: 'max_total_required',
|
|
31
|
+
maxTotalInvalid: 'max_total_invalid',
|
|
32
|
+
totalExceedsMaxTotal: 'total_exceeds_max_total',
|
|
33
|
+
maxTotalAbovePerCallLimit: 'max_total_above_per_call_limit',
|
|
34
|
+
perCallLimitExceeded: 'per_call_limit_exceeded',
|
|
35
|
+
sessionLimitExceeded: 'session_limit_exceeded',
|
|
36
|
+
buyerPrivateKeyMissing: 'buyer_private_key_missing',
|
|
37
|
+
stewardSignerUnconfigured: 'steward_signer_unconfigured',
|
|
38
|
+
// catalog trust 経由 (第三者ドメイン) の URL で、支払い時にライブ fetch した accept が
|
|
39
|
+
// discovery 掲載 accept (OpenPay サーバー生成の権威値) と食い違う = bait-and-switch。
|
|
40
|
+
catalogAcceptMismatch: 'catalog_accept_mismatch',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const SUPPORTED_NETWORKS = new Set([
|
|
44
|
+
'eip155:137',
|
|
45
|
+
'eip155:80002',
|
|
46
|
+
'eip155:8217',
|
|
47
|
+
'eip155:1001',
|
|
48
|
+
'eip155:43114',
|
|
49
|
+
'eip155:43113',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function nonEmpty(raw) {
|
|
53
|
+
return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function unique(values) {
|
|
57
|
+
return [...new Set(values)];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isObject(value) {
|
|
61
|
+
return typeof value === 'object' && value !== null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseJpycToAtomic(value, label) {
|
|
65
|
+
const raw = typeof value === 'number' ? String(value) : value;
|
|
66
|
+
if (typeof raw !== 'string' || !/^[0-9]+(?:\.[0-9]{1,18})?$/.test(raw)) {
|
|
67
|
+
throw new Error(`${label} must be a JPYC decimal with up to 18 decimals`);
|
|
68
|
+
}
|
|
69
|
+
const [whole, fraction = ''] = raw.split('.');
|
|
70
|
+
const atomic = BigInt(whole) * 10n ** 18n + BigInt(fraction.padEnd(18, '0'));
|
|
71
|
+
if (atomic <= 0n) throw new Error(`${label} must be greater than 0`);
|
|
72
|
+
return atomic;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatAtomicJpyc(value) {
|
|
76
|
+
return formatUnits(value, JPYC_DECIMALS);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseAllowedHosts(raw) {
|
|
80
|
+
const source = nonEmpty(raw) ?? DEFAULT_ALLOWED_HOSTS;
|
|
81
|
+
const hosts = source.split(',').map((part) => part.trim()).filter(Boolean);
|
|
82
|
+
if (hosts.length === 0) throw new Error('ALLOWED_HOSTS must include at least one host');
|
|
83
|
+
return unique(
|
|
84
|
+
hosts.map((host) => {
|
|
85
|
+
if (host.includes('://')) {
|
|
86
|
+
throw new Error('ALLOWED_HOSTS entries must be bare hosts, not URLs');
|
|
87
|
+
}
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = new URL(`http://${host}`);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error(`ALLOWED_HOSTS entry is invalid: ${host}`);
|
|
93
|
+
}
|
|
94
|
+
if (parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
95
|
+
throw new Error(`ALLOWED_HOSTS entry is invalid: ${host}`);
|
|
96
|
+
}
|
|
97
|
+
return parsed.hostname.toLowerCase();
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseOptionalPrivateKey(raw) {
|
|
103
|
+
const key = nonEmpty(raw);
|
|
104
|
+
if (key === undefined) return null;
|
|
105
|
+
if (!isHex(key) || key.length !== 66) {
|
|
106
|
+
throw new Error('BUYER_PRIVATE_KEY must be a 32-byte 0x-prefixed hex string');
|
|
107
|
+
}
|
|
108
|
+
return key;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseHttpUrl(raw, label) {
|
|
112
|
+
if (typeof raw !== 'string') return null;
|
|
113
|
+
try {
|
|
114
|
+
const url = new URL(raw);
|
|
115
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
|
116
|
+
return url;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function requireHttpUrl(raw, label) {
|
|
123
|
+
const url = parseHttpUrl(raw, label);
|
|
124
|
+
if (url === null) throw new Error(`${label} must be an http(s) URL`);
|
|
125
|
+
return url.toString();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readMoneyConfig(env = process.env) {
|
|
129
|
+
return {
|
|
130
|
+
signerMode: readSignerMode(env),
|
|
131
|
+
buyerPrivateKey: parseOptionalPrivateKey(env.BUYER_PRIVATE_KEY),
|
|
132
|
+
stewardApiKey: nonEmpty(env.STEWARD_API_KEY) ?? null,
|
|
133
|
+
stewardSignerSecret: nonEmpty(env.STEWARD_SIGNER_SECRET) ?? null,
|
|
134
|
+
maxPerCallAtomic: parseJpycToAtomic(
|
|
135
|
+
nonEmpty(env.MAX_PER_CALL_JPYC) ?? DEFAULT_MAX_PER_CALL_JPYC,
|
|
136
|
+
'MAX_PER_CALL_JPYC',
|
|
137
|
+
),
|
|
138
|
+
maxSessionAtomic: parseJpycToAtomic(
|
|
139
|
+
nonEmpty(env.MAX_SESSION_JPYC) ?? DEFAULT_MAX_SESSION_JPYC,
|
|
140
|
+
'MAX_SESSION_JPYC',
|
|
141
|
+
),
|
|
142
|
+
allowedHosts: parseAllowedHosts(env.ALLOWED_HOSTS),
|
|
143
|
+
catalogTrust:
|
|
144
|
+
env.CATALOG_TRUST === undefined || env.CATALOG_TRUST === ''
|
|
145
|
+
? DEFAULT_CATALOG_TRUST
|
|
146
|
+
: env.CATALOG_TRUST === 'true',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function readRuntimeConfig(env = process.env) {
|
|
151
|
+
return {
|
|
152
|
+
...readMoneyConfig(env),
|
|
153
|
+
discoveryUrl: requireHttpUrl(
|
|
154
|
+
nonEmpty(env.DISCOVERY_URL) ?? DEFAULT_DISCOVERY_URL,
|
|
155
|
+
'DISCOVERY_URL',
|
|
156
|
+
),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function optionAmount(value, fallback) {
|
|
161
|
+
return value === undefined || value === '' ? fallback : value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function parseClientOptions(options = {}) {
|
|
165
|
+
if (typeof options !== 'object' || options === null || Array.isArray(options)) {
|
|
166
|
+
throw new Error('client options must be an object');
|
|
167
|
+
}
|
|
168
|
+
const parsedSigner = parseSignerOptions(options);
|
|
169
|
+
if (
|
|
170
|
+
options.allowedHosts !== undefined &&
|
|
171
|
+
typeof options.allowedHosts !== 'string'
|
|
172
|
+
) {
|
|
173
|
+
throw new Error('ALLOWED_HOSTS must be a comma-separated string');
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
options.catalogTrust !== undefined &&
|
|
177
|
+
typeof options.catalogTrust !== 'boolean'
|
|
178
|
+
) {
|
|
179
|
+
throw new Error('catalogTrust must be a boolean');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
signerMode:
|
|
184
|
+
parsedSigner.kind === 'steward' || parsedSigner.kind === 'custom'
|
|
185
|
+
? SIGNER_MODES.steward
|
|
186
|
+
: SIGNER_MODES.envKey,
|
|
187
|
+
buyerPrivateKey:
|
|
188
|
+
parsedSigner.kind === 'private-key' ? parsedSigner.privateKey : null,
|
|
189
|
+
stewardApiKey:
|
|
190
|
+
parsedSigner.kind === 'steward' ? parsedSigner.config.apiKey : null,
|
|
191
|
+
stewardSignerSecret:
|
|
192
|
+
parsedSigner.kind === 'steward' ? parsedSigner.config.signerSecret : null,
|
|
193
|
+
maxPerCallAtomic: parseJpycToAtomic(
|
|
194
|
+
optionAmount(options.maxPerCallJpyc, DEFAULT_MAX_PER_CALL_JPYC),
|
|
195
|
+
'MAX_PER_CALL_JPYC',
|
|
196
|
+
),
|
|
197
|
+
maxSessionAtomic: parseJpycToAtomic(
|
|
198
|
+
optionAmount(options.maxSessionJpyc, DEFAULT_MAX_SESSION_JPYC),
|
|
199
|
+
'MAX_SESSION_JPYC',
|
|
200
|
+
),
|
|
201
|
+
allowedHosts: parseAllowedHosts(options.allowedHosts),
|
|
202
|
+
catalogTrust: options.catalogTrust ?? DEFAULT_CATALOG_TRUST,
|
|
203
|
+
discoveryUrl: requireHttpUrl(
|
|
204
|
+
nonEmpty(options.discoveryUrl) ?? DEFAULT_DISCOVERY_URL,
|
|
205
|
+
'DISCOVERY_URL',
|
|
206
|
+
),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function createPaymentSession(initialSpentAtomic = 0n) {
|
|
211
|
+
if (initialSpentAtomic < 0n) {
|
|
212
|
+
throw new Error('initialSpentAtomic must be non-negative');
|
|
213
|
+
}
|
|
214
|
+
return { spentAtomic: initialSpentAtomic };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function recordSuccessfulPayment(session, amountAtomic) {
|
|
218
|
+
session.spentAtomic += amountAtomic;
|
|
219
|
+
return session.spentAtomic;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function isHostAllowed(url, allowedHosts) {
|
|
223
|
+
const parsed = parseHttpUrl(url, 'url');
|
|
224
|
+
if (parsed === null) return false;
|
|
225
|
+
return allowedHosts.includes(parsed.hostname.toLowerCase());
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function reasonFromNormalizeError(error) {
|
|
229
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
230
|
+
if (message.startsWith('unsupported scheme')) return REASONS.unsupportedScheme;
|
|
231
|
+
if (message.startsWith('unsupported network') || message === 'network must be a string') {
|
|
232
|
+
return REASONS.unsupportedNetwork;
|
|
233
|
+
}
|
|
234
|
+
if (message === 'extra.openpay.forwarder-split is required') {
|
|
235
|
+
return REASONS.invalidOpenpayMode;
|
|
236
|
+
}
|
|
237
|
+
if (message === 'maxAmountRequired must equal merchantValue + feeValue') {
|
|
238
|
+
return REASONS.amountMismatch;
|
|
239
|
+
}
|
|
240
|
+
return REASONS.invalidAccept;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function addAssetReasons(reasons, rawAccept) {
|
|
244
|
+
const extra = isObject(rawAccept) ? rawAccept.extra : undefined;
|
|
245
|
+
if (
|
|
246
|
+
!isObject(extra) ||
|
|
247
|
+
extra.name !== 'JPY Coin' ||
|
|
248
|
+
extra.decimals !== JPYC_DECIMALS
|
|
249
|
+
) {
|
|
250
|
+
reasons.push(REASONS.invalidJpycAsset);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function addResourceReason(reasons, rawAccept, requestUrl) {
|
|
255
|
+
const requested = parseHttpUrl(requestUrl, 'url');
|
|
256
|
+
const resource =
|
|
257
|
+
isObject(rawAccept) && typeof rawAccept.resource === 'string'
|
|
258
|
+
? parseHttpUrl(rawAccept.resource, 'accept.resource')
|
|
259
|
+
: null;
|
|
260
|
+
if (
|
|
261
|
+
requested === null ||
|
|
262
|
+
resource === null ||
|
|
263
|
+
resource.hostname.toLowerCase() !== requested.hostname.toLowerCase() ||
|
|
264
|
+
resource.toString() !== requested.toString()
|
|
265
|
+
) {
|
|
266
|
+
reasons.push(REASONS.resourceMismatch);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function summarizeAccept(accept) {
|
|
271
|
+
const merchantValue = accept.extra.openpay.merchantValue;
|
|
272
|
+
const feeValue = accept.extra.openpay.feeValue;
|
|
273
|
+
const total = merchantValue + feeValue;
|
|
274
|
+
return {
|
|
275
|
+
priceAtomic: merchantValue,
|
|
276
|
+
feeAtomic: feeValue,
|
|
277
|
+
totalAtomic: total,
|
|
278
|
+
priceJpyc: formatAtomicJpyc(merchantValue),
|
|
279
|
+
feeJpyc: formatAtomicJpyc(feeValue),
|
|
280
|
+
totalJpyc: formatAtomicJpyc(total),
|
|
281
|
+
network: accept.network,
|
|
282
|
+
asset: accept.asset,
|
|
283
|
+
description: undefined,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function validateAcceptForPayment(rawAccept, requestUrl) {
|
|
288
|
+
const reasons = [];
|
|
289
|
+
let accept = null;
|
|
290
|
+
let summary = null;
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
accept = normalizePaymentRequirements(rawAccept);
|
|
294
|
+
summary = summarizeAccept(accept);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
reasons.push(reasonFromNormalizeError(error));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (accept !== null && !SUPPORTED_NETWORKS.has(accept.network)) {
|
|
300
|
+
reasons.push(REASONS.unsupportedNetwork);
|
|
301
|
+
}
|
|
302
|
+
addAssetReasons(reasons, rawAccept);
|
|
303
|
+
addResourceReason(reasons, rawAccept, requestUrl);
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
ok: reasons.length === 0,
|
|
307
|
+
reasons: unique(reasons),
|
|
308
|
+
accept,
|
|
309
|
+
summary,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// catalog trust の掲載 accept (discovery = OpenPay サーバー権威) とライブ accept が、金銭に効く
|
|
314
|
+
// 全フィールド (asset/forwarder/受取先/各金額/commit) まで一致するかを照合する。第三者ドメインが
|
|
315
|
+
// 掲載時と別の forwarder/asset を bait-and-switch して buyer に攻撃者宛の署名を作らせる P0 を塞ぐ。
|
|
316
|
+
// どちらかが正規化不能なら不一致 (fail-close)。
|
|
317
|
+
function catalogAcceptConsistent(liveRawAccept, listedRawAccept) {
|
|
318
|
+
try {
|
|
319
|
+
const a = normalizePaymentRequirements(liveRawAccept);
|
|
320
|
+
const b = normalizePaymentRequirements(listedRawAccept);
|
|
321
|
+
return (
|
|
322
|
+
a.network === b.network &&
|
|
323
|
+
a.asset === b.asset &&
|
|
324
|
+
a.extra.openpay.forwarder === b.extra.openpay.forwarder &&
|
|
325
|
+
a.extra.openpay.merchant === b.extra.openpay.merchant &&
|
|
326
|
+
a.extra.openpay.merchantValue === b.extra.openpay.merchantValue &&
|
|
327
|
+
a.extra.openpay.feeReceiver === b.extra.openpay.feeReceiver &&
|
|
328
|
+
a.extra.openpay.feeValue === b.extra.openpay.feeValue &&
|
|
329
|
+
a.extra.openpay.commitVersion === b.extra.openpay.commitVersion
|
|
330
|
+
);
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseMaxTotalArg(value, reasons) {
|
|
337
|
+
if (value === undefined || value === null) {
|
|
338
|
+
reasons.push(REASONS.maxTotalRequired);
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
return parseJpycToAtomic(value, 'maxTotalJpyc');
|
|
343
|
+
} catch {
|
|
344
|
+
reasons.push(REASONS.maxTotalInvalid);
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function evaluatePaymentGuards({
|
|
350
|
+
url,
|
|
351
|
+
accept,
|
|
352
|
+
config,
|
|
353
|
+
sessionSpentAtomic = 0n,
|
|
354
|
+
maxTotalJpyc,
|
|
355
|
+
requireMaxTotal = false,
|
|
356
|
+
requirePrivateKey = false,
|
|
357
|
+
requireSigner = false,
|
|
358
|
+
signerAvailable = false,
|
|
359
|
+
// Map<string, rawAccept> | null。カタログ信頼用に呼び出し側が解決した「掲載 URL → 掲載 accept
|
|
360
|
+
// (OpenPay サーバー生成の権威値)」。URL 一致で支払いを許可し、accept を bait-and-switch 照合に使う。
|
|
361
|
+
catalogListings = null,
|
|
362
|
+
}) {
|
|
363
|
+
const reasons = [];
|
|
364
|
+
const parsedUrl = parseHttpUrl(url, 'url');
|
|
365
|
+
if (parsedUrl === null) {
|
|
366
|
+
reasons.push(REASONS.invalidUrl);
|
|
367
|
+
} else {
|
|
368
|
+
const hostAllowed = config.allowedHosts.includes(parsedUrl.hostname.toLowerCase());
|
|
369
|
+
// カタログ信頼はホストでなく **URL 完全一致** — allowlist より狭い単位で許可する。
|
|
370
|
+
const listedAccept =
|
|
371
|
+
config.catalogTrust && catalogListings instanceof Map
|
|
372
|
+
? catalogListings.get(parsedUrl.toString())
|
|
373
|
+
: undefined;
|
|
374
|
+
const catalogListed = listedAccept !== undefined;
|
|
375
|
+
if (!hostAllowed && !catalogListed) {
|
|
376
|
+
reasons.push(REASONS.hostNotAllowed);
|
|
377
|
+
} else if (!hostAllowed && catalogListed) {
|
|
378
|
+
// ALLOWED_HOSTS 直 (open-pay.jp) はサーバー権威ゆえ照合不要。catalog trust 経由 (第三者
|
|
379
|
+
// ドメイン) でのみ、ライブ accept が掲載 accept と金銭フィールドまで一致するか照合する。
|
|
380
|
+
if (!catalogAcceptConsistent(accept, listedAccept)) {
|
|
381
|
+
reasons.push(REASONS.catalogAcceptMismatch);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const acceptValidation = validateAcceptForPayment(accept, url);
|
|
387
|
+
reasons.push(...acceptValidation.reasons);
|
|
388
|
+
|
|
389
|
+
let maxTotalAtomic = null;
|
|
390
|
+
if (requireMaxTotal) {
|
|
391
|
+
maxTotalAtomic = parseMaxTotalArg(maxTotalJpyc, reasons);
|
|
392
|
+
if (maxTotalAtomic !== null && maxTotalAtomic > config.maxPerCallAtomic) {
|
|
393
|
+
reasons.push(REASONS.maxTotalAbovePerCallLimit);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (acceptValidation.summary !== null) {
|
|
398
|
+
const total = acceptValidation.summary.totalAtomic;
|
|
399
|
+
if (maxTotalAtomic !== null && total > maxTotalAtomic) {
|
|
400
|
+
reasons.push(REASONS.totalExceedsMaxTotal);
|
|
401
|
+
}
|
|
402
|
+
if (!requireMaxTotal && total > config.maxPerCallAtomic) {
|
|
403
|
+
reasons.push(REASONS.perCallLimitExceeded);
|
|
404
|
+
}
|
|
405
|
+
if (sessionSpentAtomic + total > config.maxSessionAtomic) {
|
|
406
|
+
reasons.push(REASONS.sessionLimitExceeded);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (requirePrivateKey || requireSigner) {
|
|
411
|
+
if (config.signerMode === SIGNER_MODES.steward) {
|
|
412
|
+
if (!signerAvailable) reasons.push(REASONS.stewardSignerUnconfigured);
|
|
413
|
+
} else if (config.buyerPrivateKey === null) {
|
|
414
|
+
reasons.push(REASONS.buyerPrivateKeyMissing);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
ok: reasons.length === 0,
|
|
420
|
+
reasons: unique(reasons),
|
|
421
|
+
accept: acceptValidation.accept,
|
|
422
|
+
summary: acceptValidation.summary,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function redactSensitiveText(text, secrets = []) {
|
|
427
|
+
let out = String(text);
|
|
428
|
+
for (const secret of secrets) {
|
|
429
|
+
if (typeof secret === 'string' && secret.length > 0) {
|
|
430
|
+
out = out.split(secret).join('[redacted_private_key]');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return out.replace(/\b0x[0-9a-fA-F]{130}\b/g, '[redacted_signature]');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function safeErrorMessage(error, config = {}) {
|
|
437
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
438
|
+
return redactSensitiveText(message, [
|
|
439
|
+
config.buyerPrivateKey,
|
|
440
|
+
config.stewardApiKey,
|
|
441
|
+
config.stewardSignerSecret,
|
|
442
|
+
]);
|
|
443
|
+
}
|
package/src/index.mjs
ADDED
package/src/payment.mjs
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
bytesToHex,
|
|
4
|
+
encodeAbiParameters,
|
|
5
|
+
getAddress,
|
|
6
|
+
isAddress,
|
|
7
|
+
keccak256,
|
|
8
|
+
} from 'viem';
|
|
9
|
+
|
|
10
|
+
export const RECEIVE_WITH_AUTHORIZATION_TYPES = {
|
|
11
|
+
ReceiveWithAuthorization: [
|
|
12
|
+
{ name: 'from', type: 'address' },
|
|
13
|
+
{ name: 'to', type: 'address' },
|
|
14
|
+
{ name: 'value', type: 'uint256' },
|
|
15
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
16
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
17
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function isObject(value) {
|
|
22
|
+
return typeof value === 'object' && value !== null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requireDec(value, label) {
|
|
26
|
+
if (typeof value !== 'string' || !/^[0-9]+$/.test(value)) {
|
|
27
|
+
throw new Error(`${label} must be a decimal string`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requireAddress(value, label) {
|
|
33
|
+
if (typeof value !== 'string' || !isAddress(value)) {
|
|
34
|
+
throw new Error(`${label} must be an EVM address`);
|
|
35
|
+
}
|
|
36
|
+
return getAddress(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireBytes32(value, label) {
|
|
40
|
+
if (typeof value !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(value)) {
|
|
41
|
+
throw new Error(`${label} must be bytes32`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function chainIdFromNetwork(network) {
|
|
47
|
+
if (typeof network !== 'string') throw new Error('network must be a string');
|
|
48
|
+
const match = /^eip155:([0-9]+)$/.exec(network);
|
|
49
|
+
if (!match) throw new Error(`unsupported network: ${network}`);
|
|
50
|
+
return Number(match[1]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function normalizePaymentRequirements(raw) {
|
|
54
|
+
if (!isObject(raw)) throw new Error('accepts[0] must be an object');
|
|
55
|
+
if (raw.scheme !== 'exact') throw new Error(`unsupported scheme: ${raw.scheme}`);
|
|
56
|
+
const chainId = chainIdFromNetwork(raw.network);
|
|
57
|
+
const asset = requireAddress(raw.asset, 'asset');
|
|
58
|
+
const payTo = requireAddress(raw.payTo, 'payTo');
|
|
59
|
+
const maxAmountRequired = BigInt(
|
|
60
|
+
requireDec(raw.maxAmountRequired, 'maxAmountRequired'),
|
|
61
|
+
);
|
|
62
|
+
const extra = raw.extra;
|
|
63
|
+
if (!isObject(extra)) throw new Error('extra is required');
|
|
64
|
+
if (extra.assetTransferMethod !== 'eip3009') {
|
|
65
|
+
throw new Error(`unsupported assetTransferMethod: ${extra.assetTransferMethod}`);
|
|
66
|
+
}
|
|
67
|
+
if (typeof extra.name !== 'string' || extra.name.length === 0) {
|
|
68
|
+
throw new Error('extra.name is required');
|
|
69
|
+
}
|
|
70
|
+
if (typeof extra.version !== 'string' || extra.version.length === 0) {
|
|
71
|
+
throw new Error('extra.version is required');
|
|
72
|
+
}
|
|
73
|
+
const openpay = extra.openpay;
|
|
74
|
+
if (!isObject(openpay) || openpay.mode !== 'forwarder-split') {
|
|
75
|
+
throw new Error('extra.openpay.forwarder-split is required');
|
|
76
|
+
}
|
|
77
|
+
const forwarder = requireAddress(openpay.forwarder, 'extra.openpay.forwarder');
|
|
78
|
+
if (payTo !== forwarder) throw new Error('payTo must match extra.openpay.forwarder');
|
|
79
|
+
const merchant = requireAddress(openpay.merchant, 'extra.openpay.merchant');
|
|
80
|
+
const merchantValue = BigInt(
|
|
81
|
+
requireDec(openpay.merchantValue, 'extra.openpay.merchantValue'),
|
|
82
|
+
);
|
|
83
|
+
const feeReceiver = requireAddress(openpay.feeReceiver, 'extra.openpay.feeReceiver');
|
|
84
|
+
const feeValue = BigInt(requireDec(openpay.feeValue, 'extra.openpay.feeValue'));
|
|
85
|
+
const commitVersion = requireBytes32(
|
|
86
|
+
openpay.commitVersion,
|
|
87
|
+
'extra.openpay.commitVersion',
|
|
88
|
+
);
|
|
89
|
+
const total = merchantValue + feeValue;
|
|
90
|
+
if (maxAmountRequired !== total) {
|
|
91
|
+
throw new Error('maxAmountRequired must equal merchantValue + feeValue');
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
scheme: raw.scheme,
|
|
95
|
+
network: raw.network,
|
|
96
|
+
chainId,
|
|
97
|
+
asset,
|
|
98
|
+
maxTimeoutSeconds: requirePositiveInteger(
|
|
99
|
+
raw.maxTimeoutSeconds,
|
|
100
|
+
'maxTimeoutSeconds',
|
|
101
|
+
),
|
|
102
|
+
extra: {
|
|
103
|
+
name: extra.name,
|
|
104
|
+
version: extra.version,
|
|
105
|
+
openpay: {
|
|
106
|
+
forwarder,
|
|
107
|
+
merchant,
|
|
108
|
+
merchantValue,
|
|
109
|
+
feeReceiver,
|
|
110
|
+
feeValue,
|
|
111
|
+
commitVersion,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function requirePositiveInteger(value, label) {
|
|
118
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
119
|
+
throw new Error(`${label} must be a positive integer`);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildForwarderNonce(params, chainId, forwarder, commitVersion) {
|
|
125
|
+
return keccak256(
|
|
126
|
+
encodeAbiParameters(
|
|
127
|
+
[
|
|
128
|
+
{ type: 'bytes32' },
|
|
129
|
+
{ type: 'address' },
|
|
130
|
+
{ type: 'address' },
|
|
131
|
+
{ type: 'uint256' },
|
|
132
|
+
{ type: 'address' },
|
|
133
|
+
{ type: 'uint256' },
|
|
134
|
+
{ type: 'uint256' },
|
|
135
|
+
{ type: 'uint256' },
|
|
136
|
+
{ type: 'bytes32' },
|
|
137
|
+
{ type: 'uint256' },
|
|
138
|
+
{ type: 'address' },
|
|
139
|
+
],
|
|
140
|
+
[
|
|
141
|
+
commitVersion,
|
|
142
|
+
params.from,
|
|
143
|
+
params.merchant,
|
|
144
|
+
params.merchantValue,
|
|
145
|
+
params.feeReceiver,
|
|
146
|
+
params.feeValue,
|
|
147
|
+
params.validAfter,
|
|
148
|
+
params.validBefore,
|
|
149
|
+
params.intentSalt,
|
|
150
|
+
BigInt(chainId),
|
|
151
|
+
forwarder,
|
|
152
|
+
],
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function buildTypedDataFromPaymentRequirements(rawAccept, authorization) {
|
|
158
|
+
const accept = normalizePaymentRequirements(rawAccept);
|
|
159
|
+
const params = {
|
|
160
|
+
from: requireAddress(authorization.from, 'authorization.from'),
|
|
161
|
+
merchant: accept.extra.openpay.merchant,
|
|
162
|
+
merchantValue: accept.extra.openpay.merchantValue,
|
|
163
|
+
feeReceiver: accept.extra.openpay.feeReceiver,
|
|
164
|
+
feeValue: accept.extra.openpay.feeValue,
|
|
165
|
+
validAfter: BigInt(requireDec(authorization.validAfter, 'authorization.validAfter')),
|
|
166
|
+
validBefore: BigInt(requireDec(authorization.validBefore, 'authorization.validBefore')),
|
|
167
|
+
intentSalt: requireBytes32(authorization.intentSalt, 'authorization.intentSalt'),
|
|
168
|
+
};
|
|
169
|
+
const forwarder = accept.extra.openpay.forwarder;
|
|
170
|
+
const value = params.merchantValue + params.feeValue;
|
|
171
|
+
return {
|
|
172
|
+
accept,
|
|
173
|
+
params,
|
|
174
|
+
typedData: {
|
|
175
|
+
domain: {
|
|
176
|
+
name: accept.extra.name,
|
|
177
|
+
version: accept.extra.version,
|
|
178
|
+
chainId: accept.chainId,
|
|
179
|
+
verifyingContract: accept.asset,
|
|
180
|
+
},
|
|
181
|
+
types: RECEIVE_WITH_AUTHORIZATION_TYPES,
|
|
182
|
+
primaryType: 'ReceiveWithAuthorization',
|
|
183
|
+
message: {
|
|
184
|
+
from: params.from,
|
|
185
|
+
to: forwarder,
|
|
186
|
+
value,
|
|
187
|
+
validAfter: params.validAfter,
|
|
188
|
+
validBefore: params.validBefore,
|
|
189
|
+
nonce: buildForwarderNonce(
|
|
190
|
+
params,
|
|
191
|
+
accept.chainId,
|
|
192
|
+
forwarder,
|
|
193
|
+
accept.extra.openpay.commitVersion,
|
|
194
|
+
),
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function createAuthorization(
|
|
201
|
+
from,
|
|
202
|
+
maxTimeoutSeconds,
|
|
203
|
+
nowSec = Math.floor(Date.now() / 1000),
|
|
204
|
+
) {
|
|
205
|
+
return {
|
|
206
|
+
from: getAddress(from),
|
|
207
|
+
validAfter: '0',
|
|
208
|
+
validBefore: String(nowSec + maxTimeoutSeconds),
|
|
209
|
+
intentSalt: bytesToHex(randomBytes(32)),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function encodePaymentPayload(payload) {
|
|
214
|
+
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function decodePaymentResponse(raw) {
|
|
218
|
+
if (!raw) return null;
|
|
219
|
+
return JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function paymentPayloadFor(accept, authorization, signature) {
|
|
223
|
+
return {
|
|
224
|
+
x402Version: 1,
|
|
225
|
+
scheme: accept.scheme,
|
|
226
|
+
network: accept.network,
|
|
227
|
+
payload: {
|
|
228
|
+
signature,
|
|
229
|
+
authorization,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|