@absol-labs/agent 0.10.0 → 0.11.0

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 (141) hide show
  1. package/README.md +107 -23
  2. package/dist/capability/invocation-capability.d.ts +18 -6
  3. package/dist/capability/invocation-capability.d.ts.map +1 -1
  4. package/dist/capability/invocation-capability.js.map +1 -1
  5. package/dist/cli/commands/claim.d.ts.map +1 -1
  6. package/dist/cli/commands/claim.js +20 -4
  7. package/dist/cli/commands/claim.js.map +1 -1
  8. package/dist/cli/commands/close.d.ts.map +1 -1
  9. package/dist/cli/commands/close.js +16 -4
  10. package/dist/cli/commands/close.js.map +1 -1
  11. package/dist/cli/commands/invoke.d.ts.map +1 -1
  12. package/dist/cli/commands/invoke.js +1 -0
  13. package/dist/cli/commands/invoke.js.map +1 -1
  14. package/dist/cli/commands/open.d.ts.map +1 -1
  15. package/dist/cli/commands/open.js +63 -11
  16. package/dist/cli/commands/open.js.map +1 -1
  17. package/dist/cli/commands/reclaim-unverified.d.ts.map +1 -1
  18. package/dist/cli/commands/reclaim-unverified.js +12 -4
  19. package/dist/cli/commands/reclaim-unverified.js.map +1 -1
  20. package/dist/cli/commands/reclaim.d.ts.map +1 -1
  21. package/dist/cli/commands/reclaim.js +16 -5
  22. package/dist/cli/commands/reclaim.js.map +1 -1
  23. package/dist/cli/commands/streams.js +1 -1
  24. package/dist/cli/commands/streams.js.map +1 -1
  25. package/dist/cli/deps.d.ts +53 -7
  26. package/dist/cli/deps.d.ts.map +1 -1
  27. package/dist/cli/deps.js +65 -15
  28. package/dist/cli/deps.js.map +1 -1
  29. package/dist/cli/privy.d.ts +83 -0
  30. package/dist/cli/privy.d.ts.map +1 -0
  31. package/dist/cli/privy.js +230 -0
  32. package/dist/cli/privy.js.map +1 -0
  33. package/dist/cli/spend-policy.d.ts +60 -0
  34. package/dist/cli/spend-policy.d.ts.map +1 -0
  35. package/dist/cli/spend-policy.js +65 -0
  36. package/dist/cli/spend-policy.js.map +1 -0
  37. package/dist/cli/wallet.d.ts +108 -39
  38. package/dist/cli/wallet.d.ts.map +1 -1
  39. package/dist/cli/wallet.js +108 -43
  40. package/dist/cli/wallet.js.map +1 -1
  41. package/dist/gateway/caller-auth-gateway.d.ts +38 -3
  42. package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
  43. package/dist/gateway/caller-auth-gateway.js +25 -1
  44. package/dist/gateway/caller-auth-gateway.js.map +1 -1
  45. package/dist/gateway/http-server.d.ts +53 -0
  46. package/dist/gateway/http-server.d.ts.map +1 -1
  47. package/dist/gateway/http-server.js +189 -4
  48. package/dist/gateway/http-server.js.map +1 -1
  49. package/dist/gateway/x402-mode.d.ts +123 -0
  50. package/dist/gateway/x402-mode.d.ts.map +1 -0
  51. package/dist/gateway/x402-mode.js +212 -0
  52. package/dist/gateway/x402-mode.js.map +1 -0
  53. package/dist/index.d.ts +6 -4
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +8 -3
  56. package/dist/index.js.map +1 -1
  57. package/dist/mandates/env.d.ts +3 -2
  58. package/dist/mandates/env.d.ts.map +1 -1
  59. package/dist/mandates/env.js +3 -2
  60. package/dist/mandates/env.js.map +1 -1
  61. package/dist/mcp/server.d.ts +49 -0
  62. package/dist/mcp/server.d.ts.map +1 -1
  63. package/dist/mcp/server.js +513 -24
  64. package/dist/mcp/server.js.map +1 -1
  65. package/dist/mcp/service-invoker.d.ts +97 -0
  66. package/dist/mcp/service-invoker.d.ts.map +1 -0
  67. package/dist/mcp/service-invoker.js +167 -0
  68. package/dist/mcp/service-invoker.js.map +1 -0
  69. package/dist/sdk/invoke.d.ts +19 -4
  70. package/dist/sdk/invoke.d.ts.map +1 -1
  71. package/dist/sdk/invoke.js +69 -5
  72. package/dist/sdk/invoke.js.map +1 -1
  73. package/dist/x402/delivery-oracle.d.ts +108 -0
  74. package/dist/x402/delivery-oracle.d.ts.map +1 -0
  75. package/dist/x402/delivery-oracle.js +174 -0
  76. package/dist/x402/delivery-oracle.js.map +1 -0
  77. package/dist/x402/facilitator-server-entry.d.ts +3 -0
  78. package/dist/x402/facilitator-server-entry.d.ts.map +1 -0
  79. package/dist/x402/facilitator-server-entry.js +27 -0
  80. package/dist/x402/facilitator-server-entry.js.map +1 -0
  81. package/dist/x402/facilitator-server.d.ts +73 -0
  82. package/dist/x402/facilitator-server.d.ts.map +1 -0
  83. package/dist/x402/facilitator-server.js +305 -0
  84. package/dist/x402/facilitator-server.js.map +1 -0
  85. package/dist/x402/facilitator.d.ts +69 -17
  86. package/dist/x402/facilitator.d.ts.map +1 -1
  87. package/dist/x402/facilitator.js +76 -33
  88. package/dist/x402/facilitator.js.map +1 -1
  89. package/dist/x402/payment-requirements.d.ts +106 -0
  90. package/dist/x402/payment-requirements.d.ts.map +1 -0
  91. package/dist/x402/payment-requirements.js +138 -0
  92. package/dist/x402/payment-requirements.js.map +1 -0
  93. package/dist/x402/relayer.d.ts +41 -0
  94. package/dist/x402/relayer.d.ts.map +1 -0
  95. package/dist/x402/relayer.js +64 -0
  96. package/dist/x402/relayer.js.map +1 -0
  97. package/dist/x402/resource-index.d.ts +75 -0
  98. package/dist/x402/resource-index.d.ts.map +1 -0
  99. package/dist/x402/resource-index.js +126 -0
  100. package/dist/x402/resource-index.js.map +1 -0
  101. package/dist/x402/verified-delivery-facilitator.d.ts +285 -0
  102. package/dist/x402/verified-delivery-facilitator.d.ts.map +1 -0
  103. package/dist/x402/verified-delivery-facilitator.js +423 -0
  104. package/dist/x402/verified-delivery-facilitator.js.map +1 -0
  105. package/dist/zktls/t2-delivery-proof.d.ts +6 -6
  106. package/docs/agent-layer.md +12 -8
  107. package/docs/cli.md +158 -22
  108. package/docs/mcp-hosted.md +4 -3
  109. package/docs/mcp-reference.md +175 -0
  110. package/docs/quickstart.md +18 -4
  111. package/docs/threat-model.md +4 -3
  112. package/docs/x402.md +648 -0
  113. package/package.json +18 -7
  114. package/src/capability/invocation-capability.ts +17 -1
  115. package/src/cli/commands/claim.ts +30 -4
  116. package/src/cli/commands/close.ts +20 -4
  117. package/src/cli/commands/invoke.ts +1 -0
  118. package/src/cli/commands/open.ts +76 -10
  119. package/src/cli/commands/reclaim-unverified.ts +16 -4
  120. package/src/cli/commands/reclaim.ts +22 -5
  121. package/src/cli/commands/streams.ts +1 -1
  122. package/src/cli/deps.ts +115 -24
  123. package/src/cli/privy.ts +396 -0
  124. package/src/cli/spend-policy.ts +139 -0
  125. package/src/cli/wallet.ts +188 -57
  126. package/src/gateway/caller-auth-gateway.ts +77 -6
  127. package/src/gateway/http-server.ts +280 -6
  128. package/src/gateway/x402-mode.ts +350 -0
  129. package/src/index.ts +40 -5
  130. package/src/mandates/env.ts +3 -2
  131. package/src/mcp/server.ts +681 -29
  132. package/src/mcp/service-invoker.ts +285 -0
  133. package/src/sdk/invoke.ts +90 -12
  134. package/src/x402/delivery-oracle.ts +285 -0
  135. package/src/x402/facilitator-server-entry.ts +35 -0
  136. package/src/x402/facilitator-server.ts +445 -0
  137. package/src/x402/facilitator.ts +125 -46
  138. package/src/x402/payment-requirements.ts +231 -0
  139. package/src/x402/relayer.ts +122 -0
  140. package/src/x402/resource-index.ts +228 -0
  141. package/src/x402/verified-delivery-facilitator.ts +744 -0
