@bosonprotocol/x402-server 0.2.0 → 0.3.0-alpha-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/dist/cjs/index.js CHANGED
@@ -116,6 +116,31 @@ function buildForwardingAdapter(signer, chainId) {
116
116
  getCurrentTimeMs: () => unreachable2("getCurrentTimeMs")
117
117
  };
118
118
  }
119
+
120
+ // src/store.ts
121
+ function mapAsStore(m) {
122
+ return {
123
+ async get(key) {
124
+ return m.get(key);
125
+ },
126
+ async set(key, value) {
127
+ m.set(key, value);
128
+ },
129
+ async delete(key) {
130
+ m.delete(key);
131
+ },
132
+ async *entries() {
133
+ for (const e of m.entries()) yield e;
134
+ }
135
+ };
136
+ }
137
+ function isStore(value) {
138
+ if (value === null || typeof value !== "object") return false;
139
+ const v = value;
140
+ return typeof v.get === "function" && typeof v.set === "function" && typeof v.delete === "function" && typeof v.entries === "function";
141
+ }
142
+
143
+ // src/config.ts
119
144
  var httpUrlSchema = zod.z.string().url().refine((url) => url.startsWith("http://") || url.startsWith("https://"), {
120
145
  message: "must be an http(s) URL"
121
146
  });
@@ -138,22 +163,40 @@ var coreSdkReadShallowSchema = zod.z.object({
138
163
  var fulfillmentChannelShallowSchema = zod.z.object({
139
164
  id: zod.z.string().min(1),
140
165
  validate: zod.z.function(),
141
- onCommit: zod.z.function()
166
+ onCommit: zod.z.function(),
167
+ // Optional delivery dispatch — present on real channels, omitted by
168
+ // hosts that deliver out-of-band.
169
+ onFulfill: zod.z.function().optional()
142
170
  }).passthrough();
171
+ var asyncStoreSchema = () => zod.z.custom(isStore, {
172
+ message: "must implement the async Store<V> interface (get/set/delete/entries)"
173
+ });
143
174
  var x402bServerConfigSchema = zod.z.object({
144
175
  network: escrow.evmNetworkSchema,
145
176
  chainId: zod.z.number().int().positive(),
146
177
  escrow: escrow.addressSchema,
147
178
  signer: sellerSignerSchema,
148
179
  facilitator: zod.z.object({
149
- url: httpUrlSchema
180
+ url: httpUrlSchema,
181
+ timeoutMs: zod.z.number().int().positive().optional(),
182
+ retry: zod.z.object({
183
+ attempts: zod.z.number().int().positive(),
184
+ backoffMs: zod.z.number().int().nonnegative()
185
+ }).strict().optional(),
186
+ idempotencyKey: zod.z.function().args().returns(zod.z.string()).optional()
150
187
  }).strict(),
151
188
  channelRegistry: x402Actions.channelRegistryZodSchema,
152
189
  exchangeReader: exchangeReaderShallowSchema.optional(),
153
190
  subgraphUrl: httpUrlSchema.optional(),
154
191
  coreSdkRead: coreSdkReadShallowSchema.optional(),
155
- exchangeFulfillmentOptionStore: zod.z.instanceof(Map).optional(),
156
- fulfillmentRecoveryStore: zod.z.instanceof(Map).optional(),
192
+ exchangeFulfillmentOptionStore: asyncStoreSchema().optional(),
193
+ fulfillmentRecoveryStore: asyncStoreSchema().optional(),
194
+ logger: zod.z.object({
195
+ debug: zod.z.function(),
196
+ info: zod.z.function(),
197
+ warn: zod.z.function(),
198
+ error: zod.z.function()
199
+ }).passthrough().optional(),
157
200
  fulfillmentChannels: zod.z.array(fulfillmentChannelShallowSchema).superRefine((channels, ctx) => {
158
201
  const seen = /* @__PURE__ */ new Set();
159
202
  const duplicates = /* @__PURE__ */ new Set();
@@ -167,7 +210,8 @@ var x402bServerConfigSchema = zod.z.object({
167
210
  message: `fulfillmentChannels has duplicate id(s): ${[...duplicates].join(", ")}`
168
211
  });
169
212
  }
170
- }).optional()
213
+ }).optional(),
214
+ mode: zod.z.enum(["development", "production"]).optional()
171
215
  }).strict().superRefine((cfg, ctx) => {
172
216
  const networkChainId = Number(cfg.network.split(":")[1]);
173
217
  if (networkChainId !== cfg.chainId) {
@@ -177,6 +221,36 @@ var x402bServerConfigSchema = zod.z.object({
177
221
  message: `chainId (${cfg.chainId}) must match network (${cfg.network})`
178
222
  });
179
223
  }
224
+ if (cfg.mode === "production") {
225
+ if (cfg.exchangeReader === void 0) {
226
+ ctx.addIssue({
227
+ code: zod.z.ZodIssueCode.custom,
228
+ path: ["exchangeReader"],
229
+ message: "required when mode is 'production' (post-settle state verification)"
230
+ });
231
+ }
232
+ if (cfg.coreSdkRead === void 0 && cfg.subgraphUrl === void 0) {
233
+ ctx.addIssue({
234
+ code: zod.z.ZodIssueCode.custom,
235
+ path: ["subgraphUrl"],
236
+ message: "one of `coreSdkRead` or `subgraphUrl` is required when mode is 'production' (read client for withdraw / available-funds)"
237
+ });
238
+ }
239
+ if (cfg.exchangeFulfillmentOptionStore === void 0) {
240
+ ctx.addIssue({
241
+ code: zod.z.ZodIssueCode.custom,
242
+ path: ["exchangeFulfillmentOptionStore"],
243
+ message: "required when mode is 'production'; without a persistent store the Flow A redeem-time option gate silently relaxes after a restart"
244
+ });
245
+ }
246
+ if (cfg.fulfillmentRecoveryStore === void 0) {
247
+ ctx.addIssue({
248
+ code: zod.z.ZodIssueCode.custom,
249
+ path: ["fulfillmentRecoveryStore"],
250
+ message: "required when mode is 'production'; without a persistent store, post-settle channel-onCommit failures lose their replay handle"
251
+ });
252
+ }
253
+ }
180
254
  });
