@absol-labs/agent 0.4.0 → 0.6.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 (57) hide show
  1. package/README.md +187 -0
  2. package/dist/capability/invocation-capability.d.ts +184 -0
  3. package/dist/capability/invocation-capability.d.ts.map +1 -0
  4. package/dist/capability/invocation-capability.js +183 -0
  5. package/dist/capability/invocation-capability.js.map +1 -0
  6. package/dist/discovery/registry.d.ts +260 -15
  7. package/dist/discovery/registry.d.ts.map +1 -1
  8. package/dist/discovery/registry.js +174 -31
  9. package/dist/discovery/registry.js.map +1 -1
  10. package/dist/frameworks/agentkit.js +3 -3
  11. package/dist/frameworks/agentkit.js.map +1 -1
  12. package/dist/frameworks/crewai.js +1 -1
  13. package/dist/frameworks/crewai.js.map +1 -1
  14. package/dist/frameworks/eliza.js +2 -2
  15. package/dist/frameworks/eliza.js.map +1 -1
  16. package/dist/frameworks/langchain.js +2 -2
  17. package/dist/frameworks/langchain.js.map +1 -1
  18. package/dist/gateway/caller-auth-gateway.d.ts +108 -0
  19. package/dist/gateway/caller-auth-gateway.d.ts.map +1 -0
  20. package/dist/gateway/caller-auth-gateway.js +191 -0
  21. package/dist/gateway/caller-auth-gateway.js.map +1 -0
  22. package/dist/gateway/http-server.d.ts +51 -0
  23. package/dist/gateway/http-server.d.ts.map +1 -0
  24. package/dist/gateway/http-server.js +241 -0
  25. package/dist/gateway/http-server.js.map +1 -0
  26. package/dist/gateway/server-entry.d.ts +2 -0
  27. package/dist/gateway/server-entry.d.ts.map +1 -0
  28. package/dist/gateway/server-entry.js +30 -0
  29. package/dist/gateway/server-entry.js.map +1 -0
  30. package/dist/index.d.ts +6 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +6 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/mcp/server.js +4 -2
  35. package/dist/mcp/server.js.map +1 -1
  36. package/dist/sdk/invoke.d.ts +136 -0
  37. package/dist/sdk/invoke.d.ts.map +1 -0
  38. package/dist/sdk/invoke.js +274 -0
  39. package/dist/sdk/invoke.js.map +1 -0
  40. package/dist/wallet/lifecycle.d.ts +101 -0
  41. package/dist/wallet/lifecycle.d.ts.map +1 -0
  42. package/dist/wallet/lifecycle.js +57 -0
  43. package/dist/wallet/lifecycle.js.map +1 -0
  44. package/package.json +6 -3
  45. package/src/capability/invocation-capability.ts +255 -0
  46. package/src/discovery/registry.ts +213 -35
  47. package/src/frameworks/agentkit.ts +3 -3
  48. package/src/frameworks/crewai.ts +1 -1
  49. package/src/frameworks/eliza.ts +2 -2
  50. package/src/frameworks/langchain.ts +2 -2
  51. package/src/gateway/caller-auth-gateway.ts +332 -0
  52. package/src/gateway/http-server.ts +342 -0
  53. package/src/gateway/server-entry.ts +38 -0
  54. package/src/index.ts +86 -0
  55. package/src/mcp/server.ts +4 -2
  56. package/src/sdk/invoke.ts +495 -0
  57. package/src/wallet/lifecycle.ts +158 -0
