openpay-x402-sdk 0.5.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 CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - Add `createDualGate` — a dual-rail seller gate that serves both JPYC (Polygon,
6
+ OpenPay facilitator) and USDC (Base, standard x402 relayed via OpenPay to the
7
+ CDP facilitator). USDC payments settle directly to the seller wallet with 0%
8
+ OpenPay fee; if the USDC face cannot be fetched, the gate degrades to
9
+ JPYC-only and never blocks JPYC payments.
10
+ - Add `createListingClient` — programmatic marketplace listing (register, list,
11
+ update, deactivate) with built-in SIWE sign-in, so sellers and agents can
12
+ publish listings without the web form. `register` requires an explicit
13
+ `attested: true` (the SDK never attests on your behalf); set `usdc` to also
14
+ appear on the x402 Bazaar after the first settled purchase.
15
+ - Return an explicit `settlement` field from `pay()`, because HTTP `200` only
16
+ means the seller returned a body and is not evidence that the payment settled.
17
+ `verified` means a receipt header was present and the facilitator signature
18
+ bound it to this payment, `unverified` means a header was present but
19
+ unsigned, malformed, forged, or mismatched, and `receipt_unavailable` means no
20
+ header was returned or the facilitator signer could not be resolved. Treat
21
+ anything but `verified` as not proven paid. `receipt` keeps its previous
22
+ meaning and an unlocked response body is still never discarded.
23
+ - Take over a spend lock left behind by a killed process instead of failing
24
+ budgeted payments forever. A lock whose last modification is older than
25
+ `SPEND_LOCK_STALE_MS` (60s, now exported) is moved aside with an atomic
26
+ `rename` — never `unlink` — and the mover re-inspects the moved file to prove
27
+ it took the very lock it measured, so two processes that observe the same
28
+ stale lock cannot both enter the critical section. A lock younger than the
29
+ window is left alone, so a live holder is never displaced.
30
+ - Record the owning `pid` and `createdAt` in the lock file, name the lock path in
31
+ a new `detail` on `{ ok: false, reason: 'unavailable' }`, and treat an
32
+ already-absent lock at release time as a completed release. A custom `fsImpl`
33
+ without `stat`/`rename` keeps the previous fail-closed behavior and now warns
34
+ once instead of disabling the takeover silently.
35
+ - Resolve the target hostname before calling an injected custom `fetchImpl`, so a
36
+ public name pointing at a private or link-local address is rejected before the
37
+ custom transport runs. Connection-time rebinding protection still requires
38
+ supplying `lookup`; a resolver failure does not block, since only the transport
39
+ that opens the socket can re-validate the address it connects to.
40
+ - Require `DISCOVERY_URL` to be `https`, with plaintext `http` allowed only for
41
+ `localhost` / `127.0.0.1`. The discovery origin is the authority for catalog
42
+ trust — URLs it lists are payable without an `ALLOWED_HOSTS` entry — so a
43
+ substitutable plaintext catalog could pass an attacker's resource off as
44
+ reviewed. Both `readRuntimeConfig` and `parseClientOptions` enforce it.
45
+ - Declare the new surface in `index.d.ts` (`SETTLEMENT`, `SettlementStatus`,
46
+ `PaymentResult.settlement`, `SPEND_LOCK_STALE_MS`, `SpendReservationResult.detail`,
47
+ `fsImpl.stat`, `createDualGate`, `createListingClient` and their inputs) and
48
+ document dual-rail selling, code-side listing, settlement truth, stale-lock
49
+ takeover, and the custom-transport boundary in the README.
50
+
3
51
  ## 0.5.0
4
52
 
5
53
  - Reserve session and daily capacity immediately before exposing a signed
package/README.md CHANGED
@@ -121,6 +121,54 @@ ledger.
121
121
  The copy-paste paywall snippet generated by OpenPay provides the same one-shot
122
122
  gate; `createJpycGate` is its importable SDK counterpart with split settlement.
123
123
 
124
+ ### Dual-rail: also sell in USDC (Base) and appear on the x402 Bazaar
125
+
126
+ 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
128
+ both JPYC and USDC `accepts` plus a `PAYMENT-REQUIRED` header; USDC payments are
129
+ relayed by OpenPay to the CDP facilitator and settle directly to your Base
130
+ 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.
132
+
133
+ ```js
134
+ import { createDualGate } from 'openpay-x402-sdk';
135
+
136
+ const gate = createDualGate({
137
+ resourceUrl: process.env.MY_RESOURCE_URL,
138
+ resourceId: process.env.MY_RESOURCE_ID,
139
+ });
140
+ ```
141
+
142
+ `handle()` / `verify()` work exactly like `createJpycGate`, on both rails.
143
+
144
+ ### Register listings without the web form
145
+
146
+ `createListingClient` signs in with SIWE and manages your marketplace listings
147
+ programmatically — so a seller (or an agent) can go from nothing to a dual-rail
148
+ listing entirely in code:
149
+
150
+ ```js
151
+ import { createListingClient } from 'openpay-x402-sdk';
152
+
153
+ const listings = createListingClient({ privateKey: process.env.SELLER_PRIVATE_KEY });
154
+ const { resource, paywallSnippet } = await listings.register({
155
+ url: 'https://api.example.com/paid/report',
156
+ description: 'What the purchase completes, in one paragraph.',
157
+ priceJpyc: '100',
158
+ category: 'api',
159
+ usdc: { priceUsd: '0.01', serviceName: 'Example Report API' }, // optional USDC face
160
+ attested: true, // your personal attestation — the SDK never sets this for you
161
+ });
162
+ // resource.id → pass to createDualGate; paywallSnippet → or paste the snippet instead
163
+ ```
164
+
165
+ `register` refuses to run without an explicit `attested: true`: you must
166
+ personally affirm that you have the right to provide and charge for the
167
+ resource and that it is payment-gated (HTTP 402). `list()`, `update(id, input)`,
168
+ and `deactivate(id)` complete the lifecycle; `update` without `usdc` removes the
169
+ USDC face, so pass the previous value to keep it. The private key signs locally
170
+ and is never transmitted.
171
+
124
172
  ## Money guards
