@absol-labs/agent 0.12.0-rc.2 → 0.12.0-rc.4

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.
Files changed (86) hide show
  1. package/dist/cli/commands/claim.d.ts +15 -0
  2. package/dist/cli/commands/claim.d.ts.map +1 -1
  3. package/dist/cli/commands/claim.js +65 -23
  4. package/dist/cli/commands/claim.js.map +1 -1
  5. package/dist/cli/commands/pay.d.ts +10 -0
  6. package/dist/cli/commands/pay.d.ts.map +1 -1
  7. package/dist/cli/commands/pay.js +246 -50
  8. package/dist/cli/commands/pay.js.map +1 -1
  9. package/dist/cli/commands/refund.d.ts +23 -12
  10. package/dist/cli/commands/refund.d.ts.map +1 -1
  11. package/dist/cli/commands/refund.js +104 -131
  12. package/dist/cli/commands/refund.js.map +1 -1
  13. package/dist/cli/deps.d.ts +6 -0
  14. package/dist/cli/deps.d.ts.map +1 -1
  15. package/dist/cli/deps.js +3 -0
  16. package/dist/cli/deps.js.map +1 -1
  17. package/dist/cli/errors.d.ts +8 -1
  18. package/dist/cli/errors.d.ts.map +1 -1
  19. package/dist/cli/errors.js.map +1 -1
  20. package/dist/cli/status.d.ts +6 -0
  21. package/dist/cli/status.d.ts.map +1 -1
  22. package/dist/cli/status.js +25 -6
  23. package/dist/cli/status.js.map +1 -1
  24. package/dist/gateway/x402-opener-mode.d.ts.map +1 -1
  25. package/dist/gateway/x402-opener-mode.js +104 -7
  26. package/dist/gateway/x402-opener-mode.js.map +1 -1
  27. package/dist/mcp/server.d.ts +10 -0
  28. package/dist/mcp/server.d.ts.map +1 -1
  29. package/dist/mcp/server.js +21 -1
  30. package/dist/mcp/server.js.map +1 -1
  31. package/dist/mcp/x402-tools.d.ts +7 -1
  32. package/dist/mcp/x402-tools.d.ts.map +1 -1
  33. package/dist/mcp/x402-tools.js +159 -6
  34. package/dist/mcp/x402-tools.js.map +1 -1
  35. package/dist/x402-buyer/index.d.ts +1 -0
  36. package/dist/x402-buyer/index.d.ts.map +1 -1
  37. package/dist/x402-buyer/index.js +1 -0
  38. package/dist/x402-buyer/index.js.map +1 -1
  39. package/dist/x402-buyer/pay.d.ts +38 -2
  40. package/dist/x402-buyer/pay.d.ts.map +1 -1
  41. package/dist/x402-buyer/pay.js +52 -2
  42. package/dist/x402-buyer/pay.js.map +1 -1
  43. package/dist/x402-buyer/refund-actions.d.ts +168 -0
  44. package/dist/x402-buyer/refund-actions.d.ts.map +1 -0
  45. package/dist/x402-buyer/refund-actions.js +351 -0
  46. package/dist/x402-buyer/refund-actions.js.map +1 -0
  47. package/dist/x402-opener/block-floors.d.ts +35 -0
  48. package/dist/x402-opener/block-floors.d.ts.map +1 -0
  49. package/dist/x402-opener/block-floors.js +72 -0
  50. package/dist/x402-opener/block-floors.js.map +1 -0
  51. package/dist/x402-opener/facilitator-server.d.ts.map +1 -1
  52. package/dist/x402-opener/facilitator-server.js +7 -0
  53. package/dist/x402-opener/facilitator-server.js.map +1 -1
  54. package/dist/x402-opener/index.d.ts +1 -0
  55. package/dist/x402-opener/index.d.ts.map +1 -1
  56. package/dist/x402-opener/index.js +1 -0
  57. package/dist/x402-opener/index.js.map +1 -1
  58. package/dist/x402-opener/refund-relay.d.ts +62 -1
  59. package/dist/x402-opener/refund-relay.d.ts.map +1 -1
  60. package/dist/x402-opener/refund-relay.js +387 -208
  61. package/dist/x402-opener/refund-relay.js.map +1 -1
  62. package/dist/x402-opener/scheme.d.ts +8 -0
  63. package/dist/x402-opener/scheme.d.ts.map +1 -1
  64. package/dist/x402-opener/scheme.js +14 -0
  65. package/dist/x402-opener/scheme.js.map +1 -1
  66. package/docs/cli.md +62 -9
  67. package/docs/mcp-reference.md +23 -0
  68. package/docs/x402.md +126 -29
  69. package/package.json +2 -2
  70. package/src/cli/commands/claim.ts +72 -30
  71. package/src/cli/commands/pay.ts +299 -51
  72. package/src/cli/commands/refund.ts +133 -143
  73. package/src/cli/deps.ts +9 -0
  74. package/src/cli/errors.ts +8 -1
  75. package/src/cli/status.ts +28 -7
  76. package/src/gateway/x402-opener-mode.ts +131 -6
  77. package/src/mcp/server.ts +30 -1
  78. package/src/mcp/x402-tools.ts +222 -7
  79. package/src/x402-buyer/index.ts +1 -0
  80. package/src/x402-buyer/pay.ts +99 -2
  81. package/src/x402-buyer/refund-actions.ts +486 -0
  82. package/src/x402-opener/block-floors.ts +78 -0
  83. package/src/x402-opener/facilitator-server.ts +7 -0
  84. package/src/x402-opener/index.ts +1 -0
  85. package/src/x402-opener/refund-relay.ts +486 -217
  86. package/src/x402-opener/scheme.ts +38 -0
@@ -1,6 +1,7 @@
1
1
  import { streamEscrowV2Abi, x402UpfrontStreamOpenerAbi, x402UpfrontStreamOpenerFactoryAbi, } from "@absol-labs/shared";
2
2
  import { BaseError, ContractFunctionRevertedError, concat, decodeEventLog, encodeAbiParameters, getAddress, isAddress, isErc6492Signature, isHex, keccak256, maxUint256, } from "viem";
