Repository: ponder-sh/ponder
Pull request #1458 (2025-01-22 16:16:14)

fix: indexing error reporting


Patch:
diff --git a/.changeset/fluffy-pumas-wink.md b/.changeset/fluffy-pumas-wink.md
new file mode 100644
index 00000000..9dc23441
--- /dev/null
+++ b/.changeset/fluffy-pumas-wink.md
@@ -0,0 +1,5 @@
+---
+"ponder": patch
+---
+
+Fixed a bug where indexing errors would sometimes be obfuscated by an internal error like `Cannot read properties of undefined (reading 'hash')`.
diff --git a/packages/core/src/indexing/service.ts b/packages/core/src/indexing/service.ts
index bd59fabf..12b82afe 100644
--- a/packages/core/src/indexing/service.ts
+++ b/packages/core/src/indexing/service.ts
@@ -1,6 +1,5 @@
 import type { IndexingFunctions } from "@/build/configAndIndexingFunctions.js";
 import type { Common } from "@/common/common.js";
-import { BaseError } from "@/common/errors.js";
 import type { Network } from "@/config/networks.js";
 import type { Schema } from "@/drizzle/index.js";
 import type { IndexingStore } from "@/indexing-store/index.js";
@@ -12,6 +11,7 @@ import {
 } from "@/sync/source.js";
 import type { Db } from "@/types/db.js";
 import type { Block, Log, Trace, Transaction } from "@/types/eth.js";