125
173
 
126
174
  | Option | Default | Guard |
@@ -159,9 +207,12 @@ atomic `reserve(key, amount, limit, reservation)` method; legacy
159
207
  config readers.
160
208
 
161
209
  An abrupt process or machine stop can leave `spend.json.lock`; reservations then
162
- fail closed instead of paying without a limit. Stop every process that uses the
163
- same store before inspecting and manually removing a stale lock. Never remove
164
- the lock while another process may still own it.
210
+ fail closed instead of paying without a limit. The lock file records the owning
211
+ `pid` and `createdAt`, and a lock whose last modification is older than 60
212
+ seconds is taken over automatically on the next reservation, so a killed process
213
+ no longer blocks budgeted payments forever. A lock younger than that is left
214
+ alone and the rejection names the lock path in its `detail`. Stop every process
215
+ that uses the same store before removing a lock by hand.
165
216
 
166
217
  The client also rejects non-JPYC metadata, unsupported networks or schemes,
167
218
  non-canonical JPYC contracts or EIP-712 domains, seller timeouts above
@@ -172,9 +223,14 @@ signature. `MAX_TIMEOUT_SECONDS` is the equivalent setting for the exported
172
223
  environment config readers. Target host/catalog admission and private-address
173
224
  checks run before buyer target requests. Those requests require HTTPS, do not
174
225
  follow redirects, and have a 15-second timeout. The default Node transport also
175
- validates DNS before and during connection to block rebinding; an explicitly
176
- injected custom `fetchImpl` is a trusted transport boundary and is responsible
177
- for equivalent connect-time enforcement.
226
+ validates DNS before and during connection to block rebinding. A custom
227
+ `fetchImpl` still gets the pre-connection resolution check a hostname that
228
+ resolves to a private or link-local address is rejected before the injected
229
+ transport is called — but connection-time rebinding protection requires also
230
+ supplying `lookup`, because only the transport that opens the socket can
231
+ re-validate the address it actually connects to. A custom `fetchImpl` without
232
+ `lookup` therefore remains a trusted transport boundary for connect-time
233
+ enforcement.
178
234
 
179
235
  `pay()` returns a non-null `receipt` only when the facilitator signer advertised
180
236
  by `/api/facilitator/supported` signed it and the transaction, payer, network,
@@ -182,6 +238,14 @@ asset, merchant amount, fee, chain, and authorization nonce all match this
182
238
  payment. A missing, malformed, forged, or mismatched seller response header
183
239
  becomes `receipt: null` without discarding the unlocked response body.
184
240
 
241
+ `pay()` also returns an explicit `settlement` field, because an HTTP `200` only
242
+ means the seller returned a body and is not evidence that the payment settled.
243
+ `verified` means a receipt header was present and its facilitator signature was
244
+ bound to this payment; `unverified` means a header was present but unsigned,
245
+ malformed, forged, or mismatched; `receipt_unavailable` means no header was
246
+ returned or the facilitator signer could not be resolved. Treat `unverified` and
247
+ `receipt_unavailable` as not proven paid.
248
+
185
249
  ## Signers
186
250
 
187
251
  Choose exactly one of `privateKey`, `steward`, or `signer`. Supplying more than
package/index.d.ts CHANGED
@@ -75,6 +75,11 @@ export type SpendReservationResult =
75
75
  ok: false;
76
76
  reason: 'limit_exceeded' | 'unavailable';
77
77
  totalAtomic?: string;
78
+ /**
79
+ * Operator-facing hint for `unavailable`, present only when the block came from the
80
+ * lock file (it names the lock path). Never parse it; it is diagnostic text.
81
+ */
82
+ detail?: string;
78
83
  };
79
84
 
80
85
  export type PaymentLookup = (
@@ -97,9 +102,21 @@ export interface FileSpendStoreOptions {
97
102
  ): Promise<{ close(): Promise<unknown> }>;
98
103
  rename?(from: string, to: string): Promise<unknown>;
99
104
  unlink?(path: string): Promise<unknown>;
105
+ /**
106
+ * Required (together with `rename`) for taking over a lock left behind by a killed
107
+ * process. When either is missing the takeover is disabled and the SDK warns once —
108
+ * a stale lock then blocks budgeted payments until it is removed by hand.
109
+ */
110
+ stat?(path: string): Promise<{ mtimeMs?: number; mtime?: Date | number }>;
100
111
  };
101
112
  }
102
113
 
