openpay-x402-sdk 0.8.1 → 0.10.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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 — 2026-09-23
4
+
5
+ - Breaking: seller gates require a trusted `resourceId` and `expectedRecipient`;
6
+ dual gates also require `expectedUsdcRecipient`. Missing/invalid pins fail at
7
+ construction. JPYC pins `extra.openpay.merchant`; USDC pins `payTo` in every
8
+ advertised representation. Mismatches log and throw before payment advertising
9
+ or processing, without a fallback to registry-supplied recipients.
10
+ - Fetch seller listings by exact ID using `/api/discovery/{resourceId}`, independent
11
+ of duplicate URLs, catalog order or pagination. Cache only validated requirements
12
+ and retain each payment's snapshot through verify/settle. USDC relay requests
13
+ carry the snapshot for comparison of money fields against the current registry.
14
+ On relay 409, discard the USDC cache and return freshly pinned terms without
15
+ replaying payment. JPYC unavailability does not block USDC; trust failures use
16
+ `SellerPinError` and still stop both rails.
17
+ - Update seller types, examples and generated snippets. Existing sellers must
18
+ configure pins and redeploy the SDK/snippet; deploy the matching relay update
19
+ first. No dependencies added. Unpublished; rule-15 human review is required
20
+ before adoption.
21
+
22
+ ## 0.9.0 — 2026-09-21
23
+
24
+ - 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.
25
+ - Redact any `0x` + 64-hex value in error text as `[redacted_32byte_hex]` (a key configured through the SDK is still replaced as `[redacted_private_key]`). The generic label is deliberate: the same shape is also a transaction hash or a nonce, and it must not read as "your key leaked". Signature redaction is unchanged. Update public types; existing env-key / Steward behavior is unchanged.
26
+
3
27
  ## 0.8.1
4
28
 
5
29
  - Fix `openpay-x402-sdk/delivery` on Cloudflare Workers: the JWKS fetch called a
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.
@@ -577,6 +612,20 @@ returned or the facilitator signer could not be resolved. Treat `unverified` and
577
612
 
578
613
  ## Signers
579
614
 
615
+ The environment adapter accepts `SIGNER_MODE=env-key` (default), `steward`, or
616
+ `keystore`. Keystore is an explicit caller-managed mode: `createSigner(env)`
617
+ throws instead of loading a file or falling back to `BUYER_PRIVATE_KEY`. A caller
618
+ such as `openpay-x402-mcp` loads its wallet and uses
619
+ `createSignerFromOptions({ privateKey })`, passing the signer to the executor.
620
+ The keystore guard returns `wallet_not_initialized` when `signerAvailable` is
621
+ false; env-key and Steward retain their existing guard behavior. File storage
622
+ and the default daily limit for keystore are MCP responsibilities.
623
+
624
+ `safeErrorMessage` / `redactSensitiveText` redact 32-byte key hex and 65-byte
625
+ signature hex in error text. Do not apply these functions to payment payloads:
626
+ authorization nonces are also 32-byte hex. OpenPay does not receive, store, or
627
+ recover local wallet keys (OpenPay は鍵を受け取らない・保管しない・復元できない).
628
+
580
629
  Choose exactly one of `privateKey`, `steward`, or `signer`. Supplying more than
581
630
  one is a startup error. A custom signer has an EVM `address` and an async
582
631
  `signTypedData(typedData)` method.
package/index.d.ts CHANGED
@@ -166,7 +166,7 @@ export type OpenPayClientOptions = ClientCommonOptions &
166
166
  );
167
167
 