+import type { DeepPartial } from "@/types/utils.js";
 import {
   type Checkpoint,
   decodeCheckpoint,
@@ -385,13 +385,7 @@ const executeSetup = async (
     const error = _error instanceof Error ? _error : new Error(String(_error));
 
     addStackTrace(error, common.options);
-
-    if (error instanceof BaseError) {
-      error.meta.push(toErrorMeta(event));
-    } else {
-      // @ts-expect-error
-      error.meta = [toErrorMeta(event)];
-    }
+    addErrorMeta(error, toErrorMeta(event));
 
     const decodedCheckpoint = decodeCheckpoint(event.checkpoint);
     common.logger.error({
@@ -451,13 +445,7 @@ const executeEvent = async (
     const error = _error instanceof Error ? _error : new Error(String(_error));
 
     addStackTrace(error, common.options);
-
-    if (error instanceof BaseError) {
-      error.meta.push(toErrorMeta(event));
-    } else {
-      // @ts-expect-error
-      error.meta = [toErrorMeta(event)];
-    }
+    addErrorMeta(error, toErrorMeta(event));
 
     const decodedCheckpoint = decodeCheckpoint(event.checkpoint);
 
@@ -475,77 +463,100 @@ const executeEvent = async (
   return { status: "success" };
 };
 
-const blockText = (block: Block) =>
-  `Block:\n${prettyPrint({
-    hash: block.hash,
-    number: block.number,
-    timestamp: block.timestamp,
-  })}`;
-
-const transactionText = (transaction: Transaction) =>
-  `Transaction:\n${prettyPrint({
-    hash: transaction.hash,
-    from: transaction.from,
-    to: transaction.to,
-  })}`;
-
-const logText = (log: Log) =>
-  `Log:\n${prettyPrint({
-    index: log.logIndex,
-    address: log.address,
-  })}`;
-
-const traceText = (trace: Trace) =>
-  `Trace:\n${prettyPrint({
-    traceIndex: trace.traceIndex,
-    from: trace.from,
-    to: trace.to,
-  })}`;
-
-const toErrorMeta = (event: Event | SetupEvent) => {
-  switch (event.type) {
+const toErrorMeta = (event: DeepPartial<Event> | DeepPartial<SetupEvent>) => {
+  switch (event?.type) {
     case "setup": {
       return `Block:\n${prettyPrint({
-        number: event.block,
+        number: event?.block,
       })}`;
     }
 
     case "log": {
       return [
-        `Event arguments:\n${prettyPrint(event.event.args)}`,
-        logText(event.event.log),
-        transactionText(event.event.transaction),
-        blockText(event.event.block),
+        `Event arguments:\n${prettyPrint(event?.event?.args)}`,
+        logText(event?.event?.log),
+        transactionText(event?.event?.transaction),
+        blockText(event?.event?.block),
       ].join("\n");
     }
 
     case "trace": {
       return [
-        `Call trace arguments:\n${prettyPrint(event.event.args)}`,
-        traceText(event.event.trace),
-        transactionText(event.event.transaction),
-        blockText(event.event.block),
+        `Call trace arguments:\n${prettyPrint(event?.event?.args)}`,
+        traceText(event?.event?.trace),
+        transactionText(event?.event?.transaction),
+        blockText(event?.event?.block),
       ].join("\n");
     }
 
     case "transfer": {
       return [
-        `Transfer arguments:\n${prettyPrint(event.event.transfer)}`,
-        traceText(event.event.trace),
-        transactionText(event.event.transaction),
-        blockText(event.event.block),
+        `Transfer arguments:\n${prettyPrint(event?.event?.transfer)}`,
+        traceText(event?.event?.trace),
+        transactionText(event?.event?.transaction),
+        blockText(event?.event?.block),
       ].join("\n");
     }
 
     case "block": {
-      return blockText(event.event.block);
+      return blockText(event?.event?.block);
     }
 
     case "transaction": {
       return [
-        transactionText(event.event.transaction),
-        blockText(event.event.block),
+        transactionText(event?.event?.transaction),
+        blockText(event?.event?.block),
       ].join("\n");
     }
+
+    default: {
+      return undefined;
+    }
   }
 };
+
+const addErrorMeta = (error: unknown, meta: string | undefined) => {
+  // If error isn't an object we can modify, do nothing
+  if (typeof error !== "object" || error === null) return;
+  if (meta === undefined) return;
+
+  try {
+    const errorObj = error as { meta?: unknown };
+    // If meta exists and is an array, try to add to it
+    if (Array.isArray(errorObj.meta)) {
+      errorObj.meta = [...errorObj.meta, meta];
+    } else {
+      // Otherwise set meta to be a new array with the meta string
+      errorObj.meta = [meta];
+    }
+  } catch {
+    // Ignore errors
+  }
+};
+
+const blockText = (block?: DeepPartial<Block>) =>
+  `Block:\n${prettyPrint({
+    hash: block?.hash,
+    number: block?.number,
+    timestamp: block?.timestamp,
+  })}`;
+
+const transactionText = (transaction?: DeepPartial<Transaction>) =>
+  `Transaction:\n${prettyPrint({
+    hash: transaction?.hash,
+    from: transaction?.from,
+    to: transaction?.to,
+  })}`;
+
+const logText = (log?: DeepPartial<Log>) =>
+  `Log:\n${prettyPrint({
+    index: log?.logIndex,
+    address: log?.address,
+  })}`;
+
+const traceText = (trace?: DeepPartial<Trace>) =>
+  `Trace:\n${prettyPrint({
+    traceIndex: trace?.traceIndex,
+    from: trace?.from,
+    to: trace?.to,
+  })}`;
diff --git a/packages/core/src/types/utils.ts b/packages/core/src/types/utils.ts
index 0f9e6d08..fce082c2 100644
--- a/packages/core/src/types/utils.ts
+++ b/packages/core/src/types/utils.ts
@@ -18,3 +18,11 @@ export type NonNull<T> = {
 };
 
 export type PonderTypeError<error extends string> = error;
+
+export type DeepPartial<T> = {
+  [P in keyof T]?: T[P] extends (infer U)[]
+    ? DeepPartial<U>[]
+    : T[P] extends object
+      ? DeepPartial<T[P]>
+      : T[P];
+};
diff --git a/packages/core/src/utils/print.ts b/packages/core/src/utils/print.ts
index f849bf88..46b161cb 100644
--- a/packages/core/src/utils/print.ts
+++ b/packages/core/src/utils/print.ts
@@ -1,8 +1,10 @@
 // Adapted from viem.
 // https://github.com/wagmi-dev/viem/blob/021ce8e5a3fb02db6139564345a91fc77cba08a6/src/errors/transaction.ts#L6-L19
 export function prettyPrint(
-  args: Record<string, bigint | number | string | undefined | false | unknown>,
+  args?: Record<string, bigint | number | string | undefined | false | unknown>,
 ) {
+  if (args === undefined) return "(undefined)";
+
   const entries = Object.entries(args)
     .map(([key, value]) => {
       if (value === undefined) return null;


Test patch:
diff --git a/packages/core/src/indexing/service.test.ts b/packages/core/src/indexing/service.test.ts
index de683397..80b7da3a 100644
--- a/packages/core/src/indexing/service.test.ts
+++ b/packages/core/src/indexing/service.test.ts
@@ -913,6 +913,81 @@ test("processEvents() error", async (context) => {
   await cleanup();
 });
 
+test("processEvents() error with missing event object properties", async (context) => {
+  const { common } = context;
+  const { syncStore, indexingStore, cleanup } = await setupDatabaseServices(
+    context,
+    { schema },
+  );
+
+  const sync = await createSync({
+    common,
+    syncStore,
+    networks,
+    sources,
+    onRealtimeEvent: () => Promise.resolve(),
+    onFatalError: () => {},
+    initialCheckpoint: encodeCheckpoint(zeroCheckpoint),
+  });
+
+  const throwError = async ({ event }: { event: any; context: Context }) => {
+    // biome-ignore lint/performance/noDelete: <explanation>
+    delete event.transaction;
+    throw new Error("empty transaction");
+  };
+
+  const indexingFunctions = {
+    "Erc20:Transfer(address indexed from, address indexed to, uint256 amount)":
+      throwError,
+  };
+
+  const indexingService = create({
+    indexingFunctions,
+    common,
+    sources,
+    networks,
+    sync,
+  });
+
+  setIndexingStore(indexingService, indexingStore);
+
+  const topics = encodeEventTopics({
+    abi: erc20ABI,
+    eventName: "Transfer",
+    args: {
+      from: zeroAddress,
+      to: ALICE,
+    },
+  });
+
+  const data = padHex(toHex(parseEther("1")), { size: 32 });
+
+  const rawEvent = {
+    chainId: 1,
+    sourceIndex: 0,
+    checkpoint: encodeCheckpoint(zeroCheckpoint),
+    block: {} as RawEvent["block"],
+    transaction: {} as RawEvent["transaction"],
+    log: {
+      id: "test",
+      data,
+      topics,
+    },
+  } as RawEvent;
+
+  const events = decodeEvents(common, sources, [rawEvent]);
+  const result = await processEvents(indexingService, { events });
+
+  expect(result).toMatchInlineSnapshot(`
+    {
+      "error": [Error: empty transaction],
+      "status": "error",
+    }
+  `);
+
+  await cleanup();
+});
+
 test("execute() error after killed", async (context) => {
   const { common } = context;
   const { syncStore, indexingStore, cleanup } = await setupDatabaseServices(