@absol-labs/agent 0.12.0-rc.1 → 0.12.0-rc.2

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 (113) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/commands/claim.d.ts +32 -0
  3. package/dist/cli/commands/claim.d.ts.map +1 -1
  4. package/dist/cli/commands/claim.js +115 -27
  5. package/dist/cli/commands/claim.js.map +1 -1
  6. package/dist/cli/commands/pay.d.ts +15 -0
  7. package/dist/cli/commands/pay.d.ts.map +1 -1
  8. package/dist/cli/commands/pay.js +131 -7
  9. package/dist/cli/commands/pay.js.map +1 -1
  10. package/dist/cli/commands/reclaim.d.ts.map +1 -1
  11. package/dist/cli/commands/reclaim.js +20 -4
  12. package/dist/cli/commands/reclaim.js.map +1 -1
  13. package/dist/cli/commands/refund.d.ts +19 -1
  14. package/dist/cli/commands/refund.d.ts.map +1 -1
  15. package/dist/cli/commands/refund.js +200 -27
  16. package/dist/cli/commands/refund.js.map +1 -1
  17. package/dist/cli/commands/status.d.ts.map +1 -1
  18. package/dist/cli/commands/status.js +11 -1
  19. package/dist/cli/commands/status.js.map +1 -1
  20. package/dist/cli/commands/watch.d.ts.map +1 -1
  21. package/dist/cli/commands/watch.js +2 -0
  22. package/dist/cli/commands/watch.js.map +1 -1
  23. package/dist/cli/context.d.ts +19 -0
  24. package/dist/cli/context.d.ts.map +1 -1
  25. package/dist/cli/deps.d.ts +20 -0
  26. package/dist/cli/deps.d.ts.map +1 -1
  27. package/dist/cli/deps.js +14 -0
  28. package/dist/cli/deps.js.map +1 -1
  29. package/dist/cli/progress.d.ts +13 -1
  30. package/dist/cli/progress.d.ts.map +1 -1
  31. package/dist/cli/progress.js +7 -1
  32. package/dist/cli/progress.js.map +1 -1
  33. package/dist/cli/run.d.ts.map +1 -1
  34. package/dist/cli/run.js +26 -14
  35. package/dist/cli/run.js.map +1 -1
  36. package/dist/cli/status.d.ts +103 -0
  37. package/dist/cli/status.d.ts.map +1 -1
  38. package/dist/cli/status.js +310 -41
  39. package/dist/cli/status.js.map +1 -1
  40. package/dist/cli/wallet.d.ts +1 -1
  41. package/dist/cli/wallet.d.ts.map +1 -1
  42. package/dist/cli/wallet.js +28 -4
  43. package/dist/cli/wallet.js.map +1 -1
  44. package/dist/gateway/http-server.d.ts +17 -0
  45. package/dist/gateway/http-server.d.ts.map +1 -1
  46. package/dist/gateway/http-server.js +76 -9
  47. package/dist/gateway/http-server.js.map +1 -1
  48. package/dist/gateway/x402-opener-mode.d.ts +39 -2
  49. package/dist/gateway/x402-opener-mode.d.ts.map +1 -1
  50. package/dist/gateway/x402-opener-mode.js +169 -36
  51. package/dist/gateway/x402-opener-mode.js.map +1 -1
  52. package/dist/mcp/server.d.ts +12 -0
  53. package/dist/mcp/server.d.ts.map +1 -1
  54. package/dist/mcp/server.js +28 -9
  55. package/dist/mcp/server.js.map +1 -1
  56. package/dist/mcp/x402-tools.d.ts +8 -3
  57. package/dist/mcp/x402-tools.d.ts.map +1 -1
  58. package/dist/mcp/x402-tools.js +126 -6
  59. package/dist/mcp/x402-tools.js.map +1 -1
  60. package/dist/sdk/client.d.ts +13 -0
  61. package/dist/sdk/client.d.ts.map +1 -1
  62. package/dist/sdk/client.js +15 -0
  63. package/dist/sdk/client.js.map +1 -1
  64. package/dist/x402-buyer/pay.d.ts +22 -0
  65. package/dist/x402-buyer/pay.d.ts.map +1 -1
  66. package/dist/x402-buyer/pay.js +33 -3
  67. package/dist/x402-buyer/pay.js.map +1 -1
  68. package/dist/x402-opener/facilitator-entry.js +4 -1
  69. package/dist/x402-opener/facilitator-entry.js.map +1 -1
  70. package/dist/x402-opener/facilitator-server.d.ts +29 -0
  71. package/dist/x402-opener/facilitator-server.d.ts.map +1 -1
  72. package/dist/x402-opener/facilitator-server.js +67 -1
  73. package/dist/x402-opener/facilitator-server.js.map +1 -1
  74. package/dist/x402-opener/refund-relay.d.ts +30 -4
  75. package/dist/x402-opener/refund-relay.d.ts.map +1 -1
  76. package/dist/x402-opener/refund-relay.js +123 -22
  77. package/dist/x402-opener/refund-relay.js.map +1 -1
  78. package/dist/x402-opener/scheme.d.ts +79 -0
  79. package/dist/x402-opener/scheme.d.ts.map +1 -1
  80. package/dist/x402-opener/scheme.js +326 -40
  81. package/dist/x402-opener/scheme.js.map +1 -1
  82. package/dist/x402-opener/terms.d.ts +61 -1
  83. package/dist/x402-opener/terms.d.ts.map +1 -1
  84. package/dist/x402-opener/terms.js +94 -2
  85. package/dist/x402-opener/terms.js.map +1 -1
  86. package/docs/cli.md +87 -9
  87. package/docs/mcp-reference.md +18 -2
  88. package/docs/quickstart.md +3 -3
  89. package/docs/x402.md +150 -10
  90. package/package.json +3 -3
  91. package/src/cli/commands/claim.ts +167 -32
  92. package/src/cli/commands/pay.ts +150 -8
  93. package/src/cli/commands/reclaim.ts +25 -5
  94. package/src/cli/commands/refund.ts +231 -33
  95. package/src/cli/commands/status.ts +11 -1
  96. package/src/cli/commands/watch.ts +4 -0
  97. package/src/cli/context.ts +16 -0
  98. package/src/cli/deps.ts +37 -0
  99. package/src/cli/progress.ts +21 -2
  100. package/src/cli/run.ts +26 -13
  101. package/src/cli/status.ts +440 -43
  102. package/src/cli/wallet.ts +44 -7
  103. package/src/gateway/http-server.ts +96 -8
  104. package/src/gateway/x402-opener-mode.ts +234 -37
  105. package/src/mcp/server.ts +43 -11
  106. package/src/mcp/x402-tools.ts +171 -7
  107. package/src/sdk/client.ts +25 -0
  108. package/src/x402-buyer/pay.ts +41 -3
  109. package/src/x402-opener/facilitator-entry.ts +4 -1
  110. package/src/x402-opener/facilitator-server.ts +101 -1
  111. package/src/x402-opener/refund-relay.ts +150 -20
  112. package/src/x402-opener/scheme.ts +459 -41
  113. package/src/x402-opener/terms.ts +155 -2
