@absol-labs/agent 0.2.0 → 0.3.1

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.
@@ -132,11 +132,6 @@ export interface MetrikMandateStateResolver {
132
132
  getRevokedMandateIds(owner: `0x${string}`): Promise<readonly `0x${string}`[]>;
133
133
  }
134
134
 
135
- const DEFAULT_MANDATE_STATE_RESOLVER: MetrikMandateStateResolver = {
136
- getSpentSoFarUsdc: async () => 0n,
137
- getRevokedMandateIds: async () => [],
138
- };
139
-
140
135
  /**
141
136
  * The subset of the Metrik SDK the action provider drives. `VerifiedStreamAgentClient`
142
137
  * satisfies this structurally; tests inject a lightweight double so the provider
@@ -205,6 +200,21 @@ export class MetrikActionProviderConfigError extends Error {
205
200
  }
206
201
  }
207
202
 
203
+ /**
204
+ * Raised when a hire cannot be authorized because a fund-safety mandate control
205
+ * (the cumulative-cap accounting or the revocation state) cannot be POSITIVELY
206
+ * verified — no resolver wired, or a wired resolver threw/returned garbage. The
207
+ * provider denies the hire (fail-closed, buyer-favoring) rather than proceeding
208
+ * on an unverified "0 spent / none revoked" assumption (the B6 fix). Buyer
209
+ * recovery (close / reclaim) is NEVER blocked by this — it is signature-only.
210
+ */
211
+ export class MetrikSpendControlUnavailableError extends Error {
212
+ constructor(message: string) {
213
+ super(message);
214
+ this.name = "MetrikSpendControlUnavailableError";
215
+ }
216
+ }
217
+
208
218
  /**
209
219
  * Coinbase AgentKit action provider that lets any AgentKit-based agent hire and
210
220
  * pay third-party services through Metrik's verified USDC escrow. Every action
@@ -214,7 +224,10 @@ export class MetrikActionProviderConfigError extends Error {
214
224
  export class MetrikActionProvider extends ActionProvider<WalletProvider> {
215
225
  readonly #client: MetrikAgentKitClient;
216
226
  readonly #signedMandate: SignedSpendMandate;
217
- readonly #resolver: MetrikMandateStateResolver;
227
+ // Stored as the raw partial the caller supplied. A missing method means the
228
+ // corresponding fund-safety control cannot be verified, so the HIRE path fails
229
+ // CLOSED (see `#resolveSpendControls`). Buyer recovery never reads this.
230
+ readonly #resolver: Partial<MetrikMandateStateResolver>;
218
231
  readonly #now: () => number;
219
232
  readonly #chainId: number;
220
233
  readonly #discover: (
@@ -234,23 +247,24 @@ export class MetrikActionProvider extends ActionProvider<WalletProvider> {
234
247
 
235
248
  this.#client = resolveClient(config);
236
249
  this.#signedMandate = config.signedMandate;
237
- this.#resolver = {
238
- ...DEFAULT_MANDATE_STATE_RESOLVER,
239
- ...config.mandateStateResolver,
240
- };
250
+ this.#resolver = { ...config.mandateStateResolver };
241
251
  // Fail-loud on the two mandate controls that require external state. The
242
252
  // action provider holds no stream registry, so without a wired resolver the
243
- // cumulative cap (maxTotalUsdc) and revocation are not enforced. Warn once.
253
+ // cumulative cap (maxTotalUsdc) and revocation cannot be verified and every
254
+ // mandate carries a positive maxTotalUsdc, so a missing spent resolver can
255
+ // NEVER verify the cap. `hire_verified_service` therefore fails CLOSED
256
+ // (denies) until both are wired; warn once so the reason is visible at deploy
257
+ // time. Buyer recovery (close / reclaim) stays available regardless.
244
258
  if (config.mandateStateResolver?.getSpentSoFarUsdc === undefined) {
245
259
  warnAgentKitOnce(
246
260
  "agentkit-spent",
247
- "[metrik] AgentKit provider: no getSpentSoFarUsdc wired — the mandate cumulative cap (maxTotalUsdc) is NOT enforced across hires (per-stream/rate/duration/operator/expiry still are).",
261
+ "[metrik] AgentKit provider: no getSpentSoFarUsdc wired — the mandate cumulative cap (maxTotalUsdc) cannot be verified, so hire_verified_service will DENY every hire (fail-closed) until a resolver is wired.",
248
262
  );
249
263
  }
250
264
  if (config.mandateStateResolver?.getRevokedMandateIds === undefined) {
251
265
  warnAgentKitOnce(
252
266
  "agentkit-revocation",
253
- "[metrik] AgentKit provider: no getRevokedMandateIds wired — revoked mandates will NOT be rejected until you wire a revocation store.",
267
+ "[metrik] AgentKit provider: no getRevokedMandateIds wired — revocation state cannot be verified, so hire_verified_service will DENY every hire (fail-closed) until a resolver is wired.",
254
268
  );
255
269
  }
256
270
  this.#now = config.now ?? (() => Math.floor(Date.now() / 1000));
@@ -299,10 +313,10 @@ export class MetrikActionProvider extends ActionProvider<WalletProvider> {
299
313
 
300
314
  const owner = this.#signedMandate.owner as `0x${string}`;
301
315
  const nowSeconds = this.#now();
302
- const [spentSoFarUsdc, revokedMandateIds] = await Promise.all([
303
- this.#resolver.getSpentSoFarUsdc(owner),
304
- this.#resolver.getRevokedMandateIds(owner),
305
- ]);
316
+ // FAIL-CLOSED fund safety (B6): deny the hire unless BOTH the cumulative-cap
317
+ // accounting and the revocation state are positively verified.
318
+ const { spentSoFarUsdc, revokedMandateIds } =
319
+ await this.#resolveSpendControls(owner);
306
320
 
307
321
  const operator = args.operator as `0x${string}`;
308
322
  const serviceRef = args.serviceRef as `0x${string}`;
@@ -386,7 +400,7 @@ export class MetrikActionProvider extends ActionProvider<WalletProvider> {
386
400
  this.#assertWalletNetwork(walletProvider);
387
401
 
388
402
  const result = await this.#client.reclaimStream(
389
- await this.#authorizedStreamAction(args.streamId as `0x${string}`, {
403
+ this.#authorizedStreamAction(args.streamId as `0x${string}`, {
390
404
  ...(args.closeFirst === undefined
391
405
  ? {}
392
406
  : { closeFirst: args.closeFirst }),
@@ -413,7 +427,7 @@ export class MetrikActionProvider extends ActionProvider<WalletProvider> {
413
427
  this.#assertWalletNetwork(walletProvider);
414
428
 
415
429
  const result = await this.#client.closeStream(
416
- await this.#authorizedStreamAction(args.streamId as `0x${string}`, {}),
430
+ this.#authorizedStreamAction(args.streamId as `0x${string}`, {}),
417
431
  );
418
432
  return `Closed stream ${args.streamId}. tx=${result.txHash}`;
419
433
  }
@@ -435,22 +449,79 @@ export class MetrikActionProvider extends ActionProvider<WalletProvider> {
435
449
  }
436
450
  }
437
451
 
438
- async #authorizedStreamAction<TExtra extends object>(
452
+ /**
453
+ * FAIL-CLOSED resolution of the two fund-safety controls for the HIRE path.
454
+ * Every mandate carries a positive `maxTotalUsdc`, so a missing spent resolver
455
+ * can never verify the cap; a missing revocation resolver can never verify
456
+ * revocation. A resolver that throws (state store down) or returns garbage is
457
+ * likewise treated as "cannot verify" → deny. Never a silent fail-open to
458
+ * `{ 0n, [] }`.
459
+ */
460
+ async #resolveSpendControls(owner: `0x${string}`): Promise<{
461
+ spentSoFarUsdc: bigint;
462
+ revokedMandateIds: readonly `0x${string}`[];
463
+ }> {
464
+ const getSpentSoFarUsdc = this.#resolver.getSpentSoFarUsdc;
465
+ const getRevokedMandateIds = this.#resolver.getRevokedMandateIds;
466
+ if (getSpentSoFarUsdc === undefined) {
467
+ throw new MetrikSpendControlUnavailableError(
468
+ "cannot hire verified service: no getSpentSoFarUsdc wired — the mandate cumulative cap (maxTotalUsdc) cannot be verified (fail-closed)",
469
+ );
470
+ }
471
+ if (getRevokedMandateIds === undefined) {
472
+ throw new MetrikSpendControlUnavailableError(
473
+ "cannot hire verified service: no getRevokedMandateIds wired — mandate revocation state cannot be verified (fail-closed)",
474
+ );
475
+ }
476
+
477
+ let spentSoFarUsdc: bigint;
478
+ try {
479
+ spentSoFarUsdc = await getSpentSoFarUsdc(owner);
480
+ } catch (error) {
481
+ throw new MetrikSpendControlUnavailableError(
482
+ `cannot verify mandate cumulative cap (maxTotalUsdc): ${asErrorMessage(error)} (fail-closed)`,
483
+ );
484
+ }
485
+ if (typeof spentSoFarUsdc !== "bigint" || spentSoFarUsdc < 0n) {
486
+ throw new MetrikSpendControlUnavailableError(
487
+ "cannot verify mandate cumulative cap (maxTotalUsdc): resolver returned an invalid spent-so-far value (fail-closed)",
488
+ );
489
+ }
490
+
491
+ let revokedMandateIds: readonly `0x${string}`[];
492
+ try {
493
+ revokedMandateIds = await getRevokedMandateIds(owner);
494
+ } catch (error) {
495
+ throw new MetrikSpendControlUnavailableError(
496
+ `cannot verify mandate revocation state: ${asErrorMessage(error)} (fail-closed)`,
497
+ );
498
+ }
499
+ if (!Array.isArray(revokedMandateIds)) {
500
+ throw new MetrikSpendControlUnavailableError(
501
+ "cannot verify mandate revocation state: resolver returned a non-array value (fail-closed)",
502
+ );
503
+ }
504
+
505
+ return { spentSoFarUsdc, revokedMandateIds };
506
+ }
507
+
508
+ /**
509
+ * Context for a BUYER-RECOVERY action (close / reclaim). These are authorized
510
+ * on the mandate owner's signature ALONE (see `authorizeBuyerRecovery` in the
511
+ * SDK client) and must NEVER be blocked by missing or failing spend-control
512
+ * state — otherwise a lapsed resolver could strand the buyer's own funds. The
513
+ * spent/revoked fields are ignored downstream, so we pass safe neutral values.
514
+ */
515
+ #authorizedStreamAction<TExtra extends object>(
439
516
  streamId: `0x${string}`,
