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