@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.
- package/README.md +187 -0
- package/dist/capability/invocation-capability.d.ts +184 -0
- package/dist/capability/invocation-capability.d.ts.map +1 -0
- package/dist/capability/invocation-capability.js +183 -0
- package/dist/capability/invocation-capability.js.map +1 -0
- package/dist/discovery/registry.d.ts +260 -15
- package/dist/discovery/registry.d.ts.map +1 -1
- package/dist/discovery/registry.js +174 -31
- package/dist/discovery/registry.js.map +1 -1
- package/dist/frameworks/agentkit.js +3 -3
- package/dist/frameworks/agentkit.js.map +1 -1
- package/dist/frameworks/crewai.js +1 -1
- package/dist/frameworks/crewai.js.map +1 -1
- package/dist/frameworks/eliza.js +2 -2
- package/dist/frameworks/eliza.js.map +1 -1
- package/dist/frameworks/langchain.js +2 -2
- package/dist/frameworks/langchain.js.map +1 -1
- package/dist/gateway/caller-auth-gateway.d.ts +108 -0
- package/dist/gateway/caller-auth-gateway.d.ts.map +1 -0
- package/dist/gateway/caller-auth-gateway.js +191 -0
- package/dist/gateway/caller-auth-gateway.js.map +1 -0
- package/dist/gateway/http-server.d.ts +51 -0
- package/dist/gateway/http-server.d.ts.map +1 -0
- package/dist/gateway/http-server.js +241 -0
- package/dist/gateway/http-server.js.map +1 -0
- package/dist/gateway/server-entry.d.ts +2 -0
- package/dist/gateway/server-entry.d.ts.map +1 -0
- package/dist/gateway/server-entry.js +30 -0
- package/dist/gateway/server-entry.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +4 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/sdk/invoke.d.ts +136 -0
- package/dist/sdk/invoke.d.ts.map +1 -0
- package/dist/sdk/invoke.js +274 -0
- package/dist/sdk/invoke.js.map +1 -0
- package/dist/wallet/lifecycle.d.ts +101 -0
- package/dist/wallet/lifecycle.d.ts.map +1 -0
- package/dist/wallet/lifecycle.js +57 -0
- package/dist/wallet/lifecycle.js.map +1 -0
- package/package.json +6 -3
- package/src/capability/invocation-capability.ts +255 -0
- package/src/discovery/registry.ts +213 -35
- package/src/frameworks/agentkit.ts +3 -3
- package/src/frameworks/crewai.ts +1 -1
- package/src/frameworks/eliza.ts +2 -2
- package/src/frameworks/langchain.ts +2 -2
- package/src/gateway/caller-auth-gateway.ts +332 -0
- package/src/gateway/http-server.ts +342 -0
- package/src/gateway/server-entry.ts +38 -0
- package/src/index.ts +86 -0
- package/src/mcp/server.ts +4 -2
- package/src/sdk/invoke.ts +495 -0
- 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
|
|
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
|
|
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
|
|
83
|
-
"
|
|
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
|
|
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
|
|
98
|
-
service_ref:
|
|
99
|
-
operator: "
|
|
100
|
-
public_url: "https://
|
|
101
|
-
category: "
|
|
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:
|
|
104
|
-
|
|
105
|
-
version:
|
|
106
|
-
operator: "
|
|
107
|
-
publicUrl: "https://
|
|
108
|
-
issuedAt:
|
|
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
|
-
"
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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, "
|
|
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
|
|
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
|
|
227
|
-
: "metrik discovery: registry did not include
|
|
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 = (
|
|
290
|
-
record
|
|
291
|
-
|
|
292
|
-
|
|
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,
|
|
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(
|
|
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:
|
|
317
|
-
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}
|
|
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
|
|
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(
|
package/src/frameworks/crewai.ts
CHANGED
|
@@ -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
|
|
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
|
")",
|
package/src/frameworks/eliza.ts
CHANGED
|
@@ -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
|
|
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"),
|