@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
@@ -0,0 +1,486 @@
1
+ import type {
2
+ RefundClient,
3
+ RefundRelayResult,
4
+ RefundStatus,
5
+ X402PayerSigner,
6
+ } from "@absol-labs/sdk/x402";
7
+
8
+ /**
9
+ * The buyer's own close + reclaim of an x402 stream, through the seller's
10
+ * gasless refund relay. ONE implementation behind `metrik refund`,
11
+ * `metrik pay --close-after`, the MCP `x402_pay` tool's `closeAfter` and
12
+ * {@link closeAfterPayment} (`@absol-labs/agent/x402-buyer`).
13
+ *
14
+ * Nothing here is an automatic refund: it runs only when the buyer asks for
15
+ * it, and it is the buyer's own client that signs the Close and asks the
16
+ * relay to send the permissionless reclaim. The relay pays the gas; the
17
+ * opener always sends the reclaim to the payer.
18
+ *
19
+ * The waits are bounded and the relay's own: `RefundClient.status()` waits
20
+ * out a `503 chain-lagging` (honouring `Retry-After`), and
21
+ * `RefundClient.reclaim()` retries, bounded by the client's `maxAttempts` /
22
+ * `maxDelayMs`, while the relay says the checkpoint covering the close is not
23
+ * posted yet.
24
+ */
25
+
26
+ /**
27
+ * After a close/reclaim, how many times the summary re-reads the relay until
28
+ * it reflects the transaction just mined (a load-balanced RPC behind the relay
29
+ * can answer from a node that has not seen it yet), and how long it waits
30
+ * between reads. Bounded: past it the summary is derived from the verified
31
+ * receipts instead.
32
+ */
33
+ export const POST_ACTION_READS = 6;
34
+ export const POST_ACTION_INTERVAL_MS = 1_500;
35
+
36
+ /** Why a recovery is over. */
37
+ export type FinishedReason = "reclaimed" | "nothing-to-reclaim";
38
+ /** Why a requested close / reclaim sent nothing. */
39
+ export type SkipReason = FinishedReason | "already-closed";
40
+ /** Which step of a close + reclaim did not finish. */
41
+ export type RefundActionStage = "close" | "reclaim";
42
+
43
+ export interface RefundActionsInput {
44
+ /** The SDK refund client, bound to ONE stream. */
45
+ readonly refunds: RefundClient;
46
+ readonly streamId: `0x${string}`;
47
+ readonly opener: `0x${string}`;
48
+ /** The payer as the chain (or the signed payment) knows it, when known. */
49
+ readonly payer: `0x${string}` | null;
50
+ /** Stop the stream (the payer signs the opener's EIP-712 Close). */
51
+ readonly close: boolean;
52
+ /** Ask the relay to send the permissionless reclaim (to the payer). */
53
+ readonly reclaim: boolean;
54
+ /**
55
+ * The payer's signer, resolved only when a Close actually has to be signed
56
+ * (a stream that is already closed needs no wallet). Its caller authorizes
57
+ * it (e.g. the mandate owner's signature) before returning it.
58
+ */
59
+ readonly closeSigner: () => Promise<X402PayerSigner>;
60
+ readonly sleep: (ms: number) => Promise<void>;
61
+ /** Progress lines (stderr in the CLI). */
62
+ readonly note?: (line: string) => void;
63
+ /** What the run is waiting on, while it waits (`null` clears it). */
64
+ readonly setActivity?: (activity: string | null) => void;
65
+ /** Wording of the reclaim wait for {@link RefundActionsInput.setActivity}. */
66
+ readonly reclaimActivity?: string;
67
+ }
68
+
69
+ export interface RefundActionsResult {
70
+ readonly close: RefundRelayResult | null;
71
+ readonly closeSkipped: SkipReason | null;
72
+ readonly reclaim: RefundRelayResult | null;
73
+ readonly reclaimSkipped: SkipReason | null;
74
+ /** The relay's view AFTER this run's actions (or derived from their receipts). */
75
+ readonly status: RefundStatus;
76
+ /**
77
+ * Set when the recovery is over without a reclaim from THIS run: the stream
78
+ * was already reclaimed, or the verified delivery covers the whole deposit.
79
+ */
80
+ readonly finished: FinishedReason | null;
81
+ }
82
+
83
+ /**
84
+ * A close or reclaim that did not complete. `closed` says whether the stream
85
+ * is known to be closed (by this run or before it), so the caller can name the
86
+ * exact command that finishes the job. `cause` is the SDK's error, unchanged.
87
+ */
88
+ export class RefundActionsError extends Error {
89
+ readonly stage: RefundActionStage;
90
+ readonly closed: boolean;
91
+ readonly closeResult: RefundRelayResult | null;
92
+
93
+ constructor(context: {
94
+ readonly stage: RefundActionStage;
95
+ readonly closed: boolean;
96
+ readonly closeResult: RefundRelayResult | null;
97
+ readonly cause: unknown;
98
+ }) {
99
+ super(
100
+ `the ${context.stage} did not finish: ${context.cause instanceof Error ? context.cause.message : String(context.cause)}`,
101
+ { cause: context.cause },
102
+ );
103
+ this.name = "RefundActionsError";
104
+ this.stage = context.stage;
105
+ this.closed = context.closed;
106
+ this.closeResult = context.closeResult;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Close and/or reclaim one x402 stream through its refund relay, then read
112
+ * the state AFTER those actions. Finished states (already closed, already
113
+ * reclaimed, nothing to reclaim) are reported, not thrown. Anything else that
114
+ * fails is a {@link RefundActionsError} carrying the SDK's error as `cause`.
115
+ */
116
+ export async function runRefundActions(
117
+ input: RefundActionsInput,
118
+ ): Promise<RefundActionsResult> {
119
+ const { refunds } = input;
120
+ let closeResult: RefundRelayResult | null = null;
121
+ let closeSkipped: SkipReason | null = null;
122
+ // Set when the relay itself says there is nothing left: the stream was
123
+ // already reclaimed, or the verified delivery covers the whole deposit.
124
+ // Those are FINISHED recoveries, not failures — a second close + reclaim on
125
+ // a done stream must succeed and never point the payer at a gas-spending
126
+ // on-chain recovery that cannot recover a cent.
127
+ let finishedByRelay: FinishedReason | null = null;
128
+ if (input.close) {
129
+ try {
130
+ // Ask first: a stream that is already closed (or already finished)
131
+ // needs no Close, so there is nothing to sign and no wallet is needed.
132
+ const before = await refunds.status();
133
+ const finishedBefore = finishedReason(before);
134
+ if (finishedBefore !== null || before.status === "closed") {
135
+ closeSkipped = finishedBefore ?? "already-closed";
136
+ finishedByRelay = finishedBefore;
137
+ input.note?.(
138
+ finishedBefore === null
139
+ ? "stream is already closed — no Close to sign."
140
+ : `stream is already closed and ${finishedBefore === "reclaimed" ? "reclaimed" : "has nothing left to reclaim"} — no Close to sign.`,
141
+ );
142
+ } else {
143
+ const signer = await input.closeSigner();
144
+ try {
145
+ closeResult = await refunds.close({ signer });
146
+ input.note?.(
147
+ `close relayed in ${closeResult.transaction}${closeResult.verified ? " (receipt verified on-chain)" : ""}`,
148
+ );
149
+ } catch (error) {
150
+ // The status read above said "active", but the stream closed in the
151
+ // meantime (it reached expiresAt, or another close landed): the
152
+ // state the payer asked for. Anything else is a real failure.
153
+ if (relayCode(error) !== "already-closed") throw error;
154
+ closeSkipped = "already-closed";
155
+ input.note?.("stream was already closed — no Close was needed.");
156
+ }
157
+ }
158
+ } catch (error) {
159
+ throw new RefundActionsError({
160
+ stage: "close",
161
+ closed: false,
162
+ closeResult: null,
163
+ cause: error,
164
+ });
165
+ }
166
+ }
167
+
168
+ let reclaimResult: RefundRelayResult | null = null;
169
+ let reclaimSkipped: SkipReason | null = null;
170
+ if (input.reclaim) {
171
+ if (finishedByRelay !== null) {
172
+ reclaimSkipped = finishedByRelay;
173
+ } else {
174
+ if (input.reclaimActivity !== undefined) {
175
+ input.setActivity?.(input.reclaimActivity);
176
+ }
177
+ try {
178
+ reclaimResult = await refunds.reclaim();
179
+ } catch (error) {
180
+ const finished = finishedFromRelayCode(relayCode(error));
181
+ if (finished === null) {
182
+ throw new RefundActionsError({
183
+ stage: "reclaim",
184
+ closed: closeResult !== null || closeSkipped !== null,
185
+ closeResult,
186
+ cause: error,
187
+ });
188
+ }
189
+ reclaimSkipped = finished;
190
+ finishedByRelay = finished;
191
+ } finally {
192
+ input.setActivity?.(null);
193
+ }
194
+ }
195
+ }
196
+
197
+ // The summary describes the state AFTER what this run did: re-read until
198
+ // the relay reflects the close/reclaim just mined (bounded), else derive it
199
+ // from the verified receipts. Never the pre-action state.
200
+ const status = await statusAfterActions(refunds, {
201
+ closed: closeResult !== null,
202
+ reclaim: reclaimResult,
203
+ payer: input.payer,
204
+ opener: input.opener,
205
+ streamId: input.streamId,
206
+ sleep: input.sleep,
207
+ });
208
+ // A reclaim THIS run performed is reported as a reclaim, not as "already
209
+ // finished".
210
+ const finished =
211
+ reclaimResult === null ? (finishedReason(status) ?? finishedByRelay) : null;
212
+ return {
213
+ close: closeResult,
214
+ closeSkipped,
215
+ reclaim: reclaimResult,
216
+ reclaimSkipped,
217
+ status,
218
+ finished,
219
+ };
220
+ }
221
+
222
+ /** The outcome of a close + reclaim run right after a paid response. */
223
+ export interface CloseAfterOutcome {
224
+ /**
225
+ * `completed`: closed and reclaimed (or the relay says nothing is left).
226
+ * `nothing-to-close`: an upfront-only payment, no stream was opened.
227
+ * `incomplete`: the close or the reclaim did not finish; nothing is lost —
228
+ * finish it with the refund command (`incomplete.stage` says which part).
229
+ */
230
+ readonly outcome: "completed" | "nothing-to-close" | "incomplete";
231
+ readonly streamId: `0x${string}` | null;
232
+ readonly close: RefundRelayResult | null;
233
+ readonly closeSkipped: SkipReason | null;
234
+ readonly reclaim: RefundRelayResult | null;
235
+ readonly reclaimSkipped: SkipReason | null;
236
+ /**
237
+ * Base units returned to the payer by THIS run's reclaim, from the mined
238
+ * receipt's `ReclaimForwarded` (only when the receipt was verified; `null`
239
+ * otherwise, including a relay-reported amount nobody checked).
240
+ */
241
+ readonly refundedUsdc: bigint | null;
242
+ readonly finished: FinishedReason | null;
243
+ readonly status: RefundStatus | null;
244
+ readonly incomplete: {
245
+ readonly stage: RefundActionStage | "no-relay";
246
+ /** Whether the stream is known to be closed already. */
247
+ readonly closed: boolean;
248
+ readonly reason: string;
249
+ readonly error: unknown;
250
+ } | null;
251
+ }
252
+
253
+ export interface CloseAfterPaymentInput extends Omit<
254
+ RefundActionsInput,
255
+ "refunds" | "streamId" | "opener" | "close" | "reclaim"
256
+ > {
257
+ /** The settled payment (`metrikStreamOpener` of the pay result). */
258
+ readonly payment: {
259
+ readonly streamId: `0x${string}` | null;
260
+ readonly opener: `0x${string}`;
261
+ readonly refundUrl: string | null;
262
+ };
263
+ /** Builds the SDK refund client for that stream (the caller's own wiring). */
264
+ readonly openRefunds: () => Promise<RefundClient>;
265
+ }
266
+
267
+ /**
268
+ * Right after a paid response, at the BUYER's request: close the stream the
269
+ * payment opened (the payer signs the Close, the relay pays the gas), wait for
270
+ * the checkpoint covering the close, and reclaim the unearned remainder to the
271
+ * payer. Never throws for a close/reclaim failure: the payment already
272
+ * succeeded, so the outcome says what did not finish instead.
273
+ *
274
+ * An upfront-only payment (no stream) has nothing to close: the outcome is
275
+ * `nothing-to-close` and nothing is requested, signed or sent.
276
+ */
277
+ export async function closeAfterPayment(
278
+ input: CloseAfterPaymentInput,
279
+ ): Promise<CloseAfterOutcome> {
280
+ const { payment } = input;
281
+ const empty = {
282
+ close: null,
283
+ closeSkipped: null,
284
+ reclaim: null,
285
+ reclaimSkipped: null,
286
+ refundedUsdc: null,
287
+ finished: null,
288
+ status: null,
289
+ } as const;
290
+ if (payment.streamId === null || /^0x0{64}$/i.test(payment.streamId)) {
291
+ return {
292
+ ...empty,
293
+ outcome: "nothing-to-close",
294
+ streamId: null,
295
+ incomplete: null,
296
+ };
297
+ }
298
+ const streamId = payment.streamId;
299
+ if (payment.refundUrl === null) {
300
+ return {
301
+ ...empty,
302
+ outcome: "incomplete",
303
+ streamId,
304
+ incomplete: {
305
+ stage: "no-relay",
306
+ closed: false,
307
+ reason:
308
+ "the seller advertised no refund relay, so there is no gasless close; the stream accrues only for verified seconds until it expires, and anyone can reclaim the unearned remainder to the payer after that",
309
+ error: null,
310
+ },
311
+ };
312
+ }
313
+ let refunds: RefundClient;
314
+ try {
315
+ refunds = await input.openRefunds();
316
+ } catch (error) {
317
+ return {
318
+ ...empty,
319
+ outcome: "incomplete",
320
+ streamId,
321
+ incomplete: {
322
+ stage: "close",
323
+ closed: false,
324
+ reason: errorMessage(error),
325
+ error,
326
+ },
327
+ };
328
+ }
329
+ try {
330
+ const result = await runRefundActions({
331
+ ...input,
332
+ refunds,
333
+ streamId,
334
+ opener: payment.opener,
335
+ close: true,
336
+ reclaim: true,
337
+ });
338
+ return {
339
+ outcome: "completed",
340
+ streamId,
341
+ close: result.close,
342
+ closeSkipped: result.closeSkipped,
343
+ reclaim: result.reclaim,
344
+ reclaimSkipped: result.reclaimSkipped,
345
+ refundedUsdc:
346
+ result.reclaim !== null && result.reclaim.verified
347
+ ? result.reclaim.amount
348
+ : null,
349
+ finished: result.finished,
350
+ status: result.status,
351
+ incomplete: null,
352
+ };
353
+ } catch (error) {
354
+ if (!(error instanceof RefundActionsError)) throw error;
355
+ return {
356
+ ...empty,
357
+ outcome: "incomplete",
358
+ streamId,
359
+ close: error.closeResult,
360
+ incomplete: {
361
+ stage: error.stage,
362
+ closed: error.closed,
363
+ reason: errorMessage(error.cause),
364
+ error: error.cause,
365
+ },
366
+ };
367
+ }
368
+ }
369
+
370
+ function errorMessage(error: unknown): string {
371
+ return error instanceof Error ? error.message : String(error);
372
+ }
373
+
374
+ /**
375
+ * The relay's view of a stream AFTER this run's actions. Reads until it
376
+ * reflects them (closed after a close, reclaimed after a reclaim), bounded;
377
+ * if the relay still lags, the state those verified transactions imply is
378
+ * reported instead, marked as derived. With no action, one plain read.
379
+ */
380
+ export async function statusAfterActions(
381
+ refunds: RefundClient,
382
+ done: {
383
+ readonly closed: boolean;
384
+ readonly reclaim: RefundRelayResult | null;
385
+ readonly payer: `0x${string}` | null;
386
+ readonly opener: `0x${string}`;
387
+ readonly streamId: `0x${string}`;
388
+ readonly sleep: (ms: number) => Promise<void>;
389
+ },
390
+ ): Promise<RefundStatus> {
391
+ const reclaimed = done.reclaim !== null;
392
+ if (!done.closed && !reclaimed) return await refunds.status();
393
+ const reflects = (status: RefundStatus): boolean =>
394
+ (!done.closed || status.status === "closed" || status.reclaimed) &&
395
+ (!reclaimed || status.reclaimed);
396
+ let last: RefundStatus | null = null;
397
+ for (let attempt = 1; attempt <= POST_ACTION_READS; attempt++) {
398
+ try {
399
+ const status = await refunds.status();
400
+ if (reflects(status)) return status;
401
+ last = status;
402
+ } catch {
403
+ // A lagging relay (503 chain-lagging, a stale not-found, a blip): the
404
+ // transactions already happened, so keep going and derive if need be.
405
+ }
406
+ if (attempt < POST_ACTION_READS) await done.sleep(POST_ACTION_INTERVAL_MS);
407
+ }
408
+ return derivedStatus(last, done);
409
+ }
410
+
411
+ /** The state this run's verified close/reclaim imply, when the relay lags. */
412
+ function derivedStatus(
413
+ last: RefundStatus | null,
414
+ done: {
415
+ readonly closed: boolean;
416
+ readonly reclaim: RefundRelayResult | null;
417
+ readonly payer: `0x${string}` | null;
418
+ readonly opener: `0x${string}`;
419
+ readonly streamId: `0x${string}`;
420
+ },
421
+ ): RefundStatus {
422
+ const reclaimed = done.reclaim !== null;
423
+ const note =
424
+ "derived from this run's transaction receipts; the refund relay had not caught up with them yet";
425
+ return {
426
+ streamId: done.streamId,
427
+ chainId: last?.chainId ?? 0,
428
+ opener: last?.opener ?? done.opener,
429
+ payer: last?.payer ?? done.payer ?? done.opener,
430
+ status: "closed",
431
+ reclaimed: reclaimed || (last?.reclaimed ?? false),
432
+ chainTime: last?.chainTime ?? null,
433
+ reclaimable: null,
434
+ // Both legs this run verified are on-chain facts: the stream is closed.
435
+ state: "closed",
436
+ onChainStatus: "closed",
437
+ maxRefund: last?.maxRefund ?? null,
438
+ maxRefundMeaning: last?.maxRefundMeaning ?? null,
439
+ reclaimableMeaning: last?.reclaimableMeaning ?? null,
440
+ next: reclaimed
441
+ ? {
442
+ action: "none",
443
+ message:
444
+ "Reclaimed: the unearned remainder was returned to the payer. Nothing further to do.",
445
+ }
446
+ : {
447
+ action: "reclaim",
448
+ message:
449
+ "Closed. Reclaim the unearned remainder once a checkpoint covering the close is posted (usually within a minute).",
450
+ },
451
+ raw: { derived: true, note },
452
+ } satisfies RefundStatus;
453
+ }
454
+
455
+ /**
456
+ * Whether the relay's status says nothing is left to recover. `reclaimed` is
457
+ * the chain's one-reclaim-per-stream flag; `next: none` with `reclaimable: 0`
458
+ * is the relay's "the verified delivery covers the whole deposit".
459
+ */
460
+ export function finishedReason(status: RefundStatus): FinishedReason | null {
461
+ if (status.reclaimed) return "reclaimed";
462
+ if (status.next.action === "none" && status.reclaimable === 0n) {
463
+ return "nothing-to-reclaim";
464
+ }
465
+ return null;
466
+ }
467
+
468
+ /** The relay's 409 codes that mean "finished", not "failed". */
469
+ function finishedFromRelayCode(code: string | null): FinishedReason | null {
470
+ if (code === "already-reclaimed") return "reclaimed";
471
+ if (code === "nothing-to-reclaim") return "nothing-to-reclaim";
472
+ return null;
473
+ }
474
+
475
+ /** The relay's refusal code, looked up through a few `cause` links. */
476
+ export function relayCode(error: unknown): string | null {
477
+ for (let current: unknown = error, depth = 0; depth < 4; depth++) {
478
+ if (!(current instanceof Error)) return null;
479
+ const code = (current as { code?: unknown }).code;
480
+ if (current.name === "RefundRelayError" && typeof code === "string") {
481
+ return code;
482
+ }
483
+ current = current.cause;
484
+ }
485
+ return null;
486
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The lowest block a read about a stream must be served from, per stream.
3
+ *
4
+ * A public RPC endpoint is usually a load balancer over several nodes that are
5
+ * not all at the same height: the node that returned a transaction's receipt
6
+ * can be a block or two ahead of the node that answers the next `eth_call` at
7
+ * `latest`. Right after the facilitator settles a payment (or the refund relay
8
+ * mines a close or reclaim), a read at `latest` can therefore still see the
9
+ * stream as it was BEFORE that transaction — not opened yet, still active,
10
+ * not reclaimed (live finding 2026-10-03: the relay answered `not-found` for a
11
+ * stream it had settled half a second earlier).
12
+ *
13
+ * Whoever observes a mined receipt for a stream records its block here; the
14
+ * refund relay then refuses to answer from any block below it (it waits,
15
+ * bounded, for the RPC to catch up and pins every read of one answer to one
16
+ * block). In-memory, per process, bounded; an entry only ever raises.
17
+ */
18
+ export class StreamBlockFloors {
19
+ private readonly floors = new Map<
20
+ string,
21
+ { readonly block: bigint; readonly at: number }
22
+ >();
23
+
24
+ constructor(
25
+ private readonly options: {
26
+ /** How long an entry is kept, ms. Default 1 h. */
27
+ readonly ttlMs?: number;
28
+ /** Hard cap on entries (oldest dropped first). Default 10 000. */
29
+ readonly maxEntries?: number;
30
+ readonly nowMs?: () => number;
31
+ } = {},
32
+ ) {}
33
+
34
+ private now(): number {
35
+ return (this.options.nowMs ?? Date.now)();
36
+ }
37
+
38
+ /** Records that `streamId` changed in `block` (a mined receipt's block). */
39
+ note(streamId: string, block: bigint | number | null | undefined): void {
40
+ if (block === null || block === undefined) return;
41
+ const value = BigInt(block);
42
+ if (value <= 0n) return;
43
+ const key = streamId.toLowerCase();
44
+ const current = this.get(key);
45
+ if (current !== undefined && current >= value) return;
46
+ const max = this.options.maxEntries ?? 10_000;
47
+ if (!this.floors.has(key) && this.floors.size >= max) {
48
+ this.prune();
49
+ while (this.floors.size >= max) {
50
+ const oldest = this.floors.keys().next().value;
51
+ if (oldest === undefined) break;
52
+ this.floors.delete(oldest);
53
+ }
54
+ }
55
+ this.floors.delete(key);
56
+ this.floors.set(key, { block: value, at: this.now() });
57
+ }
58
+
59
+ /** The floor for `streamId`, or `undefined` when none is known. */
60
+ get(streamId: string): bigint | undefined {
61
+ const key = streamId.toLowerCase();
62
+ const entry = this.floors.get(key);
63
+ if (entry === undefined) return undefined;
64
+ if (this.now() - entry.at >= (this.options.ttlMs ?? 3_600_000)) {
65
+ this.floors.delete(key);
66
+ return undefined;
67
+ }
68
+ return entry.block;
69
+ }
70
+
71
+ private prune(): void {
72
+ const ttl = this.options.ttlMs ?? 3_600_000;
73
+ const now = this.now();
74
+ for (const [key, entry] of this.floors) {
75
+ if (now - entry.at >= ttl) this.floors.delete(key);
76
+ }
77
+ }
78
+ }
@@ -36,6 +36,7 @@ import {
36
36
  type RefundRelayHandler,
37
37
  type RefundRelayResponse,
38
38
  } from "./refund-relay.js";
39
+ import { StreamBlockFloors } from "./block-floors.js";
39
40
  import { RelayerQueue } from "./relayer-queue.js";
40
41
  import {
41
42
  MetrikOpenerExactScheme,
@@ -528,10 +529,15 @@ export async function buildOpenerFacilitator(
528
529
  const walletClient = createWalletClient({ account, chain, transport });
529
530
  // ONE queue for every transaction this relayer key sends.
530
531
  const relayerQueue = new RelayerQueue();
532
+ // ONE set of per-stream block floors: the scheme records each settlement's
533
+ // block, and the refund relay never answers below it (a lagging RPC node
534
+ // would otherwise call a stream settled a second ago "not found").
535
+ const streamFloors = new StreamBlockFloors();
531
536
  const receiptTimeoutMs = options.receiptTimeoutMs;
532
537
 
533
538
  const scheme = new MetrikOpenerExactScheme({
534
539
  relayerQueue,
540
+ streamFloors,
535
541
  advertiseRefundRelay: options.refundRelay !== undefined,
536
542
  network: options.network,
537
543
  factoryAddress: options.factoryAddress,
@@ -574,6 +580,7 @@ export async function buildOpenerFacilitator(
574
580
  publicClient,
575
581
  walletClient,
576
582
  relayerQueue,
583
+ streamFloors,
577
584
  ...(receiptTimeoutMs === undefined ? {} : { receiptTimeoutMs }),
578
585
  ...(relay.publicBaseUrl === undefined
579
586
  ? {}
@@ -11,3 +11,4 @@ export * from "./scheme.js";
11
11
  export * from "./facilitator-server.js";
12
12
  export * from "./refund-relay.js";
13
13
  export * from "./relayer-queue.js";
14
+ export * from "./block-floors.js";