@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
@@ -1,4 +1,8 @@
1
- import { streamEscrowV2Abi } from "@absol-labs/shared";
1
+ import {
2
+ streamEscrowV2Abi,
3
+ x402UpfrontStreamOpenerAbi,
4
+ x402UpfrontStreamOpenerFactoryAbi,
5
+ } from "@absol-labs/shared";
2
6
  import type {
3
7
  FacilitatorContext,
4
8
  Network,
@@ -39,15 +43,30 @@ import type { OperatorSignedListingResolver } from "./listing-resolver.js";
39
43
  import { RelayerQueue } from "./relayer-queue.js";
40
44
  import {
41
45
  chainIdFromNetwork,
46
+ assertValidUpfrontTerms,
42
47
  maxEarnable,
43
48
  OPENER_EXTRA_KEY,
44
49
  OPENER_SCHEME,
50
+ OPENER_TERMS_VERSION,
45
51
  parseUintString,
46
52
  termsFromExtra,
47
53
  termsTuple,
54
+ upfrontRefundable,
55
+ upfrontTermsTuple,
56
+ type OpenerKind,
48
57
  type OpenerTerms,
58
+ type UpfrontOpenerTerms,
49
59
  } from "./terms.js";
50
60
 
61
+ /**
62
+ * What one checked payment settles: the factory's kind and its terms. An
63
+ * `"upfront"` payment carries the 6-field terms (price + non-refundable
64
+ * upfrontAmount); a `"stream"` payment the 4-field ones.
65
+ */
66
+ export type OpenerSettlementPlan =
67
+ | { readonly kind: "stream"; readonly terms: OpenerTerms }
68
+ | { readonly kind: "upfront"; readonly terms: UpfrontOpenerTerms };
69
+
51
70
  /**
52
71
  * `MetrikOpenerExactScheme` — an x402 v2 `exact` facilitator scheme that
53
72
  * settles every payment INTO a `StreamEscrowV2` stream through the ownerless
@@ -104,6 +123,20 @@ import {
104
123
  *
105
124
  * Relayer submissions (simulate + write) are serialized so concurrent payers
106
125
  * never race for the relayer's account nonce.
126
+ *
127
+ * ## The upfront ("minimum charge") opener kind
128
+ *
129
+ * With `upfrontFactoryAddress` set, a quote may also name the
130
+ * `X402UpfrontStreamOpenerFactory` (metrik-contracts#54). Its 6-field terms
131
+ * add `price` and a non-refundable `upfrontAmount`; the kind is decided by
132
+ * WHICH configured factory the quote names, never by the wire fields. Verify
133
+ * additionally requires the factory's own `validateTerms` rules,
134
+ * `price - upfrontAmount <= rate * maxDuration`, `amount == price` and
135
+ * `payTo == upfrontFactory.predict(terms)`, and simulates the 6-tuple
136
+ * `deployAndSettle`. Settle succeeds only on a receipt that shows exactly the
137
+ * signed split (see {@link MetrikOpenerExactScheme.upfrontSettlement}); an
138
+ * upfront-only payment (`upfrontAmount == price`) opens no stream and is
139
+ * reported with `streamId: null`.
107
140
  */
108
141
 
109
142
  /** Reasons this scheme adds to the stock `exact` ones. All fail closed. */
@@ -156,6 +189,19 @@ export interface MetrikOpenerExactSchemeConfig {
156
189
  readonly network: Network;
157
190
  /** The deployed `X402StreamOpenerFactory`. */
158
191
  readonly factoryAddress: Address;
192
+ /**
193
+ * The deployed `X402UpfrontStreamOpenerFactory` (metrik-contracts#54), bound
194
+ * to the SAME escrow. When set, upfront ("minimum charge") payments are
195
+ * accepted too and `/supported` advertises it as `upfrontOpener`. Unset = the
196
+ * stream factory only (the pre-upfront behaviour).
197
+ */
198
+ readonly upfrontFactoryAddress?: Address;
199
+ /**
200
+ * The implementation `upfrontFactoryAddress` must report
201
+ * (`factory.implementation()`), e.g. the registry's. Checked by
202
+ * {@link MetrikOpenerExactScheme.init} when given.
203
+ */
204
+ readonly upfrontImplementation?: Address;
159
205
  /** The `StreamEscrowV2` the factory must point at (checked by {@link init}). */
160
206
  readonly escrowAddress: Address;
161
207
  /** Settlement tokens this facilitator accepts. Must contain `factory.usdc()`. */
@@ -211,7 +257,7 @@ type LedgerEntry =
211
257
  readonly payload: PaymentPayload;
212
258
  readonly requirements: PaymentRequirements;
213
259
  readonly auth: ExactEIP3009Payload & { signature: Hex };
214
- readonly terms: OpenerTerms;
260
+ readonly plan: OpenerSettlementPlan;
215
261
  readonly opener: Address;
216
262
  }
217
263
  | {
@@ -227,6 +273,19 @@ export interface OpenerFacilitatorBinding {
227
273
  readonly escrow: Address;
228
274
  readonly usdc: Address;
229
275
  readonly relayer: Address;
276
+ /** The upfront factory, when configured (same escrow and token). */
277
+ readonly upfront?: {
278
+ readonly factory: Address;
279
+ readonly implementation: Address;
280
+ };
281
+ }
282
+
283
+ /** The upfront opener kind advertised in the `/supported` marker. */
284
+ export interface UpfrontOpenerMarker {
285
+ readonly factory: Address;
286
+ readonly implementation: Address;
287
+ readonly escrow: Address;
288
+ readonly termsVersion: typeof OPENER_TERMS_VERSION.upfront;
230
289
  }
231
290
 
232
291
  /**
@@ -251,6 +310,12 @@ export interface OpenerFacilitatorMarker {
251
310
  * Absent (read as false) otherwise.
252
311
  */
253
312
  readonly refundRelay?: true;
313
+ /**
314
+ * Present when this facilitator also settles the UPFRONT ("minimum charge")
315
+ * opener kind. `factory` above stays the stream factory, so a gateway that
316
+ * predates the upfront kind reads the marker exactly as before.
317
+ */
318
+ readonly upfrontOpener?: UpfrontOpenerMarker;
254
319
  }
255
320
 
256
321
  export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
@@ -276,6 +341,22 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
276
341
  if (!isAddress(config.escrowAddress)) {
277
342
  throw new Error("escrowAddress must be an EVM address");
278
343
  }
344
+ if (
345
+ config.upfrontFactoryAddress !== undefined &&
346
+ (!isAddress(config.upfrontFactoryAddress) ||
347
+ getAddress(config.upfrontFactoryAddress) ===
348
+ getAddress(config.factoryAddress))
349
+ ) {
350
+ throw new Error(
351
+ "upfrontFactoryAddress must be an EVM address other than factoryAddress",
352
+ );
353
+ }
354
+ if (
355
+ config.upfrontImplementation !== undefined &&
356
+ !isAddress(config.upfrontImplementation)
357
+ ) {
358
+ throw new Error("upfrontImplementation must be an EVM address");
359
+ }
279
360
  if (config.assetAllowlist.length === 0) {
280
361
  throw new Error("assetAllowlist must not be empty");
281
362
  }
@@ -372,12 +453,65 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
372
453
  `factory settlement token ${factoryUsdc} is not on the asset allowlist`,
373
454
  );
