@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
package/src/mcp/server.ts CHANGED
@@ -389,6 +389,22 @@ export interface StoredStreamRecord {
389
389
  * seller at settlement). Informational: `budgetUsdc` always counts it.
390
390
  */
391
391
  readonly nonRefundableUsdc?: bigint;
392
+ /**
393
+ * x402 streams only: base units this server's own reclaim returned to the
394
+ * payer (`x402_pay` with `closeAfter`), as proven by the mined receipt's
395
+ * `ReclaimForwarded`. Subtracted from `budgetUsdc` when counting spend, so
396
+ * only what the payment finally kept counts against the mandate. Never set
397
+ * from a relay's unverified word.
398
+ */
399
+ readonly reclaimedUsdc?: bigint;
400
+ }
401
+
402
+ /** What a record counts against the spend mandate: the budget less a verified reclaim. */
403
+ export function countedSpendUsdc(record: StoredStreamRecord): bigint {
404
+ const reclaimed = record.reclaimedUsdc ?? 0n;
405
+ return reclaimed > 0n && reclaimed <= record.budgetUsdc
406
+ ? record.budgetUsdc - reclaimed
407
+ : record.budgetUsdc;
392
408
  }
393
409
 
394
410
  export interface StreamRegistry {
@@ -470,6 +486,7 @@ export class FileStreamRegistry implements StreamRegistry {
470
486
  x402Reservation: z.boolean().optional(),
471
487
  x402Charge: z.literal("upfront-only").optional(),
472
488
  nonRefundableUsdc: decimalStringSchema.optional(),
489
+ reclaimedUsdc: decimalStringSchema.optional(),
473
490
  }),
474
491
  )
475
492
  .parse(JSON.parse(contents));
@@ -500,6 +517,9 @@ export class FileStreamRegistry implements StreamRegistry {
500
517
  ...(record.nonRefundableUsdc === undefined
501
518
  ? {}
502
519
  : { nonRefundableUsdc: BigInt(record.nonRefundableUsdc) }),
520
+ ...(record.reclaimedUsdc === undefined
521
+ ? {}
522
+ : { reclaimedUsdc: BigInt(record.reclaimedUsdc) }),
503
523
  },
504
524
  ]),
505
525
  );
@@ -519,6 +539,9 @@ export class FileStreamRegistry implements StreamRegistry {
519
539
  ...(record.nonRefundableUsdc === undefined
520
540
  ? {}
521
541
  : { nonRefundableUsdc: record.nonRefundableUsdc.toString() }),
542
+ ...(record.reclaimedUsdc === undefined
543
+ ? {}
544
+ : { reclaimedUsdc: record.reclaimedUsdc.toString() }),
522
545
  })),
523
546
  null,
524
547
  2,
@@ -711,7 +734,7 @@ export function createVerifiedStreamMcpServer(
711
734
  const records = await registry.list();
712
735
  return records
713
736
  .filter((record) => record.buyer.toLowerCase() === owner.toLowerCase())
714
- .reduce((sum, record) => sum + record.budgetUsdc, 0n);
737
+ .reduce((sum, record) => sum + countedSpendUsdc(record), 0n);
715
738
  };
716
739
  if (options.mandateStateResolver?.getRevokedMandateIds === undefined) {
717
740
  warnOnce(
@@ -1186,6 +1209,9 @@ export function createVerifiedStreamMcpServer(
1186
1209
  ...(record.nonRefundableUsdc === undefined
1187
1210
  ? {}
1188
1211
  : { nonRefundableUsdc: record.nonRefundableUsdc.toString() }),
1212
+ ...(record.reclaimedUsdc === undefined
1213
+ ? {}
1214
+ : { reclaimedUsdc: record.reclaimedUsdc.toString() }),
1189
1215
  status,
1190
1216
  };
1191
1217
  }),
@@ -1607,6 +1633,9 @@ export async function createVerifiedStreamMcpServerOptionsFromEnv(
1607
1633
  ? {}
1608
1634
  : { opener: target.opener }),
1609
1635
  ...(target.fetch === undefined ? {} : { fetch: target.fetch }),
1636
+ ...(target.settledAt === undefined
1637
+ ? {}
1638
+ : { settledAt: target.settledAt }),
1610
1639
  // Every relay "success" is checked against the receipt.
