chain-insights 0.56.21 → 0.57.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,16 @@ let node_crypto = require("node:crypto");
26
26
  * on one UTC day, newest first, at most 50, a short text summary and
27
27
  * chain-insights.transfers.v1 in structuredContent. One anchored USE facts
28
28
  * graph_query read.
29
+ * - link page, {network, from, to, day, limit, skip?, first_day?}: a page of
30
+ * `limit` transfers (1 to 50) newest first, read from `day` back one UTC day
31
+ * at a time, at most TRANSFERS_MAX_DAYS (a week of) reads a call, never before
32
+ * first_day. The answer carries `next` ({day, skip}, or null when every
33
+ * transfer back to first_day is listed) which the next call sends back as
34
+ * day and skip. The view's "More transfers".
35
+ * - details, {network, address, details: true}: the lifetime totals, counts,
36
+ * role and labels of one address, chain-insights.address.v1, one anchored
37
+ * topology read. The view fills its panel with it when the rows of a query
38
+ * carried no totals.
29
39
  *
30
40
  * Every read goes through the proxy's remote client, the same path and payment
31
41
  * wrapping a graph_query call takes, and is billed as a graph_query. Every
@@ -38,10 +48,15 @@ const FLOWS_NETWORK = "robinhood";
38
48
  /** Where a graph_expand page may start on each side, at most. */
39
49
  const FLOWS_MAX_OFFSET = 1e4;
40
50
  const TRANSFERS_SCHEMA = "chain-insights.transfers.v1";
51
+ const ADDRESS_SCHEMA = "chain-insights.address.v1";
41
52
  const INVALID_ADDRESS_TEXT = "invalid_address: a full 0x address of 40 hexadecimal characters is needed, for example 0x04911a118f11c75667e4d0dfb8e640af5a353550";
42
53
  const OFFSET_TOO_LARGE_TEXT = `invalid_offset: in_offset and out_offset must each be at most ${FLOWS_MAX_OFFSET}`;
43
54
  const OFFSET_NEGATIVE_TEXT = "invalid_offset: in_offset and out_offset must each be a whole number, 0 or more";
44
55
  const BOTH_FORMS_TEXT = "invalid_arguments: pass address (with in_offset and out_offset) to expand a node, or from, to and day to list the transfers of a link, not both";
56
+ const INVALID_LIMIT_TEXT = "invalid_limit: limit must be a whole number from 1 to 50";
57
+ const INVALID_SKIP_TEXT = "invalid_skip: skip must be a whole number of 0 or more";
58
+ const INVALID_FIRST_DAY_TEXT = "invalid_first_day: first_day must be a calendar date written YYYY-MM-DD (UTC), not after day";
59
+ const DETAILS_FORM_TEXT = "invalid_arguments: details takes address alone; pass no from, to, day or offsets with it";
45
60
  const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/;
46
61
  const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
47
62
  const CODE_PREFIX = /^([a-z][a-z0-9_]*):\s*/;