374
455
  }
456
+ let upfront: OpenerFacilitatorBinding["upfront"];
457
+ if (this.config.upfrontFactoryAddress !== undefined) {
458
+ const upfrontFactory = getAddress(this.config.upfrontFactoryAddress);
459
+ const upfrontCode = await publicClient.getCode({
460
+ address: upfrontFactory,
461
+ });
462
+ if (upfrontCode === undefined || upfrontCode === "0x") {
463
+ throw new Error(
464
+ `no contract at upfront factory address ${upfrontFactory}`,
465
+ );
466
+ }
467
+ const [upEscrow, upUsdc, upImplementation] = await Promise.all([
468
+ publicClient.readContract({
469
+ address: upfrontFactory,
470
+ abi: x402UpfrontStreamOpenerFactoryAbi,
471
+ functionName: "escrow",
472
+ }),
473
+ publicClient.readContract({
474
+ address: upfrontFactory,
475
+ abi: x402UpfrontStreamOpenerFactoryAbi,
476
+ functionName: "usdc",
477
+ }),
478
+ publicClient.readContract({
479
+ address: upfrontFactory,
480
+ abi: x402UpfrontStreamOpenerFactoryAbi,
481
+ functionName: "implementation",
482
+ }),
483
+ ]);
484
+ if (getAddress(upEscrow) !== getAddress(escrowAddress)) {
485
+ throw new Error(
486
+ `upfront factory escrow ${upEscrow} is not the configured escrow ${escrowAddress}`,
487
+ );
488
+ }
489
+ if (getAddress(upUsdc) !== getAddress(factoryUsdc)) {
490
+ throw new Error(
491
+ `upfront factory token ${upUsdc} is not the stream factory's ${factoryUsdc}`,
492
+ );
493
+ }
494
+ if (
495
+ this.config.upfrontImplementation !== undefined &&
496
+ getAddress(upImplementation) !==
497
+ getAddress(this.config.upfrontImplementation)
498
+ ) {
499
+ throw new Error(
500
+ `upfront factory implementation ${upImplementation} is not the expected ${this.config.upfrontImplementation}`,
501
+ );
502
+ }
503
+ upfront = {
504
+ factory: upfrontFactory,
505
+ implementation: getAddress(upImplementation),
506
+ };
507
+ }
375
508
  this.binding = {
376
509
  chainId: this.chainId,
377
510
  factory: getAddress(factoryAddress),
378
511
  escrow: getAddress(factoryEscrow),
379
512
  usdc: getAddress(factoryUsdc),
380
513
  relayer: this.config.walletClient.account.address,
514
+ ...(upfront === undefined ? {} : { upfront }),
381
515
  };