181
255
  function assertChannelRegistryEscrowMatch(config) {
182
256
  if (config.escrow.toLowerCase() !== config.channelRegistry.escrow.toLowerCase()) {
@@ -197,7 +271,22 @@ var FacilitatorHttpError = class extends Error {
197
271
  }
198
272
  };
199
273
 
274
+ // src/logger.ts
275
+ var noopLogger = {
276
+ debug: () => {
277
+ },
278
+ info: () => {
279
+ },
280
+ warn: () => {
281
+ },
282
+ error: () => {
283
+ }
284
+ };
285
+
200
286
  // src/facilitator/client.ts
287
+ var DEFAULT_TIMEOUT_MS = 3e4;
288
+ var DEFAULT_RETRY = { attempts: 3, backoffMs: 200 };
289
+ var IDEMPOTENCY_KEY_HEADER = "x-x402b-idempotency-key";
201
290
  function createFacilitatorClient(opts) {
202
291
  const fetchImpl = opts.fetch ?? globalThis.fetch;
203
292
  if (fetchImpl === void 0) {
@@ -207,19 +296,49 @@ function createFacilitatorClient(opts) {
207
296
  }
208
297
  const baseUrl = opts.url.replace(/\/+$/, "");
209
298
  const baseHeaders = { "content-type": "application/json", ...opts.headers ?? {} };
210
- const post = async (path, body, validate) => {
299
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
300
+ const retry = opts.retry ?? DEFAULT_RETRY;
301
+ if (!Number.isInteger(retry.attempts) || retry.attempts < 1) {
302
+ throw new Error("createFacilitatorClient: retry.attempts must be an integer >= 1");
303
+ }
304
+ if (!Number.isFinite(retry.backoffMs) || retry.backoffMs < 0) {
305
+ throw new Error("createFacilitatorClient: retry.backoffMs must be a finite number >= 0");
306
+ }
307
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
308
+ throw new Error("createFacilitatorClient: timeoutMs must be a finite number > 0");
309
+ }
310
+ const newIdempotencyKey = opts.idempotencyKey ?? (() => globalThis.crypto.randomUUID());
311
+ const setTimeoutImpl = opts.setTimeout ?? setTimeout;
312
+ const clearTimeoutImpl = opts.clearTimeout ?? clearTimeout;
313
+ const logger = opts.logger ?? noopLogger;
314
+ const postOnce = async (path, body, validate, extraHeaders) => {
315
+ const controller = new AbortController();
316
+ const timer = setTimeoutImpl(() => controller.abort(), timeoutMs);
317
+ const headers = { ...baseHeaders, ...extraHeaders };
211
318
  let res;
212
319
  try {
213
320
  res = await fetchImpl(`${baseUrl}${path}`, {
214
321
  method: "POST",
215
- headers: baseHeaders,
216
- body: JSON.stringify(body)
322
+ headers,
323
+ body: JSON.stringify(body),
324
+ signal: controller.signal
217
325
  });
218
326
  } catch (cause) {
219
- throw new FacilitatorHttpError(`facilitator network error (${path})`, {
220
- code: "NETWORK_ERROR",
221
- cause
327
+ const aborted = controller.signal.aborted;
328
+ logger.warn(aborted ? "facilitator request timed out" : "facilitator network error", {
329
+ path,
330
+ timeoutMs: aborted ? timeoutMs : void 0,
331
+ error: cause instanceof Error ? cause.message : String(cause)
222
332
  });
333
+ throw new FacilitatorHttpError(
334
+ aborted ? `facilitator request timed out after ${timeoutMs}ms (${path})` : `facilitator network error (${path})`,
335
+ {
336
+ code: aborted ? "TIMEOUT" : "NETWORK_ERROR",
337
+ cause
338
+ }
339
+ );
340
+ } finally {
341
+ clearTimeoutImpl(timer);
223
342
  }
224
343
  let text;
225
344
  try {
@@ -246,6 +365,12 @@ function createFacilitatorClient(opts) {
246
365
  return parsed;
247
366
  }
248
367
  const facilitatorCode = extractFacilitatorCode(parsed);
368
+ logger.warn("facilitator HTTP non-2xx", {
369
+ path,
370
+ status: res.status,
371
+ facilitatorCode,
372
+ reason: reasonString(parsed)
373
+ });
249
374
  throw new FacilitatorHttpError(
250
375
  `facilitator HTTP ${res.status} (${path}): ${reasonString(parsed) ?? text}`,
251
376
  {
@@ -263,16 +388,76 @@ function createFacilitatorClient(opts) {
263
388
  }
264
389
  return parsed;
265
390
  };
391
+ const post = async (path, body, validate, extraHeaders = {}) => {
392
+ let lastError;
393
+ for (let attempt = 0; attempt < retry.attempts; attempt += 1) {
394
+ if (attempt > 0) {
395
+ await sleep(retry.backoffMs * attempt, setTimeoutImpl);
396
+ }
397
+ try {
398
+ return await postOnce(path, body, validate, extraHeaders);
399
+ } catch (e) {
400
+ if (e instanceof FacilitatorHttpError && isRetryable(e)) {
401
+ lastError = e;
402
+ continue;
403
+ }
404
+ throw e;
405
+ }
406
+ }
407
+ throw lastError;
408
+ };
266
409
  return {
267
410
  verify: (input) => post("/verify", input, isVerifyResult),
268
- settle: (input) => post("/settle", input, isSettleResult),
411
+ settle: (input) => post("/settle", input, isSettleResult, {
412
+ [IDEMPOTENCY_KEY_HEADER]: newIdempotencyKey()
413
+ }),
269
414
  performAction: (input) => post(
270
415
  `/perform-action?action=${encodeURIComponent(input.action)}`,
271
416
  input,
272
417
  isPerformActionResult
273
- )
418
+ ),
419
+ async healthCheck() {
420
+ const controller = new AbortController();
421
+ const timer = setTimeoutImpl(() => controller.abort(), timeoutMs);
422
+ let res;
423
+ try {
424
+ res = await fetchImpl(`${baseUrl}/healthz`, {
425
+ method: "GET",
426
+ headers: { ...opts.headers ?? {} },
427
+ signal: controller.signal
428
+ });
429
+ } catch (cause) {
430
+ const aborted = controller.signal.aborted;
431
+ throw new FacilitatorHttpError(
432
+ aborted ? `facilitator request timed out after ${timeoutMs}ms (/healthz)` : "facilitator network error (/healthz)",
433
+ {
434
+ code: aborted ? "TIMEOUT" : "NETWORK_ERROR",
435
+ cause
436
+ }
437
+ );
438
+ } finally {
439
+ clearTimeoutImpl(timer);
440
+ }
441
+ if (!res.ok) {
442
+ throw new FacilitatorHttpError(`facilitator HTTP ${res.status} (/healthz)`, {
443
+ code: "BAD_HTTP_STATUS",
444
+ status: res.status
445
+ });
446
+ }
447
+ }
274
448
  };
275
449
  }
450
+ function isRetryable(e) {
451
+ if (e.code === "NETWORK_ERROR" || e.code === "TIMEOUT") return true;
452
+ if (e.code === "BAD_HTTP_STATUS" && e.status !== void 0 && e.status >= 500) return true;
453
+ return false;
454
+ }
455
+ function sleep(ms, setTimeoutImpl) {
456
+ if (ms <= 0) return Promise.resolve();
457
+ return new Promise((resolve) => {
458
+ setTimeoutImpl(resolve, ms);
459
+ });
460
+ }
276
461
  function isObject(v) {
277
462
  return v !== null && typeof v === "object";
278
463
  }
@@ -368,6 +553,21 @@ function emitNextActions(input, registry, facilitatorUrl) {
368
553
  };
369
554
  }
370
555
 
556
+ // src/handlers/fulfillment-result.ts
557
+ function serializeFulfillmentResult(result) {
558
+ if (result.kind === "inline") {
559
+ return {
560
+ kind: "inline",
561
+ body: Buffer.from(result.body).toString("base64"),
562
+ contentType: result.contentType
563
+ };
564
+ }
565
+ return {
566
+ kind: "async",
567
+ ...result.pointer !== void 0 ? { pointer: result.pointer } : {}
568
+ };
569
+ }
570
+
371
571
  // src/handlers/types.ts
372
572
  function handlerOk(body) {
373
573
  return { ok: true, status: 200, body };
@@ -889,6 +1089,7 @@ async function handleCommitAndRedeem(input, ctx) {
889
1089
  });
890
1090
  }
891
1091
  async function handleCommitImpl(input, ctx, expected) {
1092
+ const logger = ctx.logger ?? noopLogger;
892
1093
  const decoded = decodeXPaymentHeader(input.paymentHeader);
893
1094
  if (!decoded.ok) {
894
1095
  const status = decoded.code === "MISSING_HEADER" ? 402 : 400;
@@ -977,28 +1178,38 @@ async function handleCommitImpl(input, ctx, expected) {
977
1178
  );
978
1179
  }
979
1180
  if (expected.expectedState === x402Actions.ExchangeState.COMMITTED) {
980
- ctx.exchangeFulfillmentOptionStore.set(
1181
+ await ctx.exchangeFulfillmentOptionStore.set(
981
1182
  settleResult.exchangeId,
982
1183
  input.requirements.fulfillment?.options.map((option) => option.id) ?? []
983
1184
  );
984
1185
  }
985
1186
  const warnings = [];
1187
+ let delivery;
986
1188
  if (expected.expectedState === x402Actions.ExchangeState.REDEEMED && decoded.payload.fulfillment !== void 0 && decoded.payload.fulfillment.data !== void 0) {
987
1189
  const pending = {
988
1190
  exchangeId: settleResult.exchangeId,
989
1191
  option: decoded.payload.fulfillment.option,
990
1192
  data: decoded.payload.fulfillment.data,
991
1193
  redeemer: decoded.payload.payload.buyer,
992
- recordedAt: Date.now()
1194
+ recordedAt: Date.now(),
1195
+ phase: "commit"
993
1196
  };
994
- ctx.fulfillmentRecoveryStore.set(settleResult.exchangeId, pending);
1197
+ await ctx.fulfillmentRecoveryStore.set(settleResult.exchangeId, pending);
1198
+ logger.debug("x402-server: fulfillment recovery entry recorded (Flow B)", {
1199
+ exchangeId: settleResult.exchangeId,
1200
+ option: decoded.payload.fulfillment.option
1201
+ });
995
1202
  const channel = channelById.get(decoded.payload.fulfillment.option);
996
1203
  if (channel === void 0) {
997
1204
  const reason = "no channel adapter is registered";
998
- ctx.fulfillmentRecoveryStore.set(settleResult.exchangeId, {
1205
+ await ctx.fulfillmentRecoveryStore.set(settleResult.exchangeId, {
999
1206
  ...pending,
1000
1207
  error: reason
1001
1208
  });
1209
+ logger.error("x402-server: Flow B channel adapter missing post-settle", {
1210
+ exchangeId: settleResult.exchangeId,
1211
+ option: decoded.payload.fulfillment.option
1212
+ });
1002
1213
  warnings.push({
1003
1214
  code: "FULFILLMENT_COMMIT_DEFERRED",
1004
1215
  reason: "atomic redeem succeeded on-chain, but no channel adapter is registered",
@@ -1011,13 +1222,54 @@ async function handleCommitImpl(input, ctx, expected) {
1011
1222
  } else {
1012
1223
  try {
1013
1224
  await channel.onCommit(settleResult.exchangeId, decoded.payload.fulfillment.data);
1014
- ctx.fulfillmentRecoveryStore.delete(settleResult.exchangeId);
1225
+ if (channel.onFulfill !== void 0) {
1226
+ const deliveryPending = {
1227
+ ...pending,
1228
+ phase: "delivery",
1229
+ recordedAt: Date.now()
1230
+ };
1231
+ await ctx.fulfillmentRecoveryStore.set(settleResult.exchangeId, deliveryPending);
1232
+ try {
1233
+ delivery = serializeFulfillmentResult(await channel.onFulfill(settleResult.exchangeId));
1234
+ await ctx.fulfillmentRecoveryStore.delete(settleResult.exchangeId);
1235
+ logger.debug("x402-server: Flow B channel onCommit succeeded", {
1236
+ exchangeId: settleResult.exchangeId,
1237
+ option: decoded.payload.fulfillment.option
1238
+ });
1239
+ } catch (e) {
1240
+ const reason = e instanceof Error ? e.message : String(e);
1241
+ await ctx.fulfillmentRecoveryStore.set(settleResult.exchangeId, {
1242
+ ...deliveryPending,
1243
+ error: reason
1244
+ });
1245
+ warnings.push({
1246
+ code: "FULFILLMENT_DELIVERY_DEFERRED",
1247
+ reason: "atomic redeem succeeded on-chain, but the channel's delivery dispatch failed",
1248
+ details: {
1249
+ exchangeId: settleResult.exchangeId,
1250
+ option: decoded.payload.fulfillment.option,
1251
+ error: reason
1252
+ }
1253
+ });
1254
+ }
1255
+ } else {
1256
+ await ctx.fulfillmentRecoveryStore.delete(settleResult.exchangeId);
1257
+ logger.debug("x402-server: Flow B channel onCommit succeeded", {
1258
+ exchangeId: settleResult.exchangeId,
1259
+ option: decoded.payload.fulfillment.option
1260
+ });
1261
+ }
1015
1262
  } catch (e) {
1016
1263
  const reason = e instanceof Error ? e.message : String(e);
1017
- ctx.fulfillmentRecoveryStore.set(settleResult.exchangeId, {
1264
+ await ctx.fulfillmentRecoveryStore.set(settleResult.exchangeId, {
1018
1265
  ...pending,
1019
1266
  error: reason
1020
1267
  });
1268
+ logger.warn("x402-server: Flow B channel onCommit failed; recovery entry retained", {
1269
+ exchangeId: settleResult.exchangeId,
1270
+ option: decoded.payload.fulfillment.option,
1271
+ error: reason
1272
+ });
1021
1273
  warnings.push({
1022
1274
  code: "FULFILLMENT_COMMIT_DEFERRED",
1023
1275
  reason: "atomic redeem succeeded on-chain, but the channel adapter rejected the data",
@@ -1042,7 +1294,8 @@ async function handleCommitImpl(input, ctx, expected) {
1042
1294
  exchangeId: settleResult.exchangeId,
1043
1295
  txHash: settleResult.txHash,
1044
1296
  nextActions,
1045
- ...warnings.length > 0 ? { warnings } : {}
1297
+ ...warnings.length > 0 ? { warnings } : {},
1298
+ ...delivery !== void 0 ? { fulfillment: delivery } : {}
1046
1299
  });
1047
1300
  }
1048
1301
  function buildExpectedFromRequirements(requirements, state) {
@@ -1142,7 +1395,7 @@ async function handleRedeem(input, ctx) {
1142
1395
  }
1143
1396
  let resolvedChannel;
1144
1397
  if (input.fulfillment !== void 0) {
1145
- const advertisedOptions = ctx.exchangeFulfillmentOptionStore.get(input.exchangeId);
1398
+ const advertisedOptions = await ctx.exchangeFulfillmentOptionStore.get(input.exchangeId);
1146
1399
  if (advertisedOptions !== void 0 && !advertisedOptions.includes(input.fulfillment.option)) {
1147
1400
  return handlerErr(
1148
1401
  400,
@@ -1185,23 +1438,40 @@ async function handleRedeem(input, ctx) {
1185
1438
  }
1186
1439
  const result = await handlePerformAction("boson-redeem", input, ctx);
1187
1440
  if (!result.ok) return result;
1188
- let warning;
1441
+ const warnings = [];
1442
+ let delivery;
1189
1443
  if (resolvedChannel !== void 0 && input.fulfillment !== void 0) {
1190
1444
  const pending = {
1191
1445
  exchangeId: input.exchangeId,
1192
1446
  option: input.fulfillment.option,
1193
1447
  data: input.fulfillment.data,
1194
1448
  redeemer,
1195
- recordedAt: Date.now()
1449
+ recordedAt: Date.now(),
1450
+ phase: "commit"
1196
1451
  };
1197
- ctx.fulfillmentRecoveryStore.set(input.exchangeId, pending);
1452
+ const logger = ctx.logger ?? noopLogger;
1453
+ await ctx.fulfillmentRecoveryStore.set(input.exchangeId, pending);
1454
+ logger.debug("x402-server: fulfillment recovery entry recorded (Flow A redeem)", {
1455
+ exchangeId: input.exchangeId,
1456
+ option: input.fulfillment.option
1457
+ });
1458
+ let onCommitOk = false;
1198
1459
  try {
1199
1460
  await resolvedChannel.onCommit(input.exchangeId, input.fulfillment.data);
1200
- ctx.fulfillmentRecoveryStore.delete(input.exchangeId);
1461
+ onCommitOk = true;
1462
+ logger.debug("x402-server: Flow A channel onCommit succeeded", {
1463
+ exchangeId: input.exchangeId,
1464
+ option: input.fulfillment.option
1465
+ });
1201
1466
  } catch (e) {
1202
1467
  const reason = errorMessage(e);
1203
- ctx.fulfillmentRecoveryStore.set(input.exchangeId, { ...pending, error: reason });
1204
- warning = {
1468
+ await ctx.fulfillmentRecoveryStore.set(input.exchangeId, { ...pending, error: reason });
1469
+ logger.warn("x402-server: Flow A channel onCommit failed; recovery entry retained", {
1470
+ exchangeId: input.exchangeId,
1471
+ option: input.fulfillment.option,
1472
+ error: reason
1473
+ });
1474
+ warnings.push({
1205
1475
  code: "FULFILLMENT_UPDATE_DEFERRED",
1206
1476
  reason: "redeem succeeded on-chain, but the server could not persist the fulfillment update",
1207
1477
  details: {
@@ -1209,16 +1479,48 @@ async function handleRedeem(input, ctx) {
1209
1479
  option: input.fulfillment.option,
1210
1480
  error: reason
1211
1481
  }
1212
- };
1482
+ });
1483
+ }
1484
+ if (onCommitOk) {
1485
+ if (resolvedChannel.onFulfill !== void 0) {
1486
+ const deliveryPending = {
1487
+ ...pending,
1488
+ phase: "delivery",
1489
+ recordedAt: Date.now()
1490
+ };
1491
+ await ctx.fulfillmentRecoveryStore.set(input.exchangeId, deliveryPending);
1492
+ try {
1493
+ delivery = serializeFulfillmentResult(await resolvedChannel.onFulfill(input.exchangeId));
1494
+ await ctx.fulfillmentRecoveryStore.delete(input.exchangeId);
1495
+ } catch (e) {
1496
+ const reason = errorMessage(e);
1497
+ await ctx.fulfillmentRecoveryStore.set(input.exchangeId, {
1498
+ ...deliveryPending,
1499
+ error: reason
1500
+ });
1501
+ warnings.push({
1502
+ code: "FULFILLMENT_DELIVERY_DEFERRED",
1503
+ reason: "redeem succeeded on-chain, but the fulfillment channel's delivery dispatch failed",
1504
+ details: {
1505
+ exchangeId: input.exchangeId,
1506
+ option: input.fulfillment.option,
1507
+ error: reason
1508
+ }
1509
+ });
1510
+ }
1511
+ } else {
1512
+ await ctx.fulfillmentRecoveryStore.delete(input.exchangeId);
1513
+ }
1213
1514
  }
1214
1515
  }
1215
- ctx.exchangeFulfillmentOptionStore.delete(input.exchangeId);
1216
- if (warning !== void 0) {
1516
+ await ctx.exchangeFulfillmentOptionStore.delete(input.exchangeId);
1517
+ if (warnings.length > 0 || delivery !== void 0) {
1217
1518
  return {
1218
1519
  ...result,
1219
1520
  body: {
1220
1521
  ...result.body,
1221
- warnings: [...result.body.warnings ?? [], warning]
1522
+ ...delivery !== void 0 ? { fulfillment: delivery } : {},
1523
+ ...warnings.length > 0 ? { warnings: [...result.body.warnings ?? [], ...warnings] } : {}
1222
1524
  }
1223
1525
  };
1224
1526
  }
@@ -1461,6 +1763,52 @@ function asCoreSdkReadAdapter(coreSdk) {
1461
1763
  return coreSdk;
1462
1764
  }
1463
1765
 
1766
+ // src/concurrency.ts
1767
+ function createKeyedMutex() {
1768
+ const chains = /* @__PURE__ */ new Map();
1769
+ return {
1770
+ runExclusive(key, fn) {
1771
+ const prev = chains.get(key) ?? Promise.resolve();
1772
+ const run = prev.then(fn, fn);
1773
+ const tracked = run.catch(() => void 0);
1774
+ chains.set(key, tracked);
1775
+ tracked.then(() => {
1776
+ if (chains.get(key) === tracked) chains.delete(key);
1777
+ });
1778
+ return run;
1779
+ }
1780
+ };
1781
+ }
1782
+
1783
+ // src/health.ts
1784
+ function createHealthCheck(deps) {
1785
+ return async () => {
1786
+ const facilitator = await probe(() => deps.facilitator.healthCheck()) ? "ok" : "down";
1787
+ let readClient;
1788
+ let readClientFailed = false;
1789
+ if (typeof deps.coreSdkRead === "function") {
1790
+ try {
1791
+ readClient = deps.coreSdkRead();
1792
+ } catch {
1793
+ readClientFailed = true;
1794
+ }
1795
+ } else {
1796
+ readClient = deps.coreSdkRead;
1797
+ }
1798
+ const subgraph = readClientFailed ? "down" : readClient === void 0 ? "n/a" : await probe(() => readClient.getSellersByAddress(ZERO_ADDRESS_PROBE)) ? "ok" : "down";
1799
+ return { facilitator, subgraph };
1800
+ };
1801
+ }
1802
+ async function probe(fn) {
1803
+ try {
1804
+ await fn();
1805
+ return true;
1806
+ } catch {
1807
+ return false;
1808
+ }
1809
+ }
1810
+ var ZERO_ADDRESS_PROBE = "0x0000000000000000000000000000000000000000";
1811
+
1464
1812
  // src/server.ts
1465
1813
  function withFacilitatorEndpoints(requirements, facilitatorUrl) {
1466
1814
  return {
@@ -1474,9 +1822,23 @@ function withFacilitatorEndpoints(requirements, facilitatorUrl) {
1474
1822
  function createX402bServer(config) {
1475
1823
  const validated = x402bServerConfigSchema.parse(config);
1476
1824
  assertChannelRegistryEscrowMatch(validated);
1477
- const facilitator = createFacilitatorClient({ url: validated.facilitator.url });
1478
- const exchangeFulfillmentOptionStore = validated.exchangeFulfillmentOptionStore ?? /* @__PURE__ */ new Map();
1479
- const fulfillmentRecoveryStore = validated.fulfillmentRecoveryStore ?? /* @__PURE__ */ new Map();
1825
+ const logger = validated.logger ?? noopLogger;
1826
+ logger.info("x402-server: createX402bServer", {
1827
+ network: validated.network,
1828
+ chainId: validated.chainId,
1829
+ escrow: validated.escrow,
1830
+ facilitatorUrl: validated.facilitator.url
1831
+ });
1832
+ const facilitator = createFacilitatorClient({
1833
+ url: validated.facilitator.url,
1834
+ logger,
1835
+ ...validated.facilitator.timeoutMs !== void 0 ? { timeoutMs: validated.facilitator.timeoutMs } : {},
1836
+ ...validated.facilitator.retry !== void 0 ? { retry: validated.facilitator.retry } : {},
1837
+ ...validated.facilitator.idempotencyKey !== void 0 ? { idempotencyKey: validated.facilitator.idempotencyKey } : {}
1838
+ });
1839
+ const exchangeMutex = createKeyedMutex();
1840
+ const exchangeFulfillmentOptionStore = validated.exchangeFulfillmentOptionStore ?? mapAsStore(/* @__PURE__ */ new Map());
1841
+ const fulfillmentRecoveryStore = validated.fulfillmentRecoveryStore ?? mapAsStore(/* @__PURE__ */ new Map());
1480
1842
  validated.exchangeFulfillmentOptionStore = exchangeFulfillmentOptionStore;
1481
1843
  validated.fulfillmentRecoveryStore = fulfillmentRecoveryStore;
1482
1844
  const signOffer = (unsigned) => signFullOffer({
@@ -1494,14 +1856,10 @@ function createX402bServer(config) {
1494
1856
  return validated.exchangeReader;
1495
1857
  };
1496
1858
  let cachedCoreSdkRead;
1497
- const requireCoreSdkRead = (action) => {
1859
+ const getOrCreateCoreSdkRead = () => {
1498
1860
  if (validated.coreSdkRead !== void 0) return validated.coreSdkRead;
1499
1861
  if (cachedCoreSdkRead !== void 0) return cachedCoreSdkRead;
1500
- if (validated.subgraphUrl === void 0) {
1501
- throw new Error(
1502
- `x402-server: handlers.${action}() requires either \`coreSdkRead\` or \`subgraphUrl\` in config (subgraph read step).`
1503
- );
1504
- }
1862
+ if (validated.subgraphUrl === void 0) return void 0;
1505
1863
  cachedCoreSdkRead = asCoreSdkReadAdapter(
1506
1864
  new coreSdk.CoreSDK({
1507
1865
  web3Lib: createReadOnlyWeb3LibStub(),
@@ -1512,10 +1870,106 @@ function createX402bServer(config) {
1512
1870
  );
1513
1871
  return cachedCoreSdkRead;
1514
1872
  };
1873
+ const requireCoreSdkRead = (action) => {
1874
+ const client = getOrCreateCoreSdkRead();
1875
+ if (client === void 0) {
1876
+ throw new Error(
1877
+ `x402-server: handlers.${action}() requires either \`coreSdkRead\` or \`subgraphUrl\` in config (subgraph read step).`
1878
+ );
1879
+ }
1880
+ return client;
1881
+ };
1882
+ const recovery = {
1883
+ async list() {
1884
+ const entries = [];
1885
+ for await (const [, value] of fulfillmentRecoveryStore.entries()) {
1886
+ entries.push(value);
1887
+ }
1888
+ return entries;
1889
+ },
1890
+ async replay(exchangeId) {
1891
+ const entry = await fulfillmentRecoveryStore.get(exchangeId);
1892
+ if (entry === void 0) {
1893
+ return { ok: false, reason: `no pending recovery entry for exchangeId '${exchangeId}'` };
1894
+ }
1895
+ const channels = validated.fulfillmentChannels ?? [];
1896
+ const channel = channels.find((c) => c.id === entry.option);
1897
+ if (channel === void 0) {
1898
+ const reason = `no channel adapter is registered for option '${entry.option}'`;
1899
+ await fulfillmentRecoveryStore.set(exchangeId, { ...entry, error: reason });
1900
+ logger.warn("x402-server: recovery replay failed (no adapter)", {
1901
+ exchangeId,
1902
+ option: entry.option
1903
+ });
1904
+ return { ok: false, reason };
1905
+ }
1906
+ if (entry.phase === "delivery") {
1907
+ if (channel.onFulfill === void 0) {
1908
+ const reason = `channel '${entry.option}' has no onFulfill; cannot replay delivery phase`;
1909
+ await fulfillmentRecoveryStore.set(exchangeId, { ...entry, error: reason });
1910
+ logger.warn("x402-server: recovery replay failed (no onFulfill)", {
1911
+ exchangeId,
1912
+ option: entry.option
1913
+ });
1914
+ return { ok: false, reason };
1915
+ }
1916
+ try {
1917
+ await channel.onFulfill(exchangeId);
1918
+ await fulfillmentRecoveryStore.delete(exchangeId);
1919
+ logger.info("x402-server: recovery replay succeeded", {
1920
+ exchangeId,
1921
+ option: entry.option,
1922
+ phase: entry.phase
1923
+ });
1924
+ return { ok: true };
1925
+ } catch (e) {
1926
+ const reason = e instanceof Error ? e.message : String(e);
1927
+ await fulfillmentRecoveryStore.set(exchangeId, { ...entry, error: reason });
1928
+ logger.warn("x402-server: recovery replay failed (channel error)", {
1929
+ exchangeId,
1930
+ option: entry.option,
1931
+ phase: entry.phase,
1932
+ error: reason
1933
+ });
1934
+ return { ok: false, reason };
1935
+ }
1936
+ }
1937
+ try {
1938
+ await channel.onCommit(exchangeId, entry.data);
1939
+ await fulfillmentRecoveryStore.delete(exchangeId);
1940
+ logger.info("x402-server: recovery replay succeeded", {
1941
+ exchangeId,
1942
+ option: entry.option,
1943
+ phase: entry.phase
1944
+ });
1945
+ return { ok: true };
1946
+ } catch (e) {
1947
+ const reason = e instanceof Error ? e.message : String(e);
1948
+ await fulfillmentRecoveryStore.set(exchangeId, { ...entry, error: reason });
1949
+ logger.warn("x402-server: recovery replay failed (channel error)", {
1950
+ exchangeId,
1951
+ option: entry.option,
1952
+ phase: entry.phase,
1953
+ error: reason
1954
+ });
1955
+ return { ok: false, reason };
1956
+ }
1957
+ }
1958
+ };
1959
+ const healthCheck = createHealthCheck({
1960
+ facilitator,
1961
+ // Materialise the lazy `subgraphUrl`-backed read client on first
1962
+ // probe so a configured subgraph is actually checked (reporting
1963
+ // `"ok"` / `"down"` rather than `"n/a"`). Only the
1964
+ // "no `coreSdkRead` AND no `subgraphUrl`" case maps to `"n/a"`.
1965
+ coreSdkRead: () => getOrCreateCoreSdkRead()
1966
+ });
1515
1967
  return {
1516
1968
  config: validated,
1517
1969
  facilitator,
1518
1970
  signOffer,
1971
+ recovery,
1972
+ healthCheck,
1519
1973
  async buildPaymentRequirements(input) {
1520
1974
  const offer = "unsigned" in input.offer ? await signOffer(input.offer.unsigned) : input.offer;
1521
1975
  const requirements = buildPaymentRequirements({
@@ -1538,47 +1992,73 @@ function createX402bServer(config) {
1538
1992
  facilitator,
1539
1993
  exchangeReader: await requireReader("commit"),
1540
1994
  fulfillmentRecoveryStore,
1541
- exchangeFulfillmentOptionStore
1995
+ exchangeFulfillmentOptionStore,
1996
+ logger
1542
1997
  }),
1543
1998
  commitAndRedeem: async (input) => handleCommitAndRedeem(input, {
1544
1999
  config: validated,
1545
2000
  facilitator,
1546
2001
  exchangeReader: await requireReader("commitAndRedeem"),
1547
2002
  fulfillmentRecoveryStore,
1548
- exchangeFulfillmentOptionStore
1549
- }),
1550
- redeem: async (input) => handleRedeem(input, {
1551
- config: validated,
1552
- facilitator,
1553
- exchangeReader: await requireReader("redeem"),
1554
2003
  exchangeFulfillmentOptionStore,
1555
- fulfillmentRecoveryStore
1556
- }),
1557
- complete: async (input) => handleComplete(input, {
1558
- config: validated,
1559
- facilitator,
1560
- exchangeReader: await requireReader("complete")
1561
- }),
1562
- disputeRaise: async (input) => handleDisputeRaise(input, {
1563
- config: validated,
1564
- facilitator,
1565
- exchangeReader: await requireReader("disputeRaise")
1566
- }),
1567
- disputeResolve: async (input) => handleDisputeResolve(input, {
1568
- config: validated,
1569
- facilitator,
1570
- exchangeReader: await requireReader("disputeResolve")
1571
- }),
1572
- disputeRetract: async (input) => handleDisputeRetract(input, {
1573
- config: validated,
1574
- facilitator,
1575
- exchangeReader: await requireReader("disputeRetract")
1576
- }),
1577
- disputeEscalate: async (input) => handleDisputeEscalate(input, {
1578
- config: validated,
1579
- facilitator,
1580
- exchangeReader: await requireReader("disputeEscalate")
2004
+ logger
1581
2005
  }),
2006
+ redeem: async (input) => exchangeMutex.runExclusive(
2007
+ input.exchangeId,
2008
+ async () => handleRedeem(input, {
2009
+ config: validated,
2010
+ facilitator,
2011
+ exchangeReader: await requireReader("redeem"),
2012
+ exchangeFulfillmentOptionStore,
2013
+ fulfillmentRecoveryStore,
2014
+ logger
2015
+ })
2016
+ ),
2017
+ complete: async (input) => exchangeMutex.runExclusive(
2018
+ input.exchangeId,
2019
+ async () => handleComplete(input, {
2020
+ config: validated,
2021
+ facilitator,
2022
+ exchangeReader: await requireReader("complete"),
2023
+ logger
2024
+ })
2025
+ ),
2026
+ disputeRaise: async (input) => exchangeMutex.runExclusive(
2027
+ input.exchangeId,
2028
+ async () => handleDisputeRaise(input, {
2029
+ config: validated,
2030
+ facilitator,
2031
+ exchangeReader: await requireReader("disputeRaise"),
2032
+ logger
2033
+ })
2034
+ ),
2035
+ disputeResolve: async (input) => exchangeMutex.runExclusive(
2036
+ input.exchangeId,
2037
+ async () => handleDisputeResolve(input, {
2038
+ config: validated,
2039
+ facilitator,
2040
+ exchangeReader: await requireReader("disputeResolve"),
2041
+ logger
2042
+ })
2043
+ ),
2044
+ disputeRetract: async (input) => exchangeMutex.runExclusive(
2045
+ input.exchangeId,
2046
+ async () => handleDisputeRetract(input, {
2047
+ config: validated,
2048
+ facilitator,
2049
+ exchangeReader: await requireReader("disputeRetract"),
2050
+ logger
2051
+ })
2052
+ ),
2053
+ disputeEscalate: async (input) => exchangeMutex.runExclusive(
2054
+ input.exchangeId,
2055
+ async () => handleDisputeEscalate(input, {
2056
+ config: validated,
2057
+ facilitator,
2058
+ exchangeReader: await requireReader("disputeEscalate"),
2059
+ logger
2060
+ })
2061
+ ),
1582
2062
  withdrawFunds: async (input) => handleWithdrawFunds(input, {
1583
2063
  config: validated,
1584
2064
  facilitator,
@@ -1610,11 +2090,14 @@ exports.ADDRESS_RE = ADDRESS_RE;
1610
2090
  exports.DECIMAL_UINT_RE = DECIMAL_UINT_RE;
1611
2091
  exports.FacilitatorHttpError = FacilitatorHttpError;
1612
2092
  exports.HEX_BYTES_RE = HEX_BYTES_RE;
2093
+ exports.IDEMPOTENCY_KEY_HEADER = IDEMPOTENCY_KEY_HEADER;
1613
2094
  exports.X_PAYMENT_RESPONSE_HEADER = X_PAYMENT_RESPONSE_HEADER;
1614
2095
  exports.asCoreSdkReadAdapter = asCoreSdkReadAdapter;
1615
2096
  exports.assertChannelRegistryEscrowMatch = assertChannelRegistryEscrowMatch;
1616
2097
  exports.buildPaymentRequirements = buildPaymentRequirements;
1617
2098
  exports.createFacilitatorClient = createFacilitatorClient;
2099
+ exports.createHealthCheck = createHealthCheck;
2100
+ exports.createKeyedMutex = createKeyedMutex;
1618
2101
  exports.createX402bServer = createX402bServer;
1619
2102
  exports.decodeXPaymentHeader = decodeXPaymentHeader;
1620
2103
  exports.emitNextActions = emitNextActions;
@@ -1632,6 +2115,9 @@ exports.handleRedeem = handleRedeem;
1632
2115
  exports.handleWithdrawFunds = handleWithdrawFunds;
1633
2116
  exports.handlerErr = handlerErr;
1634
2117
  exports.handlerOk = handlerOk;
2118
+ exports.isStore = isStore;
2119
+ exports.mapAsStore = mapAsStore;
2120
+ exports.noopLogger = noopLogger;
1635
2121
  exports.plainHandlerOk = plainHandlerOk;
1636
2122
  exports.resolveEntityId = resolveEntityId;
1637
2123
  exports.signFullOffer = signFullOffer;