1611
1640
  verifyWith: readOnlyMetrikClient({
1612
1641
  escrowAddress: parsed.escrow,
@@ -31,6 +31,10 @@ import type {
31
31
  PayX402WithMandateResult,
32
32
  X402PostSignatureError,
33
33
  } from "../x402-buyer/pay.js";
34
+ import {
35
+ closeAfterPayment,
36
+ type CloseAfterOutcome,
37
+ } from "../x402-buyer/refund-actions.js";
34
38
 
35
39
  /**
36
40
  * The MCP surface for x402 (metrik-agent#121 item 4): `x402_pay`,
@@ -45,7 +49,7 @@ import type {
45
49
  */
46
50
 
47
51
  export const X402_PAY_TOOL_DESCRIPTION =
48
- "Pay an x402 endpoint (HTTP 402 Payment Required, x402 v2 'exact') and return its response. A Metrik x402 seller quotes a payment that opens an escrow STREAM; the seller earns ratePerSecond x VERIFIED seconds, capped at the deposit, and the unearned remainder stays recoverable by this server's wallet. An UPFRONT ('minimum charge') quote splits the price: upfrontAmount is a NON-REFUNDABLE per-call charge paid to the seller at settlement, and only price - upfrontAmount is escrowed and refundable; when upfrontAmount == price no stream opens at all (streamId null, nothing to refund). Subject to the agent's spend mandate (the WHOLE price vs per-stream and total caps, rate, duration, operator) and a cap on the non-refundable part (maxUpfrontAmountUsdc, default 0.10 USDC), checked BEFORE anything is signed. Refunds are NOT automatic: use refund_status and refund with the returned streamId.";
52
+ "Pay an x402 endpoint (HTTP 402 Payment Required, x402 v2 'exact') and return its response. A Metrik x402 seller quotes a payment that opens an escrow STREAM; the seller earns ratePerSecond x VERIFIED seconds, capped at the deposit, and the unearned remainder stays recoverable by this server's wallet. An UPFRONT ('minimum charge') quote splits the price: upfrontAmount is a NON-REFUNDABLE per-call charge paid to the seller at settlement, and only price - upfrontAmount is escrowed and refundable; when upfrontAmount == price no stream opens at all (streamId null, nothing to refund). Subject to the agent's spend mandate (the WHOLE price vs per-stream and total caps, rate, duration, operator) and a cap on the non-refundable part (maxUpfrontAmountUsdc, default 0.10 USDC), checked BEFORE anything is signed. Refunds are NOT automatic: pass closeAfter=true to have this server close the stream and reclaim the unearned part right after the response (recommended for one-shot calls), or use refund_status and refund with the returned streamId later.";
49
53
  export const REFUND_STATUS_TOOL_DESCRIPTION =
50
54
  "Read the refund state of a stream this server paid for over x402, from the seller's gasless refund relay: whether it is running, what is reclaimable, and the next step (close / wait / reclaim). An upfront-only payment (no stream) has nothing to refund. Moves no funds.";
51
55
  export const REFUND_TOOL_DESCRIPTION =
@@ -66,6 +70,8 @@ export interface McpX402Options {
66
70
  readonly opener?: `0x${string}`;
67
71
  /** The guarded fetch the relay client must use. */
68
72
  readonly fetch?: typeof fetch;
73
+ /** When the payment settled, unix ms (`x402_pay` with `closeAfter`). */
74
+ readonly settledAt?: number;
69
75
  }) => Promise<RefundClient>;
70
76
  /** Passed to the SDK payer (e.g. a transport for the on-chain checks). */
71
77
  readonly sdkOptions?: PayX402WithMandateInput["sdkOptions"];
@@ -76,6 +82,8 @@ export interface McpX402Options {
76
82
  * refund_status may contact (exact, or `*.domain`). Unset = any PUBLIC host.
77
83
  */
78
84
  readonly allowedHosts?: readonly string[];
85
+ /** Delay used while a close-after waits for the relay (tests). Default `setTimeout`. */
86
+ readonly sleep?: (ms: number) => Promise<void>;
79
87
  /** Base fetch under the outbound guard (tests). Default `globalThis.fetch`. */
80
88
  readonly fetch?: typeof fetch;
81
89
  /** DNS resolution for the outbound guard (tests). */
@@ -118,6 +126,8 @@ export interface McpX402Context {
118
126
  readonly x402Charge?: "upfront-only";
119
127
  /** The non-refundable part of the payment (the upfront charge), base units. */
120
128
  readonly nonRefundableUsdc?: bigint;
129
+ /** Base units this server's own verified reclaim returned to the payer. */
130
+ readonly reclaimedUsdc?: bigint;
121
131
  }) => Promise<void>;
122
132
  /** Drop a record (a released reservation). Absent = reservations are kept. */
123
133
  readonly removeStream?: (streamId: `0x${string}`) => Promise<void>;
@@ -185,6 +195,12 @@ const x402PaySchema = {
185
195
  .describe(
186
196
  "Cap on the NON-REFUNDABLE per-call part of an upfront-opener quote, base units. Default 100000 (0.10 USDC); 0 refuses any upfront charge.",
187
197
  ),
198
+ closeAfter: z
199
+ .boolean()
200
+ .optional()
201
+ .describe(
202
+ "true = right after a successful paid response, this server closes the stream on your behalf (it signs the payer's Close; the seller's refund relay sends it and pays the gas), waits for the checkpoint covering the close, and reclaims the unearned part to the payer. For one-shot calls: without it the stream keeps running and the seller can earn the whole metered part for one response. Your request, sent by this server; not an automatic refund. Only what the payment finally keeps counts against the spend mandate. The tool can take a minute or two longer.",
203
+ ),
188
204
  } as const;
189
205
 
190
206
  const ZERO_STREAM_ID = `0x${"00".repeat(32)}` as const;
@@ -245,12 +261,16 @@ export function registerX402Tools(
245
261
  streamId: `0x${string}`;
246
262
  opener?: `0x${string}`;
247
263
  fetch?: typeof fetch;
264
+ settledAt?: number;
248
265
  }) => {
249
266
  const { refundClient } = await import("@absol-labs/sdk/x402");
250
267
  return refundClient(target.refundUrl, {
251
268
  streamId: target.streamId,
252
269
  ...(target.opener === undefined ? {} : { opener: target.opener }),
253
270
  ...(target.fetch === undefined ? {} : { fetch: target.fetch }),
271
+ ...(target.settledAt === undefined
272
+ ? {}
273
+ : { settledAt: target.settledAt }),
254
274
  expectedChainId: ctx.x402.network.chain.id,
255
275
  // Chain access is what lets the SDK read the opener's KIND on-chain and
256
276
  // sign an upfront opener's Close under its own EIP-712 domain; without
@@ -292,7 +312,10 @@ export function registerX402Tools(
292
312
  await assertOutboundUrlAllowed(args.url, guard);
293
313
  const signedMandate = await ctx.resolveMandate();
294
314
  const owner = signedMandate.owner as `0x${string}`;
295
- return await ctx.serialize(owner, async () => {
315
+ // A stream payment whose close-after runs OUTSIDE this owner's
316
+ // serialized queue (it can wait a minute or two for a checkpoint).
317
+ const pending: { close: PendingCloseAfter | null } = { close: null };
318
+ const answer = await ctx.serialize(owner, async () => {
296
319
  const spentSoFarUsdc = await ctx.getSpentSoFarUsdc(owner);
297
320
  const revokedMandateIds = await ctx.getRevokedMandateIds(owner);
298
321
  const nowSeconds = ctx.now();
@@ -372,6 +395,7 @@ export function registerX402Tools(
372
395
  }
373
396
  const opener = result.metrikStreamOpener;
374
397
  const upfrontAmount = result.quote?.upfrontAmount ?? null;
398
+ let streamRecord: StreamRecord | null = null;
375
399
  if (
376
400
  result.paid &&
377
401
  opener !== null &&
@@ -404,7 +428,7 @@ export function registerX402Tools(
404
428
  result.approved !== null
405
429
  ) {
406
430
  // Replace the reservation with the real stream (same amount).
407
- await ctx.recordStream({
431
+ streamRecord = {
408
432
  streamId: opener.streamId,
409
433
  buyer: owner,
410
434
  operator: result.approved.operator,
@@ -422,7 +446,8 @@ export function registerX402Tools(
422
446
  ...(upfrontAmount === null || upfrontAmount === 0n
423
447
  ? {}
424
448
  : { nonRefundableUsdc: upfrontAmount }),
425
- });
449
+ };
450
+ await ctx.recordStream(streamRecord);
426
451
  await release();
427
452
  } else if (!result.paid) {
428
453
  // No 402 was answered, so nothing was approved or signed.
@@ -461,19 +486,51 @@ export function registerX402Tools(
461
486
  };
462
487
  if (opener !== null && opener.streamId === null) {
463
488
  return ctx.ok(
464
- `paid ${result.quote?.amount.toString() ?? "?"} USDC base units as an UPFRONT-ONLY charge; HTTP ${result.response.status}. The whole price was a non-refundable per-call charge paid to the seller at settlement: no stream was opened, the payment is final and there is nothing to refund.`,
465
- structured,
489
+ `paid ${result.quote?.amount.toString() ?? "?"} USDC base units as an UPFRONT-ONLY charge; HTTP ${result.response.status}. The whole price was a non-refundable per-call charge paid to the seller at settlement: no stream was opened, the payment is final and there is nothing to refund.${args.closeAfter === true ? " closeAfter: nothing to close (no stream was opened); nothing was signed or sent." : ""}`,
490
+ args.closeAfter === true
491
+ ? {
492
+ ...structured,
493
+ closeAfter: {
494
+ outcome: "nothing-to-close",
495
+ refundIsAutomatic: false,
496
+ },
497
+ }
498
+ : structured,
466
499
  );
467
500
  }
468
501
  const split =
469
502
  upfrontAmount !== null && upfrontAmount > 0n
470
503
  ? ` Of the ${result.quote?.amount.toString() ?? "?"} paid, ${upfrontAmount.toString()} was a NON-REFUNDABLE per-call charge (final); only the ${opener?.deposit?.toString() ?? "?"} escrowed can come back.`
471
504
  : "";
505
+ const paidText = `paid ${opener?.deposit?.toString() ?? "?"} USDC base units into stream ${opener?.streamId ?? "?"}; HTTP ${result.response.status}.${split}`;
506
+ if (
507
+ args.closeAfter === true &&
508
+ opener !== null &&
509
+ opener.streamId !== null &&
510
+ streamRecord !== null
511
+ ) {
512
+ pending.close = {
513
+ opener: { ...opener, streamId: opener.streamId },
514
+ record: streamRecord,
515
+ paidText,
516
+ structured,
517
+ };
518
+ return null;
519
+ }
472
520
  return ctx.ok(
473
- `paid ${opener?.deposit?.toString() ?? "?"} USDC base units into stream ${opener?.streamId ?? "?"}; HTTP ${result.response.status}.${split} The seller earns only for VERIFIED seconds; the unearned remainder is NOT refunded automatically — call refund (or refund_status) with this streamId.`,
521
+ `${paidText} The seller earns only for VERIFIED seconds; the unearned remainder is NOT refunded automatically — call refund (or refund_status) with this streamId.`,
474
522
  structured,
475
523
  );
476
524
  });
525
+ if (answer !== null || pending.close === null) {
526
+ return answer ?? ctx.unexpected(new Error("no x402_pay result"));
527
+ }
528
+ return await runCloseAfter(ctx, pending.close, {
529
+ owner,
530
+ guard,
531
+ guardedFetch,
532
+ openRefunds,
533
+ });
477
534
  } catch (error) {
478
535
  if (error instanceof OutboundRequestRefusedError) {
479
536
  return ctx.fail(error.message);
@@ -620,6 +677,164 @@ export function registerX402Tools(
620
677
  );
621
678
  }
622
679
 
680
+ type StreamRecord = Parameters<McpX402Context["recordStream"]>[0];
681
+ type ToolResult = ReturnType<McpX402Context["ok"]>;
682
+
683
+ /** A paid stream whose close-after runs once the owner's queue is released. */
684
+ interface PendingCloseAfter {
685
+ readonly opener: NonNullable<
686
+ PayX402WithMandateResult["metrikStreamOpener"]
687
+ > & {
688
+ readonly streamId: `0x${string}`;
689
+ };
690
+ readonly record: StreamRecord;
691
+ readonly paidText: string;
692
+ readonly structured: Record<string, unknown>;
693
+ }
694
+
695
+ /**
696
+ * `x402_pay` with `closeAfter`: right after the paid response, at the model's
697
+ * request, this server closes the stream (it signs the payer's Close, the
698
+ * seller's relay sends it) and reclaims the unearned part to the payer, with
699
+ * `metrik refund`'s run. The tool still SUCCEEDS when that does not finish —
700
+ * the payment and the response are good — and says exactly how to finish it.
701
+ *
702
+ * Spend accounting: only a reclaim whose mined receipt was VERIFIED
703
+ * (`ReclaimForwarded` to the payer) is credited back, as the record's
704
+ * `reclaimedUsdc`; the relay's unverified word never lowers counted spend.
705
+ */
706
+ async function runCloseAfter(
707
+ ctx: McpX402Context,
708
+ pending: PendingCloseAfter,
709
+ wiring: {
710
+ readonly owner: `0x${string}`;
711
+ readonly guard: Parameters<typeof assertOutboundUrlAllowed>[1];
712
+ readonly guardedFetch: typeof fetch;
713
+ readonly openRefunds: (target: {
714
+ refundUrl: string;
715
+ streamId: `0x${string}`;
716
+ opener?: `0x${string}`;
717
+ fetch?: typeof fetch;
718
+ settledAt?: number;
719
+ }) => Promise<RefundClient>;
720
+ },
721
+ ): Promise<ToolResult> {
722
+ const { opener, record } = pending;
723
+ const outcome = await closeAfterPayment({
724
+ payment: opener,
725
+ payer: ctx.x402.signer.address as `0x${string}`,
726
+ openRefunds: async () => {
727
+ const refundUrl = opener.refundUrl as string;
728
+ await assertOutboundUrlAllowed(
729
+ refundUrl.replace("{streamId}", opener.streamId),
730
+ wiring.guard,
731
+ );
732
+ return await wiring.openRefunds({
733
+ refundUrl,
734
+ streamId: opener.streamId,
735
+ opener: opener.opener,
736
+ fetch: wiring.guardedFetch,
737
+ ...(typeof opener.settledAt === "number"
738
+ ? { settledAt: opener.settledAt }
739
+ : {}),
740
+ });
741
+ },
742
+ // Buyer recovery, authorized on the mandate owner's signature alone —
743
+ // the same rule as the refund tool.
744
+ closeSigner: async () => {
745
+ const signedMandate = await ctx.resolveMandate();
746
+ const decision = await verifyMandateOwnerSignature(signedMandate);
747
+ if (!decision.allowed) {
748
+ throw new Error(`mandate denied: ${decision.reason ?? "unknown"}`);
749
+ }
750
+ return ctx.x402.signer;
751
+ },
752
+ sleep:
753
+ ctx.x402.sleep ??
754
+ (async (ms) => {
755
+ await new Promise((resolve) => setTimeout(resolve, ms));
756
+ }),
757
+ });
758
+
759
+ let counted = record.budgetUsdc;
760
+ const refunded = outcome.refundedUsdc;
761
+ if (refunded !== null && refunded > 0n && refunded <= record.budgetUsdc) {
762
+ // Only what the payment finally kept counts against the mandate.
763
+ await ctx.serialize(wiring.owner, async () => {
764
+ await ctx.recordStream({ ...record, reclaimedUsdc: refunded });
765
+ });
766
+ counted = record.budgetUsdc - refunded;
767
+ }
768
+ const closeAfter = {
769
+ outcome: outcome.outcome,
770
+ close:
771
+ outcome.close === null
772
+ ? null
773
+ : {
774
+ transaction: outcome.close.transaction,
775
+ verified: outcome.close.verified,
776
+ },
777
+ closeSkipped: outcome.closeSkipped,
778
+ reclaim:
779
+ outcome.reclaim === null
780
+ ? null
781
+ : {
782
+ transaction: outcome.reclaim.transaction,
783
+ amountUsdc: outcome.reclaim.amount?.toString() ?? null,
784
+ verified: outcome.reclaim.verified,
785
+ },
786
+ reclaimSkipped: outcome.reclaimSkipped,
787
+ refundedUsdc: refunded?.toString() ?? null,
788
+ finishedReason: outcome.finished,
789
+ incomplete:
790
+ outcome.incomplete === null
791
+ ? null
792
+ : {
793
+ stage: outcome.incomplete.stage,
794
+ closed: outcome.incomplete.closed,
795
+ reason: outcome.incomplete.reason,
796
+ },
797
+ sentBy: "buyer",
798
+ refundIsAutomatic: false,
799
+ };
800
+ const structured = {
801
+ ...pending.structured,
802
+ closeAfter,
803
+ countedSpendUsdc: counted.toString(),
804
+ };
805
+ return ctx.ok(
806
+ `${pending.paidText} ${describeCloseAfter(outcome, opener.streamId, counted)}`,
807
+ structured,
808
+ );
809
+ }
810
+
811
+ function describeCloseAfter(
812
+ outcome: CloseAfterOutcome,
813
+ streamId: `0x${string}`,
814
+ counted: bigint,
815
+ ): string {
816
+ const spend = `${counted.toString()} USDC base units count against the spend mandate.`;
817
+ if (outcome.outcome === "incomplete") {
818
+ const incomplete = outcome.incomplete;
819
+ if (incomplete?.stage === "no-relay") {
820
+ return `closeAfter did NOT finish: ${incomplete.reason}. ${spend}`;
821
+ }
822
+ return `closeAfter did NOT finish: the ${incomplete?.stage ?? "close"} did not complete (${incomplete?.reason ?? "unknown"}). Nothing is lost: the unearned part stays in the escrow for the payer. Finish it with refund (streamId ${streamId}${incomplete?.closed === true ? ", reclaim=true" : ", close=true, reclaim=true"}). ${spend}`;
823
+ }
824
+ const closed =
825
+ outcome.close === null
826
+ ? "the stream was already closed"
827
+ : `closed the stream (${outcome.close.transaction})`;
828
+ if (outcome.reclaim !== null) {
829
+ const amount = outcome.refundedUsdc ?? outcome.reclaim.amount;
830
+ return `closeAfter: at your request this server ${closed} and reclaimed ${amount?.toString() ?? "the unearned remainder"} USDC base units to the payer (${outcome.reclaim.transaction}${outcome.refundedUsdc !== null ? ", receipt verified on-chain" : ", relay-reported, not verified"}). ${spend}`;
831
+ }
832
+ if (outcome.finished === "reclaimed") {
833
+ return `closeAfter: ${closed}; it was already reclaimed to the payer. ${spend}`;
834
+ }
835
+ return `closeAfter: ${closed}; nothing to reclaim — the verified delivery covers the whole deposit. ${spend}`;
836
+ }
837
+
623
838
  /** Upfront-only payments: a finished, successful answer — not an error. */
624
839
  function nothingToRefund(ctx: McpX402Context, args: { streamId: string }) {
625
840
  return ctx.ok(
@@ -7,3 +7,4 @@
7
7
  */
8
8
  export * from "./pay.js";
9
9
  export * from "./guarded-fetch.js";
10
+ export * from "./refund-actions.js";
@@ -1,7 +1,8 @@
1
- import type { MetrikNetwork } from "@absol-labs/sdk";
1
+ import type { MetrikClient, MetrikNetwork } from "@absol-labs/sdk";
2
2
  import {
3
3
  findStreamForAuthorization,
4
4
  payWithX402,
5
+ refundClient,
5
6
  X402PaymentRefusedError,
6
7
  type PayWithX402Options,
7
8
  type PayWithX402Result,
@@ -19,6 +20,7 @@ import {
19
20
  type MandateDecision,
20
21
  type SignedSpendMandate,
21
22
  } from "../mandates/mandate.js";
23
+ import { closeAfterPayment, type CloseAfterOutcome } from "./refund-actions.js";
22
24
 
23
25
  /**
24
26
  * Buyer-side x402 payments under a spend mandate (metrik-agent#121).
@@ -164,6 +166,35 @@ export interface PayX402WithMandateInput {
164
166
  PublicClient,
165
167
  "readContract" | "getLogs" | "getBlockNumber"
166
168
  >;
169
+ /**
170
+ * Close the stream and reclaim the unearned part right after a successful
171
+ * paid response, at the buyer's request: `signer` signs the payer's Close,
172
+ * the seller's refund relay sends it (and pays the gas), the client waits
173
+ * (bounded, honouring `Retry-After`) for the checkpoint covering the close,
174
+ * then asks the relay to reclaim to the payer. This is the buyer's own
175
+ * client acting for the buyer, not an automatic refund. Its outcome is
176
+ * {@link PayX402WithMandateResult.closeAfter}; a close/reclaim that does not
177
+ * finish never fails the payment. `true` uses the defaults below.
178
+ */
179
+ readonly closeAfter?: boolean | CloseAfterOptions;
180
+ }
181
+
182
+ /** Options of {@link PayX402WithMandateInput.closeAfter}. */
183
+ export interface CloseAfterOptions {
184
+ /**
185
+ * Recommended: checks every relayed close/reclaim against its mined receipt,
186
+ * so `closeAfter.refundedUsdc` is the amount the receipt proves. Without it
187
+ * the relay's word is reported and `refundedUsdc` stays `null`.
188
+ */
189
+ readonly verifyWith?: MetrikClient;
190
+ /** Relay attempts while it says "not yet" (default the SDK's, 5). */
191
+ readonly maxAttempts?: number;
192
+ /** Longest single wait honoured, ms (default the SDK's, 60 000). */
193
+ readonly maxDelayMs?: number;
194
+ /** Injectable delay (tests). */
195
+ readonly sleep?: (ms: number) => Promise<void>;
196
+ /** Test seam: the refund client factory. Default the SDK's `refundClient`. */
197
+ readonly refundClientImpl?: typeof refundClient;
167
198
  }
168
199
 
169
200
  /** What is known about a payment whose signature was handed out but that did not complete cleanly. */
@@ -286,6 +317,13 @@ function authorizationFromHeader(
286
317
  export interface PayX402WithMandateResult extends PayWithX402Result {
287
318
  /** The stream-open request the mandate approved (null if nothing was paid). */
288
319
  readonly approved: HireRequest | null;
320
+ /**
321
+ * What `closeAfter` did: `null` when it was not requested or nothing was
322
+ * paid. What the payment finally cost is `approved.budgetUsdc -
323
+ * closeAfter.refundedUsdc` (when `refundedUsdc` is non-null): a caller that
324
+ * tracks cumulative spend counts only that.
325
+ */
326
+ readonly closeAfter: CloseAfterOutcome | null;
289
327
  }
290
328
 
291
329
  function smaller(a: bigint, b: bigint | undefined): bigint {
@@ -444,7 +482,66 @@ export async function payX402WithMandate(
444
482
  sentAuthorization,
445
483
  });
446
484
  }
447
- return { ...result, approved: result.paid ? approved : null };
485
+ const closeAfter =
486
+ input.closeAfter === undefined ||
487
+ input.closeAfter === false ||
488
+ !result.paid ||
489
+ result.metrikStreamOpener === null
490
+ ? null
491
+ : await runCloseAfter(input, result.metrikStreamOpener);
492
+ return { ...result, approved: result.paid ? approved : null, closeAfter };
493
+ }
494
+
495
+ /** {@link PayX402WithMandateInput.closeAfter}, with the SDK's refund client. */
496
+ async function runCloseAfter(
497
+ input: PayX402WithMandateInput,
498
+ payment: NonNullable<PayWithX402Result["metrikStreamOpener"]>,
499
+ ): Promise<CloseAfterOutcome> {
500
+ const options: CloseAfterOptions =
501
+ typeof input.closeAfter === "object" ? input.closeAfter : {};
502
+ const sleep =
503
+ options.sleep ??
504
+ (async (ms: number) => {
505
+ await new Promise((resolve) => setTimeout(resolve, ms));
506
+ });
507
+ const open = options.refundClientImpl ?? refundClient;
508
+ return await closeAfterPayment({
509
+ payment,
510
+ payer: getAddress(input.signer.address),
511
+ openRefunds: async () =>
512
+ open(payment, {
513
+ ...(options.verifyWith === undefined
514
+ ? {
515
+ // Chain access proves the opener's kind (the Close's EIP-712
516
+ // domain) when no MetrikClient is given.
517
+ publicClient:
518
+ input.sdkOptions?.publicClient ??
519
+ (createPublicClient({
520
+ chain: input.network.chain,
521
+ transport:
522
+ input.sdkOptions?.transport ??
523
+ http(input.network.rpcUrls[0]),
524
+ }) as PublicClient),
525
+ network: input.network,
526
+ }
527
+ : { verifyWith: options.verifyWith }),
528
+ expectedChainId: input.network.chain.id,
529
+ ...(input.sdkOptions?.fetch === undefined
530
+ ? {}
531
+ : { fetch: input.sdkOptions.fetch }),
532
+ ...(options.maxAttempts === undefined
533
+ ? {}
534
+ : { maxAttempts: options.maxAttempts }),
535
+ ...(options.maxDelayMs === undefined
536
+ ? {}
537
+ : { maxDelayMs: options.maxDelayMs }),
538
+ sleep,
539
+ }),
540
+ // The mandate's owner IS the payer (checked before paying): the buyer's
541
+ // own recovery needs nothing more.
542
+ closeSigner: async () => input.signer,
543
+ sleep,
544
+ });
448
545
  }
449
546
 
450
547
  /**