382
516
  return this.binding;
383
517
  }
@@ -399,6 +533,16 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
399
533
  ...(this.config.advertiseRefundRelay === true
400
534
  ? { refundRelay: true as const }
401
535
  : {}),
536
+ ...(binding.upfront === undefined
537
+ ? {}
538
+ : {
539
+ upfrontOpener: {
540
+ factory: binding.upfront.factory,
541
+ implementation: binding.upfront.implementation,
542
+ escrow: binding.escrow,
543
+ termsVersion: OPENER_TERMS_VERSION.upfront,
544
+ },
545
+ }),
402
546
  };
403
547
  return { [OPENER_EXTRA_KEY]: marker };
404
548
  }
@@ -500,7 +644,7 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
500
644
  await this.awaitSettlement(
501
645
  existing.hash,
502
646
  existing.auth,
503
- existing.terms,
647
+ existing.plan,
504
648
  existing.opener,
505
649
  network,
506
650
  ),
@@ -536,10 +680,10 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
536
680
  return refusal(checked.response, network, payer);
537
681
  }
538
682
 
539
- const { auth, terms, opener } = checked;
683
+ const { auth, plan, opener } = checked;
540
684
  let hash: Hex;
541
685
  try {
542
- hash = await this.submitSerialized(auth, terms);
686
+ hash = await this.submitSerialized(auth, plan);
543
687
  } catch (error) {
544
688
  this.ledger.delete(key);
545
689
  return failure(
@@ -555,8 +699,8 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
555
699
  payload,
556
700
  requirements,
557
701
  validBefore,
558
- await this.awaitSettlement(hash, auth, terms, opener, network),
559
- { auth, terms, opener },
702
+ await this.awaitSettlement(hash, auth, plan, opener, network),
703
+ { auth, plan, opener },
560
704
  );
561
705
  } catch (error) {
562
706
  // Unknown state after a possible broadcast: keep the authorization
@@ -585,7 +729,7 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
585
729
  result: SettleResponse,
586
730
  context: {
587
731
  readonly auth: ExactEIP3009Payload & { signature: Hex };
588
- readonly terms: OpenerTerms;
732
+ readonly plan: OpenerSettlementPlan;
589
733
  readonly opener: Address;
590
734
  },
591
735
  ): SettleResponse {
@@ -606,7 +750,7 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
606
750
  payload,
607
751
  requirements,
608
752
  auth: context.auth,
609
- terms: context.terms,
753
+ plan: context.plan,
610
754
  opener: context.opener,
611
755
  });