168
168
  export interface RuntimeConfig {
169
- signerMode: 'env-key' | 'steward';
169
+ signerMode: 'env-key' | 'steward' | 'keystore';
170
170
  buyerPrivateKey: string | null;
171
171
  stewardApiKey: string | null;
172
172
  stewardSignerSecret: string | null;
@@ -193,6 +193,12 @@ 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;
198
+ /** Short display name (first-party, or seller-provided). */
199
+ title?: string;
200
+ /** One line describing when an agent should buy this resource (optional). */
201
+ trigger?: string;
196
202
  resource: string;
197
203
  description?: string;
198
204
  category?: string;
@@ -309,6 +315,10 @@ export function createOpenPayClient(
309
315
 
310
316
  export interface JpycGateOptions {
311
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;
312
322
  openpayOrigin?: string;
313
323
  fetchImpl?: typeof globalThis.fetch;
314
324
  now?: () => number;
@@ -496,14 +506,15 @@ export interface LicenseGate {
496
506
  export function createLicenseGate(options: LicenseGateOptions): LicenseGate;
497
507
 
498
508
  export interface DualGateOptions extends JpycGateOptions {
499
- /** OpenPay listing id (MY_RESOURCE_ID in the generated snippet). Enables the USDC (Base) rail. */
500
- resourceId: string;
509
+ /** Trusted USDC recipient (payTo); required even when equal to the JPYC recipient. */
510
+ expectedUsdcRecipient: Address;
501
511
  }
502
512
 
503
513
  /**
504
514
  * Dual-rail seller gate: JPYC (Polygon, OpenPay facilitator) plus USDC (Base, standard x402
505
515
  * relayed to the CDP facilitator via OpenPay). If the USDC face cannot be fetched (relay off
506
- * 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.
507
518
  */
508
519
  export function createDualGate(options: DualGateOptions): JpycGate;
509
520
 
@@ -698,6 +709,7 @@ export const REASONS: {
698
709
  dailyAuthorizationCrossesUtcDay: 'daily_authorization_crosses_utc_day';
699
710
  buyerPrivateKeyMissing: 'buyer_private_key_missing';
700
711
  stewardSignerUnconfigured: 'steward_signer_unconfigured';
712
+ walletNotInitialized: 'wallet_not_initialized';
701
713
  catalogAcceptMismatch: 'catalog_accept_mismatch';
702
714
  };
703
715
  export const SUPPORTED_JPYC_ASSETS: Readonly<
@@ -822,10 +834,11 @@ export function safeErrorMessage(
822
834
  export const SIGNER_MODES: {
823
835
  envKey: 'env-key';
824
836
  steward: 'steward';
837
+ keystore: 'keystore';
825
838
  };
826
839
  export function readSignerMode(
827
840
  env?: Record<string, string | undefined>,
828
- ): 'env-key' | 'steward';
841
+ ): 'env-key' | 'steward' | 'keystore';
829
842
  export function createSigner(
830
843
  env?: Record<string, string | undefined>,
831
844
  options?: { fetchImpl?: typeof globalThis.fetch },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
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;
package/src/guards.mjs CHANGED
@@ -47,6 +47,7 @@ export const REASONS = {
47
47
  dailyAuthorizationCrossesUtcDay: 'daily_authorization_crosses_utc_day',
48
48
  buyerPrivateKeyMissing: 'buyer_private_key_missing',
49
49
  stewardSignerUnconfigured: 'steward_signer_unconfigured',
50
+ walletNotInitialized: 'wallet_not_initialized',
50
51
  // catalog trust 経由 (第三者ドメイン) の URL で、支払い時にライブ fetch した accept が
51
52
  // discovery 掲載 accept (OpenPay サーバー生成の権威値) と食い違う = bait-and-switch。
52
53
  catalogAcceptMismatch: 'catalog_accept_mismatch',
@@ -554,6 +555,8 @@ export function evaluatePaymentGuards({
554
555
  if (requirePrivateKey || requireSigner) {
555
556
  if (config.signerMode === SIGNER_MODES.steward) {
556
557
  if (!signerAvailable) reasons.push(REASONS.stewardSignerUnconfigured);
558
+ } else if (config.signerMode === SIGNER_MODES.keystore) {
559
+ if (!signerAvailable) reasons.push(REASONS.walletNotInitialized);
557
560
  } else if (config.buyerPrivateKey === null) {
558
561
  reasons.push(REASONS.buyerPrivateKeyMissing);
559
562
  }
@@ -574,7 +577,11 @@ export function redactSensitiveText(text, secrets = []) {
574
577
  out = out.split(secret).join('[redacted_private_key]');
575
578
  }
576
579
  }
577
- return out.replace(/\b0x[0-9a-fA-F]{130}\b/g, '[redacted_signature]');
580
+ return out
581
+ .replace(/\b0x[0-9a-fA-F]{130}\b/g, '[redacted_signature]')
582
+ // 32 バイトの hex は秘密鍵と同じ形だが、取引ハッシュや nonce でもあり得る。「鍵が漏れた」と
583
+ // 誤解させないラベルにする (既知の秘密の置換は上の [redacted_private_key] のまま)。
584
+ .replace(/\b0x[0-9a-fA-F]{64}\b/g, '[redacted_32byte_hex]');
578
585
  }
579
586
 
580
587
  export function safeErrorMessage(error, config = {}) {
@@ -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/signer.mjs CHANGED
@@ -4,6 +4,7 @@ import { privateKeyToAccount } from 'viem/accounts';
4
4
  export const SIGNER_MODES = {
5
5
  envKey: 'env-key',
6
6
  steward: 'steward',
7
+ keystore: 'keystore',
7
8
  };
8
9
 
9
10
  function nonEmpty(raw) {
@@ -12,8 +13,8 @@ function nonEmpty(raw) {
12
13
 
13
14
  export function readSignerMode(env = process.env) {
14
15
  const mode = nonEmpty(env.SIGNER_MODE) ?? SIGNER_MODES.envKey;
15
- if (mode !== SIGNER_MODES.envKey && mode !== SIGNER_MODES.steward) {
16
- throw new Error('SIGNER_MODE must be "env-key" or "steward"');
16
+ if (mode !== SIGNER_MODES.envKey && mode !== SIGNER_MODES.steward && mode !== SIGNER_MODES.keystore) {
17
+ throw new Error('SIGNER_MODE must be "env-key", "steward", or "keystore"');
17
18
  }
18
19
  return mode;
19
20
  }
@@ -182,6 +183,9 @@ function createStewardSigner(env, fetchImpl) {
182
183
 
183
184
  export function createSigner(env = process.env, { fetchImpl = fetch } = {}) {
184
185
  const mode = readSignerMode(env);
186
+ if (mode === SIGNER_MODES.keystore) {
187
+ throw new Error('keystore keys must be supplied by the caller via createSignerFromOptions');
188
+ }
185
189
  if (mode === SIGNER_MODES.envKey) return createEnvKeySigner(env);
186
190
  return createStewardSigner(env, fetchImpl);
187
191
  }