@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
@@ -32,6 +32,17 @@ import {
32
32
  } from "../wallet.js";
33
33
  import { hasHeader, parseHeaders } from "./invoke.js";
34
34
  import type { X402PostSignatureError } from "../../x402-buyer/pay.js";
35
+ import {
36
+ closeAfterPayment,
37
+ type CloseAfterOutcome,
38
+ } from "../../x402-buyer/refund-actions.js";
39
+ import {
40
+ authorizeCloseSigner,
41
+ DEFAULT_ATTEMPTS,
42
+ DEFAULT_MAX_WAIT_SECONDS,
43
+ explainRefundFailure,
44
+ reclaimActivity,
45
+ } from "./refund.js";
35
46
  import type { CommandContext, CommandSpec } from "../context.js";
36
47
 
37
48
  /** Default stream window a request-sized mandate allows (one day). */
@@ -44,7 +55,7 @@ export const payCommand: CommandSpec = {
44
55
  summary:
45
56
  "Pay an x402 endpoint into a recoverable escrow stream, under a spend mandate",
46
57
  usage:
47
- 'metrik pay <url> [--json \'{"text":"hi"}\' | --data <body>] [--method POST] [--header "K: V"] --max-amount <usdc> [--max-rate <usdc/s>] [--max-upfront <usdc>] [--max-duration <seconds>] [--min-duration <seconds>] [--operator <address>] [--units atomic]',
58
+ 'metrik pay <url> [--json \'{"text":"hi"}\' | --data <body>] [--method POST] [--header "K: V"] --max-amount <usdc> [--max-rate <usdc/s>] [--max-upfront <usdc>] [--max-duration <seconds>] [--min-duration <seconds>] [--operator <address>] [--close-after] [--units atomic]',
48
59
  flags: [
49
60
  "data",
50
61
  "json-body",
@@ -56,6 +67,7 @@ export const payCommand: CommandSpec = {
56
67
  "max-duration",
57
68
  "min-duration",
58
69
  "operator",
70
+ "close-after",
59
71
  "units",
60
72
  "allow-legacy-factory",
61
73
  ],
@@ -78,6 +90,8 @@ export const payCommand: CommandSpec = {
78
90
  "--max-upfront <usdc> (cap on the NON-REFUNDABLE per-call part of an upfront-opener quote; default 0.10 USDC, 0 refuses any)",
79
91
  data: "--data <body>",
80
92
  "json-body": "--json <body> (alias: --json-body <body>)",
93
+ "close-after":
94
+ "--close-after (after a successful paid response, this client closes the stream for you and reclaims the unearned part: it signs the payer's Close, the seller's refund relay sends it and pays the gas, then it waits for the covering checkpoint and asks the relay to reclaim to the payer. Your request, sent by your client; not an automatic refund. Exit 5 if it does not finish, with the command that finishes it)",
81
95
  },
82
96
  async run(context: CommandContext): Promise<void> {
83
97
  const { args, config, env, out, deps } = context;
@@ -207,7 +221,16 @@ export const payCommand: CommandSpec = {
207
221
  const opener = result.metrikStreamOpener;
208
222
  const quote = result.quote;
209
223
  const settlementTx = settlementTransaction(result.paymentResponse);
210
- out.result({
224
+ const closeAfter = booleanFlag(args, "close-after");
225
+ // A stream on another (e.g. legacy) registered escrow is reached by
226
+ // naming that escrow; the CLI defaults to the network's current one.
227
+ const escrowFlag =
228
+ opener?.escrow !== null &&
229
+ opener?.escrow !== undefined &&
230
+ opener.escrow.toLowerCase() !== config.escrow.toLowerCase()
231
+ ? ` --escrow ${opener.escrow}`
232
+ : "";
233
+ const payResult = {
211
234
  paid: result.paid,
212
235
  // The facilitator's settlement transaction, from the PAYMENT-RESPONSE
213
236
  // header the seller relayed: the facilitator's relayer broadcast it, not
@@ -240,19 +263,41 @@ export const payCommand: CommandSpec = {
240
263
  minEarnSeconds: quote?.minEarnSeconds ?? null,
241
264
  mandateSource: envCaps === null ? "request" : "env",
242
265
  refundIsAutomatic: false,
243
- });
266
+ };
244
267
 
245
268
  if (!result.paid) {
269
+ // Nothing settled (the endpoint answered without a payment, or the paid
270
+ // request failed and was never settled): there is nothing to close.
271
+ out.result({
272
+ ...payResult,
273
+ ...(closeAfter ? { closeAfter: { outcome: "not-paid" } } : {}),
274
+ });
246
275
  out.print(
247
276
  `The endpoint answered ${result.response.status} without asking for payment; nothing was signed or paid.`,
248
277
  );
249
278
  out.print(truncate(bodyText));
279
+ if (closeAfter) {
280
+ out.print(
281
+ "--close-after: nothing was paid, so there is nothing to close.",
282
+ );
283
+ }
250
284
  return;
251
285
  }
252
286
  out.print(`Paid. HTTP ${result.response.status}`);
253
287
  out.print(truncate(bodyText));
254
288
  out.print("");
255
289
  if (opener !== null && opener.streamId === null) {
290
+ out.result({
291
+ ...payResult,
292
+ ...(closeAfter
293
+ ? {
294
+ closeAfter: {
295
+ outcome: "nothing-to-close",
296
+ refundIsAutomatic: false,
297
+ },
298
+ }
299
+ : {}),
300
+ });
256
301
  // Upfront-only: no stream exists, so there is no refund or reclaim to offer.
257
302
  out.table([
258
303
  ["opener", opener.opener],
@@ -271,54 +316,62 @@ export const payCommand: CommandSpec = {
271
316
  out.print(
272
317
  "This payment is FINAL: it was an upfront-only (minimum-charge) payment, the whole price was paid to the seller at settlement as a non-refundable per-call charge, and no escrow stream was opened. There is nothing to refund or reclaim.",
273
318
  );
319
+ if (closeAfter) {
320
+ out.print(
321
+ "--close-after: nothing to close — no stream was opened. Nothing was signed or sent.",
322
+ );
323
+ }
274
324
  return;
275
325
  }
276
- if (opener !== null && opener.streamId !== null) {
277
- out.table([
278
- ["streamId", opener.streamId],
279
- ["opener", opener.opener],
280
- ["payer", account.address],
281
- ...splitRows(quote, opener.upfrontFee),
282
- ...(opener.deposit === null
283
- ? []
284
- : ([["deposit", formatUsdc(opener.deposit)]] as const)),
285
- ...(opener.ratePerSecond === null
286
- ? []
287
- : ([
288
- ["rate", `${formatUsdc(opener.ratePerSecond)} / verified second`],
289
- ] as const)),
290
- ...(quote?.minEarnSeconds === null ||
291
- quote?.minEarnSeconds === undefined
292
- ? []
293
- : ([
294
- [
295
- "fully earned after",
296
- `${formatDuration(Number(quote.minEarnSeconds))} of verified delivery`,
297
- ],
298
- ] as const)),
299
- ...(opener.expiresAt === null
300
- ? []
301
- : ([
302
- ["expires", new Date(opener.expiresAt * 1000).toISOString()],
303
- ] as const)),
304
- ["refund url", opener.refundUrl ?? "(the seller advertised no relay)"],
305
- ...(settlementTx === null
306
- ? []
307
- : ([
308
- [
309
- "settlement tx",
310
- `${settlementTx} (reported by the seller's facilitator in PAYMENT-RESPONSE; its relayer broadcast it)`,
311
- ],
312
- ] as const)),
313
- ]);
314
- out.print("");
315
- // A stream on another (e.g. legacy) registered escrow is reached by
316
- // naming that escrow; the CLI defaults to the network's current one.
317
- const escrowFlag =
318
- opener.escrow !== null &&
319
- opener.escrow.toLowerCase() !== config.escrow.toLowerCase()
320
- ? ` --escrow ${opener.escrow}`
321
- : "";
326
+ if (opener === null || opener.streamId === null) {
327
+ out.result(payResult);
328
+ return;
329
+ }
330
+ const streamId = opener.streamId;
331
+ out.table([
332
+ ["streamId", streamId],
333
+ ["opener", opener.opener],
334
+ ["payer", account.address],
335
+ ...splitRows(quote, opener.upfrontFee),
336
+ ...(opener.deposit === null
337
+ ? []
338
+ : ([["deposit", formatUsdc(opener.deposit)]] as const)),
339
+ ...(opener.ratePerSecond === null
340
+ ? []
341
+ : ([
342
+ ["rate", `${formatUsdc(opener.ratePerSecond)} / verified second`],
343
+ ] as const)),
344
+ ...(quote?.minEarnSeconds === null || quote?.minEarnSeconds === undefined
345
+ ? []
346
+ : ([
347
+ [
348
+ "fully earned after",
349
+ `${formatDuration(Number(quote.minEarnSeconds))} of verified delivery`,
350
+ ],
351
+ ] as const)),
352
+ ...(opener.expiresAt === null
353
+ ? []
354
+ : ([
355
+ ["expires", new Date(opener.expiresAt * 1000).toISOString()],
356
+ ] as const)),
357
+ ["refund url", opener.refundUrl ?? "(the seller advertised no relay)"],
358
+ ...(settlementTx === null
359
+ ? []
360
+ : ([
361
+ [
362
+ "settlement tx",
363
+ `${settlementTx} (reported by the seller's facilitator in PAYMENT-RESPONSE; its relayer broadcast it)`,
364
+ ],
365
+ ] as const)),
366
+ ]);
367
+ out.print("");
368
+ const refundCommand = (flags: string): string =>
369
+ opener.refundUrl === null
370
+ ? `metrik reclaim ${streamId}${escrowFlag}`
371
+ : `metrik refund ${streamId} --refund-url ${opener.refundUrl}${escrowFlag} ${flags}`;
372
+
373
+ if (!closeAfter) {
374
+ out.result(payResult);
322
375
  out.print(
323
376
  quote?.openerKind === "upfront" &&
324
377
  quote.upfrontAmount !== null &&
@@ -328,13 +381,208 @@ export const payCommand: CommandSpec = {
328
381
  );
329
382
  out.print(
330
383
  opener.refundUrl === null
331
- ? ` metrik reclaim ${opener.streamId}${escrowFlag} (anyone may send it; the refund always goes to the payer)`
332
- : ` metrik refund ${opener.streamId} --refund-url ${opener.refundUrl}${escrowFlag} --close --reclaim`,
384
+ ? ` ${refundCommand("")} (anyone may send it; the refund always goes to the payer)`
385
+ : ` ${refundCommand("--close --reclaim")}`,
333
386
  );
387
+ return;
334
388
  }
389
+
390
+ // --close-after: the buyer asked for it, so THIS client now sends the
391
+ // buyer's own close and reclaim. Not an automatic refund.
392
+ out.note(
393
+ "--close-after: closing the stream now, as you asked. This client signs the payer's Close and the seller's refund relay sends it (the relay pays the gas); then it waits for the checkpoint covering the close and asks the relay to reclaim the unearned part to the payer.",
394
+ );
395
+ const outcome = await closeAfterPayment({
396
+ payment: opener,
397
+ payer: account.address,
398
+ openRefunds: async () =>
399
+ await x402.refundClient(config, {
400
+ refundUrl: opener.refundUrl as string,
401
+ streamId,
402
+ opener: opener.opener,
403
+ maxAttempts: DEFAULT_ATTEMPTS,
404
+ maxDelayMs: DEFAULT_MAX_WAIT_SECONDS * 1_000,
405
+ ...(typeof opener.settledAt === "number"
406
+ ? { settledAt: opener.settledAt }
407
+ : {}),
408
+ }),
409
+ closeSigner: async () =>
410
+ await authorizeCloseSigner(account, config.chainId),
411
+ sleep: deps.sleep,
412
+ note: out.note,
413
+ ...(context.progress === undefined
414
+ ? {}
415
+ : {
416
+ setActivity: (activity: string | null) =>
417
+ context.progress?.setActivity(activity),
418
+ }),
419
+ reclaimActivity: reclaimActivity(
420
+ DEFAULT_ATTEMPTS,
421
+ DEFAULT_MAX_WAIT_SECONDS,
422
+ ),
423
+ });
424
+ const finishCommand =
425
+ outcome.outcome === "incomplete"
426
+ ? refundCommand(
427
+ outcome.incomplete?.closed === true
428
+ ? "--reclaim"
429
+ : "--close --reclaim",
430
+ )
431
+ : null;
432
+ out.result({
433
+ ...payResult,
434
+ closeAfter: describeCloseAfter(outcome, {
435
+ price: quote?.amount ?? null,
436
+ deposit: opener.deposit,
437
+ finishCommand,
438
+ }),
439
+ });
440
+ out.table(closeAfterRows(outcome, quote, opener.deposit));
441
+ out.print("");
442
+ if (outcome.outcome === "incomplete") {
443
+ const incomplete = outcome.incomplete;
444
+ const why =
445
+ incomplete?.error instanceof Error
446
+ ? explainRefundFailure(incomplete.error)
447
+ : null;
448
+ throw new CliError(
449
+ `paid (HTTP ${result.response.status}), but the close-after did not finish: ${incomplete?.stage === "no-relay" ? "" : `the ${incomplete?.stage ?? "close"} did not finish — `}${incomplete?.reason ?? "unknown"}`,
450
+ {
451
+ exitCode: 5,
452
+ hint: `the payment and the response are fine, and nothing is lost: ${incomplete?.closed === true ? "the stream is closed and " : ""}the unearned part stays in the escrow for the payer. Finish it with:\n ${finishCommand ?? refundCommand("--close --reclaim")}${
453
+ why instanceof CliError && why.hint !== undefined
454
+ ? `\n (${why.hint})`
455
+ : ""
456
+ }`,
457
+ cause: incomplete?.error ?? undefined,
458
+ },
459
+ );
460
+ }
461
+ out.print(describeCloseAfterDone(outcome));
335
462
  },
336
463
  };
337
464
 
465
+ /**
466
+ * The `closeAfter` part of `metrik pay --close-after --json`: what this
467
+ * client's close + reclaim did, with the amounts from the verified receipt.
468
+ */
469
+ export function describeCloseAfter(
470
+ outcome: CloseAfterOutcome,
471
+ context: {
472
+ readonly price: bigint | null;
473
+ readonly deposit: bigint | null;
474
+ readonly finishCommand: string | null;
475
+ },
476
+ ): Record<string, unknown> {
477
+ const refunded = outcome.refundedUsdc;
478
+ return {
479
+ outcome: outcome.outcome,
480
+ streamId: outcome.streamId,
481
+ close:
482
+ outcome.close === null
483
+ ? null
484
+ : {
485
+ transaction: outcome.close.transaction,
486
+ verified: outcome.close.verified,
487
+ },
488
+ closeSkipped: outcome.closeSkipped,
489
+ reclaim:
490
+ outcome.reclaim === null
491
+ ? null
492
+ : {
493
+ transaction: outcome.reclaim.transaction,
494
+ amount: outcome.reclaim.amount,
495
+ verified: outcome.reclaim.verified,
496
+ },
497
+ reclaimSkipped: outcome.reclaimSkipped,
498
+ // Base units returned to the payer, from the receipt's ReclaimForwarded
499
+ // (null unless the receipt was verified).
500
+ refunded,
501
+ // What the payment finally cost: price - refunded (upfront + earned).
502
+ kept:
503
+ refunded === null || context.price === null
504
+ ? null
505
+ : context.price - refunded,
506
+ // What the seller earned from the escrow: deposit - refunded.
507
+ earned:
508
+ refunded === null || context.deposit === null
509
+ ? null
510
+ : context.deposit - refunded,
511
+ finishedReason: outcome.finished,
512
+ status: outcome.status?.status ?? null,
513
+ reclaimed: outcome.status?.reclaimed ?? null,
514
+ incomplete:
515
+ outcome.incomplete === null
516
+ ? null
517
+ : {
518
+ stage: outcome.incomplete.stage,
519
+ closed: outcome.incomplete.closed,
520
+ reason: outcome.incomplete.reason,
521
+ },
522
+ finishCommand: context.finishCommand,
523
+ // The buyer's own client sent these, at the buyer's request.
524
+ sentBy: "buyer",
525
+ refundIsAutomatic: false,
526
+ };
527
+ }
528
+
529
+ /** Human rows for a close-after run. */
530
+ function closeAfterRows(
531
+ outcome: CloseAfterOutcome,
532
+ quote: X402QuoteSummary | null,
533
+ deposit: bigint | null,
534
+ ): (readonly [string, string])[] {
535
+ const rows: (readonly [string, string])[] = [];
536
+ if (outcome.close !== null) {
537
+ rows.push([
538
+ "close tx",
539
+ `${outcome.close.transaction}${outcome.close.verified ? " (verified)" : " (relay-reported)"}`,
540
+ ]);
541
+ } else if (outcome.closeSkipped !== null) {
542
+ rows.push(["close", "skipped — already closed"]);
543
+ }
544
+ if (outcome.reclaim !== null) {
545
+ rows.push([
546
+ "reclaim tx",
547
+ `${outcome.reclaim.transaction}${outcome.reclaim.verified ? " (verified)" : " (relay-reported)"}`,
548
+ ]);
549
+ const amount = outcome.refundedUsdc ?? outcome.reclaim.amount;
550
+ if (amount !== null) {
551
+ rows.push([
552
+ "refunded to payer",
553
+ `${formatUsdc(amount)}${outcome.refundedUsdc === null ? " (relay-reported; the receipt was NOT verified)" : " (from the verified receipt)"}`,
554
+ ]);
555
+ if (deposit !== null) {
556
+ rows.push([
557
+ "seller earned",
558
+ `${formatUsdc(deposit - amount)} (verified seconds)`,
559
+ ]);
560
+ }
561
+ if (quote !== null) {
562
+ rows.push([
563
+ "payment cost",
564
+ `${formatUsdc(quote.amount - amount)} of the ${formatUsdc(quote.amount)} paid`,
565
+ ]);
566
+ }
567
+ }
568
+ }
569
+ return rows;
570
+ }
571
+
572
+ function describeCloseAfterDone(outcome: CloseAfterOutcome): string {
573
+ if (outcome.reclaim !== null) {
574
+ const amount = outcome.refundedUsdc ?? outcome.reclaim.amount;
575
+ return `Closed and reclaimed at your request: ${amount === null ? "the unearned remainder" : formatUsdc(amount)} went back to the payer${outcome.refundedUsdc !== null ? " (receipt verified on-chain)" : ""}. Nothing further to do for this stream.`;
576
+ }
577
+ if (outcome.finished === "reclaimed") {
578
+ return "Already finished: the stream is closed and was already reclaimed to the payer. Nothing further to do.";
579
+ }
580
+ if (outcome.finished === "nothing-to-reclaim") {
581
+ return "Closed at your request. Nothing to reclaim: the verified delivery covers the whole deposit, so none of it is unearned.";
582
+ }
583
+ return "Closed at your request.";
584
+ }
585
+
338
586
  /**
339
587
  * One line stating what a quote commits the payer to, printed before signing:
340
588
  * for an upfront-opener quote, the NON-REFUNDABLE per-call part and the