@@ -0,0 +1,255 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ import {
4
+ getAddress,
5
+ isAddress,
6
+ isHex,
7
+ keccak256,
8
+ recoverTypedDataAddress,
9
+ stringToBytes,
10
+ type Address,
11
+ type Hex,
12
+ type LocalAccount,
13
+ } from "viem";
14
+ import { z } from "zod";
15
+
16
+ /**
17
+ * `InvocationCapability` — the stream-bound EIP-712 credential that closes the
18
+ * "pay -> use" loop (metrik-protocol#62/#63/#64).
19
+ *
20
+ * A buyer who has opened an active, funded stream signs one of these per
21
+ * service call: "I, `buyer`, authorize exactly `method` `pathHash` against
22
+ * `serviceRef` under stream `streamId`, once, before `expiry`." The seller's
23
+ * gateway (`../gateway/caller-auth-gateway.ts`) recovers the signer, cross
24
+ * checks it against the on-chain stream, and enforces the nonce/expiry/route
25
+ * binding before proxying the call upstream.
26
+ *
27
+ * Deliberately kept LOCAL to `@absol-labs/agent` for this MVP slice (per
28
+ * metrik-agent#64) rather than added to `@absol-labs/shared` - it is never
29
+ * read on-chain (no contract parses it), so there is no cross-repo byte
30
+ * compatibility requirement the way there is for `DeliveryReceipt`.
31
+ */
32
+
33
+ const addressSchema = z
34
+ .string()
35
+ .refine((value) => isAddress(value), "must be an EVM address");
36
+ const bytes32Schema = z
37
+ .string()
38
+ .refine(
39
+ (value) => isHex(value, { strict: true }) && value.length === 66,
40
+ "must be bytes32 hex",
41
+ );
42
+ const signatureSchema = z
43
+ .string()
44
+ .refine(
45
+ (value) => isHex(value, { strict: true }) && value.length === 132,
46
+ "must be a 65-byte signature",
47
+ );
48
+
49
+ /** HTTP methods a capability may authorize. */
50
+ export const httpMethodSchema = z.enum([
51
+ "GET",
52
+ "POST",
53
+ "PUT",
54
+ "PATCH",
55
+ "DELETE",
56
+ "HEAD",
57
+ "OPTIONS",
58
+ ]);
59
+ export type HttpMethod = z.infer<typeof httpMethodSchema>;
60
+
61
+ export const invocationCapabilitySchema = z.object({
62
+ /** The on-chain `StreamEscrowV2` stream this capability draws authority from. */
63
+ streamId: bytes32Schema,
64
+ /** Must equal `stream.buyer` and the EIP-712 signer. */
65
+ buyer: addressSchema,
66
+ /** Must equal the target service's `serviceRef` (and the stream's). */
67
+ serviceRef: bytes32Schema,
68
+ /** The single HTTP method this capability authorizes. */
69
+ method: httpMethodSchema,
70
+ /** `keccak256` of the exact request path this capability authorizes. */
71
+ pathHash: bytes32Schema,
72
+ /** Single-use anti-replay nonce. */
73
+ nonce: bytes32Schema,
74
+ /** Unix seconds after which the capability is no longer valid. */
75
+ expiry: z.number().int().positive(),
76
+ });
77
+ export type InvocationCapability = z.infer<typeof invocationCapabilitySchema>;
78
+
79
+ export const signedInvocationCapabilitySchema =
80
+ invocationCapabilitySchema.extend({
81
+ signature: signatureSchema,
82
+ });
83
+ export type SignedInvocationCapability = z.infer<
84
+ typeof signedInvocationCapabilitySchema
85
+ >;
86
+
87
+ /** Chain-bound domain: a capability for one chain/escrow can never be replayed against another. */
88
+ export interface InvocationCapabilityDomainInput {
89
+ readonly chainId: number;
90
+ /** The `StreamEscrowV2` the stream settles on. */
91
+ readonly verifyingContract: Address;
92
+ }
93
+
94
+ export const invocationCapabilityDomainName =
95
+ "Metrik Invocation Capability" as const;
96
+ export const invocationCapabilityDomainVersion = "1" as const;
97
+
98
+ export const invocationCapabilityTypedData = {
99
+ InvocationCapability: [
100
+ { name: "streamId", type: "bytes32" },
101
+ { name: "buyer", type: "address" },
102
+ { name: "serviceRef", type: "bytes32" },
103
+ { name: "method", type: "string" },
104
+ { name: "pathHash", type: "bytes32" },
105
+ { name: "nonce", type: "bytes32" },
106
+ { name: "expiry", type: "uint64" },
107
+ ],
108
+ } as const;
109
+
110
+ /** Default single-use lifetime for a freshly-issued capability. Deliberately short. */
111
+ export const DEFAULT_CAPABILITY_TTL_SECONDS = 60;
112
+
113
+ /** Header a buyer's request carries the signed capability in. */
114
+ export const CAPABILITY_HEADER_NAME = "x-metrik-capability";
115
+
116
+ export function createInvocationCapabilityEip712Domain(
117
+ domain: InvocationCapabilityDomainInput,
118
+ ) {
119
+ return {
120
+ name: invocationCapabilityDomainName,
121
+ version: invocationCapabilityDomainVersion,
122
+ chainId: domain.chainId,
123
+ verifyingContract: getAddress(domain.verifyingContract),
124
+ } as const;
125
+ }
126
+
127
+ /**
128
+ * `keccak256` of the exact request path this capability authorizes (e.g.
129
+ * `/v1/infer`). Path only - no scheme/host/query/fragment - so the signer and
130
+ * the gateway hash the identical canonical string regardless of how each side
131
+ * received the full URL.
132
+ */
133
+ export function hashInvocationPath(path: string): Hex {
134
+ return keccak256(stringToBytes(normalizeInvocationPath(path)));
135
+ }
136
+
137
+ /** Strips query/fragment and any trailing slash (except the root) for a stable canonical form. */
138
+ export function normalizeInvocationPath(path: string): string {
139
+ const withoutFragment = path.split("#")[0] ?? "";
140
+ const withoutQuery = withoutFragment.split("?")[0] ?? "";
141
+ if (withoutQuery.length === 0) {
142
+ return "/";
143
+ }
144
+ if (withoutQuery.length > 1 && withoutQuery.endsWith("/")) {
145
+ return withoutQuery.slice(0, -1);
146
+ }
147
+ return withoutQuery;
148
+ }
149
+
150
+ function invocationCapabilityMessage(capability: InvocationCapability) {
151
+ const parsed = invocationCapabilitySchema.parse(capability);
152
+ return {
153
+ streamId: parsed.streamId as Hex,
154
+ buyer: getAddress(parsed.buyer),
155
+ serviceRef: parsed.serviceRef as Hex,
156
+ method: parsed.method,
157
+ pathHash: parsed.pathHash as Hex,
158
+ nonce: parsed.nonce as Hex,
159
+ expiry: BigInt(parsed.expiry),
160
+ };
161
+ }
162
+
163
+ export function buildInvocationCapabilityTypedData(
164
+ capability: InvocationCapability,
165
+ domain: InvocationCapabilityDomainInput,
166
+ ) {
167
+ return {
168
+ domain: createInvocationCapabilityEip712Domain(domain),
169
+ types: invocationCapabilityTypedData,
170
+ primaryType: "InvocationCapability" as const,
171
+ message: invocationCapabilityMessage(capability),
172
+ };
173
+ }
174
+
175
+ /** Signs an `InvocationCapability` with the buyer's own wallet. */
176
+ export async function signInvocationCapability(
177
+ capability: InvocationCapability,
178
+ domain: InvocationCapabilityDomainInput,
179
+ account: LocalAccount,
180
+ ): Promise<Hex> {
181
+ const typedData = buildInvocationCapabilityTypedData(capability, domain);
182
+ return account.signTypedData(typedData);
183
+ }
184
+
185
+ /** Recovers the EIP-712 signer of a capability. Throws on a malformed signature. */
186
+ export async function recoverInvocationCapabilitySigner(
187
+ capability: InvocationCapability,
188
+ domain: InvocationCapabilityDomainInput,
189
+ signature: Hex,
190
+ ): Promise<Address> {
191
+ const typedData = buildInvocationCapabilityTypedData(capability, domain);
192
+ return recoverTypedDataAddress({ ...typedData, signature });
193
+ }
194
+
195
+ /** Cryptographically random 32-byte single-use nonce. */
196
+ export function generateCapabilityNonce(): Hex {
197
+ return `0x${randomBytes(32).toString("hex")}` as Hex;
198
+ }
199
+
200
+ export class InvalidCapabilityHeaderError extends Error {
201
+ constructor(message: string, options?: { readonly cause?: unknown }) {
202
+ super(message, options);
203
+ this.name = "InvalidCapabilityHeaderError";
204
+ }
205
+ }
206
+
207
+ /** Encodes a signed capability as an opaque, header-safe (base64url) string. */
208
+ export function encodeCapabilityHeader(
209
+ signed: SignedInvocationCapability,
210
+ ): string {
211
+ const parsed = signedInvocationCapabilitySchema.parse(signed);
212
+ const json = JSON.stringify({
213
+ streamId: parsed.streamId,
214
+ buyer: parsed.buyer,
215
+ serviceRef: parsed.serviceRef,
216
+ method: parsed.method,
217
+ pathHash: parsed.pathHash,
218
+ nonce: parsed.nonce,
219
+ expiry: parsed.expiry,
220
+ signature: parsed.signature,
221
+ });
222
+ return Buffer.from(json, "utf8").toString("base64url");
223
+ }
224
+
225
+ /** Decodes and schema-validates a capability header value. Fails closed on any malformation. */
226
+ export function decodeCapabilityHeader(
227
+ headerValue: string,
228
+ ): SignedInvocationCapability {
229
+ let json: string;
230
+ try {
231
+ json = Buffer.from(headerValue, "base64url").toString("utf8");
232
+ } catch (cause) {
233
+ throw new InvalidCapabilityHeaderError(
234
+ "malformed base64 capability header",
235
+ { cause },
236
+ );
237
+ }
238
+
239
+ let raw: unknown;
240
+ try {
241
+ raw = JSON.parse(json);
242
+ } catch (cause) {
243
+ throw new InvalidCapabilityHeaderError("malformed JSON capability header", {
244
+ cause,
245
+ });
246
+ }
247
+
248
+ const result = signedInvocationCapabilitySchema.safeParse(raw);
249
+ if (!result.success) {
250
+ throw new InvalidCapabilityHeaderError(
251
+ `invalid capability shape: ${result.error.message}`,
252
+ );
253
+ }
254
+ return result.data;
255
+ }
@@ -1,7 +1,11 @@
1
1
  import {
2
2
  deriveServiceRef,
3
3
  recoverServiceBindingSigner,
4
+ recoverServiceDescriptorSigner,
4
5
  serviceBindingSchema,
6
+ serviceDescriptorSchema,
7
+ type SignedServiceBinding,
8
+ type SignedServiceDescriptor,
5
9
  } from "@absol-labs/shared";
