@agentfromzero/agentpassport-sdk 0.1.0 → 0.2.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.
@@ -0,0 +1,328 @@
1
+ // Index-backed trust signals: what the AgentPassport HyperIndex indexer (Envio, see ../../indexer)
2
+ // derives from every JobEscrow / AgentPassport / ERC-8004 event, joined with Nansen wallet
3
+ // intelligence about the people behind the money (hirers) and the agent (owner / agentWallet).
4
+ //
5
+ // The on-chain `AgentPassport.meets(agentId, policy)` stays the source of truth for hard counts.
6
+ // The index adds what a contract cannot cheaply know: how many *different* hirers paid, whether one
7
+ // hirer is most of the volume, what share of the agent's ERC-8004 feedback is escrow-backed, and,
8
+ // through Nansen, whether a hirer has any real on-chain history or is linked to the agent itself.
9
+ //
10
+ // Two sources, one shape: a published snapshot (`agentpassport/index-snapshot@1` JSON, e.g.
11
+ // https://agentfromzero.netlify.app/agentpassport/index.json) or a live self-hosted GraphQL
12
+ // endpoint (the Hasura in front of the indexer, e.g. http://localhost:8088/v1/graphql).
13
+
14
+ export const INDEX_SNAPSHOT_SCHEMA = "agentpassport/index-snapshot@1";
15
+
16
+ /** Nansen profile of one EVM address (mainnets Nansen covers, incl. Monad mainnet; not testnets). */
17
+ export interface CounterpartyIntel {
18
+ address: string;
19
+ /** Earliest address that sent this wallet native gas, with Nansen's label for it. */
20
+ firstFunder: { address: string; name: string | null; chain: string; tx: string | null; at: string | null } | null;
21
+ /** Sum of current token balances across Nansen-covered chains, in USD (null = not queried). */
22
+ footprintUsd: number | null;
23
+ /** Chains with a non-zero balance. */
24
+ chains: string[];
25
+ /** Wallets Nansen relates to this one (funding, deployer, …), with labels. */
26
+ related: Array<{ address: string; label: string | null; relation: string; chain: string }>;
27
+ /** Risk words found in funder / related-wallet labels (mixer, exploit, scam, …). */
28
+ flags: string[];
29
+ /** Nansen sees any history for this address. */
30
+ visible: boolean;
31
+ fetchedAt: string;
32
+ }
33
+
34
+ export interface IndexedHirer {
35
+ address: string;
36
+ jobsSettled: number;
37
+ volumeSettled: string;
38
+ /** Hirer is the agent's owner / wallet, was funded by it, or Nansen relates the two. */
39
+ linkedToAgent: boolean;
40
+ /** Counts toward `minWeightedHirers`: Nansen-visible, not linked, not flagged. */
41
+ weighted: boolean;
42
+ intel: CounterpartyIntel | null;
43
+ }
44
+
45
+ export interface IndexedAgent {
46
+ agentId: string;
47
+ owner: string | null;
48
+ agentWallet: string | null;
49
+ agentURI: string | null;
50
+ jobs: { opened: number; delivered: number; settled: number; refunded: number; disputed: number };
51
+ volumeSettled: string;
52
+ settledHirers: number;
53
+ repeatHirers: number;
54
+ topHirerShareBps: number;
55
+ feedback: { count: number; escrowBacked: number; revoked: number; escrowBackedShareBps: number };
56
+ avgDeliverySeconds: number;
57
+ onTimeDeliveries: number;
58
+ /** Index score v1, 0-100 (indexer/src/lib/score.ts). */
59
+ score: number;
60
+ scoreBreakdown?: Record<string, number | null>;
61
+ firstJobAt: string | null;
62
+ lastSettledAt: string | null;
63
+ hirers: IndexedHirer[];
64
+ intel: {
65
+ owner: CounterpartyIntel | null;
66
+ weightedHirers: number;
67
+ linkedHirers: number;
68
+ flagged: string[];
69
+ /** "nansen" when every counterparty was profiled, "partial" / "none" otherwise. */
70
+ coverage: "nansen" | "partial" | "none";
71
+ };
72
+ }
73
+
74
+ export interface IndexSnapshot {
75
+ schema: typeof INDEX_SNAPSHOT_SCHEMA;
76
+ generatedAt: string;
77
+ chainId: number;
78
+ indexer: { engine: string; mode: string; graphql: string | null };
79
+ block: { number: number; time: string | null };
80
+ protocol: Record<string, unknown>;
81
+ agents: Record<string, IndexedAgent>;
82
+ nansen: { provider: string; plan: string | null; creditsRemaining: number | null; addressesProfiled: number; note: string } | null;
83
+ }
84
+
85
+ /** Index / Nansen rules. Every field optional; unset rules are not checked. */
86
+ export interface IndexPolicy {
87
+ minIndexScore?: number;
88
+ /** Distinct hirers that paid (settled) this agent. */
89
+ minDistinctHirers?: number;
90
+ /** Largest single hirer's share of settled volume, basis points. */
91
+ maxTopHirerShareBps?: number;
92
+ /** Escrow-backed share of the agent's ERC-8004 feedback, basis points. */
93
+ minEscrowBackedFeedbackShareBps?: number;
94
+ /** Nansen: settled hirers with visible on-chain history that are not linked to the agent. */
95
+ minWeightedHirers?: number;
96
+ /** Nansen: settled hirers linked to the agent (self-dealing). */
97
+ maxLinkedHirers?: number;
98
+ /** Nansen: reject if a hirer or the owner carries a risk flag. */
99
+ forbidFlagged?: boolean;
100
+ /** Reject a stale index (seconds between the indexed block time and now). */
101
+ maxIndexAgeSeconds?: number;
102
+ }
103
+
104
+ export const INDEX_POLICY_FIELDS = [
105
+ "minIndexScore",
106
+ "minDistinctHirers",
107
+ "maxTopHirerShareBps",
108
+ "minEscrowBackedFeedbackShareBps",
109
+ "minWeightedHirers",
110
+ "maxLinkedHirers",
111
+ "forbidFlagged",
112
+ "maxIndexAgeSeconds",
113
+ ] as const satisfies ReadonlyArray<keyof IndexPolicy>;
114
+
115
+ export interface IndexCheck {
116
+ rule: keyof IndexPolicy;
117
+ source: "envio-index" | "nansen";
118
+ required: string;
119
+ actual: string;
120
+ ok: boolean;
121
+ }
122
+
123
+ /** Splits a loose object into on-chain policy fields and index policy fields (validated). */
124
+ export function toIndexPolicy(input: Record<string, unknown>): IndexPolicy {
125
+ const out: IndexPolicy = {};
126
+ for (const k of INDEX_POLICY_FIELDS) {
127
+ const v = input[k];
128
+ if (v === undefined) continue;
129
+ if (k === "forbidFlagged") {
130
+ if (typeof v !== "boolean") throw new Error("policy.forbidFlagged must be true or false");
131
+ out.forbidFlagged = v;
132
+ continue;
133
+ }
134
+ if (!/^\d+$/.test(String(v))) throw new Error(`policy.${k} must be a non-negative integer`);
135
+ out[k] = Number(v);
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /** Evaluates index / Nansen rules for one agent. An agent the index has never seen fails every rule. */
141
+ export function evaluateIndexPolicy(
142
+ agent: IndexedAgent | undefined,
143
+ policy: IndexPolicy,
144
+ meta: { blockTime: string | null; now?: Date } = { blockTime: null },
145
+ ): { ok: boolean; checks: IndexCheck[] } {
146
+ const checks: IndexCheck[] = [];
147
+ const add = (rule: keyof IndexPolicy, source: IndexCheck["source"], required: string, actual: string, ok: boolean) =>
148
+ checks.push({ rule, source, required, actual, ok });
149
+ const a = agent;
150
+ const none = "not indexed";
151
+ if (policy.minIndexScore !== undefined) add("minIndexScore", "envio-index", `>= ${policy.minIndexScore}`, a ? `${a.score}` : none, !!a && a.score >= policy.minIndexScore);
152
+ if (policy.minDistinctHirers !== undefined)
153
+ add("minDistinctHirers", "envio-index", `>= ${policy.minDistinctHirers}`, a ? `${a.settledHirers}` : none, !!a && a.settledHirers >= policy.minDistinctHirers);
154
+ if (policy.maxTopHirerShareBps !== undefined)
155
+ add(
156
+ "maxTopHirerShareBps",
157
+ "envio-index",
158
+ `<= ${policy.maxTopHirerShareBps} bps`,
159
+ a ? `${a.topHirerShareBps} bps` : none,
160
+ !!a && a.settledHirers > 0 && a.topHirerShareBps <= policy.maxTopHirerShareBps,
161
+ );
162
+ if (policy.minEscrowBackedFeedbackShareBps !== undefined)
163
+ add(
164
+ "minEscrowBackedFeedbackShareBps",
165
+ "envio-index",
166
+ `>= ${policy.minEscrowBackedFeedbackShareBps} bps`,
167
+ a ? `${a.feedback.escrowBackedShareBps} bps (${a.feedback.escrowBacked}/${a.feedback.count})` : none,
168
+ !!a && a.feedback.count > 0 && a.feedback.escrowBackedShareBps >= policy.minEscrowBackedFeedbackShareBps,
169
+ );
170
+ if (policy.maxIndexAgeSeconds !== undefined) {
171
+ const now = (meta.now ?? new Date()).getTime();
172
+ const age = meta.blockTime ? Math.max(0, Math.round((now - Date.parse(meta.blockTime)) / 1000)) : null;
173
+ add("maxIndexAgeSeconds", "envio-index", `<= ${policy.maxIndexAgeSeconds}s`, age === null ? "unknown" : `${age}s`, age !== null && age <= policy.maxIndexAgeSeconds);
174
+ }
175
+ const covered = !!a && a.intel.coverage !== "none";
176
+ if (policy.minWeightedHirers !== undefined)
177
+ add(
178
+ "minWeightedHirers",
179
+ "nansen",
180
+ `>= ${policy.minWeightedHirers}`,
181
+ !a ? none : covered ? `${a.intel.weightedHirers} of ${a.settledHirers} hirers` : "no Nansen data",
182
+ covered && a!.intel.weightedHirers >= policy.minWeightedHirers,
183
+ );
184
+ if (policy.maxLinkedHirers !== undefined)
185
+ add(
186
+ "maxLinkedHirers",
187
+ "nansen",
188
+ `<= ${policy.maxLinkedHirers}`,
189
+ !a ? none : covered ? `${a.intel.linkedHirers}` : "no Nansen data",
190
+ covered && a!.intel.linkedHirers <= policy.maxLinkedHirers,
191
+ );
192
+ if (policy.forbidFlagged)
193
+ add(
194
+ "forbidFlagged",
195
+ "nansen",
196
+ "no risk flags",
197
+ !a ? none : covered ? (a.intel.flagged.length ? a.intel.flagged.join("; ") : "none") : "no Nansen data",
198
+ covered && a!.intel.flagged.length === 0,
199
+ );
200
+ return { ok: checks.every((c) => c.ok), checks };
201
+ }
202
+
203
+ /** Loads a published snapshot and checks its schema. */
204
+ export async function fetchIndexSnapshot(url: string, fetchImpl: typeof fetch = fetch): Promise<IndexSnapshot> {
205
+ const res = await fetchImpl(url);
206
+ if (!res.ok) throw new Error(`index snapshot ${url}: HTTP ${res.status}`);
207
+ const snap = (await res.json()) as IndexSnapshot;
208
+ if (snap?.schema !== INDEX_SNAPSHOT_SCHEMA) throw new Error(`index snapshot ${url}: unexpected schema ${String(snap?.schema)}`);
209
+ return snap;
210
+ }
211
+
212
+ /** The GraphQL query the snapshot is built from (Hasura over the HyperIndex Postgres schema). */
213
+ export const INDEX_AGENT_QUERY = /* GraphQL */ `
214
+ query AgentTrust($id: String!) {
215
+ Agent(where: { id: { _eq: $id } }) {
216
+ id owner agentWallet agentURI jobsOpened jobsDelivered jobsSettled jobsRefunded jobsDisputed
217
+ volumeSettled settledHirers repeatHirers topHirerShareBps feedbackCount feedbackEscrowBacked
218
+ feedbackRevoked escrowBackedShareBps avgDeliverySeconds onTimeDeliveries score firstJobAt lastSettledAt
219
+ hirers(order_by: { volumeSettled: desc }) { hirer_id jobsSettled volumeSettled }
220
+ }
221
+ _meta { progressBlock progressBlockTime }
222
+ }
223
+ `;
224
+
225
+ /**
226
+ * Reads one agent straight from a live indexer GraphQL endpoint (index fields only; Nansen intel
227
+ * lives in snapshots because it costs API credits and needs a key).
228
+ */
229
+ export async function queryIndexedAgent(
230
+ graphqlUrl: string,
231
+ agentId: bigint | number | string,
232
+ fetchImpl: typeof fetch = fetch,
233
+ ): Promise<{ agent: IndexedAgent | undefined; block: { number: number; time: string | null } }> {
234
+ const res = await fetchImpl(graphqlUrl, {
235
+ method: "POST",
236
+ headers: { "content-type": "application/json" },
237
+ body: JSON.stringify({ query: INDEX_AGENT_QUERY, variables: { id: String(agentId) } }),
238
+ });
239
+ if (!res.ok) throw new Error(`indexer GraphQL ${graphqlUrl}: HTTP ${res.status}`);
240
+ const body = (await res.json()) as { data?: { Agent: RawAgent[]; _meta: Array<{ progressBlock: number; progressBlockTime: string | null }> }; errors?: unknown };
241
+ if (!body.data) throw new Error(`indexer GraphQL error: ${JSON.stringify(body.errors).slice(0, 300)}`);
242
+ const meta = body.data._meta[0];
243
+ const raw = body.data.Agent[0];
244
+ return { agent: raw ? fromRawAgent(raw) : undefined, block: { number: meta?.progressBlock ?? 0, time: meta?.progressBlockTime ?? null } };
245
+ }
246
+
247
+ /** Row shape returned by INDEX_AGENT_QUERY. */
248
+ export interface RawAgent {
249
+ id: string;
250
+ owner: string | null;
251
+ agentWallet: string | null;
252
+ agentURI: string | null;
253
+ jobsOpened: number;
254
+ jobsDelivered: number;
255
+ jobsSettled: number;
256
+ jobsRefunded: number;
257
+ jobsDisputed: number;
258
+ volumeSettled: string;
259
+ settledHirers: number;
260
+ repeatHirers: number;
261
+ topHirerShareBps: number;
262
+ feedbackCount: number;
263
+ feedbackEscrowBacked: number;
264
+ feedbackRevoked: number;
265
+ escrowBackedShareBps: number;
266
+ avgDeliverySeconds: number;
267
+ onTimeDeliveries: number;
268
+ score: number;
269
+ firstJobAt: string | null;
270
+ lastSettledAt: string | null;
271
+ hirers: Array<{ hirer_id: string; jobsSettled: number; volumeSettled: string }>;
272
+ }
273
+
274
+ /** Index row -> IndexedAgent, optionally joined with Nansen profiles keyed by lowercase address. */
275
+ export function fromRawAgent(r: RawAgent, intel: Record<string, CounterpartyIntel> = {}): IndexedAgent {
276
+ const lc = (s: string | null) => (s ? s.toLowerCase() : null);
277
+ const self = new Set([lc(r.owner), lc(r.agentWallet)].filter((x): x is string => !!x));
278
+ const hirers: IndexedHirer[] = r.hirers
279
+ .filter((h) => h.jobsSettled > 0)
280
+ .map((h) => {
281
+ const address = h.hirer_id.toLowerCase();
282
+ const p = intel[address] ?? null;
283
+ const linkedToAgent =
284
+ self.has(address) ||
285
+ (!!p?.firstFunder && self.has(p.firstFunder.address.toLowerCase())) ||
286
+ (!!p && p.related.some((w) => self.has(w.address.toLowerCase())));
287
+ return {
288
+ address,
289
+ jobsSettled: h.jobsSettled,
290
+ volumeSettled: String(h.volumeSettled),
291
+ linkedToAgent,
292
+ weighted: !!p && p.visible && !linkedToAgent && p.flags.length === 0,
293
+ intel: p,
294
+ };
295
+ });
296
+ const owner = r.owner ? (intel[r.owner.toLowerCase()] ?? null) : null;
297
+ const profiled = hirers.filter((h) => h.intel).length + (owner ? 1 : 0);
298
+ const wanted = hirers.length + (r.owner ? 1 : 0);
299
+ const flagged = [
300
+ ...(owner?.flags.length ? [`owner ${r.owner}: ${owner.flags.join(", ")}`] : []),
301
+ ...hirers.filter((h) => h.intel?.flags.length).map((h) => `hirer ${h.address}: ${h.intel!.flags.join(", ")}`),
302
+ ];
303
+ return {
304
+ agentId: r.id,
305
+ owner: lc(r.owner),
306
+ agentWallet: lc(r.agentWallet),
307
+ agentURI: r.agentURI,
308
+ jobs: { opened: r.jobsOpened, delivered: r.jobsDelivered, settled: r.jobsSettled, refunded: r.jobsRefunded, disputed: r.jobsDisputed },
309
+ volumeSettled: String(r.volumeSettled),
310
+ settledHirers: r.settledHirers,
311
+ repeatHirers: r.repeatHirers,
312
+ topHirerShareBps: r.topHirerShareBps,
313
+ feedback: { count: r.feedbackCount, escrowBacked: r.feedbackEscrowBacked, revoked: r.feedbackRevoked, escrowBackedShareBps: r.escrowBackedShareBps },
314
+ avgDeliverySeconds: r.avgDeliverySeconds,
315
+ onTimeDeliveries: r.onTimeDeliveries,
316
+ score: r.score,
317
+ firstJobAt: r.firstJobAt,
318
+ lastSettledAt: r.lastSettledAt,
319
+ hirers,
320
+ intel: {
321
+ owner,
322
+ weightedHirers: hirers.filter((h) => h.weighted).length,
323
+ linkedHirers: hirers.filter((h) => h.linkedToAgent).length,
324
+ flagged,
325
+ coverage: profiled === 0 ? "none" : profiled >= wanted ? "nansen" : "partial",
326
+ },
327
+ };
328
+ }