@@ -12,7 +12,13 @@ import {
12
12
  verifyMandateOwnerSignature,
13
13
  type SignedSpendMandate,
14
14
  } from "../mandates/mandate.js";
15
- import { keccak256, toHex } from "viem";
15
+ import {
16
+ createPublicClient,
17
+ http,
18
+ keccak256,
19
+ toHex,
20
+ type PublicClient,
21
+ } from "viem";
16
22
 
17
23
  import {
18
24
  assertOutboundUrlAllowed,
@@ -39,11 +45,11 @@ import type {
39
45
  */
40
46
 
41
47
  export const X402_PAY_TOOL_DESCRIPTION =
42
- "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 with deposit = price; the seller earns ratePerSecond x VERIFIED seconds, capped at the deposit, and the unearned remainder stays recoverable by this server's wallet. Subject to the agent's spend mandate (price vs per-stream and total caps, rate, duration, operator), checked BEFORE anything is signed. Refunds are NOT automatic: use refund_status and refund with the returned streamId.";
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.";
43
49
  export const REFUND_STATUS_TOOL_DESCRIPTION =
44
- "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). Moves no funds.";
50
+ "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.";
45
51
  export const REFUND_TOOL_DESCRIPTION =
46
- "Recover the unearned part of an x402 payment through the seller's gasless refund relay: close=true stops the stream (this wallet signs the opener's Close; the relay pays the gas), reclaim=true sends the permissionless reclaim (the refund always goes to the payer). Nothing is refunded automatically; reclaim waits for the settlement window and may say when to retry.";
52
+ "Recover the unearned part of an x402 payment through the seller's gasless refund relay: close=true stops the stream (this wallet signs the opener's Close; the relay pays the gas), reclaim=true sends the permissionless reclaim (the refund always goes to the payer). Only the escrowed part can come back: an upfront charge is final, and an upfront-only payment has nothing to refund. Nothing is refunded automatically; reclaim waits for the settlement window and may say when to retry.";
47
53
 
48
54
  export interface McpX402Options {
49
55
  /** The payer: this server's own wallet (must own the server mandate). */
@@ -108,12 +114,17 @@ export interface McpX402Context {
108
114
  readonly refundUrl?: string;
109
115
  readonly opener?: `0x${string}`;
110
116
  readonly x402Reservation?: boolean;
117
+ /** Not a stream: a settled upfront-only charge (counted spend, nothing to refund). */
118
+ readonly x402Charge?: "upfront-only";
119
+ /** The non-refundable part of the payment (the upfront charge), base units. */
120
+ readonly nonRefundableUsdc?: bigint;
111
121
  }) => Promise<void>;
112
122
  /** Drop a record (a released reservation). Absent = reservations are kept. */
113
123
  readonly removeStream?: (streamId: `0x${string}`) => Promise<void>;
114
124
  readonly findStream: (streamId: `0x${string}`) => Promise<{
115
125
  readonly refundUrl?: string;
116
126
  readonly opener?: `0x${string}`;
127
+ readonly x402Charge?: "upfront-only";
117
128
  } | null>;
118
129
  readonly now: () => number;
119
130
  readonly ok: (
@@ -167,8 +178,17 @@ const x402PaySchema = {
167
178
  .describe(
168
179
  "Refuse a quote the seller can fully earn in fewer verified seconds.",
169
180
  ),
181
+ maxUpfrontAmountUsdc: z
182
+ .string()
183
+ .regex(/^(0|[1-9]\d*)$/, "a non-negative integer of USDC base units")
184
+ .optional()
185
+ .describe(
186
+ "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
+ ),
170
188
  } as const;
171
189
 
190
+ const ZERO_STREAM_ID = `0x${"00".repeat(32)}` as const;
191
+
172
192
  const refundSchema = {
173
193
  streamId: bytes32Schema,
174
194
  refundUrl: z
@@ -232,6 +252,11 @@ export function registerX402Tools(
232
252
  ...(target.opener === undefined ? {} : { opener: target.opener }),
233
253
  ...(target.fetch === undefined ? {} : { fetch: target.fetch }),
234
254
  expectedChainId: ctx.x402.network.chain.id,
255
+ // Chain access is what lets the SDK read the opener's KIND on-chain and
256
+ // sign an upfront opener's Close under its own EIP-712 domain; without
257
+ // it, such a Close is refused.
258
+ publicClient: refundReader(ctx.x402),
259
+ network: ctx.x402.network,
235
260
  });
236
261
  });
237
262
 
@@ -304,6 +329,9 @@ export function registerX402Tools(
304
329
  ...(args.minDurationSeconds === undefined
305
330
  ? {}
306
331
  : { minDurationSeconds: args.minDurationSeconds }),
332
+ ...(args.maxUpfrontAmountUsdc === undefined
333
+ ? {}
334
+ : { maxUpfrontAmount: BigInt(args.maxUpfrontAmountUsdc) }),
307
335
  sdkOptions: { ...ctx.x402.sdkOptions, fetch: guardedFetch },
308
336
  ...(ctx.x402.chainReader === undefined
309
337
  ? {}
@@ -327,6 +355,9 @@ export function registerX402Tools(
327
355
  mandateId: signedMandate.mandateId as `0x${string}`,
328
356
  opener: quote.payTo,
329
357
  x402Reservation: true,
358
+ ...(quote.upfrontAmount === null || quote.upfrontAmount === 0n
359
+ ? {}
360
+ : { nonRefundableUsdc: quote.upfrontAmount }),
330
361
  });
331
362
  },
332
363
  });