440
517
  extra: TExtra,
441
- ): Promise<MandateAuthorizedStreamActionInput & TExtra> {
442
- const owner = this.#signedMandate.owner as `0x${string}`;
443
- const nowSeconds = this.#now();
444
- const [spentSoFarUsdc, revokedMandateIds] = await Promise.all([
445
- this.#resolver.getSpentSoFarUsdc(owner),
446
- this.#resolver.getRevokedMandateIds(owner),
447
- ]);
518
+ ): MandateAuthorizedStreamActionInput & TExtra {
448
519
  return {
449
520
  streamId,
450
521
  signedMandate: this.#signedMandate,
451
- spentSoFarUsdc,
452
- nowSeconds,
453
- revokedMandateIds,
522
+ spentSoFarUsdc: 0n,
523
+ nowSeconds: this.#now(),
524
+ revokedMandateIds: [],
454
525
  ...extra,
455
526
  };
456
527
  }
@@ -520,6 +591,10 @@ function resolveClient(
520
591
  );
521
592
  }
522
593
 
594
+ function asErrorMessage(error: unknown): string {
595
+ return error instanceof Error ? error.message : String(error);
596
+ }
597
+
523
598
  const USDC_DECIMALS = 6n;
524
599
  const USDC_SCALE = 10n ** USDC_DECIMALS;
