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

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 (46) hide show
  1. package/dist/cli/commands/claim.d.ts +9 -0
  2. package/dist/cli/commands/claim.d.ts.map +1 -1
  3. package/dist/cli/commands/claim.js +12 -1
  4. package/dist/cli/commands/claim.js.map +1 -1
  5. package/dist/cli/commands/refund.d.ts +2 -0
  6. package/dist/cli/commands/refund.d.ts.map +1 -1
  7. package/dist/cli/commands/refund.js +126 -8
  8. package/dist/cli/commands/refund.js.map +1 -1
  9. package/dist/cli/status.d.ts +6 -0
  10. package/dist/cli/status.d.ts.map +1 -1
  11. package/dist/cli/status.js +25 -6
  12. package/dist/cli/status.js.map +1 -1
  13. package/dist/gateway/x402-opener-mode.d.ts.map +1 -1
  14. package/dist/gateway/x402-opener-mode.js +104 -7
  15. package/dist/gateway/x402-opener-mode.js.map +1 -1
  16. package/dist/x402-opener/block-floors.d.ts +35 -0
  17. package/dist/x402-opener/block-floors.d.ts.map +1 -0
  18. package/dist/x402-opener/block-floors.js +72 -0
  19. package/dist/x402-opener/block-floors.js.map +1 -0
  20. package/dist/x402-opener/facilitator-server.d.ts.map +1 -1
  21. package/dist/x402-opener/facilitator-server.js +7 -0
  22. package/dist/x402-opener/facilitator-server.js.map +1 -1
  23. package/dist/x402-opener/index.d.ts +1 -0
  24. package/dist/x402-opener/index.d.ts.map +1 -1
  25. package/dist/x402-opener/index.js +1 -0
  26. package/dist/x402-opener/index.js.map +1 -1
  27. package/dist/x402-opener/refund-relay.d.ts +62 -1
  28. package/dist/x402-opener/refund-relay.d.ts.map +1 -1
  29. package/dist/x402-opener/refund-relay.js +387 -208
  30. package/dist/x402-opener/refund-relay.js.map +1 -1
  31. package/dist/x402-opener/scheme.d.ts +8 -0
  32. package/dist/x402-opener/scheme.d.ts.map +1 -1
  33. package/dist/x402-opener/scheme.js +14 -0
  34. package/dist/x402-opener/scheme.js.map +1 -1
  35. package/docs/cli.md +23 -2
  36. package/docs/x402.md +83 -26
  37. package/package.json +2 -2
  38. package/src/cli/commands/claim.ts +17 -1
  39. package/src/cli/commands/refund.ts +149 -8
  40. package/src/cli/status.ts +28 -7
  41. package/src/gateway/x402-opener-mode.ts +131 -6
  42. package/src/x402-opener/block-floors.ts +78 -0
  43. package/src/x402-opener/facilitator-server.ts +7 -0
  44. package/src/x402-opener/index.ts +1 -0
  45. package/src/x402-opener/refund-relay.ts +486 -217
  46. package/src/x402-opener/scheme.ts +38 -0
@@ -26,6 +26,7 @@ import {
26
26
  } from "viem";
27
27
 
28
28
  import { x402StreamOpenerAbi, x402StreamOpenerFactoryAbi } from "./abi.js";
29
+ import { StreamBlockFloors } from "./block-floors.js";
29
30
  import { RelayerQueue } from "./relayer-queue.js";
30
31
  import { chainIdFromNetwork, type OpenerKind } from "./terms.js";
