Repository: ponder-sh/ponder
Pull request #1580 (2025-03-04 20:19:45)

fix: unrecoverable reorg crash


Patch:
diff --git a/.changeset/cyan-donkeys-impress.md b/.changeset/cyan-donkeys-impress.md
new file mode 100644
index 00000000..8143f1a7
--- /dev/null
+++ b/.changeset/cyan-donkeys-impress.md
@@ -0,0 +1,5 @@
+---
+"ponder": patch
+---
+
+Fixed crash behavior when an unrecoverable reorg occurs.
diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md
index 252c14f8..665aceb4 100644
--- a/packages/client/CHANGELOG.md
+++ b/packages/client/CHANGELOG.md
@@ -1,7 +1,5 @@
 # @ponder/client
 
-## 0.9.25
-
 ## 0.9.24
 
 ## 0.9.23
diff --git a/packages/client/package.json b/packages/client/package.json
index ff42d307..5dde64d3 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@ponder/client",
-  "version": "0.9.25",
+  "version": "0.9.24",
   "description": "",
   "license": "MIT",
   "type": "module",
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index e9fd9365..88a2211e 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,11 +1,5 @@
 # ponder
 
-## 0.9.25
-
-### Patch Changes
-
-- [#1571](https://github.com/ponder-sh/ponder/pull/1571) [`e05716bd35ead4f0a305c2bdd92050d63042f01b`](https://github.com/ponder-sh/ponder/commit/e05716bd35ead4f0a305c2bdd92050d63042f01b) Thanks [@typedarray](https://github.com/typedarray)! - Fixed React 19 compatibility issues in monorepos. Removed `ink` and `react` dependencies, introduced a new terminal UI implementation with the same functionality as before.
-
 ## 0.9.24
 
 ### Patch Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index e7d79ace..3683ba0d 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
 {
   "name": "ponder",
-  "version": "0.9.25",
+  "version": "0.9.24",
   "description": "An open-source framework for crypto application backends",
   "license": "MIT",
   "type": "module",
@@ -59,7 +59,6 @@
     "@hono/node-server": "1.13.3",
     "@ponder/utils": "workspace:*",
     "abitype": "^0.10.2",
-    "ansi-escapes": "^7.0.0",
     "commander": "^12.0.0",
     "conf": "^12.0.0",
     "dataloader": "^2.2.2",
@@ -70,18 +69,19 @@
     "graphql": "^16.8.1",
     "graphql-yoga": "^5.3.0",
     "http-terminator": "^3.2.0",
+    "ink": "^4.4.1",
     "kysely": "^0.26.3",
     "pg": "^8.11.3",
     "pg-connection-string": "^2.6.2",
     "pg-copy-streams": "^6.0.6",
     "pg-query-emscripten": "5.1.0",
-    "picocolors": "^1.1.1",
+    "picocolors": "^1.0.0",
     "pino": "^8.16.2",
     "prom-client": "^15.0.0",
+    "react": "^18.2.0",
     "semver": "^7.7.1",
     "stacktrace-parser": "^0.1.10",
     "superjson": "^2.2.2",
-    "terminal-size": "^4.0.0",
     "vite": "5.0.7",
     "vite-node": "1.0.2",
     "vite-tsconfig-paths": "4.3.1"
@@ -94,6 +94,7 @@
     "@types/node": "^20.10.0",
     "@types/pg": "^8.10.9",
     "@types/pg-copy-streams": "^1.2.5",
+    "@types/react": "^18.2.38",
     "@types/semver": "^7.5.8",
     "@viem/anvil": "^0.0.6",
     "@wagmi/cli": "^1.5.2",
diff --git a/packages/core/src/bin/commands/list.ts b/packages/core/src/bin/commands/list.ts
index 787b5d9f..04998f2b 100644
--- a/packages/core/src/bin/commands/list.ts
+++ b/packages/core/src/bin/commands/list.ts
@@ -5,7 +5,7 @@ import { MetricsService } from "@/internal/metrics.js";
 import { buildOptions } from "@/internal/options.js";
 import { createShutdown } from "@/internal/shutdown.js";
 import { createTelemetry } from "@/internal/telemetry.js";
-import { buildTable } from "@/ui/app.js";
+import { printTable } from "@/ui/Table.js";
 import { formatEta } from "@/utils/format.js";
 import { and, eq, inArray, sql } from "drizzle-orm";
 import { unionAll } from "drizzle-orm/pg-core";
@@ -97,39 +97,30 @@ export async function list({ cliOptions }: { cliOptions: CliOptions }) {
   // @ts-ignore
   const result = await unionAll(...queries);
 
-  const columns = [
-    { title: "Schema", key: "table_schema", align: "left" },
-    { title: "Active", key: "active", align: "right" },
-    { title: "Last active", key: "last_active", align: "right" },
-    { title: "Table count", key: "table_count", align: "right" },
-  ];
-
-  const rows = result
-    .filter((row) => row.value.is_dev === 0)
-    .map((row) => ({
-      table_schema: row.schema,
-      active:
-        row.value.is_locked === 1 &&
-        row.value.heartbeat_at + common.options.databaseHeartbeatTimeout >
-          Date.now()
-          ? "yes"
-          : "no",
-      last_active:
-        row.value.is_locked === 1
-          ? "---"
-          : `${formatEta(Date.now() - row.value.heartbeat_at)} ago`,
-      table_count: row.value.table_names.length,
-    }));
-
-  if (rows.length === 0) {
-    console.log("No 'ponder start' apps found in this database.\n");
-    await exit({ reason: "Success", code: 0 });
-    return;
-  }
-
-  const lines = buildTable(rows, columns);
-  const text = [...lines, ""].join("\n");
-  console.log(text);
+  printTable({
+    columns: [
+      { title: "Schema", key: "table_schema", align: "left" },
+      { title: "Active", key: "active", align: "right" },
+      { title: "Last active", key: "last_active", align: "right" },
+      { title: "Table count", key: "table_count", align: "right" },
+    ],
+    rows: result
+      .filter((row) => row.value.is_dev === 0)
+      .map((row) => ({
+        table_schema: row.schema,
+        active:
+          row.value.is_locked === 1 &&
+          row.value.heartbeat_at + common.options.databaseHeartbeatTimeout >
+            Date.now()
+            ? "yes"
+            : "no",
+        last_active:
+          row.value.is_locked === 1
+            ? "---"
+            : `${formatEta(Date.now() - row.value.heartbeat_at)} ago`,
+        table_count: row.value.table_names.length,
+      })),
+  });
 
   await exit({ reason: "Success", code: 0 });
 }
diff --git a/packages/core/src/graphql/middleware.ts b/packages/core/src/graphql/middleware.ts
index ba6487e1..3bb34441 100644
--- a/packages/core/src/graphql/middleware.ts
+++ b/packages/core/src/graphql/middleware.ts
@@ -1,6 +1,6 @@
-import { graphiQLHtml } from "@/graphql/graphiql.html.js";
 import type { Schema } from "@/internal/types.js";
 import type { ReadonlyDrizzle } from "@/types/db.js";
+import { graphiQLHtml } from "@/ui/graphiql.html.js";
 import { maxAliasesPlugin } from "@escape.tech/graphql-armor-max-aliases";
 import { maxDepthPlugin } from "@escape.tech/graphql-armor-max-depth";
 import { maxTokensPlugin } from "@escape.tech/graphql-armor-max-tokens";
diff --git a/packages/core/src/internal/metrics.ts b/packages/core/src/internal/metrics.ts
index 51215b0e..a0e43da5 100644
--- a/packages/core/src/internal/metrics.ts
+++ b/packages/core/src/internal/metrics.ts
@@ -1,4 +1,4 @@
-import { truncate } from "@/utils/truncate.js";
+import { truncateEventName } from "@/utils/truncate.js";
 import prometheus from "prom-client";
 
 const databaseQueryDurationMs = [
@@ -517,7 +517,7 @@ export async function getIndexingProgress(metrics: MetricsService) {
   }
 
   const events = indexingCompletedEventsMetric.map((m) => {
-    const eventName = truncate(m.labels.event as string);
+    const eventName = truncateEventName(m.labels.event as string);
     const count = m.value;
 
     const durationSum = indexingDurationSum[eventName] ?? 0;
diff --git a/packages/core/src/sync-realtime/index.ts b/packages/core/src/sync-realtime/index.ts
index a84c59b6..5fba35d6 100644
--- a/packages/core/src/sync-realtime/index.ts
+++ b/packages/core/src/sync-realtime/index.ts
@@ -116,7 +116,6 @@ export const createRealtimeSync = (
    * `parentHash` => `hash`.
    */
   let unfinalizedBlocks: LightBlock[] = [];
-  // let queue: Queue<void, BlockWithEventData & { endClock?: () => number }>;
   let consecutiveErrors = 0;
   let interval: NodeJS.Timeout | undefined;
 
@@ -529,9 +528,10 @@ export const createRealtimeSync = (
 
         const msg = `Encountered unrecoverable '${args.network.name}' reorg beyond finalized block ${hexToNumber(finalizedBlock.number)}`;
 
-        args.common.logger.warn({ service: "realtime", msg });
-
-        throw new Error(msg);
+        const error = new Error(msg);
+        args.common.logger.error({ service: "realtime", msg });
+        args.onFatalError(error);
+        return;
       } else {
         remoteBlock = await _eth_getBlockByHash(args.requestQueue, {
           hash: remoteBlock.parentHash,
diff --git a/packages/core/src/ui/ProgressBar.tsx b/packages/core/src/ui/ProgressBar.tsx
new file mode 100644
index 00000000..196231be
--- /dev/null
+++ b/packages/core/src/ui/ProgressBar.tsx
@@ -0,0 +1,16 @@
+import { Text } from "ink";
+import React from "react";
+
+export const ProgressBar = ({ current = 5, end = 10, width = 36 }) => {
+  const maxCount = width || process.stdout.columns || 80;
+
+  const fraction = current / end;
+  const count = Math.min(Math.floor(maxCount * fraction), maxCount);
+
+  return (
+    <Text>
+      <Text>{"█".repeat(count)}</Text>
+      <Text>{"░".repeat(maxCount - count)}</Text>
+    </Text>
+  );
+};
diff --git a/packages/core/src/ui/Table.tsx b/packages/core/src/ui/Table.tsx
new file mode 100644
index 00000000..f7f506f3
--- /dev/null
+++ b/packages/core/src/ui/Table.tsx
@@ -0,0 +1,142 @@
+import { Box, Text, render } from "ink"; // Assuming you're using ink for CLI UI components
+import React from "react";
+
+const MAX_COLUMN_WIDTH = 24;
+
+export function Table<TRow extends { [key: string]: any }>(props: {
+  columns: {
+    title: string;
+    key: keyof TRow;
+    align: "left" | "right";
+    format?: (value: any, row: TRow) => string | number | React.JSX.Element;
+  }[];
+  rows: TRow[];
+}) {
+  const { columns, rows } = props;
+
+  const formattedRows = rows.map((row) =>
+    columns.reduce(
+      (acc, column) => ({
+        ...acc,
+        [column.key.toString()]: column.format
+          ? column.format(row[column.key], row)
+          : row[column.key],
+      }),
+      {} as TRow,
+    ),
+  );
+
+  const columnWidths = columns.map((column) => {
+    let maxWidth = Math.max(
+      ...formattedRows.map((row) =>
+        row[column.key] !== undefined ? row[column.key].toString().length : 9,
+      ),
+      column.title.length,
+    );
+    maxWidth = Math.min(maxWidth, MAX_COLUMN_WIDTH);
+    return maxWidth;
+  });
+
+  return (
+    <Box flexDirection="column">
+      {/* Top Line */}
+      {/* <Box flexDirection="row" key="top">
+        <Text>┌</Text>
+        {columnWidths.map((width, index) => (
+          // biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
+          <Text key={index}>
+            {"─".repeat(width + 2)}
+            {index < columns.length - 1 ? "┬" : "┐"}
+          </Text>
+        ))}
+      </Box> */}
+
+      {/* Column Titles */}
+      <Box flexDirection="row" key="title">
+        {columns.map(({ title }, index) => (
+          <React.Fragment key={`title-${title}`}>
+            <Text>│</Text>
+            <Box
+              width={columnWidths[index]}
+              justifyContent="flex-start"
+              marginX={1}
+            >
+              <Text bold wrap="truncate-end">
+                {title}
+              </Text>
+            </Box>
+          </React.Fragment>
+        ))}
+        <Text>│</Text>
+      </Box>
+
+      {/* Separator Line */}
+      <Box flexDirection="row" key="separator">
+        <Text>├</Text>
+        {columnWidths.map((width, index) => (
+          // biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
+          <Text key={index}>
+            {"─".repeat(width + 2)}
+            {index < columns.length - 1 ? "┼" : "┤"}
+          </Text>
+        ))}
+      </Box>
+
+      {/* Rows of Data */}
+      {formattedRows.map((row, rowIndex) => (
+        <Box
+          flexDirection="row"
+          // biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
+          key={rowIndex}
+        >
+          {columns.map(({ key, align }, index) => (
+            // biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
+            <React.Fragment key={index}>
+              <Text>│</Text>
+              <Box
+                width={columnWidths[index]}
+                justifyContent={align === "left" ? "flex-start" : "flex-end"}
+                marginX={1}
+              >
+                <Text wrap="truncate-end">{row[key]}</Text>
+              </Box>
+            </React.Fragment>
+          ))}
+          <Text>│</Text>
+        </Box>
+      ))}
+
+      {/* Bottom Line */}
+      {/* <Box flexDirection="row" key="bottom">
+        <Text>└</Text>
+        {columnWidths.map((width, index) => (
+          // biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
+          <Text key={index}>
+            {"─".repeat(width + 2)}
+            {index < columns.length - 1 ? "┴" : "┘"}
+          </Text>
+        ))}
+      </Box> */}
+    </Box>
+  );
+}
+
+export function printTable<TRow extends { [key: string]: any }>(props: {
+  columns: {
+    title: string;
+    key: keyof TRow;
+    align: "left" | "right";
+    format?: (value: any, row: TRow) => string | number | React.JSX.Element;
+  }[];
+  rows: TRow[];
+}) {
+  const table = (
+    <>
+      <Text> </Text>
+      <Table {...props} />
+      <Text> </Text>
+    </>
+  );
+  const instance = render(table);
+  instance.cleanup();
+}
diff --git a/packages/core/src/ui/app.ts b/packages/core/src/ui/app.ts
deleted file mode 100644
index dd9ebe8b..00000000
--- a/packages/core/src/ui/app.ts
+++ /dev/null
@@ -1,215 +0,0 @@
-import type {
-  getAppProgress,
-  getIndexingProgress,
-  getSyncProgress,
-} from "@/internal/metrics.js";
-import { formatEta, formatPercentage } from "@/utils/format.js";
-import pc from "picocolors";
-
-export type UiState = {
-  port: number;
-  hostname: string;
-  sync: Awaited<ReturnType<typeof getSyncProgress>>;
-  indexing: Awaited<ReturnType<typeof getIndexingProgress>>;
-  app: Awaited<ReturnType<typeof getAppProgress>>;
-};
-
-export const initialUiState: UiState = {
-  port: 42069,
-  hostname: "localhost",
-  sync: [],
-  indexing: {
-    hasError: false,
-    overall: {
-      completedSeconds: 0,
-      cachedSeconds: 0,
-      totalSeconds: 0,
-      progress: 0,
-      totalEvents: 0,
-    },
-    events: [],
-  },
-  app: {
-    progress: 0,
-    eta: undefined,
-    mode: undefined,
-  },
-};
-
-const buildProgressBar = (current: number, end: number, width = 48): string => {
-  const fraction = current / end;
-  const count = Math.min(Math.floor(width * fraction), width);
-  return "█".repeat(count) + "░".repeat(width - count);
-};
-
-export const buildTable = (
-  rows: { [key: string]: any }[],
-  columns: {
-    title: string;
-    key: string;
-    align: "left" | "right" | string;
-    format?: (value: any, row: { [key: string]: any }) => string | number;
-    maxWidth?: number;
-  }[],
-): string[] => {
-  if (rows.length === 0) {
-    return ["Waiting to start..."];
-  }
-
-  // Calculate column widths
-  const DEFAULT_MAX_COLUMN_WIDTH = 24;
-  const columnWidths = columns.map((column) => {
-    const formattedRows = rows.map((row) => {
-      const value = column.format
-        ? column.format(row[column.key], row)
-        : row[column.key];
-      return value !== undefined ? String(value) : "";
-    });
-
-    const maxWidth = Math.max(
-      ...formattedRows.map((val) => val.toString().length),
-      column.title.length,
-    );
-    return Math.min(maxWidth, column.maxWidth ?? DEFAULT_MAX_COLUMN_WIDTH);
-  });
-
-  // Generate header row
-  const headerRow = [
-    "│ ",
-    columns
-      .map((col, i) => {
-        const width = columnWidths[i] ?? 0;
-        return col.title
-          .padEnd(width, " ")
-          .padStart(col.align === "right" ? width : width, " ");
-      })
-      .join(" │ "),
-    " │",
-  ].join("");
-
-  // Generate separator
-  const separator = [
-    "├─",
-    columnWidths.map((w) => "─".repeat(w)).join("─┼─"),
-    "─┤",
-  ].join("");
-
-  // Generate data rows
-  const dataRows = rows.map((row) => {
-    return [
-      "│ ",
-      columns
-        .map((col, i) => {
-          const width = columnWidths[i] ?? 0;
-          const value = col.format
-            ? col.format(row[col.key], row)
-            : row[col.key];
-          const strValue = value !== undefined ? String(value) : "";
-          return col.align === "right"
-            ? strValue.padStart(width, " ")
-            : strValue.padEnd(width, " ");
-        })
-        .join(" │ "),
-      " │",
-    ].join("");
-  });
-
-  return [headerRow, separator, ...dataRows];
-};
-
-export const buildUiLines = (ui: UiState): string[] => {
-  const { sync, indexing, app, port, hostname } = ui;
-  const lines: string[] = [];
-
-  lines.push("");
-
-  if (indexing.hasError) {
-    lines.push(
-      pc.cyan("Resolve the error and save your changes to reload the server."),
-    );
-    return lines;
-  }
-
-  lines.push(pc.bold("Sync"));
-  lines.push("");
-
-  if (sync.length === 0) {
-    lines.push("Waiting to start...");
-  } else {
-    lines.push(
-      ...buildTable(sync, [
-        {
-          title: "Network",
-          key: "networkName",
-          align: "left",
-        },
-        {
-          title: "Status",
-          key: "status",
-          align: "left",
-          format: (_, row) =>
-            row.status === "historical"
-              ? `${row.status} (${formatPercentage(row.progress)})`
-              : row.status,
-        },
-        {
-          title: "Block",
-          key: "block",
-          align: "right",
-        },
-        {
-          title: "RPC (req/s)",
-          key: "rps",
-          align: "right",
-          format: (_, row) => row.rps.toFixed(1),
-        },
-      ]),
-    );
-  }
-
-  lines.push("");
-  lines.push(pc.bold("Indexing"));
-  lines.push("");
-
-  if (indexing.events.length === 0) {
-    lines.push("Waiting to start...");
-  } else {
-    lines.push(
-      ...buildTable(indexing.events, [
-        { title: "Event", key: "eventName", align: "left", maxWidth: 36 },
-        { title: "Count", key: "count", align: "right" },
-        {
-          title: "Duration (ms)",
-          key: "averageDuration",
-          align: "right",
-          format: (v) => (v > 0 ? (v < 0.001 ? "<0.001" : v.toFixed(3)) : "-"),
-        },
-      ]),
-    );
-  }
-
-  lines.push("");
-
-  let progressLabel = pc.bold("Progress");
-  if (app.mode !== undefined && app.progress !== 0) {
-    progressLabel += ` (${app.mode === "historical" ? pc.yellowBright("historical") : pc.greenBright("live")})`;
-  }
-  lines.push(progressLabel);
-  lines.push("");
-
-  const progressValue = app.mode === "realtime" ? 1 : app.progress;
-  const progressBar = buildProgressBar(progressValue, 1, 48);
-  let progressText = `${progressBar} ${formatPercentage(progressValue)}`;
-
-  if (app.eta !== undefined && app.eta !== 0) {
-    progressText += ` (${formatEta(app.eta * 1_000)} eta)`;
-  }
-
-  lines.push(progressText);
-  lines.push("");
-
-  lines.push(pc.bold("API functions"));
-  lines.push(`Server live at http://${hostname}:${port}`);
-
-  return lines;
-};
diff --git a/packages/core/src/ui/app.tsx b/packages/core/src/ui/app.tsx
new file mode 100644
index 00000000..abb7891f
--- /dev/null
+++ b/packages/core/src/ui/app.tsx
@@ -0,0 +1,174 @@
+import type {
+  getAppProgress,
+  getIndexingProgress,
+  getSyncProgress,
+} from "@/internal/metrics.js";
+import { formatEta, formatPercentage } from "@/utils/format.js";
+import { Box, Text, render as inkRender } from "ink";
+import React from "react";
+import { ProgressBar } from "./ProgressBar.js";
+import { Table } from "./Table.js";
+
+export type UiState = {
+  port: number;
+  hostname: string;
+  sync: Awaited<ReturnType<typeof getSyncProgress>>;
+  indexing: Awaited<ReturnType<typeof getIndexingProgress>>;
+  app: Awaited<ReturnType<typeof getAppProgress>>;
+};
+
+export const buildUiState = (): UiState => {
+  return {
+    port: 42069,
+    hostname: "localhost",
+    sync: [],
+    indexing: {
+      hasError: false,
+      overall: {
+        completedSeconds: 0,
+        cachedSeconds: 0,
+        totalSeconds: 0,
+        progress: 0,
+        totalEvents: 0,
+      },
+      events: [],
+    },
+    app: {
+      progress: 0,
+      eta: undefined,
+      mode: undefined,
+    },
+  };
+};
+
+const App = (ui: UiState) => {
+  const { sync, indexing, app, port, hostname } = ui;
+
+  return (
+    <Box flexDirection="column">
+      <Text> </Text>
+
+      {indexing.hasError ? (
+        <Text color="cyan">
+          Resolve the error and save your changes to reload the server.
+        </Text>
+      ) : (
+        <>
+          <Text bold={true}>Sync</Text>
+          <Text> </Text>
+          {sync.length === 0 ? (
+            <Text>Waiting to start...</Text>
+          ) : (
+            <Table
+              rows={sync}
+              columns={[
+                {
+                  title: "Network",
+                  key: "networkName",
+                  align: "left",
+                },
+                {
+                  title: "Status",
+                  key: "status",
+                  align: "left",
+                  format: (_, row) =>
+                    row.status === "historical"
+                      ? `${row.status} (${formatPercentage(row.progress)})`
+                      : row.status,
+                },
+                {
+                  title: "Block",
+                  key: "block",
+                  align: "right",
+                },
+                {
+                  title: "RPC (req/s)",
+                  key: "rps",
+                  align: "right",
+                  format: (_, row) => row.rps.toFixed(1),
+                },
+              ]}
+            />
+          )}
+          <Text> </Text>
+
+          <Text bold={true}>Indexing</Text>
+          <Text> </Text>
+          {indexing.events.length === 0 ? (
+            <Text>Waiting to start...</Text>
+          ) : (
+            <Table
+              rows={indexing.events}
+              columns={[
+                { title: "Event", key: "eventName", align: "left" },
+                { title: "Count", key: "count", align: "right" },
+                {
+                  title: "Duration (ms)",
+                  key: "averageDuration",
+                  align: "right",
+                  format: (v) =>
+                    v > 0 ? (v < 0.001 ? "<0.001" : v.toFixed(3)) : "-",
+                },
+              ]}
+            />
+          )}
+          <Text> </Text>
+
+          <Box flexDirection="row">
+            <Text bold={true}>Progress </Text>
+            {app.mode === undefined || app.progress === 0 ? null : (
+              <Text>
+                (
+                {app.mode === "historical" ? (
+                  <Text color="yellowBright">historical</Text>
+                ) : (
+                  <Text color="greenBright">live</Text>
+                )}
+                )
+              </Text>
+            )}
+          </Box>
+          <Text> </Text>
+          <Box flexDirection="row">
+            <ProgressBar
+              current={app.mode === "realtime" ? 1 : app.progress}
+              end={1}
+              width={48}
+            />
+            <Text>
+              {" "}
+              {formatPercentage(app.mode === "realtime" ? 1 : app.progress)}
+              {app.eta === undefined || app.eta === 0
+                ? null
+                : ` (${formatEta(app.eta * 1_000)} eta)`}
+            </Text>
+          </Box>
+          <Text> </Text>
+
+          <Box flexDirection="column">
+            <Text bold>API functions </Text>
+            <Box flexDirection="row">
+              <Text>
+                Server live at http://{hostname}:{port}
+              </Text>
+            </Box>
+          </Box>
+        </>
+      )}
+    </Box>
+  );
+};
+
+export const setupInkApp = (ui: UiState) => {
+  const app = inkRender(<App {...ui} />);
+
+  return {
+    render: (newUi: UiState) => {
+      app.rerender(<App {...newUi} />);
+    },
+    unmount: () => {
+      app.clear();
+      app.unmount();
+    },
+  };
+};
diff --git a/packages/core/src/graphql/graphiql.html.ts b/packages/core/src/ui/graphiql.html.ts
similarity index 100%
rename from packages/core/src/graphql/graphiql.html.ts
rename to packages/core/src/ui/graphiql.html.ts
diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts
index e541355e..e3141655 100644
--- a/packages/core/src/ui/index.ts
+++ b/packages/core/src/ui/index.ts
@@ -4,36 +4,26 @@ import {
   getIndexingProgress,
   getSyncProgress,
 } from "@/internal/metrics.js";
-import { buildUiLines, initialUiState } from "./app.js";
-import { patchWriteStreams } from "./patch.js";
+import { buildUiState, setupInkApp } from "./app.js";
 
 export function createUi({ common }: { common: Common }) {
-  const ui = initialUiState;
+  const ui = buildUiState();
+  const { render, unmount } = setupInkApp(ui);
 
-  const { refresh, shutdown } = patchWriteStreams({
-    getLines: () => buildUiLines(ui),
-  });
-
-  // Update the UI state every 100ms (independent of write rate)
-  const stateUpdateInterval = setInterval(async () => {
+  const renderInterval = setInterval(async () => {
     ui.sync = await getSyncProgress(common.metrics);
     ui.indexing = await getIndexingProgress(common.metrics);
     ui.app = await getAppProgress(common.metrics);
 
     if (common.options.hostname) ui.hostname = common.options.hostname;
-    const port = (await common.metrics.ponder_http_server_port.get()).values[0]!
-      .value;
-    if (port !== 0) ui.port = port;
-  }, 100);
+    ui.port = (await common.metrics.ponder_http_server_port.get())
+      .values[0]!.value;
 
-  // Refresh the UI every 32ms
-  const refreshInterval = setInterval(() => {
-    refresh();
-  }, 32);
+    render(ui);
+  }, 100);
 
-  common.shutdown.add(() => {
-    clearInterval(stateUpdateInterval);
-    clearInterval(refreshInterval);
-    shutdown();
+  common.shutdown.add(async () => {
+    clearInterval(renderInterval);
+    unmount();
   });
 }
diff --git a/packages/core/src/ui/patch.ts b/packages/core/src/ui/patch.ts
deleted file mode 100644
index 27205001..00000000
--- a/packages/core/src/ui/patch.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import ansi from "ansi-escapes";
-import terminalSize from "terminal-size";
-
-export function patchWriteStreams({ getLines }: { getLines: () => string[] }) {
-  const originalStdoutWrite = process.stdout.write;
-  const originalStderrWrite = process.stderr.write;
-
-  let isWriting = false;
-  let previousLineCount = 0;
-
-  let terminalWidth = terminalSize().columns;
-
-  const calculateLineCount = (lines: string[]): number => {
-    let count = 0;
-
-    // For each line, calculate how many terminal lines it will occupy
-    for (const line of lines) {
-      // Apply wrapping logic to get actual number of lines
-      const visibleLength = line.replaceAll(ansiEscapeRegex, "").length;
-      count += Math.max(1, Math.ceil(visibleLength / terminalWidth));
-    }
-
-    return count;
-  };
-
-  // Clear previous UI and render new UI
-  const clearAndWriteLines = (lines: string[]) => {
-    if (isWriting && !lines) return;
-
-    const wasAlreadyWriting = isWriting;
-    if (!wasAlreadyWriting) isWriting = true;
-
-    try {
-      const text = [...lines, ""].join("\n"); // Include trailing newline
-
-      // Calculate line count after constructing the text to ensure accuracy
-      const newLineCount = calculateLineCount(lines);
-
-      // Move cursor up to the start of the previous UI
-      if (previousLineCount > 0) {
-        originalStdoutWrite.call(
-          process.stdout,
-          ansi.cursorUp(previousLineCount),
-        );
-      }
-      // Clear all lines of the previous UI and below
-      originalStdoutWrite.call(process.stdout, ansi.eraseDown);
-      // Write the new UI
-      originalStdoutWrite.call(process.stdout, text);
-
-      previousLineCount = newLineCount;
-    } finally {
-      if (!wasAlreadyWriting) isWriting = false;
-    }
-  };
-
-  // Monkey patch stdout and stderr to handle out-of-band output
-  const handleOutput = function (
-    this: NodeJS.WriteStream,
-    buffer: string | Uint8Array,
-    encoding?: BufferEncoding,
-    cb?: (err?: Error) => void,
-  ) {
-    const originalWrite =
-      this === process.stderr ? originalStderrWrite : originalStdoutWrite;
-
-    // If we're already writing, use the original to avoid recursion
-    if (isWriting) {
-      return originalWrite.apply(this, [buffer, encoding, cb]);
-    }
-
-    // Clear the UI
-    if (previousLineCount > 0) {
-      originalStdoutWrite.call(
-        process.stdout,
-        ansi.cursorUp(previousLineCount) + ansi.eraseDown,
-      );
-      previousLineCount = 0;
-    }
-
-    // Write the new content
-    const result = originalWrite.apply(this, [buffer, encoding, cb]);
-
-    // Clear and write the latest UI below the new content
-    const lines = getLines();
-    clearAndWriteLines(lines);
-
-    return result;
-  };
-  process.stdout.write = handleOutput as typeof process.stdout.write;
-  process.stderr.write = handleOutput as typeof process.stderr.write;
-
-  // On terminal resize, reset terminal width and force a re-render
-  const resizeListener = () => {
-    terminalWidth = terminalSize().columns;
-
-    // Clear the UI
-    if (previousLineCount > 0) {
-      originalStdoutWrite.call(
-        process.stdout,
-        ansi.cursorUp(previousLineCount) + ansi.eraseDown,
-      );
-      previousLineCount = 0;
-    }
-
-    // Clear and write the latest UI
-    const lines = getLines();
-    clearAndWriteLines(lines);
-  };
-  process.stdout.on("resize", resizeListener);
-
-  const shutdown = () => {
-    // Restore original write methods
-    process.stdout.write = originalStdoutWrite;
-    process.stderr.write = originalStderrWrite;
-
-    // Clear the UI
-    if (previousLineCount > 0) {
-      originalStdoutWrite.call(
-        process.stdout,
-        ansi.cursorUp(previousLineCount) + ansi.eraseDown,
-      );
-    }
-
-    // Remove resize listener
-    process.stdout.removeListener("resize", resizeListener);
-  };
-
-  return {
-    refresh: () => {
-      const lines = getLines();
-      clearAndWriteLines(lines);
-    },
-    shutdown,
-  };
-}
-
-// Regex to strip ANSI escape sequences from a string
-const ansiEscapeRegex = new RegExp(
-  [
-    "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
-    "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))",
-  ].join("|"),
-  "g",
-);
diff --git a/packages/core/src/utils/truncate.ts b/packages/core/src/utils/truncate.ts
index db6e14a5..d542c9b6 100644
--- a/packages/core/src/utils/truncate.ts
+++ b/packages/core/src/utils/truncate.ts
@@ -2,10 +2,10 @@
  * Truncates a given string to a specified maximum length, adding ellipsis in the middle if necessary.
  *
  * @param string - The string to be truncated.
- * @param maxLength - The maximum length of the truncated string, including the ellipsis. Defaults to 36.
+ * @param maxLength - The maximum length of the truncated string, including the ellipsis. Defaults to 24.
  * @returns The truncated string with ellipsis in the middle if it exceeds the maximum length.
  */
-export const truncate = (string: string, maxLength = 36): string => {
+export const truncateEventName = (string: string, maxLength = 24): string => {
   if (string.length <= maxLength) return string;
 
   const prefixLength = Math.floor(maxLength / 2) - 2; // Keep half the start
diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json
index 7e1913e6..974541a1 100644
--- a/packages/core/tsconfig.build.json
+++ b/packages/core/tsconfig.build.json
@@ -9,6 +9,7 @@
     "declarationMap": true,
     "sourceMap": true,
 
+    "jsx": "react",
     "rootDir": "src",
     "baseUrl": ".",
     "paths": {
diff --git a/packages/create-ponder/CHANGELOG.md b/packages/create-ponder/CHANGELOG.md
index e9ada585..f105c638 100644
--- a/packages/create-ponder/CHANGELOG.md
+++ b/packages/create-ponder/CHANGELOG.md
@@ -1,7 +1,5 @@
 # create-ponder
 
-## 0.9.25
-
 ## 0.9.24
 
 ## 0.9.23
diff --git a/packages/create-ponder/package.json b/packages/create-ponder/package.json
index 57c447a3..64e9d569 100644
--- a/packages/create-ponder/package.json
+++ b/packages/create-ponder/package.json
@@ -1,6 +1,6 @@
 {
   "name": "create-ponder",
-  "version": "0.9.25",
+  "version": "0.9.24",
   "type": "module",
   "description": "A CLI tool to create Ponder apps",
   "license": "MIT",
diff --git a/packages/eslint-config-ponder/CHANGELOG.md b/packages/eslint-config-ponder/CHANGELOG.md
index 4629d367..e39c1118 100644
--- a/packages/eslint-config-ponder/CHANGELOG.md
+++ b/packages/eslint-config-ponder/CHANGELOG.md
@@ -1,7 +1,5 @@
 # eslint-config-ponder
 
-## 0.9.25
-
 ## 0.9.24
 
 ## 0.9.23
diff --git a/packages/eslint-config-ponder/package.json b/packages/eslint-config-ponder/package.json
index 463906dc..ef16e303 100644
--- a/packages/eslint-config-ponder/package.json
+++ b/packages/eslint-config-ponder/package.json
@@ -1,6 +1,6 @@
 {
   "name": "eslint-config-ponder",
-  "version": "0.9.25",
+  "version": "0.9.24",
   "description": "ESLint config for Ponder apps",
   "license": "MIT",
   "main": "./index.js",
diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md
index 65c00393..d8baa747 100644
--- a/packages/react/CHANGELOG.md
+++ b/packages/react/CHANGELOG.md
@@ -1,12 +1,5 @@
 # @ponder/react
 
-## 0.9.25
-
-### Patch Changes
-
-- Updated dependencies []:
-  - @ponder/client@0.9.25
-
 ## 0.9.24
 
 ### Patch Changes
diff --git a/packages/react/package.json b/packages/react/package.json
index bf827e2a..102edcfd 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@ponder/react",
-  "version": "0.9.25",
+  "version": "0.9.24",
   "description": "React hooks for Ponder",
   "license": "MIT",
   "repository": {
@@ -37,7 +37,7 @@
     "superjson": "^2.2.2"
   },
   "peerDependencies": {
-    "@ponder/client": ">=0.9.25",
+    "@ponder/client": ">=0.9.24",
     "@tanstack/react-query": ">=5.0.0",
     "react": ">=18",
     "typescript": ">=5.0.4"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b7841852..b8bff183 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -567,7 +567,7 @@ importers:
     dependencies:
       forge-std:
         specifier: github:foundry-rs/forge-std
-        version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/87989d2f1bd1142be444674eae61437947a16ad6
+        version: https://codeload.github.com/foundry-rs/forge-std/tar.gz/bf909b22fa55e244796dfa920c9639fdffa1c545
 
   examples/with-foundry/ponder:
     dependencies:
@@ -818,9 +818,6 @@ importers:
       abitype:
         specifier: ^0.10.2
         version: 0.10.3(typescript@5.3.3)(zod@3.23.8)
-      ansi-escapes:
-        specifier: ^7.0.0
-        version: 7.0.0
       commander:
         specifier: ^12.0.0
         version: 12.0.0
@@ -851,6 +848,9 @@ importers:
       http-terminator:
         specifier: ^3.2.0
         version: 3.2.0
+      ink:
+        specifier: ^4.4.1
+        version: 4.4.1(@types/react@18.2.46)(react@18.2.0)
       kysely:
         specifier: ^0.26.3
         version: 0.26.3
@@ -867,14 +867,17 @@ importers:
         specifier: 5.1.0
         version: 5.1.0
       picocolors:
-        specifier: ^1.1.1
-        version: 1.1.1
+        specifier: ^1.0.0
+        version: 1.0.0
       pino:
         specifier: ^8.16.2
         version: 8.17.2
       prom-client:
         specifier: ^15.0.0
         version: 15.1.0
+      react:
+        specifier: ^18.2.0
+        version: 18.2.0
       semver:
         specifier: ^7.7.1
         version: 7.7.1
@@ -884,9 +887,6 @@ importers:
       superjson:
         specifier: ^2.2.2
         version: 2.2.2
-      terminal-size:
-        specifier: ^4.0.0
-        version: 4.0.0
       vite:
         specifier: 5.0.7
         version: 5.0.7(@types/node@20.11.24)
@@ -918,6 +918,9 @@ importers:
       '@types/pg-copy-streams':
         specifier: ^1.2.5
         version: 1.2.5
+      '@types/react':
+        specifier: ^18.2.38
+        version: 18.2.46
       '@types/semver':
         specifier: ^7.5.8
         version: 7.5.8
@@ -1076,6 +1079,10 @@ packages:
   '@adraffy/ens-normalize@1.10.0':
     resolution: {integrity: sha512-nA9XHtlAkYfJxY7bce8DcN7eKxWWCWkU+1GR9d+U6MbNpfwQp8TI7vqOsBsMcHoT4mBu2kypKoSKnghEzOOq5Q==}
 
+  '@alcalzone/ansi-tokenize@0.1.3':
+    resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==}
+    engines: {node: '>=14.13.1'}
+
   '@alloc/quick-lru@5.2.0':
     resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
     engines: {node: '>=10'}
@@ -3474,10 +3481,6 @@ packages:
     resolution: {integrity: sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==}
     engines: {node: '>=14.16'}
 
-  ansi-escapes@7.0.0:
-    resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==}
-    engines: {node: '>=18'}
-
   ansi-regex@4.1.1:
     resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==}
     engines: {node: '>=6'}
@@ -3639,6 +3642,10 @@ packages:
   atomically@2.0.2:
     resolution: {integrity: sha512-Xfmb4q5QV7uqTlVdMSTtO5eF4DCHfNOdaPyKlbFShkzeNP+3lj3yjjcbdjSmEY4+pDBKJ9g26aP+ImTe88UHoQ==}
 
+  auto-bind@5.0.1:
+    resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   autoprefixer@10.4.16:
     resolution: {integrity: sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==}
     engines: {node: ^10 || ^12 || >=14}
@@ -3965,6 +3972,10 @@ packages:
     resolution: {integrity: sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg==}
     engines: {node: '>=12'}
 
+  cli-boxes@3.0.0:
+    resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
+    engines: {node: '>=10'}
+
   cli-cursor@3.1.0:
     resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
     engines: {node: '>=8'}
@@ -3985,6 +3996,10 @@ packages:
     resolution: {integrity: sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ==}
     engines: {node: 10.* || >= 12.*}
 
+  cli-truncate@3.1.0:
+    resolution: {integrity: sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   cli-truncate@4.0.0:
     resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
     engines: {node: '>=18'}
@@ -4015,6 +4030,10 @@ packages:
     resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==}
     engines: {node: '>=6'}
 
+  code-excerpt@4.0.0:
+    resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   color-convert@1.9.3:
     resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
 
@@ -4089,6 +4108,10 @@ packages:
   convert-source-map@2.0.0:
     resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
 
+  convert-to-spaces@2.0.1:
+    resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   copy-anything@3.0.5:
     resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
     engines: {node: '>=12.13'}
@@ -4802,10 +4825,6 @@ packages:
     resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
     engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
 
-  environment@1.1.0:
-    resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
-    engines: {node: '>=18'}
-
   err-code@3.0.1:
     resolution: {integrity: sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==}
 
@@ -4869,6 +4888,10 @@ packages:
     resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
     engines: {node: '>=0.8.0'}
 
+  escape-string-regexp@2.0.0:
+    resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
+    engines: {node: '>=8'}
+
   escape-string-regexp@4.0.0:
     resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
     engines: {node: '>=10'}
@@ -5212,8 +5235,8 @@ packages:
   forever-agent@0.6.1:
     resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
 
-  forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/87989d2f1bd1142be444674eae61437947a16ad6:
-    resolution: {tarball: https://codeload.github.com/foundry-rs/forge-std/tar.gz/87989d2f1bd1142be444674eae61437947a16ad6}
+  forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/bf909b22fa55e244796dfa920c9639fdffa1c545:
+    resolution: {tarball: https://codeload.github.com/foundry-rs/forge-std/tar.gz/bf909b22fa55e244796dfa920c9639fdffa1c545}
     version: 1.9.6
 
   form-data@2.3.3:
@@ -5520,8 +5543,8 @@ packages:
   hast-util-to-estree@3.1.0:
     resolution: {integrity: sha512-lfX5g6hqVh9kjS/B9E2gSkvHH4SZNiQFiqWS0x9fENzEl+8W12RqdRxX6d/Cwxi30tPQs3bIO+aolQJNp1bIyw==}
 
-  hast-util-to-html@9.0.5:
-    resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
+  hast-util-to-html@9.0.4:
+    resolution: {integrity: sha512-wxQzXtdbhiwGAUKrnQJXlOPmHnEehzphwkK7aluUPQ+lEc1xefC8pblMgpp2w5ldBTEfveRIrADcrhGIWrlTDA==}
 
   hast-util-to-parse5@8.0.0:
     resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==}
@@ -5643,6 +5666,19 @@ packages:
   ini@1.3.8:
     resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
 
+  ink@4.4.1:
+    resolution: {integrity: sha512-rXckvqPBB0Krifk5rn/5LvQGmyXwCUpBfmTwbkQNBY9JY8RSl3b8OftBNEYxg4+SWUhEKcPifgope28uL9inlA==}
+    engines: {node: '>=14.16'}
+    peerDependencies:
+      '@types/react': '>=18.0.0'
+      react: '>=18.0.0'
+      react-devtools-core: ^4.19.1
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      react-devtools-core:
+        optional: true
+
   inline-style-parser@0.1.1:
     resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==}
 
@@ -5726,6 +5762,10 @@ packages:
     resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
     engines: {node: '>= 0.4'}
 
+  is-ci@3.0.1:
+    resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==}
+    hasBin: true
+
   is-core-module@2.13.1:
     resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==}
 
@@ -5794,6 +5834,9 @@ packages:
     resolution: {integrity: sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==}
     engines: {node: '>=8'}
 
+  is-lower-case@2.0.2:
+    resolution: {integrity: sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==}
+
   is-map@2.0.2:
     resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==}
 
@@ -5881,6 +5924,9 @@ packages:
     resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==}
     engines: {node: '>=12'}
 
+  is-upper-case@2.0.2:
+    resolution: {integrity: sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==}
+
   is-weakmap@2.0.1:
     resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==}
 
@@ -7025,6 +7071,10 @@ packages:
   password-prompt@1.1.3:
     resolution: {integrity: sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw==}
 
+  patch-console@2.0.0:
+    resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   path-case@3.0.4:
     resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==}
 
@@ -7130,9 +7180,6 @@ packages:
   picocolors@1.0.0:
     resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
 
-  picocolors@1.1.1:
-    resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
-
   picomatch@2.3.1:
     resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
     engines: {node: '>=8.6'}
@@ -7313,9 +7360,6 @@ packages:
   property-information@6.5.0:
     resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
 
-  property-information@7.0.0:
-    resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==}
-
   protobufjs@6.11.4:
     resolution: {integrity: sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==}
     hasBin: true
@@ -7390,6 +7434,12 @@ packages:
   react-native-fetch-api@3.0.0:
     resolution: {integrity: sha512-g2rtqPjdroaboDKTsJCTlcmtw54E25OjyaunUP0anOZn4Fuo2IKs8BVfe02zVggA/UysbmfSnRJIqtNkAgggNA==}
 
+  react-reconciler@0.29.0:
+    resolution: {integrity: sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==}
+    engines: {node: '>=0.10.0'}
+    peerDependencies:
+      react: ^18.2.0
+
   react@18.2.0:
     resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
     engines: {node: '>=0.10.0'}
@@ -7768,6 +7818,10 @@ packages:
     resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
     engines: {node: '>=12'}
 
+  slice-ansi@6.0.0:
+    resolution: {integrity: sha512-6bn4hRfkTvDfUoEQYkERg0BVF1D0vrX9HEkMl08uDiNWvVvjylLHvZFZWkDo6wjT8tUctbYl1nCOuE66ZTaUtA==}
+    engines: {node: '>=14.16'}
+
   slice-ansi@7.1.0:
     resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==}
     engines: {node: '>=18'}
@@ -7843,6 +7897,10 @@ packages:
     engines: {node: '>=0.10.0'}
     hasBin: true
 
+  stack-utils@2.0.6:
+    resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
+    engines: {node: '>=10'}
+
   stackback@0.0.2:
     resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
 
@@ -8068,10 +8126,6 @@ packages:
     resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
     engines: {node: '>=8'}
 
-  terminal-size@4.0.0:
-    resolution: {integrity: sha512-rcdty1xZ2/BkWa4ANjWRp4JGpda2quksXIHgn5TMjNBPZfwzJIgR68DKfSYiTL+CZWowDX/sbOo5ME/FRURvYQ==}
-    engines: {node: '>=18'}
-
   text-table@0.2.0:
     resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
 
@@ -8246,6 +8300,10 @@ packages:
     resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
     engines: {node: '>=4'}
 
+  type-fest@0.12.0:
+    resolution: {integrity: sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg==}
+    engines: {node: '>=10'}
+
   type-fest@0.13.1:
     resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
     engines: {node: '>=10'}
@@ -8667,6 +8725,10 @@ packages:
     resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==}
     engines: {node: '>=8'}
 
+  widest-line@4.0.1:
+    resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==}
+    engines: {node: '>=12'}
+
   wordwrap@1.0.0:
     resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
 
@@ -8785,6 +8847,9 @@ packages:
     resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
     engines: {node: '>=12.20'}
 
+  yoga-wasm-web@0.3.3:
+    resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==}
+
   zod-validation-error@1.5.0:
     resolution: {integrity: sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw==}
     engines: {node: '>=16.0.0'}
@@ -8803,6 +8868,11 @@ snapshots:
 
   '@adraffy/ens-normalize@1.10.0': {}
 
+  '@alcalzone/ansi-tokenize@0.1.3':
+    dependencies:
+      ansi-styles: 6.2.1
+      is-fullwidth-code-point: 4.0.0
+
   '@alloc/quick-lru@5.2.0': {}
 
   '@ampproject/remapping@2.2.1':
@@ -11258,7 +11328,7 @@ snapshots:
       node-fetch: 3.3.2
       ora: 6.3.1
       pathe: 1.1.1
-      picocolors: 1.1.1
+      picocolors: 1.0.0
       prettier: 2.8.8
       viem: 1.21.4(typescript@5.3.3)(zod@3.23.8)
       zod: 3.23.8
@@ -11387,10 +11457,6 @@ snapshots:
     dependencies:
       type-fest: 3.13.1
 
-  ansi-escapes@7.0.0:
-    dependencies:
-      environment: 1.1.0
-
   ansi-regex@4.1.1: {}
 
   ansi-regex@5.0.1: {}
@@ -11558,13 +11624,15 @@ snapshots:
       stubborn-fs: 1.2.5
       when-exit: 2.1.2
 
+  auto-bind@5.0.1: {}
+
   autoprefixer@10.4.16(postcss@8.4.32):
     dependencies:
       browserslist: 4.22.2
       caniuse-lite: 1.0.30001572
       fraction.js: 4.3.7
       normalize-range: 0.1.2
-      picocolors: 1.1.1
+      picocolors: 1.0.0
       postcss: 8.4.32
       postcss-value-parser: 4.2.0
 
@@ -11931,6 +11999,8 @@ snapshots:
     dependencies:
       escape-string-regexp: 5.0.0
 
+  cli-boxes@3.0.0: {}
+
   cli-cursor@3.1.0:
     dependencies:
       restore-cursor: 3.1.0
@@ -11952,6 +12022,11 @@ snapshots:
     optionalDependencies:
       colors: 1.4.0
 
+  cli-truncate@3.1.0:
+    dependencies:
+      slice-ansi: 5.0.0
+      string-width: 5.1.2
+
   cli-truncate@4.0.0:
     dependencies:
       slice-ansi: 5.0.0
@@ -11982,6 +12057,10 @@ snapshots:
 
   clsx@2.1.0: {}
 
+  code-excerpt@4.0.0:
+    dependencies:
+      convert-to-spaces: 2.0.1
+
   color-convert@1.9.3:
     dependencies:
       color-name: 1.1.3
@@ -12049,6 +12128,8 @@ snapshots:
 
   convert-source-map@2.0.0: {}
 
+  convert-to-spaces@2.0.1: {}
+
   copy-anything@3.0.5:
     dependencies:
       is-what: 4.1.16
@@ -12649,8 +12730,6 @@ snapshots:
 
   env-paths@3.0.0: {}
 
-  environment@1.1.0: {}
-
   err-code@3.0.1: {}
 
   error-ex@1.3.2:
@@ -12852,6 +12931,8 @@ snapshots:
 
   escape-string-regexp@1.0.5: {}
 
+  escape-string-regexp@2.0.0: {}
+
   escape-string-regexp@4.0.0: {}
 
   escape-string-regexp@5.0.0: {}
@@ -13341,7 +13422,7 @@ snapshots:
 
   forever-agent@0.6.1: {}
 
-  forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/87989d2f1bd1142be444674eae61437947a16ad6: {}
+  forge-std@https://codeload.github.com/foundry-rs/forge-std/tar.gz/bf909b22fa55e244796dfa920c9639fdffa1c545: {}
 
   form-data@2.3.3:
     dependencies:
@@ -13766,7 +13847,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  hast-util-to-html@9.0.5:
+  hast-util-to-html@9.0.4:
     dependencies:
       '@types/hast': 3.0.4
       '@types/unist': 3.0.3
@@ -13775,7 +13856,7 @@ snapshots:
       hast-util-whitespace: 3.0.0
       html-void-elements: 3.0.0
       mdast-util-to-hast: 13.2.0
-      property-information: 7.0.0
+      property-information: 6.5.0
       space-separated-tokens: 2.0.2
       stringify-entities: 4.0.4
       zwitch: 2.0.4
@@ -13908,6 +13989,40 @@ snapshots:
 
   ini@1.3.8: {}
 
+  ink@4.4.1(@types/react@18.2.46)(react@18.2.0):
+    dependencies:
+      '@alcalzone/ansi-tokenize': 0.1.3
+      ansi-escapes: 6.2.0
+      auto-bind: 5.0.1
+      chalk: 5.4.1
+      cli-boxes: 3.0.0
+      cli-cursor: 4.0.0
+      cli-truncate: 3.1.0
+      code-excerpt: 4.0.0
+      indent-string: 5.0.0
+      is-ci: 3.0.1
+      is-lower-case: 2.0.2
+      is-upper-case: 2.0.2
+      lodash: 4.17.21
+      patch-console: 2.0.0
+      react: 18.2.0
+      react-reconciler: 0.29.0(react@18.2.0)
+      scheduler: 0.23.0
+      signal-exit: 3.0.7
+      slice-ansi: 6.0.0
+      stack-utils: 2.0.6
+      string-width: 5.1.2
+      type-fest: 0.12.0
+      widest-line: 4.0.1
+      wrap-ansi: 8.1.0
+      ws: 8.17.1
+      yoga-wasm-web: 0.3.3
+    optionalDependencies:
+      '@types/react': 18.2.46
+    transitivePeerDependencies:
+      - bufferutil
+      - utf-8-validate
+
   inline-style-parser@0.1.1: {}
 
   interface-datastore@6.1.1:
@@ -14056,6 +14171,10 @@ snapshots:
 
   is-callable@1.2.7: {}
 
+  is-ci@3.0.1:
+    dependencies:
+      ci-info: 3.9.0
+
   is-core-module@2.13.1:
     dependencies:
       hasown: 2.0.0
@@ -14106,6 +14225,10 @@ snapshots:
     dependencies:
       ip-regex: 4.3.0
 
+  is-lower-case@2.0.2:
+    dependencies:
+      tslib: 2.6.2
+
   is-map@2.0.2: {}
 
   is-negative-zero@2.0.2: {}
@@ -14169,6 +14292,10 @@ snapshots:
 
   is-unicode-supported@1.3.0: {}
 
+  is-upper-case@2.0.2:
+    dependencies:
+      tslib: 2.6.2
+
   is-weakmap@2.0.1: {}
 
   is-weakref@1.0.2:
@@ -15805,6 +15932,8 @@ snapshots:
       ansi-escapes: 4.3.2
       cross-spawn: 7.0.3
 
+  patch-console@2.0.0: {}
+
   path-case@3.0.4:
     dependencies:
       dot-case: 3.0.4
@@ -15908,8 +16037,6 @@ snapshots:
 
   picocolors@1.0.0: {}
 
-  picocolors@1.1.1: {}
-
   picomatch@2.3.1: {}
 
   pidtree@0.6.0: {}
@@ -15996,13 +16123,13 @@ snapshots:
   postcss@8.4.31:
     dependencies:
       nanoid: 3.3.7
-      picocolors: 1.1.1
+      picocolors: 1.0.0
       source-map-js: 1.0.2
 
   postcss@8.4.32:
     dependencies:
       nanoid: 3.3.7
-      picocolors: 1.1.1
+      picocolors: 1.0.0
       source-map-js: 1.0.2
 
   postgres-array@2.0.0: {}
@@ -16083,8 +16210,6 @@ snapshots:
 
   property-information@6.5.0: {}
 
-  property-information@7.0.0: {}
-
   protobufjs@6.11.4:
     dependencies:
       '@protobufjs/aspromise': 1.1.2
@@ -16164,6 +16289,12 @@ snapshots:
     dependencies:
       p-defer: 3.0.0
 
+  react-reconciler@0.29.0(react@18.2.0):
+    dependencies:
+      loose-envify: 1.4.0
+      react: 18.2.0
+      scheduler: 0.23.0
+
   react@18.2.0:
     dependencies:
       loose-envify: 1.4.0
@@ -16597,7 +16728,7 @@ snapshots:
 
   shikiji@0.6.10:
     dependencies:
-      hast-util-to-html: 9.0.5
+      hast-util-to-html: 9.0.4
 
   side-channel@1.0.4:
     dependencies:
@@ -16632,6 +16763,11 @@ snapshots:
       ansi-styles: 6.2.1
       is-fullwidth-code-point: 4.0.0
 
+  slice-ansi@6.0.0:
+    dependencies:
+      ansi-styles: 6.2.1
+      is-fullwidth-code-point: 4.0.0
+
   slice-ansi@7.1.0:
     dependencies:
       ansi-styles: 6.2.1
@@ -16719,6 +16855,10 @@ snapshots:
       safer-buffer: 2.1.2
       tweetnacl: 0.14.5
 
+  stack-utils@2.0.6:
+    dependencies:
+      escape-string-regexp: 2.0.0
+
   stackback@0.0.2: {}
 
   stacktrace-parser@0.1.10:
@@ -16910,7 +17050,7 @@ snapshots:
       css-tree: 2.3.1
       css-what: 6.1.0
       csso: 5.0.5
-      picocolors: 1.1.1
+      picocolors: 1.0.0
 
   sync-request@6.1.0:
     dependencies:
@@ -16945,7 +17085,7 @@ snapshots:
       micromatch: 4.0.5
       normalize-path: 3.0.0
       object-hash: 3.0.0
-      picocolors: 1.1.1
+      picocolors: 1.0.0
       postcss: 8.4.32
       postcss-import: 15.1.0(postcss@8.4.32)
       postcss-js: 4.0.1(postcss@8.4.32)
@@ -16991,8 +17131,6 @@ snapshots:
 
   term-size@2.2.1: {}
 
-  terminal-size@4.0.0: {}
-
   text-table@0.2.0: {}
 
   then-request@6.0.2:
@@ -17216,6 +17354,8 @@ snapshots:
 
   type-detect@4.0.8: {}
 
+  type-fest@0.12.0: {}
+
   type-fest@0.13.1: {}
 
   type-fest@0.20.2: {}
@@ -17409,7 +17549,7 @@ snapshots:
     dependencies:
       browserslist: 4.22.2
       escalade: 3.1.1
-      picocolors: 1.1.1
+      picocolors: 1.0.0
 
   update-check@1.5.4:
     dependencies:
@@ -17555,7 +17695,7 @@ snapshots:
       cac: 6.7.14
       debug: 4.4.0(supports-color@8.1.1)
       pathe: 1.1.1
-      picocolors: 1.1.1
+      picocolors: 1.0.0
       vite: 5.0.7(@types/node@20.11.24)
     transitivePeerDependencies:
       - '@types/node'
@@ -17602,7 +17742,7 @@ snapshots:
       local-pkg: 0.5.0
       magic-string: 0.30.5
       pathe: 1.1.1
-      picocolors: 1.1.1
+      picocolors: 1.0.0
       std-env: 3.7.0
       strip-literal: 1.3.0
       tinybench: 2.5.1
@@ -17745,6 +17885,10 @@ snapshots:
     dependencies:
       string-width: 4.2.3
 
+  widest-line@4.0.1:
+    dependencies:
+      string-width: 5.1.2
+
   wordwrap@1.0.0: {}
 
   wrap-ansi@6.2.0:
@@ -17834,6 +17978,8 @@ snapshots:
 
   yocto-queue@1.0.0: {}
 
+  yoga-wasm-web@0.3.3: {}
+
   zod-validation-error@1.5.0(zod@3.23.8):
     dependencies:
       zod: 3.23.8


Test patch:
diff --git a/packages/core/src/sync-realtime/index.test.ts b/packages/core/src/sync-realtime/index.test.ts
index 725b4932..a88b6a02 100644
--- a/packages/core/src/sync-realtime/index.test.ts
+++ b/packages/core/src/sync-realtime/index.test.ts
@@ -975,13 +975,15 @@ test("handleReorg() throws error for deep reorg", async (context) => {
     blockNumber: 0,
   });
 
+  const spy = vi.fn();
+
   const realtimeSync = createRealtimeSync({
     common,
     network,
     requestQueue,
     sources,
     onEvent: vi.fn(),
-    onFatalError: vi.fn(),
+    onFatalError: spy,
   });
 
   await testClient.mine({ blocks: 3 });
@@ -1007,4 +1009,5 @@ test("handleReorg() throws error for deep reorg", async (context) => {
   });
 
   expect(realtimeSync.unfinalizedBlocks).toHaveLength(0);
+  expect(spy).toHaveBeenCalledWith(expect.any(Error));
 });
diff --git a/packages/core/src/utils/truncate.test.ts b/packages/core/src/utils/truncate.test.ts
index 632efee4..76a97789 100644
--- a/packages/core/src/utils/truncate.test.ts
+++ b/packages/core/src/utils/truncate.test.ts
@@ -1,21 +1,21 @@
 import { expect } from "vitest";
 import { test } from "vitest";
-import { truncate } from "./truncate.js";
+import { truncateEventName } from "./truncate.js";
 
 test("truncates a long string correctly", () => {
-  expect(truncate("EnergyMarketOrderBookV1Contract", 24)).toBe(
+  expect(truncateEventName("EnergyMarketOrderBookV1Contract", 24)).toBe(
     "EnergyMark...V1Contract",
   );
 });
 
 test("returns the same string if it's within maxLength", () => {
-  expect(truncate("EnergyMarketOrderBookV1Contract", 31)).toBe(
+  expect(truncateEventName("EnergyMarketOrderBookV1Contract", 31)).toBe(
     "EnergyMarketOrderBookV1Contract",
   );
 });
 
 test("truncates a long string correctly with default maxLength", () => {
-  expect(truncate("EnergyMarketOrderBookV1Contract:Action")).toBe(
-    "EnergyMarketOrde...1Contract:Action",
+  expect(truncateEventName("EnergyMarketOrderBookV1Contract")).toBe(
+    "EnergyMark...V1Contract",
   );
 });