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/CHANGELOG.md +67 -0
- package/README.md +134 -19
- package/index.d.ts +238 -0
- package/package.json +1 -1
- package/src/catalog.mjs +27 -3
- package/src/client.mjs +16 -1
- package/src/dualGate.mjs +196 -0
- package/src/executor.mjs +417 -57
- package/src/gate.mjs +151 -6
- package/src/guards.mjs +123 -27
- package/src/index.mjs +4 -0
- package/src/listing.mjs +173 -0
- package/src/network.mjs +273 -0
- package/src/payment.mjs +20 -1
- package/src/receipt.mjs +201 -0
- package/src/spendStore.mjs +411 -32
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(
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 {
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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)) {
|
|
@@ -121,9 +167,20 @@ function parseHttpUrl(raw, label) {
|
|
|
121
167
|
}
|
|
122
168
|
}
|
|
123
169
|
|
|
124
|
-
|
|
170
|
+
// DISCOVERY_URL は catalog trust の権威 (ここに載る URL は ALLOWED_HOSTS への手動追加なしで
|
|
171
|
+
// 支払える)。平文 http はネットワーク上で差し替え可能なので、攻撃者のカタログが「審査済み」に
|
|
172
|
+
// 化けて支払い先を乗っ取る波及を断つため https に限る。例外はローカル開発 (localhost /
|
|
173
|
+
// 127.0.0.1) のみ — 自機の facilitator/カタログを立てて配線を確かめる用途。
|
|
174
|
+
function requireDiscoveryUrl(raw, label) {
|
|
125
175
|
const url = parseHttpUrl(raw, label);
|
|
126
176
|
if (url === null) throw new Error(`${label} must be an http(s) URL`);
|
|
177
|
+
if (
|
|
178
|
+
url.protocol === 'http:' &&
|
|
179
|
+
url.hostname !== 'localhost' &&
|
|
180
|
+
url.hostname !== '127.0.0.1'
|
|
181
|
+
) {
|
|
182
|
+
throw new Error(`${label} must use https (http is allowed only for localhost)`);
|
|
183
|
+
}
|
|
127
184
|
return url.toString();
|
|
128
185
|
}
|
|
129
186
|
|
|
@@ -141,6 +198,10 @@ export function readMoneyConfig(env = process.env) {
|
|
|
141
198
|
nonEmpty(env.MAX_SESSION_JPYC) ?? DEFAULT_MAX_SESSION_JPYC,
|
|
142
199
|
'MAX_SESSION_JPYC',
|
|
143
200
|
),
|
|
201
|
+
maxTimeoutSeconds: parseMaxTimeoutSeconds(
|
|
202
|
+
nonEmpty(env.MAX_TIMEOUT_SECONDS) ?? DEFAULT_MAX_TIMEOUT_SECONDS,
|
|
203
|
+
'MAX_TIMEOUT_SECONDS',
|
|
204
|
+
),
|
|
144
205
|
maxDailyAtomic:
|
|
145
206
|
nonEmpty(env.MAX_DAILY_JPYC) === undefined
|
|
146
207
|
? null
|
|
@@ -156,7 +217,7 @@ export function readMoneyConfig(env = process.env) {
|
|
|
156
217
|
export function readRuntimeConfig(env = process.env) {
|
|
157
218
|
return {
|
|
158
219
|
...readMoneyConfig(env),
|
|
159
|
-
discoveryUrl:
|
|
220
|
+
discoveryUrl: requireDiscoveryUrl(
|
|
160
221
|
nonEmpty(env.DISCOVERY_URL) ?? DEFAULT_DISCOVERY_URL,
|
|
161
222
|
'DISCOVERY_URL',
|
|
162
223
|
),
|
|
@@ -204,13 +265,17 @@ export function parseClientOptions(options = {}) {
|
|
|
204
265
|
optionAmount(options.maxSessionJpyc, DEFAULT_MAX_SESSION_JPYC),
|
|
205
266
|
'MAX_SESSION_JPYC',
|
|
206
267
|
),
|
|
268
|
+
maxTimeoutSeconds: parseMaxTimeoutSeconds(
|
|
269
|
+
optionAmount(options.maxTimeoutSeconds, DEFAULT_MAX_TIMEOUT_SECONDS),
|
|
270
|
+
'maxTimeoutSeconds',
|
|
271
|
+
),
|
|
207
272
|
maxDailyAtomic:
|
|
208
273
|
options.maxDailyJpyc === undefined || options.maxDailyJpyc === ''
|
|
209
274
|
? null
|
|
210
275
|
: parseJpycToAtomic(options.maxDailyJpyc, 'MAX_DAILY_JPYC'),
|
|
211
276
|
allowedHosts: parseAllowedHosts(options.allowedHosts),
|
|
212
277
|
catalogTrust: options.catalogTrust ?? DEFAULT_CATALOG_TRUST,
|
|
213
|
-
discoveryUrl:
|
|
278
|
+
discoveryUrl: requireDiscoveryUrl(
|
|
214
279
|
nonEmpty(options.discoveryUrl) ?? DEFAULT_DISCOVERY_URL,
|
|
215
280
|
'DISCOVERY_URL',
|
|
216
281
|
),
|
|
@@ -250,12 +315,17 @@ function reasonFromNormalizeError(error) {
|
|
|
250
315
|
return REASONS.invalidAccept;
|
|
251
316
|
}
|
|
252
317
|
|
|
253
|
-
function addAssetReasons(reasons, rawAccept) {
|
|
318
|
+
function addAssetReasons(reasons, rawAccept, accept) {
|
|
254
319
|
const extra = isObject(rawAccept) ? rawAccept.extra : undefined;
|
|
320
|
+
const expected =
|
|
321
|
+
accept === null ? undefined : SUPPORTED_JPYC_ASSETS[accept.network];
|
|
255
322
|
if (
|
|
323
|
+
expected === undefined ||
|
|
324
|
+
accept.asset !== expected.address ||
|
|
256
325
|
!isObject(extra) ||
|
|
257
|
-
extra.name !==
|
|
258
|
-
extra.
|
|
326
|
+
extra.name !== expected.name ||
|
|
327
|
+
extra.version !== expected.version ||
|
|
328
|
+
extra.decimals !== expected.decimals
|
|
259
329
|
) {
|
|
260
330
|
reasons.push(REASONS.invalidJpycAsset);
|
|
261
331
|
}
|
|
@@ -326,7 +396,7 @@ export function validateAcceptForPayment(rawAccept, requestUrl) {
|
|
|
326
396
|
if (accept !== null && !SUPPORTED_NETWORKS.has(accept.network)) {
|
|
327
397
|
reasons.push(REASONS.unsupportedNetwork);
|
|
328
398
|
}
|
|
329
|
-
addAssetReasons(reasons, rawAccept);
|
|
399
|
+
addAssetReasons(reasons, rawAccept, accept);
|
|
330
400
|
addResourceReason(reasons, rawAccept, requestUrl);
|
|
331
401
|
|
|
332
402
|
return {
|
|
@@ -338,7 +408,7 @@ export function validateAcceptForPayment(rawAccept, requestUrl) {
|
|
|
338
408
|
}
|
|
339
409
|
|
|
340
410
|
// catalog trust の掲載 accept (discovery = OpenPay サーバー権威) とライブ accept が、金銭に効く
|
|
341
|
-
// 全フィールド (asset
|
|
411
|
+
// 全フィールド (asset/有効期限/forwarder/受取先/各金額/commit) まで一致するかを照合する。第三者ドメインが
|
|
342
412
|
// 掲載時と別の forwarder/asset を bait-and-switch して buyer に攻撃者宛の署名を作らせる P0 を塞ぐ。
|
|
343
413
|
// どちらかが正規化不能なら不一致 (fail-close)。
|
|
344
414
|
function catalogAcceptConsistent(liveRawAccept, listedRawAccept) {
|
|
@@ -348,6 +418,7 @@ function catalogAcceptConsistent(liveRawAccept, listedRawAccept) {
|
|
|
348
418
|
return (
|
|
349
419
|
a.network === b.network &&
|
|
350
420
|
a.asset === b.asset &&
|
|
421
|
+
a.maxTimeoutSeconds === b.maxTimeoutSeconds &&
|
|
351
422
|
a.extra.openpay.forwarder === b.extra.openpay.forwarder &&
|
|
352
423
|
a.extra.openpay.merchant === b.extra.openpay.merchant &&
|
|
353
424
|
a.extra.openpay.merchantValue === b.extra.openpay.merchantValue &&
|
|
@@ -390,31 +461,39 @@ export function evaluatePaymentGuards({
|
|
|
390
461
|
}) {
|
|
391
462
|
const reasons = [];
|
|
392
463
|
const maxDailyAtomic = config.maxDailyAtomic ?? null;
|
|
464
|
+
const configuredMaxTimeoutSeconds =
|
|
465
|
+
config.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS;
|
|
466
|
+
// 公開 primitive では config を手組みできるため、parser を迂回した不正な上限値が
|
|
467
|
+
// authorization の絶対上限を広げる波及を断ち、無効値は 0 扱いで fail-close する。
|
|
468
|
+
const maxTimeoutSeconds =
|
|
469
|
+
Number.isSafeInteger(configuredMaxTimeoutSeconds) &&
|
|
470
|
+
configuredMaxTimeoutSeconds > 0
|
|
471
|
+
? Math.min(
|
|
472
|
+
configuredMaxTimeoutSeconds,
|
|
473
|
+
MAX_SUPPORTED_TIMEOUT_SECONDS,
|
|
474
|
+
)
|
|
475
|
+
: 0;
|
|
393
476
|
const parsedUrl = parseHttpUrl(url, 'url');
|
|
477
|
+
let catalogAcceptMatches = false;
|
|
394
478
|
if (parsedUrl === null) {
|
|
395
479
|
reasons.push(REASONS.invalidUrl);
|
|
396
480
|
} else {
|
|
397
481
|
const hostAllowed = config.allowedHosts.includes(parsedUrl.hostname.toLowerCase());
|
|
398
|
-
//
|
|
399
|
-
//
|
|
482
|
+
// catalog admission は完全一致だけ。query-free 掲載を任意 query へ広げると、
|
|
483
|
+
// GET 副作用を持つ未審査 URL へ buyer を到達させる波及が残る。
|
|
400
484
|
let listedAccept;
|
|
401
485
|
if (config.catalogTrust && catalogListings instanceof Map) {
|
|
402
486
|
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
487
|
}
|
|
411
488
|
const catalogListed = listedAccept !== undefined;
|
|
489
|
+
catalogAcceptMatches =
|
|
490
|
+
catalogListed && catalogAcceptConsistent(accept, listedAccept);
|
|
412
491
|
if (!hostAllowed && !catalogListed) {
|
|
413
492
|
reasons.push(REASONS.hostNotAllowed);
|
|
414
493
|
} else if (!hostAllowed && catalogListed) {
|
|
415
494
|
// ALLOWED_HOSTS 直 (open-pay.jp) はサーバー権威ゆえ照合不要。catalog trust 経由 (第三者
|
|
416
495
|
// ドメイン) でのみ、ライブ accept が掲載 accept と金銭フィールドまで一致するか照合する。
|
|
417
|
-
if (!
|
|
496
|
+
if (!catalogAcceptMatches) {
|
|
418
497
|
reasons.push(REASONS.catalogAcceptMismatch);
|
|
419
498
|
}
|
|
420
499
|
}
|
|
@@ -422,6 +501,16 @@ export function evaluatePaymentGuards({
|
|
|
422
501
|
|
|
423
502
|
const acceptValidation = validateAcceptForPayment(accept, url);
|
|
424
503
|
reasons.push(...acceptValidation.reasons);
|
|
504
|
+
if (acceptValidation.accept !== null) {
|
|
505
|
+
const knownForwarder =
|
|
506
|
+
SUPPORTED_JPYC_FORWARDERS[acceptValidation.accept.network];
|
|
507
|
+
if (
|
|
508
|
+
acceptValidation.accept.extra.openpay.forwarder !== knownForwarder &&
|
|
509
|
+
!catalogAcceptMatches
|
|
510
|
+
) {
|
|
511
|
+
reasons.push(REASONS.invalidOpenpayForwarder);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
425
514
|
|
|
426
515
|
let maxTotalAtomic = null;
|
|
427
516
|
if (requireMaxTotal) {
|
|
@@ -451,6 +540,13 @@ export function evaluatePaymentGuards({
|
|
|
451
540
|
}
|
|
452
541
|
}
|
|
453
542
|
|
|
543
|
+
if (
|
|
544
|
+
acceptValidation.accept !== null &&
|
|
545
|
+
acceptValidation.accept.maxTimeoutSeconds > maxTimeoutSeconds
|
|
546
|
+
) {
|
|
547
|
+
reasons.push(REASONS.timeoutTooLong);
|
|
548
|
+
}
|
|
549
|
+
|
|
454
550
|
if (maxDailyAtomic !== null && dailySpentAtomic === null) {
|
|
455
551
|
reasons.push(REASONS.dailySpendUnavailable);
|
|
456
552
|
}
|
package/src/index.mjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
export * from './catalog.mjs';
|
|
2
2
|
export * from './client.mjs';
|
|
3
|
+
export * from './dualGate.mjs';
|
|
4
|
+
export * from './listing.mjs';
|
|
3
5
|
export * from './executor.mjs';
|
|
4
6
|
export * from './gate.mjs';
|
|
5
7
|
export * from './guards.mjs';
|
|
8
|
+
export * from './network.mjs';
|
|
6
9
|
export * from './payment.mjs';
|
|
10
|
+
export * from './receipt.mjs';
|
|
7
11
|
export * from './signer.mjs';
|
|
8
12
|
export * from './spendStore.mjs';
|
package/src/listing.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// コード出品クライアント: 出品フォームを開かずに、SIWE サインイン込みで AI ストアの
|
|
2
|
+
// 出品を登録・一覧・更新・無効化する (POST/GET/PATCH/DELETE /api/facilitator/resources)。
|
|
3
|
+
//
|
|
4
|
+
// 法的に重要: register には **attested: true の明示が必須** (自動付与しない)。これは
|
|
5
|
+
// 「登録するリソースを提供・課金する正当な権利を有し、支払い (HTTP 402 等) でゲートして
|
|
6
|
+
// いる」という出品者本人の表明であり、SDK が代わりに宣言してよいものではない。
|
|
7
|
+
//
|
|
8
|
+
// セッション: 初回操作時に SIWE (EIP-4361) でサインインし cookie を保持・再利用する。
|
|
9
|
+
// 鍵はメモリ内でのみ使用し、どこにも送信しない (署名のみ)。
|
|
10
|
+
|
|
11
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
12
|
+
import { createSiweMessage } from 'viem/siwe';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
|
|
15
|
+
const SIWE_MESSAGE_TTL_MS = 5 * 60_000;
|
|
16
|
+
const DEFAULT_SIWE_CHAIN_ID = 137; // Polygon (OpenPay の常時サポートチェーン)
|
|
17
|
+
const DEFAULT_STATEMENT = 'Sign in to OpenPay to manage your x402 listings.';
|
|
18
|
+
|
|
19
|
+
async function readJson(response) {
|
|
20
|
+
try {
|
|
21
|
+
return await response.json();
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createListingClient({
|
|
28
|
+
privateKey,
|
|
29
|
+
openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
|
|
30
|
+
fetchImpl = globalThis.fetch,
|
|
31
|
+
chainId = DEFAULT_SIWE_CHAIN_ID,
|
|
32
|
+
statement = DEFAULT_STATEMENT,
|
|
33
|
+
now = Date.now,
|
|
34
|
+
}) {
|
|
35
|
+
if (typeof privateKey !== 'string' || !privateKey.startsWith('0x')) {
|
|
36
|
+
throw new Error('privateKey (0x...) is required');
|
|
37
|
+
}
|
|
38
|
+
const account = privateKeyToAccount(privateKey);
|
|
39
|
+
const origin = openpayOrigin.replace(/\/+$/, '');
|
|
40
|
+
let cookie = null;
|
|
41
|
+
|
|
42
|
+
async function signIn() {
|
|
43
|
+
const nonceResponse = await fetchImpl(`${origin}/api/auth/siwe/nonce`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
});
|
|
46
|
+
const nonceBody = await readJson(nonceResponse);
|
|
47
|
+
if (!nonceResponse.ok || typeof nonceBody?.nonce !== 'string') {
|
|
48
|
+
throw new Error(`siwe nonce failed: HTTP ${nonceResponse.status}`);
|
|
49
|
+
}
|
|
50
|
+
const issuedAt = new Date(now());
|
|
51
|
+
const message = createSiweMessage({
|
|
52
|
+
domain: new URL(origin).host,
|
|
53
|
+
address: account.address,
|
|
54
|
+
statement,
|
|
55
|
+
uri: origin,
|
|
56
|
+
version: '1',
|
|
57
|
+
chainId,
|
|
58
|
+
nonce: nonceBody.nonce,
|
|
59
|
+
issuedAt,
|
|
60
|
+
expirationTime: new Date(issuedAt.getTime() + SIWE_MESSAGE_TTL_MS),
|
|
61
|
+
});
|
|
62
|
+
const signature = await account.signMessage({ message });
|
|
63
|
+
const verifyResponse = await fetchImpl(`${origin}/api/auth/siwe/verify`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'content-type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ message, signature }),
|
|
67
|
+
});
|
|
68
|
+
if (!verifyResponse.ok) {
|
|
69
|
+
const body = await readJson(verifyResponse);
|
|
70
|
+
throw new Error(`siwe verify failed: ${body?.error ?? `HTTP ${verifyResponse.status}`}`);
|
|
71
|
+
}
|
|
72
|
+
const setCookie = verifyResponse.headers.get('set-cookie') ?? '';
|
|
73
|
+
const sessionCookie = setCookie.split(';')[0];
|
|
74
|
+
if (!sessionCookie.includes('=')) {
|
|
75
|
+
throw new Error('siwe verify returned no session cookie');
|
|
76
|
+
}
|
|
77
|
+
cookie = sessionCookie;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function authedFetch(path, init = {}, retried = false) {
|
|
81
|
+
if (cookie === null) await signIn();
|
|
82
|
+
const response = await fetchImpl(`${origin}${path}`, {
|
|
83
|
+
...init,
|
|
84
|
+
headers: { ...(init.headers ?? {}), cookie },
|
|
85
|
+
});
|
|
86
|
+
// セッション失効 (401) は一度だけサインインし直して再試行する。
|
|
87
|
+
if (response.status === 401 && !retried) {
|
|
88
|
+
cookie = null;
|
|
89
|
+
return authedFetch(path, init, true);
|
|
90
|
+
}
|
|
91
|
+
return response;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildBody(input) {
|
|
95
|
+
const { url, description, priceJpyc, category, docsUrl, license, payTo, usdc } = input;
|
|
96
|
+
return {
|
|
97
|
+
url,
|
|
98
|
+
description,
|
|
99
|
+
priceJpyc,
|
|
100
|
+
category,
|
|
101
|
+
...(docsUrl ? { docsUrl } : {}),
|
|
102
|
+
...(license ? { license } : {}),
|
|
103
|
+
...(payTo ? { payTo } : {}),
|
|
104
|
+
...(usdc ? { usdc } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function expectOk(response, okStatus) {
|
|
109
|
+
const body = await readJson(response);
|
|
110
|
+
if (response.status !== okStatus) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`openpay listing API failed: HTTP ${response.status} ${body?.error ?? ''}`.trim(),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return body;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
/** SIWE でサインインする出品者アドレス (checksum)。 */
|
|
120
|
+
address: account.address,
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 出品を登録する。attested: true の明示が必須 —
|
|
124
|
+
* 「このリソースを提供・課金する正当な権利があり、402 等の支払いゲートを実装している」
|
|
125
|
+
* という出品者本人の表明で、SDK は代行しない。
|
|
126
|
+
* 戻り値: { resource, paywallSnippet } (usdc 面つきなら dual-rail スニペット)。
|
|
127
|
+
*/
|
|
128
|
+
async register(input) {
|
|
129
|
+
if (input?.attested !== true) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
'attested: true is required — you must personally affirm that you have the right ' +
|
|
132
|
+
'to provide and charge for this resource and that it is payment-gated (HTTP 402).',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const response = await authedFetch('/api/facilitator/resources', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'content-type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ ...buildBody(input), attested: true }),
|
|
139
|
+
});
|
|
140
|
+
return expectOk(response, 201);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/** 自分の出品一覧 (paywallSnippet つき)。 */
|
|
144
|
+
async list() {
|
|
145
|
+
const response = await authedFetch('/api/facilitator/resources');
|
|
146
|
+
const body = await expectOk(response, 200);
|
|
147
|
+
return body.resources ?? [];
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/** 出品を編集する (usdc を省略すると USDC 面は外れる — 現状維持は前回の値を渡す)。 */
|
|
151
|
+
async update(id, input) {
|
|
152
|
+
const response = await authedFetch(
|
|
153
|
+
`/api/facilitator/resources/${encodeURIComponent(id)}`,
|
|
154
|
+
{
|
|
155
|
+
method: 'PATCH',
|
|
156
|
+
headers: { 'content-type': 'application/json' },
|
|
157
|
+
body: JSON.stringify(buildBody(input)),
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
return expectOk(response, 200);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
/** 出品を無効化する (公開カタログから外す・履歴は残る)。 */
|
|
164
|
+
async deactivate(id) {
|
|
165
|
+
const response = await authedFetch(
|
|
166
|
+
`/api/facilitator/resources/${encodeURIComponent(id)}`,
|
|
167
|
+
{ method: 'DELETE' },
|
|
168
|
+
);
|
|
169
|
+
await expectOk(response, 200);
|
|
170
|
+
return true;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|