6
10
  import { isHex } from "viem";
7
11
  import { z } from "zod";
@@ -35,9 +39,14 @@ export interface ServiceListing {
35
39
  readonly serviceRef: `0x${string}`;
36
40
  readonly operator: `0x${string}`;
37
41
  readonly publicUrl: string;
42
+ readonly access: "public" | "gated";
43
+ /** Signed invocation origin; gated listings fail closed if this is absent. */
44
+ readonly accessUrl: string;
45
+ /** The schema-validated and cryptographically verified signed record. */
46
+ readonly signed: SignedServiceBinding | SignedServiceDescriptor;
38
47
  /** Unsigned informational category from the listings row (may be absent). */
39
48
  readonly category: string | null;
40
- /** Optional signed operator-supplied target metadata (e.g. resolver hints). */
49
+ /** Optional unsigned operator-supplied resolver hints; never used for invocation. */
41
50
  readonly targetMetadata?: Record<string, string>;
42
51
  }
43
52
 
@@ -76,14 +85,16 @@ export const DEFAULT_METRIK_REGISTRY_URL =
76
85
  const DEFAULT_TIMEOUT_MS = 5_000;
77
86
 
78
87
  /**
79
- * `serviceRef` of the built-in verified demo listing. Kept as a named constant so
88
+ * `serviceRef` of the built-in verified live-data listing. Kept as a named constant so
80
89
  * callers can recognise (and dedupe against) the seeded row.
81
90
  */