@@ -340,7 +371,38 @@ export function registerX402Tools(
340
371
  });
341
372
  }
342
373
  const opener = result.metrikStreamOpener;
343
- if (result.paid && opener !== null && result.approved !== null) {
374
+ const upfrontAmount = result.quote?.upfrontAmount ?? null;
375
+ if (
376
+ result.paid &&
377
+ opener !== null &&
378
+ opener.streamId === null &&
379
+ result.approved !== null
380
+ ) {
381
+ // Upfront-only: there is NO stream to record or refund. The spend
382
+ // still counts against the mandate (the whole price is gone), so
383
+ // the reservation is replaced by a permanent, non-stream charge.
384
+ await ctx.recordStream({
385
+ streamId: chargeKey(owner, opener.opener, result),
386
+ buyer: owner,
387
+ operator: result.approved.operator,
388
+ serviceRef:
389
+ result.quote?.serviceRef ?? (`0x${"00".repeat(32)}` as const),
390
+ budgetUsdc: result.approved.budgetUsdc,
391
+ ratePerSecondUsdc: result.approved.ratePerSecondUsdc,
392
+ maxDurationSeconds: result.approved.maxDurationSeconds,
393
+ openedAtSeconds: nowSeconds,
394
+ mandateId: signedMandate.mandateId as `0x${string}`,
395
+ opener: opener.opener,
396
+ x402Charge: "upfront-only",
397
+ nonRefundableUsdc: result.approved.budgetUsdc,
398
+ });
399
+ await release();
400
+ } else if (
401
+ result.paid &&
402
+ opener !== null &&
403
+ opener.streamId !== null &&
404
+ result.approved !== null
405
+ ) {
344
406
  // Replace the reservation with the real stream (same amount).
345
407
  await ctx.recordStream({
346
408
  streamId: opener.streamId,
@@ -357,6 +419,9 @@ export function registerX402Tools(
357
419
  ? {}
358
420
  : { refundUrl: opener.refundUrl }),
359
421
  opener: opener.opener,
422
+ ...(upfrontAmount === null || upfrontAmount === 0n
423
+ ? {}
424
+ : { nonRefundableUsdc: upfrontAmount }),
360
425
  });
361
426
  await release();
362
427
  } else if (!result.paid) {
@@ -386,10 +451,26 @@ export function registerX402Tools(
386
451
  ratePerSecondUsdc: opener?.ratePerSecond?.toString() ?? null,
387
452
  expiresAt: opener?.expiresAt ?? null,
388
453
  minEarnSeconds: result.quote?.minEarnSeconds?.toString() ?? null,
454
+ openerKind: result.quote?.openerKind ?? null,
455
+ priceUsdc: result.quote?.amount.toString() ?? null,
456
+ nonRefundableUsdc: upfrontAmount?.toString() ?? null,
457
+ refundableUsdc: result.quote?.refundableAmount?.toString() ?? null,
458
+ upfrontFeeUsdc: opener?.upfrontFee?.toString() ?? null,
459
+ upfrontOnly: opener?.upfrontOnly ?? false,
389
460
  refundIsAutomatic: false,
390
461
  };
462
+ if (opener !== null && opener.streamId === null) {
463
+ 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,
466
+ );
467
+ }
468
+ const split =
469
+ upfrontAmount !== null && upfrontAmount > 0n
470
+ ? ` 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
+ : "";
391
472
  return ctx.ok(
392
- `paid ${opener?.deposit?.toString() ?? "?"} USDC base units into stream ${opener?.streamId ?? "?"}; HTTP ${result.response.status}. The seller earns only for VERIFIED seconds; the unearned remainder is NOT refunded automatically — call refund (or refund_status) with this streamId.`,
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.`,
393
474
  structured,
394
475
  );
