openpay-x402-sdk 0.9.0 → 0.10.1
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 +30 -0
- package/README.md +47 -12
- package/index.d.ts +10 -3
- package/package.json +1 -1
- package/src/dualGate.mjs +47 -26
- package/src/gate.mjs +12 -7
- package/src/sellerPins.mjs +75 -0
- package/src/spendStore.mjs +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.1 — 2026-09-23
|
|
4
|
+
|
|
5
|
+
- Create spend-store directories with mode 0700, including compatibility saves,
|
|
6
|
+
so an env-key user's spend directory can later hold an MCP keystore without
|
|
7
|
+
failing its permission checks. Existing directories are never chmod-ed; inspect
|
|
8
|
+
and manually repair legacy 0755 directories before switching to keystore mode.
|
|
9
|
+
Mode 0700 also applies to newly created intermediate directories at custom
|
|
10
|
+
spend-store paths; pre-existing shared/group directories keep their permissions.
|
|
11
|
+
- Preserve spend reservations, locks, accounting and wallet 0600/0700 checks.
|
|
12
|
+
No dependencies added. Unpublished; rule-15 human review is required before adoption.
|
|
13
|
+
|
|
14
|
+
## 0.10.0 — 2026-09-23
|
|
15
|
+
|
|
16
|
+
- Breaking: seller gates require a trusted `resourceId` and `expectedRecipient`;
|
|
17
|
+
dual gates also require `expectedUsdcRecipient`. Missing/invalid pins fail at
|
|
18
|
+
construction. JPYC pins `extra.openpay.merchant`; USDC pins `payTo` in every
|
|
19
|
+
advertised representation. Mismatches log and throw before payment advertising
|
|
20
|
+
or processing, without a fallback to registry-supplied recipients.
|
|
21
|
+
- Fetch seller listings by exact ID using `/api/discovery/{resourceId}`, independent
|
|
22
|
+
of duplicate URLs, catalog order or pagination. Cache only validated requirements
|
|
23
|
+
and retain each payment's snapshot through verify/settle. USDC relay requests
|
|
24
|
+
carry the snapshot for comparison of money fields against the current registry.
|
|
25
|
+
On relay 409, discard the USDC cache and return freshly pinned terms without
|
|
26
|
+
replaying payment. JPYC unavailability does not block USDC; trust failures use
|
|
27
|
+
`SellerPinError` and still stop both rails.
|
|
28
|
+
- Update seller types, examples and generated snippets. Existing sellers must
|
|
29
|
+
configure pins and redeploy the SDK/snippet; deploy the matching relay update
|
|
30
|
+
first. No dependencies added. Unpublished; rule-15 human review is required
|
|
31
|
+
before adoption.
|
|
32
|
+
|
|
3
33
|
## 0.9.0 — 2026-09-21
|
|
4
34
|
|
|
5
35
|
- Add explicit `keystore` signer mode and `wallet_not_initialized` guard reason. `createSigner` requires the caller to supply keystore keys via `createSignerFromOptions`; no env-key fallback.
|
package/README.md
CHANGED
|
@@ -47,14 +47,19 @@ transaction id appearing in text. The gate unlocks only on the facilitator's `ve
|
|
|
47
47
|
`settle` responses, which are backed by on-chain settlement; replayed or already-used
|
|
48
48
|
authorizations are refused at that layer.
|
|
49
49
|
|
|
50
|
-
Create a gate with the
|
|
51
|
-
|
|
50
|
+
Create a gate with the resource URL, listing ID and expected recipient from your
|
|
51
|
+
own seller dashboard/config. Never obtain these pins from the discovery response
|
|
52
|
+
being checked. In 0.10.0, `resourceId` and `expectedRecipient` are required;
|
|
53
|
+
missing/invalid pins throw at construction. For inexpensive content, `handle()`
|
|
54
|
+
verifies and settles the payment in one call:
|
|
52
55
|
|
|
53
56
|
```js
|
|
54
57
|
import { createJpycGate } from 'openpay-x402-sdk';
|
|
55
58
|
|
|
56
59
|
const gate = createJpycGate({
|
|
57
60
|
resourceUrl: process.env.MY_RESOURCE_URL,
|
|
61
|
+
resourceId: process.env.MY_RESOURCE_ID,
|
|
62
|
+
expectedRecipient: process.env.EXPECTED_RECIPIENT,
|
|
58
63
|
maxUpstreamSeconds: 60,
|
|
59
64
|
});
|
|
60
65
|
|
|
@@ -99,11 +104,21 @@ export async function GET(request) {
|
|
|
99
104
|
}
|
|
100
105
|
```
|
|
101
106
|
|
|
102
|
-
`createJpycGate` fetches
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
`createJpycGate` fetches the single public listing at `/api/discovery/{resourceId}`,
|
|
108
|
+
checks its ID/URL and every JPYC `extra.openpay.merchant` against your recipient,
|
|
109
|
+
and caches only validated requirements for five minutes. JPYC's top-level `payTo`
|
|
110
|
+
is the OpenPay forwarder, not your recipient. Hidden/inactive/missing listings
|
|
111
|
+
cannot bootstrap a gate. Identity/recipient mismatches log an error and throw
|
|
112
|
+
before a 402, verification or settlement; map these errors to an HTTP 500 response.
|
|
113
|
+
Each payment retains its validated requirements through settlement. Pass
|
|
105
114
|
`openpayOrigin` to use an origin other than `https://open-pay.jp`.
|
|
106
115
|
|
|
116
|
+
Upgrade existing seller deployments to 0.10.0 and configure the required pins,
|
|
117
|
+
or regenerate and redeploy your snippet from your own dashboard. Old installed
|
|
118
|
+
SDKs and pasted snippets remain vulnerable until replaced; a package release does
|
|
119
|
+
not update them. Register the listing, then retrieve its pinned snippet from the
|
|
120
|
+
dashboard before serving payments.
|
|
121
|
+
|
|
107
122
|
Before `verify()` contacts the facilitator, each gate instance claims a canonical
|
|
108
123
|
authorization identity until its `validBefore` time. Set `maxUpstreamSeconds`
|
|
109
124
|
(default `60`) to the seller's worst-case upstream duration; the gate also
|
|
@@ -124,11 +139,22 @@ gate; `createJpycGate` is its importable SDK counterpart with split settlement.
|
|
|
124
139
|
### Dual-rail: also sell in USDC (Base) and appear on the x402 Bazaar
|
|
125
140
|
|
|
126
141
|
If your listing has the USDC face enabled, use `createDualGate` with the listing
|
|
127
|
-
id
|
|
142
|
+
id and both expected recipients from your own config (shown in the generated
|
|
143
|
+
snippet). `expectedUsdcRecipient` is also required, even if both addresses match.
|
|
144
|
+
USDC uses `payTo`; it is checked in the v1 body, v2 accept and every accept in the
|
|
145
|
+
`PAYMENT-REQUIRED` header before caching or advertising. The 402 then carries
|
|
128
146
|
both JPYC and USDC `accepts` plus a `PAYMENT-REQUIRED` header; USDC payments are
|
|
129
147
|
relayed by OpenPay to the CDP facilitator and settle directly to your Base
|
|
130
148
|
address with 0% OpenPay fee. If the USDC face cannot be fetched (relay off or
|
|
131
|
-
unavailable), the gate degrades to JPYC-only
|
|
149
|
+
unavailable), the gate degrades to JPYC-only. JPYC catalog unavailability likewise
|
|
150
|
+
allows USDC-only challenges and payments. Identity/recipient mismatches raise
|
|
151
|
+
`SellerPinError` and stop both rails. The gate sends the same validated USDC
|
|
152
|
+
requirements to relay verify/settle; the relay compares payTo, amount, asset,
|
|
153
|
+
network and scheme against the registry. Display metadata edits do not interrupt
|
|
154
|
+
payments. A relay 409 clears the USDC cache and returns a freshly fetched,
|
|
155
|
+
pin-validated 402 without replaying the payment. A poisoned refresh still fails
|
|
156
|
+
closed; an unavailable refresh never re-advertises the stale USDC terms. Deploy
|
|
157
|
+
the matching OpenPay relay update before upgrading seller gates.
|
|
132
158
|
|
|
133
159
|
```js
|
|
134
160
|
import { createDualGate } from 'openpay-x402-sdk';
|
|
@@ -136,6 +162,8 @@ import { createDualGate } from 'openpay-x402-sdk';
|
|
|
136
162
|
const gate = createDualGate({
|
|
137
163
|
resourceUrl: process.env.MY_RESOURCE_URL,
|
|
138
164
|
resourceId: process.env.MY_RESOURCE_ID,
|
|
165
|
+
expectedRecipient: process.env.EXPECTED_RECIPIENT,
|
|
166
|
+
expectedUsdcRecipient: process.env.EXPECTED_USDC_RECIPIENT,
|
|
139
167
|
});
|
|
140
168
|
```
|
|
141
169
|
|
|
@@ -159,7 +187,8 @@ const { resource, paywallSnippet } = await listings.register({
|
|
|
159
187
|
usdc: { priceUsd: '0.01', serviceName: 'Example Report API' }, // optional USDC face
|
|
160
188
|
attested: true, // your personal attestation — the SDK never sets this for you
|
|
161
189
|
});
|
|
162
|
-
// resource.id
|
|
190
|
+
// Pin resource.id, your JPYC payTo and your USDC payTo in createDualGate,
|
|
191
|
+
// or paste paywallSnippet from this authenticated registration response.
|
|
163
192
|
```
|
|
164
193
|
|
|
165
194
|
`register` refuses to run without an explicit `attested: true`: you must
|
|
@@ -171,9 +200,11 @@ and is never transmitted.
|
|
|
171
200
|
|
|
172
201
|
## 利用ライセンス (License NFT)
|
|
173
202
|
|
|
174
|
-
SDK 0.
|
|
175
|
-
one product ID.
|
|
176
|
-
your server. The
|
|
203
|
+
SDK 0.10.0 resolves the NFT definition from
|
|
204
|
+
one product ID. For the license gate, set `LICENSE_PRODUCT_ID` and
|
|
205
|
+
`LICENSE_SESSION_SECRET` on your server. The usage gate below also requires your
|
|
206
|
+
own `MY_RESOURCE_ID` and `EXPECTED_RECIPIENT` pins. The license session secret must
|
|
207
|
+
contain at least 32 random bytes of key material
|
|
177
208
|
(for example 32 random bytes encoded as hex). Replace the service URLs below
|
|
178
209
|
with your own:
|
|
179
210
|
|
|
@@ -188,7 +219,11 @@ const entry = createLicenseGate({
|
|
|
188
219
|
},
|
|
189
220
|
});
|
|
190
221
|
await entry.ready();
|
|
191
|
-
const usage = createJpycGate({
|
|
222
|
+
const usage = createJpycGate({
|
|
223
|
+
resourceUrl: 'https://service.example/api/paid',
|
|
224
|
+
resourceId: process.env.MY_RESOURCE_ID,
|
|
225
|
+
expectedRecipient: process.env.EXPECTED_RECIPIENT,
|
|
226
|
+
});
|
|
192
227
|
```
|
|
193
228
|
|
|
194
229
|
Polygon (137) and Amoy (80002) use public RPC defaults; `rpcUrl` is optional.
|
package/index.d.ts
CHANGED
|
@@ -193,6 +193,8 @@ export interface OpenPaySession {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
export interface DiscoveryItem {
|
|
196
|
+
/** Present for registered seller listings; obtain trusted gate pins from your own config. */
|
|
197
|
+
id?: string;
|
|
196
198
|
/** Short display name (first-party, or seller-provided). */
|
|
197
199
|
title?: string;
|
|
198
200
|
/** One line describing when an agent should buy this resource (optional). */
|
|
@@ -313,6 +315,10 @@ export function createOpenPayClient(
|
|
|
313
315
|
|
|
314
316
|
export interface JpycGateOptions {
|
|
315
317
|
resourceUrl: string;
|
|
318
|
+
/** Trusted listing ID from your own seller dashboard/config; never a URL search result. */
|
|
319
|
+
resourceId: string;
|
|
320
|
+
/** Seller JPYC recipient (extra.openpay.merchant), NOT the forwarder payTo. */
|
|
321
|
+
expectedRecipient: Address;
|
|
316
322
|
openpayOrigin?: string;
|
|
317
323
|
fetchImpl?: typeof globalThis.fetch;
|
|
318
324
|
now?: () => number;
|
|
@@ -500,14 +506,15 @@ export interface LicenseGate {
|
|
|
500
506
|
export function createLicenseGate(options: LicenseGateOptions): LicenseGate;
|
|
501
507
|
|
|
502
508
|
export interface DualGateOptions extends JpycGateOptions {
|
|
503
|
-
/**
|
|
504
|
-
|
|
509
|
+
/** Trusted USDC recipient (payTo); required even when equal to the JPYC recipient. */
|
|
510
|
+
expectedUsdcRecipient: Address;
|
|
505
511
|
}
|
|
506
512
|
|
|
507
513
|
/**
|
|
508
514
|
* Dual-rail seller gate: JPYC (Polygon, OpenPay facilitator) plus USDC (Base, standard x402
|
|
509
515
|
* relayed to the CDP facilitator via OpenPay). If the USDC face cannot be fetched (relay off
|
|
510
|
-
* or unavailable), the gate degrades to JPYC-only
|
|
516
|
+
* or unavailable), the gate degrades to JPYC-only. Identity/recipient mismatches throw and log
|
|
517
|
+
* before any 402 or payment call; they never trigger a rail fallback.
|
|
511
518
|
*/
|
|
512
519
|
export function createDualGate(options: DualGateOptions): JpycGate;
|
|
513
520
|
|
package/package.json
CHANGED
package/src/dualGate.mjs
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
// - verify/settle: CDP facilitator への中継 (支払いは購入者 → 出品者 payTo へ直接)
|
|
6
6
|
//
|
|
7
7
|
// 隔離 (最重要): USDC 面の取得失敗 (リレー未点灯・障害) は null に落とし、JPYC ゲートだけで
|
|
8
|
-
// 継続する — 付帯面 (USDC) の障害が決済本体 (JPYC)
|
|
8
|
+
// 継続する — 付帯面 (USDC) の障害が決済本体 (JPYC) を止めない。宛先不一致は両面を停止する。
|
|
9
9
|
//
|
|
10
10
|
// レール振り分け: PAYMENT-SIGNATURE ヘッダ (v2 = USDC クライアント)、または x-payment (v1) の
|
|
11
11
|
// network が USDC 面と一致するときだけ USDC レール。その他は従来の JPYC ゲートへ委譲する。
|
|
12
12
|
// JPYC レールの 402 には USDC accepts を追記 (decorate) して両面を常に見せる。
|
|
13
13
|
|
|
14
14
|
import { createJpycGate } from './gate.mjs';
|
|
15
|
+
import { assertSellerPins, SellerPinError, validateUsdcFace } from './sellerPins.mjs';
|
|
15
16
|
|
|
16
17
|
const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
|
|
17
18
|
const USDC_FACE_CACHE_MS = 5 * 60_000;
|
|
@@ -32,17 +33,20 @@ function encodeBase64Json(value) {
|
|
|
32
33
|
export function createDualGate({
|
|
33
34
|
resourceUrl,
|
|
34
35
|
resourceId,
|
|
36
|
+
expectedRecipient,
|
|
37
|
+
expectedUsdcRecipient,
|
|
35
38
|
openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
|
|
36
39
|
fetchImpl = globalThis.fetch,
|
|
37
40
|
now = Date.now,
|
|
38
41
|
maxUpstreamSeconds,
|
|
39
42
|
settlementGraceSeconds,
|
|
40
|
-
}) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
43
|
+
} = {}) {
|
|
44
|
+
assertSellerPins(resourceId, expectedRecipient);
|
|
45
|
+
assertSellerPins(resourceId, expectedUsdcRecipient, 'expectedUsdcRecipient');
|
|
44
46
|
const jpyc = createJpycGate({
|
|
45
47
|
resourceUrl,
|
|
48
|
+
resourceId,
|
|
49
|
+
expectedRecipient,
|
|
46
50
|
openpayOrigin,
|
|
47
51
|
fetchImpl,
|
|
48
52
|
now,
|
|
@@ -57,21 +61,23 @@ export function createDualGate({
|
|
|
57
61
|
if (usdcCache !== null && now() - usdcCachedAt < USDC_FACE_CACHE_MS) {
|
|
58
62
|
return usdcCache;
|
|
59
63
|
}
|
|
64
|
+
let face;
|
|
60
65
|
try {
|
|
61
66
|
const response = await fetchImpl(
|
|
62
67
|
`${origin}/api/x402/relay/requirements?resourceId=${encodeURIComponent(resourceId)}`,
|
|
63
68
|
);
|
|
64
69
|
if (!response.ok) return null;
|
|
65
|
-
|
|
66
|
-
if (!face || typeof face !== 'object' || !face.v1Accepts) return null;
|
|
67
|
-
usdcCache = face;
|
|
68
|
-
usdcCachedAt = now();
|
|
69
|
-
return face;
|
|
70
|
+
face = await response.json();
|
|
70
71
|
} catch {
|
|
71
72
|
// リレー未点灯/障害 → USDC 面なしで継続 (JPYC 本体を止めない)。キャッシュしない
|
|
72
73
|
// (復旧したら次のリクエストで拾う)。
|
|
73
74
|
return null;
|
|
74
75
|
}
|
|
76
|
+
// Keep trust failures outside the availability fallback: poisoning must stop both rails.
|
|
77
|
+
validateUsdcFace(face, resourceId, expectedUsdcRecipient, decodeBase64Json);
|
|
78
|
+
usdcCache = face;
|
|
79
|
+
usdcCachedAt = now();
|
|
80
|
+
return face;
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
// JPYC ゲートが返した 402 に USDC 面 (accepts + PAYMENT-REQUIRED ヘッダ) を追記する。
|
|
@@ -98,20 +104,8 @@ export function createDualGate({
|
|
|
98
104
|
);
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
//
|
|
102
|
-
|
|
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
|
-
}
|
|
107
|
+
// Reuse this payment's validated JPYC/USDC snapshot even if another request refreshes the cache.
|
|
108
|
+
async function usdcChallenge(usdc, jpycAccepts, error) {
|
|
115
109
|
const headers = { 'content-type': 'application/json' };
|
|
116
110
|
if (typeof usdc.paymentRequiredHeader === 'string') {
|
|
117
111
|
headers['PAYMENT-REQUIRED'] = usdc.paymentRequiredHeader;
|
|
@@ -126,6 +120,17 @@ export function createDualGate({
|
|
|
126
120
|
);
|
|
127
121
|
}
|
|
128
122
|
|
|
123
|
+
async function availableJpycAccepts(request) {
|
|
124
|
+
try {
|
|
125
|
+
const challenge = await jpyc.verify({ url: request.url, headers: { get: () => null } });
|
|
126
|
+
return (await challenge.json()).accepts;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// A JPYC outage must not block USDC; trust failures must still stop both rails.
|
|
129
|
+
if (error instanceof SellerPinError) throw error;
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
129
134
|
async function verify(request) {
|
|
130
135
|
const usdc = await usdcFace();
|
|
131
136
|
const signatureHeader = request.headers.get('payment-signature');
|
|
@@ -146,34 +151,50 @@ export function createDualGate({
|
|
|
146
151
|
(typeof v1Network === 'string' && v1Network === usdc.v1Accepts.network));
|
|
147
152
|
|
|
148
153
|
if (usdcRail) {
|
|
154
|
+
const jpycAccepts = await availableJpycAccepts(request);
|
|
149
155
|
const relay = async (path) => {
|
|
150
156
|
const response = await fetchImpl(`${origin}/api/x402/relay/${path}`, {
|
|
151
157
|
method: 'POST',
|
|
152
158
|
headers: { 'content-type': 'application/json' },
|
|
153
159
|
body: JSON.stringify({
|
|
154
160
|
resourceId,
|
|
161
|
+
paymentRequirements: usdc.v1Accepts,
|
|
155
162
|
...(signatureHeader
|
|
156
163
|
? { paymentSignatureHeader: signatureHeader }
|
|
157
164
|
: { paymentHeader: v1Header }),
|
|
158
165
|
}),
|
|
159
166
|
});
|
|
167
|
+
if (response.status === 409) {
|
|
168
|
+
// A stale price must not wedge payments for the cache TTL. Re-pin fresh terms,
|
|
169
|
+
// then ask the buyer again without replaying the old authorization.
|
|
170
|
+
usdcCache = null;
|
|
171
|
+
const fresh = await usdcFace();
|
|
172
|
+
if (!fresh) throw new Error('OpenPay USDC requirements unavailable');
|
|
173
|
+
return usdcChallenge(fresh, jpycAccepts, 'requirements_mismatch');
|
|
174
|
+
}
|
|
160
175
|
return response.json();
|
|
161
176
|
};
|
|
162
177
|
const verification = await relay('verify');
|
|
178
|
+
if (verification instanceof Response) return verification;
|
|
163
179
|
if (verification.isValid !== true) {
|
|
164
|
-
return usdcChallenge(usdc, verification.invalidReason || 'payment_invalid');
|
|
180
|
+
return usdcChallenge(usdc, jpycAccepts, verification.invalidReason || 'payment_invalid');
|
|
165
181
|
}
|
|
166
182
|
return {
|
|
167
183
|
async settle() {
|
|
168
184
|
const settlement = await relay('settle');
|
|
185
|
+
if (settlement instanceof Response) return settlement;
|
|
169
186
|
if (settlement.success !== true) {
|
|
170
|
-
return usdcChallenge(usdc, settlement.errorReason || 'settlement_failed');
|
|
187
|
+
return usdcChallenge(usdc, jpycAccepts, settlement.errorReason || 'settlement_failed');
|
|
171
188
|
}
|
|
172
189
|
return { paymentResponseHeader: encodeBase64Json(settlement) };
|
|
173
190
|
},
|
|
174
191
|
};
|
|
175
192
|
}
|
|
176
193
|
|
|
194
|
+
if (usdc && !signatureHeader && !v1Header) {
|
|
195
|
+
return usdcChallenge(usdc, await availableJpycAccepts(request), 'payment_required');
|
|
196
|
+
}
|
|
197
|
+
|
|
177
198
|
const result = await jpyc.verify(request);
|
|
178
199
|
if (result instanceof Response) return decorate402(result, usdc);
|
|
179
200
|
return {
|
package/src/gate.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { assertSellerPins, validateJpycListing } from './sellerPins.mjs';
|
|
2
|
+
|
|
1
3
|
const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
|
|
2
4
|
const ACCEPTS_CACHE_MS = 5 * 60_000;
|
|
3
5
|
const DEFAULT_MAX_UPSTREAM_SECONDS = 60;
|
|
@@ -93,12 +95,15 @@ function authorizationClaim(paymentPayload, paymentRequirements) {
|
|
|
93
95
|
|
|
94
96
|
export function createJpycGate({
|
|
95
97
|
resourceUrl,
|
|
98
|
+
resourceId,
|
|
99
|
+
expectedRecipient,
|
|
96
100
|
openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
|
|
97
101
|
fetchImpl = globalThis.fetch,
|
|
98
102
|
now = Date.now,
|
|
99
103
|
maxUpstreamSeconds = DEFAULT_MAX_UPSTREAM_SECONDS,
|
|
100
104
|
settlementGraceSeconds = DEFAULT_SETTLEMENT_GRACE_SECONDS,
|
|
101
|
-
}) {
|
|
105
|
+
} = {}) {
|
|
106
|
+
assertSellerPins(resourceId, expectedRecipient);
|
|
102
107
|
if (!Number.isSafeInteger(maxUpstreamSeconds) || maxUpstreamSeconds < 0) {
|
|
103
108
|
throw new Error('maxUpstreamSeconds must be a non-negative integer');
|
|
104
109
|
}
|
|
@@ -160,12 +165,12 @@ export function createJpycGate({
|
|
|
160
165
|
return acceptsCache;
|
|
161
166
|
}
|
|
162
167
|
|
|
163
|
-
const response = await fetchImpl(`${origin}/api/discovery`);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
const response = await fetchImpl(`${origin}/api/discovery/${encodeURIComponent(resourceId)}`);
|
|
169
|
+
if (response.status === 404) throw new Error(`resource not found in OpenPay catalog: ${resourceId}`);
|
|
170
|
+
if (!response.ok) throw new Error(`OpenPay catalog request failed (HTTP ${response.status}): ${resourceId}`);
|
|
171
|
+
const mine = await response.json();
|
|
172
|
+
// A poisoned listing must not reach a challenge, cache, or facilitator payment.
|
|
173
|
+
validateJpycListing(mine, resourceId, resourceUrl, expectedRecipient);
|
|
169
174
|
acceptsCache = mine.accepts;
|
|
170
175
|
acceptsCachedAt = now();
|
|
171
176
|
return acceptsCache;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
function address(value) {
|
|
2
|
+
return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value)
|
|
3
|
+
? value.toLowerCase() : null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export class SellerPinError extends Error {
|
|
7
|
+
constructor(reason) {
|
|
8
|
+
super(`OpenPay seller gate: ${reason}`);
|
|
9
|
+
this.name = 'SellerPinError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function assertSellerPins(resourceId, expectedRecipient, name = 'expectedRecipient') {
|
|
14
|
+
if (typeof resourceId !== 'string' || resourceId.trim().length === 0) {
|
|
15
|
+
throw new Error('resourceId is required from your own OpenPay listing');
|
|
16
|
+
}
|
|
17
|
+
if (!address(expectedRecipient)) {
|
|
18
|
+
throw new Error(`${name} is required and must be a seller wallet address from your own config`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function rejectSellerRequirements(reason) {
|
|
23
|
+
// A registry mismatch must stop payment advertising/settlement, not become a rail fallback.
|
|
24
|
+
console.error(`[openpay-x402] ${reason}`);
|
|
25
|
+
throw new SellerPinError(reason);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function validateJpycListing(item, resourceId, resourceUrl, expectedRecipient) {
|
|
29
|
+
if (item?.id !== resourceId || item.resource !== resourceUrl) {
|
|
30
|
+
rejectSellerRequirements('resource identity mismatch');
|
|
31
|
+
}
|
|
32
|
+
if (!Array.isArray(item.accepts)) {
|
|
33
|
+
rejectSellerRequirements('resource has no payment requirements');
|
|
34
|
+
}
|
|
35
|
+
// Empty requirements signal JPYC configuration/availability, not a substituted recipient.
|
|
36
|
+
if (item.accepts.length === 0) throw new Error('resource has no payment requirements');
|
|
37
|
+
for (const accept of item.accepts) {
|
|
38
|
+
const split = accept?.extra?.openpay;
|
|
39
|
+
if (address(split?.merchant) !== address(expectedRecipient)) {
|
|
40
|
+
rejectSellerRequirements('JPYC recipient mismatch');
|
|
41
|
+
}
|
|
42
|
+
if (split.mode !== 'forwarder-split' || !address(split.forwarder) ||
|
|
43
|
+
address(accept.payTo) !== address(split.forwarder)) {
|
|
44
|
+
rejectSellerRequirements('JPYC forwarder mismatch');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateUsdcFace(face, resourceId, expectedRecipient, decode) {
|
|
50
|
+
if (face?.resourceId !== resourceId) rejectSellerRequirements('USDC resource identity mismatch');
|
|
51
|
+
let required;
|
|
52
|
+
try {
|
|
53
|
+
required = decode(face.paymentRequiredHeader);
|
|
54
|
+
} catch {
|
|
55
|
+
rejectSellerRequirements('invalid USDC payment requirements header');
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(required?.accepts) || required.accepts.length === 0) {
|
|
58
|
+
rejectSellerRequirements('USDC header has no payment requirements');
|
|
59
|
+
}
|
|
60
|
+
const accepts = [face.v1Accepts, face.v2Accept, ...required.accepts];
|
|
61
|
+
for (const accept of accepts) {
|
|
62
|
+
if (address(accept?.payTo) !== address(expectedRecipient)) {
|
|
63
|
+
rejectSellerRequirements('USDC recipient mismatch');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const v1 = face.v1Accepts;
|
|
67
|
+
const network = { base: 'eip155:8453', 'base-sepolia': 'eip155:84532' }[v1.network];
|
|
68
|
+
for (const accept of accepts.slice(1)) {
|
|
69
|
+
if (!network || accept.network !== network || accept.scheme !== v1.scheme ||
|
|
70
|
+
!address(v1.asset) || address(accept.asset) !== address(v1.asset) ||
|
|
71
|
+
accept.amount !== v1.maxAmountRequired) {
|
|
72
|
+
rejectSellerRequirements('USDC requirements mismatch');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/spendStore.mjs
CHANGED
|
@@ -256,7 +256,9 @@ export function createFileSpendStore({ path, fsImpl } = {}) {
|
|
|
256
256
|
if (!supportsAtomicFileUpdates(runtime.fileSystem)) {
|
|
257
257
|
throw new Error('atomic spend store file operations are unavailable');
|
|
258
258
|
}
|
|
259
|
-
|
|
259
|
+
// Spend storage shares the default wallet directory: public directory bits would block
|
|
260
|
+
// a later keystore migration. Keep new directories private without chmod-ing existing ones.
|
|
261
|
+
await runtime.fileSystem.mkdir(runtime.directory, { recursive: true, mode: 0o700 });
|
|
260
262
|
const handle = await acquireLock(runtime.fileSystem, runtime.lockPath);
|
|
261
263
|
try {
|
|
262
264
|
return await operation(runtime);
|
|
@@ -438,7 +440,8 @@ export function createFileSpendStore({ path, fsImpl } = {}) {
|
|
|
438
440
|
}
|
|
439
441
|
const current = documentForDate(document, key.slice(-10));
|
|
440
442
|
current[key] = atomicString;
|
|
441
|
-
|
|
443
|
+
// Compatibility saves must not create a shared directory the keystore would reject.
|
|
444
|
+
await runtime.fileSystem.mkdir(runtime.directory, { recursive: true, mode: 0o700 });
|
|
442
445
|
await runtime.fileSystem.writeFile(
|
|
443
446
|
runtime.targetPath,
|
|
444
447
|
`${JSON.stringify(current, null, 2)}\n`,
|