82
- export const DEMO_SEED_SERVICE_REF =
83
- "0x84ef78c5ac9fc084422dc2d3e1ef19623bb05d0d81c097d4da49f9c47ec3ed12" as const;
91
+ export const LIVE_DATA_SEED_SERVICE_REF =
92
+ "0x79e57358e98a35273c1cd9d5f1bf9ae1e0af8705db432eb706459dc4f88b6fe0" as const;
93
+ /** @deprecated Use {@link LIVE_DATA_SEED_SERVICE_REF}. */
94
+ export const DEMO_SEED_SERVICE_REF = LIVE_DATA_SEED_SERVICE_REF;
84
95
 
85
96
  /**
86
- * A REAL, operator-signed demo listing row embedded so `discoverServices()` returns
97
+ * A real, operator-signed live-data listing row embedded so `discoverServices()` returns
87
98
  * something hireable with ZERO configuration (no Supabase anon key, no registry URL).
88
99
  *
89
100
  * This is PUBLIC data — a signed {@link https://github.com/Absol-Labs | Metrik}
@@ -94,23 +105,115 @@ export const DEMO_SEED_SERVICE_REF =
94
105
  * with any field here and the seed silently drops itself, identically to a bad
95
106
  * registry row. The `category` column is unsigned informational metadata only.
96
107
  */