114
+ /**
115
+ * A spend lock older than this cannot belong to a live holder, so it may be taken over.
116
+ * Exported so operators and tests can reason about the same window as the store.
117
+ */
118
+ export const SPEND_LOCK_STALE_MS: number;
119
+
103
120
  interface ClientCommonOptions {
104
121
  maxPerCallJpyc?: JpycAmount;
105
122
  maxSessionJpyc?: JpycAmount;
@@ -243,10 +260,30 @@ export interface InvalidChallengeQuote {
243
260
 
244
261
  export type QuoteResult = GuardedQuote | InvalidChallengeQuote;
245
262
 
263
+ /**
264
+ * Settlement truth for one paid call. `status: 200` alone is not evidence of settlement.
265
+ * - `verified`: a receipt header was present and the facilitator signature bound it to this payment.
266
+ * - `unverified`: a receipt header was present but unsigned, malformed, forged, or mismatched.
267
+ * - `receipt_unavailable`: no receipt header, or the facilitator signer could not be resolved.
268
+ */
269
+ export type SettlementStatus = 'verified' | 'unverified' | 'receipt_unavailable';
270
+
271
+ /** The `SettlementStatus` values as a runtime object (`unavailable` = `receipt_unavailable`). */
272
+ export const SETTLEMENT: {
273
+ readonly verified: 'verified';
274
+ readonly unverified: 'unverified';
275
+ readonly unavailable: 'receipt_unavailable';
276
+ };
277
+
246
278
  export interface PaymentResult {
247
279
  status: number;
248
280
  body: unknown;
249
281
  receipt: unknown;
282
+ /**
283
+ * Optional so objects built before this field existed stay source-compatible. Absent must be
284
+ * read the same way as `receipt_unavailable`: no settlement was proven.
285
+ */
286
+ settlement?: SettlementStatus;
250
287
  }
251
288
 
252
289
  export interface OpenPayClient {
@@ -296,6 +333,94 @@ export interface JpycGate {
296
333
 
297
334
  export function createJpycGate(options: JpycGateOptions): JpycGate;
298
335
 
336
+ export interface DualGateOptions extends JpycGateOptions {
337
+ /** OpenPay listing id (MY_RESOURCE_ID in the generated snippet). Enables the USDC (Base) rail. */
338
+ resourceId: string;
339
+ }
340
+
341
+ /**
342
+ * Dual-rail seller gate: JPYC (Polygon, OpenPay facilitator) plus USDC (Base, standard x402
343
+ * relayed to the CDP facilitator via OpenPay). If the USDC face cannot be fetched (relay off
344
+ * or unavailable), the gate degrades to JPYC-only — the USDC side never blocks JPYC payments.
345
+ */
346
+ export function createDualGate(options: DualGateOptions): JpycGate;
347
+
348
+ export interface ListingUsdcInput {
349
+ /** USD price without a $ sign, up to 6 decimals (e.g. "0.005"). */
350
+ priceUsd: string;
351
+ /** USDC (Base) receiving address. Defaults to the JPYC payTo. */
352
+ payTo?: string;
353
+ /** Display name for x402 Bazaar search (max 60 chars). */
354
+ serviceName?: string;
355
+ }
356
+
357
+ export interface ListingInput {
358
+ url: string;
359
+ description: string;
360
+ /** Integer JPYC price as a string (e.g. "100"). */
361
+ priceJpyc: string;
362
+ category: string;
363
+ docsUrl?: string;
364
+ license?: string;
365
+ /** JPYC receiving address. Defaults to the signed-in wallet. */
366
+ payTo?: string;
367
+ /** Enable the USDC (Base) face — also lists on the x402 Bazaar after the first settle. */
368
+ usdc?: ListingUsdcInput;
369
+ }
370
+
371
+ export interface RegisterListingInput extends ListingInput {
372
+ /**
373
+ * Required, must be literally true: your personal attestation that you have the right to
374
+ * provide and charge for this resource and that it is payment-gated (HTTP 402).
375
+ * The SDK never sets this for you.
376
+ */
377
+ attested: true;
378
+ }
379
+
380
+ export interface ListingRecord {
381
+ id: string;
382
+ url: string;
383
+ description: string;
384
+ priceJpyc: string;
385
+ category: string;
386
+ payTo: string;
387
+ docsUrl?: string;
388
+ license?: string;
389
+ usdc?: { payTo: string; priceUsd: string; serviceName?: string };
390
+ paywallSnippet?: string;
391
+ hidden?: boolean;
392
+ }
393
+
394
+ export interface RegisterListingResult {
395
+ resource: ListingRecord;
396
+ /** Copy-paste 402 gate for your server (dual-rail x402Gate when usdc is set). */
397
+ paywallSnippet: string;
398
+ }
399
+
400
+ export interface ListingClient {
401
+ /** The seller wallet address that signs in via SIWE (checksummed). */
402
+ address: Address;
403
+ register(input: RegisterListingInput): Promise<RegisterListingResult>;
404
+ list(): Promise<ListingRecord[]>;
405
+ update(id: string, input: ListingInput): Promise<{ resource: ListingRecord }>;
406
+ deactivate(id: string): Promise<boolean>;
407
+ }
408
+
409
+ /**
410
+ * Programmatic listing client — register, list, update, and deactivate OpenPay marketplace
411
+ * listings without the web form. Signs in with SIWE using the given private key on first use;
412
+ * the key is only used to sign locally and is never transmitted.
413
+ */
414
+ export function createListingClient(options: {
415
+ privateKey: string;
416
+ openpayOrigin?: string;
417
+ fetchImpl?: typeof globalThis.fetch;
418
+ /** SIWE chainId (default 137 = Polygon). */
419
+ chainId?: number;
420
+ statement?: string;
421
+ now?: () => number;
422
+ }): ListingClient;
423
+
299
424
  export const RECEIVE_WITH_AUTHORIZATION_TYPES: {
300
425
  ReceiveWithAuthorization: Array<{ name: string; type: string }>;
301
426
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -0,0 +1,196 @@
1
+ // dual-rail 出品者ゲート: createJpycGate (JPYC・Polygon) に USDC (Base・標準 x402) の面を重ねる。
2
+ //
3
+ // USDC 面は OpenPay のリレー (/api/x402/relay/*) から取得・中継する:
4
+ // - requirements: 402 に並記する完成形 (v1Accepts + PAYMENT-REQUIRED ヘッダ) を配布
5
+ // - verify/settle: CDP facilitator への中継 (支払いは購入者 → 出品者 payTo へ直接)
6
+ //
7
+ // 隔離 (最重要): USDC 面の取得失敗 (リレー未点灯・障害) は null に落とし、JPYC ゲートだけで
8
+ // 継続する — 付帯面 (USDC) の障害が決済本体 (JPYC) を止めない。
9
+ //
10
+ // レール振り分け: PAYMENT-SIGNATURE ヘッダ (v2 = USDC クライアント)、または x-payment (v1) の
11
+ // network が USDC 面と一致するときだけ USDC レール。その他は従来の JPYC ゲートへ委譲する。
12
+ // JPYC レールの 402 には USDC accepts を追記 (decorate) して両面を常に見せる。
13
+
14
+ import { createJpycGate } from './gate.mjs';
15
+
16
+ const DEFAULT_OPENPAY_ORIGIN = 'https://open-pay.jp';
17
+ const USDC_FACE_CACHE_MS = 5 * 60_000;
18
+
19
+ function decodeBase64Json(value) {
20
+ const binary = atob(value);
21
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
22
+ return JSON.parse(new TextDecoder().decode(bytes));
23
+ }
24
+
25
+ function encodeBase64Json(value) {
26
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
27
+ let binary = '';
28
+ for (const byte of bytes) binary += String.fromCharCode(byte);
29
+ return btoa(binary);
30
+ }
31
+
32
+ export function createDualGate({
33
+ resourceUrl,
34
+ resourceId,
35
+ openpayOrigin = DEFAULT_OPENPAY_ORIGIN,
36
+ fetchImpl = globalThis.fetch,
37
+ now = Date.now,
38
+ maxUpstreamSeconds,
39
+ 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
+ }
44
+ const jpyc = createJpycGate({
45
+ resourceUrl,
46
+ openpayOrigin,
47
+ fetchImpl,
48
+ now,
49
+ ...(maxUpstreamSeconds === undefined ? {} : { maxUpstreamSeconds }),
50
+ ...(settlementGraceSeconds === undefined ? {} : { settlementGraceSeconds }),
51
+ });
52
+ const origin = openpayOrigin.replace(/\/+$/, '');
53
+
54
+ let usdcCache = null;
55
+ let usdcCachedAt = 0;
56
+ async function usdcFace() {
57
+ if (usdcCache !== null && now() - usdcCachedAt < USDC_FACE_CACHE_MS) {
58
+ return usdcCache;
59
+ }
60
+ try {
61
+ const response = await fetchImpl(
62
+ `${origin}/api/x402/relay/requirements?resourceId=${encodeURIComponent(resourceId)}`,
63
+ );
64
+ 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
+ } catch {
71
+ // リレー未点灯/障害 → USDC 面なしで継続 (JPYC 本体を止めない)。キャッシュしない
72
+ // (復旧したら次のリクエストで拾う)。
73
+ return null;
74
+ }
75
+ }
76
+
77
+ // JPYC ゲートが返した 402 に USDC 面 (accepts + PAYMENT-REQUIRED ヘッダ) を追記する。
78
+ // 402 以外・USDC 面なし・body が読めない場合はそのまま返す (壊さない)。
79
+ async function decorate402(response, usdc) {
80
+ if (!usdc || !(response instanceof Response) || response.status !== 402) {
81
+ return response;
82
+ }
83
+ let body;
84
+ try {
85
+ body = await response.clone().json();
86
+ } catch {
87
+ return response;
88
+ }
89
+ if (!body || !Array.isArray(body.accepts)) return response;
90
+ const headers = new Headers(response.headers);
91
+ headers.set('content-type', 'application/json');
92
+ if (typeof usdc.paymentRequiredHeader === 'string') {
93
+ headers.set('PAYMENT-REQUIRED', usdc.paymentRequiredHeader);
94
+ }
95
+ return new Response(
96
+ JSON.stringify({ ...body, accepts: [...body.accepts, usdc.v1Accepts] }),
97
+ { status: 402, headers },
98
+ );
99
+ }
100
+
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
+ }
115
+ const headers = { 'content-type': 'application/json' };
116
+ if (typeof usdc.paymentRequiredHeader === 'string') {
117
+ headers['PAYMENT-REQUIRED'] = usdc.paymentRequiredHeader;
118
+ }
119
+ return new Response(
120
+ JSON.stringify({
121
+ x402Version: 1,
122
+ accepts: [...jpycAccepts, usdc.v1Accepts],
123
+ error,
124
+ }),
125
+ { status: 402, headers },
126
+ );
127
+ }
128
+
129
+ async function verify(request) {
130
+ const usdc = await usdcFace();
131
+ const signatureHeader = request.headers.get('payment-signature');
132
+ const v1Header = request.headers.get('x-payment');
133
+ let v1Network = null;
134
+ if (v1Header) {
135
+ try {
136
+ const decoded = decodeBase64Json(v1Header);
137
+ if (decoded && typeof decoded === 'object') v1Network = decoded.network;
138
+ } catch {
139
+ /* 不正 header は下のレール判定で JPYC 側に流し、そこで 402 になる */
140
+ }
141
+ }
142
+
143
+ const usdcRail =
144
+ usdc !== null &&
145
+ (Boolean(signatureHeader) ||
146
+ (typeof v1Network === 'string' && v1Network === usdc.v1Accepts.network));
147
+
148
+ if (usdcRail) {
149
+ const relay = async (path) => {
150
+ const response = await fetchImpl(`${origin}/api/x402/relay/${path}`, {
151
+ method: 'POST',
152
+ headers: { 'content-type': 'application/json' },
153
+ body: JSON.stringify({
154
+ resourceId,
155
+ ...(signatureHeader
156
+ ? { paymentSignatureHeader: signatureHeader }
157
+ : { paymentHeader: v1Header }),
158
+ }),
159
+ });
160
+ return response.json();
161
+ };
162
+ const verification = await relay('verify');
163
+ if (verification.isValid !== true) {
164
+ return usdcChallenge(usdc, verification.invalidReason || 'payment_invalid');
165
+ }
166
+ return {
167
+ async settle() {
168
+ const settlement = await relay('settle');
169
+ if (settlement.success !== true) {
170
+ return usdcChallenge(usdc, settlement.errorReason || 'settlement_failed');
171
+ }
172
+ return { paymentResponseHeader: encodeBase64Json(settlement) };
173
+ },
174
+ };
175
+ }
176
+
177
+ const result = await jpyc.verify(request);
178
+ if (result instanceof Response) return decorate402(result, usdc);
179
+ return {
180
+ async settle() {
181
+ const settlement = await result.settle();
182
+ return settlement instanceof Response
183
+ ? decorate402(settlement, usdc)
184
+ : settlement;
185
+ },
186
+ };
187
+ }
188
+
189
+ async function handle(request) {
190
+ const verification = await verify(request);
191
+ if (verification instanceof Response) return verification;
192
+ return verification.settle();
193
+ }
194
+
195
+ return { handle, verify };
196
+ }
package/src/executor.mjs CHANGED
@@ -127,36 +127,53 @@ function utcDate(value) {
127
127
  return instant.toISOString().slice(0, 10);
128
128
  }
129
129
 
130
- async function safeReceipt(
130
+ // Settlement truth for one paid call. HTTP 200 only means the seller returned a body; it is not
131
+ // evidence that the facilitator settled on chain, and an LLM reading `status: 200` will assume it is.
132
+ // 'verified' — a receipt header was present and the facilitator signature bound it to this payment
133
+ // 'unverified' — a receipt header was present but unsigned, malformed, forged, or mismatched
134
+ // 'receipt_unavailable' — no receipt header, or the facilitator signer could not be resolved
135
+ export const SETTLEMENT = Object.freeze({
136
+ verified: 'verified',
137
+ unverified: 'unverified',
138
+ unavailable: 'receipt_unavailable',
139
+ });
140
+
141
+ async function settlementOutcome(
131
142
  raw,
132
143
  reservation,
133
144
  normalizedAccept,
134
145
  resolveReceiptSigner,
135
146
  ) {
147
+ if (typeof raw !== 'string' || raw.length === 0) {
148
+ return { receipt: null, settlement: SETTLEMENT.unavailable };
149
+ }
136
150
  try {
137
151
  const decoded = decodePaymentResponse(raw);
138
- if (decoded === null) return null;
152
+ // A header that cannot be decoded is a seller claim we could not check, not an absent claim.
153
+ if (decoded === null) {
154
+ return { receipt: null, settlement: SETTLEMENT.unverified };
155
+ }
139
156
  const expectedSigner = await resolveReceiptSigner();
140
- if (
141
- expectedSigner === null ||
142
- !(await verifyBoundPaymentResponse(decoded, {
143
- expectedSigner,
144
- payer: reservation.payer,
145
- network: reservation.network,
146
- asset: reservation.asset,
147
- chainId: normalizedAccept.chainId,
148
- merchant: normalizedAccept.extra.openpay.merchant,
149
- merchantValue: normalizedAccept.extra.openpay.merchantValue,
150
- feeValue: normalizedAccept.extra.openpay.feeValue,
151
- nonce: reservation.id,
152
- }))
153
- ) {
154
- return null;
157
+ if (expectedSigner === null) {
158
+ return { receipt: null, settlement: SETTLEMENT.unavailable };
155
159
  }
156
- return decoded;
160
+ const bound = await verifyBoundPaymentResponse(decoded, {
161
+ expectedSigner,
162
+ payer: reservation.payer,
163
+ network: reservation.network,
164
+ asset: reservation.asset,
165
+ chainId: normalizedAccept.chainId,
166
+ merchant: normalizedAccept.extra.openpay.merchant,
167
+ merchantValue: normalizedAccept.extra.openpay.merchantValue,
168
+ feeValue: normalizedAccept.extra.openpay.feeValue,
169
+ nonce: reservation.id,
170
+ });
171
+ return bound
172
+ ? { receipt: decoded, settlement: SETTLEMENT.verified }
173
+ : { receipt: null, settlement: SETTLEMENT.unverified };
157
174
  } catch {
158
175
  // A seller-controlled receipt header must not erase an unlocked body and invite a duplicate payment.
159
- return null;
176
+ return { receipt: null, settlement: SETTLEMENT.unverified };
160
177
  }
161
178
  }
162
179
 
@@ -533,7 +550,7 @@ export function createPaymentExecutor({
533
550
  await confirmDailyReservation(reservation.id);
534
551
  }
535
552
  }
536
- const receipt = await safeReceipt(
553
+ const { receipt, settlement } = await settlementOutcome(
537
554
  unlocked.headers.get('x-payment-response'),
538
555
  reservation,
539
556
  normalizedAccept,
@@ -543,6 +560,7 @@ export function createPaymentExecutor({
543
560
  status: unlocked.status,
544
561
  body: unlockedBody,
545
562
  receipt,
563
+ settlement,
546
564
  };
547
565
  }
548
566
 
package/src/guards.mjs CHANGED
@@ -167,9 +167,20 @@ function parseHttpUrl(raw, label) {
167
167
  }
168
168
  }
169
169
 
170
- function requireHttpUrl(raw, label) {
170
+ // DISCOVERY_URL は catalog trust の権威 (ここに載る URL は ALLOWED_HOSTS への手動追加なしで
171
+ // 支払える)。平文 http はネットワーク上で差し替え可能なので、攻撃者のカタログが「審査済み」に
172
+ // 化けて支払い先を乗っ取る波及を断つため https に限る。例外はローカル開発 (localhost /
173
+ // 127.0.0.1) のみ — 自機の facilitator/カタログを立てて配線を確かめる用途。
174
+ function requireDiscoveryUrl(raw, label) {
171
175
  const url = parseHttpUrl(raw, label);
172
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
+ }
173
184
  return url.toString();
174
185
  }
175
186
 
@@ -206,7 +217,7 @@ export function readMoneyConfig(env = process.env) {
206
217
  export function readRuntimeConfig(env = process.env) {
207
218
  return {
208
219
  ...readMoneyConfig(env),
209
- discoveryUrl: requireHttpUrl(
220
+ discoveryUrl: requireDiscoveryUrl(
210
221
  nonEmpty(env.DISCOVERY_URL) ?? DEFAULT_DISCOVERY_URL,
211
222
  'DISCOVERY_URL',
212
223
  ),
@@ -264,7 +275,7 @@ export function parseClientOptions(options = {}) {
264
275
  : parseJpycToAtomic(options.maxDailyJpyc, 'MAX_DAILY_JPYC'),
265
276
  allowedHosts: parseAllowedHosts(options.allowedHosts),
266
277
  catalogTrust: options.catalogTrust ?? DEFAULT_CATALOG_TRUST,
267
- discoveryUrl: requireHttpUrl(
278
+ discoveryUrl: requireDiscoveryUrl(
268
279
  nonEmpty(options.discoveryUrl) ?? DEFAULT_DISCOVERY_URL,
269
280
  'DISCOVERY_URL',
270
281
  ),
package/src/index.mjs CHANGED
@@ -1,5 +1,7 @@
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';
@@ -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
+ }
package/src/network.mjs CHANGED
@@ -240,6 +240,25 @@ export async function fetchPaymentTarget(
240
240
  normalizedAddresses(
241
241
  await lookupWithSignal(resolver, parsed.hostname, signal),
242
242
  );
243
+ } else {
244
+ // A custom fetchImpl used to skip pre-resolution entirely, so a public hostname pointing at
245
+ // 169.254.169.254 or another private target reached the injected transport unchecked. Resolve
246
+ // with the default resolver and reject a proven-private target before the transport runs.
247
+ // A resolver failure is not itself proof of a private target and the custom transport resolves
248
+ // independently, so it does not block here; connection-time rebinding protection still
249
+ // requires supplying `lookup` (documented in the README).
250
+ let addresses = null;
251
+ try {
252
+ addresses = await lookupWithSignal(resolver, parsed.hostname, signal);
253
+ } catch (error) {
254
+ // An aborted pre-resolution is the caller's timeout, not a DNS verdict; continuing would
255
+ // hand the transport a dead signal.
256
+ if (signal.aborted) throw error;
257
+ addresses = null;
258
+ }
259
+ if (Array.isArray(addresses) && addresses.length > 0) {
260
+ normalizedAddresses(addresses);
261
+ }
243
262
  }
244
263
 
245
264
  const init = {
@@ -3,6 +3,12 @@ import { randomUUID } from 'node:crypto';
3
3
  const RESERVATIONS_KEY = '__openpayReservations';
4
4
  const LOCK_RETRY_COUNT = 40;
5
5
  const LOCK_RETRY_MS = 25;
6
+ // A lock older than this cannot belong to a live holder: the retry loop above waits only
7
+ // LOCK_RETRY_COUNT * LOCK_RETRY_MS (1s) and every critical section is a small read plus an
8
+ // atomic replace, so a live process either finishes or dies far inside this window. Without the
9
+ // takeover a SIGKILL leaves spend.json.lock behind and every budgeted payment fails forever.
10
+ export const SPEND_LOCK_STALE_MS = 60_000;
11
+ const LOCK_ERROR_CODE = 'ESPENDLOCK';
6
12
 
7
13
  function isObject(value) {
8
14
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -42,6 +48,14 @@ function reservationMatches(existing, reservation) {
42
48
  );
43
49
  }
44
50
 
51
+ // Only a lock error carries a path we raised ourselves; arbitrary errors stay opaque so a
52
+ // filesystem message cannot leak into a caller-visible field.
53
+ function unavailableDetail(error) {
54
+ return isObject(error) && error.code === LOCK_ERROR_CODE
55
+ ? { detail: error.message }
56
+ : {};
57
+ }
58
+
45
59
  function supportsAtomicFileUpdates(fileSystem) {
46
60
  return (
47
61
  typeof fileSystem.open === 'function' &&
@@ -50,6 +64,26 @@ function supportsAtomicFileUpdates(fileSystem) {
50
64
  );
51
65
  }
52
66
 
67
+ // stale takeover は「古さの判定 (stat)」と「奪取の原子化 (rename)」の両方を要する。
68
+ function supportsStaleTakeover(fileSystem) {
69
+ return (
70
+ typeof fileSystem.stat === 'function' && typeof fileSystem.rename === 'function'
71
+ );
72
+ }
73
+
74
+ let staleTakeoverWarned = false;
75
+
76
+ // 掟13: takeover が無効な fsImpl を **黙って** 素通りさせない。無効なら「SIGKILL で残った
77
+ // ロックが予算つき支払いを永久に止める」状態に戻るので、運用者が気づけるよう一度だけ警告する。
78
+ function warnStaleTakeoverUnavailable() {
79
+ if (staleTakeoverWarned) return;
80
+ staleTakeoverWarned = true;
81
+ console.warn(
82
+ 'openpay-x402-sdk: fsImpl lacks stat/rename — stale spend-lock takeover is disabled; ' +
83
+ 'a lock left behind by a killed process will block budgeted payments until it is removed.',
84
+ );
85
+ }
86
+
53
87
  export function createFileSpendStore({ path, fsImpl } = {}) {
54
88
  let runtimePromise;
55
89
 
@@ -87,17 +121,134 @@ export function createFileSpendStore({ path, fsImpl } = {}) {
87
121
  return document;
88
122
  }
89
123
 
124
+ function lockUnavailable(lockPath, detail) {
125
+ return Object.assign(
126
+ new Error(
127
+ `spend store lock unavailable: ${lockPath}${detail === undefined ? '' : ` (${detail})`}`,
128
+ ),
129
+ { code: LOCK_ERROR_CODE, lockPath },
130
+ );
131
+ }
132
+
133
+ // Record who holds the lock so an operator inspecting a stuck store can tell whether the pid
134
+ // is still alive. Failure to annotate must not release a lock we successfully created.
135
+ async function annotateLock(handle, lockPath) {
136
+ if (typeof handle?.writeFile !== 'function') return;
137
+ try {
138
+ await handle.writeFile(
139
+ JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }),
140
+ 'utf8',
141
+ );
142
+ } catch {
143
+ // The lock is held either way; the owner note is diagnostic only.
144
+ }
145
+ }
146
+
147
+ // 「そのロックファイルが何者か」を age と同一性 (inode + mtime) の両方で捉える。takeover は
148
+ // 「stat した stale そのものを奪えたか」を後段で照合する必要があるため、age だけでは足りない。
149
+ async function lockIdentity(fileSystem, lockPath) {
150
+ try {
151
+ const stats = await fileSystem.stat(lockPath);
152
+ const modified = Number(stats?.mtimeMs ?? stats?.mtime);
153
+ if (!Number.isFinite(modified)) return null;
154
+ return { ageMs: Date.now() - modified, modified, ino: stats?.ino };
155
+ } catch {
156
+ // A lock that vanished or cannot be inspected is not provably stale.
157
+ return null;
158
+ }
159
+ }
160
+
161
+ // inode が取れる実 fs では inode 一致が同一ファイルの証明。inode を返さない fsImpl では
162
+ // mtime 一致で代替する (takeover 直後の新しいロックは必ず mtime が今なので判別できる)。
163
+ function isSameLockFile(observed, moved) {
164
+ if (observed === null || moved === null) return false;
165
+ if (observed.ino !== undefined && moved.ino !== undefined) {
166
+ return observed.ino === moved.ino && observed.modified === moved.modified;
167
+ }
168
+ return observed.modified === moved.modified;
169
+ }
170
+
90
171
  async function acquireLock(fileSystem, lockPath) {
91
172
  for (let attempt = 0; attempt < LOCK_RETRY_COUNT; attempt += 1) {
92
173
  try {
93
- return await fileSystem.open(lockPath, 'wx');
174
+ const handle = await fileSystem.open(lockPath, 'wx');
175
+ await annotateLock(handle, lockPath);
176
+ return handle;
94
177
  } catch (error) {
95
178
  if (!isLockHeld(error)) throw error;
96
- if (attempt === LOCK_RETRY_COUNT - 1) throw error;
179
+ if (attempt === LOCK_RETRY_COUNT - 1) break;
97
180
  await wait(LOCK_RETRY_MS);
98
181
  }
99
182
  }
100
- throw new Error('spend store lock unavailable');
183
+
184
+ // Stale takeover. A fresh lock is left alone, so a live holder is never displaced; only a lock
185
+ // that outlived the 1s contention window is taken over.
186
+ //
187
+ // ⚠️ The takeover MUST NOT unlink lockPath. `unlink(lockPath)` then `open(lockPath,'wx')` lets
188
+ // two processes that both saw the same stale lock interleave as
189
+ // A: unlink → open (holds the lock)
190
+ // B: unlink (deletes A's *fresh* lock) → open (succeeds)
191
+ // so both run the critical section at once and one reservation is lost. `rename` is atomic and
192
+ // exactly one process can move the stale file away, so ownership of the takeover is proven
193
+ // before lockPath is recreated.
194
+ //
195
+ // ⚠️ rename が原子的なのは「移動」だけで、「stat した stale を移動した」ことまでは保証しない。
196
+ // stat と rename の間に相手が takeover を終えていると、掴むのは相手の **新しい** ロックになる
197
+ // (A: rename → open(保持) / B: rename で A のロックを退避 → open 成功 = 両者が臨界区間)。
198
+ // だから移動先を stat し直し、奪ったのが stat した stale 本人であることを照合する。
199
+ if (!supportsStaleTakeover(fileSystem)) {
200
+ warnStaleTakeoverUnavailable();
201
+ throw lockUnavailable(lockPath, 'held (stale takeover unavailable)');
202
+ }
203
+ const observed = await lockIdentity(fileSystem, lockPath);
204
+ if (observed === null || observed.ageMs < SPEND_LOCK_STALE_MS) {
205
+ throw lockUnavailable(
206
+ lockPath,
207
+ observed === null
208
+ ? 'held'
209
+ : `held for ${Math.round(observed.ageMs / 1000)}s`,
210
+ );
211
+ }
212
+ const stalePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
213
+ try {
214
+ await fileSystem.rename(lockPath, stalePath);
215
+ } catch (error) {
216
+ // ENOENT = another process won the same takeover and its lock is now fresh. Report the
217
+ // contention instead of guessing; the caller retries from the normal acquisition loop.
218
+ throw lockUnavailable(
219
+ lockPath,
220
+ isMissingFile(error)
221
+ ? 'reacquired by another process'
222
+ : 'stale, takeover failed',
223
+ );
224
+ }
225
+ // 退避したのが stat した stale 本人でなければ、生きた保持者のロックを奪ってしまっている。
226
+ // 元に戻してから撤退する (戻さないと保持者の解放が空振りし、次の到着者が素通りで入れてしまう)。
227
+ if (!isSameLockFile(observed, await lockIdentity(fileSystem, stalePath))) {
228
+ try {
229
+ await fileSystem.rename(stalePath, lockPath);
230
+ } catch {
231
+ // 復元できないケースまで握るのは危険だが、ここで投げても保持者のロックは戻らない。
232
+ // 呼び出し側には下の unavailable で「入れなかった」と伝わる方が安全側。
233
+ }
234
+ throw lockUnavailable(lockPath, 'reacquired by another process');
235
+ }
236
+ let handle;
237
+ try {
238
+ handle = await fileSystem.open(lockPath, 'wx');
239
+ } catch (error) {
240
+ if (!isLockHeld(error)) throw error;
241
+ throw lockUnavailable(lockPath, 'reacquired by another process');
242
+ } finally {
243
+ try {
244
+ await fileSystem.unlink(stalePath);
245
+ } catch {
246
+ // The stale file is already out of the lock path, so a leftover copy cannot hand the
247
+ // critical section to anyone; only disk tidiness suffers.
248
+ }
249
+ }
250
+ await annotateLock(handle, lockPath);
251
+ return handle;
101
252
  }
102
253
 
103
254
  async function withLock(operation) {
@@ -113,7 +264,14 @@ export function createFileSpendStore({ path, fsImpl } = {}) {
113
264
  try {
114
265
  await handle.close();
115
266
  } finally {
116
- await runtime.fileSystem.unlink(runtime.lockPath);
267
+ try {
268
+ await runtime.fileSystem.unlink(runtime.lockPath);
269
+ } catch (error) {
270
+ // ENOENT = ロックは既に無い (takeover を試みた相手が一瞬退避した等) = 解放の目的は達成済み。
271
+ // 解放の空振りで、書き込みまで終えた予約を unavailable に化けさせない。ENOENT 以外は
272
+ // 「ロックが残る」ので握りつぶさず投げる。
273
+ if (!isMissingFile(error)) throw error;
274
+ }
117
275
  }
118
276
  }
119
277
  }
@@ -237,8 +395,10 @@ export function createFileSpendStore({ path, fsImpl } = {}) {
237
395
  await writeDocument(runtime, current);
238
396
  return { ok: true, totalAtomic: next.toString() };
239
397
  });
240
- } catch {
241
- return { ok: false, reason: 'unavailable' };
398
+ } catch (error) {
399
+ // Name the lock file when the block came from it. A bare "unavailable" sends operators
400
+ // hunting through the payment path for a failure that is one leftover file.
401
+ return { ok: false, reason: 'unavailable', ...unavailableDetail(error) };
242
402
  }
243
403
  }
244
404