395
476
  });
@@ -405,9 +486,21 @@ export function registerX402Tools(
405
486
  const bind = async (args: {
406
487
  streamId: string;
407
488
  refundUrl?: string | undefined;
408
- }): Promise<RefundClient | { readonly error: string }> => {
489
+ }): Promise<
490
+ | RefundClient
491
+ | { readonly error: string }
492
+ | { readonly nothingToRefund: string }
493
+ > => {
409
494
  const streamId = args.streamId.toLowerCase() as `0x${string}`;
495
+ // Stream id 0 is an upfront-only payment: nothing was escrowed. Never
496
+ // contact a relay or sign anything for it.
497
+ if (streamId === ZERO_STREAM_ID) {
498
+ return { nothingToRefund: streamId };
499
+ }
410
500
  const record = await ctx.findStream(streamId);
501
+ if (record?.x402Charge === "upfront-only") {
502
+ return { nothingToRefund: streamId };
503
+ }
411
504
  const refundUrl = args.refundUrl ?? record?.refundUrl;
412
505
  if (refundUrl === undefined) {
413
506
  return {
@@ -449,9 +542,13 @@ export function registerX402Tools(
449
542
  try {
450
543
  const refunds = await bind(args);
451
544
  if ("error" in refunds) return ctx.fail(refunds.error);
545
+ if ("nothingToRefund" in refunds) return nothingToRefund(ctx, args);
452
546
  const status = await refunds.status();
453
547
  return ctx.ok(describeStatus(status), serializeStatus(status));
454
548
  } catch (error) {
549
+ if (error instanceof Error && error.name === "NothingToRefundError") {
550
+ return nothingToRefund(ctx, args);
551
+ }
455
552
  return explainRefundError(ctx, error);
456
553
  }
457
554
  },
@@ -474,6 +571,7 @@ export function registerX402Tools(
474
571
  try {
475
572
  const refunds = await bind(args);
476
573
  if ("error" in refunds) return ctx.fail(refunds.error);
574
+ if ("nothingToRefund" in refunds) return nothingToRefund(ctx, args);
477
575
  let closeResult: RefundRelayResult | null = null;
478
576
  if (args.close === true) {
479
577
  // Buyer recovery, authorized on the mandate owner's signature alone
@@ -513,12 +611,53 @@ export function registerX402Tools(
513
611
  },
514
612
  );
515
613
  } catch (error) {
614
+ if (error instanceof Error && error.name === "NothingToRefundError") {
615
+ return nothingToRefund(ctx, args);
616
+ }
516
617
  return explainRefundError(ctx, error);
517
618
  }
518
619
  },
519
620
  );
520
621
  }
521
622
 
623
+ /** Upfront-only payments: a finished, successful answer — not an error. */
624
+ function nothingToRefund(ctx: McpX402Context, args: { streamId: string }) {
625
+ return ctx.ok(
626
+ "nothing to refund: this was an upfront-only payment — the whole price was a non-refundable per-call charge paid to the seller at settlement, no stream was opened and nothing was escrowed. Nothing was requested, signed or sent.",
627
+ {
628
+ streamId: args.streamId.toLowerCase(),
629
+ nothingToRefund: true,
630
+ upfrontOnly: true,
631
+ refundIsAutomatic: false,
632
+ },
633
+ );
634
+ }
635
+
636
+ /** Registry key of a settled upfront-only charge (not an escrow stream id). */
637
+ function chargeKey(
638
+ owner: `0x${string}`,
639
+ opener: `0x${string}`,
640
+ result: PayX402WithMandateResult,
641
+ ): `0x${string}` {
642
+ return keccak256(
643
+ toHex(
644
+ `x402-upfront-charge:${owner}:${opener}:${result.authorization?.nonce ?? crypto.randomUUID()}`,
645
+ ),
646
+ );
647
+ }
648
+
649
+ /** Read-only chain access for the SDK refund client's opener-kind check. */
650
+ function refundReader(options: McpX402Options): PublicClient {
651
+ return (
652
+ options.sdkOptions?.publicClient ??
653
+ (createPublicClient({
654
+ chain: options.network.chain,
655
+ transport:
656
+ options.sdkOptions?.transport ?? http(options.network.rpcUrls[0]),
657
+ }) as PublicClient)
658
+ );
659
+ }
660
+
522
661
  function serializeStatus(status: RefundStatus): Record<string, unknown> {
523
662
  return {
524
663
  streamId: status.streamId,
@@ -561,6 +700,28 @@ async function settleFailure(
561
700
  const post = error as X402PostSignatureError;
562
701
  if (post.releasable) {
563
702
  await state.release();
703
+ } else if (post.upfrontOnly && post.state === "used") {
704
+ // Settled as an upfront-only charge: no stream to record, but the spend
705
+ // is real and stays counted — as a non-stream charge.
706
+ await ctx.recordStream({
707
+ streamId: keccak256(
708
+ toHex(
709
+ `x402-upfront-charge:${state.owner}:${post.opener}:${post.authorization?.nonce ?? crypto.randomUUID()}`,
710
+ ),
711
+ ),
712
+ buyer: state.owner,
713
+ operator: post.approved.operator,
714
+ serviceRef: post.quote.serviceRef ?? (`0x${"00".repeat(32)}` as const),
715
+ budgetUsdc: post.approved.budgetUsdc,
716
+ ratePerSecondUsdc: post.approved.ratePerSecondUsdc,
717
+ maxDurationSeconds: post.approved.maxDurationSeconds,
718
+ openedAtSeconds: state.nowSeconds,
719
+ mandateId: state.mandateId,
720
+ opener: post.opener,
721
+ x402Charge: "upfront-only",
722
+ nonRefundableUsdc: post.approved.budgetUsdc,
723
+ });
724
+ await state.release();
564
725
  } else if (post.streamId !== null) {
565
726
  await ctx.recordStream({
566
727
  streamId: post.streamId,
@@ -573,6 +734,9 @@ async function settleFailure(
573
734
  openedAtSeconds: state.nowSeconds,
574
735
  mandateId: state.mandateId,
575
736
  opener: post.opener,
737
+ ...(post.quote.upfrontAmount === null || post.quote.upfrontAmount === 0n
738
+ ? {}
739
+ : { nonRefundableUsdc: post.quote.upfrontAmount }),
576
740
  });
577
741
  await state.release();
578
742
  }
package/src/sdk/client.ts CHANGED
@@ -87,6 +87,11 @@ export interface AgentSdkClient {
87
87
  claimable(streamId: `0x${string}`): Promise<bigint>;
88
88
  reclaimable(streamId: `0x${string}`): Promise<bigint>;
89
89
  claim(streamId: `0x${string}`): Promise<StreamProofTransactionResult>;
90
+ /**
91
+ * StreamEscrowV3 only: redeem the on-chain proven high-water without a proof.
92
+ * Optional so an existing custom SDK client keeps compiling.
93
+ */
94
+ claimProven?(streamId: `0x${string}`): Promise<StreamProofTransactionResult>;
90
95
  close(streamId: `0x${string}`): Promise<StreamProofTransactionResult>;
91
96
  reclaim(streamId: `0x${string}`): Promise<StreamProofTransactionResult>;
92
97
  /**
@@ -192,6 +197,26 @@ export class VerifiedStreamAgentClient implements VerifiedStreamAgentOpener {
192
197
  return await this.sdkClient.claim(input.streamId);
193
198
  }
194
199
 
200
+ /**
201
+ * Operator-only, StreamEscrowV3 only: redeem the stream's on-chain proven
202
+ * high-water (`claimProven`) without a fresh proof. Authorized exactly like
203
+ * {@link claimStream} — it pays the same party from the same escrow, only an
204
+ * amount already proven against a signed checkpoint root and recorded
205
+ * on-chain, so it can never pay more than a proof claim could have.
206
+ */
207
+ async claimProvenStream(
208
+ input: MandateAuthorizedStreamActionInput,
209
+ ): Promise<StreamProofTransactionResult> {
210
+ const claimProven = this.sdkClient.claimProven?.bind(this.sdkClient);
211
+ if (claimProven === undefined) {
212
+ throw new Error(
213
+ "this SDK client cannot send claimProven (StreamEscrowV3 only)",
214
+ );
215
+ }
216
+ await this.authorizeExistingStreamAction(input);
217
+ return await claimProven(input.streamId);
218
+ }
219
+
195
220
  async closeStream(
196
221
  input: MandateAuthorizedStreamActionInput,
197
222
  ): Promise<StreamProofTransactionResult> {
@@ -39,6 +39,15 @@ import {
39
39
  * | `maxDurationSeconds` | `maxDuration` (the stream's window) |
40
40
  * | `allowedOperators` | `operator` (who the stream pays) |
41
41
  *
42
+ * An UPFRONT ("minimum charge") opener quote is mapped the same way, with
43
+ * `budgetUsdc = price` — the WHOLE payment, of which `upfrontAmount` is
44
+ * non-refundable (paid to the seller at settlement) and only the rest is
45
+ * escrowed. So the per-stream and total caps always count the upfront part as
46
+ * spent, and nothing a refund returns is ever credited back. Its size is also
47
+ * capped separately, before signing, by the SDK's `maxUpfrontAmount` (default
48
+ * 0.10 USDC; pass {@link PayX402WithMandateInput.maxUpfrontAmount} to tighten
49
+ * it, `0n` to refuse every upfront charge).
50
+ *
42
51
  * The check runs inside the SDK's `onQuote` hook — after every SDK protection
43
52
  * (scheme, network, USDC domain, EIP-3009 only, registered factory, on-chain
44
53
  * `predict`, authorization lifetime) and BEFORE anything is signed — so a
@@ -117,6 +126,12 @@ export interface PayX402WithMandateInput {
117
126
  readonly maxRatePerSecond?: bigint;
118
127
  /** Refuse a quote the seller can fully earn in fewer verified seconds. */
119
128
  readonly minDurationSeconds?: number;
129
+ /**
130
+ * Ceiling on the NON-REFUNDABLE upfront part of an upfront-opener quote, base
131
+ * units (the SDK's `maxUpfrontAmount`). Default the SDK's 0.10 USDC; `0n`
132
+ * refuses any upfront charge. Never above the price ceiling in effect.
133
+ */
134
+ readonly maxUpfrontAmount?: bigint;
120
135
  /** Passed through to the SDK (transport, fetch, publicClient, ...). */
121
136
  readonly sdkOptions?: Pick<
122
137
  PayWithX402Options,
@@ -179,6 +194,12 @@ export class X402PostSignatureError extends Error {
179
194
  /** The stream the payment opened, when it could be identified. */
180
195
  readonly streamId: `0x${string}` | null;
181
196
  readonly opener: `0x${string}`;
197
+ /**
198
+ * `true` when the chain shows (or the signed quote implies) an UPFRONT-ONLY
199
+ * payment: no stream, the whole price was a non-refundable charge, nothing
200
+ * to recover.
201
+ */
202
+ readonly upfrontOnly: boolean;
182
203
 
183
204
  constructor(context: {
184
205
  readonly state: X402PostSignatureState;
@@ -186,11 +207,16 @@ export class X402PostSignatureError extends Error {
186
207
  readonly quote: X402QuoteSummary;
187
208
  readonly authorization: SignedTransferAuthorization | null;
188
209
  readonly streamId: `0x${string}` | null;
210
+ readonly upfrontOnly?: boolean;
189
211
  readonly cause: unknown;
190
212
  }) {
213
+ const upfrontOnly =
214
+ context.upfrontOnly === true || context.quote.upfrontOnly === true;
191
215
  const what =
192
216
  context.state === "used"
193
- ? `the payment SETTLED (the authorization was used)${context.streamId === null ? ", but its stream could not be identified" : ` into stream ${context.streamId}`}`
217
+ ? upfrontOnly
218
+ ? "the payment SETTLED (the authorization was used) as an UPFRONT-ONLY charge: the whole price went to the seller, no stream was opened and there is nothing to refund"
219
+ : `the payment SETTLED (the authorization was used)${context.streamId === null ? ", but its stream could not be identified" : ` into stream ${context.streamId}`}`
194
220
  : context.state === "possibly-used"
195
221
  ? "the payment MAY HAVE SETTLED: whether the authorization was used could not be established, so treat it as spent"
196
222
  : context.state === "unused-but-valid"
@@ -207,6 +233,7 @@ export class X402PostSignatureError extends Error {
207
233
  this.authorization = context.authorization;
208
234
  this.streamId = context.streamId;
209
235
  this.opener = getAddress(context.quote.payTo);
236
+ this.upfrontOnly = upfrontOnly;
210
237
  }
211
238
 
212
239
  /** Only an authorization that provably can never move funds frees its reservation. */
@@ -322,6 +349,8 @@ export async function payX402WithMandate(
322
349
  mandate.maxRatePerSecondUsdc,
323
350
  input.maxRatePerSecond,
324
351
  );
352
+ // A negative maxUpfrontAmount is refused by the SDK (InvalidInputError)
353
+ // before the seller is contacted.
325
354
 
326
355
  // 3. The per-quote mandate check, before signing.
327
356
  let denial: X402MandateDeniedError | null = null;
@@ -357,6 +386,9 @@ export async function payX402WithMandate(
357
386
  network: input.network,
358
387
  maxAmount,
359
388
  maxRatePerSecond,
389
+ ...(input.maxUpfrontAmount === undefined
390
+ ? {}
391
+ : { maxUpfrontAmount: smaller(input.maxUpfrontAmount, maxAmount) }),
360
392
  ...(input.minDurationSeconds === undefined
361
393
  ? {}
362
394
  : { minDurationSeconds: input.minDurationSeconds }),
@@ -440,6 +472,8 @@ async function classifyPostSignatureFailure(
440
472
  typeof sdkError.authorizationUsed === "boolean"
441
473
  ? sdkError.authorizationUsed
442
474
  : null;
475
+ let upfrontOnly =
476
+ (sdkError as { readonly upfrontOnly?: boolean }).upfrontOnly === true;
443
477
  let streamId: `0x${string}` | null =
444
478
  typeof sdkError.streamId === "string" &&
445
479
  /^0x[0-9a-fA-F]{64}$/.test(sdkError.streamId)
@@ -477,16 +511,19 @@ async function classifyPostSignatureFailure(
477
511
  publicClient: reader as PublicClient,
478
512
  });
479
513
  streamId = recovery.streamId;
514
+ // An upfront-only payment opens no stream: the opener's
515
+ // UpfrontCharged (streamId 0), read on-chain, proves it was used.
516
+ if (recovery.upfrontOnly) upfrontOnly = true;
480
517
  } catch {
481
518
  streamId = null;
482
519
  }
483
- if (streamId !== null) used = true;
520
+ if (streamId !== null || upfrontOnly) used = true;
484
521
  }
485
522
  }
486
523
 
487
524
  const nowSeconds = BigInt(Math.floor(Date.now() / 1000));
488
525
  const state: X402PostSignatureState =
489
- streamId !== null || used === true
526
+ streamId !== null || upfrontOnly || used === true
490
527
  ? "used"
491
528
  : used === false && authorization !== null
492
529
  ? authorization.validBefore <= nowSeconds
@@ -499,6 +536,7 @@ async function classifyPostSignatureFailure(
499
536
  quote: context.quote,
500
537
  authorization,
501
538
  streamId,
539
+ upfrontOnly,
502
540
  cause: context.error,
503
541
  });
504
542
  }
@@ -15,7 +15,10 @@ async function main(): Promise<void> {
15
15
  console.error(
16
16
  `[metrik-opener-facilitator] listening on port ${server.boundPort} — chain ${binding.chainId}, ` +
17
17
  `factory ${binding.factory}, escrow ${binding.escrow}, token ${binding.usdc}, relayer ${binding.relayer}. ` +
18
- "Every payment settles into a StreamEscrowV2 stream via deployAndSettle; nothing is ever transferred to payTo directly. " +
18
+ (binding.upfront === undefined
19
+ ? "Upfront (minimum-charge) opener: OFF. "
20
+ : `Upfront (minimum-charge) opener factory ${binding.upfront.factory} (implementation ${binding.upfront.implementation}): ON — its non-refundable upfrontAmount is paid to the seller at settlement, only price - upfrontAmount is escrowed and refundable. `) +
21
+ "Every payment settles through the opener factory's deployAndSettle (an escrow stream for the metered part); nothing is ever transferred to payTo directly. " +
19
22
  (server.refundRelayEnabled
20
23
  ? "Gasless refund relay ON at /streams/:streamId (close needs the payer's signature; reclaim is permissionless and pays only payerOf). There is no automatic refund: nothing is sent unless an endpoint is called."
21
24
  : "Gasless refund relay OFF (METRIK_OPENER_FACILITATOR_REFUND_RELAY=0): a gateway will refuse to quote through this facilitator."),
@@ -7,7 +7,7 @@ import {
7
7
  } from "node:http";
8
8
  import type { AddressInfo } from "node:net";
9
9
 
10
- import { networkForCaip2 } from "@absol-labs/sdk";
10
+ import { networkForCaip2, networkOpenerFactories } from "@absol-labs/sdk";
11
11
  import { x402Facilitator } from "@x402/core/facilitator";
12
12
  import type { Network } from "@x402/core/types";
13
13
  import {
@@ -470,6 +470,13 @@ export interface OpenerFacilitatorOptions {
470
470
  readonly rpcUrl: string;
471
471
  readonly relayerPrivateKey: Hex;
472
472
  readonly factoryAddress: Address;
473
+ /**
474
+ * The `X402UpfrontStreamOpenerFactory` to settle ALSO (same escrow), or
475
+ * undefined for stream openers only. See {@link resolveUpfrontFactory}.
476
+ */
477
+ readonly upfrontFactoryAddress?: Address;
478
+ /** The implementation the upfront factory must report (the registry's). */
479
+ readonly upfrontImplementation?: Address;
473
480
  readonly escrowAddress: Address;
474
481
  readonly assetAllowlist: readonly Address[];
475
482
  /** Default: a {@link RegistryListingResolver} on the public registry. */
@@ -528,6 +535,12 @@ export async function buildOpenerFacilitator(
528
535
  advertiseRefundRelay: options.refundRelay !== undefined,
529
536
  network: options.network,
530
537
  factoryAddress: options.factoryAddress,
538
+ ...(options.upfrontFactoryAddress === undefined
539
+ ? {}
540
+ : { upfrontFactoryAddress: options.upfrontFactoryAddress }),
541
+ ...(options.upfrontImplementation === undefined
542
+ ? {}
543
+ : { upfrontImplementation: options.upfrontImplementation }),
531
544
  escrowAddress: options.escrowAddress,
532
545
  assetAllowlist: options.assetAllowlist,
533
546
  publicClient,
@@ -553,6 +566,9 @@ export async function buildOpenerFacilitator(
553
566
  refundRelay = new OpenerRefundRelay({
554
567
  network: options.network,
555
568
  factoryAddress: options.factoryAddress,
569
+ ...(options.upfrontFactoryAddress === undefined
570
+ ? {}
571
+ : { upfrontFactoryAddress: options.upfrontFactoryAddress }),
556
572
  escrowAddress: options.escrowAddress,
557
573
  oracleUrl: relay.oracleUrl,
558
574
  publicClient,
@@ -601,6 +617,10 @@ export interface OpenerFacilitatorEnvConfig {
601
617
  readonly authToken: string;
602
618
  readonly requireAuthOnVerify: boolean;
603
619
  readonly factoryAddress: Address;
620
+ /** The upfront opener factory also settled, when enabled. */
621
+ readonly upfrontFactoryAddress?: Address;
622
+ /** Its expected implementation, when the registry records it. */
623
+ readonly upfrontImplementation?: Address;
604
624
  readonly escrowAddress: Address;
605
625
  readonly assetAllowlist: readonly Address[];
606
626
  readonly registryUrl: string;
@@ -637,6 +657,12 @@ export interface OpenerRefundRelayEnvConfig {
637
657
  * registry entry; on a network the registry does not know (or one with no
638
658
  * opener factory) each must be given explicitly.
639
659
  * - `METRIK_OPENER_FACILITATOR_FACTORY_ADDRESS` — default the network's registered factory.
660
+ * - `METRIK_OPENER_FACILITATOR_UPFRONT_FACTORY_ADDRESS` — the
661
+ * `X402UpfrontStreamOpenerFactory` (upfront "minimum charge" listings) settled
662
+ * ALONGSIDE the stream factory. Default: the registry's upfront factory for
663
+ * the configured escrow, when it records one (Base Sepolia: bound to
664
+ * StreamEscrowV3); none otherwise. `off` disables it. It must be bound to the
665
+ * same escrow and token, or the facilitator refuses to start.
640
666
  * - `METRIK_OPENER_FACILITATOR_ESCROW_ADDRESS` — default the network's registered `StreamEscrowV2`.
641
667
  * - `METRIK_OPENER_FACILITATOR_ASSETS` — comma-separated allowlist; default the network's USDC.
642
668
  * - `METRIK_OPENER_FACILITATOR_REGISTRY_URL` — default the public listings registry.
@@ -736,6 +762,11 @@ export function parseOpenerFacilitatorEnvConfig(
736
762
  "METRIK_OPENER_FACILITATOR_ESCROW_ADDRESS",
737
763
  );
738
764
  }
765
+ const upfront = resolveUpfrontFactory(
766
+ env.METRIK_OPENER_FACILITATOR_UPFRONT_FACTORY_ADDRESS,
767
+ network,
768
+ escrowAddress,
769
+ );
739
770
  const assetsRaw = env.METRIK_OPENER_FACILITATOR_ASSETS?.trim();
740
771
  let assetAllowlist: Address[];
741
772
  if (assetsRaw === undefined || assetsRaw.length === 0) {
@@ -805,6 +836,7 @@ export function parseOpenerFacilitatorEnvConfig(
805
836
  DEFAULT_SETTLE_RATE_LIMIT_PER_MINUTE,
806
837
  ),
807
838
  factoryAddress,
839
+ ...(upfront === undefined ? {} : upfront),
808
840
  escrowAddress,
809
841
  assetAllowlist,
810
842
  registryUrl,
@@ -813,6 +845,68 @@ export function parseOpenerFacilitatorEnvConfig(
813
845
  };
814
846
  }
815
847
 
848
+ /**
849
+ * The upfront opener factory the facilitator also settles, from
850
+ * `METRIK_OPENER_FACILITATOR_UPFRONT_FACTORY_ADDRESS`: unset = the network
851
+ * registry's upfront factory OF THE CONFIGURED ESCROW'S deployment (so a
852
+ * facilitator pinned to a legacy escrow never picks up a factory bound to
853
+ * another one); `off`/`none`/`0`/`false` = disabled; an address = that factory
854
+ * (its implementation is pinned only when the registry records it).
855
+ */
856
+ export function resolveUpfrontFactory(
857
+ raw: string | undefined,
858
+ network: Network,
859
+ escrowAddress: Address,
860
+ ):
861
+ | {
862
+ readonly upfrontFactoryAddress: Address;
863
+ readonly upfrontImplementation?: Address;
864
+ }
865
+ | undefined {
866
+ const value = raw?.trim() ?? "";
867
+ const registered = networkForCaip2(network);
868
+ const forEscrow =
869
+ registered === undefined
870
+ ? undefined
871
+ : networkOpenerFactories(registered).find(
872
+ (entry) =>
873
+ entry.kind === "upfront" &&
874
+ getAddress(entry.deployment.escrow.address) ===
875
+ getAddress(escrowAddress),
876
+ );
877
+ if (value.length === 0) {
878
+ if (forEscrow === undefined) return undefined;
879
+ return {
880
+ upfrontFactoryAddress: getAddress(forEscrow.address),
881
+ ...(forEscrow.implementation === null
882
+ ? {}
883
+ : { upfrontImplementation: getAddress(forEscrow.implementation) }),
884
+ };
885
+ }
886
+ if (["off", "none", "0", "false", "no"].includes(value.toLowerCase())) {
887
+ return undefined;
888
+ }
889
+ if (!isAddress(value)) {
890
+ throw new Error(
891
+ "METRIK_OPENER_FACILITATOR_UPFRONT_FACTORY_ADDRESS must be an EVM address (or off)",
892
+ );
893
+ }
894
+ const known =
895
+ registered === undefined
896
+ ? undefined
897
+ : networkOpenerFactories(registered).find(
898
+ (entry) =>
899
+ entry.kind === "upfront" &&
900
+ getAddress(entry.address) === getAddress(value),
901
+ );
902
+ return {
903
+ upfrontFactoryAddress: getAddress(value),
904
+ ...(known?.implementation == null
905
+ ? {}
906
+ : { upfrontImplementation: getAddress(known.implementation) }),
907
+ };
908
+ }
909
+
816
910
  function parseRefundRelayEnv(
817
911
  env: NodeJS.ProcessEnv,
818
912
  network: Network,
@@ -914,6 +1008,12 @@ export async function startOpenerFacilitatorServerFromEnv(
914
1008
  rpcUrl: config.rpcUrl,
915
1009
  relayerPrivateKey: config.relayerPrivateKey,
916
1010
  factoryAddress: config.factoryAddress,
1011
+ ...(config.upfrontFactoryAddress === undefined
1012
+ ? {}
1013
+ : { upfrontFactoryAddress: config.upfrontFactoryAddress }),
1014
+ ...(config.upfrontImplementation === undefined
1015
+ ? {}
1016
+ : { upfrontImplementation: config.upfrontImplementation }),
917
1017
  escrowAddress: config.escrowAddress,
918
1018
  assetAllowlist: config.assetAllowlist,
919
1019
  registryUrl: config.registryUrl,