97
- export const DEMO_SEED_LISTING = {
98
- service_ref: DEMO_SEED_SERVICE_REF,
99
- operator: "0x28ea4eF61ac4cca3ed6a64dBb5b2D4be1aDC9814",
100
- public_url: "https://metrik-demo-service.vercel.app",
101
- category: "demo",
108
+ export const LIVE_DATA_SEED_LISTING = {
109
+ service_ref: LIVE_DATA_SEED_SERVICE_REF,
110
+ operator: "0xB3e162711920dFD8933609019Dc35C73cC1a09F0",
111
+ public_url: "https://livedata.137.23.50.249.sslip.io",
112
+ category: "live-data",
102
113
  signed_binding: {
103
- serviceRef: DEMO_SEED_SERVICE_REF,
104
- binding: {
105
- version: 2,
106
- operator: "0x28ea4eF61ac4cca3ed6a64dBb5b2D4be1aDC9814",
107
- publicUrl: "https://metrik-demo-service.vercel.app",
108
- issuedAt: 1_787_500_000,
114
+ serviceRef: LIVE_DATA_SEED_SERVICE_REF,
115
+ descriptor: {
116
+ version: 3,
117
+ operator: "0xB3e162711920dFD8933609019Dc35C73cC1a09F0",
118
+ publicUrl: "https://livedata.137.23.50.249.sslip.io",
119
+ issuedAt: 1_787_772_057,
120
+ interface: {
121
+ baseUrl: "https://livedata.137.23.50.249.sslip.io",
122
+ healthPath: "/health",
123
+ endpoints: [
124
+ {
125
+ name: "health",
126
+ path: "/health",
127
+ method: "GET",
128
+ description: "Liveness probe.",
129
+ },
130
+ {
131
+ name: "readiness",
132
+ path: "/readiness",
133
+ method: "GET",
134
+ description:
135
+ "Non-value dependency readiness backed by the same real BTC cache policy.",
136
+ },
137
+ {
138
+ name: "price",
139
+ path: "/price",
140
+ method: "GET",
141
+ description:
142
+ "Real live crypto spot price (?symbol=BTC|ETH|SOL) from CoinGecko's public API.",
143
+ },
144
+ ],
145
+ authModel: "caller-auth",
146
+ example: {
147
+ endpoint: "price",
148
+ request: { symbol: "BTC" },
149
+ response: { symbol: "BTC", source: "coingecko-public-api" },
150
+ description:
151
+ "Real, nondeterministic live price — no re-execution guarantee.",
152
+ },
153
+ expectedStatuses: [200],
154
+ },
155
+ verification: {
156
+ canaries: [
157
+ {
158
+ path: "/readiness",
159
+ method: "GET",
160
+ matcher: {
161
+ type: "json-field-equals",
162
+ field: "source",
163
+ expected: "coingecko-public-api",
164
+ },
165
+ nonce: {
166
+ inject: { in: "query", name: "nonce" },
167
+ echo: { from: "json-path", path: "nonce" },
168
+ },
169
+ },
170
+ {
171
+ path: "/canary",
172
+ method: "GET",
173
+ matcher: {
174
+ type: "includes",
175
+ expected: "metrik-canary:live-data:",
176
+ },
177
+ nonce: {
178
+ inject: { in: "query", name: "nonce" },
179
+ echo: { from: "json-path", path: "nonce" },
180
+ },
181
+ },
182
+ ],
183
+ schema: {
184
+ path: "/readiness?nonce=metrik-schema",
185
+ required: [
186
+ { pointer: "ready", type: "boolean" },
187
+ { pointer: "source", type: "string", nonEmpty: true },
188
+ { pointer: "fetchedAt", type: "string", nonEmpty: true },
189
+ { pointer: "priceAgeMs", type: "number" },
190
+ { pointer: "freshness", type: "string", nonEmpty: true },
191
+ ],
192
+ },
193
+ sla: { maxLatencyMs: 5000, freshnessSeconds: 60 },
194
+ },
195
+ commercial: {
196
+ minRatePerSecond: "100",
197
+ maxRatePerSecond: "10000",
198
+ currency: "USDC",
199
+ slaDescription:
200
+ "Failed or unproven intervals do not advance verified cumulative entitlement; the stream remains active until buyer close or expiry.",
201
+ refundPolicy:
202
+ "The buyer reclaims unspent escrow using the latest checkpoint after close/expiry, or the unverified escape path after its grace window.",
203
+ },
204
+ access: "gated",
205
+ callerAuth: {
206
+ mechanism: "on-chain",
207
+ role: "buyer",
208
+ accessUrl: "https://gateway.137.23.50.249.sslip.io",
209
+ },
109
210
  },
110
211
  signature:
111
- "0x17917e96836a63238d0abe70c8c55081c4d694ad43311cdb3ea91dad349827fa6480657be2362e0b7f0524a0ee4ba0c48068255c3423a965392724874d244cd21b",
212
+ "0x2f4d2784eb40b1542517bc19f12534fb2e97da4ade5a3ff56193fcfb97fbef921309f77af3445e336cb07a4379011b080bba700033a098f672b0b2f096197f161b",
112
213
  },
113
214
  } as const;
215
+ /** @deprecated Use {@link LIVE_DATA_SEED_LISTING}. */
216
+ export const DEMO_SEED_LISTING = LIVE_DATA_SEED_LISTING;
114
217
 
115
218
  const signature65HexSchema = z
116
219
  .string()
@@ -135,12 +238,25 @@ const signedBindingRecordSchema = z.object({
135
238
  targetMetadata: z.record(z.string()).optional(),
136
239
  });
137
240
 
241
+ const signedDescriptorRecordSchema = z.object({
242
+ serviceRef: bytes32HexSchema,
243
+ descriptor: serviceDescriptorSchema,
244
+ signature: signature65HexSchema,
245
+ targetMetadata: z.record(z.string()).optional(),
246
+ });
247
+
138
248
  /** A Supabase `listings` row. Only `signed_binding` is trusted; `category` is
139
249
  * carried through as unsigned informational metadata. Unknown columns are ignored. */
140
250
  const listingRowSchema = z
141
251
  .object({
252
+ service_ref: bytes32HexSchema.optional(),
253
+ operator: z.string().optional(),
254
+ public_url: z.string().optional(),
142
255
  category: z.string().nullish(),
143
- signed_binding: signedBindingRecordSchema,
256
+ signed_binding: z.union([
257
+ signedBindingRecordSchema,
258
+ signedDescriptorRecordSchema,
259
+ ]),
144
260
  })
145
261
  .passthrough();
146
262
 
@@ -167,13 +283,13 @@ export async function discoverServices(
167
283
 
168
284
  // `registryUnavailable` = the registry could not be queried at all (no fetch /
169
285
  // network error / non-200). It never throws into the hire flow; instead it
170
- // degrades to the built-in verified demo seed (when enabled) so a cold user
286
+ // degrades to the built-in verified live-data seed (when enabled) so a cold user
171
287
  // still discovers a hireable service.
172
288
  let rows: unknown[] = [];
173
289
  let registryUnavailable = false;
174
290
  if (typeof fetchImpl !== "function") {
175
291
  logger.warn(
176
- "metrik discovery: no fetch implementation available; falling back to demo seed only",
292
+ "metrik discovery: no fetch implementation available; falling back to live-data seed only",
177
293
  );
178
294
  registryUnavailable = true;
179
295
  } else {
@@ -182,9 +298,9 @@ export async function discoverServices(
182
298
  } catch (error) {
183
299
  // Fail-safe / buyer-favouring: a down, unreachable, or 401 registry (the
184
300
  // Supabase default rejects credential-free reads) must never throw into the
185
- // agent's hire flow — it degrades to the demo seed.
301
+ // agent's hire flow — it degrades to the live-data seed.
186
302
  logger.warn(
187
- `metrik discovery: listings registry unavailable (${errorMessage(error)}); falling back to demo seed only`,
303
+ `metrik discovery: listings registry unavailable (${errorMessage(error)}); falling back to live-data seed only`,
188
304
  );
189
305
  registryUnavailable = true;
190
306
  }
@@ -210,21 +326,21 @@ export async function discoverServices(
210
326
  }
211
327
  }
212
328
 
213
- // Append the built-in verified demo listing when the registry did not already
329
+ // Append the built-in verified live-data listing when the registry did not already
214
330
  // surface it. Runs through the SAME fail-closed verifyRow path, so it is
215
331
  // verified exactly like a registry row and can never be a trust bypass.
216
332
  if (includeDemoSeed && !seedAlreadyPresent) {
217
- const seed = await verifyRow(DEMO_SEED_LISTING, "demo-seed", logger);
333
+ const seed = await verifyRow(DEMO_SEED_LISTING, "live-data-seed", logger);
218
334
  if (seed === null) {
219
335
  // Defensive: only reachable if the embedded constant were corrupted.
220
336
  logger.warn(
221
- "metrik discovery: built-in demo seed failed fail-closed verification; not seeding",
337
+ "metrik discovery: built-in live-data seed failed fail-closed verification; not seeding",
222
338
  );
223
339
  } else if (opts.category === undefined || seed.category === opts.category) {
224
340
  logger.warn(
225
341
  registryUnavailable
226
- ? "metrik discovery: registry unavailable — serving the built-in verified demo listing"
227
- : "metrik discovery: registry did not include the demo service — appending the built-in verified demo listing",
342
+ ? "metrik discovery: registry unavailable — serving the built-in verified live-data listing"
343
+ : "metrik discovery: registry did not include live data — appending the built-in verified listing",
228
344
  );
229
345
  listings.push(seed);
230
346
  }
@@ -284,26 +400,59 @@ async function verifyRow(
284
400
  }
285
401
  const { signed_binding: record, category } = parsed.data;
286
402
 
403
+ const signedIdentity =
404
+ "descriptor" in record
405
+ ? {
406
+ operator: record.descriptor.operator,
407
+ publicUrl: record.descriptor.publicUrl,
408
+ }
409
+ : {
410
+ operator: record.binding.operator,
411
+ publicUrl: record.binding.publicUrl,
412
+ };
413
+ if (
414
+ (parsed.data.service_ref !== undefined &&
415
+ !eqHex(parsed.data.service_ref, record.serviceRef)) ||
416
+ (parsed.data.operator !== undefined &&
417
+ !eqHex(parsed.data.operator, signedIdentity.operator)) ||
418
+ (parsed.data.public_url !== undefined &&
419
+ parsed.data.public_url !== signedIdentity.publicUrl)
420
+ ) {
421
+ logger.warn(
422
+ `metrik discovery: row ${index} dropped — flat identity columns disagree with signed record`,
423
+ );
424
+ return null;
425
+ }
426
+
287
427
  let signer: `0x${string}` | null;
288
428
  try {
289
- signer = (await recoverServiceBindingSigner(
290
- record.binding,
291
- record.signature as `0x${string}`,
292
- )) as `0x${string}` | null;
429
+ signer = (
430
+ "descriptor" in record
431
+ ? await recoverServiceDescriptorSigner(
432
+ record.descriptor,
433
+ record.signature as `0x${string}`,
434
+ )
435
+ : await recoverServiceBindingSigner(
436
+ record.binding,
437
+ record.signature as `0x${string}`,
438
+ )
439
+ ) as `0x${string}` | null;
293
440
  } catch (error) {
294
441
  logger.warn(
295
442
  `metrik discovery: row ${index} dropped — signature recovery threw (${errorMessage(error)})`,
296
443
  );
297
444
  return null;
298
445
  }
299
- if (signer === null || !eqHex(signer, record.binding.operator)) {
446
+ if (signer === null || !eqHex(signer, signedIdentity.operator)) {
300
447
  logger.warn(
301
448
  `metrik discovery: row ${index} dropped — signature does not recover to operator (recovered ${signer ?? "null"})`,
302
449
  );
303
450
  return null;
304
451
  }
305
452
 
306
- const derivedRef = deriveServiceRef(record.binding);
453
+ const derivedRef = deriveServiceRef(
454
+ "descriptor" in record ? record.descriptor : record.binding,
455
+ );
307
456
  if (!eqHex(derivedRef, record.serviceRef)) {
308
457
  logger.warn(
309
458
  `metrik discovery: row ${index} dropped — serviceRef ${record.serviceRef} does not match derived ${derivedRef}`,
@@ -311,10 +460,31 @@ async function verifyRow(
311
460
  return null;
312
461
  }
313
462
 
463
+ const access =
464
+ "descriptor" in record ? (record.descriptor.access ?? "public") : "public";
465
+ const accessUrl =
466
+ "descriptor" in record
467
+ ? access === "gated"
468
+ ? record.descriptor.callerAuth?.accessUrl
469
+ : (record.descriptor.interface?.baseUrl ?? record.descriptor.publicUrl)
470
+ : record.binding.publicUrl;
471
+ if (
472
+ accessUrl === undefined ||
473
+ (access === "gated" && !isHttpsUrl(accessUrl))
474
+ ) {
475
+ logger.warn(
476
+ `metrik discovery: row ${index} dropped — gated descriptor has no signed HTTPS callerAuth.accessUrl`,
477
+ );
478
+ return null;
479
+ }
480
+
314
481
  return {
315
482
  serviceRef: derivedRef,
316
- operator: record.binding.operator as `0x${string}`,
317
- publicUrl: record.binding.publicUrl,
483
+ operator: signedIdentity.operator as `0x${string}`,
484
+ publicUrl: signedIdentity.publicUrl,
485
+ access,
486
+ accessUrl,
487
+ signed: record as SignedServiceBinding | SignedServiceDescriptor,
318
488
  category: category ?? null,
319
489
  ...(record.targetMetadata === undefined
320
490
  ? {}
@@ -322,6 +492,14 @@ async function verifyRow(
322
492
  };
323
493
  }
324
494
 
495
+ function isHttpsUrl(value: string): boolean {
496
+ try {
497
+ return new URL(value).protocol === "https:";
498
+ } catch {
499
+ return false;
500
+ }
501
+ }
502
+
325
503
  function eqHex(a: string, b: string): boolean {
326
504
  return a.toLowerCase() === b.toLowerCase();
327
505
  }
@@ -290,7 +290,7 @@ export class MetrikActionProvider extends ActionProvider<WalletProvider> {
290
290
  }
291
291
  const lines = listings.map(
292
292
  (listing) =>
293
- `- ${listing.serviceRef} operator=${listing.operator} url=${listing.publicUrl}${listing.category ? ` category=${listing.category}` : ""}`,
293
+ `- ${listing.serviceRef} operator=${listing.operator} verificationUrl=${listing.publicUrl} access=${listing.access} accessUrl=${listing.accessUrl}${listing.category ? ` category=${listing.category}` : ""}`,
294
294
  );
295
295
  return (
296
296
  `Discovered ${listings.length} verified Metrik service${listings.length === 1 ? "" : "s"} ` +
@@ -365,7 +365,7 @@ export class MetrikActionProvider extends ActionProvider<WalletProvider> {
365
365
  @CreateAction({
366
366
  name: "check_stream_status",
367
367
  description:
368
- "Read a Metrik stream's status: verified accrued amount, whether it is active or paused (two failed checks auto-pause), claimable-by-seller, and reclaimable-by-buyer USDC.",
368
+ "Read a Metrik stream's status, verified accrued amount, claimable-by-seller, and reclaimable-by-buyer USDC. In V2, failed or unproven intervals do not advance cumulative entitlement; the stream remains active until buyer close or expiry.",
369
369
  schema: checkStreamStatusSchema,
370
370
  })
371
371
  async checkStreamStatus(
@@ -390,7 +390,7 @@ export class MetrikActionProvider extends ActionProvider<WalletProvider> {
390
390
  @CreateAction({
391
391
  name: "reclaim_unspent",
392
392
  description:
393
- "Reclaim unspent escrowed USDC from a Metrik stream back to the buyer. Set closeFirst to close an active stream before reclaiming. Runs under the same signed mandate controls.",
393
+ "Reclaim unspent escrowed USDC from a Metrik stream back to the buyer. Set closeFirst to close an active stream before reclaiming. V2 reclaim follows checkpoint finalization or escape-window rules. Runs under the same signed mandate controls.",
394
394
  schema: reclaimUnspentSchema,
395
395
  })
396
396
  async reclaimUnspent(
@@ -72,7 +72,7 @@ export function renderCrewAiVerifiedStreamPythonExample(
72
72
  "",
73
73
  "buyer_agent = Agent(",
74
74
  ' role="Verified Compute Buyer",',
75
- ' goal="Hire a verified service and stop work when the stream pauses.",',
75
+ ' goal="Hire a verified service and close it if delivery is no longer verified.",',
76
76
  ' backstory="An operator that only pays for oracle-verified delivery.",',
77
77
  " mcps=[metrik_mcp],",
78
78
  ")",
@@ -368,7 +368,7 @@ function createStatusAction(agentClient: MetrikElizaClient): Action {
368
368
  "is the service still delivering",
369
369
  ],
370
370
  description:
371
- "Read the current Metrik stream status verified accrual, active/paused state (two failed checks auto-pause), and claimable/reclaimable USDC plus whether the agent should stop work.",
371
+ "Read the current Metrik stream status, verified accrual, and claimable/reclaimable USDC, plus whether the agent should stop work. In V2, failed or unproven intervals do not advance cumulative entitlement; buyer close or expiry ends the session.",
372
372
  examples: createExamples("CHECK_STREAM_STATUS"),
373
373
  validate: async (_runtime, message) =>
374
374
  canParseElizaActionInput(message.content, "CHECK_STREAM_STATUS"),
@@ -408,7 +408,7 @@ function createReclaimAction(
408
408
  "refund the remaining balance",
409
409
  ],
410
410
  description:
411
- "Reclaim unspent escrowed USDC from a verified stream back to the buyer under the original signed mandate. Set closeFirst to close an active stream before reclaiming.",
411
+ "Reclaim unspent escrowed USDC from a verified stream back to the buyer under the original signed mandate. Set closeFirst to close an active stream before reclaiming. V2 reclaim follows checkpoint finalization or escape-window rules.",
412
412
  examples: createExamples("RECLAIM_UNSPENT"),
413
413
  validate: async (_runtime, message) =>
414
414
  canParseElizaActionInput(message.content, "RECLAIM_UNSPENT"),