525
600
 
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  VerifiedStreamX402Facilitator,
29
29
  DeliveryProofUnavailableError,
30
30
  X402PayloadError,
31
+ X402SpendControlUnavailableError,
31
32
  SettlementTargetMismatchError,
32
33
  X402_SCHEME,
33
34
  encodeX402PayloadHeader,
@@ -46,6 +47,8 @@ export {
46
47
 
47
48
  export {
48
49
  DEFAULT_METRIK_REGISTRY_URL,
50
+ DEMO_SEED_LISTING,
51
+ DEMO_SEED_SERVICE_REF,
49
52
  discoverServices,
50
53
  type DiscoverServicesOptions,
51
54
  type ServiceListing,
package/src/mcp/server.ts CHANGED
@@ -188,7 +188,7 @@ export const METRIK_MCP_TOOLS: readonly McpToolSpec[] = [
188
188
  {
189
189
  name: "prove_https_response",
190
190
  description:
191
- "Generate a zkTLS proof of the exact HTTPS response consumed for a tracked stream. This is an L4 delivery signal and must be cross-checked with oracle/L1 evidence.",
191
+ "Generate a zkTLS proof of the exact HTTPS response consumed for a tracked stream. This is an L2 delivery signal (consumer zkTLS) and must be cross-checked with oracle/L1 evidence.",
192
192
  movesFunds: false,
193
193
  },
194
194
  ] as const;
@@ -130,17 +130,37 @@ export class DeliveryProofUnavailableError extends Error {
130
130
  }
131
131
  }
132
132
 
133
+ /**
134
+ * Raised when a spend cannot be authorized because a fund-safety mandate control
135
+ * (the cumulative-cap accounting or the revocation state) cannot be POSITIVELY
136
+ * verified — no resolver wired, or a wired resolver threw/returned garbage. The
137
+ * facilitator denies the open in this case (fail-closed, buyer-favoring) rather
138
+ * than proceeding on an unverified assumption of "0 spent / none revoked".
139
+ */
140
+ export class X402SpendControlUnavailableError extends Error {
141
+ constructor(message: string) {
142
+ super(message);
143
+ this.name = "X402SpendControlUnavailableError";
144
+ }
145
+ }
146
+
133
147
  export class VerifiedStreamX402Facilitator implements X402Facilitator {
134
148
  private readonly agentClient: VerifiedStreamAgentOpener;
135
149
  private readonly deliveryProofs: ConsumerDeliveryProofService | undefined;
136
150
  private readonly sdkConfig: StreamProofClientConfig;
137
151
  private readonly nowSeconds: () => number;
138
- private readonly resolveSpentSoFarUsdc: (
139
- signedMandate: SignedSpendMandate,
140
- ) => bigint | Promise<bigint>;
141
- private readonly resolveRevokedMandateIds: (
142
- signedMandate: SignedSpendMandate,
143
- ) => Iterable<`0x${string}`> | Promise<Iterable<`0x${string}`>>;
152
+ // Left `undefined` when the caller wired no resolver. A missing resolver means
153
+ // the corresponding fund-safety control cannot be positively verified, so
154
+ // `open()` fails CLOSED rather than defaulting to a fail-open "0 spent / none
155
+ // revoked" assumption (the B6 fix).
156
+ private readonly resolveSpentSoFarUsdc:
157
+ | ((signedMandate: SignedSpendMandate) => bigint | Promise<bigint>)
158
+ | undefined;
159
+ private readonly resolveRevokedMandateIds:
160
+ | ((
161
+ signedMandate: SignedSpendMandate,
162
+ ) => Iterable<`0x${string}`> | Promise<Iterable<`0x${string}`>>)
163
+ | undefined;
144
164
 
145
165
  constructor(options: VerifiedStreamFacilitatorOptions) {
146
166
  this.sdkConfig = options.sdkConfig;
@@ -150,24 +170,25 @@ export class VerifiedStreamX402Facilitator implements X402Facilitator {
150
170
  this.deliveryProofs = options.deliveryProofs;
151
171
  this.nowSeconds =
152
172
  options.nowSeconds ?? (() => Math.floor(Date.now() / 1000));
153
- this.resolveSpentSoFarUsdc = options.resolveSpentSoFarUsdc ?? (() => 0n);
154
- this.resolveRevokedMandateIds =
155
- options.resolveRevokedMandateIds ?? (() => []);
173
+ this.resolveSpentSoFarUsdc = options.resolveSpentSoFarUsdc;
174
+ this.resolveRevokedMandateIds = options.resolveRevokedMandateIds;
156
175
 
157
176
  // Fail-loud on the two mandate controls that require external state. This
158
- // facilitator is stateless (no stream registry), so it cannot enforce the
159
- // cumulative-cap or revocation without a wired resolver. Warn once so a
160
- // deployment never silently ships with maxTotalUsdc / revocation disabled.
177
+ // facilitator is stateless (no stream registry), so without a wired resolver
178
+ // it cannot verify the cumulative cap (maxTotalUsdc) or revocation and
179
+ // every mandate carries a positive maxTotalUsdc, so a missing spent resolver
180
+ // can NEVER verify the cap. `open()` therefore fails CLOSED (denies) until
181
+ // both resolvers are wired; warn once so the reason is visible at deploy time.
161
182
  if (options.resolveSpentSoFarUsdc === undefined) {
162
183
  warnFacilitatorOnce(
163
184
  "x402-spent",
164
- "[metrik] x402 facilitator: no resolveSpentSoFarUsdc wired — the mandate cumulative cap (maxTotalUsdc) is NOT enforced across streams (per-stream/rate/duration/operator/expiry still are).",
185
+ "[metrik] x402 facilitator: no resolveSpentSoFarUsdc wired — the mandate cumulative cap (maxTotalUsdc) cannot be verified, so open() will DENY every spend (fail-closed) until a resolver is wired.",
165
186
  );
166
187
  }
167
188
  if (options.resolveRevokedMandateIds === undefined) {
168
189
  warnFacilitatorOnce(
169
190
  "x402-revocation",
170
- "[metrik] x402 facilitator: no resolveRevokedMandateIds wired — revoked mandates will NOT be rejected until you wire a revocation store.",
191
+ "[metrik] x402 facilitator: no resolveRevokedMandateIds wired — revocation state cannot be verified, so open() will DENY every spend (fail-closed) until a resolver is wired.",
171
192
  );
172
193
  }
173
194
  }
@@ -182,12 +203,61 @@ export class VerifiedStreamX402Facilitator implements X402Facilitator {
182
203
  const payload = parseX402PayloadHeader(payloadHeader);
183
204
  assertSettlementTarget(payload.challenge.requirements, this.sdkConfig);
184
205
 
185
- const spentSoFarUsdc = await this.resolveSpentSoFarUsdc(
186
- payload.signedMandate,
187
- );
188
- const revokedMandateIds = await this.resolveRevokedMandateIds(
189
- payload.signedMandate,
190
- );
206
+ // FAIL-CLOSED fund safety (B6). Both the cumulative-cap accounting and the
207
+ // revocation state must be POSITIVELY verified before any spend. A missing
208
+ // resolver, a resolver that throws (state store down), or a resolver that
209
+ // returns a non-bigint/negative spend is treated as "state cannot be
210
+ // verified" and DENIES the open — never a silent fail-open to 0/none.
211
+ if (this.resolveSpentSoFarUsdc === undefined) {
212
+ throw new X402SpendControlUnavailableError(
213
+ "cannot open verified stream: no resolveSpentSoFarUsdc wired — the mandate cumulative cap (maxTotalUsdc) cannot be verified (fail-closed)",
214
+ );
215
+ }
216
+ if (this.resolveRevokedMandateIds === undefined) {
217
+ throw new X402SpendControlUnavailableError(
218
+ "cannot open verified stream: no resolveRevokedMandateIds wired — mandate revocation state cannot be verified (fail-closed)",
219
+ );
220
+ }
221
+
222
+ let spentSoFarUsdc: bigint;
223
+ try {
224
+ spentSoFarUsdc = await this.resolveSpentSoFarUsdc(payload.signedMandate);
225
+ } catch (error) {
226
+ throw new X402SpendControlUnavailableError(
227
+ `cannot verify mandate cumulative cap (maxTotalUsdc): ${
228
+ error instanceof Error ? error.message : String(error)
229
+ } (fail-closed)`,
230
+ );
231
+ }
232
+ if (typeof spentSoFarUsdc !== "bigint" || spentSoFarUsdc < 0n) {
233
+ throw new X402SpendControlUnavailableError(
234
+ "cannot verify mandate cumulative cap (maxTotalUsdc): resolver returned an invalid spent-so-far value (fail-closed)",
235
+ );
236
+ }
237
+
238
+ let revokedMandateIds: Iterable<`0x${string}`>;
239
+ try {
240
+ revokedMandateIds = await this.resolveRevokedMandateIds(
241
+ payload.signedMandate,
242
+ );
243
+ } catch (error) {
244
+ throw new X402SpendControlUnavailableError(
245
+ `cannot verify mandate revocation state: ${
246
+ error instanceof Error ? error.message : String(error)
247
+ } (fail-closed)`,
248
+ );
249
+ }
250
+ if (
251
+ revokedMandateIds === null ||
252
+ revokedMandateIds === undefined ||
253
+ typeof (revokedMandateIds as { [Symbol.iterator]?: unknown })[
254
+ Symbol.iterator
255
+ ] !== "function"
256
+ ) {
257
+ throw new X402SpendControlUnavailableError(
258
+ "cannot verify mandate revocation state: resolver returned a non-iterable value (fail-closed)",
259
+ );
260
+ }
191
261
 
192
262
  const result = await this.agentClient.openVerifiedStream({
193
263
  operator: payload.challenge.requirements.operator as `0x${string}`,
@@ -346,7 +346,7 @@ function buildRequestContext(
346
346
  return {
347
347
  contextAddress: binding.owner,
348
348
  contextMessage: JSON.stringify({
349
- kind: "metrik-l4-http-response/v1",
349
+ kind: "metrik-l2-http-response/v1",
350
350
  streamId: binding.streamId ?? null,
351
351
  operator: binding.operator ?? null,
352
352
  serviceRef: binding.serviceRef ?? null,