chain-insights 0.48.0 → 0.49.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/dist/apps/claude-view.html +654 -45
- package/dist/cli.cjs +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/{graph-schema-CgWgVltN.cjs → graph-schema-CXD7NtEX.cjs} +93 -41
- package/dist/{graph-schema-DhVJiVba.mjs → graph-schema-D7wSNOT5.mjs} +94 -42
- package/dist/graph-schema-D7wSNOT5.mjs.map +1 -0
- package/dist/mcp-proxy.cjs +1 -1
- package/dist/mcp-proxy.mjs +1 -1
- package/dist/mcp-proxy.mjs.map +1 -1
- package/docs/mcp-proxy.md +5 -1
- package/package.json +1 -1
- package/dist/graph-schema-DhVJiVba.mjs.map +0 -1
package/dist/cli.cjs
CHANGED
|
@@ -152,7 +152,7 @@ async function printNetworkCapability(name, opts) {
|
|
|
152
152
|
async function printNetworkSchema(name, opts) {
|
|
153
153
|
const { loadConfig } = await Promise.resolve().then(() => require("./config-BxtMoo9c.cjs")).then((n) => n.config_exports);
|
|
154
154
|
const { resolveGraphMcpEndpoint } = await Promise.resolve().then(() => require("./client-Ypw9BNbn.cjs")).then((n) => n.client_exports);
|
|
155
|
-
const { formatGraphSchemaText, getGraphSchema } = await Promise.resolve().then(() => require("./graph-schema-
|
|
155
|
+
const { formatGraphSchemaText, getGraphSchema } = await Promise.resolve().then(() => require("./graph-schema-CXD7NtEX.cjs")).then((n) => n.graph_schema_exports);
|
|
156
156
|
const config = await loadConfig();
|
|
157
157
|
const schema = await getGraphSchema({
|
|
158
158
|
network: name,
|
package/dist/cli.mjs
CHANGED
|
@@ -150,7 +150,7 @@ async function printNetworkCapability(name, opts) {
|
|
|
150
150
|
async function printNetworkSchema(name, opts) {
|
|
151
151
|
const { loadConfig } = await import("./config-9MabJh5R.mjs").then((n) => n.t);
|
|
152
152
|
const { resolveGraphMcpEndpoint } = await import("./client-gsLi528K.mjs").then((n) => n.r);
|
|
153
|
-
const { formatGraphSchemaText, getGraphSchema } = await import("./graph-schema-
|
|
153
|
+
const { formatGraphSchemaText, getGraphSchema } = await import("./graph-schema-D7wSNOT5.mjs").then((n) => n.i);
|
|
154
154
|
const config = await loadConfig();
|
|
155
155
|
const schema = await getGraphSchema({
|
|
156
156
|
network: name,
|
|
@@ -16,11 +16,12 @@ let node_crypto = require("node:crypto");
|
|
|
16
16
|
*
|
|
17
17
|
* Two forms, told apart by the arguments:
|
|
18
18
|
*
|
|
19
|
-
* - node, {network, address, in_offset?, out_offset?}: one address with
|
|
20
|
-
* newest senders and receivers, a text summary and
|
|
21
|
-
* structuredContent. Composed from
|
|
22
|
-
* topology graph
|
|
23
|
-
* newest first
|
|
19
|
+
* - node, {network, address, in_offset?, out_offset?}: one address with up to
|
|
20
|
+
* 250 newest senders and 250 newest receivers, a text summary and
|
|
21
|
+
* chain-insights.flows.v1 in structuredContent. Composed from at most five
|
|
22
|
+
* anchored graph_query reads on the topology graph: the node, then its
|
|
23
|
+
* outgoing and its incoming FLOWS_TO, newest first, two reads a side because
|
|
24
|
+
* a reply holds at most 200 rows.
|
|
24
25
|
* - link, {network, from, to, day}: the transfers between two known addresses
|
|
25
26
|
* on one UTC day, newest first, at most 50, a short text summary and
|
|
26
27
|
* chain-insights.transfers.v1 in structuredContent. One anchored USE facts
|
|
@@ -80,24 +81,35 @@ const NODE_FIELDS = "b.address AS address, b.labels AS labels, b.is_exchange AS
|
|
|
80
81
|
const PARTY_FIELDS = NODE_FIELDS + ", f.amount_usd_sum AS usd, f.tx_count AS tx_count, " + "f.first_seen_timestamp AS first_seen, f.last_seen_timestamp AS last_seen";
|
|
81
82
|
const PAGE_ORDER = " ORDER BY f.last_seen_timestamp DESC, b.address";
|
|
82
83
|
/**
|
|
83
|
-
* The
|
|
84
|
-
* it shows: the extra row says
|
|
84
|
+
* The anchored reads of one page: the node, then the receivers, then the
|
|
85
|
+
* senders. Each side asks for one row more than it shows: the extra row says
|
|
86
|
+
* another page exists. A reply holds at most FLOWS_READ_ROWS rows, so a side
|
|
87
|
+
* is read in chunks on the same order, each starting where the last one ends.
|
|
85
88
|
*/
|
|
86
89
|
function flowsReadsFor(address, inOffset, outOffset) {
|
|
87
|
-
const
|
|
90
|
+
const side = (id, offset, match) => {
|
|
91
|
+
const reads = [];
|
|
92
|
+
for (let done = 0; done < 251; done += 200) {
|
|
93
|
+
const skip = offset + done;
|
|
94
|
+
const limit = Math.min(200, 251 - done);
|
|
95
|
+
reads.push({
|
|
96
|
+
id,
|
|
97
|
+
query: `USE topology MATCH ${match} RETURN ${PARTY_FIELDS}${PAGE_ORDER}${skip > 0 ? ` SKIP ${skip}` : ""} LIMIT ${limit}`,
|
|
98
|
+
skip,
|
|
99
|
+
limit
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return reads;
|
|
103
|
+
};
|
|
88
104
|
return [
|
|
89
105
|
{
|
|
90
106
|
id: "node",
|
|
91
|
-
query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
id: "out",
|
|
95
|
-
query: `USE topology MATCH (a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address) RETURN ${PARTY_FIELDS}${page(outOffset)}`
|
|
107
|
+
query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1`,
|
|
108
|
+
skip: 0,
|
|
109
|
+
limit: 1
|
|
96
110
|
},
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
query: `USE topology MATCH (b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'}) RETURN ${PARTY_FIELDS}${page(inOffset)}`
|
|
100
|
-
}
|
|
111
|
+
...side("out", outOffset, `(a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address)`),
|
|
112
|
+
...side("in", inOffset, `(b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'})`)
|
|
101
113
|
];
|
|
102
114
|
}
|
|
103
115
|
/** The role word of an address, first match wins. */
|
|
@@ -129,17 +141,22 @@ function flowsNodeOf(row) {
|
|
|
129
141
|
}
|
|
130
142
|
function assembleFlowView(network, page, senders, receivers, truncated) {
|
|
131
143
|
const center = flowsNodeOf(page.center);
|
|
144
|
+
const cursor = {
|
|
145
|
+
in_offset: page.inOffset + senders.length,
|
|
146
|
+
out_offset: page.outOffset + receivers.length
|
|
147
|
+
};
|
|
132
148
|
const view = {
|
|
133
149
|
schema: FLOWS_SCHEMA,
|
|
134
150
|
network,
|
|
135
151
|
center: center.address,
|
|
136
152
|
nodes: [center],
|
|
137
153
|
edges: [],
|
|
138
|
-
cursor
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
cursor,
|
|
155
|
+
truncated,
|
|
156
|
+
remaining: {
|
|
157
|
+
senders: Math.max(0, center.degree_in - cursor.in_offset),
|
|
158
|
+
receivers: Math.max(0, center.degree_out - cursor.out_offset)
|
|
159
|
+
}
|
|
143
160
|
};
|
|
144
161
|
const seen = /* @__PURE__ */ new Set([center.address]);
|
|
145
162
|
const add = (row, incoming) => {
|
|
@@ -162,7 +179,18 @@ function assembleFlowView(network, page, senders, receivers, truncated) {
|
|
|
162
179
|
return view;
|
|
163
180
|
}
|
|
164
181
|
function fitsFlowBudget(view) {
|
|
165
|
-
return view.nodes.length <=
|
|
182
|
+
return view.nodes.length <= 500 && JSON.stringify(view).length < 14e4;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* How many senders and receivers stay after `drops` of the oldest links go,
|
|
186
|
+
* one at a time from the longer side, the senders first on a tie.
|
|
187
|
+
*/
|
|
188
|
+
function keptAfterDrops(senders, receivers, drops) {
|
|
189
|
+
let s = senders;
|
|
190
|
+
let r = receivers;
|
|
191
|
+
for (let left = Math.min(drops, s + r); left > 0; left -= 1) if (s >= r) s -= 1;
|
|
192
|
+
else r -= 1;
|
|
193
|
+
return [s, r];
|
|
166
194
|
}
|
|
167
195
|
/**
|
|
168
196
|
* flows.v1 from one page: the centre first, then the senders and the
|
|
@@ -170,18 +198,27 @@ function fitsFlowBudget(view) {
|
|
|
170
198
|
* data would hold more than FLOWS_MAX_NODES addresses or reach FLOWS_MAX_CHARS
|
|
171
199
|
* characters, the oldest links go first, the view is marked truncated, and the
|
|
172
200
|
* cursor still says where the next page starts.
|
|
201
|
+
*
|
|
202
|
+
* Each dropped link only makes the view smaller, so the fewest drops that fit
|
|
203
|
+
* are found by halving: about ten views are measured for a full page, not one
|
|
204
|
+
* per dropped link.
|
|
173
205
|
*/
|
|
174
206
|
function buildFlowView(network, page) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
207
|
+
const overflow = page.senders.length > 250 || page.receivers.length > 250;
|
|
208
|
+
const senders = page.senders.slice(0, 250);
|
|
209
|
+
const receivers = page.receivers.slice(0, 250);
|
|
210
|
+
const viewAfter = (drops) => {
|
|
211
|
+
const [s, r] = keptAfterDrops(senders.length, receivers.length, drops);
|
|
212
|
+
return assembleFlowView(network, page, senders.slice(0, s), receivers.slice(0, r), overflow || drops > 0);
|
|
213
|
+
};
|
|
214
|
+
let low = 0;
|
|
215
|
+
let high = senders.length + receivers.length;
|
|
216
|
+
while (low < high) {
|
|
217
|
+
const mid = low + high >> 1;
|
|
218
|
+
if (fitsFlowBudget(viewAfter(mid))) high = mid;
|
|
219
|
+
else low = mid + 1;
|
|
184
220
|
}
|
|
221
|
+
return viewAfter(low);
|
|
185
222
|
}
|
|
186
223
|
function formatFlowsUsd(value) {
|
|
187
224
|
if (!(value > 0)) return "$0";
|
|
@@ -321,6 +358,12 @@ function refusalText(answer) {
|
|
|
321
358
|
const rest = textCode === code ? text.replace(CODE_PREFIX, "") : text;
|
|
322
359
|
return rest ? `${code}: ${rest}` : `${code}: the graph endpoint refused the read`;
|
|
323
360
|
}
|
|
361
|
+
/** The endpoint says it cut the reply short (facts.query.truncated). */
|
|
362
|
+
function cutByEndpoint(answer) {
|
|
363
|
+
const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
|
|
364
|
+
const query = isRecord$1(facts) ? facts.query : void 0;
|
|
365
|
+
return isRecord$1(query) && query.truncated === true;
|
|
366
|
+
}
|
|
324
367
|
function rowsOf(answer) {
|
|
325
368
|
const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
|
|
326
369
|
const query = isRecord$1(facts) ? facts.query : void 0;
|
|
@@ -350,15 +393,23 @@ function checkOffset(value) {
|
|
|
350
393
|
}
|
|
351
394
|
/**
|
|
352
395
|
* Read and answer one node page. The reads run one after another: the node
|
|
353
|
-
* first, so an address the graph does not hold costs one read
|
|
354
|
-
*
|
|
355
|
-
*
|
|
396
|
+
* first, so an address the graph does not hold costs one read; then the
|
|
397
|
+
* receivers and the senders, the chunks of a side joined in order. A chunk that
|
|
398
|
+
* comes back short, and not cut by the endpoint, ends its side, so the next
|
|
399
|
+
* chunk of that side is not read or billed. The first read that does not
|
|
400
|
+
* answer ends the call.
|
|
356
401
|
*/
|
|
357
402
|
async function runNode(deps, network, address, inOffset, outOffset) {
|
|
358
403
|
const unavailable = deps.unavailable?.();
|
|
359
404
|
if (unavailable) return errorResult$1(unavailable);
|
|
360
|
-
const rows = {
|
|
405
|
+
const rows = {
|
|
406
|
+
node: [],
|
|
407
|
+
out: [],
|
|
408
|
+
in: []
|
|
409
|
+
};
|
|
410
|
+
const ended = /* @__PURE__ */ new Set();
|
|
361
411
|
for (const read of flowsReadsFor(address, inOffset, outOffset)) {
|
|
412
|
+
if (ended.has(read.id)) continue;
|
|
362
413
|
let answer;
|
|
363
414
|
try {
|
|
364
415
|
answer = await deps.graphQuery({
|
|
@@ -371,13 +422,14 @@ async function runNode(deps, network, address, inOffset, outOffset) {
|
|
|
371
422
|
if (answer.isError === true) return errorResult$1(refusalText(answer));
|
|
372
423
|
const found = rowsOf(answer);
|
|
373
424
|
if (!found) return errorResult$1("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
|
|
374
|
-
rows[read.id]
|
|
425
|
+
rows[read.id].push(...found);
|
|
375
426
|
if (read.id === "node" && found.length === 0) return errorResult$1(`${address} is not in the ${network} topology graph`);
|
|
427
|
+
if (found.length < read.limit && !cutByEndpoint(answer)) ended.add(read.id);
|
|
376
428
|
}
|
|
377
429
|
const view = buildFlowView(network, {
|
|
378
|
-
center: rows.node
|
|
379
|
-
senders: rows.in
|
|
380
|
-
receivers: rows.out
|
|
430
|
+
center: rows.node[0] ?? {},
|
|
431
|
+
senders: rows.in,
|
|
432
|
+
receivers: rows.out,
|
|
381
433
|
inOffset,
|
|
382
434
|
outOffset
|
|
383
435
|
});
|
|
@@ -456,7 +508,7 @@ async function handleGraphExpand(args, deps) {
|
|
|
456
508
|
return runNode(deps, network, address, inOffset, outOffset);
|
|
457
509
|
}
|
|
458
510
|
const GRAPH_EXPAND_TITLE = "Expand a node or a link in the picture";
|
|
459
|
-
const GRAPH_EXPAND_DESCRIPTION = "Expand an open Chain Insights picture. Called by the view when the investigator clicks, never by the model. A node, {network, address, in_offset?, out_offset?}, loads the newest senders and receivers of one address (
|
|
511
|
+
const GRAPH_EXPAND_DESCRIPTION = "Expand an open Chain Insights picture. Called by the view when the investigator clicks, never by the model. A node, {network, address, in_offset?, out_offset?}, loads the newest senders and receivers of one address (up to 250 a side, from the offsets); billed as at most five graph_query topology reads. A link, {network, from, to, day}, lists the transfers between two addresses on one UTC day (YYYY-MM-DD), newest first, at most 50; billed as one graph_query facts read. Read-only; an argument refused before a read costs nothing.";
|
|
460
512
|
//#endregion
|
|
461
513
|
//#region src/mcp/graph-schema-cache.ts
|
|
462
514
|
/** How long a schema with a failed read stays fresh. */
|
|
@@ -15,11 +15,12 @@ import { createHash } from "node:crypto";
|
|
|
15
15
|
*
|
|
16
16
|
* Two forms, told apart by the arguments:
|
|
17
17
|
*
|
|
18
|
-
* - node, {network, address, in_offset?, out_offset?}: one address with
|
|
19
|
-
* newest senders and receivers, a text summary and
|
|
20
|
-
* structuredContent. Composed from
|
|
21
|
-
* topology graph
|
|
22
|
-
* newest first
|
|
18
|
+
* - node, {network, address, in_offset?, out_offset?}: one address with up to
|
|
19
|
+
* 250 newest senders and 250 newest receivers, a text summary and
|
|
20
|
+
* chain-insights.flows.v1 in structuredContent. Composed from at most five
|
|
21
|
+
* anchored graph_query reads on the topology graph: the node, then its
|
|
22
|
+
* outgoing and its incoming FLOWS_TO, newest first, two reads a side because
|
|
23
|
+
* a reply holds at most 200 rows.
|
|
23
24
|
* - link, {network, from, to, day}: the transfers between two known addresses
|
|
24
25
|
* on one UTC day, newest first, at most 50, a short text summary and
|
|
25
26
|
* chain-insights.transfers.v1 in structuredContent. One anchored USE facts
|
|
@@ -79,24 +80,35 @@ const NODE_FIELDS = "b.address AS address, b.labels AS labels, b.is_exchange AS
|
|
|
79
80
|
const PARTY_FIELDS = NODE_FIELDS + ", f.amount_usd_sum AS usd, f.tx_count AS tx_count, " + "f.first_seen_timestamp AS first_seen, f.last_seen_timestamp AS last_seen";
|
|
80
81
|
const PAGE_ORDER = " ORDER BY f.last_seen_timestamp DESC, b.address";
|
|
81
82
|
/**
|
|
82
|
-
* The
|
|
83
|
-
* it shows: the extra row says
|
|
83
|
+
* The anchored reads of one page: the node, then the receivers, then the
|
|
84
|
+
* senders. Each side asks for one row more than it shows: the extra row says
|
|
85
|
+
* another page exists. A reply holds at most FLOWS_READ_ROWS rows, so a side
|
|
86
|
+
* is read in chunks on the same order, each starting where the last one ends.
|
|
84
87
|
*/
|
|
85
88
|
function flowsReadsFor(address, inOffset, outOffset) {
|
|
86
|
-
const
|
|
89
|
+
const side = (id, offset, match) => {
|
|
90
|
+
const reads = [];
|
|
91
|
+
for (let done = 0; done < 251; done += 200) {
|
|
92
|
+
const skip = offset + done;
|
|
93
|
+
const limit = Math.min(200, 251 - done);
|
|
94
|
+
reads.push({
|
|
95
|
+
id,
|
|
96
|
+
query: `USE topology MATCH ${match} RETURN ${PARTY_FIELDS}${PAGE_ORDER}${skip > 0 ? ` SKIP ${skip}` : ""} LIMIT ${limit}`,
|
|
97
|
+
skip,
|
|
98
|
+
limit
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return reads;
|
|
102
|
+
};
|
|
87
103
|
return [
|
|
88
104
|
{
|
|
89
105
|
id: "node",
|
|
90
|
-
query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
id: "out",
|
|
94
|
-
query: `USE topology MATCH (a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address) RETURN ${PARTY_FIELDS}${page(outOffset)}`
|
|
106
|
+
query: `USE topology MATCH (b:Address {address: '${address}'}) RETURN ${NODE_FIELDS} LIMIT 1`,
|
|
107
|
+
skip: 0,
|
|
108
|
+
limit: 1
|
|
95
109
|
},
|
|
96
|
-
{
|
|
97
|
-
|
|
98
|
-
query: `USE topology MATCH (b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'}) RETURN ${PARTY_FIELDS}${page(inOffset)}`
|
|
99
|
-
}
|
|
110
|
+
...side("out", outOffset, `(a:Address {address: '${address}'})-[f:FLOWS_TO]->(b:Address)`),
|
|
111
|
+
...side("in", inOffset, `(b:Address)-[f:FLOWS_TO]->(a:Address {address: '${address}'})`)
|
|
100
112
|
];
|
|
101
113
|
}
|
|
102
114
|
/** The role word of an address, first match wins. */
|
|
@@ -128,17 +140,22 @@ function flowsNodeOf(row) {
|
|
|
128
140
|
}
|
|
129
141
|
function assembleFlowView(network, page, senders, receivers, truncated) {
|
|
130
142
|
const center = flowsNodeOf(page.center);
|
|
143
|
+
const cursor = {
|
|
144
|
+
in_offset: page.inOffset + senders.length,
|
|
145
|
+
out_offset: page.outOffset + receivers.length
|
|
146
|
+
};
|
|
131
147
|
const view = {
|
|
132
148
|
schema: FLOWS_SCHEMA,
|
|
133
149
|
network,
|
|
134
150
|
center: center.address,
|
|
135
151
|
nodes: [center],
|
|
136
152
|
edges: [],
|
|
137
|
-
cursor
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
cursor,
|
|
154
|
+
truncated,
|
|
155
|
+
remaining: {
|
|
156
|
+
senders: Math.max(0, center.degree_in - cursor.in_offset),
|
|
157
|
+
receivers: Math.max(0, center.degree_out - cursor.out_offset)
|
|
158
|
+
}
|
|
142
159
|
};
|
|
143
160
|
const seen = /* @__PURE__ */ new Set([center.address]);
|
|
144
161
|
const add = (row, incoming) => {
|
|
@@ -161,7 +178,18 @@ function assembleFlowView(network, page, senders, receivers, truncated) {
|
|
|
161
178
|
return view;
|
|
162
179
|
}
|
|
163
180
|
function fitsFlowBudget(view) {
|
|
164
|
-
return view.nodes.length <=
|
|
181
|
+
return view.nodes.length <= 500 && JSON.stringify(view).length < 14e4;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* How many senders and receivers stay after `drops` of the oldest links go,
|
|
185
|
+
* one at a time from the longer side, the senders first on a tie.
|
|
186
|
+
*/
|
|
187
|
+
function keptAfterDrops(senders, receivers, drops) {
|
|
188
|
+
let s = senders;
|
|
189
|
+
let r = receivers;
|
|
190
|
+
for (let left = Math.min(drops, s + r); left > 0; left -= 1) if (s >= r) s -= 1;
|
|
191
|
+
else r -= 1;
|
|
192
|
+
return [s, r];
|
|
165
193
|
}
|
|
166
194
|
/**
|
|
167
195
|
* flows.v1 from one page: the centre first, then the senders and the
|
|
@@ -169,18 +197,27 @@ function fitsFlowBudget(view) {
|
|
|
169
197
|
* data would hold more than FLOWS_MAX_NODES addresses or reach FLOWS_MAX_CHARS
|
|
170
198
|
* characters, the oldest links go first, the view is marked truncated, and the
|
|
171
199
|
* cursor still says where the next page starts.
|
|
200
|
+
*
|
|
201
|
+
* Each dropped link only makes the view smaller, so the fewest drops that fit
|
|
202
|
+
* are found by halving: about ten views are measured for a full page, not one
|
|
203
|
+
* per dropped link.
|
|
172
204
|
*/
|
|
173
205
|
function buildFlowView(network, page) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
206
|
+
const overflow = page.senders.length > 250 || page.receivers.length > 250;
|
|
207
|
+
const senders = page.senders.slice(0, 250);
|
|
208
|
+
const receivers = page.receivers.slice(0, 250);
|
|
209
|
+
const viewAfter = (drops) => {
|
|
210
|
+
const [s, r] = keptAfterDrops(senders.length, receivers.length, drops);
|
|
211
|
+
return assembleFlowView(network, page, senders.slice(0, s), receivers.slice(0, r), overflow || drops > 0);
|
|
212
|
+
};
|
|
213
|
+
let low = 0;
|
|
214
|
+
let high = senders.length + receivers.length;
|
|
215
|
+
while (low < high) {
|
|
216
|
+
const mid = low + high >> 1;
|
|
217
|
+
if (fitsFlowBudget(viewAfter(mid))) high = mid;
|
|
218
|
+
else low = mid + 1;
|
|
183
219
|
}
|
|
220
|
+
return viewAfter(low);
|
|
184
221
|
}
|
|
185
222
|
function formatFlowsUsd(value) {
|
|
186
223
|
if (!(value > 0)) return "$0";
|
|
@@ -320,6 +357,12 @@ function refusalText(answer) {
|
|
|
320
357
|
const rest = textCode === code ? text.replace(CODE_PREFIX, "") : text;
|
|
321
358
|
return rest ? `${code}: ${rest}` : `${code}: the graph endpoint refused the read`;
|
|
322
359
|
}
|
|
360
|
+
/** The endpoint says it cut the reply short (facts.query.truncated). */
|
|
361
|
+
function cutByEndpoint(answer) {
|
|
362
|
+
const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
|
|
363
|
+
const query = isRecord$1(facts) ? facts.query : void 0;
|
|
364
|
+
return isRecord$1(query) && query.truncated === true;
|
|
365
|
+
}
|
|
323
366
|
function rowsOf(answer) {
|
|
324
367
|
const facts = isRecord$1(answer.structuredContent) ? answer.structuredContent.facts : void 0;
|
|
325
368
|
const query = isRecord$1(facts) ? facts.query : void 0;
|
|
@@ -349,15 +392,23 @@ function checkOffset(value) {
|
|
|
349
392
|
}
|
|
350
393
|
/**
|
|
351
394
|
* Read and answer one node page. The reads run one after another: the node
|
|
352
|
-
* first, so an address the graph does not hold costs one read
|
|
353
|
-
*
|
|
354
|
-
*
|
|
395
|
+
* first, so an address the graph does not hold costs one read; then the
|
|
396
|
+
* receivers and the senders, the chunks of a side joined in order. A chunk that
|
|
397
|
+
* comes back short, and not cut by the endpoint, ends its side, so the next
|
|
398
|
+
* chunk of that side is not read or billed. The first read that does not
|
|
399
|
+
* answer ends the call.
|
|
355
400
|
*/
|
|
356
401
|
async function runNode(deps, network, address, inOffset, outOffset) {
|
|
357
402
|
const unavailable = deps.unavailable?.();
|
|
358
403
|
if (unavailable) return errorResult$1(unavailable);
|
|
359
|
-
const rows = {
|
|
404
|
+
const rows = {
|
|
405
|
+
node: [],
|
|
406
|
+
out: [],
|
|
407
|
+
in: []
|
|
408
|
+
};
|
|
409
|
+
const ended = /* @__PURE__ */ new Set();
|
|
360
410
|
for (const read of flowsReadsFor(address, inOffset, outOffset)) {
|
|
411
|
+
if (ended.has(read.id)) continue;
|
|
361
412
|
let answer;
|
|
362
413
|
try {
|
|
363
414
|
answer = await deps.graphQuery({
|
|
@@ -370,13 +421,14 @@ async function runNode(deps, network, address, inOffset, outOffset) {
|
|
|
370
421
|
if (answer.isError === true) return errorResult$1(refusalText(answer));
|
|
371
422
|
const found = rowsOf(answer);
|
|
372
423
|
if (!found) return errorResult$1("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
|
|
373
|
-
rows[read.id]
|
|
424
|
+
rows[read.id].push(...found);
|
|
374
425
|
if (read.id === "node" && found.length === 0) return errorResult$1(`${address} is not in the ${network} topology graph`);
|
|
426
|
+
if (found.length < read.limit && !cutByEndpoint(answer)) ended.add(read.id);
|
|
375
427
|
}
|
|
376
428
|
const view = buildFlowView(network, {
|
|
377
|
-
center: rows.node
|
|
378
|
-
senders: rows.in
|
|
379
|
-
receivers: rows.out
|
|
429
|
+
center: rows.node[0] ?? {},
|
|
430
|
+
senders: rows.in,
|
|
431
|
+
receivers: rows.out,
|
|
380
432
|
inOffset,
|
|
381
433
|
outOffset
|
|
382
434
|
});
|
|
@@ -455,7 +507,7 @@ async function handleGraphExpand(args, deps) {
|
|
|
455
507
|
return runNode(deps, network, address, inOffset, outOffset);
|
|
456
508
|
}
|
|
457
509
|
const GRAPH_EXPAND_TITLE = "Expand a node or a link in the picture";
|
|
458
|
-
const GRAPH_EXPAND_DESCRIPTION = "Expand an open Chain Insights picture. Called by the view when the investigator clicks, never by the model. A node, {network, address, in_offset?, out_offset?}, loads the newest senders and receivers of one address (
|
|
510
|
+
const GRAPH_EXPAND_DESCRIPTION = "Expand an open Chain Insights picture. Called by the view when the investigator clicks, never by the model. A node, {network, address, in_offset?, out_offset?}, loads the newest senders and receivers of one address (up to 250 a side, from the offsets); billed as at most five graph_query topology reads. A link, {network, from, to, day}, lists the transfers between two addresses on one UTC day (YYYY-MM-DD), newest first, at most 50; billed as one graph_query facts read. Read-only; an argument refused before a read costs nothing.";
|
|
459
511
|
//#endregion
|
|
460
512
|
//#region src/mcp/graph-schema-cache.ts
|
|
461
513
|
/** How long a schema with a failed read stays fresh. */
|
|
@@ -911,4 +963,4 @@ async function handleMetaSchema(args, deps) {
|
|
|
911
963
|
//#endregion
|
|
912
964
|
export { handleMetaSchema as a, GRAPH_EXPAND_TITLE as c, graph_schema_exports as i, GRAPH_EXPAND_TOOL as l, META_SCHEMA_DESCRIPTION as n, FLOWS_MAX_OFFSET as o, META_SCHEMA_TITLE as r, GRAPH_EXPAND_DESCRIPTION as s, GraphSchemaError as t, handleGraphExpand as u };
|
|
913
965
|
|
|
914
|
-
//# sourceMappingURL=graph-schema-
|
|
966
|
+
//# sourceMappingURL=graph-schema-D7wSNOT5.mjs.map
|