@@ -77,6 +92,41 @@ function normalizeFlowsDay(raw) {
77
92
  function transfersReadFor(from, to, day) {
78
93
  return `USE facts MATCH (a:Address {address: "${from}"})-[t:TRANSFER]->(b:Address {address: "${to}"}) WHERE t.block_date = "${day}" RETURN t.tx_id AS tx_id, t.block_timestamp AS block_timestamp, t.amount AS amount, t.asset_symbol AS asset_symbol, t.amount_usd AS amount_usd LIMIT 200`;
79
94
  }
95
+ /** The UTC day before a YYYY-MM-DD day. */
96
+ function dayBefore(day) {
97
+ return (/* @__PURE__ */ new Date(Date.parse(`${day}T00:00:00Z`) - 864e5)).toISOString().slice(0, 10);
98
+ }
99
+ /**
100
+ * The one topology read of a details call: the address alone, with its
101
+ * lifetime rollups and activity span. The address has passed its check, so it
102
+ * is written into the read as a literal.
103
+ */
104
+ function detailsReadFor(address) {
105
+ return `USE topology MATCH (b:Address {address: "${address}"}) RETURN ${NODE_FIELDS}, b.first_activity_timestamp AS first_activity_timestamp, b.last_activity_timestamp AS last_activity_timestamp LIMIT 1`;
106
+ }
107
+ /** chain-insights.address.v1 from the one row of a details read. */
108
+ function buildAddressView(network, address, row) {
109
+ const node = flowsNodeOf({
110
+ ...row,
111
+ address
112
+ });
113
+ return {
114
+ schema: ADDRESS_SCHEMA,
115
+ network,
116
+ ...node,
117
+ is_exchange: row.is_exchange === true,
118
+ is_sanctioned: row.is_sanctioned === true,
119
+ is_scam: row.is_scam === true,
120
+ is_victim: row.is_victim === true,
121
+ pool: row.pool === true,
122
+ first_activity_timestamp: flowsNumberOrNull(row.first_activity_timestamp),
123
+ last_activity_timestamp: flowsNumberOrNull(row.last_activity_timestamp)
124
+ };
125
+ }
126
+ /** The one line of a details answer. */
127
+ function addressSummary(view) {
128
+ return `${view.address} on ${view.network}: ${view.role}; lifetime ${formatFlowsUsd(view.total_in_usd)} in, ${formatFlowsUsd(view.total_out_usd)} out; ${view.degree_in} senders and ${view.degree_out} receivers in the topology graph` + (view.labels.length ? `; labels ${view.labels.join(", ")}` : "") + ".";
129
+ }
80
130
  const NODE_FIELDS = "b.address AS address, b.labels AS labels, b.is_exchange AS is_exchange, b.is_sanctioned AS is_sanctioned, b.is_scam AS is_scam, b.is_victim AS is_victim, b:Pool AS pool, b.total_in_usd AS total_in_usd, b.total_out_usd AS total_out_usd, b.degree_in AS degree_in, b.degree_out AS degree_out";
81
131
  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";
82
132
  const PAGE_ORDER = " ORDER BY f.last_seen_timestamp DESC, b.address";
@@ -321,11 +371,14 @@ function transferLine(transfer) {
321
371
  }
322
372
  /** The short text of a link answer: the pair, the day, the count and the newest few transfers. */
323
373
  function transfersSummary(view) {
324
- const head = `Transfers from ${view.from} to ${view.to} on ${view.day} (UTC) on ${view.network}`;
325
- if (view.transfers.length === 0) return `${head}: none.`;
326
- const lines = [`${head}: ${view.truncated ? `the newest ${view.transfers.length}, and the day holds more` : `${view.transfers.length}`}, newest first.`, ...view.transfers.slice(0, 5).map(transferLine)];
374
+ const paged = view.through_day !== void 0;
375
+ const span = paged && view.through_day !== view.day ? `${view.through_day} to ${view.day}` : view.day;
376
+ const head = `Transfers from ${view.from} to ${view.to} on ${span} (UTC) on ${view.network}`;
377
+ if (view.transfers.length === 0) return paged && view.next ? `${head}: none; earlier days remain before ${view.next.day}.` : `${head}: none.`;
378
+ const lines = [`${head}: ${view.truncated ? paged ? `${view.transfers.length}, and a day holds more than one read lists` : `the newest ${view.transfers.length}, and the day holds more` : `${view.transfers.length}`}, newest first.`, ...view.transfers.slice(0, 5).map(transferLine)];
327
379
  const rest = view.transfers.length - 5;
328
380
  if (rest > 0) lines.push(`- and ${rest} more in the picture.`);
381
+ if (paged && view.next) lines.push(`- earlier transfers remain, from ${view.next.day} back.`);
329
382
  return lines.join("\n");
330
383
  }
331
384
  function errorResult$1(text) {
@@ -385,6 +438,21 @@ function checkAddress(raw, name) {
385
438
  function checkDay(raw) {
386
439
  return normalizeFlowsDay(raw) ?? { error: "invalid_day: day must be a calendar date written YYYY-MM-DD (UTC), for example 2026-07-10" };
387
440
  }
441
+ function checkLimit(value) {
442
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 50) return { error: INVALID_LIMIT_TEXT };
443
+ return value;
444
+ }
445
+ function checkSkip(value) {
446
+ if (value === void 0 || value === null) return 0;
447
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return { error: INVALID_SKIP_TEXT };
448
+ return value;
449
+ }
450
+ function checkFirstDay(value, day) {
451
+ if (value === void 0 || value === null) return null;
452
+ const first = normalizeFlowsDay(value);
453
+ if (!first || first > day) return { error: INVALID_FIRST_DAY_TEXT };
454
+ return first;
455
+ }
388
456
  function checkOffset(value) {
389
457
  if (value === void 0 || value === null) return 0;
390
458
  if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return { error: OFFSET_NEGATIVE_TEXT };
@@ -468,6 +536,127 @@ async function runLink(deps, network, from, to, day) {
468
536
  isError: false
469
537
  };
470
538
  }
539
+ /** One facts read of a day, as the paged walk needs it: the rows newest first, or the error that ends the call. */
540
+ async function readDay(deps, network, from, to, day) {
541
+ let answer;
542
+ try {
543
+ answer = await deps.graphQuery({
544
+ network,
545
+ query: transfersReadFor(from, to, day)
546
+ });
547
+ } catch (err) {
548
+ return { error: deps.describeFailure(err) };
549
+ }
550
+ if (answer.isError === true) return { error: refusalText(answer) };
551
+ const rows = rowsOf(answer);
552
+ if (!rows) return { error: "graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows" };
553
+ return {
554
+ rows: rows.map(transferOf).sort((a, b) => (b.block_timestamp ?? -Infinity) - (a.block_timestamp ?? -Infinity)),
555
+ cut: rows.length >= 200
556
+ };
557
+ }
558
+ /**
559
+ * Read and answer one page of a link's transfers: from `day` back, one day a
560
+ * read, until the page holds `limit` transfers, TRANSFERS_MAX_DAYS days were
561
+ * read, or the day before first_day is reached. `skip` passes over the
562
+ * transfers of `day` an earlier page listed. The first read that does not
563
+ * answer ends the call.
564
+ */
565
+ async function runLinkPage(deps, network, from, to, day, page) {
566
+ const unavailable = deps.unavailable?.();
567
+ if (unavailable) return errorResult$1(unavailable);
568
+ const floor = page.firstDay ?? "2015-01-01";
569
+ const transfers = [];
570
+ let current = day;
571
+ let skip = page.skip;
572
+ let daysRead = 0;
573
+ let through = day;
574
+ let truncated = false;
575
+ let next = null;
576
+ for (;;) {
577
+ if (current < floor) break;
578
+ if (daysRead >= 7) {
579
+ next = {
580
+ day: current,
581
+ skip
582
+ };
583
+ break;
584
+ }
585
+ const read = await readDay(deps, network, from, to, current);
586
+ if ("error" in read) return errorResult$1(read.error);
587
+ daysRead += 1;
588
+ through = current;
589
+ if (read.cut) truncated = true;
590
+ const rest = read.rows.slice(skip);
591
+ const room = page.limit - transfers.length;
592
+ if (rest.length > room) {
593
+ transfers.push(...rest.slice(0, room));
594
+ next = {
595
+ day: current,
596
+ skip: skip + room
597
+ };
598
+ break;
599
+ }
600
+ transfers.push(...rest);
601
+ const earlier = dayBefore(current);
602
+ if (transfers.length >= page.limit) {
603
+ next = earlier < floor ? null : {
604
+ day: earlier,
605
+ skip: 0
606
+ };
607
+ break;
608
+ }
609
+ current = earlier;
610
+ skip = 0;
611
+ }
612
+ const view = {
613
+ schema: TRANSFERS_SCHEMA,
614
+ network,
615
+ from,
616
+ to,
617
+ day,
618
+ transfers,
619
+ truncated,
620
+ through_day: through,
621
+ days_read: daysRead,
622
+ next
623
+ };
624
+ return {
625
+ content: [{
626
+ type: "text",
627
+ text: transfersSummary(view)
628
+ }],
629
+ structuredContent: view,
630
+ isError: false
631
+ };
632
+ }
633
+ /** Read and answer the details of one address: a single anchored topology read. */
634
+ async function runDetails(deps, network, address) {
635
+ const unavailable = deps.unavailable?.();
636
+ if (unavailable) return errorResult$1(unavailable);
637
+ let answer;
638
+ try {
639
+ answer = await deps.graphQuery({
640
+ network,
641
+ query: detailsReadFor(address)
642
+ });
643
+ } catch (err) {
644
+ return errorResult$1(deps.describeFailure(err));
645
+ }
646
+ if (answer.isError === true) return errorResult$1(refusalText(answer));
647
+ const rows = rowsOf(answer);
648
+ if (!rows) return errorResult$1("graph_query_failed: the graph endpoint answered without chain-insights.result.v1 rows");
649
+ if (rows.length === 0) return errorResult$1(`${address} is not in the ${network} topology graph`);
650
+ const view = buildAddressView(network, address, rows[0]);
651
+ return {
652
+ content: [{
653
+ type: "text",
654
+ text: addressSummary(view)
655
+ }],
656
+ structuredContent: view,
657
+ isError: false
658
+ };
659
+ }
471
660
  function given(record, keys) {
472
661
  return keys.some((key) => record[key] !== void 0 && record[key] !== null);
473
662
  }
@@ -488,7 +677,8 @@ async function handleGraphExpand(args, deps) {
488
677
  if (isLink && given(record, [
489
678
  "address",
490
679
  "in_offset",
491
- "out_offset"
680
+ "out_offset",
681
+ "details"
492
682
  ])) return errorResult$1(BOTH_FORMS_TEXT);
493
683
  if (isLink) {
494
684
  const from = checkAddress(record.from, "from");
@@ -497,8 +687,32 @@ async function handleGraphExpand(args, deps) {
497
687
  if (typeof to !== "string") return errorResult$1(to.error);
498
688
  const day = checkDay(record.day);
499
689
  if (typeof day !== "string") return errorResult$1(day.error);
690
+ if (given(record, [
691
+ "limit",
692
+ "skip",
693
+ "first_day"
694
+ ])) {
695
+ const limit = checkLimit(record.limit);
696
+ if (typeof limit !== "number") return errorResult$1(limit.error);
697
+ const skip = checkSkip(record.skip);
698
+ if (typeof skip !== "number") return errorResult$1(skip.error);
699
+ const firstDay = checkFirstDay(record.first_day, day);
700
+ if (firstDay !== null && typeof firstDay !== "string") return errorResult$1(firstDay.error);
701
+ return runLinkPage(deps, network, from, to, day, {
702
+ skip,
703
+ firstDay,
704
+ limit
705
+ });
706
+ }
500
707
  return runLink(deps, network, from, to, day);
501
708
  }
709
+ if (record.details !== void 0 && record.details !== null) {
710
+ if (record.details !== true) return errorResult$1(DETAILS_FORM_TEXT);
711
+ if (given(record, ["in_offset", "out_offset"])) return errorResult$1(DETAILS_FORM_TEXT);
712
+ const address = checkAddress(record.address);
713
+ if (typeof address !== "string") return errorResult$1(address.error);
714
+ return runDetails(deps, network, address);
715
+ }
502
716
  const address = checkAddress(record.address);
503
717
  if (typeof address !== "string") return errorResult$1(address.error);
504
718
  const inOffset = checkOffset(record.in_offset);
@@ -508,7 +722,7 @@ async function handleGraphExpand(args, deps) {
508
722
  return runNode(deps, network, address, inOffset, outOffset);
509
723
  }
510
724
  const GRAPH_EXPAND_TITLE = "Expand a node or a link in the picture";
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.";
725
+ 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. A link page, {network, from, to, day, limit, skip?, first_day?}, lists `limit` (1-50) transfers from `day` back across days, newest first, and answers a `next` cursor ({day, skip}) while older ones remain; billed as at most seven facts reads (a week). Details, {network, address, details: true}, answer the lifetime totals, counts, role and labels of one address; billed as one topology read. Read-only; an argument refused before a read costs nothing.";
512
726
  //#endregion
513
727
  //#region src/mcp/graph-schema-cache.ts
514
728
  /** How long a schema with a failed read stays fresh. */
@@ -6,7 +6,7 @@ const require_tool_visibility = require("./tool-visibility-CDe-ft0L.cjs");
6
6
  const require_usage_status = require("./usage-status-BdwwgYsu.cjs");
7
7
  const require_subscription_status = require("./subscription-status-Cm7LqY_h.cjs");
8
8
  const require_capabilities = require("./capabilities-BGkL9yAr.cjs");
9
- const require_graph_schema = require("./graph-schema-CXD7NtEX.cjs");
9
+ const require_graph_schema = require("./graph-schema-_u1aB_f1.cjs");
10
10
  const require_action_log = require("./action-log-HniMMF7f.cjs");
11
11
  let node_url = require("node:url");
12
12
  let node_path = require("node:path");
@@ -1708,7 +1708,11 @@ async function createProxy() {
1708
1708
  out_offset: zod.number().int().min(0).max(require_graph_schema.FLOWS_MAX_OFFSET).optional(),
1709
1709
  from: zod.string().optional(),
1710
1710
  to: zod.string().optional(),
1711
- day: zod.string().optional()
1711
+ day: zod.string().optional(),
1712
+ limit: zod.number().int().min(1).max(50).optional(),
1713
+ skip: zod.number().int().min(0).optional(),
1714
+ first_day: zod.string().optional(),
1715
+ details: zod.boolean().optional()
1712
1716
  },
1713
1717
  annotations: VIEW_TOOL_ANNOTATIONS,
1714
1718
  _meta: APP_ONLY_TOOL_META
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-proxy.d.cts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;qBAuEa;qBACA;;;;;wBAyBG;YAkBJ;wBAEI,oBAAoB,MAAK,OAAO,aAA2B;KAsCtE,iBAAiB,eAAe,EAAE;qBAgB1B;wBAyGG,2BAA2B,mBAAmB;;;;;;;;wBAomBxC,eAAe"}
1
+ {"version":3,"file":"mcp-proxy.d.cts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;qBAwEa;qBACA;;;;;wBAyBG;YAkBJ;wBAEI,oBAAoB,MAAK,OAAO,aAA2B;KAsCtE,iBAAiB,eAAe,EAAE;qBAgB1B;wBAyGG,2BAA2B,mBAAmB;;;;;;;;wBAomBxC,eAAe"}
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-proxy.d.mts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;qBAuEa;qBACA;;;;;wBAyBG;YAkBJ;wBAEI,oBAAoB,MAAK,OAAO,aAA2B;KAsCtE,iBAAiB,eAAe,EAAE;qBAgB1B;wBAyGG,2BAA2B,mBAAmB;;;;;;;;wBAomBxC,eAAe"}
1
+ {"version":3,"file":"mcp-proxy.d.mts","names":[],"sources":["../src/mcp/proxy.ts"],"mappings":";;qBAwEa;qBACA;;;;;wBAyBG;YAkBJ;wBAEI,oBAAoB,MAAK,OAAO,aAA2B;KAsCtE,iBAAiB,eAAe,EAAE;qBAgB1B;wBAyGG,2BAA2B,mBAAmB;;;;;;;;wBAomBxC,eAAe"}
@@ -4,7 +4,7 @@ import { i as isAppOnlyTool, n as PUBLIC_MCP_TOOL_ALLOWED_ARGS, r as PUBLIC_MCP_
4
4
  import { t as primitiveBackendUsageStatus } from "./usage-status-CXOnrq-s.mjs";
5
5
  import { n as unavailableSubscriptionStatus } from "./subscription-status-CJ-DaMW9.mjs";
6
6
  import { r as mirrorGraphNetworkCapabilities } from "./capabilities-Cur8x6c7.mjs";
7
- import { a as handleMetaSchema, c as GRAPH_EXPAND_TITLE, l as GRAPH_EXPAND_TOOL, n as META_SCHEMA_DESCRIPTION, o as FLOWS_MAX_OFFSET, r as META_SCHEMA_TITLE, s as GRAPH_EXPAND_DESCRIPTION, t as GraphSchemaError, u as handleGraphExpand } from "./graph-schema-D7wSNOT5.mjs";
7
+ import { a as handleMetaSchema, c as GRAPH_EXPAND_TITLE, l as GRAPH_EXPAND_TOOL, n as META_SCHEMA_DESCRIPTION, o as FLOWS_MAX_OFFSET, r as META_SCHEMA_TITLE, s as GRAPH_EXPAND_DESCRIPTION, t as GraphSchemaError, u as handleGraphExpand } from "./graph-schema-CwsQBYrP.mjs";
8
8
  import { r as appendActionLog, t as actionLogSignalsFromResult } from "./action-log-D49Par9P.mjs";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import path from "node:path";
@@ -1704,7 +1704,11 @@ async function createProxy() {
1704
1704
  out_offset: z.number().int().min(0).max(FLOWS_MAX_OFFSET).optional(),
1705
1705
  from: z.string().optional(),
1706
1706
  to: z.string().optional(),
1707
- day: z.string().optional()
1707
+ day: z.string().optional(),
1708
+ limit: z.number().int().min(1).max(50).optional(),
1709
+ skip: z.number().int().min(0).optional(),
1710
+ first_day: z.string().optional(),
1711
+ details: z.boolean().optional()
1708
1712
  },
1709
1713
  annotations: VIEW_TOOL_ANNOTATIONS,
1710
1714
  _meta: APP_ONLY_TOOL_META