612
756
  return result;
@@ -633,16 +777,10 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
633
777
  */
634
778
  private submitSerialized(
635
779
  auth: ExactEIP3009Payload & { signature: Hex },
636
- terms: OpenerTerms,
780
+ plan: OpenerSettlementPlan,
637
781
  ): Promise<Hex> {
638
782
  const run = async (): Promise<Hex> => {
639
- const { request } = await this.config.publicClient.simulateContract({
640
- account: this.config.walletClient.account,
641
- address: this.config.factoryAddress,
642
- abi: x402StreamOpenerFactoryAbi,
643
- functionName: "deployAndSettle",
644
- args: [termsTuple(terms), authorizationTuple(auth)],
645
- });
783
+ const { request } = await this.simulateDeployAndSettle(auth, plan);
646
784
  return this.config.walletClient.writeContract({
647
785
  ...request,
648
786
  chain: this.config.walletClient.chain ?? null,
@@ -680,10 +818,41 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
680
818
  }
681
819
  }
682
820
 
821
+ /**
822
+ * `eth_call` (simulate) `deployAndSettle` on the factory of the plan's kind:
823
+ * the 4-tuple stream factory, or the 6-tuple upfront factory.
824
+ */
825
+ private async simulateDeployAndSettle(
826
+ auth: ExactEIP3009Payload & { signature: Hex },
827
+ plan: OpenerSettlementPlan,
828
+ ) {
829
+ const { publicClient, walletClient } = this.config;
830
+ if (plan.kind === "upfront") {
831
+ const factory = this.binding?.upfront?.factory;
832
+ if (factory === undefined) {
833
+ throw new Error("the upfront opener factory is not configured");
834
+ }
835
+ return await publicClient.simulateContract({
836
+ account: walletClient.account,
837
+ address: factory,
838
+ abi: x402UpfrontStreamOpenerFactoryAbi,
839
+ functionName: "deployAndSettle",
840
+ args: [upfrontTermsTuple(plan.terms), authorizationTuple(auth)],
841
+ });
842
+ }
843
+ return await publicClient.simulateContract({
844
+ account: walletClient.account,
845
+ address: this.config.factoryAddress,
846
+ abi: x402StreamOpenerFactoryAbi,
847
+ functionName: "deployAndSettle",
848
+ args: [termsTuple(plan.terms), authorizationTuple(auth)],
849
+ });
850
+ }
851
+
683
852
  private async awaitSettlement(
684
853
  hash: Hex,
685
854
  auth: ExactEIP3009Payload & { signature: Hex },
686
- terms: OpenerTerms,
855
+ plan: OpenerSettlementPlan,
687
856
  opener: Address,
688
857
  network: Network,
689
858
  ): Promise<SettleResponse> {
@@ -714,12 +883,24 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
714
883
  hash,
715
884
  );
716
885
  }
886
+ if (plan.kind === "upfront") {
887
+ return this.upfrontSettlement(
888
+ receipt.logs,
889
+ hash,
890
+ auth,
891
+ plan.terms,
892
+ opener,
893
+ network,
894
+ );
895
+ }
896
+ const terms = plan.terms;
717
897
  const opened = findStreamOpened(
718
898
  receipt.logs,
719
899
  opener,
720
900
  getAddress(this.config.escrowAddress),
721
901
  auth,
722
902
  terms,
903
+ BigInt(auth.authorization.value),
723
904
  );
724
905
  if (opened === undefined) {
725
906
  return failure(
@@ -750,6 +931,104 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
750
931
  };
751
932
  }
752
933
 
934
+ /**
935
+ * The success response of a mined UPFRONT settlement, or a mismatch failure.
936
+ * The receipt must show exactly what the signed terms imply:
937
+ *
938
+ * - `upfrontAmount > 0`: the opener's `UpfrontCharged` for this payer, nonce
939
+ * and operator, for exactly `upfrontAmount`, naming the stream it opened
940
+ * (or `bytes32(0)` when none was);
941
+ * - `price > upfrontAmount`: the escrow's `StreamOpened` (buyer = opener) and
942
+ * the opener's `StreamOpenedFor` with `deposit = price - upfrontAmount`;
943
+ * - `upfrontAmount == price` (upfront-only): NO stream at all. Success, with
944
+ * `streamId: null`: nothing was escrowed and there is nothing to refund.
945
+ *
946
+ * The reported `upfrontAmount` / `upfrontFee` are read from `UpfrontCharged`.
947
+ */
948
+ /** @internal */
949
+ private upfrontSettlement(
950
+ logs: readonly Log[],
951
+ hash: Hex,
952
+ auth: ExactEIP3009Payload & { signature: Hex },
953
+ terms: UpfrontOpenerTerms,
954
+ opener: Address,
955
+ network: Network,
956
+ ): SettleResponse {
957
+ const payer = getAddress(auth.authorization.from);
958
+ const escrow = getAddress(this.config.escrowAddress);
959
+ const refundable = upfrontRefundable(terms);
960
+ const mismatch = (why: string) =>
961
+ failure(
962
+ OpenerInvalidReason.streamEventMismatch,
963
+ network,
964
+ payer,
965
+ `the mined receipt does not show the expected upfront settlement: ${why}`,
966
+ hash,
967
+ );
968
+ const charged = findUpfrontCharged(logs, opener, auth, terms);
969
+ if (terms.upfrontAmount > 0n && charged === undefined) {
970
+ return mismatch(`no UpfrontCharged for ${terms.upfrontAmount}`);
971
+ }
972
+ if (terms.upfrontAmount === 0n && charged !== undefined) {
973
+ return mismatch("an UpfrontCharged for terms without an upfront part");
974
+ }
975
+ let streamId: Hex | null = null;
976
+ let expiresAt: bigint | null = null;
977
+ if (refundable > 0n) {
978
+ const opened = findStreamOpened(
979
+ logs,
980
+ opener,
981
+ escrow,
982
+ auth,
983
+ terms,
984
+ refundable,
985
+ );
986
+ if (opened === undefined) {
987
+ return mismatch(`no stream with deposit ${refundable}`);
988
+ }
989
+ if (
990
+ charged !== undefined &&
991
+ charged.streamId.toLowerCase() !== opened.streamId.toLowerCase()
992
+ ) {
993
+ return mismatch("UpfrontCharged names another stream");
994
+ }
995
+ streamId = opened.streamId;
996
+ expiresAt = opened.expiresAt;
997
+ } else {
998
+ // Upfront-only: the opener must have opened nothing.
999
+ if (charged === undefined || !/^0x0{64}$/i.test(charged.streamId)) {
1000
+ return mismatch("an upfront-only payment must report streamId 0");
1001
+ }
1002
+ if (
1003
+ findStreamOpened(logs, opener, escrow, auth, terms, undefined) !==
1004
+ undefined
1005
+ ) {
1006
+ return mismatch("an upfront-only payment opened a stream");
1007
+ }
1008
+ }
1009
+ return {
1010
+ success: true,
1011
+ transaction: hash,
1012
+ network,
1013
+ payer,
1014
+ amount: auth.authorization.value,
1015
+ extra: {
1016
+ [OPENER_EXTRA_KEY]: {
1017
+ // `null` (not bytes32(0)) for an upfront-only payment: no stream.
1018
+ streamId,
1019
+ opener,
1020
+ escrow,
1021
+ // The refundable, metered part — the most any refund can return.
1022
+ deposit: refundable.toString(),
1023
+ ratePerSecond: terms.ratePerSecond.toString(),
1024
+ ...(expiresAt === null ? {} : { expiresAt: Number(expiresAt) }),
1025
+ upfrontAmount: (charged?.amount ?? 0n).toString(),
1026
+ upfrontFee: (charged?.fee ?? 0n).toString(),
1027
+ },
1028
+ },
1029
+ };
1030
+ }
1031
+
753
1032
  /** Every Metrik check plus the stock verifier. Cheapest, most local first. */
754
1033
  private async check(
755
1034
  payload: PaymentPayload,
@@ -759,7 +1038,7 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
759
1038
  | {
760
1039
  readonly ok: true;
761
1040
  readonly auth: ExactEIP3009Payload & { signature: Hex };
762
- readonly terms: OpenerTerms;
1041
+ readonly plan: OpenerSettlementPlan;
763
1042
  readonly opener: Address;
764
1043
  }
765
1044
  | { readonly ok: false; readonly response: VerifyResponse }
@@ -816,16 +1095,59 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
816
1095
  return refuse(OpenerInvalidReason.assetNotAllowed);
817
1096
  }
818
1097
 
819
- let terms: OpenerTerms;
820
- let factory: Address;
1098
+ let parsed: ReturnType<typeof termsFromExtra>;
821
1099
  try {
822
- ({ terms, factory } = termsFromExtra(requirements.extra));
1100
+ parsed = termsFromExtra(requirements.extra);
823
1101
  } catch (error) {
824
1102
  return refuse(OpenerInvalidReason.invalidTerms, describe(error));
825
1103
  }
826
- if (factory !== binding.factory) {
1104
+ const { terms, factory } = parsed;
1105
+ // Only a factory this facilitator is bound to (and proved in init) is
1106
+ // ever settled; its kind decides the terms shape — never the wire fields.
1107
+ let kind: OpenerKind;
1108
+ if (factory === binding.factory) {
1109
+ kind = "stream";
1110
+ } else if (
1111
+ binding.upfront !== undefined &&
1112
+ factory === binding.upfront.factory
1113
+ ) {
1114
+ kind = "upfront";
1115
+ } else {
827
1116
  return refuse(OpenerInvalidReason.factoryMismatch);
828
1117
  }
1118
+ if (
1119
+ parsed.termsVersion !== undefined &&
1120
+ parsed.termsVersion !== OPENER_TERMS_VERSION[kind]
1121
+ ) {
1122
+ return refuse(
1123
+ OpenerInvalidReason.invalidTerms,
1124
+ `termsVersion ${parsed.termsVersion} does not match the ${kind} opener factory (${OPENER_TERMS_VERSION[kind]})`,
1125
+ );
1126
+ }
1127
+ let plan: OpenerSettlementPlan;
1128
+ if (kind === "upfront") {
1129
+ if (parsed.upfront === undefined) {
1130
+ return refuse(
1131
+ OpenerInvalidReason.invalidTerms,
1132
+ "the upfront opener factory needs price and upfrontAmount in the terms",
1133
+ );
1134
+ }
1135
+ const upfrontTerms: UpfrontOpenerTerms = { ...terms, ...parsed.upfront };
1136
+ try {
1137
+ assertValidUpfrontTerms(upfrontTerms);
1138
+ } catch (error) {
1139
+ return refuse(OpenerInvalidReason.invalidTerms, describe(error));
1140
+ }
1141
+ plan = { kind, terms: upfrontTerms };
1142
+ } else {
1143
+ if (parsed.upfront !== undefined) {
1144
+ return refuse(
1145
+ OpenerInvalidReason.invalidTerms,
1146
+ "price/upfrontAmount are not terms of the stream opener factory (its payTo does not commit to them)",
1147
+ );
1148
+ }
1149
+ plan = { kind, terms };
1150
+ }
829
1151
 
830
1152
  let amount: bigint;
831
1153
  try {
@@ -842,16 +1164,41 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
842
1164
  `amount ${amount} is below this facilitator's minimum ${this.minAmount}`,
843
1165
  );
844
1166
  }
845
- if (amount > maxEarnable(terms)) {
1167
+ if (plan.kind === "upfront") {
1168
+ // The opener accepts exactly its price; anything else reverts on-chain.
1169
+ if (amount !== plan.terms.price) {
1170
+ return refuse(
1171
+ OpenerInvalidReason.invalidAmount,
1172
+ `amount ${amount} is not the upfront opener's fixed price ${plan.terms.price}`,
1173
+ );
1174
+ }
1175
+ } else if (amount > maxEarnable(terms)) {
846
1176
  return refuse(OpenerInvalidReason.amountExceedsTerms);
847
1177
  }
848
1178
 
849
- const predicted = await this.config.publicClient.readContract({
850
- address: binding.factory,
851
- abi: x402StreamOpenerFactoryAbi,
852
- functionName: "predict",
853
- args: [termsTuple(terms)],
854
- });
1179
+ let predicted: Address;
1180
+ try {
1181
+ predicted =
1182
+ plan.kind === "upfront"
1183
+ ? await this.config.publicClient.readContract({
1184
+ address: binding.upfront?.factory as Address,
1185
+ abi: x402UpfrontStreamOpenerFactoryAbi,
1186
+ functionName: "predict",
1187
+ args: [upfrontTermsTuple(plan.terms)],
1188
+ })
1189
+ : await this.config.publicClient.readContract({
1190
+ address: binding.factory,
1191
+ abi: x402StreamOpenerFactoryAbi,
1192
+ functionName: "predict",
1193
+ args: [termsTuple(terms)],
1194
+ });
1195
+ } catch (error) {
1196
+ // predict reverts InvalidTerms for terms no transaction can deploy.
1197
+ if (revertReason(error) === "InvalidTerms") {
1198
+ return refuse(OpenerInvalidReason.invalidTerms, "InvalidTerms");
1199
+ }
1200
+ throw error;
1201
+ }
855
1202
  if (
856
1203
  !isAddress(requirements.payTo) ||
857
1204
  getAddress(requirements.payTo) !== getAddress(predicted)
@@ -880,18 +1227,12 @@ export class MetrikOpenerExactScheme implements SchemeNetworkFacilitator {
880
1227
 
881
1228
  const signed = auth as ExactEIP3009Payload & { signature: Hex };
882
1229
  try {
883
- await this.config.publicClient.simulateContract({
884
- account: this.config.walletClient.account,
885
- address: binding.factory,
886
- abi: x402StreamOpenerFactoryAbi,
887
- functionName: "deployAndSettle",
888
- args: [termsTuple(terms), authorizationTuple(signed)],
889
- });
1230
+ await this.simulateDeployAndSettle(signed, plan);
890
1231
  } catch (error) {
891
1232
  return refuse(OpenerInvalidReason.simulationFailed, revertReason(error));
892
1233
  }
893
1234
 
894
- return { ok: true, auth: signed, terms, opener: getAddress(predicted) };
1235
+ return { ok: true, auth: signed, plan, opener: getAddress(predicted) };
895
1236
  }
896
1237
  }
897
1238
 
@@ -925,8 +1266,9 @@ function findStreamOpened(
925
1266
  escrow: Address,
926
1267
  auth: ExactEIP3009Payload,
927
1268
  terms: OpenerTerms,
1269
+ /** Expected stream deposit; `undefined` matches ANY deposit (an absence check). */
1270
+ deposit: bigint | undefined,
928
1271
  ): { readonly streamId: Hex; readonly expiresAt: bigint } | undefined {
929
- const value = BigInt(auth.authorization.value);
930
1272
  const payer = getAddress(auth.authorization.from);
931
1273
  const escrowStreams = new Map<string, bigint>();
932
1274
  for (const log of logs) {
@@ -951,7 +1293,7 @@ function findStreamOpened(
951
1293
  getAddress(args.buyer) === opener &&
952
1294
  getAddress(args.operator) === getAddress(terms.operator) &&
953
1295
  args.serviceRef.toLowerCase() === terms.serviceRef.toLowerCase() &&
954
- args.deposit === value &&
1296
+ (deposit === undefined || args.deposit === deposit) &&
955
1297
  args.ratePerSecond === terms.ratePerSecond
956
1298
  ) {
957
1299
  escrowStreams.set(args.streamId.toLowerCase(), BigInt(args.expiresAt));
@@ -976,7 +1318,7 @@ function findStreamOpened(
976
1318
  getAddress(args.payer) === payer &&
977
1319
  args.authorizationNonce.toLowerCase() ===
978
1320
  auth.authorization.nonce.toLowerCase() &&
979
- args.deposit === value
1321
+ (deposit === undefined || args.deposit === deposit)
980
1322
  ) {
981
1323
  return { streamId: args.streamId, expiresAt };
982
1324
  }
@@ -987,6 +1329,54 @@ function findStreamOpened(
987
1329
  return undefined;
988
1330
  }
989
1331
 
1332
+ /**
1333
+ * The opener's `UpfrontCharged` for this payer, authorization nonce and
1334
+ * operator, for exactly the terms' `upfrontAmount` (its `fee` is the protocol
1335
+ * fee taken out of it). `undefined` when the receipt carries none.
1336
+ */
1337
+ function findUpfrontCharged(
1338
+ logs: readonly Log[],
1339
+ opener: Address,
1340
+ auth: ExactEIP3009Payload,
1341
+ terms: UpfrontOpenerTerms,
1342
+ ):
1343
+ | { readonly streamId: Hex; readonly amount: bigint; readonly fee: bigint }
1344
+ | undefined {
1345
+ const payer = getAddress(auth.authorization.from);
1346
+ for (const log of logs) {
1347
+ if (getAddress(log.address) !== opener) continue;
1348
+ try {
1349
+ const decoded = decodeEventLog({
1350
+ abi: x402UpfrontStreamOpenerAbi,
1351
+ data: log.data,
1352
+ topics: log.topics,
1353
+ });
1354
+ if (decoded.eventName !== "UpfrontCharged") continue;
1355
+ const args = decoded.args as unknown as {
1356
+ authorizationNonce: Hex;
1357
+ payer: Address;
1358
+ operator: Address;
1359
+ amount: bigint;
1360
+ fee: bigint;
1361
+ streamId: Hex;
1362
+ };
1363
+ if (
1364
+ getAddress(args.payer) === payer &&
1365
+ args.authorizationNonce.toLowerCase() ===
1366
+ auth.authorization.nonce.toLowerCase() &&
1367
+ getAddress(args.operator) === getAddress(terms.operator) &&
1368
+ args.amount === terms.upfrontAmount &&
1369
+ args.fee <= args.amount
1370
+ ) {
1371
+ return { streamId: args.streamId, amount: args.amount, fee: args.fee };
1372
+ }
1373
+ } catch {
1374
+ continue;
1375
+ }
1376
+ }
1377
+ return undefined;
1378
+ }
1379
+
990
1380
  /**
991
1381
  * Reads an opener facilitator marker out of a `/supported` kind's `extra`, or
992
1382
  * `undefined` when the facilitator does not advertise opener settlement.
@@ -1023,6 +1413,34 @@ export function readOpenerFacilitatorMarker(
1023
1413
  minAmount: marker.minAmount,
1024
1414
  receiptTimeoutSeconds: marker.receiptTimeoutSeconds,
1025
1415
  ...(marker.refundRelay === true ? { refundRelay: true as const } : {}),
1416
+ ...(readUpfrontMarker(marker.upfrontOpener) ?? {}),
1417
+ };
1418
+ }
1419
+
1420
+ /** The optional `upfrontOpener` entry of a marker; a malformed one is ignored (absent). */
1421
+ function readUpfrontMarker(
1422
+ raw: unknown,
1423
+ ): { readonly upfrontOpener: UpfrontOpenerMarker } | undefined {
1424
+ if (typeof raw !== "object" || raw === null) return undefined;
1425
+ const entry = raw as Record<string, unknown>;
1426
+ if (
1427
+ typeof entry.factory !== "string" ||
1428
+ !isAddress(entry.factory) ||
1429
+ typeof entry.implementation !== "string" ||
1430
+ !isAddress(entry.implementation) ||
1431
+ typeof entry.escrow !== "string" ||
1432
+ !isAddress(entry.escrow) ||
1433
+ entry.termsVersion !== OPENER_TERMS_VERSION.upfront
1434
+ ) {
1435
+ return undefined;
1436
+ }
1437
+ return {
1438
+ upfrontOpener: {
1439
+ factory: getAddress(entry.factory),
1440
+ implementation: getAddress(entry.implementation),
1441
+ escrow: getAddress(entry.escrow),
1442
+ termsVersion: OPENER_TERMS_VERSION.upfront,
1443
+ },
1026
1444
  };
1027
1445
  }
1028
1446