@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.
- package/dist/cli/commands/claim.d.ts +9 -0
- package/dist/cli/commands/claim.d.ts.map +1 -1
- package/dist/cli/commands/claim.js +12 -1
- package/dist/cli/commands/claim.js.map +1 -1
- package/dist/cli/commands/refund.d.ts +2 -0
- package/dist/cli/commands/refund.d.ts.map +1 -1
- package/dist/cli/commands/refund.js +126 -8
- package/dist/cli/commands/refund.js.map +1 -1
- package/dist/cli/status.d.ts +6 -0
- package/dist/cli/status.d.ts.map +1 -1
- package/dist/cli/status.js +25 -6
- package/dist/cli/status.js.map +1 -1
- package/dist/gateway/x402-opener-mode.d.ts.map +1 -1
- package/dist/gateway/x402-opener-mode.js +104 -7
- package/dist/gateway/x402-opener-mode.js.map +1 -1
- package/dist/x402-opener/block-floors.d.ts +35 -0
- package/dist/x402-opener/block-floors.d.ts.map +1 -0
- package/dist/x402-opener/block-floors.js +72 -0
- package/dist/x402-opener/block-floors.js.map +1 -0
- package/dist/x402-opener/facilitator-server.d.ts.map +1 -1
- package/dist/x402-opener/facilitator-server.js +7 -0
- package/dist/x402-opener/facilitator-server.js.map +1 -1
- package/dist/x402-opener/index.d.ts +1 -0
- package/dist/x402-opener/index.d.ts.map +1 -1
- package/dist/x402-opener/index.js +1 -0
- package/dist/x402-opener/index.js.map +1 -1
- package/dist/x402-opener/refund-relay.d.ts +62 -1
- package/dist/x402-opener/refund-relay.d.ts.map +1 -1
- package/dist/x402-opener/refund-relay.js +387 -208
- package/dist/x402-opener/refund-relay.js.map +1 -1
- package/dist/x402-opener/scheme.d.ts +8 -0
- package/dist/x402-opener/scheme.d.ts.map +1 -1
- package/dist/x402-opener/scheme.js +14 -0
- package/dist/x402-opener/scheme.js.map +1 -1
- package/docs/cli.md +23 -2
- package/docs/x402.md +83 -26
- package/package.json +2 -2
- package/src/cli/commands/claim.ts +17 -1
- package/src/cli/commands/refund.ts +149 -8
- package/src/cli/status.ts +28 -7
- package/src/gateway/x402-opener-mode.ts +131 -6
- package/src/x402-opener/block-floors.ts +78 -0
- package/src/x402-opener/facilitator-server.ts +7 -0
- package/src/x402-opener/index.ts +1 -0
- package/src/x402-opener/refund-relay.ts +486 -217
- 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
|
-
{
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
578
|
-
|
|
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.
|
|
581
|
-
if ("status" in resolved)
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
this.
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
),
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
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
|
-
|
|
708
|
-
|
|
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
|
-
|
|
711
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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 =
|
|
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(
|
|
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.
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
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
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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.
|
|
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 =
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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.
|
|
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
|