31
32
  import {
@@ -247,6 +248,50 @@ export interface OpenerRefundRelayConfig {
247
248
  * `escrow.generation` for (chain, escrow), else `v2`.
248
249
  */
249
250
  readonly escrowGeneration?: string;
251
+ /**
252
+ * Per-stream block floors shared with the opener scheme: the block of every
253
+ * mined settlement (and of every close/reclaim this relay mines). The relay
254
+ * never answers about a stream from a block below its floor, so a lagging
255
+ * RPC node cannot make a just-opened stream look unknown or a just-closed
256
+ * one look active. Default: a private instance (this relay's own txs only).
257
+ */
258
+ readonly streamFloors?: StreamBlockFloors;
259
+ /**
260
+ * How long one request may wait for the RPC to reach a stream's floor
261
+ * block, ms, before answering `503 chain-lagging` (never a wrong `404`).
262
+ * Default {@link DEFAULT_REFUND_LAG_TOLERANCE_MS}.
263
+ */
264
+ readonly lagToleranceMs?: number;
265
+ /** Poll interval while waiting for the RPC to catch up, ms. Default 1000. */
266
+ readonly lagPollMs?: number;
267
+ /** Injectable sleep, for tests. */
268
+ readonly sleep?: (ms: number) => Promise<void>;
269
+ }
270
+
271
+ /** Default bound on waiting for a lagging RPC inside one relay request. */
272
+ export const DEFAULT_REFUND_LAG_TOLERANCE_MS = 8_000;
273
+ const DEFAULT_LAG_POLL_MS = 1_000;
274
+
275
+ /**
276
+ * One consistent view of the chain for one relay answer: every read is pinned
277
+ * to `number` (when the RPC reports it), so a load-balanced RPC cannot mix
278
+ * state from two heights into one answer.
279
+ */
280
+ interface ChainView {
281
+ readonly number: bigint | undefined;
282
+ readonly timestamp: bigint;
283
+ /** Shared poll budget of this answer (snapshot waits + pinned-read retries). */
284
+ readonly budget: { left: number };
285
+ }
286
+
287
+ /** The RPC did not reach the stream's floor block within the tolerance. */
288
+ class ChainLagging extends Error {
289
+ constructor(
290
+ readonly floor: bigint,
291
+ readonly seen: bigint | undefined,
292
+ ) {
293
+ super(`RPC is at block ${String(seen)}, below the stream's floor ${floor}`);
294
+ }
250
295
  }
251
296
 
252
297
  /** Default reuse window for a stream's status and oracle proof. */
@@ -432,8 +477,17 @@ export class OpenerRefundRelay implements RefundRelayHandler {
432
477
  private readonly logger: Pick<Console, "error">;
433
478
  private readonly statusCache = new Map<
434
479
  string,
435
- { readonly at: number; readonly response: RefundRelayResponse }
480
+ {
481
+ readonly at: number;
482
+ readonly response: RefundRelayResponse;
483
+ /** The block the answer was computed at, when known. */
484
+ readonly block: bigint | undefined;
485
+ }
436
486
  >();
487
+ private readonly floors: StreamBlockFloors;
488
+ private readonly lagToleranceMs: number;
489
+ private readonly lagPollMs: number;
490
+ private readonly sleep: (ms: number) => Promise<void>;
437
491
  private readonly proofCache = new Map<
438
492
  string,
439
493
  { readonly at: number; readonly proof: OracleProof }
@@ -511,6 +565,21 @@ export class OpenerRefundRelay implements RefundRelayHandler {
511
565
  this.perStream = new MinuteWindows(perStream, nowMs);
512
566
  this.global = new MinuteWindows(global, nowMs);
513
567
  this.fetchImpl = config.fetch ?? ((...args) => fetch(...args));
568
+ this.floors = config.streamFloors ?? new StreamBlockFloors();
569
+ this.lagToleranceMs =
570
+ config.lagToleranceMs ?? DEFAULT_REFUND_LAG_TOLERANCE_MS;
571
+ this.lagPollMs = config.lagPollMs ?? DEFAULT_LAG_POLL_MS;
572
+ for (const [name, value] of [
573
+ ["lagToleranceMs", this.lagToleranceMs],
574
+ ["lagPollMs", this.lagPollMs],
575
+ ] as const) {
576
+ if (!Number.isInteger(value) || value < 0) {
577
+ throw new Error(`${name} must be a non-negative integer`);
578
+ }
579
+ }
580
+ this.sleep =
581
+ config.sleep ??
582
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
514
583
  }
515
584
 
516
585
  /**
@@ -563,199 +632,242 @@ export class OpenerRefundRelay implements RefundRelayHandler {
563
632
  const streamId = parseStreamId(rawStreamId);
564
633
  if (streamId === undefined) return invalidStreamId();
565
634
  if (isNoStreamId(streamId)) return nothingToRefund(404);
635
+ const floor = this.floors.get(streamId);
566
636
  const cached = this.fresh(this.statusCache, streamId);
567
- if (cached !== undefined) return cached.response;
568
- const response = await this.computeStatus(streamId);
569
- // Cache definitive answers only (200 / 404), never an outage.
637
+ // A cached answer computed below the stream's floor (a tx for it was mined
638
+ // since) is stale by construction: never serve it.
639
+ if (
640
+ cached !== undefined &&
641
+ (floor === undefined ||
642
+ (cached.block !== undefined && cached.block >= floor))
643
+ ) {
644
+ return cached.response;
645
+ }
646
+ const { response, block } = await this.computeStatus(streamId);
647
+ // Cache definitive answers only (200 / 404), never an outage or a lag.
570
648
  if (response.status === 200 || response.status === 404) {
571
- this.remember(this.statusCache, streamId, { response });
649
+ this.remember(this.statusCache, streamId, { response, block });
572
650
  }
573
651
  return response;
574
652
  }
575
653
 
576
- private async computeStatus(
577
- rawStreamId: string,
578
- ): Promise<RefundRelayResponse> {
654
+ private async computeStatus(streamId: Hex): Promise<{
655
+ readonly response: RefundRelayResponse;
656
+ readonly block: bigint | undefined;
657
+ }> {
579
658
  try {
580
- const resolved = await this.resolve(rawStreamId);
581
- if ("status" in resolved) return resolved;
582
- const { streamId, stream, opener, payer, kind, factory } = resolved;
583
- const grace = this.grace();
584
- const [now, nonce, latestCheckedAt, upfrontTerms] = await Promise.all([
585
- this.chainNow(),
586
- this.readNonce(opener, payer),
587
- this.readLatestCheckedAt(),
588
- kind === "upfront"
589
- ? this.readUpfrontTerms(opener)
590
- : Promise.resolve(undefined),
591
- ]);
592
- const closed = stream.status === 1;
593
- const stopAt = stopTime(stream);
594
- const settled = latestCheckedAt >= stopAt;
595
- const fallbackAt = stopAt + grace;
596
- const escapeAt = await this.escapeHatchAt(streamId, stream);
597
-
598
- let oracle: Record<string, unknown> | null = null;
599
- let oracleNote: string | undefined;
600
- let reclaimable: bigint | undefined;
601
- if (!stream.reclaimed) {
602
- const proof = await this.fetchProof(streamId);
603
- if (proof.kind === "ok") {
604
- oracle = {
605
- cumulativeAmount: proof.cumulativeAmount.toString(),
606
- checkpointId: proof.checkpointId.toString(),
607
- checkedAt: Number(proof.checkedAt),
608
- root: proof.root,
609
- };
610
- reclaimable = await this.readReclaimable(
611
- streamId,
612
- proof.cumulativeAmount,
613
- );
614
- } else {
615
- oracleNote = proof.reason;
616
- }
659
+ const { view, resolved } = await this.resolveFresh(streamId);
660
+ if ("status" in resolved) {
661
+ return { response: resolved, block: view.number };
662
+ }
663
+ return {
664
+ response: await this.describeStatus(resolved, view),
665
+ block: view.number,
666
+ };
667
+ } catch (error) {
668
+ if (error instanceof ChainLagging) {
669
+ return { response: this.lagging(error), block: undefined };
617
670
  }
671
+ return {
672
+ response: this.unavailable("chain read", error),
673
+ block: undefined,
674
+ };
675
+ }
676
+ }
618
677
 
619
- const base = `/streams/${streamId}`;
620
- let next: Record<string, unknown>;
621
- if (stream.reclaimed) {
622
- next = {
623
- action: "none",
624
- message:
625
- "Already reclaimed: the unearned part of the deposit was sent to the payer. Nothing further to do.",
626
- };
627
- } else if (!closed && now < stream.expiresAt) {
628
- next = {
629
- action: "close",
630
- method: "POST",
631
- ...this.link(`${base}/close`),
632
- body: {
633
- deadline: "<the deadline you signed, unix seconds>",
634
- signature: "<the payer's EIP-712 signature of typedData>",
635
- },
636
- typedData: jsonTypedData(
637
- openerActionTypedData({
638
- action: "Close",
639
- kind,
640
- opener,
641
- chainId: this.chainId,
642
- streamId,
643
- nonce,
644
- deadline: now + 900n,
645
- }),
646
- ),
647
- 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.`,
648
- };
649
- } else if (!settled && now < fallbackAt) {
650
- next = {
651
- action: "wait",
652
- retryAt: Number(fallbackAt),
653
- retryAfterSeconds: Math.min(
654
- CHECKPOINT_RETRY_HINT_SECONDS,
655
- Number(fallbackAt - now),
656
- ),
657
- message:
658
- "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.",
659
- };
660
- } else if (oracle !== null && reclaimable !== undefined) {
661
- next =
662
- reclaimable > 0n
663
- ? {
664
- action: "reclaim",
665
- method: "POST",
666
- ...this.link(`${base}/reclaim`),
667
- 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.`,
668
- }
669
- : {
670
- action: "none",
671
- message:
672
- "Nothing to reclaim: the verified delivery covers the whole deposit.",
673
- };
674
- } else if (now >= escapeAt) {
675
- next = {
676
- action: "reclaim-unverified",
677
- method: "POST",
678
- ...this.link(`${base}/reclaim-unverified`),
679
- body: {
680
- deadline: "<the deadline you signed, unix seconds>",
681
- signature: "<the payer's EIP-712 signature of typedData>",
682
- },
683
- typedData: jsonTypedData(
684
- openerActionTypedData({
685
- action: "ReclaimUnverified",
686
- kind,
687
- opener,
688
- chainId: this.chainId,
689
- streamId,
690
- nonce,
691
- deadline: now + 900n,
692
- }),
693
- ),
694
- message:
695
- "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.",
678
+ private async describeStatus(
679
+ resolved: ResolvedStream,
680
+ view: ChainView,
681
+ ): Promise<RefundRelayResponse> {
682
+ const { streamId, stream, opener, payer, kind, factory } = resolved;
683
+ const grace = this.grace();
684
+ const now = view.timestamp;
685
+ const [nonce, latestCheckedAt, upfrontTerms] = await Promise.all([
686
+ this.readNonce(opener, payer, view),
687
+ this.readLatestCheckedAt(view),
688
+ kind === "upfront"
689
+ ? this.readUpfrontTerms(opener, view)
690
+ : Promise.resolve(undefined),
691
+ ]);
692
+ const closed = stream.status === 1;
693
+ const stopAt = stopTime(stream);
694
+ const settled = latestCheckedAt >= stopAt;
695
+ const fallbackAt = stopAt + grace;
696
+ const escapeAt = await this.escapeHatchAt(streamId, stream, view);
697
+
698
+ let oracle: Record<string, unknown> | null = null;
699
+ let oracleNote: string | undefined;
700
+ let reclaimable: bigint | undefined;
701
+ if (!stream.reclaimed) {
702
+ const proof = await this.fetchProof(streamId, view);
703
+ if (proof.kind === "ok") {
704
+ oracle = {
705
+ cumulativeAmount: proof.cumulativeAmount.toString(),
706
+ checkpointId: proof.checkpointId.toString(),
707
+ checkedAt: Number(proof.checkedAt),
708
+ root: proof.root,
696
709
  };
710
+ reclaimable = await this.readReclaimable(
711
+ streamId,
712
+ proof.cumulativeAmount,
713
+ view,
714
+ );
697
715
  } else {
698
- next = {
699
- action: "wait",
700
- retryAt: Number(escapeAt),
701
- retryAfterSeconds: CHECKPOINT_RETRY_HINT_SECONDS,
702
- message:
703
- "No usable oracle proof yet: retry shortly. If none ever appears, the payer-signed escape hatch opens at retryAt.",
704
- };
716
+ oracleNote = proof.reason;
705
717
  }
718
+ }
706
719
 
707
- return {
708
- status: 200,
720
+ const base = `/streams/${streamId}`;
721
+ let next: Record<string, unknown>;
722
+ if (stream.reclaimed) {
723
+ next = {
724
+ action: "none",
725
+ message:
726
+ "Already reclaimed: the unearned part of the deposit was sent to the payer. Nothing further to do.",
727
+ };
728
+ } else if (!closed && now < stream.expiresAt) {
729
+ next = {
730
+ action: "close",
731
+ method: "POST",
732
+ ...this.link(`${base}/close`),
709
733
  body: {
710
- streamId,
711
- chainId: this.chainId,
712
- escrow: this.escrow,
713
- factory,
714
- openerKind: kind,
715
- opener,
716
- payer,
717
- operator: stream.operator,
718
- serviceRef: stream.serviceRef,
719
- deposit: stream.deposit.toString(),
720
- // The most any refund of this payment can return: the escrowed
721
- // deposit. For an upfront opener that is price - upfrontAmount; the
722
- // upfront part was paid to the seller at settlement and is final.
723
- maxRefund: stream.deposit.toString(),
724
- ...(upfrontTerms === undefined
725
- ? {}
726
- : {
727
- price: upfrontTerms.price.toString(),
728
- upfrontAmount: upfrontTerms.upfrontAmount.toString(),
729
- 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.`,
730
- }),
731
- ratePerSecond: stream.ratePerSecond.toString(),
732
- claimedCumulative: stream.claimedCumulative.toString(),
733
- openedAt: Number(stream.openedAt),
734
- expiresAt: Number(stream.expiresAt),
735
- closedAt: closed ? Number(stream.closedAt) : null,
736
- status: closed ? "closed" : "active",
737
- reclaimed: stream.reclaimed,
738
- chainTime: Number(now),
739
- settlement: {
740
- stopAt: Number(stopAt),
741
- latestCheckpointCheckedAt: Number(latestCheckedAt),
742
- checkpointCoversStop: settled,
743
- fallbackAt: Number(fallbackAt),
744
- escapeHatchAt: Number(escapeAt),
745
- },
746
- oracle,
747
- ...(oracleNote === undefined ? {} : { oracleNote }),
748
- reclaimable:
749
- reclaimable === undefined ? null : reclaimable.toString(),
750
- payerNonce: nonce.toString(),
751
- next,
752
- refunds:
753
- "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.",
734
+ deadline: "<the deadline you signed, unix seconds>",
735
+ signature: "<the payer's EIP-712 signature of typedData>",
754
736
  },
737
+ typedData: jsonTypedData(
738
+ openerActionTypedData({
739
+ action: "Close",
740
+ kind,
741
+ opener,
742
+ chainId: this.chainId,
743
+ streamId,
744
+ nonce,
745
+ deadline: now + 900n,
746
+ }),
747
+ ),
748
+ 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.`,
749
+ };
750
+ } else if (!settled && now < fallbackAt) {
751
+ next = {
752
+ action: "wait",
753
+ retryAt: Number(fallbackAt),
754
+ retryAfterSeconds: Math.min(
755
+ CHECKPOINT_RETRY_HINT_SECONDS,
756
+ Number(fallbackAt - now),
757
+ ),
758
+ message:
759
+ "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.",
760
+ };
761
+ } else if (oracle !== null && reclaimable !== undefined) {
762
+ next =
763
+ reclaimable > 0n
764
+ ? {
765
+ action: "reclaim",
766
+ method: "POST",
767
+ ...this.link(`${base}/reclaim`),
768
+ 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.`,
769
+ }
770
+ : {
771
+ action: "none",
772
+ message:
773
+ "Nothing to reclaim: the verified delivery covers the whole deposit.",
774
+ };
775
+ } else if (now >= escapeAt) {
776
+ next = {
777
+ action: "reclaim-unverified",
778
+ method: "POST",
779
+ ...this.link(`${base}/reclaim-unverified`),
780
+ body: {
781
+ deadline: "<the deadline you signed, unix seconds>",
782
+ signature: "<the payer's EIP-712 signature of typedData>",
783
+ },
784
+ typedData: jsonTypedData(
785
+ openerActionTypedData({
786
+ action: "ReclaimUnverified",
787
+ kind,
788
+ opener,
789
+ chainId: this.chainId,
790
+ streamId,
791
+ nonce,
792
+ deadline: now + 900n,
793
+ }),
794
+ ),
795
+ message:
796
+ "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.",
797
+ };
798
+ } else {
799
+ next = {
800
+ action: "wait",
801
+ retryAt: Number(escapeAt),
802
+ retryAfterSeconds: CHECKPOINT_RETRY_HINT_SECONDS,
803
+ message:
804
+ "No usable oracle proof yet: retry shortly. If none ever appears, the payer-signed escape hatch opens at retryAt.",
755
805
  };
756
- } catch (error) {
757
- return this.unavailable("chain read", error);
758
806
  }
807
+
808
+ return {
809
+ status: 200,
810
+ body: {
811
+ streamId,
812
+ chainId: this.chainId,
813
+ escrow: this.escrow,
814
+ factory,
815
+ openerKind: kind,
816
+ opener,
817
+ payer,
818
+ operator: stream.operator,
819
+ serviceRef: stream.serviceRef,
820
+ deposit: stream.deposit.toString(),
821
+ // The most any refund of this payment can EVER return (a ceiling,
822
+ // not what is available now — that is `reclaimable`): the escrowed
823
+ // deposit. For an upfront opener that is price - upfrontAmount; the
824
+ // upfront part was paid to the seller at settlement and is final.
825
+ maxRefund: stream.deposit.toString(),
826
+ maxRefundMeaning:
827
+ "ceiling: the most this payment could ever refund (the escrowed deposit), whatever was delivered. Not the amount available now; see reclaimable.",
828
+ ...(upfrontTerms === undefined
829
+ ? {}
830
+ : {
831
+ price: upfrontTerms.price.toString(),
832
+ upfrontAmount: upfrontTerms.upfrontAmount.toString(),
833
+ 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.`,
834
+ }),
835
+ ratePerSecond: stream.ratePerSecond.toString(),
836
+ claimedCumulative: stream.claimedCumulative.toString(),
837
+ openedAt: Number(stream.openedAt),
838
+ expiresAt: Number(stream.expiresAt),
839
+ closedAt: closed ? Number(stream.closedAt) : null,
840
+ // `status` is the escrow's own on-chain status (Active/Closed), kept
841
+ // verbatim for clients that parse it. An Active stream past
842
+ // expiresAt is not running any more: `state` says so.
843
+ status: closed ? "closed" : "active",
844
+ onChainStatus: closed ? "closed" : "active",
845
+ state: streamState(stream, now),
846
+ reclaimed: stream.reclaimed,
847
+ chainTime: Number(now),
848
+ settlement: {
849
+ stopAt: Number(stopAt),
850
+ latestCheckpointCheckedAt: Number(latestCheckedAt),
851
+ checkpointCoversStop: settled,
852
+ fallbackAt: Number(fallbackAt),
853
+ escapeHatchAt: Number(escapeAt),
854
+ },
855
+ oracle,
856
+ ...(oracleNote === undefined ? {} : { oracleNote }),
857
+ reclaimable: reclaimable === undefined ? null : reclaimable.toString(),
858
+ reclaimableMeaning: stream.reclaimed
859
+ ? "already reclaimed: nothing more can be refunded for this stream."
860
+ : reclaimable === undefined
861
+ ? "unknown right now (no usable oracle proof yet); see next."
862
+ : !closed && now < stream.expiresAt
863
+ ? "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)."
864
+ : "available now: what a reclaim returns to the payer, given the latest verified delivery. 0 means the verified delivery covers the whole deposit.",
865
+ payerNonce: nonce.toString(),
866
+ next,
867
+ refunds:
868
+ "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.",
869
+ },
870
+ };
759
871
  }
760
872
 
761
873
  // ── POST /streams/:streamId/close ───────────────────────────────────────
@@ -773,10 +885,10 @@ export class OpenerRefundRelay implements RefundRelayHandler {
773
885
  const early = await this.precheck(key, "close");
774
886
  if (early !== undefined) return early;
775
887
  try {
776
- const resolved = await this.resolve(streamId);
888
+ const { view, resolved } = await this.resolveFresh(streamId);
777
889
  if ("status" in resolved) return resolved;
778
890
  const { stream, opener, payer, kind } = resolved;
779
- const now = await this.chainNow();
891
+ const now = view.timestamp;
780
892
  if (stream.status === 1) {
781
893
  return fail(409, "already-closed", "the stream is already closed", {
782
894
  next: this.link(`/streams/${streamId}/reclaim`),
@@ -798,11 +910,13 @@ export class OpenerRefundRelay implements RefundRelayHandler {
798
910
  payer,
799
911
  signed,
800
912
  now,
913
+ view,
801
914
  );
802
915
  if (refused !== undefined) return refused;
803
916
  return await this.submit(
804
917
  key,
805
918
  streamId,
919
+ view,
806
920
  {
807
921
  address: opener,
808
922
  functionName: "closeWithSig",
@@ -812,6 +926,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
812
926
  { kind: "close", streamId, opener, payer },
813
927
  );
814
928
  } catch (error) {
929
+ if (error instanceof ChainLagging) return this.lagging(error);
815
930
  return this.unavailable("chain read", error);
816
931
  }
817
932
  }
@@ -826,11 +941,11 @@ export class OpenerRefundRelay implements RefundRelayHandler {
826
941
  const early = await this.precheck(key, "reclaim");
827
942
  if (early !== undefined) return early;
828
943
  try {
829
- const resolved = await this.resolve(streamId);
944
+ const { view, resolved } = await this.resolveFresh(streamId);
830
945
  if ("status" in resolved) return resolved;
831
946
  const { stream, opener, payer } = resolved;
832
947
  if (stream.reclaimed) return alreadyReclaimed();
833
- const now = await this.chainNow();
948
+ const now = view.timestamp;
834
949
  if (stream.status !== 1 && now < stream.expiresAt) {
835
950
  return fail(
836
951
  409,
@@ -845,7 +960,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
845
960
  }
846
961
  const stopAt = stopTime(stream);
847
962
  const fallbackAt = stopAt + this.grace();
848
- const latestCheckedAt = await this.readLatestCheckedAt();
963
+ const latestCheckedAt = await this.readLatestCheckedAt(view);
849
964
  if (latestCheckedAt < stopAt && now < fallbackAt) {
850
965
  return fail(
851
966
  409,
@@ -855,7 +970,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
855
970
  Math.min(CHECKPOINT_RETRY_HINT_SECONDS, Number(fallbackAt - now)),
856
971
  );
857
972
  }
858
- const proof = await this.fetchProof(streamId);
973
+ const proof = await this.fetchProof(streamId, view);
859
974
  if (proof.kind === "unavailable") {
860
975
  return fail(503, "oracle-unavailable", proof.reason, {}, 30);
861
976
  }
@@ -865,7 +980,9 @@ export class OpenerRefundRelay implements RefundRelayHandler {
865
980
  "proof-unavailable",
866
981
  `${proof.reason}; retry shortly. If no proof ever appears, the payer-signed escape hatch (POST /streams/:streamId/reclaim-unverified) opens at escapeHatchAt.`,
867
982
  {
868
- escapeHatchAt: Number(await this.escapeHatchAt(streamId, stream)),
983
+ escapeHatchAt: Number(
984
+ await this.escapeHatchAt(streamId, stream, view),
985
+ ),
869
986
  },
870
987
  CHECKPOINT_RETRY_HINT_SECONDS,
871
988
  );
@@ -873,6 +990,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
873
990
  const amount = await this.readReclaimable(
874
991
  streamId,
875
992
  proof.cumulativeAmount,
993
+ view,
876
994
  );
877
995
  if (amount === 0n) {
878
996
  return fail(
@@ -884,6 +1002,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
884
1002
  return await this.submit(
885
1003
  key,
886
1004
  streamId,
1005
+ view,
887
1006
  {
888
1007
  address: opener,
889
1008
  functionName: "reclaim",
@@ -902,6 +1021,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
902
1021
  },
903
1022
  );
904
1023
  } catch (error) {
1024
+ if (error instanceof ChainLagging) return this.lagging(error);
905
1025
  return this.unavailable("chain read", error);
906
1026
  }
907
1027
  }
@@ -921,12 +1041,12 @@ export class OpenerRefundRelay implements RefundRelayHandler {
921
1041
  const early = await this.precheck(key, "reclaim");
922
1042
  if (early !== undefined) return early;
923
1043
  try {
924
- const resolved = await this.resolve(streamId);
1044
+ const { view, resolved } = await this.resolveFresh(streamId);
925
1045
  if ("status" in resolved) return resolved;
926
1046
  const { stream, opener, payer, kind } = resolved;
927
1047
  if (stream.reclaimed) return alreadyReclaimed();
928
- const now = await this.chainNow();
929
- const escapeAt = await this.escapeHatchAt(streamId, stream);
1048
+ const now = view.timestamp;
1049
+ const escapeAt = await this.escapeHatchAt(streamId, stream, view);
930
1050
  if (now < escapeAt) {
931
1051
  return fail(
932
1052
  409,
@@ -957,11 +1077,13 @@ export class OpenerRefundRelay implements RefundRelayHandler {
957
1077
  payer,
958
1078
  signed,
959
1079
  now,
1080
+ view,
960
1081
  );
961
1082
  if (refused !== undefined) return refused;
962
1083
  return await this.submit(
963
1084
  key,
964
1085
  streamId,
1086
+ view,
965
1087
  {
966
1088
  address: opener,
967
1089
  functionName: "reclaimUnverifiedWithSig",
@@ -980,6 +1102,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
980
1102
  },
981
1103
  );
982
1104
  } catch (error) {
1105
+ if (error instanceof ChainLagging) return this.lagging(error);
983
1106
  return this.unavailable("chain read", error);
984
1107
  }
985
1108
  }
@@ -1044,14 +1167,132 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1044
1167
  );
1045
1168
  }
1046
1169
 
1170
+ /**
1171
+ * The RPC has not reached the block of a transaction this facilitator saw
1172
+ * mined for the stream. NEVER a `404` (the stream exists) and never a stale
1173
+ * answer: a retryable `503`.
1174
+ */
1175
+ private lagging(error: ChainLagging): RefundRelayResponse {
1176
+ this.log("rpc lagging", error);
1177
+ return fail(
1178
+ 503,
1179
+ "chain-lagging",
1180
+ `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`,
1181
+ { minBlock: error.floor.toString() },
1182
+ 2,
1183
+ );
1184
+ }
1185
+
1186
+ /** Poll budget of one answer (snapshot waits + pinned-read retries). */
1187
+ private maxLagPolls(): number {
1188
+ if (this.lagToleranceMs === 0) return 0;
1189
+ return this.lagPollMs === 0
1190
+ ? 3
1191
+ : Math.max(1, Math.ceil(this.lagToleranceMs / this.lagPollMs));
1192
+ }
1193
+
1194
+ /**
1195
+ * A view of the chain at a block >= `floor`: waits (bounded) while the RPC's
1196
+ * `latest` is below it, then throws {@link ChainLagging}.
1197
+ */
1198
+ private async snapshot(floor: bigint | undefined): Promise<ChainView> {
1199
+ const budget = { left: this.maxLagPolls() };
1200
+ for (;;) {
1201
+ const block = await this.config.publicClient.getBlock({
1202
+ blockTag: "latest",
1203
+ });
1204
+ const number =
1205
+ typeof block.number === "bigint" ? block.number : undefined;
1206
+ if (floor === undefined || (number !== undefined && number >= floor)) {
1207
+ return { number, timestamp: BigInt(block.timestamp), budget };
1208
+ }
1209
+ if (budget.left <= 0) throw new ChainLagging(floor, number);
1210
+ budget.left -= 1;
1211
+ await this.sleep(this.lagPollMs);
1212
+ }
1213
+ }
1214
+
1215
+ /**
1216
+ * Resolves a stream on a view that is at or above its floor. A miss for a
1217
+ * stream with no known floor is confirmed once more, a poll interval later,
1218
+ * before it is answered as `404`, so a node that lags the one which mined the stream
1219
+ * (or a facilitator restarted since the settlement) cannot turn a real
1220
+ * stream into "not found".
1221
+ */
1222
+ private async resolveFresh(streamId: Hex): Promise<{
1223
+ readonly view: ChainView;
1224
+ readonly resolved: ResolvedStream | RefundRelayResponse;
1225
+ }> {
1226
+ const floor = this.floors.get(streamId);
1227
+ const view = await this.snapshot(floor);
1228
+ const resolved = await this.resolve(streamId, view);
1229
+ if (!("status" in resolved) || resolved.status !== 404) {
1230
+ return { view, resolved };
1231
+ }
1232
+ if (floor !== undefined) {
1233
+ // Pinned at or above the block that mined it, and still absent: the
1234
+ // chain itself says no. Unexpected for a stream this process settled.
1235
+ this.log(
1236
+ "not found at or above its floor",
1237
+ new Error(`${streamId} absent at block ${String(view.number)}`),
1238
+ );
1239
+ return { view, resolved };
1240
+ }
1241
+ // One confirmation, a poll interval later, on a view at least as high as
1242
+ // the first (a node behind it is waited for, bounded). Not "strictly
1243
+ // newer": a chain that mines no idle blocks (a local fork) would make
1244
+ // every genuine 404 wait the whole bound.
1245
+ await this.sleep(this.lagPollMs);
1246
+ const again = await this.snapshot(view.number).catch((error: unknown) => {
1247
+ // The RPC fell back below the first view: the first (pinned) read stands.
1248
+ if (error instanceof ChainLagging) return undefined;
1249
+ throw error;
1250
+ });
1251
+ if (again === undefined) return { view, resolved };
1252
+ return { view: again, resolved: await this.resolve(streamId, again) };
1253
+ }
1254
+
1255
+ /**
1256
+ * `readContract` pinned to the view's block. A node behind that block
1257
+ * answers with an error rather than old state; such errors are retried
1258
+ * within the view's poll budget. Contract reverts are never retried.
1259
+ */
1260
+ private async read<T>(
1261
+ view: ChainView | undefined,
1262
+ params: Record<string, unknown> | object,
1263
+ ): Promise<T> {
1264
+ for (;;) {
1265
+ try {
1266
+ return (await this.config.publicClient.readContract({
1267
+ ...(params as object),
1268
+ ...(view?.number === undefined ? {} : { blockNumber: view.number }),
1269
+ } as Parameters<PublicClient["readContract"]>[0])) as T;
1270
+ } catch (error) {
1271
+ if (
1272
+ view?.number === undefined ||
1273
+ revertName(error) !== undefined ||
1274
+ view.budget.left <= 0
1275
+ ) {
1276
+ throw error;
1277
+ }
1278
+ view.budget.left -= 1;
1279
+ await this.sleep(this.lagPollMs);
1280
+ }
1281
+ }
1282
+ }
1283
+
1047
1284
  /** When the no-proof escape hatch opens for this stream, per generation. */
1048
1285
  private async escapeHatchAt(
1049
1286
  streamId: Hex,
1050
1287
  stream: { readonly expiresAt: bigint; readonly closedAt: bigint },
1288
+ view: ChainView,
1051
1289
  ): Promise<bigint> {
1290
+ const reader: EscapeHatchReader = {
1291
+ readContract: (args) => this.read<bigint>(view, args),
1292
+ };
1052
1293
  return await readEscapeHatchReadyAt({
1053
1294
  generation: this.generation,
1054
- reader: this.config.publicClient as unknown as EscapeHatchReader,
1295
+ reader,
1055
1296
  escrow: this.escrow,
1056
1297
  streamId,
1057
1298
  stream,
@@ -1117,6 +1358,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1117
1358
  private async submit(
1118
1359
  key: string,
1119
1360
  streamId: Hex,
1361
+ view: ChainView,
1120
1362
  call: {
1121
1363
  readonly address: Address;
1122
1364
  readonly functionName:
@@ -1176,6 +1418,9 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1176
1418
  abi: x402StreamOpenerAbi,
1177
1419
  functionName: call.functionName,
1178
1420
  args: call.args,
1421
+ // The same block every precondition (and the expected amount) was
1422
+ // read at: a lagging node cannot simulate against older state.
1423
+ ...(view.number === undefined ? {} : { blockNumber: view.number }),
1179
1424
  } as unknown as Parameters<typeof publicClient.simulateContract>[0]);
1180
1425
  if (expectedResult !== undefined) {
1181
1426
  if (
@@ -1267,6 +1512,8 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1267
1512
  );
1268
1513
  }
1269
1514
  this.ledger.set(key, { state: "final", hash, success: true });
1515
+ // Every later answer about this stream must reflect this transaction.
1516
+ this.floors.note(expectation.streamId, receipt.blockNumber);
1270
1517
  if (expectation.kind === "close") {
1271
1518
  return {
1272
1519
  status: 200,
@@ -1302,18 +1549,18 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1302
1549
  /** Streams whose escrow buyer is an opener of the configured factory, or a 404. */
1303
1550
  private async resolve(
1304
1551
  rawStreamId: string,
1552
+ view: ChainView,
1305
1553
  ): Promise<ResolvedStream | RefundRelayResponse> {
1306
1554
  const streamId = parseStreamId(rawStreamId);
1307
1555
  if (streamId === undefined) return invalidStreamId();
1308
- const { publicClient } = this.config;
1309
1556
  let stream: OpenerStream;
1310
1557
  try {
1311
- stream = (await publicClient.readContract({
1558
+ stream = await this.read<OpenerStream>(view, {
1312
1559
  address: this.escrow,
1313
1560
  abi: streamEscrowV2Abi,
1314
1561
  functionName: "getStream",
1315
1562
  args: [streamId],
1316
- })) as unknown as OpenerStream;
1563
+ });
1317
1564
  } catch (error) {
1318
1565
  if (revertName(error) === "UnknownStream") return notFound();
1319
1566
  throw error;
@@ -1323,7 +1570,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1323
1570
  // The kind is whichever CONFIGURED factory's isOpener claims the buyer,
1324
1571
  // read on-chain — never inferred from the address, code or the request.
1325
1572
  const [isStreamOpener, isUpfrontOpener] = await Promise.all([
1326
- publicClient.readContract({
1573
+ this.read<boolean>(view, {
1327
1574
  address: this.factory,
1328
1575
  abi: x402StreamOpenerFactoryAbi,
1329
1576
  functionName: "isOpener",
@@ -1331,7 +1578,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1331
1578
  }),
1332
1579
  this.upfrontFactory === undefined
1333
1580
  ? Promise.resolve(false)
1334
- : publicClient.readContract({
1581
+ : this.read<boolean>(view, {
1335
1582
  address: this.upfrontFactory,
1336
1583
  abi: x402UpfrontStreamOpenerFactoryAbi,
1337
1584
  functionName: "isOpener",
@@ -1345,7 +1592,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1345
1592
  const kind: OpenerKind = isUpfrontOpener === true ? "upfront" : "stream";
1346
1593
  const factory =
1347
1594
  kind === "upfront" ? (this.upfrontFactory as Address) : this.factory;
1348
- const payer = await publicClient.readContract({
1595
+ const payer = await this.read<Address>(view, {
1349
1596
  address: buyer,
1350
1597
  abi: x402StreamOpenerAbi,
1351
1598
  functionName: "payerOf",
@@ -1384,6 +1631,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1384
1631
  payer: Address,
1385
1632
  signed: SignedBody,
1386
1633
  now: bigint,
1634
+ view: ChainView,
1387
1635
  ): Promise<RefundRelayResponse | undefined> {
1388
1636
  if (signed.deadline < now + MIN_SIGNATURE_DEADLINE_HEADROOM_SECONDS) {
1389
1637
  return fail(
@@ -1402,8 +1650,8 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1402
1650
  }
1403
1651
  const { publicClient } = this.config;
1404
1652
  const [nonce, domain] = await Promise.all([
1405
- this.readNonce(opener, payer),
1406
- publicClient.readContract({
1653
+ this.readNonce(opener, payer, view),
1654
+ this.read<unknown>(view, {
1407
1655
  address: opener,
1408
1656
  abi: x402StreamOpenerAbi,
1409
1657
  functionName: "eip712Domain",
@@ -1468,16 +1716,13 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1468
1716
  return undefined;
1469
1717
  }
1470
1718
 
1471
- private async chainNow(): Promise<bigint> {
1472
- const block = await this.config.publicClient.getBlock({
1473
- blockTag: "latest",
1474
- });
1475
- return BigInt(block.timestamp);
1476
- }
1477
-
1478
- private async readNonce(opener: Address, payer: Address): Promise<bigint> {
1719
+ private async readNonce(
1720
+ opener: Address,
1721
+ payer: Address,
1722
+ view: ChainView,
1723
+ ): Promise<bigint> {
1479
1724
  return BigInt(
1480
- await this.config.publicClient.readContract({
1725
+ await this.read<bigint>(view, {
1481
1726
  address: opener,
1482
1727
  abi: x402StreamOpenerAbi,
1483
1728
  functionName: "nonces",
@@ -1489,21 +1734,25 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1489
1734
  /** An upfront opener's immutable terms (`price`, `upfrontAmount`). */
1490
1735
  private async readUpfrontTerms(
1491
1736
  opener: Address,
1737
+ view: ChainView,
1492
1738
  ): Promise<{ readonly price: bigint; readonly upfrontAmount: bigint }> {
1493
- const terms = (await this.config.publicClient.readContract({
1494
- address: opener,
1495
- abi: x402UpfrontStreamOpenerAbi,
1496
- functionName: "terms",
1497
- })) as unknown as { price: bigint; upfrontAmount: bigint };
1739
+ const terms = await this.read<{ price: bigint; upfrontAmount: bigint }>(
1740
+ view,
1741
+ {
1742
+ address: opener,
1743
+ abi: x402UpfrontStreamOpenerAbi,
1744
+ functionName: "terms",
1745
+ },
1746
+ );
1498
1747
  return {
1499
1748
  price: BigInt(terms.price),
1500
1749
  upfrontAmount: BigInt(terms.upfrontAmount),
1501
1750
  };
1502
1751
  }
1503
1752
 
1504
- private async readLatestCheckedAt(): Promise<bigint> {
1753
+ private async readLatestCheckedAt(view: ChainView): Promise<bigint> {
1505
1754
  return BigInt(
1506
- await this.config.publicClient.readContract({
1755
+ await this.read<bigint>(view, {
1507
1756
  address: this.escrow,
1508
1757
  abi: streamEscrowV2Abi,
1509
1758
  functionName: "latestCheckpointCheckedAt",
@@ -1514,9 +1763,10 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1514
1763
  private async readReclaimable(
1515
1764
  streamId: Hex,
1516
1765
  cumulativeAmount: bigint,
1766
+ view: ChainView,
1517
1767
  ): Promise<bigint> {
1518
1768
  return BigInt(
1519
- await this.config.publicClient.readContract({
1769
+ await this.read<bigint>(view, {
1520
1770
  address: this.escrow,
1521
1771
  abi: streamEscrowV2Abi,
1522
1772
  functionName: "reclaimableFor",
@@ -1531,17 +1781,23 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1531
1781
  * root is the escrow's on-chain `latestCheckpointRoot` (the only root the
1532
1782
  * buyer's reclaim settles against).
1533
1783
  */
1534
- private async fetchProof(streamId: Hex): Promise<OracleProof> {
1784
+ private async fetchProof(
1785
+ streamId: Hex,
1786
+ view: ChainView,
1787
+ ): Promise<OracleProof> {
1535
1788
  const cached = this.fresh(this.proofCache, streamId);
1536
1789
  if (cached !== undefined) return cached.proof;
1537
- const proof = await this.loadProof(streamId);
1790
+ const proof = await this.loadProof(streamId, view);
1538
1791
  if (proof.kind !== "unavailable") {
1539
1792
  this.remember(this.proofCache, streamId, { proof });
1540
1793
  }
1541
1794
  return proof;
1542
1795
  }
1543
1796
 
1544
- private async loadProof(streamId: Hex): Promise<OracleProof> {
1797
+ private async loadProof(
1798
+ streamId: Hex,
1799
+ view: ChainView,
1800
+ ): Promise<OracleProof> {
1545
1801
  const url = `${this.oracleUrl}/checkpoint/proof?streamId=${streamId}`;
1546
1802
  let response: Response;
1547
1803
  try {
@@ -1620,7 +1876,7 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1620
1876
  return { kind: "absent", reason: "the oracle's proof does not verify" };
1621
1877
  }
1622
1878
  const onchainRoot = (
1623
- await this.config.publicClient.readContract({
1879
+ await this.read<string>(view, {
1624
1880
  address: this.escrow,
1625
1881
  abi: streamEscrowV2Abi,
1626
1882
  functionName: "latestCheckpointRoot",
@@ -1644,6 +1900,19 @@ export class OpenerRefundRelay implements RefundRelayHandler {
1644
1900
  }
1645
1901
  }
1646
1902
 
1903
+ /**
1904
+ * What the stream is doing now, as a payer means it: `closed` (on-chain
1905
+ * Closed), `expired` (on-chain still Active, but past `expiresAt`, so it
1906
+ * accrues nothing more and needs no close), or `active` (running).
1907
+ */
1908
+ export function streamState(
1909
+ stream: { readonly status: number; readonly expiresAt: bigint },
1910
+ now: bigint,
1911
+ ): "active" | "expired" | "closed" {
1912
+ if (stream.status === 1) return "closed";
1913
+ return now >= stream.expiresAt ? "expired" : "active";
1914
+ }
1915
+
1647
1916
  /** Effective delivery-stop time, exactly as `StreamEscrowV2.reclaim` computes it. */
1648
1917
  function stopTime(stream: OpenerStream): bigint {
1649
1918
  return stream.status === 1 && stream.closedAt < stream.expiresAt