@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
@@ -20,12 +20,13 @@ import {
20
20
  DEFAULT_ORACLE_SIGNER_CACHE_SECONDS,
21
21
  } from "./probe-auth.js";
22
22
  import { PROBE_MARKER_HEADER_NAME } from "../capability/probe-capability.js";
23
- import { networkForCaip2 } from "@absol-labs/sdk";
23
+ import { networkForCaip2, networkOpenerFactories } from "@absol-labs/sdk";
24
24
  import {
25
25
  chainIdFromNetwork,
26
26
  assertSchemaDeclaration,
27
27
  assertValidPrice,
28
28
  assertValidTerms,
29
+ assertValidUpfrontTerms,
29
30
  isHttpLink,
30
31
  type OpenerTerms,
31
32
  type X402SchemaDeclaration,
@@ -459,6 +460,13 @@ export interface CallerAuthGatewayX402OpenerEnvConfig {
459
460
  readonly assetVersion: string;
460
461
  readonly terms: OpenerTerms;
461
462
  readonly price: bigint;
463
+ /**
464
+ * `METRIK_GATEWAY_X402V2_UPFRONT_AMOUNT`: selects the upfront opener kind
465
+ * (then `factoryAddress` is the upfront factory). Undefined = stream kind.
466
+ */
467
+ readonly upfrontAmount?: bigint;
468
+ /** The registry's implementation of the upfront factory, when it records one. */
469
+ readonly upfrontImplementation?: Address;
462
470
  readonly maxTimeoutSeconds: number;
463
471
  /** Per-request facilitator HTTP timeout, ms. */
464
472
  readonly facilitatorTimeoutMs: number;
@@ -643,6 +651,16 @@ export function parseCallerAuthGatewayEnvConfig(
643
651
  * - `METRIK_GATEWAY_X402V2_MAX_DURATION_SECONDS` (required).
644
652
  * - `METRIK_GATEWAY_X402V2_PRICE` (required) — base units per paid request (= deposit),
645
653
  * `0 < price <= rate * maxDuration`.
654
+ * - `METRIK_GATEWAY_X402V2_UPFRONT_AMOUNT` — base units; when SET the listing
655
+ * quotes the UPFRONT ("minimum charge") opener instead: `amount = price`,
656
+ * `payTo = upfrontFactory.predict(6 terms)`; `upfrontAmount` is paid to the
657
+ * seller at settlement and is NOT refundable, only `price - upfrontAmount`
658
+ * (`<= rate * maxDuration`) is metered and refundable. `= PRICE` is
659
+ * upfront-only (no stream). Validated with the factory's rules at startup.
660
+ * - `METRIK_GATEWAY_X402V2_UPFRONT_FACTORY_ADDRESS` — the
661
+ * `X402UpfrontStreamOpenerFactory`; default the network registry's (only
662
+ * with `_UPFRONT_AMOUNT`). `_FACTORY_ADDRESS` names the STREAM factory and is
663
+ * refused together with `_UPFRONT_AMOUNT`.
646
664
  * - `METRIK_GATEWAY_X402V2_NETWORK` — default `eip155:<METRIK_GATEWAY_CHAIN_ID>`.
647
665
  * - `METRIK_GATEWAY_X402V2_ASSET` — default the network's registered USDC.
648
666
  * - `METRIK_GATEWAY_X402V2_ASSET_NAME` / `_ASSET_VERSION` — EIP-712 domain, default the
@@ -699,8 +717,55 @@ export function parseGatewayX402OpenerEnvConfig(
699
717
  // Defaults come from the registry entry for THIS network only.
700
718
  const registered = networkForCaip2(network);
701
719
  const factoryRaw = env.METRIK_GATEWAY_X402V2_FACTORY_ADDRESS?.trim();
720
+ const upfrontRaw = env.METRIK_GATEWAY_X402V2_UPFRONT_AMOUNT?.trim();
721
+ const upfrontFactoryRaw =
722
+ env.METRIK_GATEWAY_X402V2_UPFRONT_FACTORY_ADDRESS?.trim();
723
+ const upfrontMode = upfrontRaw !== undefined && upfrontRaw.length > 0;
724
+ if (
725
+ !upfrontMode &&
726
+ upfrontFactoryRaw !== undefined &&
727
+ upfrontFactoryRaw.length > 0
728
+ ) {
729
+ throw new Error(
730
+ "METRIK_GATEWAY_X402V2_UPFRONT_FACTORY_ADDRESS needs METRIK_GATEWAY_X402V2_UPFRONT_AMOUNT (it selects the upfront opener)",
731
+ );
732
+ }
702
733
  let factoryAddress: Address;
703
- if (factoryRaw === undefined || factoryRaw.length === 0) {
734
+ let upfrontImplementation: Address | undefined;
735
+ if (upfrontMode) {
736
+ if (factoryRaw !== undefined && factoryRaw.length > 0) {
737
+ throw new Error(
738
+ "METRIK_GATEWAY_X402V2_FACTORY_ADDRESS names the STREAM opener factory; with METRIK_GATEWAY_X402V2_UPFRONT_AMOUNT set the listing quotes the upfront factory — set METRIK_GATEWAY_X402V2_UPFRONT_FACTORY_ADDRESS (or nothing, for the registry's) instead",
739
+ );
740
+ }
741
+ const registeredUpfront =
742
+ registered === undefined
743
+ ? []
744
+ : networkOpenerFactories(registered).filter(
745
+ (entry) => entry.kind === "upfront",
746
+ );
747
+ if (upfrontFactoryRaw === undefined || upfrontFactoryRaw.length === 0) {
748
+ const current = registeredUpfront.find((entry) => !entry.legacy);
749
+ if (current === undefined) {
750
+ throw new Error(
751
+ `METRIK_GATEWAY_X402V2_UPFRONT_FACTORY_ADDRESS is required on ${network}: the network registry has no upfront opener factory for it`,
752
+ );
753
+ }
754
+ factoryAddress = current.address;
755
+ upfrontImplementation = current.implementation ?? undefined;
756
+ } else if (isAddress(upfrontFactoryRaw)) {
757
+ factoryAddress = upfrontFactoryRaw;
758
+ upfrontImplementation =
759
+ registeredUpfront.find(
760
+ (entry) =>
761
+ entry.address.toLowerCase() === upfrontFactoryRaw.toLowerCase(),
762
+ )?.implementation ?? undefined;
763
+ } else {
764
+ throw new Error(
765
+ "METRIK_GATEWAY_X402V2_UPFRONT_FACTORY_ADDRESS must be an EVM address",
766
+ );
767
+ }
768
+ } else if (factoryRaw === undefined || factoryRaw.length === 0) {
704
769
  if (registered?.openerFactory == null) {
705
770
  throw new Error(
706
771
  `METRIK_GATEWAY_X402V2_FACTORY_ADDRESS is required on ${network}: the network registry has no opener factory for it`,
@@ -742,9 +807,18 @@ export function parseGatewayX402OpenerEnvConfig(
742
807
  ),
743
808
  };
744
809
  const price = requireUintEnv(env, "METRIK_GATEWAY_X402V2_PRICE");
745
- // Startup validation: rate/duration > 0 and 0 < price <= rate * maxDuration.
746
- assertValidTerms(terms);
747
- assertValidPrice(price, terms);
810
+ let upfrontAmount: bigint | undefined;
811
+ if (upfrontMode) {
812
+ upfrontAmount = requireUintEnv(env, "METRIK_GATEWAY_X402V2_UPFRONT_AMOUNT");
813
+ // Startup validation, fail closed: the upfront factory's own validateTerms
814
+ // rules, 0 < price, upfrontAmount <= price, and the metered part
815
+ // (price - upfrontAmount) <= rate * maxDuration.
816
+ assertValidUpfrontTerms({ ...terms, price, upfrontAmount });
817
+ } else {
818
+ // Startup validation: rate/duration > 0 and 0 < price <= rate * maxDuration.
819
+ assertValidTerms(terms);
820
+ assertValidPrice(price, terms);
821
+ }
748
822
  const maxTimeoutSeconds = requirePositiveIntEnv(
749
823
  env,
750
824
  "METRIK_GATEWAY_X402V2_MAX_TIMEOUT_SECONDS",
@@ -828,6 +902,8 @@ export function parseGatewayX402OpenerEnvConfig(
828
902
  ),
829
903
  terms,
830
904
  price,
905
+ ...(upfrontAmount === undefined ? {} : { upfrontAmount }),
906
+ ...(upfrontImplementation === undefined ? {} : { upfrontImplementation }),
831
907
  maxTimeoutSeconds,
832
908
  facilitatorTimeoutMs: facilitatorTimeoutSeconds * 1000,
833
909
  ...(resourceBaseUrl === undefined || resourceBaseUrl.length === 0
@@ -1032,6 +1108,12 @@ export async function startCallerAuthGatewayServerFromEnv(
1032
1108
  assetVersion: config.x402Opener.assetVersion,
1033
1109
  terms: config.x402Opener.terms,
1034
1110
  price: config.x402Opener.price,
1111
+ ...(config.x402Opener.upfrontAmount === undefined
1112
+ ? {}
1113
+ : { upfrontAmount: config.x402Opener.upfrontAmount }),
1114
+ ...(config.x402Opener.upfrontImplementation === undefined
1115
+ ? {}
1116
+ : { upfrontImplementation: config.x402Opener.upfrontImplementation }),
1035
1117
  maxTimeoutSeconds: config.x402Opener.maxTimeoutSeconds,
1036
1118
  facilitatorTimeoutMs: config.x402Opener.facilitatorTimeoutMs,
1037
1119
  ...(config.x402Opener.resourceBaseUrl === undefined
@@ -1058,12 +1140,18 @@ export async function startCallerAuthGatewayServerFromEnv(
1058
1140
  },
1059
1141
  { rpcUrl: config.rpcUrl },
1060
1142
  );
1143
+ const upfront = x402Opener.upfrontAmount;
1061
1144
  console.error(
1062
1145
  "[metrik-caller-auth-gateway] x402 v2 opener mode ENABLED: a request with neither a " +
1063
1146
  "capability nor PAYMENT-SIGNATURE gets a 402 quoting payTo " +
1064
- `${x402Opener.payTo} (the escrow opener for serviceRef ${config.serviceRef}), price ` +
1065
- `${config.x402Opener.price} base units. Payments settle into StreamEscrowV2 via the Metrik ` +
1066
- `opener facilitator at ${config.x402Opener.facilitatorUrl}, and only after the upstream answers < 400. ` +
1147
+ `${x402Opener.payTo} (the ${x402Opener.openerKind} opener for serviceRef ${config.serviceRef}), price ` +
1148
+ `${config.x402Opener.price} base units` +
1149
+ (upfront === null
1150
+ ? " (all of it escrowed and metered)"
1151
+ : upfront === config.x402Opener.price
1152
+ ? " (UPFRONT-ONLY: all of it a non-refundable per-call charge; no stream, nothing to refund)"
1153
+ : ` (${upfront} a non-refundable per-call charge, ${config.x402Opener.price - upfront} escrowed, metered and refundable)`) +
1154
+ `. Payments settle via the Metrik opener facilitator at ${config.x402Opener.facilitatorUrl}, and only after the upstream answers < 400. ` +
1067
1155
  `Refunds (no ETH needed, never automatic): ${x402Opener.refundBaseUrl}/streams/:streamId.`,
1068
1156
  );
1069
1157
  const refundHost = new URL(x402Opener.refundBaseUrl).hostname;
@@ -26,6 +26,11 @@ import {
26
26
  type PublicClient,
27
27
  } from "viem";
28
28
 
29
+ import {
30
+ predictUpfrontOpenerAddress,
31
+ x402UpfrontStreamOpenerFactoryAbi,
32
+ } from "@absol-labs/shared";
33
+
29
34
  import { x402StreamOpenerFactoryAbi } from "../x402-opener/abi.js";
30
35
  import {
31
36
  RegistryListingResolver,
@@ -35,6 +40,7 @@ import { readOpenerFacilitatorMarker } from "../x402-opener/scheme.js";
35
40
  import {
36
41
  assertValidPrice,
37
42
  assertValidTerms,
43
+ assertValidUpfrontTerms,
38
44
  chainIdFromNetwork,
39
45
  assertSchemaDeclaration,
40
46
  DEFAULT_OPENER_DOCS_URL,
@@ -43,7 +49,11 @@ import {
43
49
  OPENER_SCHEME,
44
50
  termsToExtra,
45
51
  termsTuple,
52
+ upfrontTermsToExtra,
53
+ upfrontTermsTuple,
54
+ type OpenerKind,
46
55
  type OpenerTerms,
56
+ type UpfrontOpenerTerms,
47
57
  type X402SchemaDeclaration,
48
58
  } from "../x402-opener/terms.js";
49
59
 
@@ -90,6 +100,18 @@ import {
90
100
  * - refuses to start if its price is below the facilitator's advertised
91
101
  * minimum, or if its facilitator timeout would not outlast the facilitator's
92
102
  * receipt wait.
103
+ *
104
+ * ## Two opener kinds
105
+ *
106
+ * Without `upfrontAmount` the listing quotes the `X402StreamOpenerFactory`
107
+ * (`"stream"`): the whole price funds a metered stream. With `upfrontAmount`
108
+ * it quotes the `X402UpfrontStreamOpenerFactory` (`"upfront"`, the "minimum
109
+ * charge"): `amount == price`, `payTo = predict(6 terms)`, the
110
+ * `upfrontAmount` is paid to the seller at settlement and is NOT refundable,
111
+ * and only `price - upfrontAmount` funds a metered, refundable stream. The 402
112
+ * description states that split. `upfrontAmount == price` is an upfront-only
113
+ * listing: no stream opens (the settlement reports `streamId: null`), the
114
+ * response is served, and no metered session or refund exists for it.
93
115
  */
94
116
 
95
117
  /** How long a successful `/supported` marker check is trusted, seconds. */
@@ -118,8 +140,24 @@ export interface GatewayX402OpenerModeConfig {
118
140
  readonly assetVersion: string;
119
141
  /** Opener terms. `operator` + `serviceRef` must resolve to one operator-signed listing. */
120
142
  readonly terms: OpenerTerms;
121
- /** Price per paid request in token base units. Becomes the stream deposit. */
143
+ /**
144
+ * Price per paid request in token base units. For a stream opener it is the
145
+ * whole stream deposit; for an upfront opener the exact x402 amount, split
146
+ * into `upfrontAmount` (non-refundable) and `price - upfrontAmount` (deposit).
147
+ */
122
148
  readonly price: bigint;
149
+ /**
150
+ * Selects the UPFRONT opener kind (`factoryAddress` must then be an
151
+ * `X402UpfrontStreamOpenerFactory`): the non-refundable per-call part, base
152
+ * units, `0 <= upfrontAmount <= price`. Unset = the stream opener kind.
153
+ */
154
+ readonly upfrontAmount?: bigint;
155
+ /**
156
+ * The implementation the upfront factory must report (e.g. the registry's);
157
+ * checked at startup when given. `payTo` is always recomputed offline from
158
+ * the factory's own `implementation()` and must equal its on-chain `predict`.
159
+ */
160
+ readonly upfrontImplementation?: Address;
123
161
  /** Authorization validity the client signs for. Default 300s. */
124
162
  readonly maxTimeoutSeconds?: number;
125
163
  /** Public origin clients reach this gateway at, for the 402 `resource.url`. */
@@ -168,6 +206,11 @@ export interface GatewayX402OpenerModeConfig {
168
206
  export function describeOpenerPurchase(input: {
169
207
  readonly terms: OpenerTerms;
170
208
  readonly price: bigint;
209
+ /**
210
+ * Upfront openers only: the non-refundable per-call part. The description
211
+ * then states the split (non-refundable vs metered and refundable).
212
+ */
213
+ readonly upfrontAmount?: bigint;
171
214
  readonly assetDecimals: number;
172
215
  readonly assetSymbol: string;
173
216
  /** Refund URL template containing `{streamId}`. */
@@ -178,15 +221,42 @@ export function describeOpenerPurchase(input: {
178
221
  const amount = (value: bigint) =>
179
222
  `${formatUnits(value, input.assetDecimals)} ${input.assetSymbol}`;
180
223
  const { ratePerSecond, maxDuration } = input.terms;
181
- const cap =
182
- input.price < ratePerSecond * maxDuration
183
- ? ` (the seller can earn at most ${amount(input.price)})`
184
- : "";
185
224
  const prefix =
186
225
  input.serviceDescription === undefined ||
187
226
  input.serviceDescription.trim().length === 0
188
227
  ? ""
189
228
  : `${input.serviceDescription.trim()} `;
229
+ if (input.upfrontAmount !== undefined) {
230
+ const upfront = input.upfrontAmount;
231
+ const metered = input.price - upfront;
232
+ if (metered === 0n) {
233
+ return (
234
+ `${prefix}Pays for one call: ${amount(input.price)}, charged in full at settlement as a NON-REFUNDABLE per-call charge ` +
235
+ "(paid to the seller whatever is delivered). No escrow stream is opened, so nothing is metered and there is nothing to refund or reclaim. " +
236
+ `Docs: ${input.docsUrl}`
237
+ );
238
+ }
239
+ const meteredCap =
240
+ metered < ratePerSecond * maxDuration
241
+ ? ` (the seller can earn at most ${amount(metered)} of it)`
242
+ : "";
243
+ return (
244
+ `${prefix}Pays for one call: ${amount(input.price)} in total. ` +
245
+ (upfront === 0n
246
+ ? "Nothing of it is charged upfront. "
247
+ : `${amount(upfront)} is a NON-REFUNDABLE per-call charge, paid to the seller at settlement whatever is delivered. `) +
248
+ `The remaining ${amount(metered)} is metered and refundable: it funds a Metrik escrow stream of ` +
249
+ `${maxDuration.toString()} seconds at ${amount(ratePerSecond)}/s${meteredCap}; ` +
250
+ "the seller earns it only for seconds Metrik's verifier confirms the service is delivering, " +
251
+ `and any unearned part of it (at most ${amount(metered)}) is refundable to you (no ETH needed) via ${input.refundUrlTemplate}. ` +
252
+ "Refunds are not automatic: close the stream and reclaim there, using the streamId from the PAYMENT-RESPONSE. " +
253
+ `Docs: ${input.docsUrl}`
254
+ );
255
+ }
256
+ const cap =
257
+ input.price < ratePerSecond * maxDuration
258
+ ? ` (the seller can earn at most ${amount(input.price)})`
259
+ : "";
190
260
  return (
191
261
  `${prefix}Pays for one call. Your payment of ${amount(input.price)} funds a Metrik escrow stream of ` +
192
262
  `${maxDuration.toString()} seconds at ${amount(ratePerSecond)}/s${cap}; ` +
@@ -216,9 +286,13 @@ class OpenerExactServerScheme extends ExactEvmScheme {
216
286
  | Record<string, unknown>
217
287
  | undefined;
218
288
  const streamId = opened?.streamId;
289
+ // An upfront-only payment reports `streamId: null` (no stream): it gets no
290
+ // refund URL, since there is nothing to refund. bytes32(0) is treated the
291
+ // same, defensively.
219
292
  if (
220
293
  typeof streamId !== "string" ||
221
294
  !/^0x[0-9a-fA-F]{64}$/.test(streamId) ||
295
+ /^0x0{64}$/i.test(streamId) ||
222
296
  opened?.refundUrl !== undefined
223
297
  ) {
224
298
  return undefined;
@@ -249,6 +323,10 @@ export interface GatewayX402OpenerMode {
249
323
  readonly network: Network;
250
324
  readonly terms: OpenerTerms;
251
325
  readonly price: bigint;
326
+ /** `"stream"` or `"upfront"` (selected by `upfrontAmount`). */
327
+ readonly openerKind: OpenerKind;
328
+ /** The upfront kind's non-refundable part; `null` for the stream kind. */
329
+ readonly upfrontAmount: bigint | null;
252
330
  readonly resourceBaseUrl: string | undefined;
253
331
  /** The 402 `description` (what the payment buys). */
254
332
  readonly description: string;
@@ -417,9 +495,25 @@ export async function buildGatewayX402OpenerMode(
417
495
  config: GatewayX402OpenerModeConfig,
418
496
  deps: GatewayX402OpenerModeDeps = {},
419
497
  ): Promise<GatewayX402OpenerMode> {
498
+ const kind: OpenerKind =
499
+ config.upfrontAmount === undefined ? "stream" : "upfront";
500
+ const upfrontTerms: UpfrontOpenerTerms | undefined =
501
+ config.upfrontAmount === undefined
502
+ ? undefined
503
+ : {
504
+ ...config.terms,
505
+ price: config.price,
506
+ upfrontAmount: config.upfrontAmount,
507
+ };
420
508
  try {
421
- assertValidTerms(config.terms);
422
- assertValidPrice(config.price, config.terms);
509
+ if (upfrontTerms === undefined) {
510
+ assertValidTerms(config.terms);
511
+ assertValidPrice(config.price, config.terms);
512
+ } else {
513
+ // The factory's own validateTerms rules + amount == price + the metered
514
+ // part within rate * maxDuration: exactly what the SDK buyer requires.
515
+ assertValidUpfrontTerms(upfrontTerms);
516
+ }
423
517
  } catch (error) {
424
518
  throw new X402OpenerModeConfigError(
425
519
  error instanceof Error ? error.message : String(error),
@@ -501,24 +595,86 @@ export async function buildGatewayX402OpenerMode(
501
595
  const factory = getAddress(config.factoryAddress);
502
596
  const escrow = getAddress(config.escrowAddress);
503
597
  const asset = getAddress(config.asset);
504
- const [factoryEscrow, factoryUsdc, predicted] = await Promise.all([
505
- publicClient.readContract({
506
- address: factory,
507
- abi: x402StreamOpenerFactoryAbi,
508
- functionName: "escrow",
509
- }),
510
- publicClient.readContract({
511
- address: factory,
512
- abi: x402StreamOpenerFactoryAbi,
513
- functionName: "usdc",
514
- }),
515
- publicClient.readContract({
516
- address: factory,
517
- abi: x402StreamOpenerFactoryAbi,
518
- functionName: "predict",
519
- args: [termsTuple(config.terms)],
520
- }),
521
- ]);
598
+ let factoryEscrow: Address;
599
+ let factoryUsdc: Address;
600
+ let predicted: Address;
601
+ if (upfrontTerms === undefined) {
602
+ [factoryEscrow, factoryUsdc, predicted] = await Promise.all([
603
+ publicClient.readContract({
604
+ address: factory,
605
+ abi: x402StreamOpenerFactoryAbi,
606
+ functionName: "escrow",
607
+ }),
608
+ publicClient.readContract({
609
+ address: factory,
610
+ abi: x402StreamOpenerFactoryAbi,
611
+ functionName: "usdc",
612
+ }),
613
+ publicClient.readContract({
614
+ address: factory,
615
+ abi: x402StreamOpenerFactoryAbi,
616
+ functionName: "predict",
617
+ args: [termsTuple(config.terms)],
618
+ }),
619
+ ]);
620
+ } else {
621
+ let implementation: Address;
622
+ [factoryEscrow, factoryUsdc, predicted, implementation] = await Promise.all(
623
+ [
624
+ publicClient.readContract({
625
+ address: factory,
626
+ abi: x402UpfrontStreamOpenerFactoryAbi,
627
+ functionName: "escrow",
628
+ }),
629
+ publicClient.readContract({
630
+ address: factory,
631
+ abi: x402UpfrontStreamOpenerFactoryAbi,
632
+ functionName: "usdc",
633
+ }),
634
+ publicClient.readContract({
635
+ address: factory,
636
+ abi: x402UpfrontStreamOpenerFactoryAbi,
637
+ functionName: "predict",
638
+ args: [upfrontTermsTuple(upfrontTerms)],
639
+ }),
640
+ publicClient.readContract({
641
+ address: factory,
642
+ abi: x402UpfrontStreamOpenerFactoryAbi,
643
+ functionName: "implementation",
644
+ }),
645
+ ],
646
+ ).catch((error: unknown) => {
647
+ throw new X402OpenerModeConfigError(
648
+ `factory ${factory} is not an X402UpfrontStreamOpenerFactory that accepts these upfront terms (${error instanceof Error ? (error.message.split("\n")[0] ?? "error") : String(error)})`,
649
+ );
650
+ });
651
+ if (
652
+ config.upfrontImplementation !== undefined &&
653
+ getAddress(implementation) !== getAddress(config.upfrontImplementation)
654
+ ) {
655
+ throw new X402OpenerModeConfigError(
656
+ `upfront factory ${factory} reports implementation ${implementation}, not the expected ${config.upfrontImplementation}`,
657
+ );
658
+ }
659
+ // The SDK buyer recomputes payTo OFFLINE; quote only a payTo both agree on.
660
+ const offline = predictUpfrontOpenerAddress({
661
+ factory,
662
+ implementation: getAddress(implementation),
663
+ terms: {
664
+ operator: getAddress(upfrontTerms.operator),
665
+ serviceRef: upfrontTerms.serviceRef.toLowerCase() as `0x${string}`,
666
+ ratePerSecond: upfrontTerms.ratePerSecond,
667
+ maxDuration: upfrontTerms.maxDuration,
668
+ price: upfrontTerms.price,
669
+ upfrontAmount: upfrontTerms.upfrontAmount,
670
+ },
671
+ });
672
+ if (getAddress(offline) !== getAddress(predicted)) {
673
+ throw new X402OpenerModeConfigError(
674
+ `factory.predict(terms) = ${predicted} disagrees with the offline CREATE2 derivation ${offline}; refusing to quote it`,
675
+ );
676
+ }
677
+ }
522
678
  if (getAddress(factoryEscrow) !== escrow) {
523
679
  throw new X402OpenerModeConfigError(
524
680
  `factory ${factory} settles into ${factoryEscrow}, not this gateway's escrow ${escrow}`,
@@ -577,22 +733,26 @@ export async function buildGatewayX402OpenerMode(
577
733
  { cause },
578
734
  );
579
735
  }
580
- const kind = supported.kinds.find(
736
+ const supportedKind = supported.kinds.find(
581
737
  (candidate) =>
582
738
  candidate.x402Version === 2 &&
583
739
  candidate.scheme === OPENER_SCHEME &&
584
740
  candidate.network === config.network,
585
741
  );
586
- const marker = readOpenerFacilitatorMarker(kind?.extra);
742
+ const marker = readOpenerFacilitatorMarker(supportedKind?.extra);
587
743
  if (
588
744
  marker === undefined ||
589
- marker.factory !== factory ||
590
- marker.escrow !== escrow ||
745
+ !markerSettles(
746
+ marker,
747
+ upfrontTerms === undefined ? "stream" : "upfront",
748
+ factory,
749
+ escrow,
750
+ ) ||
591
751
  marker.usdc !== asset ||
592
752
  marker.chainId !== chainId
593
753
  ) {
594
754
  throw new X402OpenerFacilitatorUnverifiedError(
595
- `the x402 facilitator at ${config.facilitatorUrl} does not advertise Metrik opener settlement for factory ${factory} on ${config.network}. ` +
755
+ `the x402 facilitator at ${config.facilitatorUrl} does not advertise Metrik ${upfrontTerms === undefined ? "" : "upfront "}opener settlement for factory ${factory} on ${config.network}. ` +
596
756
  "Refusing to quote an opener payTo: a stock exact facilitator would transfer the payment straight to the opener and strand it permanently.",
597
757
  );
598
758
  }
@@ -624,6 +784,9 @@ export async function buildGatewayX402OpenerMode(
624
784
  const description = describeOpenerPurchase({
625
785
  terms: config.terms,
626
786
  price: config.price,
787
+ ...(config.upfrontAmount === undefined
788
+ ? {}
789
+ : { upfrontAmount: config.upfrontAmount }),
627
790
  assetDecimals,
628
791
  assetSymbol: config.assetName,
629
792
  refundUrlTemplate: `${refundBaseUrl}/streams/{streamId}`,
@@ -644,11 +807,21 @@ export async function buildGatewayX402OpenerMode(
644
807
  name: config.assetName,
645
808
  version: config.assetVersion,
646
809
  assetTransferMethod: "eip3009",
647
- [OPENER_EXTRA_KEY]: {
648
- ...termsToExtra(factory, config.terms),
649
- refundUrl: `${refundBaseUrl}/streams/{streamId}`,
650
- docsUrl,
651
- },
810
+ [OPENER_EXTRA_KEY]:
811
+ upfrontTerms === undefined
812
+ ? {
813
+ ...termsToExtra(factory, config.terms),
814
+ refundUrl: `${refundBaseUrl}/streams/{streamId}`,
815
+ docsUrl,
816
+ }
817
+ : {
818
+ ...upfrontTermsToExtra(factory, upfrontTerms),
819
+ // An upfront-only listing opens no stream: no refund URL.
820
+ ...(upfrontTerms.upfrontAmount === upfrontTerms.price
821
+ ? {}
822
+ : { refundUrl: `${refundBaseUrl}/streams/{streamId}` }),
823
+ docsUrl,
824
+ },
652
825
  },
653
826
  },
654
827
  maxTimeoutSeconds,
@@ -692,6 +865,7 @@ export async function buildGatewayX402OpenerMode(
692
865
  await ready();
693
866
  if (refundBaseUrl !== config.facilitatorUrl.replace(/\/+$/, "")) {
694
867
  await checkRefundRelayAt(refundBaseUrl, deps.fetch ?? fetch, {
868
+ kind,
695
869
  factory,
696
870
  escrow,
697
871
  asset,
@@ -705,6 +879,8 @@ export async function buildGatewayX402OpenerMode(
705
879
  network: config.network,
706
880
  terms: config.terms,
707
881
  price: config.price,
882
+ openerKind: kind,
883
+ upfrontAmount: config.upfrontAmount ?? null,
708
884
  resourceBaseUrl: config.resourceBaseUrl,
709
885
  description,
710
886
  refundBaseUrl,
@@ -729,6 +905,7 @@ async function checkRefundRelayAt(
729
905
  refundBaseUrl: string,
730
906
  fetchImpl: typeof fetch,
731
907
  expected: {
908
+ readonly kind: OpenerKind;
732
909
  readonly factory: Address;
733
910
  readonly escrow: Address;
734
911
  readonly asset: Address;
@@ -765,8 +942,7 @@ async function checkRefundRelayAt(
765
942
  const marker = readOpenerFacilitatorMarker(kind?.extra);
766
943
  if (
767
944
  marker === undefined ||
768
- marker.factory !== expected.factory ||
769
- marker.escrow !== expected.escrow ||
945
+ !markerSettles(marker, expected.kind, expected.factory, expected.escrow) ||
770
946
  marker.usdc !== expected.asset ||
771
947
  marker.chainId !== expected.chainId ||
772
948
  marker.refundRelay !== true
@@ -777,6 +953,27 @@ async function checkRefundRelayAt(
777
953
  }
778
954
  }
779
955
 
956
+ /**
957
+ * Whether a facilitator marker settles THIS listing's opener kind: the stream
958
+ * factory is the marker's `factory`; the upfront factory its `upfrontOpener`.
959
+ */
960
+ function markerSettles(
961
+ marker: NonNullable<ReturnType<typeof readOpenerFacilitatorMarker>>,
962
+ kind: OpenerKind,
963
+ factory: Address,
964
+ escrow: Address,
965
+ ): boolean {
966
+ if (kind === "upfront") {
967
+ return (
968
+ marker.upfrontOpener !== undefined &&
969
+ marker.upfrontOpener.factory === factory &&
970
+ marker.upfrontOpener.escrow === escrow &&
971
+ marker.escrow === escrow
972
+ );
973
+ }
974
+ return marker.factory === factory && marker.escrow === escrow;
975
+ }
976
+
780
977
  /**
781
978
  * One x402-mode request, mirroring the reference `@x402/*` middleware: verify
782
979
  * via the facilitator, run the upstream, and settle ONLY when the upstream
package/src/mcp/server.ts CHANGED
@@ -377,6 +377,18 @@ export interface StoredStreamRecord {
377
377
  * reservation key, not an escrow stream id.
378
378
  */
379
379
  readonly x402Reservation?: boolean;
380
+ /**
381
+ * NOT a stream: a settled x402 upfront-only charge (an upfront opener with
382
+ * `upfrontAmount == price` opens no stream). It counts against the
383
+ * cumulative cap like any spend and has nothing to refund; `streamId` is a
384
+ * charge key, not an escrow stream id.
385
+ */
386
+ readonly x402Charge?: "upfront-only";
387
+ /**
388
+ * x402 upfront openers: the NON-REFUNDABLE part of `budgetUsdc` (paid to the
389
+ * seller at settlement). Informational: `budgetUsdc` always counts it.
390
+ */
391
+ readonly nonRefundableUsdc?: bigint;
380
392
  }
381
393
 
382
394
  export interface StreamRegistry {
@@ -456,6 +468,8 @@ export class FileStreamRegistry implements StreamRegistry {
456
468
  refundUrl: z.string().url().optional(),
457
469
  opener: addressSchema.optional(),
458
470
  x402Reservation: z.boolean().optional(),
471
+ x402Charge: z.literal("upfront-only").optional(),
472
+ nonRefundableUsdc: decimalStringSchema.optional(),
459
473
  }),
460
474
  )
461
475
  .parse(JSON.parse(contents));
@@ -480,6 +494,12 @@ export class FileStreamRegistry implements StreamRegistry {
480
494
  ? {}
481
495
  : { opener: record.opener as `0x${string}` }),
482
496
  ...(record.x402Reservation === true ? { x402Reservation: true } : {}),
497
+ ...(record.x402Charge === undefined
498
+ ? {}
499
+ : { x402Charge: record.x402Charge }),
500
+ ...(record.nonRefundableUsdc === undefined
501
+ ? {}
502
+ : { nonRefundableUsdc: BigInt(record.nonRefundableUsdc) }),
483
503
  },
484
504
  ]),
485
505
  );
@@ -496,6 +516,9 @@ export class FileStreamRegistry implements StreamRegistry {
496
516
  ...record,
497
517
  budgetUsdc: record.budgetUsdc.toString(),
498
518
  ratePerSecondUsdc: record.ratePerSecondUsdc.toString(),
519
+ ...(record.nonRefundableUsdc === undefined
520
+ ? {}
521
+ : { nonRefundableUsdc: record.nonRefundableUsdc.toString() }),
499
522
  })),
500
523
  null,
501
524
  2,
@@ -1129,21 +1152,27 @@ export function createVerifiedStreamMcpServer(
1129
1152
  // A reservation key is not an escrow stream: report it as what it
1130
1153
  // is instead of reading a stream that does not exist.
1131
1154
  const status =
1132
- record.x402Reservation === true
1155
+ record.x402Charge === "upfront-only"
1133
1156
  ? {
1134
1157
  streamId: record.streamId,
1135
- x402Reservation:
1136
- "an x402 payment was signed but its stream could not be confirmed; its amount stays counted against the spend mandate",
1158
+ x402Charge:
1159
+ "a settled x402 upfront-only charge: the whole price was a non-refundable per-call charge, no stream exists and there is nothing to refund; its amount counts against the spend mandate",
1137
1160
  }
1138
- : await options.agentClient
1139
- .getStreamStatus(record.streamId)
1140
- .then((value) =>
1141
- serializeStreamStatus(value, record.streamId),
1142
- )
1143
- .catch((error) => ({
1161
+ : record.x402Reservation === true
1162
+ ? {
1144
1163
  streamId: record.streamId,
1145
- statusError: asErrorMessage(error),
1146
- }));
1164
+ x402Reservation:
1165
+ "an x402 payment was signed but its stream could not be confirmed; its amount stays counted against the spend mandate",
1166
+ }
1167
+ : await options.agentClient
1168
+ .getStreamStatus(record.streamId)
1169
+ .then((value) =>
1170
+ serializeStreamStatus(value, record.streamId),
1171
+ )
1172
+ .catch((error) => ({
1173
+ streamId: record.streamId,
1174
+ statusError: asErrorMessage(error),
1175
+ }));
1147
1176
  return {
1148
1177
  streamId: record.streamId,
1149
1178
  buyer: record.buyer,
@@ -1154,6 +1183,9 @@ export function createVerifiedStreamMcpServer(
1154
1183
  maxDurationSeconds: record.maxDurationSeconds,
1155
1184
  openedAtSeconds: record.openedAtSeconds,
1156
1185
  mandateId: record.mandateId,
1186
+ ...(record.nonRefundableUsdc === undefined
1187
+ ? {}
1188
+ : { nonRefundableUsdc: record.nonRefundableUsdc.toString() }),
1157
1189
  status,
1158
1190
  };
1159
1191
  }),