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 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 exact resource URL registered in OpenPay discovery. For
51
- inexpensive content, `handle()` verifies and settles the payment in one call:
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 `accepts` from `/api/discovery` and caches it for five
103
- minutes. Until `resourceUrl` is listed with a non-empty `accepts`, `handle()` and
104
- `verify()` throw; map that bootstrap condition to an HTTP 500 response. Pass
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 (shown as `MY_RESOURCE_ID` in the generated snippet). The 402 then carries
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 — USDC never blocks JPYC payments.
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 → pass to createDualGate; paywallSnippet → or paste the snippet instead
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.7.1 resolves the NFT definition from
175
- one product ID. Set only `LICENSE_PRODUCT_ID` and `LICENSE_SESSION_SECRET` on
176
- your server. The secret must contain at least 32 random bytes of key material
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({ resourceUrl: 'https://service.example/api/paid' });
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
- /** OpenPay listing id (MY_RESOURCE_ID in the generated snippet). Enables the USDC (Base) rail. */
504
- resourceId: string;
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 — the USDC side never blocks JPYC payments.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
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
- if (typeof resourceId !== 'string' || resourceId.length === 0) {
42
- throw new Error('resourceId is required (shown as MY_RESOURCE_ID in your OpenPay listing)');
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
- const face = await response.json();
66
- if (!face || typeof face !== 'object' || !face.v1Accepts) return null;
67
- usdcCache = face;
68
- usdcCachedAt = now();
69
- return face;
70
+ 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
- // USDC レールの 402。JPYC accepts の取得失敗は握って USDC 面だけで返す
102
- // (challenge の失敗で支払いエラーの伝達自体を落とさない)。
103
- async function usdcChallenge(usdc, error) {
104
- let jpycAccepts = [];
105
- try {
106
- const headerless = { url: resourceUrl, headers: { get: () => null } };
107
- const challenge = await jpyc.verify(headerless);
108
- if (challenge instanceof Response && challenge.status === 402) {
109
- const body = await challenge.json();
110
- if (Array.isArray(body.accepts)) jpycAccepts = body.accepts;
111
- }
112
- } catch {
113
- /* JPYC カタログ未掲載などは USDC のみで継続 */
114
- }
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
- const { items } = await response.json();
165
- const mine = (items || []).find((item) => item.resource === resourceUrl);
166
- if (!mine || !mine.accepts || mine.accepts.length === 0) {
167
- throw new Error(`resource not found in OpenPay catalog: ${resourceUrl}`);
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
+ }
@@ -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
- await runtime.fileSystem.mkdir(runtime.directory, { recursive: true });
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
- await runtime.fileSystem.mkdir(runtime.directory, { recursive: true });
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`,