@@ -0,0 +1,744 @@
1
+ import { getAddress } from "viem";
2
+ import {
3
+ PaymentPayloadSchema,
4
+ PaymentRequirementsSchema,
5
+ type PaymentPayload,
6
+ type PaymentRequirements,
7
+ type SettleResponse,
8
+ type VerifyResponse,
9
+ } from "x402/types";
10
+
11
+ import type {
12
+ DeliveryAssessment,
13
+ DeliveryVerdict,
14
+ DeliveryVerdictReason,
15
+ MetrikDeliveryOracle,
16
+ } from "./delivery-oracle.js";
17
+ import type { MetrikResourceIndex } from "./resource-index.js";
18
+ import {
19
+ X402_NETWORK_BY_CHAIN_ID,
20
+ X402_USDC_BY_CHAIN_ID,
21
+ X402_VERSION,
22
+ } from "./payment-requirements.js";
23
+
24
+ /**
25
+ * Metrik's x402 facilitator: a fully conformant `exact` facilitator that ALSO
26
+ * refuses to settle payment for a service its own oracle says is not delivering.
27
+ *
28
+ * ## Why this is spec-legal
29
+ *
30
+ * `VerifyResponse` is `{ isValid: boolean, invalidReason?, payer? }`. Nothing in
31
+ * the x402 specification constrains WHY a facilitator answers `false`. A
32
+ * facilitator is the party a resource server delegates its "should this payment
33
+ * go through" decision to; Metrik simply knows one more thing than every other
34
+ * facilitator, because it runs a prober.
35
+ *
36
+ * ## What this is NOT
37
+ *
38
+ * **This is strictly weaker buyer protection than a Metrik escrow stream.** A
39
+ * stream holds the buyer's money and releases it only for intervals that
40
+ * verified, so money already spent on a service that stopped delivering is
41
+ * recoverable. This gate holds nothing. It can only prevent a payment that has
42
+ * not happened yet; it can never recover one that has. An `exact` payment that
43
+ * Metrik lets through is gone, exactly as it would be through any other
44
+ * facilitator.
45
+ *
46
+ * And `isValid: true` from this facilitator is NOT an endorsement. It means only
47
+ * "the payment is well-formed and Metrik has no fresh evidence that this service
48
+ * is failing". The gate subtracts; it never adds.
49
+ *
50
+ * ## Scope: only services Metrik actually tracks
51
+ *
52
+ * A resource that maps to no Metrik listing gets the ordinary facilitator
53
+ * answer, unchanged. Refusing payments for services Metrik has no opinion about
54
+ * would not be caution, it would be a broken facilitator.
55
+ */
56
+
57
+ /** What the delivery gate did to a payment. */
58
+ export type MetrikGateOutcome =
59
+ /** The resource is not a Metrik listing — ordinary facilitator behaviour. */
60
+ | "not-applicable"
61
+ /** Fresh evidence the service is delivering; payment allowed to proceed. */
62
+ | "passed"
63
+ /** Fresh evidence the service is failing; payment refused. */
64
+ | "refused"
65
+ /** No fresh evidence; `indeterminatePolicy` decided. */
66
+ | "indeterminate"
67
+ /** The resource's payee is not the operator who signed the listing. */
68
+ | "payee-mismatch"
69
+ /** Metrik could not tell whether the resource is one of its listings. */
70
+ | "registry-unavailable";
71
+
72
+ /** The delivery gate's own verdict, before settlement policy is applied. */
73
+ export interface MetrikGateVerdict {
74
+ readonly gate: MetrikGateOutcome;
75
+ /** Human-readable explanation. See "Where the explanation goes" below. */
76
+ readonly detail: string;
77
+ readonly serviceRef?: string;
78
+ readonly deliveryVerdict?: DeliveryVerdict;
79
+ readonly deliveryReason?: DeliveryVerdictReason;
80
+ readonly lastCheckedAt?: number | null;
81
+ readonly verdictAgeSeconds?: number | null;
82
+ readonly freshnessWindowSeconds?: number;
83
+ }
84
+
85
+ export interface MetrikGateAnnotation extends MetrikGateVerdict {
86
+ /**
87
+ * Whether THIS facilitator would broadcast this payment if asked.
88
+ *
89
+ * Distinct from `isValid`, and deliberately reported on `/verify`: a payment
90
+ * can be perfectly valid and still be one this relayer will not spend its own
91
+ * gas on (see {@link SettlementRefusalReason}). Reporting it up front lets a
92
+ * resource server refuse BEFORE it executes the request, instead of doing the
93
+ * work and then failing to get paid for it.
94
+ */
95
+ readonly settleable: boolean;
96
+ /** Why `settleable` is false. Absent when it is true. */
97
+ readonly settlementRefusal?: SettlementRefusalReason;
98
+ }
99
+
100
+ export const SETTLEMENT_REFUSAL_DETAIL: Readonly<
101
+ Record<SettlementRefusalReason, string>
102
+ > = Object.freeze({
103
+ "asset-not-allowed":
104
+ "the settlement asset is not on this facilitator's allowlist. The relayer signs a call to " +
105
+ "`asset`, so an unrestricted asset would let a caller choose which contract the operator's " +
106
+ "key calls and pays gas for.",
107
+ "network-mismatch":
108
+ "the payload or the requirements name a chain this facilitator does not settle on",
109
+ "zero-amount":
110
+ "a zero-value settlement moves nothing and would only burn the relayer's gas",
111
+ "resource-not-tracked":
112
+ "this relayer only pays gas for resources Metrik tracks, and this resource did not resolve " +
113
+ "to exactly one signed Metrik listing. The payment is not refused — any other facilitator " +
114
+ "can settle it; Metrik is declining to spend its own gas on it.",
115
+ });
116
+
117
+ /**
118
+ * A `VerifyResponse` with Metrik's reasoning attached.
119
+ *
120
+ * ### Where the explanation goes
121
+ *
122
+ * `invalidReason` is a CLOSED enum in the x402 spec and there is no member for
123
+ * "this service is not delivering". Inventing one would break every conformant
124
+ * client that parses the field. So the enum carries the closest honest member
125
+ * (see {@link DELIVERY_REFUSAL_REASON}) and the real explanation travels in a
126
+ * `metrik` object alongside it.
127
+ *
128
+ * `VerifyResponseSchema` is a `strip` (not `strict`) Zod object, so this extra
129
+ * key is spec-tolerated: a conformant client parses the response successfully
130
+ * and simply drops `metrik`. A client that wants the reason can read it off the
131
+ * raw body. Nothing about the standard fields changes.
132
+ */
133
+ export type MetrikVerifyResponse = VerifyResponse & {
134
+ readonly metrik: MetrikGateAnnotation;
135
+ };
136
+
137
+ export type MetrikSettleResponse = SettleResponse & {
138
+ readonly metrik: MetrikGateAnnotation;
139
+ };
140
+
141
+ /**
142
+ * Why this facilitator will not BROADCAST a payment, independent of whether the
143
+ * payment is valid.
144
+ *
145
+ * `/settle` makes the operator's relayer sign and broadcast a call to
146
+ * `paymentRequirements.asset` and pay the gas for it. Every field of
147
+ * `PaymentRequirements` arrives in the request body, and `PaymentRequirementsSchema`
148
+ * accepts ANY `asset` address, ANY `payTo`, `maxAmountRequired: "0"`, and any
149
+ * valid-URL `resource`. The library's `verify` only checks the payload against
150
+ * those same caller-supplied requirements — signature recovers to
151
+ * `authorization.from`, `to === payTo`, window valid, and
152
+ * `balanceOf(from) >= maxAmountRequired` **on the caller-supplied asset**. An
153
+ * attacker signing from their own key to their own address satisfies all of it.
154
+ *
155
+ * Without the checks below, `/settle` is a permissionless "make the relayer
156
+ * broadcast a call to a contract of my choosing" endpoint: deploy something
157
+ * whose `balanceOf` returns a huge number and whose `transferWithAuthorization`
158
+ * burns gas, and drain the relayer until legitimate settlement stops. No funds
159
+ * are stolen — but it is the operator's money, and it induces the operator's key
160
+ * to sign transactions to contracts they never chose.
161
+ *
162
+ * The asset allowlist is the one that removes the arbitrary-contract-call
163
+ * primitive outright; the rest are defence in depth.
164
+ */
165
+ export type SettlementRefusalReason =
166
+ /** `asset` is not an operator-approved settlement token. */
167
+ | "asset-not-allowed"
168
+ /** The payload or the requirements name a chain this facilitator does not settle. */
169
+ | "network-mismatch"
170
+ /** A zero-value settlement moves nothing and only burns the relayer's gas. */
171
+ | "zero-amount"
172
+ /** The relayer only pays gas for resources Metrik actually tracks. */
173
+ | "resource-not-tracked";
174
+
175
+ /**
176
+ * Which resources this facilitator will spend the relayer's gas on.
177
+ *
178
+ * `tracked-resources` (default) is a deliberate ASYMMETRY with the delivery
179
+ * gate's allow-by-default policy, and the asymmetry is the point: an OPINION
180
+ * about a service costs nothing and should be given freely, so `/verify` stays
181
+ * universal and fails open. BROADCASTING costs the operator real gas, so
182
+ * `/settle` fails closed and is scoped to Metrik's own marketplace.
183
+ *
184
+ * This is not a censorship lever. Metrik declining to spend its own gas does
185
+ * not stop anyone being paid — any other facilitator (or the payer's own
186
+ * relayer) can settle the same payment.
187
+ *
188
+ * `any-resource` is for an operator deliberately running a general-purpose
189
+ * facilitator on a relayer they are content to fund for anyone's traffic. The
190
+ * asset allowlist still applies.
191
+ */
192
+ export type SettlementScope = "tracked-resources" | "any-resource";
193
+
194
+ export const DEFAULT_SETTLEMENT_SCOPE: SettlementScope = "tracked-resources";
195
+
196
+ /**
197
+ * The `invalidReason` used when the delivery gate refuses.
198
+ *
199
+ * The enum has no member for "the resource is not delivering", so the choice is
200
+ * between members that are all imperfect. Every payer-side member
201
+ * (`invalid_payload`, `invalid_exact_evm_payload_signature`,
202
+ * `insufficient_funds`) would be an active lie: it sends the developer to debug
203
+ * a wallet that is working correctly. `unexpected_verify_error` is also false —
204
+ * this refusal is deliberate and expected, not an error.
205
+ *
206
+ * `invalid_payment_requirements` is the only member that points at the
207
+ * REQUIREMENTS side of the exchange, which is exactly where the problem is:
208
+ * Metrik will not settle a payment quoted for THIS resource to THIS payee right
209
+ * now. It is the honest member, and the `metrik.detail` string carries the rest.
210
+ */
211
+ export const DELIVERY_REFUSAL_REASON = "invalid_payment_requirements" as const;
212
+
213
+ /** What to do when Metrik has no fresh delivery evidence. */
214
+ export type IndeterminatePolicy = "allow" | "deny";
215
+
216
+ /**
217
+ * Default: `allow`.
218
+ *
219
+ * The standing Metrik rule is buyer-favouring, and it still is — but it applies
220
+ * where Metrik HOLDS the buyer's money and must decide whether to release it.
221
+ * Here Metrik holds nothing, so refusing does not protect a buyer's funds; it
222
+ * only denies them a service they asked to buy, on a rail that works fine
223
+ * without Metrik. Four things make `allow` the right default:
224
+ *
225
+ * 1. **Absent evidence is the normal case, not the exception.** The oracle's
226
+ * `/services` projection is built from ACTIVE STREAMS. A service with no live
227
+ * Metrik stream has no row at all — on the live oracle today that is every
228
+ * service. Failing closed would refuse 100% of payments, which is a broken
229
+ * facilitator rather than a careful one.
230
+ * 2. **Fail-closed is a censorship lever.** If "oracle down" meant "no payments",
231
+ * anyone who could degrade Metrik's oracle could block payments to a
232
+ * competitor listed on it. `allow` removes that lever entirely.
233
+ * 3. **The harms are asymmetric.** A wrong refusal denies a service outright. A
234
+ * wrong allowance passes one `exact` payment, at a price the buyer already
235
+ * agreed to, which by construction is one call's worth.
236
+ * 4. **A pass never claims anything.** `isValid: true` with
237
+ * `gate: "indeterminate"` says "no fresh evidence", and the annotation says
238
+ * so in the response. Nobody has to infer an endorsement that was not made.
239
+ *
240
+ * Set `indeterminatePolicy: "deny"` for a deployment that would rather refuse
241
+ * than proceed without positive fresh evidence. That is a real, supported
242
+ * choice — it is just not a safe default.
243
+ */
244
+ export const DEFAULT_INDETERMINATE_POLICY: IndeterminatePolicy = "allow";
245
+
246
+ /** The payment-validity half of the facilitator (signature, amount, funds, window). */
247
+ export interface PaymentVerifier {
248
+ verify(
249
+ payload: PaymentPayload,
250
+ requirements: PaymentRequirements,
251
+ ): Promise<VerifyResponse>;
252
+ }
253
+
254
+ /** The broadcast half. Requires a funded relayer wallet. */
255
+ export interface PaymentSettler {
256
+ settle(
257
+ payload: PaymentPayload,
258
+ requirements: PaymentRequirements,
259
+ ): Promise<SettleResponse>;
260
+ }
261
+
262
+ export class RelayerNotConfiguredError extends Error {
263
+ constructor() {
264
+ super(
265
+ "this facilitator has no settlement relayer configured, so /settle cannot broadcast. " +
266
+ "Set METRIK_X402_RELAYER_PRIVATE_KEY (and METRIK_X402_RPC_URL) to enable settlement. " +
267
+ "Refusing rather than silently returning a fake success.",
268
+ );
269
+ this.name = "RelayerNotConfiguredError";
270
+ }
271
+ }
272
+
273
+ export interface MetrikVerifiedDeliveryFacilitatorOptions {
274
+ readonly paymentVerifier: PaymentVerifier;
275
+ /** Omit to run a verify-only facilitator: `/settle` then fails loudly. */
276
+ readonly paymentSettler?: PaymentSettler;
277
+ readonly resourceIndex: MetrikResourceIndex;
278
+ readonly deliveryOracle: MetrikDeliveryOracle;
279
+ /** Default {@link DEFAULT_INDETERMINATE_POLICY}. */
280
+ readonly indeterminatePolicy?: IndeterminatePolicy;
281
+ /** Chain this facilitator settles on. Default `84532` (Base Sepolia). */
282
+ readonly chainId?: number;
283
+ /**
284
+ * Token contracts the relayer may be asked to call. Defaults to the single
285
+ * canonical USDC for `chainId`. An empty array is rejected: a settling
286
+ * facilitator with no allowed asset can never settle anything, and silently
287
+ * accepting that would hide a misconfiguration.
288
+ *
289
+ * THIS IS THE CONTROL that stops `/settle` being an arbitrary-contract-call
290
+ * primitive. See {@link SettlementRefusalReason}.
291
+ */
292
+ readonly settlementAssetAllowlist?: readonly string[];
293
+ /** Default {@link DEFAULT_SETTLEMENT_SCOPE}. */
294
+ readonly settlementScope?: SettlementScope;
295
+ }
296
+
297
+ export interface FacilitatorSupported {
298
+ readonly x402Version: number;
299
+ readonly kind: readonly {
300
+ readonly scheme: string;
301
+ readonly networkId: string;
302
+ readonly extra: object;
303
+ }[];
304
+ }
305
+
306
+ export class MetrikVerifiedDeliveryFacilitator {
307
+ private readonly paymentVerifier: PaymentVerifier;
308
+ private readonly paymentSettler: PaymentSettler | undefined;
309
+ private readonly resourceIndex: MetrikResourceIndex;
310
+ private readonly deliveryOracle: MetrikDeliveryOracle;
311
+ private readonly indeterminatePolicy: IndeterminatePolicy;
312
+ private readonly network: string;
313
+ private readonly settlementAssets: ReadonlySet<string>;
314
+ private readonly settlementScope: SettlementScope;
315
+
316
+ constructor(options: MetrikVerifiedDeliveryFacilitatorOptions) {
317
+ const chainId = options.chainId ?? 84532;
318
+ const network = X402_NETWORK_BY_CHAIN_ID[chainId];
319
+ if (network === undefined) {
320
+ throw new Error(
321
+ `x402 v1 has no network name for chain ${chainId} (Metrik settles on Base: 8453, 84532)`,
322
+ );
323
+ }
324
+ this.paymentVerifier = options.paymentVerifier;
325
+ this.paymentSettler = options.paymentSettler;
326
+ this.resourceIndex = options.resourceIndex;
327
+ this.deliveryOracle = options.deliveryOracle;
328
+ this.indeterminatePolicy =
329
+ options.indeterminatePolicy ?? DEFAULT_INDETERMINATE_POLICY;
330
+ this.network = network;
331
+
332
+ const defaultAsset = X402_USDC_BY_CHAIN_ID[chainId]?.address;
333
+ const allowlist: readonly string[] =
334
+ options.settlementAssetAllowlist ??
335
+ (defaultAsset === undefined ? [] : [defaultAsset]);
336
+ if (allowlist.length === 0) {
337
+ throw new Error(
338
+ `no settlement asset allowlist for chain ${chainId} and none supplied. ` +
339
+ "Refusing to construct a facilitator that would either settle nothing or, " +
340
+ "worse, broadcast to a caller-chosen contract.",
341
+ );
342
+ }
343
+ const normalized = new Set<string>();
344
+ for (const candidate of allowlist) {
345
+ let checksummed: string;
346
+ try {
347
+ checksummed = getAddress(candidate as `0x${string}`);
348
+ } catch {
349
+ throw new Error(
350
+ `settlementAssetAllowlist entry is not an EVM address: ${candidate}`,
351
+ );
352
+ }
353
+ normalized.add(checksummed);
354
+ }
355
+ this.settlementAssets = normalized;
356
+ this.settlementScope = options.settlementScope ?? DEFAULT_SETTLEMENT_SCOPE;
357
+ }
358
+
359
+ /** The assets this relayer may be asked to call, for logging at startup. */
360
+ get allowedSettlementAssets(): readonly string[] {
361
+ return [...this.settlementAssets];
362
+ }
363
+
364
+ supported(): FacilitatorSupported {
365
+ return {
366
+ x402Version: X402_VERSION,
367
+ kind: [{ scheme: "exact", networkId: this.network, extra: {} }],
368
+ };
369
+ }
370
+
371
+ /** `POST /verify`. Takes an unvalidated body; never throws on bad input. */
372
+ async verify(body: unknown): Promise<MetrikVerifyResponse> {
373
+ const parsed = parseFacilitatorRequest(body);
374
+ if (parsed === undefined) {
375
+ return {
376
+ isValid: false,
377
+ invalidReason: "invalid_payload",
378
+ metrik: {
379
+ gate: "not-applicable",
380
+ settleable: false,
381
+ detail:
382
+ "request body is not a valid x402 {paymentPayload, paymentRequirements} pair",
383
+ },
384
+ };
385
+ }
386
+ return this.evaluate(parsed.paymentPayload, parsed.paymentRequirements);
387
+ }
388
+
389
+ /**
390
+ * `POST /settle`. Re-runs the full gate before broadcasting: a facilitator
391
+ * must never settle something it would not verify, and `/settle` is reachable
392
+ * without `/verify` ever having been called.
393
+ */
394
+ async settle(body: unknown): Promise<MetrikSettleResponse> {
395
+ const parsed = parseFacilitatorRequest(body);
396
+ if (parsed === undefined) {
397
+ return {
398
+ success: false,
399
+ errorReason: "invalid_payload",
400
+ transaction: "",
401
+ network: this.network as SettleResponse["network"],
402
+ metrik: {
403
+ gate: "not-applicable",
404
+ settleable: false,
405
+ detail:
406
+ "request body is not a valid x402 {paymentPayload, paymentRequirements} pair",
407
+ },
408
+ };
409
+ }
410
+
411
+ // Settlement constraints that need no network round trip run FIRST, so a
412
+ // hostile body never reaches the RPC, the registry or the oracle — let
413
+ // alone the relayer.
414
+ const preflight = this.settlementPreflight(
415
+ parsed.paymentPayload,
416
+ parsed.paymentRequirements,
417
+ );
418
+ if (preflight !== undefined) {
419
+ return this.refuseSettlement(parsed.paymentPayload, preflight, undefined);
420
+ }
421
+
422
+ const gate = await this.evaluate(
423
+ parsed.paymentPayload,
424
+ parsed.paymentRequirements,
425
+ );
426
+ if (!gate.isValid) {
427
+ return {
428
+ success: false,
429
+ ...(gate.invalidReason === undefined
430
+ ? {}
431
+ : { errorReason: gate.invalidReason }),
432
+ transaction: "",
433
+ network: parsed.paymentPayload.network,
434
+ ...(gate.payer === undefined ? {} : { payer: gate.payer }),
435
+ metrik: gate.metrik,
436
+ };
437
+ }
438
+
439
+ if (!gate.metrik.settleable) {
440
+ return this.refuseSettlement(
441
+ parsed.paymentPayload,
442
+ gate.metrik.settlementRefusal ?? "resource-not-tracked",
443
+ gate.metrik,
444
+ );
445
+ }
446
+
447
+ if (this.paymentSettler === undefined) {
448
+ throw new RelayerNotConfiguredError();
449
+ }
450
+ const settled = await this.paymentSettler.settle(
451
+ parsed.paymentPayload,
452
+ parsed.paymentRequirements,
453
+ );
454
+ return { ...settled, metrik: gate.metrik };
455
+ }
456
+
457
+ /**
458
+ * Settlement constraints checkable from the request alone — no RPC, no
459
+ * registry, no oracle. Returns the refusal, or `undefined` if the request
460
+ * clears them.
461
+ */
462
+ private settlementPreflight(
463
+ payload: PaymentPayload,
464
+ requirements: PaymentRequirements,
465
+ ): SettlementRefusalReason | undefined {
466
+ if (
467
+ payload.network !== this.network ||
468
+ requirements.network !== this.network
469
+ ) {
470
+ return "network-mismatch";
471
+ }
472
+ // THE control. Without it the relayer signs a call to whatever contract
473
+ // address the caller puts here. See `SettlementRefusalReason`.
474
+ let asset: string;
475
+ try {
476
+ asset = getAddress(requirements.asset);
477
+ } catch {
478
+ return "asset-not-allowed";
479
+ }
480
+ if (!this.settlementAssets.has(asset)) {
481
+ return "asset-not-allowed";
482
+ }
483
+ let amount: bigint;
484
+ try {
485
+ amount = BigInt(requirements.maxAmountRequired);
486
+ } catch {
487
+ return "zero-amount";
488
+ }
489
+ if (amount <= 0n) {
490
+ return "zero-amount";
491
+ }
492
+ return undefined;
493
+ }
494
+
495
+ /** Builds the standard-shaped refusal for a settlement the relayer declines. */
496
+ private refuseSettlement(
497
+ payload: PaymentPayload,
498
+ reason: SettlementRefusalReason,
499
+ gate: MetrikGateVerdict | undefined,
500
+ ): MetrikSettleResponse {
501
+ return {
502
+ success: false,
503
+ errorReason:
504
+ reason === "network-mismatch"
505
+ ? "invalid_network"
506
+ : "invalid_payment_requirements",
507
+ transaction: "",
508
+ network: this.network as SettleResponse["network"],
509
+ metrik: {
510
+ ...(gate ?? { gate: "not-applicable" as const, detail: "" }),
511
+ settleable: false,
512
+ settlementRefusal: reason,
513
+ detail: SETTLEMENT_REFUSAL_DETAIL[reason],
514
+ },
515
+ };
516
+ }
517
+
518
+ private async evaluate(
519
+ payload: PaymentPayload,
520
+ requirements: PaymentRequirements,
521
+ ): Promise<MetrikVerifyResponse> {
522
+ const evaluated = await this.evaluateGate(payload, requirements);
523
+ // `settleable` is computed once, here, from the same facts `/settle`
524
+ // enforces — so a resource server reading `/verify` learns BEFORE it does
525
+ // any work whether this relayer will actually broadcast.
526
+ const refusal =
527
+ this.settlementPreflight(payload, requirements) ??
528
+ (evaluated.isValid
529
+ ? this.scopeRefusal(evaluated.metrik.gate)
530
+ : "resource-not-tracked");
531
+ return {
532
+ ...evaluated,
533
+ metrik: {
534
+ ...evaluated.metrik,
535
+ settleable: refusal === undefined,
536
+ ...(refusal === undefined ? {} : { settlementRefusal: refusal }),
537
+ },
538
+ };
539
+ }
540
+
541
+ /** Does this gate outcome name a resource the relayer will pay gas for? */
542
+ private scopeRefusal(
543
+ gate: MetrikGateOutcome,
544
+ ): SettlementRefusalReason | undefined {
545
+ if (this.settlementScope === "any-resource") {
546
+ return undefined;
547
+ }
548
+ // Only an origin that resolved to exactly one signed listing counts.
549
+ // "not-applicable" (not ours), "ambiguous" (two claimants) and
550
+ // "registry-unavailable" (cannot tell) are all "we do not know this is
551
+ // ours", and the relayer does not spend gas on that.
552
+ return gate === "passed" ||
553
+ gate === "indeterminate" ||
554
+ gate === "refused" ||
555
+ gate === "payee-mismatch"
556
+ ? undefined
557
+ : "resource-not-tracked";
558
+ }
559
+
560
+ private async evaluateGate(
561
+ payload: PaymentPayload,
562
+ requirements: PaymentRequirements,
563
+ ): Promise<VerifyResponse & { readonly metrik: MetrikGateVerdict }> {
564
+ // 1. Ordinary payment validity first. A malformed or unfunded payment is
565
+ // rejected on its own merits, and never reaches — or leaks — the
566
+ // delivery record of a service.
567
+ const payment = await this.paymentVerifier.verify(payload, requirements);
568
+ if (!payment.isValid) {
569
+ return {
570
+ ...payment,
571
+ metrik: {
572
+ gate: "not-applicable",
573
+ detail:
574
+ "payment rejected on ordinary x402 grounds; the Metrik delivery gate did not run",
575
+ },
576
+ };
577
+ }
578
+
579
+ // 2. Is this resource one of ours? Resolved from Metrik's own verified
580
+ // registry view, keyed on the resource URL's origin.
581
+ const lookup = await this.resourceIndex.lookup(requirements.resource);
582
+ if (lookup.status === "registry-unavailable") {
583
+ return this.applyIndeterminate(payment, {
584
+ gate: "registry-unavailable",
585
+ detail:
586
+ "Metrik could not read its service registry, so it cannot tell whether this resource is one it tracks",
587
+ });
588
+ }
589
+ if (lookup.status !== "resolved" || lookup.resource === undefined) {
590
+ // Not a Metrik listing. Behave exactly like any other facilitator.
591
+ return {
592
+ ...payment,
593
+ metrik: {
594
+ gate: "not-applicable",
595
+ detail:
596
+ "resource is not a Metrik-listed service; Metrik has no delivery opinion and applied no gate",
597
+ },
598
+ };
599
+ }
600
+ const resolved = lookup.resource;
601
+
602
+ // 3. The payee must be the operator who SIGNED the listing. This is the one
603
+ // check that protects the payer rather than the seller: without it,
604
+ // anyone could quote a Metrik-listed resource and redirect its payment to
605
+ // their own address.
606
+ if (!addressesEqual(requirements.payTo, resolved.operator)) {
607
+ return {
608
+ isValid: false,
609
+ invalidReason: "invalid_exact_evm_payload_recipient_mismatch",
610
+ ...(payment.payer === undefined ? {} : { payer: payment.payer }),
611
+ metrik: {
612
+ gate: "payee-mismatch",
613
+ serviceRef: resolved.serviceRef,
614
+ detail:
615
+ `payTo ${requirements.payTo} is not the operator ${resolved.operator} who signed the listing ` +
616
+ `for ${resolved.matchedOrigin}; refusing to settle a payment for a Metrik-listed resource to a different address`,
617
+ },
618
+ };
619
+ }
620
+
621
+ // 4. The differentiator.
622
+ const assessment = await this.deliveryOracle.verdictFor(
623
+ resolved.serviceRef,
624
+ );
625
+ if (assessment.verdict === "failing") {
626
+ return {
627
+ isValid: false,
628
+ invalidReason: DELIVERY_REFUSAL_REASON,
629
+ ...(payment.payer === undefined ? {} : { payer: payment.payer }),
630
+ metrik: {
631
+ gate: "refused",
632
+ detail:
633
+ `Metrik's oracle reports this service as FAILING its delivery checks ` +
634
+ `(verdict recorded ${assessment.ageSeconds ?? "?"}s ago, within the ` +
635
+ `${assessment.freshnessWindowSeconds}s freshness window). The payment itself is valid; ` +
636
+ `Metrik is refusing to settle it. This prevents a payment — it does not recover one.`,
637
+ ...assessmentFields(assessment, resolved.serviceRef),
638
+ },
639
+ };
640
+ }
641
+ if (assessment.verdict === "indeterminate") {
642
+ return this.applyIndeterminate(payment, {
643
+ gate: "indeterminate",
644
+ detail:
645
+ `Metrik has no fresh delivery evidence for this service (${assessment.reason}; ` +
646
+ `freshness window ${assessment.freshnessWindowSeconds}s). ` +
647
+ `Policy is "${this.indeterminatePolicy}".`,
648
+ ...assessmentFields(assessment, resolved.serviceRef),
649
+ });
650
+ }
651
+ return {
652
+ ...payment,
653
+ metrik: {
654
+ gate: "passed",
655
+ detail:
656
+ `Metrik's oracle recorded a passing delivery check for this service ` +
657
+ `${assessment.ageSeconds ?? "?"}s ago. This is not an endorsement of output correctness — ` +
658
+ `Metrik verifies delivery, not correctness.`,
659
+ ...assessmentFields(assessment, resolved.serviceRef),
660
+ },
661
+ };
662
+ }
663
+
664
+ private applyIndeterminate(
665
+ payment: VerifyResponse,
666
+ annotation: MetrikGateVerdict,
667
+ ): VerifyResponse & { readonly metrik: MetrikGateVerdict } {
668
+ if (this.indeterminatePolicy === "deny") {
669
+ return {
670
+ isValid: false,
671
+ invalidReason: DELIVERY_REFUSAL_REASON,
672
+ ...(payment.payer === undefined ? {} : { payer: payment.payer }),
673
+ metrik: {
674
+ ...annotation,
675
+ detail: `${annotation.detail} Refusing because this facilitator is configured to require positive fresh evidence.`,
676
+ },
677
+ };
678
+ }
679
+ return {
680
+ ...payment,
681
+ metrik: {
682
+ ...annotation,
683
+ detail:
684
+ `${annotation.detail} Allowing: Metrik holds none of this money, so refusing would deny the ` +
685
+ `service without protecting anyone. "isValid: true" here asserts only that the payment is ` +
686
+ `well-formed — it is NOT a statement that the service is delivering.`,
687
+ },
688
+ };
689
+ }
690
+ }
691
+
692
+ function assessmentFields(
693
+ assessment: DeliveryAssessment,
694
+ serviceRef: string,
695
+ ): Omit<MetrikGateVerdict, "gate" | "detail"> {
696
+ return {
697
+ serviceRef,
698
+ deliveryVerdict: assessment.verdict,
699
+ deliveryReason: assessment.reason,
700
+ lastCheckedAt: assessment.lastCheckedAt,
701
+ verdictAgeSeconds: assessment.ageSeconds,
702
+ freshnessWindowSeconds: assessment.freshnessWindowSeconds,
703
+ };
704
+ }
705
+
706
+ function addressesEqual(a: string, b: string): boolean {
707
+ try {
708
+ return getAddress(a) === getAddress(b);
709
+ } catch {
710
+ return false;
711
+ }
712
+ }
713
+
714
+ interface ParsedFacilitatorRequest {
715
+ readonly paymentPayload: PaymentPayload;
716
+ readonly paymentRequirements: PaymentRequirements;
717
+ }
718
+
719
+ /**
720
+ * Parses the facilitator wire body with the REAL x402 schemas. Both `/verify`
721
+ * and `/settle` take the same `{paymentPayload, paymentRequirements}` pair.
722
+ */
723
+ export function parseFacilitatorRequest(
724
+ body: unknown,
725
+ ): ParsedFacilitatorRequest | undefined {
726
+ if (typeof body !== "object" || body === null) {
727
+ return undefined;
728
+ }
729
+ const candidate = body as Record<string, unknown>;
730
+ const payload = PaymentPayloadSchema.safeParse(candidate.paymentPayload);
731
+ if (!payload.success) {
732
+ return undefined;
733
+ }
734
+ const requirements = PaymentRequirementsSchema.safeParse(
735
+ candidate.paymentRequirements,
736
+ );
737
+ if (!requirements.success) {
738
+ return undefined;
739
+ }
740
+ return {
741
+ paymentPayload: payload.data,
742
+ paymentRequirements: requirements.data,
743
+ };
744
+ }