3
3
  import { x402StreamOpenerAbi, x402StreamOpenerFactoryAbi } from "./abi.js";
4
+ import { StreamBlockFloors } from "./block-floors.js";
4
5
  import { RelayerQueue } from "./relayer-queue.js";
5
6
  import { chainIdFromNetwork } from "./terms.js";
6
7
  import { escrowGenerationFor, readEscapeHatchReadyAt, UnsupportedEscrowGenerationError, } from "../escrow-generation.js";
@@ -137,6 +138,19 @@ export function processMerkleProof(leaf, proof) {
137
138
  }
138
139
  return computed;
139
140
  }
141
+ /** Default bound on waiting for a lagging RPC inside one relay request. */
142
+ export const DEFAULT_REFUND_LAG_TOLERANCE_MS = 8_000;
143
+ const DEFAULT_LAG_POLL_MS = 1_000;
144
+ /** The RPC did not reach the stream's floor block within the tolerance. */
145
+ class ChainLagging extends Error {
146
+ floor;
147
+ seen;
148
+ constructor(floor, seen) {
149
+ super(`RPC is at block ${String(seen)}, below the stream's floor ${floor}`);
150
+ this.floor = floor;
151
+ this.seen = seen;
152
+ }
153
+ }
140
154
  /** Default reuse window for a stream's status and oracle proof. */
141
155
  export const DEFAULT_REFUND_READ_CACHE_SECONDS = 5;
142
156
  const MAX_CACHED_STREAMS = 10_000;
@@ -224,6 +238,10 @@ export class OpenerRefundRelay {
224
238
  cacheMs;
225
239
  logger;
226
240
  statusCache = new Map();
241
+ floors;
242
+ lagToleranceMs;
243
+ lagPollMs;
244
+ sleep;
227
245
  proofCache = new Map();
228
246
  constructor(config) {
229
247
  this.config = config;
@@ -292,6 +310,21 @@ export class OpenerRefundRelay {
292
310
  this.perStream = new MinuteWindows(perStream, nowMs);
293
311
  this.global = new MinuteWindows(global, nowMs);
294
312
  this.fetchImpl = config.fetch ?? ((...args) => fetch(...args));
313
+ this.floors = config.streamFloors ?? new StreamBlockFloors();
314
+ this.lagToleranceMs =
315
+ config.lagToleranceMs ?? DEFAULT_REFUND_LAG_TOLERANCE_MS;
316
+ this.lagPollMs = config.lagPollMs ?? DEFAULT_LAG_POLL_MS;
317
+ for (const [name, value] of [
318
+ ["lagToleranceMs", this.lagToleranceMs],
319
+ ["lagPollMs", this.lagPollMs],
320
+ ]) {
321
+ if (!Number.isInteger(value) || value < 0) {
322
+ throw new Error(`${name} must be a non-negative integer`);
323
+ }
324
+ }
325
+ this.sleep =
326
+ config.sleep ??
327
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
295
328
  }
296
329
  /**
297
330
  * Proves the wiring before serving: the RPC is on the configured chain and
@@ -335,185 +368,218 @@ export class OpenerRefundRelay {
335
368
  return invalidStreamId();
336
369
  if (isNoStreamId(streamId))
337
370
  return nothingToRefund(404);
371
+ const floor = this.floors.get(streamId);
338
372
  const cached = this.fresh(this.statusCache, streamId);
339
- if (cached !== undefined)
373
+ // A cached answer computed below the stream's floor (a tx for it was mined
374
+ // since) is stale by construction: never serve it.
375
+ if (cached !== undefined &&
376
+ (floor === undefined ||
377
+ (cached.block !== undefined && cached.block >= floor))) {
340
378
  return cached.response;
341
- const response = await this.computeStatus(streamId);
342
- // Cache definitive answers only (200 / 404), never an outage.
379
+ }
380
+ const { response, block } = await this.computeStatus(streamId);
381
+ // Cache definitive answers only (200 / 404), never an outage or a lag.
343
382
  if (response.status === 200 || response.status === 404) {
344
- this.remember(this.statusCache, streamId, { response });
383
+ this.remember(this.statusCache, streamId, { response, block });
345
384
  }
346
385
  return response;
347
386
  }
348
- async computeStatus(rawStreamId) {
387
+ async computeStatus(streamId) {
349
388
  try {
350
- const resolved = await this.resolve(rawStreamId);
351
- if ("status" in resolved)
352
- return resolved;
353
- const { streamId, stream, opener, payer, kind, factory } = resolved;
354
- const grace = this.grace();
355
- const [now, nonce, latestCheckedAt, upfrontTerms] = await Promise.all([
356
- this.chainNow(),
357
- this.readNonce(opener, payer),
358
- this.readLatestCheckedAt(),
359
- kind === "upfront"
360
- ? this.readUpfrontTerms(opener)
361
- : Promise.resolve(undefined),
362
- ]);
363
- const closed = stream.status === 1;
364
- const stopAt = stopTime(stream);
365
- const settled = latestCheckedAt >= stopAt;
366
- const fallbackAt = stopAt + grace;
367
- const escapeAt = await this.escapeHatchAt(streamId, stream);
368
- let oracle = null;
369
- let oracleNote;
370
- let reclaimable;
371
- if (!stream.reclaimed) {
372
- const proof = await this.fetchProof(streamId);
373
- if (proof.kind === "ok") {
374
- oracle = {
375
- cumulativeAmount: proof.cumulativeAmount.toString(),
376
- checkpointId: proof.checkpointId.toString(),
377
- checkedAt: Number(proof.checkedAt),
378
- root: proof.root,
379
- };
380
- reclaimable = await this.readReclaimable(streamId, proof.cumulativeAmount);
381
- }
382
- else {
383
- oracleNote = proof.reason;
384
- }
389
+ const { view, resolved } = await this.resolveFresh(streamId);
390
+ if ("status" in resolved) {
391
+ return { response: resolved, block: view.number };
385
392
  }
386
- const base = `/streams/${streamId}`;
387
- let next;
388
- if (stream.reclaimed) {
389
- next = {
390
- action: "none",
391
- message: "Already reclaimed: the unearned part of the deposit was sent to the payer. Nothing further to do.",
392
- };
393
- }
394
- else if (!closed && now < stream.expiresAt) {
395
- next = {
396
- action: "close",
397
- method: "POST",
398
- ...this.link(`${base}/close`),
399
- body: {
400
- deadline: "<the deadline you signed, unix seconds>",
401
- signature: "<the payer's EIP-712 signature of typedData>",
402
- },
403
- typedData: jsonTypedData(openerActionTypedData({
404
- action: "Close",
405
- kind,
406
- opener,
407
- chainId: this.chainId,
408
- streamId,
409
- nonce,
410
- deadline: now + 900n,
411
- })),
412
- message: `Stop the stream early: sign typedData with the payer key (set message.deadline to a unix time at least ${MIN_SIGNATURE_DEADLINE_HEADROOM_SECONDS} s ahead) and POST it; the relay pays the gas. Otherwise the stream ends by itself at expiresAt (${Number(stream.expiresAt)}). Either way, reclaim afterwards.`,
413
- };
414
- }
415
- else if (!settled && now < fallbackAt) {
416
- next = {
417
- action: "wait",
418
- retryAt: Number(fallbackAt),
419
- retryAfterSeconds: Math.min(CHECKPOINT_RETRY_HINT_SECONDS, Number(fallbackAt - now)),
420
- message: "The seller's settlement window is open: reclaim becomes possible once the oracle posts a checkpoint covering the stop time (usually within a minute), and at the latest at retryAt.",
421
- };
422
- }
423
- else if (oracle !== null && reclaimable !== undefined) {
424
- next =
425
- reclaimable > 0n
426
- ? {
427
- action: "reclaim",
428
- method: "POST",
429
- ...this.link(`${base}/reclaim`),
430
- message: `POST with an empty body; the relay fetches the proof, pays the gas and the contract sends ${reclaimable.toString()} base units to the payer.`,
431
- }
432
- : {
433
- action: "none",
434
- message: "Nothing to reclaim: the verified delivery covers the whole deposit.",
435
- };
393
+ return {
394
+ response: await this.describeStatus(resolved, view),
395
+ block: view.number,
396
+ };
397
+ }
398
+ catch (error) {
399
+ if (error instanceof ChainLagging) {
400
+ return { response: this.lagging(error), block: undefined };
436
401
  }
437
- else if (now >= escapeAt) {
438
- next = {
439
- action: "reclaim-unverified",
440
- method: "POST",
441
- ...this.link(`${base}/reclaim-unverified`),
442
- body: {
443
- deadline: "<the deadline you signed, unix seconds>",
444
- signature: "<the payer's EIP-712 signature of typedData>",
445
- },
446
- typedData: jsonTypedData(openerActionTypedData({
447
- action: "ReclaimUnverified",
448
- kind,
449
- opener,
450
- chainId: this.chainId,
451
- streamId,
452
- nonce,
453
- deadline: now + 900n,
454
- })),
455
- message: "No oracle proof is available and the escape hatch is open: the payer may sign ReclaimUnverified to recover deposit minus what the seller already claimed. This forfeits the seller's earned-but-unclaimed revenue, so it needs the payer's own signature.",
402
+ return {
403
+ response: this.unavailable("chain read", error),
404
+ block: undefined,
405
+ };
406
+ }
407
+ }
408
+ async describeStatus(resolved, view) {
409
+ const { streamId, stream, opener, payer, kind, factory } = resolved;
410
+ const grace = this.grace();
411
+ const now = view.timestamp;
412
+ const [nonce, latestCheckedAt, upfrontTerms] = await Promise.all([
413
+ this.readNonce(opener, payer, view),
414
+ this.readLatestCheckedAt(view),
415
+ kind === "upfront"
416
+ ? this.readUpfrontTerms(opener, view)
417
+ : Promise.resolve(undefined),
418
+ ]);
419
+ const closed = stream.status === 1;
420
+ const stopAt = stopTime(stream);
421
+ const settled = latestCheckedAt >= stopAt;
422
+ const fallbackAt = stopAt + grace;
423
+ const escapeAt = await this.escapeHatchAt(streamId, stream, view);
424
+ let oracle = null;
425
+ let oracleNote;
426
+ let reclaimable;
427
+ if (!stream.reclaimed) {
428
+ const proof = await this.fetchProof(streamId, view);
429
+ if (proof.kind === "ok") {
430
+ oracle = {
431
+ cumulativeAmount: proof.cumulativeAmount.toString(),
432
+ checkpointId: proof.checkpointId.toString(),
433
+ checkedAt: Number(proof.checkedAt),
434
+ root: proof.root,
456
435
  };
436
+ reclaimable = await this.readReclaimable(streamId, proof.cumulativeAmount, view);
457
437
  }
458
438
  else {
459
- next = {
460
- action: "wait",
461
- retryAt: Number(escapeAt),
462
- retryAfterSeconds: CHECKPOINT_RETRY_HINT_SECONDS,
463
- message: "No usable oracle proof yet: retry shortly. If none ever appears, the payer-signed escape hatch opens at retryAt.",
464
- };
439
+ oracleNote = proof.reason;
465
440
  }
466
- return {
467
- status: 200,
441
+ }
442
+ const base = `/streams/${streamId}`;
443
+ let next;
444
+ if (stream.reclaimed) {
445
+ next = {
446
+ action: "none",
447
+ message: "Already reclaimed: the unearned part of the deposit was sent to the payer. Nothing further to do.",
448
+ };
449
+ }
450
+ else if (!closed && now < stream.expiresAt) {
451
+ next = {
452
+ action: "close",
453
+ method: "POST",
454
+ ...this.link(`${base}/close`),
468
455
  body: {
469
- streamId,
470
- chainId: this.chainId,
471
- escrow: this.escrow,
472
- factory,
473
- openerKind: kind,
456
+ deadline: "<the deadline you signed, unix seconds>",
457
+ signature: "<the payer's EIP-712 signature of typedData>",
458
+ },
459
+ typedData: jsonTypedData(openerActionTypedData({
460
+ action: "Close",
461
+ kind,
474
462
  opener,
475
- payer,
476
- operator: stream.operator,
477
- serviceRef: stream.serviceRef,
478
- deposit: stream.deposit.toString(),
479
- // The most any refund of this payment can return: the escrowed
480
- // deposit. For an upfront opener that is price - upfrontAmount; the
481
- // upfront part was paid to the seller at settlement and is final.
482
- maxRefund: stream.deposit.toString(),
483
- ...(upfrontTerms === undefined
484
- ? {}
485
- : {
486
- price: upfrontTerms.price.toString(),
487
- upfrontAmount: upfrontTerms.upfrontAmount.toString(),
488
- upfrontNote: `${upfrontTerms.upfrontAmount.toString()} of the ${upfrontTerms.price.toString()} paid was a non-refundable per-call charge, paid to the seller at settlement; at most the ${stream.deposit.toString()} escrowed can be refunded.`,
489
- }),
490
- ratePerSecond: stream.ratePerSecond.toString(),
491
- claimedCumulative: stream.claimedCumulative.toString(),
492
- openedAt: Number(stream.openedAt),
493
- expiresAt: Number(stream.expiresAt),
494
- closedAt: closed ? Number(stream.closedAt) : null,
495
- status: closed ? "closed" : "active",
496
- reclaimed: stream.reclaimed,
497
- chainTime: Number(now),
498
- settlement: {
499
- stopAt: Number(stopAt),
500
- latestCheckpointCheckedAt: Number(latestCheckedAt),
501
- checkpointCoversStop: settled,
502
- fallbackAt: Number(fallbackAt),
503
- escapeHatchAt: Number(escapeAt),
504
- },
505
- oracle,
506
- ...(oracleNote === undefined ? {} : { oracleNote }),
507
- reclaimable: reclaimable === undefined ? null : reclaimable.toString(),
508
- payerNonce: nonce.toString(),
509
- next,
510
- refunds: "There is no automatic refund. Unearned money is returned only when someone calls close/reclaim here (the relay pays the gas) or the payer calls the opener directly.",
463
+ chainId: this.chainId,
464
+ streamId,
465
+ nonce,
466
+ deadline: now + 900n,
467
+ })),
468
+ message: `Stop the stream early: sign typedData with the payer key (set message.deadline to a unix time at least ${MIN_SIGNATURE_DEADLINE_HEADROOM_SECONDS} s ahead) and POST it; the relay pays the gas. Otherwise the stream ends by itself at expiresAt (${Number(stream.expiresAt)}). Either way, reclaim afterwards.`,
469
+ };
470
+ }
471
+ else if (!settled && now < fallbackAt) {
472
+ next = {
473
+ action: "wait",
474
+ retryAt: Number(fallbackAt),
475
+ retryAfterSeconds: Math.min(CHECKPOINT_RETRY_HINT_SECONDS, Number(fallbackAt - now)),
476
+ message: "The seller's settlement window is open: reclaim becomes possible once the oracle posts a checkpoint covering the stop time (usually within a minute), and at the latest at retryAt.",
477
+ };
478
+ }
479
+ else if (oracle !== null && reclaimable !== undefined) {
480
+ next =
481
+ reclaimable > 0n
482
+ ? {
483
+ action: "reclaim",
484
+ method: "POST",
485
+ ...this.link(`${base}/reclaim`),
486
+ message: `POST with an empty body; the relay fetches the proof, pays the gas and the contract sends ${reclaimable.toString()} base units to the payer.`,
487
+ }
488
+ : {
489
+ action: "none",
490
+ message: "Nothing to reclaim: the verified delivery covers the whole deposit.",
491
+ };
492
+ }
493
+ else if (now >= escapeAt) {
494
+ next = {
495
+ action: "reclaim-unverified",
496
+ method: "POST",
497
+ ...this.link(`${base}/reclaim-unverified`),
498
+ body: {
499
+ deadline: "<the deadline you signed, unix seconds>",
500
+ signature: "<the payer's EIP-712 signature of typedData>",
511
501
  },
502
+ typedData: jsonTypedData(openerActionTypedData({
503
+ action: "ReclaimUnverified",
504
+ kind,
505
+ opener,
506
+ chainId: this.chainId,
507
+ streamId,
508
+ nonce,
509
+ deadline: now + 900n,
510
+ })),
511
+ message: "No oracle proof is available and the escape hatch is open: the payer may sign ReclaimUnverified to recover deposit minus what the seller already claimed. This forfeits the seller's earned-but-unclaimed revenue, so it needs the payer's own signature.",
512
512
  };
513
513
  }
514
- catch (error) {
515
- return this.unavailable("chain read", error);
514
+ else {
515
+ next = {
516
+ action: "wait",
517
+ retryAt: Number(escapeAt),
518
+ retryAfterSeconds: CHECKPOINT_RETRY_HINT_SECONDS,
519
+ message: "No usable oracle proof yet: retry shortly. If none ever appears, the payer-signed escape hatch opens at retryAt.",
520
+ };
516
521
  }
522
+ return {
523
+ status: 200,
524
+ body: {
525
+ streamId,
526
+ chainId: this.chainId,
527
+ escrow: this.escrow,
528
+ factory,
529
+ openerKind: kind,
530
+ opener,
531
+ payer,
532
+ operator: stream.operator,
533
+ serviceRef: stream.serviceRef,
534
+ deposit: stream.deposit.toString(),
535
+ // The most any refund of this payment can EVER return (a ceiling,
536
+ // not what is available now — that is `reclaimable`): the escrowed
537
+ // deposit. For an upfront opener that is price - upfrontAmount; the
538
+ // upfront part was paid to the seller at settlement and is final.
539
+ maxRefund: stream.deposit.toString(),
540
+ maxRefundMeaning: "ceiling: the most this payment could ever refund (the escrowed deposit), whatever was delivered. Not the amount available now; see reclaimable.",
541
+ ...(upfrontTerms === undefined
542
+ ? {}
543
+ : {
544
+ price: upfrontTerms.price.toString(),
545
+ upfrontAmount: upfrontTerms.upfrontAmount.toString(),
546
+ upfrontNote: `${upfrontTerms.upfrontAmount.toString()} of the ${upfrontTerms.price.toString()} paid was a non-refundable per-call charge, paid to the seller at settlement; at most the ${stream.deposit.toString()} escrowed can be refunded.`,
547
+ }),
548
+ ratePerSecond: stream.ratePerSecond.toString(),
549
+ claimedCumulative: stream.claimedCumulative.toString(),
550
+ openedAt: Number(stream.openedAt),
551
+ expiresAt: Number(stream.expiresAt),
552
+ closedAt: closed ? Number(stream.closedAt) : null,
553
+ // `status` is the escrow's own on-chain status (Active/Closed), kept
554
+ // verbatim for clients that parse it. An Active stream past
555
+ // expiresAt is not running any more: `state` says so.
556
+ status: closed ? "closed" : "active",
557
+ onChainStatus: closed ? "closed" : "active",
558
+ state: streamState(stream, now),
559
+ reclaimed: stream.reclaimed,
560
+ chainTime: Number(now),
561
+ settlement: {
562
+ stopAt: Number(stopAt),
563
+ latestCheckpointCheckedAt: Number(latestCheckedAt),
564
+ checkpointCoversStop: settled,
565
+ fallbackAt: Number(fallbackAt),
566
+ escapeHatchAt: Number(escapeAt),
567
+ },
568
+ oracle,
569
+ ...(oracleNote === undefined ? {} : { oracleNote }),
570
+ reclaimable: reclaimable === undefined ? null : reclaimable.toString(),
571
+ reclaimableMeaning: stream.reclaimed
572
+ ? "already reclaimed: nothing more can be refunded for this stream."
573
+ : reclaimable === undefined
574
+ ? "unknown right now (no usable oracle proof yet); see next."
575
+ : !closed && now < stream.expiresAt
576
+ ? "what a reclaim would return if the stream stopped now, given the latest verified delivery; it shrinks while the stream runs. Close it first (see next)."
577
+ : "available now: what a reclaim returns to the payer, given the latest verified delivery. 0 means the verified delivery covers the whole deposit.",
578
+ payerNonce: nonce.toString(),
579
+ next,
580
+ refunds: "There is no automatic refund. Unearned money is returned only when someone calls close/reclaim here (the relay pays the gas) or the payer calls the opener directly.",
581
+ },
582
+ };
517
583
  }
518
584
  // ── POST /streams/:streamId/close ───────────────────────────────────────
519
585
  async close(rawStreamId, body) {
@@ -530,11 +596,11 @@ export class OpenerRefundRelay {
530
596
  if (early !== undefined)
531
597
  return early;
532
598
  try {
533
- const resolved = await this.resolve(streamId);
599
+ const { view, resolved } = await this.resolveFresh(streamId);
534
600
  if ("status" in resolved)
535
601
  return resolved;
536
602
  const { stream, opener, payer, kind } = resolved;
537
- const now = await this.chainNow();
603
+ const now = view.timestamp;
538
604
  if (stream.status === 1) {
539
605
  return fail(409, "already-closed", "the stream is already closed", {
540
606
  next: this.link(`/streams/${streamId}/reclaim`),
@@ -543,16 +609,18 @@ export class OpenerRefundRelay {
543
609
  if (now >= stream.expiresAt) {
544
610
  return fail(409, "stream-expired", "the stream has already reached expiresAt; there is nothing to close — reclaim directly", { next: this.link(`/streams/${streamId}/reclaim`) });
545
611
  }
546
- const refused = await this.verifyPayerSignature("Close", kind, streamId, opener, payer, signed, now);
612
+ const refused = await this.verifyPayerSignature("Close", kind, streamId, opener, payer, signed, now, view);
547
613
  if (refused !== undefined)
548
614
  return refused;
549
- return await this.submit(key, streamId, {
615
+ return await this.submit(key, streamId, view, {
550
616
  address: opener,
551
617
  functionName: "closeWithSig",
552
618
  args: [streamId, signed.deadline, signed.signature],
553
619
  }, undefined, { kind: "close", streamId, opener, payer });
554
620
  }
555
621
  catch (error) {
622
+ if (error instanceof ChainLagging)
623
+ return this.lagging(error);
556
624
  return this.unavailable("chain read", error);
557
625
  }
558
626
  }
@@ -568,13 +636,13 @@ export class OpenerRefundRelay {
568
636
  if (early !== undefined)
569
637
  return early;
570
638
  try {
571
- const resolved = await this.resolve(streamId);
639
+ const { view, resolved } = await this.resolveFresh(streamId);
572
640
  if ("status" in resolved)
573
641
  return resolved;
574
642
  const { stream, opener, payer } = resolved;
575
643
  if (stream.reclaimed)
576
644
  return alreadyReclaimed();
577
- const now = await this.chainNow();
645
+ const now = view.timestamp;
578
646
  if (stream.status !== 1 && now < stream.expiresAt) {
579
647
  return fail(409, "not-reclaimable-yet", "the stream is still running: close it first (POST /streams/:streamId/close with the payer's signature) or wait until expiresAt", {
580
648
  retryAt: Number(stream.expiresAt),
@@ -583,24 +651,24 @@ export class OpenerRefundRelay {
583
651
  }
584
652
  const stopAt = stopTime(stream);
585
653
  const fallbackAt = stopAt + this.grace();
586
- const latestCheckedAt = await this.readLatestCheckedAt();
654
+ const latestCheckedAt = await this.readLatestCheckedAt(view);
587
655
  if (latestCheckedAt < stopAt && now < fallbackAt) {
588
656
  return fail(409, "settlement-window-open", "the seller's settlement window is open: the reclaim needs a checkpoint covering the stop time (the oracle usually posts one within a minute); it is possible at the latest at retryAt", { retryAt: Number(fallbackAt), stopAt: Number(stopAt) }, Math.min(CHECKPOINT_RETRY_HINT_SECONDS, Number(fallbackAt - now)));
589
657
  }
590
- const proof = await this.fetchProof(streamId);
658
+ const proof = await this.fetchProof(streamId, view);
591
659
  if (proof.kind === "unavailable") {
592
660
  return fail(503, "oracle-unavailable", proof.reason, {}, 30);
593
661
  }
594
662
  if (proof.kind === "absent") {
595
663
  return fail(409, "proof-unavailable", `${proof.reason}; retry shortly. If no proof ever appears, the payer-signed escape hatch (POST /streams/:streamId/reclaim-unverified) opens at escapeHatchAt.`, {
596
- escapeHatchAt: Number(await this.escapeHatchAt(streamId, stream)),
664
+ escapeHatchAt: Number(await this.escapeHatchAt(streamId, stream, view)),
597
665
  }, CHECKPOINT_RETRY_HINT_SECONDS);
598
666
  }
599
- const amount = await this.readReclaimable(streamId, proof.cumulativeAmount);
667
+ const amount = await this.readReclaimable(streamId, proof.cumulativeAmount, view);
600
668
  if (amount === 0n) {
601
669
  return fail(409, "nothing-to-reclaim", "the verified delivery covers the whole deposit; there is nothing to refund");
602
670
  }
603
- return await this.submit(key, streamId, {
671
+ return await this.submit(key, streamId, view, {
604
672
  address: opener,
605
673
  functionName: "reclaim",
606
674
  args: [streamId, proof.cumulativeAmount, proof.proof],
@@ -616,6 +684,8 @@ export class OpenerRefundRelay {
616
684
  });
617
685
  }
618
686
  catch (error) {
687
+ if (error instanceof ChainLagging)
688
+ return this.lagging(error);
619
689
  return this.unavailable("chain read", error);
620
690
  }
621
691
  }
@@ -634,14 +704,14 @@ export class OpenerRefundRelay {
634
704
  if (early !== undefined)
635
705
  return early;
636
706
  try {
637
- const resolved = await this.resolve(streamId);
707
+ const { view, resolved } = await this.resolveFresh(streamId);
638
708
  if ("status" in resolved)
639
709
  return resolved;
640
710
  const { stream, opener, payer, kind } = resolved;
641
711
  if (stream.reclaimed)
642
712
  return alreadyReclaimed();
643
- const now = await this.chainNow();
644
- const escapeAt = await this.escapeHatchAt(streamId, stream);
713
+ const now = view.timestamp;
714
+ const escapeAt = await this.escapeHatchAt(streamId, stream, view);
645
715
  if (now < escapeAt) {
646
716
  return fail(409, "escape-hatch-not-ready", this.generation === "v2"
647
717
  ? "the no-proof escape hatch opens only at expiresAt + escapeGraceSeconds; use the proof-based reclaim before then"
@@ -654,10 +724,10 @@ export class OpenerRefundRelay {
654
724
  if (amount <= 0n) {
655
725
  return fail(409, "nothing-to-reclaim", "the seller already claimed the whole deposit; there is nothing to refund");
656
726
  }
657
- const refused = await this.verifyPayerSignature("ReclaimUnverified", kind, streamId, opener, payer, signed, now);
727
+ const refused = await this.verifyPayerSignature("ReclaimUnverified", kind, streamId, opener, payer, signed, now, view);
658
728
  if (refused !== undefined)
659
729
  return refused;
660
- return await this.submit(key, streamId, {
730
+ return await this.submit(key, streamId, view, {
661
731
  address: opener,
662
732
  functionName: "reclaimUnverifiedWithSig",
663
733
  args: [streamId, signed.deadline, signed.signature],
@@ -673,6 +743,8 @@ export class OpenerRefundRelay {
673
743
  });
674
744
  }
675
745
  catch (error) {
746
+ if (error instanceof ChainLagging)
747
+ return this.lagging(error);
676
748
  return this.unavailable("chain read", error);
677
749
  }
678
750
  }
@@ -719,11 +791,110 @@ export class OpenerRefundRelay {
719
791
  this.log(context, error);
720
792
  return fail(503, "chain-unavailable", "the chain could not be read right now; retry shortly", {}, 10);
721
793
  }
794
+ /**
795
+ * The RPC has not reached the block of a transaction this facilitator saw
796
+ * mined for the stream. NEVER a `404` (the stream exists) and never a stale
797
+ * answer: a retryable `503`.
798
+ */
799
+ lagging(error) {
800
+ this.log("rpc lagging", error);
801
+ return fail(503, "chain-lagging", `a transaction for this stream was mined in block ${error.floor}, but the RPC has not caught up to it yet; retry in a few seconds`, { minBlock: error.floor.toString() }, 2);
802
+ }
803
+ /** Poll budget of one answer (snapshot waits + pinned-read retries). */
804
+ maxLagPolls() {
805
+ if (this.lagToleranceMs === 0)
806
+ return 0;
807
+ return this.lagPollMs === 0
808
+ ? 3
809
+ : Math.max(1, Math.ceil(this.lagToleranceMs / this.lagPollMs));
810
+ }
811
+ /**
812
+ * A view of the chain at a block >= `floor`: waits (bounded) while the RPC's
813
+ * `latest` is below it, then throws {@link ChainLagging}.
814
+ */
815
+ async snapshot(floor) {
816
+ const budget = { left: this.maxLagPolls() };
817
+ for (;;) {
818
+ const block = await this.config.publicClient.getBlock({
819
+ blockTag: "latest",
820
+ });
821
+ const number = typeof block.number === "bigint" ? block.number : undefined;
822
+ if (floor === undefined || (number !== undefined && number >= floor)) {
823
+ return { number, timestamp: BigInt(block.timestamp), budget };
824
+ }
825
+ if (budget.left <= 0)
826
+ throw new ChainLagging(floor, number);
827
+ budget.left -= 1;
828
+ await this.sleep(this.lagPollMs);
829
+ }
830
+ }
831
+ /**
832
+ * Resolves a stream on a view that is at or above its floor. A miss for a
833
+ * stream with no known floor is confirmed once more, a poll interval later,
834
+ * before it is answered as `404`, so a node that lags the one which mined the stream
835
+ * (or a facilitator restarted since the settlement) cannot turn a real
836
+ * stream into "not found".
837
+ */
838
+ async resolveFresh(streamId) {
839
+ const floor = this.floors.get(streamId);
840
+ const view = await this.snapshot(floor);
841
+ const resolved = await this.resolve(streamId, view);
842
+ if (!("status" in resolved) || resolved.status !== 404) {
843
+ return { view, resolved };
844
+ }
845
+ if (floor !== undefined) {
846
+ // Pinned at or above the block that mined it, and still absent: the
847
+ // chain itself says no. Unexpected for a stream this process settled.
848
+ this.log("not found at or above its floor", new Error(`${streamId} absent at block ${String(view.number)}`));
849
+ return { view, resolved };
850
+ }
851
+ // One confirmation, a poll interval later, on a view at least as high as
852
+ // the first (a node behind it is waited for, bounded). Not "strictly
853
+ // newer": a chain that mines no idle blocks (a local fork) would make
854
+ // every genuine 404 wait the whole bound.
855
+ await this.sleep(this.lagPollMs);
856
+ const again = await this.snapshot(view.number).catch((error) => {
857
+ // The RPC fell back below the first view: the first (pinned) read stands.
858
+ if (error instanceof ChainLagging)
859
+ return undefined;
860
+ throw error;
861
+ });
862
+ if (again === undefined)
863
+ return { view, resolved };
864
+ return { view: again, resolved: await this.resolve(streamId, again) };
865
+ }
866
+ /**
867
+ * `readContract` pinned to the view's block. A node behind that block
868
+ * answers with an error rather than old state; such errors are retried
869
+ * within the view's poll budget. Contract reverts are never retried.
870
+ */
871
+ async read(view, params) {
872
+ for (;;) {
873
+ try {
874
+ return (await this.config.publicClient.readContract({
875
+ ...params,
876
+ ...(view?.number === undefined ? {} : { blockNumber: view.number }),
877
+ }));
878
+ }
879
+ catch (error) {
880
+ if (view?.number === undefined ||
881
+ revertName(error) !== undefined ||
882
+ view.budget.left <= 0) {
883
+ throw error;
884
+ }
885
+ view.budget.left -= 1;
886
+ await this.sleep(this.lagPollMs);
887
+ }
888
+ }
889
+ }
722
890
  /** When the no-proof escape hatch opens for this stream, per generation. */
723
- async escapeHatchAt(streamId, stream) {
891
+ async escapeHatchAt(streamId, stream, view) {
892
+ const reader = {
893
+ readContract: (args) => this.read(view, args),
894
+ };
724
895
  return await readEscapeHatchReadyAt({
725
896
  generation: this.generation,
726
- reader: this.config.publicClient,
897
+ reader,
727
898
  escrow: this.escrow,
728
899
  streamId,
729
900
  stream,
@@ -771,7 +942,7 @@ export class OpenerRefundRelay {
771
942
  * and broadcasts through the shared relayer queue. `expectedResult`, when
772
943
  * set, is the exact non-zero amount the simulation must return.
773
944
  */
774
- async submit(key, streamId, call, expectedResult, expectation) {
945
+ async submit(key, streamId, view, call, expectedResult, expectation) {
775
946
  // Synchronous from here to the ledger write: two concurrent requests for
776
947
  // the same stream and action can never both reach the relayer.
777
948
  const current = this.ledger.get(key);
@@ -801,6 +972,9 @@ export class OpenerRefundRelay {
801
972
  abi: x402StreamOpenerAbi,
802
973
  functionName: call.functionName,
803
974
  args: call.args,
975
+ // The same block every precondition (and the expected amount) was
976
+ // read at: a lagging node cannot simulate against older state.
977
+ ...(view.number === undefined ? {} : { blockNumber: view.number }),
804
978
  });
805
979
  if (expectedResult !== undefined) {
806
980
  if (typeof result !== "bigint" ||
@@ -857,6 +1031,8 @@ export class OpenerRefundRelay {
857
1031
  return fail(502, "unexpected-receipt", "the mined receipt does not show the expected outcome for this stream", { transaction: hash });
858
1032
  }
859
1033
  this.ledger.set(key, { state: "final", hash, success: true });
1034
+ // Every later answer about this stream must reflect this transaction.
1035
+ this.floors.note(expectation.streamId, receipt.blockNumber);
860
1036
  if (expectation.kind === "close") {
861
1037
  return {
862
1038
  status: 200,
@@ -888,19 +1064,18 @@ export class OpenerRefundRelay {
888
1064
  };
889
1065
  }
890
1066
  /** Streams whose escrow buyer is an opener of the configured factory, or a 404. */
891
- async resolve(rawStreamId) {
1067
+ async resolve(rawStreamId, view) {
892
1068
  const streamId = parseStreamId(rawStreamId);
893
1069
  if (streamId === undefined)
894
1070
  return invalidStreamId();
895
- const { publicClient } = this.config;
896
1071
  let stream;
897
1072
  try {
898
- stream = (await publicClient.readContract({
1073
+ stream = await this.read(view, {
899
1074
  address: this.escrow,
900
1075
  abi: streamEscrowV2Abi,
901
1076
  functionName: "getStream",
902
1077
  args: [streamId],
903
- }));
1078
+ });
904
1079
  }
905
1080
  catch (error) {
906
1081
  if (revertName(error) === "UnknownStream")
@@ -913,7 +1088,7 @@ export class OpenerRefundRelay {
913
1088
  // The kind is whichever CONFIGURED factory's isOpener claims the buyer,
914
1089
  // read on-chain — never inferred from the address, code or the request.
915
1090
  const [isStreamOpener, isUpfrontOpener] = await Promise.all([
916
- publicClient.readContract({
1091
+ this.read(view, {
917
1092
  address: this.factory,
918
1093
  abi: x402StreamOpenerFactoryAbi,
919
1094
  functionName: "isOpener",
@@ -921,7 +1096,7 @@ export class OpenerRefundRelay {
921
1096
  }),
922
1097
  this.upfrontFactory === undefined
923
1098
  ? Promise.resolve(false)
924
- : publicClient.readContract({
1099
+ : this.read(view, {
925
1100
  address: this.upfrontFactory,
926
1101
  abi: x402UpfrontStreamOpenerFactoryAbi,
927
1102
  functionName: "isOpener",
@@ -934,7 +1109,7 @@ export class OpenerRefundRelay {
934
1109
  }
935
1110
  const kind = isUpfrontOpener === true ? "upfront" : "stream";
936
1111
  const factory = kind === "upfront" ? this.upfrontFactory : this.factory;
937
- const payer = await publicClient.readContract({
1112
+ const payer = await this.read(view, {
938
1113
  address: buyer,
939
1114
  abi: x402StreamOpenerAbi,
940
1115
  functionName: "payerOf",
@@ -965,7 +1140,7 @@ export class OpenerRefundRelay {
965
1140
  };
966
1141
  }
967
1142
  /** Off-chain check of a payer-signed opener action. `undefined` = valid. */
968
- async verifyPayerSignature(action, kind, streamId, opener, payer, signed, now) {
1143
+ async verifyPayerSignature(action, kind, streamId, opener, payer, signed, now, view) {
969
1144
  if (signed.deadline < now + MIN_SIGNATURE_DEADLINE_HEADROOM_SECONDS) {
970
1145
  return fail(403, "signature-expired", `the signed deadline must be at least ${MIN_SIGNATURE_DEADLINE_HEADROOM_SECONDS} s after the current chain time (${Number(now)}) so the transaction can be mined in time`, { chainTime: Number(now) });
971
1146
  }
@@ -974,8 +1149,8 @@ export class OpenerRefundRelay {
974
1149
  }
975
1150
  const { publicClient } = this.config;
976
1151
  const [nonce, domain] = await Promise.all([
977
- this.readNonce(opener, payer),
978
- publicClient.readContract({
1152
+ this.readNonce(opener, payer, view),
1153
+ this.read(view, {
979
1154
  address: opener,
980
1155
  abi: x402StreamOpenerAbi,
981
1156
  functionName: "eip712Domain",
@@ -1016,14 +1191,8 @@ export class OpenerRefundRelay {
1016
1191
  }
1017
1192
  return undefined;
1018
1193
  }
1019
- async chainNow() {
1020
- const block = await this.config.publicClient.getBlock({
1021
- blockTag: "latest",
1022
- });
1023
- return BigInt(block.timestamp);
1024
- }
1025
- async readNonce(opener, payer) {
1026
- return BigInt(await this.config.publicClient.readContract({
1194
+ async readNonce(opener, payer, view) {
1195
+ return BigInt(await this.read(view, {
1027
1196
  address: opener,
1028
1197
  abi: x402StreamOpenerAbi,
1029
1198
  functionName: "nonces",
@@ -1031,26 +1200,26 @@ export class OpenerRefundRelay {
1031
1200
  }));
1032
1201
  }
1033
1202
  /** An upfront opener's immutable terms (`price`, `upfrontAmount`). */
1034
- async readUpfrontTerms(opener) {
1035
- const terms = (await this.config.publicClient.readContract({
1203
+ async readUpfrontTerms(opener, view) {
1204
+ const terms = await this.read(view, {
1036
1205
  address: opener,
1037
1206
  abi: x402UpfrontStreamOpenerAbi,
1038
1207
  functionName: "terms",
1039
- }));
1208
+ });
1040
1209
  return {
1041
1210
  price: BigInt(terms.price),
1042
1211
  upfrontAmount: BigInt(terms.upfrontAmount),
1043
1212
  };
1044
1213
  }
1045
- async readLatestCheckedAt() {
1046
- return BigInt(await this.config.publicClient.readContract({
1214
+ async readLatestCheckedAt(view) {
1215
+ return BigInt(await this.read(view, {
1047
1216
  address: this.escrow,
1048
1217
  abi: streamEscrowV2Abi,
1049
1218
  functionName: "latestCheckpointCheckedAt",
1050
1219
  }));
1051
1220
  }
1052
- async readReclaimable(streamId, cumulativeAmount) {
1053
- return BigInt(await this.config.publicClient.readContract({
1221
+ async readReclaimable(streamId, cumulativeAmount, view) {
1222
+ return BigInt(await this.read(view, {
1054
1223
  address: this.escrow,
1055
1224
  abi: streamEscrowV2Abi,
1056
1225
  functionName: "reclaimableFor",
@@ -1063,17 +1232,17 @@ export class OpenerRefundRelay {
1063
1232
  * root is the escrow's on-chain `latestCheckpointRoot` (the only root the
1064
1233
  * buyer's reclaim settles against).
1065
1234
  */
1066
- async fetchProof(streamId) {
1235
+ async fetchProof(streamId, view) {
1067
1236
  const cached = this.fresh(this.proofCache, streamId);
1068
1237
  if (cached !== undefined)
1069
1238
  return cached.proof;
1070
- const proof = await this.loadProof(streamId);
1239
+ const proof = await this.loadProof(streamId, view);
1071
1240
  if (proof.kind !== "unavailable") {
1072
1241
  this.remember(this.proofCache, streamId, { proof });
1073
1242
  }
1074
1243
  return proof;
1075
1244
  }
1076
- async loadProof(streamId) {
1245
+ async loadProof(streamId, view) {
1077
1246
  const url = `${this.oracleUrl}/checkpoint/proof?streamId=${streamId}`;
1078
1247
  let response;
1079
1248
  try {
@@ -1142,7 +1311,7 @@ export class OpenerRefundRelay {
1142
1311
  root) {
1143
1312
  return { kind: "absent", reason: "the oracle's proof does not verify" };
1144
1313
  }
1145
- const onchainRoot = (await this.config.publicClient.readContract({
1314
+ const onchainRoot = (await this.read(view, {
1146
1315
  address: this.escrow,
1147
1316
  abi: streamEscrowV2Abi,
1148
1317
  functionName: "latestCheckpointRoot",
@@ -1163,6 +1332,16 @@ export class OpenerRefundRelay {
1163
1332
  };
1164
1333
  }
1165
1334
  }
1335
+ /**
1336
+ * What the stream is doing now, as a payer means it: `closed` (on-chain
1337
+ * Closed), `expired` (on-chain still Active, but past `expiresAt`, so it
1338
+ * accrues nothing more and needs no close), or `active` (running).
1339
+ */
1340
+ export function streamState(stream, now) {
1341
+ if (stream.status === 1)
1342
+ return "closed";
1343
+ return now >= stream.expiresAt ? "expired" : "active";
1344
+ }
1166
1345
  /** Effective delivery-stop time, exactly as `StreamEscrowV2.reclaim` computes it. */
1167
1346
  function stopTime(stream) {
1168
1347
  return stream.status === 1 && stream.closedAt < stream.expiresAt