Repository: ponder-sh/ponder
Pull request #793 (2024-04-12 01:27:58)

Refactor build service

kyscott18: Agree

Patch:
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 43637374..ab3c88d1 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,11 @@
 # @ponder/core
 
+## 0.4.3
+
+### Patch Changes
+
+- [#799](https://github.com/ponder-sh/ponder/pull/799) [`f526c79ceeee880df611ed592de06e7fb3146af5`](https://github.com/ponder-sh/ponder/commit/f526c79ceeee880df611ed592de06e7fb3146af5) Thanks [@kyscott18](https://github.com/kyscott18)! - Added support for Harmony by updating the "totalDifficulty" block property to be nullable.
+
 ## 0.4.2
 
 ### Patch Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index b6b547d5..76546344 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@ponder/core",
-  "version": "0.4.2",
+  "version": "0.4.3",
   "description": "An open-source framework for crypto application backends",
   "license": "MIT",
   "type": "module",
diff --git a/packages/core/src/bin/commands/codegen.ts b/packages/core/src/bin/commands/codegen.ts
index 92c88baa..1fe1318b 100644
--- a/packages/core/src/bin/commands/codegen.ts
+++ b/packages/core/src/bin/commands/codegen.ts
@@ -1,4 +1,4 @@
-import { BuildService } from "@/build/service.js";
+import { createBuildService } from "@/build/index.js";
 import { runCodegen } from "@/common/codegen.js";
 import { LoggerService } from "@/common/logger.js";
 import { MetricsService } from "@/common/metrics.js";
@@ -28,8 +28,7 @@ export async function codegen({ cliOptions }: { cliOptions: CliOptions }) {
   const telemetry = new TelemetryService({ options });
   const common = { options, logger, metrics, telemetry };
 
-  const buildService = new BuildService({ common });
-  await buildService.setup({ watch: false });
+  const buildService = await createBuildService({ common });
 
   const cleanup = async () => {
     await buildService.kill();
@@ -38,18 +37,19 @@ export async function codegen({ cliOptions }: { cliOptions: CliOptions }) {
 
   const shutdown = setupShutdown({ common, cleanup });
 
-  const schemaResult = await buildService.loadSchema();
-  if (schemaResult.error) {
+  const buildResult = await buildService.start({ watch: false });
+
+  if (buildResult.status === "error") {
     logger.error({
       service: "process",
       msg: "Failed schema build with error:",
-      error: schemaResult.error,
+      error: buildResult.error,
     });
     await shutdown({ reason: "Failed schema build", code: 1 });
     return;
   }
 
-  runCodegen({ common, graphqlSchema: schemaResult.graphqlSchema });
+  runCodegen({ common, graphqlSchema: buildResult.build.graphqlSchema });
 
   logger.info({ service: "codegen", msg: "Wrote ponder-env.d.ts" });
   logger.info({ service: "codegen", msg: "Wrote schema.graphql" });
diff --git a/packages/core/src/bin/commands/dev.ts b/packages/core/src/bin/commands/dev.ts
index 57be855f..7da1a74a 100644
--- a/packages/core/src/bin/commands/dev.ts
+++ b/packages/core/src/bin/commands/dev.ts
@@ -1,6 +1,6 @@
 import { existsSync } from "node:fs";
 import path from "node:path";
-import { type BuildResult, BuildService } from "@/build/service.js";
+import { type BuildResult, createBuildService } from "@/build/index.js";
 import { LoggerService } from "@/common/logger.js";
 import { MetricsService } from "@/common/metrics.js";
 import { buildOptions } from "@/common/options.js";
@@ -45,8 +45,7 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) {
   const telemetry = new TelemetryService({ options });
   const common = { options, logger, metrics, telemetry };
 
-  const buildService = new BuildService({ common });
-  await buildService.setup({ watch: true });
+  const buildService = await createBuildService({ common });
 
   const uiService = new UiService({ common });
 
@@ -61,28 +60,13 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) {
 
   const shutdown = setupShutdown({ common, cleanup });
 
-  const initialResult = await buildService.initialLoad();
-  if (!initialResult.success) {
-    await shutdown({ reason: "Failed intial build", code: 1 });
-    return cleanup;
-  }
-
-  telemetry.record({
-    event: "App Started",
-    properties: {
-      command: "ponder dev",
-      contractCount: initialResult.build.sources.length,
-      databaseKind: initialResult.build.databaseConfig.kind,
-    },
-  });
-
   const buildQueue = createQueue({
     initialStart: true,
     concurrency: 1,
     worker: async (result: BuildResult) => {
       await cleanupReloadable();
 
-      if (result.success) {
+      if (result.status === "success") {
         uiService.reset();
         metrics.resetMetrics();
 
@@ -94,7 +78,7 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) {
           },
           onReloadableError: (error) => {
             buildQueue.clear();
-            buildQueue.add({ success: false, error });
+            buildQueue.add({ status: "error", error });
           },
         });
       } else {
@@ -105,9 +89,26 @@ export async function dev({ cliOptions }: { cliOptions: CliOptions }) {
     },
   });
 
-  buildService.on("rebuild", (build) => {
-    buildQueue.clear();
-    buildQueue.add(build);
+  const initialResult = await buildService.start({
+    watch: true,
+    onBuild: (buildResult) => {
+      buildQueue.clear();
+      buildQueue.add(buildResult);
+    },
+  });
+
+  if (initialResult.status === "error") {
+    await shutdown({ reason: "Failed intial build", code: 1 });
+    return cleanup;
+  }
+
+  telemetry.record({
+    event: "App Started",
+    properties: {
+      command: "ponder dev",
+      contractCount: initialResult.build.sources.length,
+      databaseKind: initialResult.build.databaseConfig.kind,
+    },
   });
 
   buildQueue.add(initialResult);
diff --git a/packages/core/src/bin/commands/serve.ts b/packages/core/src/bin/commands/serve.ts
index 8fec3dfb..177a56a6 100644
--- a/packages/core/src/bin/commands/serve.ts
+++ b/packages/core/src/bin/commands/serve.ts
@@ -1,5 +1,5 @@
 import path from "node:path";
-import { BuildService } from "@/build/service.js";
+import { createBuildService } from "@/build/index.js";
 import { LoggerService } from "@/common/logger.js";
 import { MetricsService } from "@/common/metrics.js";
 import { buildOptions } from "@/common/options.js";
@@ -38,8 +38,7 @@ export async function serve({ cliOptions }: { cliOptions: CliOptions }) {
   const telemetry = new TelemetryService({ options });
   const common = { options, logger, metrics, telemetry };
 
-  const buildService = new BuildService({ common });
-  await buildService.setup({ watch: false });
+  const buildService = await createBuildService({ common });
 
   let cleanupReloadable = () => Promise.resolve();
 
@@ -50,11 +49,11 @@ export async function serve({ cliOptions }: { cliOptions: CliOptions }) {
 
   const shutdown = setupShutdown({ common, cleanup });
 
-  const initialResult = await buildService.initialLoad();
+  const initialResult = await buildService.start({ watch: false });
   // Once we have the initial build, we can kill the build service.
   await buildService.kill();
 
-  if (!initialResult.success) {
+  if (initialResult.status === "error") {
     await shutdown({ reason: "Failed intial build", code: 1 });
     return cleanup;
   }
diff --git a/packages/core/src/bin/commands/start.ts b/packages/core/src/bin/commands/start.ts
index 8f9ec254..4e7fd7df 100644
--- a/packages/core/src/bin/commands/start.ts
+++ b/packages/core/src/bin/commands/start.ts
@@ -1,5 +1,5 @@
 import path from "node:path";
-import { BuildService } from "@/build/service.js";
+import { createBuildService } from "@/build/index.js";
 import { LoggerService } from "@/common/logger.js";
 import { MetricsService } from "@/common/metrics.js";
 import { buildOptions } from "@/common/options.js";
@@ -35,8 +35,7 @@ export async function start({ cliOptions }: { cliOptions: CliOptions }) {
   const telemetry = new TelemetryService({ options });
   const common = { options, logger, metrics, telemetry };
 
-  const buildService = new BuildService({ common });
-  await buildService.setup({ watch: false });
+  const buildService = await createBuildService({ common });
 
   let cleanupReloadable = () => Promise.resolve();
 
@@ -47,11 +46,11 @@ export async function start({ cliOptions }: { cliOptions: CliOptions }) {
 
   const shutdown = setupShutdown({ common, cleanup });
 
-  const initialResult = await buildService.initialLoad();
+  const initialResult = await buildService.start({ watch: false });
   // Once we have the initial build, we can kill the build service.
   await buildService.kill();
 
-  if (!initialResult.success) {
+  if (initialResult.status === "error") {
     await shutdown({ reason: "Failed intial build", code: 1 });
     return cleanup;
   }
diff --git a/packages/core/src/bin/utils/run.ts b/packages/core/src/bin/utils/run.ts
index 61338086..b28d67b5 100644
--- a/packages/core/src/bin/utils/run.ts
+++ b/packages/core/src/bin/utils/run.ts
@@ -1,4 +1,4 @@
-import { type Build } from "@/build/service.js";
+import type { Build } from "@/build/index.js";
 import { runCodegen } from "@/common/codegen.js";
 import type { Common } from "@/common/common.js";
 import { PostgresDatabaseService } from "@/database/postgres/service.js";
@@ -7,13 +7,7 @@ import { SqliteDatabaseService } from "@/database/sqlite/service.js";
 import { RealtimeIndexingStore } from "@/indexing-store/realtimeStore.js";
 import type { IndexingStore } from "@/indexing-store/store.js";
 import { decodeEvents } from "@/indexing/events.js";
-import {
-  createIndexingService,
-  kill,
-  processEvents,
-  processSetupEvents,
-  updateLastEventCheckpoint,
-} from "@/indexing/service.js";
+import { createIndexingService } from "@/indexing/index.js";
 import { createServer } from "@/server/service.js";
 import { PostgresSyncStore } from "@/sync-store/postgres/store.js";
 import { SqliteSyncStore } from "@/sync-store/sqlite/store.js";
@@ -110,7 +104,7 @@ export async function run({
   });
 
   // process setup events
-  const result = await processSetupEvents(indexingService, {
+  const result = await indexingService.processSetupEvents({
     sources,
     networks,
   });
@@ -118,7 +112,7 @@ export async function run({
     onReloadableError(result.error);
 
     return async () => {
-      kill(indexingService);
+      indexingService.kill();
       await server.kill();
       await syncService.kill();
       await database.kill();
@@ -137,7 +131,7 @@ export async function run({
       })
       .then((lastEventCheckpoint) => {
         if (lastEventCheckpoint !== undefined)
-          updateLastEventCheckpoint(indexingService, lastEventCheckpoint);
+          indexingService.updateLastEventCheckpoint(lastEventCheckpoint);
       });
 
     for await (const rawEvents of syncService.getEvents({
@@ -149,7 +143,7 @@ export async function run({
       if (rawEvents.length === 0) break;
 
       const events = decodeEvents(indexingService, rawEvents);
-      const result = await processEvents(indexingService, {
+      const result = await indexingService.processEvents({
         events,
       });
 
@@ -254,7 +248,7 @@ export async function run({
   return async () => {
     runQueue.pause();
     runQueue.clear();
-    kill(indexingService);
+    indexingService.kill();
     await server.kill();
     await syncService.kill();
     await database.kill();
diff --git a/packages/core/src/build/config/config.ts b/packages/core/src/build/configAndIndexingFunctions.ts
similarity index 73%
rename from packages/core/src/build/config/config.ts
rename to packages/core/src/build/configAndIndexingFunctions.ts
index 694937e7..e8c521d0 100644
--- a/packages/core/src/build/config/config.ts
+++ b/packages/core/src/build/configAndIndexingFunctions.ts
@@ -14,13 +14,30 @@ import {
 import type { Factory, LogFilter, Source } from "@/config/sources.js";
 import { chains } from "@/utils/chains.js";
 import { toLowerCase } from "@/utils/lowercase.js";
+import { dedupe } from "@ponder/common";
 import parse from "pg-connection-string";
-import type { LogTopic } from "viem";
+import type { Hex, LogTopic } from "viem";
 
-export async function buildConfig({
+export type RawIndexingFunctions = {
+  name: string;
+  fn: (...args: any) => any;
+}[];
+
+export type IndexingFunctions = {
+  [sourceName: string]: {
+    [eventName: string]: (...args: any) => any;
+  };
+};
+
+export async function buildConfigAndIndexingFunctions({
   config,
+  rawIndexingFunctions,
   options,
-}: { config: Config; options: Pick<Options, "ponderDir" | "rootDir"> }) {
+}: {
+  config: Config;
+  rawIndexingFunctions: RawIndexingFunctions;
+  options: Pick<Options, "ponderDir" | "rootDir">;
+}) {
   const logs: { level: "warn" | "info" | "debug"; msg: string }[] = [];
 
   // Build database.
@@ -236,6 +253,49 @@ export async function buildConfig({
     }),
   );
 
+  // Validate and build indexing functions
+  let indexingFunctionCount = 0;
+  const indexingFunctions: IndexingFunctions = {};
+
+  for (const { name: eventKey, fn } of rawIndexingFunctions) {
+    const eventNameComponents = eventKey.split(":");
+    const [sourceName, eventName] = eventNameComponents;
+    if (eventNameComponents.length !== 2 || !sourceName || !eventName) {
+      throw new Error(
+        `Validation failed: Invalid event '${eventKey}', expected format '{contractName}:{eventName}'.`,
+      );
+    }
+
+    indexingFunctions[sourceName] ||= {};
+
+    if (eventName in indexingFunctions[sourceName]) {
+      throw new Error(
+        `Validation failed: Multiple indexing functions registered for event '${eventKey}'.`,
+      );
+    }
+
+    // Validate that the indexing function uses a contractName that is present in the config.
+    const matchedContractName = Object.keys(config.contracts).find(
+      (contractName) => contractName === sourceName,
+    );
+    if (!matchedContractName) {
+      // Multi-network contracts have N sources, but the hint here should not have duplicates.
+      const uniqueContractNames = dedupe(Object.keys(config.contracts));
+      throw new Error(
+        `Validation failed: Invalid contract name '${sourceName}'. Got '${sourceName}', expected one of [${uniqueContractNames
+          .map((n) => `'${n}'`)
+          .join(", ")}].`,
+      );
+    }
+
+    indexingFunctions[sourceName][eventName] = fn;
+    indexingFunctionCount += 1;
+  }
+
+  if (indexingFunctionCount === 0) {
+    logs.push({ level: "warn", msg: "No indexing functions were registered." });
+  }
+
   const sources: Source[] = Object.entries(config.contracts)
     // First, apply any network-specific overrides and flatten the result.
     .flatMap(([contractName, contract]) => {
@@ -325,10 +385,32 @@ export async function buildConfig({
         );
       }
 
+      // Get indexing function that were registered for this source
+      const registeredLogEvents = Object.keys(
+        indexingFunctions[rawContract.contractName] ?? {},
+      );
+
       // Note: This can probably throw for invalid ABIs. Consider adding explicit ABI validation before this line.
       const abiEvents = buildAbiEvents({ abi: rawContract.abi });
 
-      let topics: LogTopic[] | undefined = undefined;
+      const registeredEventSelectors: Hex[] = [];
+      // Validate that the registered log events exist in the abi
+      for (const logEvent of registeredLogEvents) {
+        const abiEvent = abiEvents.bySafeName[logEvent];
+        if (abiEvent === undefined) {
+          throw new Error(
+            `Validation failed: Event name for event '${logEvent}' not found in the contract ABI. Got '${logEvent}', expected one of [${Object.keys(
+              abiEvents.bySafeName,
+            )
+              .map((eventName) => `'${eventName}'`)
+              .join(", ")}].`,
+          );
+        }
+
+        registeredEventSelectors.push(abiEvent.selector);
+      }
+
+      let topics: LogTopic[] = [registeredEventSelectors];
 
       if (rawContract.filter !== undefined) {
         if (
@@ -360,8 +442,38 @@ export async function buildConfig({
         }
 
         // TODO: Explicit validation of indexed argument value format (array or object).
+        // The first element of the array return from `buildTopics` being defined
+        // is an invariant of the current filter design.
         // Note: This can throw.
-        topics = buildTopics(rawContract.abi, rawContract.filter);
+        const [topic0FromFilter, ...topicsFromFilter] = buildTopics(
+          rawContract.abi,
+          rawContract.filter,
+        ) as [Exclude<LogTopic, null>, ...LogTopic[]];
+
+        const filteredEventSelectors = Array.isArray(topic0FromFilter)
+          ? topic0FromFilter
+          : [topic0FromFilter];
+
+        // Validate that the topic0 value defined by the `eventFilter` is a superset of the
+        // registered indexing functions. Simply put, confirm that no indexing function is
+        // defined for a log event that is excluded by the filter.
+        for (const registeredEventSelector of registeredEventSelectors) {
+          if (!filteredEventSelectors.includes(registeredEventSelector)) {
+            const logEventName =
+              abiEvents.bySelector[registeredEventSelector]!.safeName;
+
+            throw new Error(
+              `Validation failed: Event '${logEventName}' is excluded by the event filter defined on the contract '${
+                rawContract.contractName
+              }'. Got '${logEventName}', expected one of [${filteredEventSelectors
+                .map((s) => abiEvents.bySelector[s]!.safeName)
+                .map((eventName) => `'${eventName}'`)
+                .join(", ")}].`,
+            );
+          }
+        }
+
+        topics = [registeredEventSelectors, ...topicsFromFilter];
       }
 
       const baseContract = {
@@ -428,6 +540,18 @@ export async function buildConfig({
           topics,
         },
       } satisfies LogFilter;
+    })
+    // Remove sources with no registered indexing functions
+    .filter((source) => {
+      const hasRegisteredIndexingFunctions =
+        source.criteria.topics[0]?.length !== 0;
+      if (!hasRegisteredIndexingFunctions) {
+        logs.push({
+          level: "debug",
+          msg: `No indexing functions were registered for contract ${source.contractName}`,
+        });
+      }
+      return hasRegisteredIndexingFunctions;
     });
 
   // Filter out any networks that don't have any sources registered.
@@ -444,20 +568,42 @@ export async function buildConfig({
     return hasSources;
   });
 
-  return { databaseConfig, networks: networksWithSources, sources, logs };
+  return {
+    databaseConfig,
+    networks: networksWithSources,
+    sources,
+    indexingFunctions,
+    logs,
+  };
 }
 
-export async function safeBuildConfig({
+export async function safeBuildConfigAndIndexingFunctions({
   config,
+  rawIndexingFunctions,
   options,
-}: { config: Config; options: Pick<Options, "rootDir" | "ponderDir"> }) {
+}: {
+  config: Config;
+  rawIndexingFunctions: RawIndexingFunctions;
+  options: Pick<Options, "rootDir" | "ponderDir">;
+}) {
   try {
-    const result = await buildConfig({ config, options });
+    const result = await buildConfigAndIndexingFunctions({
+      config,
+      rawIndexingFunctions,
+      options,
+    });
 
-    return { success: true, data: result } as const;
+    return {
+      status: "success",
+      sources: result.sources,
+      networks: result.networks,
+      indexingFunctions: result.indexingFunctions,
+      databaseConfig: result.databaseConfig,
+      logs: result.logs,
+    } as const;
   } catch (error_) {
     const error = error_ as Error;
-    return { success: false, error } as const;
+    return { status: "error", error } as const;
   }
 }
 
diff --git a/packages/core/src/build/functions/functions.ts b/packages/core/src/build/functions/functions.ts
deleted file mode 100644
index f0a68594..00000000
--- a/packages/core/src/build/functions/functions.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-export type RawIndexingFunctions = {
-  [fileName: string]: { name: string; fn: (...args: any) => any }[];
-};
-
-export type IndexingFunctions = {
-  [sourceName: string]: {
-    [eventName: string]: (...args: any) => any;
-  };
-};
-
-export function buildIndexingFunctions({
-  rawIndexingFunctions,
-}: { rawIndexingFunctions: RawIndexingFunctions }) {
-  const warnings: string[] = [];
-
-  let indexingFunctionCount = 0;
-  const indexingFunctions: IndexingFunctions = {};
-
-  for (const fileFns of Object.values(rawIndexingFunctions)) {
-    for (const { name: eventKey, fn } of fileFns) {
-      const eventNameComponents = eventKey.split(":");
-      const [sourceName, eventName] = eventNameComponents;
-      if (eventNameComponents.length !== 2 || !sourceName || !eventName) {
-        throw new Error(
-          `Validation failed: Invalid event '${eventKey}', expected format '{contractName}:{eventName}'.`,
-        );
-      }
-
-      indexingFunctions[sourceName] ||= {};
-
-      if (eventName in indexingFunctions[sourceName]) {
-        throw new Error(
-          `Validation failed: Multiple indexing functions registered for event '${eventKey}'.`,
-        );
-      }
-
-      indexingFunctions[sourceName][eventName] = fn;
-      indexingFunctionCount += 1;
-    }
-  }
-
-  if (indexingFunctionCount === 0) {
-    warnings.push("No indexing functions were registered.");
-  }
-
-  return { indexingFunctions, warnings } as const;
-}
-
-export function safeBuildIndexingFunctions({
-  rawIndexingFunctions,
-}: { rawIndexingFunctions: RawIndexingFunctions }) {
-  try {
-    const result = buildIndexingFunctions({ rawIndexingFunctions });
-
-    return { success: true, data: result } as const;
-  } catch (error_) {
-    const error = error_ as Error;
-    return { success: false, error } as const;
-  }
-}
diff --git a/packages/core/src/build/index.ts b/packages/core/src/build/index.ts
new file mode 100644
index 00000000..baea4811
--- /dev/null
+++ b/packages/core/src/build/index.ts
@@ -0,0 +1,14 @@
+import { type Extend, extend } from "@/utils/extend.js";
+import { create, kill, start } from "./service.js";
+import type { Build, BuildResult, Service } from "./service.js";
+
+const methods = {
+  start,
+  kill,
+};
+
+export const createBuildService = extend(create, methods);
+
+export type BuildService = Extend<Service, typeof methods>;
+
+export type { BuildResult, Build };
diff --git a/packages/core/src/build/schema/schema.ts b/packages/core/src/build/schema.ts
similarity index 97%
rename from packages/core/src/build/schema/schema.ts
rename to packages/core/src/build/schema.ts
index 4447000f..230a4f8f 100644
--- a/packages/core/src/build/schema/schema.ts
+++ b/packages/core/src/build/schema.ts
@@ -1,11 +1,11 @@
-import type { Schema } from "../../schema/types.js";
+import type { Schema } from "../schema/types.js";
 import {
   isEnumColumn,
   isManyColumn,
   isOneColumn,
   isReferenceColumn,
   referencedTableName,
-} from "../../schema/utils.js";
+} from "../schema/utils.js";
 
 export const buildSchema = ({ schema }: { schema: Schema }) => {
   // Validate enums
@@ -239,9 +239,9 @@ const validateTableOrColumnName = (key: string, type: string) => {
 export function safeBuildSchema({ schema }: { schema: Schema }) {
   try {
     const result = buildSchema({ schema });
-    return { success: true, data: result } as const;
+    return { status: "success", schema: result.schema } as const;
   } catch (error_) {
     const error = error_ as Error;
-    return { success: false, error } as const;
+    return { status: "error", error } as const;
   }
 }
diff --git a/packages/core/src/build/service.ts b/packages/core/src/build/service.ts
index eb20c49a..dcdc9786 100644
--- a/packages/core/src/build/service.ts
+++ b/packages/core/src/build/service.ts
@@ -1,6 +1,5 @@
 import { randomBytes } from "node:crypto";
 import path from "node:path";
-import { safeBuildSchema } from "@/build/schema/schema.js";
 import type { Common } from "@/common/common.js";
 import type { Config } from "@/config/config.js";
 import type { DatabaseConfig } from "@/config/database.js";
@@ -8,25 +7,34 @@ import type { Network } from "@/config/networks.js";
 import type { Source } from "@/config/sources.js";
 import type { Schema } from "@/schema/types.js";
 import { buildGraphqlSchema } from "@/server/graphql/buildGraphqlSchema.js";
-import { Emittery } from "@/utils/emittery.js";
 import { glob } from "glob";
-import { GraphQLSchema } from "graphql";
+import type { GraphQLSchema } from "graphql";
 import { type ViteDevServer, createServer } from "vite";
 import { ViteNodeRunner } from "vite-node/client";
 import { ViteNodeServer } from "vite-node/server";
 import { installSourcemapsSupport } from "vite-node/source-map";
 import { normalizeModuleId, toFilePath } from "vite-node/utils";
 import viteTsconfigPathsPlugin from "vite-tsconfig-paths";
-import { safeBuildConfig } from "./config/config.js";
 import {
   type IndexingFunctions,
   type RawIndexingFunctions,
-  safeBuildIndexingFunctions,
-} from "./functions/functions.js";
+  safeBuildConfigAndIndexingFunctions,
+} from "./configAndIndexingFunctions.js";
 import { vitePluginPonder } from "./plugin.js";
-import type { ViteNodeError } from "./stacktrace.js";
+import { safeBuildSchema } from "./schema.js";
 import { parseViteNodeError } from "./stacktrace.js";
 
+export type Service = {
+  // static
+  common: Common;
+  indexingFunctionRegex: RegExp;
+
+  // vite
+  viteDevServer: ViteDevServer;
+  viteNodeServer: ViteNodeServer;
+  viteNodeRunner: ViteNodeRunner;
+};
+
 export type Build = {
   // Build ID for caching
   buildId: string;
@@ -42,105 +50,137 @@ export type Build = {
 };
 
 export type BuildResult =
-  | { success: true; build: Build }
-  | { success: false; error: Error };
+  | { status: "success"; build: Build }
+  | { status: "error"; error: Error };
 
-type BuildServiceEvents = {
-  rebuild: BuildResult;
+type RawBuild = {
+  config: Config;
+  schema: Schema;
+  indexingFunctions: RawIndexingFunctions;
 };
 
-export class BuildService extends Emittery<BuildServiceEvents> {
-  private common: Common;
-  private indexingFunctionRegex: RegExp;
-
-  private viteDevServer: ViteDevServer = undefined!;
-  private viteNodeServer: ViteNodeServer = undefined!;
-  private viteNodeRunner: ViteNodeRunner = undefined!;
-
-  private rawIndexingFunctions: RawIndexingFunctions = {};
-
-  // Maintain the latest version of built user code to support validation.
-  // Note that `networks` and `schema` are not currently needed for validation.
-  private databaseConfig?: DatabaseConfig;
-  private networks?: Network[];
-  private sources?: Source[];
-  private schema?: Schema;
-  private graphqlSchema?: GraphQLSchema;
-  private indexingFunctions?: IndexingFunctions;
-
-  constructor({ common }: { common: Common }) {
-    super();
-    this.common = common;
-    this.indexingFunctionRegex = new RegExp(
-      `^${this.common.options.srcDir.replace(
-        /[.*+?^${}()|[\]\\]/g,
-        "\\$&",
-      )}/.*\\.(js|ts)$`,
-    );
-  }
-
-  async setup({ watch }: { watch: boolean }) {
-    const viteLogger = {
-      warnedMessages: new Set<string>(),
-      loggedErrors: new WeakSet<Error>(),
-      hasWarned: false,
-      clearScreen() {},
-      hasErrorLogged: (error: Error) => viteLogger.loggedErrors.has(error),
-      info: (msg: string) => {
-        this.common.logger.trace({ service: "build(vite)", msg });
-      },
-      warn: (msg: string) => {
-        viteLogger.hasWarned = true;
-        this.common.logger.trace({ service: "build(vite)", msg });
-      },
-      warnOnce: (msg: string) => {
-        if (viteLogger.warnedMessages.has(msg)) return;
-        viteLogger.hasWarned = true;
-        this.common.logger.trace({ service: "build(vite)", msg });
-        viteLogger.warnedMessages.add(msg);
-      },
-      error: (msg: string) => {
-        viteLogger.hasWarned = true;
-        this.common.logger.trace({ service: "build(vite)", msg });
-      },
-    };
-
-    this.viteDevServer = await createServer({
-      root: this.common.options.rootDir,
-      cacheDir: path.join(this.common.options.ponderDir, "vite"),
-      publicDir: false,
-      customLogger: viteLogger,
-      server: { hmr: false },
-      plugins: [viteTsconfigPathsPlugin(), vitePluginPonder()],
-    });
-
-    // This is Vite boilerplate (initializes the Rollup container).
-    await this.viteDevServer.pluginContainer.buildStart({});
+export const create = async ({
+  common,
+}: {
+  common: Common;
+}): Promise<Service> => {
+  const indexingFunctionRegex = new RegExp(
+    `^${common.options.srcDir.replace(
+      /[.*+?^${}()|[\]\\]/g,
+      "\\$&",
+    )}/.*\\.(js|ts)$`,
+  );
+
+  const viteLogger = {
+    warnedMessages: new Set<string>(),
+    loggedErrors: new WeakSet<Error>(),
+    hasWarned: false,
+    clearScreen() {},
+    hasErrorLogged: (error: Error) => viteLogger.loggedErrors.has(error),
+    info: (msg: string) => {
+      common.logger.trace({ service: "build(vite)", msg });
+    },
+    warn: (msg: string) => {
+      viteLogger.hasWarned = true;
+      common.logger.trace({ service: "build(vite)", msg });
+    },
+    warnOnce: (msg: string) => {
+      if (viteLogger.warnedMessages.has(msg)) return;
+      viteLogger.hasWarned = true;
+      common.logger.trace({ service: "build(vite)", msg });
+      viteLogger.warnedMessages.add(msg);
+    },
+    error: (msg: string) => {
+      viteLogger.hasWarned = true;
+      common.logger.trace({ service: "build(vite)", msg });
+    },
+  };
+
+  const viteDevServer = await createServer({
+    root: common.options.rootDir,
+    cacheDir: path.join(common.options.ponderDir, "vite"),
+    publicDir: false,
+    customLogger: viteLogger,
+    server: { hmr: false },
+    plugins: [viteTsconfigPathsPlugin(), vitePluginPonder()],
+  });
+
+  // This is Vite boilerplate (initializes the Rollup container).
+  await viteDevServer.pluginContainer.buildStart({});
+
+  const viteNodeServer = new ViteNodeServer(viteDevServer);
+  installSourcemapsSupport({
+    getSourceMap: (source) => viteNodeServer.getSourceMap(source),
+  });
+
+  const viteNodeRunner = new ViteNodeRunner({
+    root: viteDevServer.config.root,
+    fetchModule: (id) => viteNodeServer.fetchModule(id, "ssr"),
+    resolveId: (id, importer) => viteNodeServer.resolveId(id, importer, "ssr"),
+  });
+
+  return {
+    common,
+    indexingFunctionRegex,
+    viteDevServer,
+    viteNodeServer,
+    viteNodeRunner,
+  };
+};
 
-    this.viteNodeServer = new ViteNodeServer(this.viteDevServer);
-    installSourcemapsSupport({
-      getSourceMap: (source) => this.viteNodeServer.getSourceMap(source),
-    });
+/**
+ * Execute, validate, and build the files the make up a Ponder app.
+ * If `watch` is true (dev server), then use vite to re-execute changed files,
+ * and validate and build again. This function only re-executes changes files,
+ * but doesn't attempt to skip any validation or build steps.
+ */
+export const start = async (
+  buildService: Service,
+  {
+    watch,
+    onBuild,
+  }:
+    | { watch: true; onBuild: (buildResult: BuildResult) => void }
+    | { watch: false; onBuild?: never },
+): Promise<BuildResult> => {
+  const { common } = buildService;
+
+  const [
+    executeConfigResult,
+    executeSchemaResult,
+    executeIndexingFunctionsResult,
+  ] = await Promise.all([
+    executeConfig(buildService),
+    executeSchema(buildService),
+    executeIndexingFunctions(buildService),
+  ]);
+
+  if (executeConfigResult.status === "error") {
+    return { status: "error", error: executeConfigResult.error };
+  }
+  if (executeSchemaResult.status === "error") {
+    return { status: "error", error: executeSchemaResult.error };
+  }
+  if (executeIndexingFunctionsResult.status === "error") {
+    return { status: "error", error: executeIndexingFunctionsResult.error };
+  }
 
-    this.viteNodeRunner = new ViteNodeRunner({
-      root: this.viteDevServer.config.root,
-      fetchModule: (id) => this.viteNodeServer.fetchModule(id, "ssr"),
-      resolveId: (id, importer) =>
-        this.viteNodeServer.resolveId(id, importer, "ssr"),
-    });
+  const rawBuild: RawBuild = {
+    config: executeConfigResult.config,
+    schema: executeSchemaResult.schema,
+    indexingFunctions: executeIndexingFunctionsResult.indexingFunctions,
+  };
 
-    // If watch is false (`ponder start` or `ponder serve`),
-    // don't register  any event handlers on the watcher.
-    if (watch === false) return;
+  const buildResult = await validateAndBuild(buildService, rawBuild);
 
+  // If watch is false (`ponder start` or `ponder serve`),
+  // don't register  any event handlers on the watcher.
+  if (watch) {
     // Define the directories and files to ignore
-    const ignoredDirs = [
-      this.common.options.generatedDir,
-      this.common.options.ponderDir,
-    ];
+    const ignoredDirs = [common.options.generatedDir, common.options.ponderDir];
     const ignoredFiles = [
-      path.join(this.common.options.rootDir, "ponder-env.d.ts"),
-      path.join(this.common.options.rootDir, ".env.local"),
+      path.join(common.options.rootDir, "ponder-env.d.ts"),
+      path.join(common.options.rootDir, ".env.local"),
     ];
 
     const isFileIgnored = (filePath: string) => {
@@ -153,339 +193,221 @@ export class BuildService extends Emittery<BuildServiceEvents> {
       return isInIgnoredDir || isIgnoredFile;
     };
 
-    const onFileChange = async (files_: string[]) => {
-      const files = files_
-        .filter((file) => !isFileIgnored(file))
-        .map(
-          (file) =>
-            toFilePath(normalizeModuleId(file), this.common.options.rootDir)
-              .path,
-        );
+    const onFileChange = async (_file: string) => {
+      if (isFileIgnored(_file)) return;
+
+      const file = toFilePath(
+        normalizeModuleId(_file),
+        common.options.rootDir,
+      ).path;
 
       // Invalidate all modules that depend on the updated files.
       const invalidated = [
-        ...this.viteNodeRunner.moduleCache.invalidateDepTree(files),
+        ...buildService.viteNodeRunner.moduleCache.invalidateDepTree([file]),
       ];
 
       // If no files were invalidated, no need to reload.
       if (invalidated.length === 0) return;
 
-      this.common.logger.info({
+      common.logger.info({
         service: "build",
         msg: `Hot reload ${invalidated
-          .map((f) => `'${path.relative(this.common.options.rootDir, f)}'`)
+          .map((f) => `'${path.relative(common.options.rootDir, f)}'`)
           .join(", ")}`,
       });
 
-      if (invalidated.includes(this.common.options.configFile)) {
-        const configResult = await this.loadConfig();
-        const validationResult = this.validate();
-
-        const error = configResult.error ?? validationResult.error;
-
-        if (error) {
-          this.common.logger.error({ service: "build", error });
-          this.emit("rebuild", { success: false, error });
+      if (invalidated.includes(common.options.configFile)) {
+        const executeConfigResult = await executeConfig(buildService);
+        if (executeConfigResult.status === "error") {
+          onBuild({ status: "error", error: executeConfigResult.error });
           return;
         }
+        rawBuild.config = executeConfigResult.config;
       }
 
-      if (invalidated.includes(this.common.options.schemaFile)) {
-        const schemaResult = await this.loadSchema();
-        const validationResult = this.validate();
-
-        const error = schemaResult.error ?? validationResult.error;
-
-        if (error) {
-          this.common.logger.error({ service: "build", error });
-          this.emit("rebuild", { success: false, error });
+      if (invalidated.includes(common.options.schemaFile)) {
+        const executeSchemaResult = await executeSchema(buildService);
+        if (executeSchemaResult.status === "error") {
+          onBuild({ status: "error", error: executeSchemaResult.error });
           return;
         }
+        rawBuild.schema = executeSchemaResult.schema;
       }
 
-      const indexingFunctionFiles = invalidated.filter((file) =>
-        this.indexingFunctionRegex.test(file),
+      const hasIndexingFunctionUpdate = invalidated.some((file) =>
+        buildService.indexingFunctionRegex.test(file),
       );
-      if (indexingFunctionFiles.length > 0) {
-        const indexingFunctionsResult = await this.loadIndexingFunctions({
-          files: indexingFunctionFiles,
-        });
-        const validationResult = this.validate();
-
-        const error = indexingFunctionsResult.error ?? validationResult.error;
-
-        if (error) {
-          this.common.logger.error({ service: "build", error });
-          this.emit("rebuild", { success: false, error });
+      if (hasIndexingFunctionUpdate) {
+        const executeIndexingFunctionsResult =
+          await executeIndexingFunctions(buildService);
+        if (executeIndexingFunctionsResult.status === "error") {
+          onBuild({
+            status: "error",
+            error: executeIndexingFunctionsResult.error,
+          });
           return;
         }
+        rawBuild.indexingFunctions =
+          executeIndexingFunctionsResult.indexingFunctions;
       }
 
-      this.emit("rebuild", {
-        success: true,
-        build: {
-          buildId: randomBytes(5).toString("hex"),
-          databaseConfig: this.databaseConfig!,
-          sources: this.sources!,
-          networks: this.networks!,
-          schema: this.schema!,
-          graphqlSchema: this.graphqlSchema!,
-          indexingFunctions: this.indexingFunctions!,
-        },
-      });
+      const buildResult = await validateAndBuild(buildService, rawBuild);
+      onBuild(buildResult);
     };
 
-    this.viteDevServer.watcher.on("change", async (file) => {
-      await onFileChange([file]);
-    });
+    buildService.viteDevServer.watcher.on("change", onFileChange);
   }
 
-  async kill() {
-    this.cancelMutexes();
-    await this.viteDevServer?.close();
-    this.common.logger.debug({
-      service: "build",
-      msg: "Killed build service",
-    });
-  }
+  return buildResult;
+};
 
-  async initialLoad() {
-    const logAndReturnError = (error: Error) => {
-      this.common.logger.error({
-        service: "build",
-        msg: "Failed initial build with error:",
-        error,
-      });
-      return { success: false, error } as const;
-    };
+export const kill = async (buildService: Service): Promise<void> => {
+  await buildService.viteDevServer?.close();
+  buildService.common.logger.debug({
+    service: "build",
+    msg: "Killed build service",
+  });
+};
 
-    const configResult = await this.loadConfig();
-    if (!configResult.success) return logAndReturnError(configResult.error);
-
-    const schemaResult = await this.loadSchema();
-    if (!schemaResult.success) return logAndReturnError(schemaResult.error);
-
-    const files = glob.sync(
-      path.join(this.common.options.srcDir, "**/*.{js,mjs,ts,mts}"),
-    );
-    const indexingFunctionsResult = await this.loadIndexingFunctions({ files });
-    if (!indexingFunctionsResult.success)
-      return logAndReturnError(indexingFunctionsResult.error);
-
-    const validationResult = this.validate();
-    if (validationResult.error)
-      return logAndReturnError(validationResult.error);
-
-    const { databaseConfig, sources, networks } = configResult;
-    const { schema, graphqlSchema } = schemaResult;
-    const { indexingFunctions } = indexingFunctionsResult;
-
-    return {
-      success: true,
-      build: {
-        buildId: randomBytes(5).toString("hex"),
-        databaseConfig,
-        networks,
-        sources,
-        schema,
-        graphqlSchema,
-        indexingFunctions,
-      },
-    } satisfies BuildResult;
+const executeConfig = async (
+  buildService: Service,
+): Promise<
+  { status: "success"; config: Config } | { status: "error"; error: Error }
+> => {
+  const executeResult = await executeFile(buildService, {
+    file: buildService.common.options.configFile,
+  });
+
+  if (executeResult.status === "error") {
+    logError(buildService, executeResult.error);
+    return executeResult;
   }
 
-  async loadConfig() {
-    const loadResult = await this.executeFile(this.common.options.configFile);
-    if (!loadResult.success) {
-      return { success: false, error: loadResult.error } as const;
-    }
-
-    const rawConfig = loadResult.exports.default as Config;
-    const buildResult = await safeBuildConfig({
-      config: rawConfig,
-      options: this.common.options,
-    });
-
-    if (buildResult.error) {
-      return { success: false, error: buildResult.error } as const;
-    }
-
-    for (const log of buildResult.data.logs) {
-      this.common.logger[log.level]({ service: "build", msg: log.msg });
-    }
+  const config = executeResult.exports.default as Config;
 
-    const { databaseConfig, sources, networks } = buildResult.data;
-    this.databaseConfig = databaseConfig;
-    this.networks = networks;
-    this.sources = sources;
+  return { status: "success", config } as const;
+};
 
-    return { success: true, databaseConfig, sources, networks } as const;
+const executeSchema = async (
+  buildService: Service,
+): Promise<
+  { status: "success"; schema: Schema } | { status: "error"; error: Error }
+> => {
+  const executeResult = await executeFile(buildService, {
+    file: buildService.common.options.schemaFile,
+  });
+
+  if (executeResult.status === "error") {
+    logError(buildService, executeResult.error);
+    return executeResult;
   }
 
-  async loadSchema() {
-    const loadResult = await this.executeFile(this.common.options.schemaFile);
-    if (loadResult.error) {
-      return { success: false, error: loadResult.error } as const;
-    }
+  const schema = executeResult.exports.default as Schema;
 
-    const rawSchema = loadResult.exports.default as Schema;
+  return { status: "success", schema };
+};
 
-    const buildResult = safeBuildSchema({ schema: rawSchema });
-    if (buildResult.error) {
-      return { success: false, error: buildResult.error } as const;
+const executeIndexingFunctions = async (
+  buildService: Service,
+): Promise<
+  | { status: "success"; indexingFunctions: RawIndexingFunctions }
+  | { status: "error"; error: Error }
+> => {
+  const files = glob.sync(
+    path.join(buildService.common.options.srcDir, "**/*.{js,mjs,ts,mts}"),
+  );
+
+  const executeResults = await Promise.all(
+    files.map((file) => executeFile(buildService, { file })),
+  );
+
+  const indexingFunctions: RawIndexingFunctions = [];
+
+  for (const executeResult of executeResults) {
+    if (executeResult.status === "error") {
+      logError(buildService, executeResult.error);
+      return executeResult;
     }
 
-    const schema = buildResult.data.schema;
-    this.schema = schema;
+    indexingFunctions.push(...(executeResult.exports?.ponder?.fns ?? []));
+  }
 
-    // TODO: Probably move this elsewhere. Also, handle errors.
-    const graphqlSchema = buildGraphqlSchema(buildResult.data.schema);
-    this.graphqlSchema = graphqlSchema;
+  return { status: "success", indexingFunctions };
+};
 
-    return { success: true, schema, graphqlSchema } as const;
+const validateAndBuild = async (
+  { common }: Pick<Service, "common">,
+  rawBuild: RawBuild,
+): Promise<BuildResult> => {
+  // Validate and build the schema
+  const buildSchemaResult = safeBuildSchema({
+    schema: rawBuild.schema,
+  });
+  if (buildSchemaResult.status === "error") {
+    logError({ common }, buildSchemaResult.error);
+    return buildSchemaResult;
   }
 
-  async loadIndexingFunctions({ files }: { files: string[] }) {
-    const rawLoadResults = await Promise.all(
-      files.map((file) => this.executeFile(file)),
-    );
-    const loadResultErrors = rawLoadResults.filter(
-      (r): r is { success: false; error: ViteNodeError } => !r.success,
-    );
-    const loadResults = rawLoadResults.filter(
-      (r): r is { success: true; file: string; exports: any } => r.success,
-    );
-
-    if (loadResultErrors.length > 0) {
-      return { success: false, error: loadResultErrors[0].error } as const;
-    }
+  const graphqlSchema = buildGraphqlSchema(buildSchemaResult.schema);
 
-    for (const result of loadResults) {
-      const { file, exports } = result;
-      const fns = (exports?.ponder?.fns ?? []) as { name: string; fn: any }[];
-      (this.rawIndexingFunctions || {})[file] = fns;
-    }
-
-    const buildResult = safeBuildIndexingFunctions({
-      rawIndexingFunctions: this.rawIndexingFunctions,
+  // Validates and build the config
+  const buildConfigAndIndexingFunctionsResult =
+    await safeBuildConfigAndIndexingFunctions({
+      config: rawBuild.config,
+      rawIndexingFunctions: rawBuild.indexingFunctions,
+      options: common.options,
     });
-    if (!buildResult.success) {
-      return { success: false, error: buildResult.error } as const;
-    }
-
-    for (const warning of buildResult.data.warnings) {
-      this.common.logger.warn({ service: "build", msg: warning });
-    }
-
-    const indexingFunctions = buildResult.data.indexingFunctions;
-    this.indexingFunctions = indexingFunctions;
-
-    return { success: true, indexingFunctions } as const;
+  if (buildConfigAndIndexingFunctionsResult.status === "error") {
+    logError({ common }, buildConfigAndIndexingFunctionsResult.error);
+    return buildConfigAndIndexingFunctionsResult;
   }
 
-  /**
-   * Validates and builds the latest config, schema, and indexing functions.
-   * Returns an error if validation fails.
-   */
-  private validate() {
-    if (this.sources === undefined || this.sources.length === 0) {
-      return { success: true } as const;
-    }
-    if (
-      this.indexingFunctions === undefined ||
-      Object.keys(this.indexingFunctions).length === 0
-    ) {
-      for (const source of this.sources) {
-        source.criteria.topics = undefined;
-      }
-      return { success: true } as const;
-    }
+  for (const log of buildConfigAndIndexingFunctionsResult.logs) {
+    common.logger[log.level]({ service: "build", msg: log.msg });
+  }
 
-    for (const [sourceName, fns] of Object.entries(this.indexingFunctions)) {
-      const source = this.sources.find((s) => s.contractName === sourceName);
-      if (!source) {
-        // Multi-network contracts have N sources, but the hint here should not have duplicates.
-        const uniqueContractNames = [
-          ...new Set(this.sources.map((s) => s.contractName)),
-        ];
-        const error = new Error(
-          `Validation failed: Invalid contract name '${sourceName}'. Got '${sourceName}', expected one of [${uniqueContractNames
-            .map((n) => `'${n}'`)
-            .join(", ")}].`,
-        );
-        return { success: false, error } as const;
-      }
+  return {
+    status: "success",
+    build: {
+      buildId: randomBytes(5).toString("hex"),
+      databaseConfig: buildConfigAndIndexingFunctionsResult.databaseConfig,
+      networks: buildConfigAndIndexingFunctionsResult.networks,
+      sources: buildConfigAndIndexingFunctionsResult.sources,
+      schema: buildSchemaResult.schema,
+      graphqlSchema,
+      indexingFunctions:
+        buildConfigAndIndexingFunctionsResult.indexingFunctions,
+    },
+  };
+};
 
-      for (const eventName of Object.keys(fns)) {
-        const eventKey = `${sourceName}:${eventName}`;
-
-        if (eventName === "setup") continue;
-
-        // Validate that the event name is found in the contract ABI.
-        if (source.abiEvents.bySafeName[eventName] === undefined) {
-          const error = new Error(
-            `Validation failed: Event name for event '${eventKey}' not found in the contract ABI. Got '${eventName}', expected one of [${Object.keys(
-              source.abiEvents.bySafeName,
-            )
-              .map((eventName) => `'${eventName}'`)
-              .join(", ")}].`,
-          );
-          return { success: false, error } as const;
-        }
-      }
+const executeFile = async (
+  { common, viteNodeRunner }: Service,
+  { file }: { file: string },
+): Promise<
+  | {
+      status: "success";
+      exports: any;
     }
-
-    // Validate source topics
-    for (const [sourceName, fns] of Object.entries(this.indexingFunctions)) {
-      for (const source of this.sources.filter(
-        (s) => s.contractName === sourceName,
-      )) {
-        for (const eventName of Object.keys(fns)) {
-          const eventKey = `${sourceName}:${eventName}`;
-
-          if (eventName === "setup") continue;
-          const filteredEventSelectors = source.criteria.topics?.[0]
-            ? Array.isArray(source.criteria.topics[0])
-              ? source.criteria.topics[0]
-              : [source.criteria.topics[0]]
-            : [];
-
-          // Validate that the selector for this event was not filtered out by the user via an event filter.
-          const eventSelector =
-            source.abiEvents.bySafeName[eventName]!.selector;
-          if (
-            filteredEventSelectors.length > 0 &&
-            !filteredEventSelectors.includes(eventSelector)
-          ) {
-            const error = new Error(
-              `Validation failed: Event '${eventKey}' is excluded by the event filter defined on the contract '${sourceName}'. Got '${eventName}', expected one of [${filteredEventSelectors
-                .map((s) => source.abiEvents.bySelector[s]!.safeName)
-                .map((eventName) => `'${eventName}'`)
-                .join(", ")}].`,
-            );
-            return { success: false, error } as const;
-          }
-        }
-
-        // Update source topic0 to include only registered event selectors
-        const registeredEventSelectors = Object.keys(fns)
-          .filter((eventName) => eventName !== "setup")
-          .map((eventName) => source.abiEvents.bySafeName[eventName]!.selector);
-        (source.criteria.topics ??= [])[0] = registeredEventSelectors;
-      }
+  | {
+      status: "error";
+      error: Error;
     }
-
-    return { success: true } as const;
+> => {
+  try {
+    const exports = await viteNodeRunner.executeFile(file);
+    return { status: "success", exports } as const;
+  } catch (error_) {
+    const relativePath = path.relative(common.options.rootDir, file);
+    const error = parseViteNodeError(relativePath, error_ as Error);
+    return { status: "error", error } as const;
   }
+};
 
-  private async executeFile(file: string) {
-    try {
-      const exports = await this.viteNodeRunner.executeFile(file);
-      return { success: true, file, exports } as const;
-    } catch (error_) {
-      const relativePath = path.relative(this.common.options.rootDir, file);
-      const error = parseViteNodeError(relativePath, error_ as Error);
-      return { success: false, error } as const;
-    }
-  }
-}
+const logError = ({ common }: Pick<Service, "common">, error: Error) => {
+  common.logger.error({
+    service: "build",
+    msg: "Failed build with error:",
+    error,
+  });
+};
diff --git a/packages/core/src/build/stacktrace.ts b/packages/core/src/build/stacktrace.ts
index b018c25f..38245c0e 100644
--- a/packages/core/src/build/stacktrace.ts
+++ b/packages/core/src/build/stacktrace.ts
@@ -1,5 +1,4 @@
 import { readFileSync } from "node:fs";
-
 import { codeFrameColumns } from "@babel/code-frame";
 import { parse as parseStackTrace } from "stacktrace-parser";
 
@@ -15,7 +14,7 @@ class ESBuildContextError extends Error {
   override name = "ESBuildContextError";
 }
 
-export type ViteNodeError =
+type ViteNodeError =
   | ESBuildTransformError
   | ESBuildBuildError
   | ESBuildContextError
diff --git a/packages/core/src/config/factories.ts b/packages/core/src/config/factories.ts
index 8bb1190e..7154e0b2 100644
--- a/packages/core/src/config/factories.ts
+++ b/packages/core/src/config/factories.ts
@@ -1,8 +1,7 @@
-import type { AbiEvent } from "abitype";
-import { getEventSelector } from "viem";
-
 import { toLowerCase } from "@/utils/lowercase.js";
 import { getBytesConsumedByParam } from "@/utils/offset.js";
+import type { AbiEvent } from "abitype";
+import { getEventSelector } from "viem";
 import type { FactoryCriteria } from "./sources.js";
 
 export function buildFactoryCriteria({
@@ -28,7 +27,7 @@ export function buildFactoryCriteria({
       eventSelector,
       // Add 1 because inputs will not contain an element for topic0 (the signature).
       childAddressLocation: `topic${(indexedInputPosition + 1) as 1 | 2 | 3}`,
-    } satisfies FactoryCriteria;
+    } satisfies Omit<FactoryCriteria, "topics">;
   }
 
   const nonIndexedInputs = event.inputs.filter(
@@ -55,5 +54,5 @@ export function buildFactoryCriteria({
     address,
     eventSelector,
     childAddressLocation: `offset${offset}`,
-  } satisfies FactoryCriteria;
+  } satisfies Omit<FactoryCriteria, "topics">;
 }
diff --git a/packages/core/src/config/sources.ts b/packages/core/src/config/sources.ts
index 9529bf72..7376f1d4 100644
--- a/packages/core/src/config/sources.ts
+++ b/packages/core/src/config/sources.ts
@@ -3,14 +3,14 @@ import type { AbiEvents } from "./abi.js";
 
 export type LogFilterCriteria = {
   address?: Address | Address[];
-  topics?: LogTopic[];
+  topics: LogTopic[];
 };
 
 export type FactoryCriteria = {
   address: Address;
   eventSelector: Hex;
   childAddressLocation: "topic1" | "topic2" | "topic3" | `offset${number}`;
-  topics?: LogTopic[];
+  topics: LogTopic[];
 };
 
 type BaseSource = {
diff --git a/packages/core/src/indexing/events.ts b/packages/core/src/indexing/events.ts
index dbcd5a75..0617e765 100644
--- a/packages/core/src/indexing/events.ts
+++ b/packages/core/src/indexing/events.ts
@@ -1,7 +1,7 @@
 import type { RawEvent } from "@/sync-store/store.js";
 import type { Block, Log, Transaction } from "@/types/eth.js";
 import { decodeEventLog } from "viem";
-import type { IndexingService } from "./service.js";
+import type { Service } from "./service.js";
 
 export type SetupEvent = {
   type: "setup";
@@ -29,7 +29,7 @@ export type LogEvent = {
 export type Event = LogEvent;
 
 export const decodeEvents = (
-  { common, sourceById }: Pick<IndexingService, "sourceById" | "common">,
+  { common, sourceById }: Pick<Service, "sourceById" | "common">,
   rawEvents: RawEvent[],
 ): Event[] => {
   const events: Event[] = [];
diff --git a/packages/core/src/indexing/index.ts b/packages/core/src/indexing/index.ts
new file mode 100644
index 00000000..9a724e82
--- /dev/null
+++ b/packages/core/src/indexing/index.ts
@@ -0,0 +1,23 @@
+import { type Extend, extend } from "@/utils/extend.js";
+import {
+  create,
+  kill,
+  processEvents,
+  processSetupEvents,
+  updateLastEventCheckpoint,
+} from "./service.js";
+import type { Context, Service } from "./service.js";
+
+const methods = {
+  create,
+  kill,
+  processEvents,
+  processSetupEvents,
+  updateLastEventCheckpoint,
+};
+
+export const createIndexingService = extend(create, methods);
+
+export type IndexingService = Extend<Service, typeof methods>;
+
+export type { Context };
diff --git a/packages/core/src/indexing/ponderActions.ts b/packages/core/src/indexing/ponderActions.ts
index bc599f24..173b729c 100644
--- a/packages/core/src/indexing/ponderActions.ts
+++ b/packages/core/src/indexing/ponderActions.ts
@@ -27,7 +27,7 @@ import {
   multicall as viemMulticall,
   readContract as viemReadContract,
 } from "viem/actions";
-import type { IndexingService, createIndexingService } from "./service.js";
+import type { Service, create } from "./service.js";
 
 export type BlockOptions =
   | {
@@ -88,10 +88,7 @@ export type ReadOnlyClient<
 >;
 
 export const buildCachedActions = (
-  contextState: Pick<
-    IndexingService["currentEvent"]["contextState"],
-    "blockNumber"
-  >,
+  contextState: Pick<Service["currentEvent"]["contextState"], "blockNumber">,
 ) => {
   return <
     TTransport extends Transport = Transport,
@@ -181,17 +178,14 @@ export const buildDb = ({
   schema,
   indexingStore,
   contextState,
-}: Pick<
-  Parameters<typeof createIndexingService>[0],
-  "common" | "schema" | "indexingStore"
-> & {
+}: Pick<Parameters<typeof create>[0], "common" | "schema" | "indexingStore"> & {
   contextState: Pick<
-    IndexingService["currentEvent"]["contextState"],
+    Service["currentEvent"]["contextState"],
     "encodedCheckpoint"
   >;
 }) => {
   return Object.keys(schema.tables).reduce<
-    IndexingService["currentEvent"]["context"]["db"]
+    Service["currentEvent"]["context"]["db"]
   >((acc, tableName) => {
     acc[tableName] = {
       findUnique: async ({ id }) => {
diff --git a/packages/core/src/indexing/service.ts b/packages/core/src/indexing/service.ts
index 181db382..7b8f772f 100644
--- a/packages/core/src/indexing/service.ts
+++ b/packages/core/src/indexing/service.ts
@@ -1,4 +1,4 @@
-import type { IndexingFunctions } from "@/build/functions/functions.js";
+import type { IndexingFunctions } from "@/build/configAndIndexingFunctions.js";
 import type { Common } from "@/common/common.js";
 import { NonRetryableError } from "@/common/errors.js";
 import type { Network } from "@/config/networks.js";
@@ -41,7 +41,7 @@ export type Context = {
   >;
 };
 
-export type IndexingService = {
+export type Service = {
   // static
   common: Common;
   indexingFunctions: IndexingFunctions;
@@ -75,7 +75,7 @@ export type IndexingService = {
   contractsByChainId: { [chainId: number]: Context["contracts"] };
 };
 
-export const createIndexingService = ({
+export const create = ({
   indexingFunctions,
   common,
   sources,
@@ -91,28 +91,25 @@ export const createIndexingService = ({
   syncService: SyncService;
   indexingStore: IndexingStore;
   schema: Schema;
-}): IndexingService => {
-  const contextState: IndexingService["currentEvent"]["contextState"] = {
+}): Service => {
+  const contextState: Service["currentEvent"]["contextState"] = {
     encodedCheckpoint: undefined!,
     blockNumber: undefined!,
   };
-  const clientByChainId: IndexingService["clientByChainId"] = {};
-  const contractsByChainId: IndexingService["contractsByChainId"] = {};
+  const clientByChainId: Service["clientByChainId"] = {};
+  const contractsByChainId: Service["contractsByChainId"] = {};
 
-  const networkByChainId = networks.reduce<IndexingService["networkByChainId"]>(
+  const networkByChainId = networks.reduce<Service["networkByChainId"]>(
     (acc, cur) => {
       acc[cur.chainId] = cur;
       return acc;
     },
     {},
   );
-  const sourceById = sources.reduce<IndexingService["sourceById"]>(
-    (acc, cur) => {
-      acc[cur.id] = cur;
-      return acc;
-    },
-    {},
-  );
+  const sourceById = sources.reduce<Service["sourceById"]>((acc, cur) => {
+    acc[cur.id] = cur;
+    return acc;
+  }, {});
 
   // build contractsByChainId
   for (const source of sources) {
@@ -150,7 +147,7 @@ export const createIndexingService = ({
   }
 
   // build eventCount
-  const eventCount: IndexingService["eventCount"] = {};
+  const eventCount: Service["eventCount"] = {};
   for (const contractName of Object.keys(indexingFunctions)) {
     for (const eventName of Object.keys(indexingFunctions[contractName])) {
       const indexingFunctionKey = `${contractName}:${eventName}`;
@@ -186,7 +183,7 @@ export const createIndexingService = ({
 };
 
 export const processSetupEvents = async (
-  indexingService: IndexingService,
+  indexingService: Service,
   {
     sources,
     networks,
@@ -242,7 +239,7 @@ export const processSetupEvents = async (
 };
 
 export const processEvents = async (
-  indexingService: IndexingService,
+  indexingService: Service,
   { events }: { events: Event[] },
 ): Promise<
   | { status: "error"; error: Error }
@@ -331,7 +328,7 @@ export const processEvents = async (
   return { status: "success" };
 };
 
-export const kill = (indexingService: IndexingService) => {
+export const kill = (indexingService: Service) => {
   indexingService.common.logger.debug({
     service: "indexing",
     msg: "Killed indexing service",
@@ -340,7 +337,7 @@ export const kill = (indexingService: IndexingService) => {
 };
 
 export const updateLastEventCheckpoint = (
-  indexingService: IndexingService,
+  indexingService: Service,
   lastEventCheckpoint: Checkpoint,
 ) => {
   indexingService.lastEventCheckpoint = lastEventCheckpoint;
@@ -353,7 +350,7 @@ export const updateLastEventCheckpoint = (
   }
 };
 
-const updateCompletedEvents = (indexingService: IndexingService) => {
+const updateCompletedEvents = (indexingService: Service) => {
   for (const event of Object.keys(indexingService.eventCount)) {
     for (const network of Object.keys(indexingService.eventCount[event])) {
       const metricLabel = {
@@ -369,7 +366,7 @@ const updateCompletedEvents = (indexingService: IndexingService) => {
 };
 
 const executeSetup = async (
-  indexingService: IndexingService,
+  indexingService: Service,
   { event }: { event: SetupEvent },
 ): Promise<
   | { status: "error"; error: Error }
@@ -456,7 +453,7 @@ const executeSetup = async (
 };
 
 const executeLog = async (
-  indexingService: IndexingService,
+  indexingService: Service,
   { event }: { event: LogEvent },
 ): Promise<
   | { status: "error"; error: Error }
diff --git a/packages/core/src/sync-store/postgres/encoding.ts b/packages/core/src/sync-store/postgres/encoding.ts
index c95afa06..17058924 100644
--- a/packages/core/src/sync-store/postgres/encoding.ts
+++ b/packages/core/src/sync-store/postgres/encoding.ts
@@ -27,7 +27,7 @@ type BlocksTable = {
   size: bigint;
   stateRoot: Hash;
   timestamp: bigint;
-  totalDifficulty: bigint;
+  totalDifficulty: bigint | null;
   transactionsRoot: Hash;
 
   chainId: number;
@@ -56,7 +56,9 @@ export function rpcToPostgresBlock(
     size: BigInt(block.size),
     stateRoot: block.stateRoot,
     timestamp: BigInt(block.timestamp),
-    totalDifficulty: BigInt(block.totalDifficulty!),
+    totalDifficulty: block.totalDifficulty
+      ? BigInt(block.totalDifficulty)
+      : null,
     transactionsRoot: block.transactionsRoot,
   };
 }
diff --git a/packages/core/src/sync-store/postgres/migrations.ts b/packages/core/src/sync-store/postgres/migrations.ts
index 8f88ee11..c2e68585 100644
--- a/packages/core/src/sync-store/postgres/migrations.ts
+++ b/packages/core/src/sync-store/postgres/migrations.ts
@@ -1,5 +1,4 @@
 import type { Common } from "@/common/common.js";
-import { EVENT_TYPES, encodeCheckpoint } from "@/utils/checkpoint.js";
 import type { Kysely } from "kysely";
 import { type Migration, type MigrationProvider, sql } from "kysely";
 
@@ -457,81 +456,83 @@ const migrations: Record<string, Migration> = {
     },
   },
   "2024_03_20_0_checkpoint_in_logs_table": {
+    async up(_db: Kysely<any>) {
+      // no-op migration to avoid crashing databases that successfully ran this migration
+      return;
+    },
+  },
+  "2024_04_04_0_log_events_indexes": {
     async up(db: Kysely<any>) {
+      await db.schema.dropIndex("blockNumberIndex").ifExists().execute();
+      await db.schema.dropIndex("blockTimestampIndex").ifExists().execute();
+
       await db.schema
-        .alterTable("logs")
-        .addColumn("checkpoint", "varchar(75)")
+        .createIndex("logBlockNumberIndex")
+        .on("logs")
+        .column("blockNumber")
+        .execute();
+    },
+  },
+  "2024_04_14_0_nullable_block_total_difficulty": {
+    async up(db: Kysely<any>) {
+      await db.schema
+        .alterTable("blocks")
+        .alterColumn("totalDifficulty", (col) => col.dropNotNull())
         .execute();
+    },
+  },
+  "2024_04_14_1_add_checkpoint_column_to_logs_table": {
+    async up(db: Kysely<any>) {
       await db.executeQuery(
         sql`
-          WITH checkpoint_vals AS (
-            SELECT logs.id, blocks.timestamp, blocks."chainId", blocks.number, logs."transactionIndex", logs."logIndex"
-            FROM ponder_sync.logs logs
-            JOIN ponder_sync.blocks blocks ON logs."blockHash" = blocks.hash
-          )
-          UPDATE ponder_sync.logs
-          SET checkpoint=
-              (lpad(checkpoint_vals.timestamp::text, 10, '0') ||
-              lpad(checkpoint_vals."chainId"::text, 16, '0') ||
-              lpad(checkpoint_vals.number::text, 16, '0') ||
-              lpad(checkpoint_vals."transactionIndex"::text, 16, '0') ||
-              '5' ||
-              lpad(checkpoint_vals."logIndex"::text, 16, '0'))
-          FROM checkpoint_vals
-          WHERE ponder_sync.logs.id = checkpoint_vals.id;
+        ALTER TABLE ponder_sync.logs 
+        ADD COLUMN IF NOT EXISTS 
+        checkpoint varchar(75)`.compile(db),
+      );
+    },
+  },
+  "2024_04_14_2_set_checkpoint_in_logs_table": {
+    async up(db: Kysely<any>) {
+      await db.executeQuery(sql`SET statement_timeout = 3600000;`.compile(db));
+      await db.executeQuery(
+        sql`
+        CREATE TEMP TABLE cp_vals AS 
+        SELECT
+          logs.id,
+          (lpad(blocks.timestamp::text, 10, '0') ||
+          lpad(blocks."chainId"::text, 16, '0') ||
+          lpad(blocks.number::text, 16, '0') ||
+          lpad(logs."transactionIndex"::text, 16, '0') ||
+          '5' ||
+          lpad(logs."logIndex"::text, 16, '0')) AS checkpoint
+        FROM ponder_sync.logs logs
+        JOIN ponder_sync.blocks blocks ON logs."blockHash" = blocks.hash;
         `.compile(db),
       );
 
-      // sanity check our checkpoint encoding on the first 10 rows of the table
-      const checkRes = await db.executeQuery<{
-        timestamp: number;
-        chainId: number;
-        number: number;
-        transactionIndex: number;
-        logIndex: number;
-        checkpoint: bigint;
-      }>(
+      await db.executeQuery(
         sql`
-          SELECT blocks.timestamp, blocks."chainId", blocks.number, logs."transactionIndex", logs."logIndex", logs.checkpoint
-          FROM ponder_sync.logs logs
-          JOIN ponder_sync.blocks blocks ON logs."blockHash" = blocks.hash
-          LIMIT 10
+        CREATE INDEX ON cp_vals(id)
         `.compile(db),
       );
 
-      for (const row of checkRes.rows) {
-        const expected = encodeCheckpoint({
-          blockTimestamp: row.timestamp,
-          chainId: row.chainId,
-          blockNumber: row.number,
-          transactionIndex: row.transactionIndex,
-          eventType: EVENT_TYPES.logs,
-          eventIndex: row.logIndex,
-        });
-
-        if (row.checkpoint.toString() !== expected) {
-          throw new Error(
-            `data migration failed: expected new checkpoint column to have value ${expected} but got ${row.checkpoint}`,
-          );
-        }
-      }
-
-      await db.schema
-        .createIndex("logs_checkpoint_index")
-        .on("logs")
-        .column("checkpoint")
-        .execute();
+      await db.executeQuery(
+        sql`
+          UPDATE ponder_sync.logs
+          SET checkpoint=cp_vals.checkpoint
+          FROM cp_vals
+          WHERE ponder_sync.logs.id = cp_vals.id
+        `.compile(db),
+      );
     },
   },
-  "2024_04_04_0_log_events_indexes": {
+  "2024_04_14_3_index_on_logs_checkpoint": {
     async up(db: Kysely<any>) {
-      await db.schema.dropIndex("blockNumberIndex").ifExists().execute();
-      await db.schema.dropIndex("blockTimestampIndex").ifExists().execute();
-
       await db.schema
-        .createIndex("logBlockNumberIndex")
+        .createIndex("logs_checkpoint_index")
+        .ifNotExists()
         .on("logs")
-        .column("blockNumber")
+        .column("checkpoint")
         .execute();
     },
   },
diff --git a/packages/core/src/sync-store/postgres/store.ts b/packages/core/src/sync-store/postgres/store.ts
index 5ec626fc..6b1ba1cc 100644
--- a/packages/core/src/sync-store/postgres/store.ts
+++ b/packages/core/src/sync-store/postgres/store.ts
@@ -859,7 +859,6 @@ export class PostgresSyncStore implements SyncStore {
             .where((eb) => {
               const logFilterCmprs = sources
                 .filter(sourceIsLogFilter)
-                .filter((source) => source.criteria.topics !== undefined)
                 .map((logFilter) => {
                   const exprs = this.buildLogFilterCmprs({ eb, logFilter });
                   exprs.push(eb("source_id", "=", logFilter.id));
@@ -868,7 +867,6 @@ export class PostgresSyncStore implements SyncStore {
 
               const factoryCmprs = sources
                 .filter(sourceIsFactory)
-                .filter((source) => source.criteria.topics !== undefined)
                 .map((factory) => {
                   const exprs = this.buildFactoryCmprs({ eb, factory });
                   exprs.push(eb("source_id", "=", factory.id));
@@ -1068,7 +1066,6 @@ export class PostgresSyncStore implements SyncStore {
         .where((eb) => {
           const logFilterCmprs = sources
             .filter(sourceIsLogFilter)
-            .filter((source) => source.criteria.topics !== undefined)
             .map((logFilter) => {
               const exprs = this.buildLogFilterCmprs({ eb, logFilter });
               return eb.and(exprs);
@@ -1076,7 +1073,6 @@ export class PostgresSyncStore implements SyncStore {
 
           const factoryCmprs = sources
             .filter(sourceIsFactory)
-            .filter((source) => source.criteria.topics !== undefined)
             .map((factory) => {
               const exprs = this.buildFactoryCmprs({ eb, factory });
               return eb.and(exprs);
diff --git a/packages/core/src/sync-store/sqlite/encoding.ts b/packages/core/src/sync-store/sqlite/encoding.ts
index be0a4f81..4132d609 100644
--- a/packages/core/src/sync-store/sqlite/encoding.ts
+++ b/packages/core/src/sync-store/sqlite/encoding.ts
@@ -30,7 +30,7 @@ type BlocksTable = {
   size: BigIntText;
   stateRoot: Hash;
   timestamp: BigIntText;
-  totalDifficulty: BigIntText;
+  totalDifficulty: BigIntText | null;
   transactionsRoot: Hash;
 
   chainId: number;
@@ -61,7 +61,9 @@ export function rpcToSqliteBlock(
     size: encodeAsText(block.size),
     stateRoot: block.stateRoot,
     timestamp: encodeAsText(block.timestamp),
-    totalDifficulty: encodeAsText(block.totalDifficulty!),
+    totalDifficulty: block.totalDifficulty
+      ? encodeAsText(block.totalDifficulty)
+      : null,
     transactionsRoot: block.transactionsRoot,
   };
 }
diff --git a/packages/core/src/sync-store/sqlite/migrations.ts b/packages/core/src/sync-store/sqlite/migrations.ts
index 4458a5fb..5435e4f4 100644
--- a/packages/core/src/sync-store/sqlite/migrations.ts
+++ b/packages/core/src/sync-store/sqlite/migrations.ts
@@ -1,5 +1,3 @@
-import { EVENT_TYPES, encodeCheckpoint } from "@/utils/checkpoint.js";
-import { decodeToBigInt } from "@/utils/encoding.js";
 import { type Kysely, sql } from "kysely";
 import { type Migration, type MigrationProvider } from "kysely";
 
@@ -463,86 +461,89 @@ const migrations: Record<string, Migration> = {
     },
   },
   "2024_03_20_0_checkpoint_in_logs_table": {
+    async up(_db: Kysely<any>) {
+      // no-op migration to avoid crashing databases that successfully ran this migration
+      return;
+    },
+  },
+  "2024_04_04_0_log_events_indexes": {
+    async up(db: Kysely<any>) {
+      await db.schema.dropIndex("blockNumberIndex").ifExists().execute();
+      await db.schema.dropIndex("blockTimestampIndex").ifExists().execute();
+
+      await db.schema
+        .createIndex("logBlockNumberIndex")
+        .on("logs")
+        .column("blockNumber")
+        .execute();
+    },
+  },
+  "2024_04_14_0_nullable_block_total_difficulty": {
+    async up(db: Kysely<any>) {
+      await columnDropNotNull({
+        db,
+        table: "blocks",
+        column: "totalDifficulty",
+        columnType: "varchar(79)",
+      });
+    },
+  },
+  "2024_04_14_1_add_checkpoint_column_to_logs_table": {
     async up(db: Kysely<any>) {
+      if (await hasCheckpointCol(db)) {
+        return;
+      }
       await db.schema
         .alterTable("logs")
         .addColumn("checkpoint", "varchar(75)")
         .execute();
+    },
+  },
+  "2024_04_14_2_set_checkpoint_in_logs_table": {
+    async up(db: Kysely<any>) {
       await db.executeQuery(
         sql`
-          WITH checkpoint_vals AS (
-              SELECT logs.id, blocks.timestamp, blocks.chainId, blocks.number, logs.transactionIndex, logs.logIndex
-              FROM logs
-              JOIN blocks ON logs."blockHash" = blocks.hash
-          )
-          UPDATE logs
-          SET checkpoint =
-              substr(checkpoint_vals.timestamp, -10, 10) ||
-              substr('0000000000000000' || checkpoint_vals.chainId, -16, 16) ||
-              substr(checkpoint_vals.number, -16, 16) ||
-              substr('0000000000000000' || checkpoint_vals.transactionIndex, -16, 16) ||
-              '5' ||
-              substr('0000000000000000' || checkpoint_vals.logIndex, -16, 16)
-          FROM checkpoint_vals
-          WHERE logs.id = checkpoint_vals.id;
-        `.compile(db),
+        CREATE TEMPORARY TABLE cp_vals AS
+        SELECT 
+          logs.id,
+          substr(blocks.timestamp, -10, 10) ||
+            substr('0000000000000000' || blocks.chainId, -16, 16) ||
+            substr(blocks.number, -16, 16) ||
+            substr('0000000000000000' || logs.transactionIndex, -16, 16) ||
+            '5' ||
+            substr('0000000000000000' || logs.logIndex, -16, 16) as checkpoint
+          FROM logs
+          JOIN blocks ON logs."blockHash" = blocks.hash
+      `.compile(db),
       );
 
-      // sanity check our checkpoint encoding on the first 10 rows of the table
-      const checkRes = await db.executeQuery<{
-        timestamp: string;
-        chainId: number;
-        number: string;
-        transactionIndex: number;
-        logIndex: number;
-        checkpoint: string;
-      }>(
+      await db.executeQuery(
         sql`
-          SELECT blocks.timestamp, blocks.chainId, blocks.number, logs.transactionIndex, logs.logIndex, logs.checkpoint
-          FROM logs
-          JOIN blocks ON logs.blockHash = blocks.hash
-          LIMIT 10;
-        `.compile(db),
+        UPDATE logs 
+        SET checkpoint=cp_vals.checkpoint
+        FROM cp_vals
+        WHERE logs.id = cp_vals.id
+      `.compile(db),
       );
-
-      for (const row of checkRes.rows) {
-        const expected = encodeCheckpoint({
-          blockTimestamp: Number(decodeToBigInt(row.timestamp)),
-          chainId: row.chainId,
-          blockNumber: Number(decodeToBigInt(row.number)),
-          transactionIndex: row.transactionIndex,
-          eventType: EVENT_TYPES.logs,
-          eventIndex: row.logIndex,
-        });
-
-        if (row.checkpoint.toString() !== expected) {
-          throw new Error(
-            `data migration failed: expected new checkpoint column to have value ${expected} but got ${row.checkpoint}`,
-          );
-        }
-      }
-
-      await db.schema
-        .createIndex("logs_checkpoint_index")
-        .on("logs")
-        .column("checkpoint")
-        .execute();
     },
   },
-  "2024_04_04_0_log_events_indexes": {
+  "2024_04_14_3_index_on_logs_checkpoint": {
     async up(db: Kysely<any>) {
-      await db.schema.dropIndex("blockNumberIndex").ifExists().execute();
-      await db.schema.dropIndex("blockTimestampIndex").ifExists().execute();
-
       await db.schema
-        .createIndex("logBlockNumberIndex")
+        .createIndex("logs_checkpoint_index")
+        .ifNotExists()
         .on("logs")
-        .column("blockNumber")
+        .column("checkpoint")
         .execute();
     },
   },
 };
 
+async function hasCheckpointCol(db: Kysely<any>) {
+  const res = await db.executeQuery(sql`PRAGMA table_info("logs")`.compile(db));
+  return res.rows.some((x: any) => x.name === "checkpoint");
+}
+
 const columnDropNotNull = async ({
   db,
   table,
diff --git a/packages/core/src/sync-store/sqlite/store.ts b/packages/core/src/sync-store/sqlite/store.ts
index f582af7e..72b9d005 100644
--- a/packages/core/src/sync-store/sqlite/store.ts
+++ b/packages/core/src/sync-store/sqlite/store.ts
@@ -884,7 +884,6 @@ export class SqliteSyncStore implements SyncStore {
             .where((eb) => {
               const logFilterCmprs = sources
                 .filter(sourceIsLogFilter)
-                .filter((source) => source.criteria.topics !== undefined)
                 .map((logFilter) => {
                   const exprs = this.buildLogFilterCmprs({ eb, logFilter });
                   exprs.push(eb("source_id", "=", logFilter.id));
@@ -893,7 +892,6 @@ export class SqliteSyncStore implements SyncStore {
 
               const factoryCmprs = sources
                 .filter(sourceIsFactory)
-                .filter((source) => source.criteria.topics !== undefined)
                 .map((factory) => {
                   const exprs = this.buildFactoryCmprs({ eb, factory });
                   exprs.push(eb("source_id", "=", factory.id));
@@ -1011,7 +1009,9 @@ export class SqliteSyncStore implements SyncStore {
                 size: decodeToBigInt(row.block_size),
                 stateRoot: row.block_stateRoot,
                 timestamp: decodeToBigInt(row.block_timestamp),
-                totalDifficulty: decodeToBigInt(row.block_totalDifficulty),
+                totalDifficulty: row.block_totalDifficulty
+                  ? decodeToBigInt(row.block_totalDifficulty)
+                  : null,
                 transactionsRoot: row.block_transactionsRoot,
               },
               transaction: {
@@ -1104,7 +1104,6 @@ export class SqliteSyncStore implements SyncStore {
         .where((eb) => {
           const logFilterCmprs = sources
             .filter(sourceIsLogFilter)
-            .filter((source) => source.criteria.topics !== undefined)
             .map((logFilter) => {
               const exprs = this.buildLogFilterCmprs({ eb, logFilter });
               return eb.and(exprs);
@@ -1112,7 +1111,6 @@ export class SqliteSyncStore implements SyncStore {
 
           const factoryCmprs = sources
             .filter(sourceIsFactory)
-            .filter((source) => source.criteria.topics !== undefined)
             .map((factory) => {
               const exprs = this.buildFactoryCmprs({ eb, factory });
               return eb.and(exprs);
diff --git a/packages/core/src/types/eth.ts b/packages/core/src/types/eth.ts
index b16fa5eb..2e8b9e7e 100644
--- a/packages/core/src/types/eth.ts
+++ b/packages/core/src/types/eth.ts
@@ -42,7 +42,7 @@ export type Block = {
   /** Unix timestamp of when this block was collated */
   timestamp: bigint;
   /** Total difficulty of the chain until this block */
-  totalDifficulty: bigint;
+  totalDifficulty: bigint | null;
   /** Root of this block’s transaction trie */
   transactionsRoot: Hash;
 };
diff --git a/packages/core/src/utils/extend.ts b/packages/core/src/utils/extend.ts
new file mode 100644
index 00000000..4616e137
--- /dev/null
+++ b/packages/core/src/utils/extend.ts
@@ -0,0 +1,55 @@
+import type { Prettify } from "@/types/utils.js";
+
+export const extend = <
+  TCreate extends (...params: any[]) => any,
+  TMethods extends { [methodName: string]: (...params: any[]) => unknown },
+>(
+  create: TCreate,
+  _methods: TMethods,
+): ((
+  ...params: Parameters<TCreate>
+) => ReturnType<TCreate> extends Promise<any>
+  ? Promise<Extend<Awaited<ReturnType<TCreate>>, TMethods>>
+  : Extend<ReturnType<TCreate>, TMethods>) => {
+  return (...params: Parameters<TCreate>) => {
+    const service = create(...params);
+
+    if (service instanceof Promise) {
+      return service.then((s) => {
+        const methods: any = {};
+        for (const [methodName, method] of Object.entries(_methods)) {
+          methods[methodName] = (...params: any) => method(s, ...params);
+        }
+
+        return {
+          ...s,
+          ...methods,
+        };
+      });
+    } else {
+      const methods: any = {};
+      for (const [methodName, method] of Object.entries(_methods)) {
+        methods[methodName] = (...params: any) => method(service, ...params);
+      }
+
+      return {
+        ...service,
+        ...methods,
+      };
+    }
+  };
+};
+
+export type Extend<
+  service,
+  methods extends { [methodName: string]: (...params: any[]) => unknown },
+> = Prettify<
+  service & {
+    [methodName in keyof methods]: Parameters<methods[methodName]> extends [
+      any,
+      ...infer parameters,
+    ]
+      ? (...params: parameters) => ReturnType<methods[methodName]>
+      : never;
+  }
+>;
diff --git a/packages/create-ponder/CHANGELOG.md b/packages/create-ponder/CHANGELOG.md
index aa9ec40b..81912385 100644
--- a/packages/create-ponder/CHANGELOG.md
+++ b/packages/create-ponder/CHANGELOG.md
@@ -1,5 +1,7 @@
 # create-ponder
 
+## 0.4.3
+
 ## 0.4.2
 
 ## 0.4.1
diff --git a/packages/create-ponder/package.json b/packages/create-ponder/package.json
index 8e647e29..29a0c222 100644
--- a/packages/create-ponder/package.json
+++ b/packages/create-ponder/package.json
@@ -1,6 +1,6 @@
 {
   "name": "create-ponder",
-  "version": "0.4.2",
+  "version": "0.4.3",
   "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 b04f3ada..a850a47f 100644
--- a/packages/eslint-config-ponder/CHANGELOG.md
+++ b/packages/eslint-config-ponder/CHANGELOG.md
@@ -1,5 +1,7 @@
 # eslint-config-ponder
 
+## 0.4.3
+
 ## 0.4.2
 
 ## 0.4.1
diff --git a/packages/eslint-config-ponder/package.json b/packages/eslint-config-ponder/package.json
index ad8d81b0..4fda9fd1 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.4.2",
+  "version": "0.4.3",
   "description": "ESLint config for Ponder apps",
   "license": "MIT",
   "main": "./index.js",


Test patch:
diff --git a/packages/core/src/_test/utils.ts b/packages/core/src/_test/utils.ts
index 77aede0a..1d53c167 100644
--- a/packages/core/src/_test/utils.ts
+++ b/packages/core/src/_test/utils.ts
@@ -1,5 +1,5 @@
 import { type AddressInfo, createServer } from "node:net";
-import { buildConfig } from "@/build/config/config.js";
+import { buildConfigAndIndexingFunctions } from "@/build/configAndIndexingFunctions.js";
 import type { Common } from "@/common/common.js";
 import { createConfig } from "@/config/config.js";
 import { type Source } from "@/config/sources.js";
@@ -116,8 +116,12 @@ export const getNetworkAndSources = async (
   common: Common,
 ) => {
   const config = getConfig(addresses);
-  const { networks, sources } = await buildConfig({
+  const { networks, sources } = await buildConfigAndIndexingFunctions({
     config,
+    rawIndexingFunctions: [
+      { name: "Erc20:Transfer", fn: () => {} },
+      { name: "Pair:Swap", fn: () => {} },
+    ],
     options: common.options,
   });
   const mainnet = { ...networks[0], finalityBlockCount: 4 };
diff --git a/packages/core/src/bin/utils/run.test.ts b/packages/core/src/bin/utils/run.test.ts
index c6bdb5f1..6e9e51fc 100644
--- a/packages/core/src/bin/utils/run.test.ts
+++ b/packages/core/src/bin/utils/run.test.ts
@@ -1,5 +1,5 @@
 import { setupAnvil, setupIsolatedDatabase } from "@/_test/setup.js";
-import type { Build } from "@/build/service.js";
+import type { Build } from "@/build/index.js";
 import * as codegen from "@/common/codegen.js";
 import { createSchema } from "@/schema/schema.js";
 import { buildGraphqlSchema } from "@/server/graphql/buildGraphqlSchema.js";
diff --git a/packages/core/src/build/config/config.test.ts b/packages/core/src/build/configAndIndexingFunctions.test.ts
similarity index 65%
rename from packages/core/src/build/config/config.test.ts
rename to packages/core/src/build/configAndIndexingFunctions.test.ts
index 71098768..cb3d79b8 100644
--- a/packages/core/src/build/config/config.test.ts
+++ b/packages/core/src/build/configAndIndexingFunctions.test.ts
@@ -2,8 +2,11 @@ import path from "path";
 import type { Options } from "@/common/options.js";
 import { http, getEventSelector, parseAbiItem } from "viem";
 import { expect, test, vi } from "vitest";
-import { type Config, createConfig } from "../../config/config.js";
-import { buildConfig, safeBuildConfig } from "./config.js";
+import { type Config, createConfig } from "../config/config.js";
+import {
+  buildConfigAndIndexingFunctions,
+  safeBuildConfigAndIndexingFunctions,
+} from "./configAndIndexingFunctions.js";
 
 const event0 = parseAbiItem("event Event0(bytes32 indexed arg)");
 const event1 = parseAbiItem("event Event1()");
@@ -21,7 +24,7 @@ const options = {
   rootDir: "rootDir",
 } as const satisfies Pick<Options, "rootDir" | "ponderDir">;
 
-test("buildConfig() builds topics for multiple events", async () => {
+test("buildConfigAndIndexingFunctions() builds topics for multiple events", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("http://127.0.0.1:8545") },
@@ -39,20 +42,27 @@ test("buildConfig() builds topics for multiple events", async () => {
     },
   });
 
-  const { sources } = await buildConfig({ config, options });
+  const { sources } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [
+      { name: "a:Event0", fn: () => {} },
+      { name: "a:Event1", fn: () => {} },
+    ],
+    options,
+  });
 
   expect(sources[0].criteria.topics).toMatchObject([
     [getEventSelector(event0), getEventSelector(event1)],
   ]);
 });
 
-test("buildConfig() handles overloaded event signatures and combines topics", async () => {
+test("buildConfigAndIndexingFunctions() handles overloaded event signatures and combines topics", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("http://127.0.0.1:8545") },
     },
     contracts: {
-      BaseRegistrartImplementation: {
+      a: {
         network: { mainnet: {} },
         abi: [event1, event1Overloaded],
         filter: {
@@ -66,14 +76,21 @@ test("buildConfig() handles overloaded event signatures and combines topics", as
     },
   });
 
-  const { sources } = await buildConfig({ config, options });
+  const { sources } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [
+      { name: "a:Event1()", fn: () => {} },
+      { name: "a:Event1(bytes32 indexed)", fn: () => {} },
+    ],
+    options,
+  });
 
   expect(sources[0].criteria.topics).toMatchObject([
     [getEventSelector(event1), getEventSelector(event1Overloaded)],
   ]);
 });
 
-test("buildConfig() creates a source for each network for multi-network contracts", async () => {
+test("buildConfigAndIndexingFunctions() creates a source for each network for multi-network contracts", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("http://127.0.0.1:8545") },
@@ -87,12 +104,16 @@ test("buildConfig() creates a source for each network for multi-network contract
     },
   });
 
-  const { sources } = await buildConfig({ config, options });
+  const { sources } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
   expect(sources.length).toBe(2);
 });
 
-test("buildConfig() builds topics for event with args", async () => {
+test("buildConfigAndIndexingFunctions() builds topics for event with args", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("http://127.0.0.1:8545") },
@@ -115,15 +136,19 @@ test("buildConfig() builds topics for event with args", async () => {
     },
   });
 
-  const { sources } = await buildConfig({ config, options });
+  const { sources } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
   expect(sources[0].criteria.topics).toMatchObject([
-    getEventSelector(event0),
+    [getEventSelector(event0)],
     bytes1,
   ]);
 });
 
-test("buildConfig() builds topics for event with unnamed parameters", async () => {
+test("buildConfigAndIndexingFunctions() builds topics for event with unnamed parameters", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("http://127.0.0.1:8545") },
@@ -144,15 +169,19 @@ test("buildConfig() builds topics for event with unnamed parameters", async () =
     },
   });
 
-  const { sources } = await buildConfig({ config, options });
+  const { sources } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event1", fn: () => {} }],
+    options,
+  });
 
   expect(sources[0].criteria.topics).toMatchObject([
-    getEventSelector(event1Overloaded),
+    [getEventSelector(event1Overloaded)],
     [bytes1, bytes2],
   ]);
 });
 
-test("buildConfig() overrides default values with network-specific values", async () => {
+test("buildConfigAndIndexingFunctions() overrides default values with network-specific values", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("http://127.0.0.1:8545") },
@@ -174,12 +203,16 @@ test("buildConfig() overrides default values with network-specific values", asyn
     },
   });
 
-  const { sources } = await buildConfig({ config, options });
+  const { sources } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
   expect(sources[0].criteria.address).toBe(address2);
 });
 
-test("buildConfig() handles network name shortcut", async () => {
+test("buildConfigAndIndexingFunctions() handles network name shortcut", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("http://127.0.0.1:8545") },
@@ -197,12 +230,16 @@ test("buildConfig() handles network name shortcut", async () => {
     },
   });
 
-  const { sources } = await buildConfig({ config, options });
+  const { sources } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
   expect(sources[0].networkName).toBe("mainnet");
 });
 
-test("buildConfig() validates network name", async () => {
+test("buildConfigAndIndexingFunctions() validates network name", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("http://127.0.0.1:8545") },
@@ -217,15 +254,19 @@ test("buildConfig() validates network name", async () => {
     },
   });
 
-  const result = await safeBuildConfig({ config, options });
+  const result = await safeBuildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid network for contract 'a'. Got 'mainnetz', expected one of ['mainnet'].",
   );
 });
 
-test("buildConfig() warns for public RPC URL", async () => {
+test("buildConfigAndIndexingFunctions() warns for public RPC URL", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("https://cloudflare-eth.com") },
@@ -239,10 +280,14 @@ test("buildConfig() warns for public RPC URL", async () => {
     },
   });
 
-  const result = await safeBuildConfig({ config, options });
+  const result = await safeBuildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
-  expect(result.success).toBe(true);
-  expect(result.data?.logs.filter((l) => l.level === "warn")).toMatchObject([
+  expect(result.status).toBe("success");
+  expect(result.logs!.filter((l) => l.level === "warn")).toMatchObject([
     {
       level: "warn",
       msg: "Network 'mainnet' is using a public RPC URL (https://cloudflare-eth.com). Most apps require an RPC URL with a higher rate limit.",
@@ -250,7 +295,7 @@ test("buildConfig() warns for public RPC URL", async () => {
   ]);
 });
 
-test("buildConfig() validates against multiple events and indexed argument values", async () => {
+test("buildConfigAndIndexingFunctions() validates against multiple events and indexed argument values", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("https://cloudflare-eth.com") },
@@ -268,15 +313,19 @@ test("buildConfig() validates against multiple events and indexed argument value
     },
   }) as any;
 
-  const result = await safeBuildConfig({ config, options });
+  const result = await safeBuildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Event filter for contract 'a' cannot contain indexed argument values if multiple events are provided.",
   );
 });
 
-test("buildConfig() validates event filter event name must be present in ABI", async () => {
+test("buildConfigAndIndexingFunctions() validates event filter event name must be present in ABI", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("https://cloudflare-eth.com") },
@@ -293,15 +342,19 @@ test("buildConfig() validates event filter event name must be present in ABI", a
     },
   });
 
-  const result = await safeBuildConfig({ config, options });
+  const result = await safeBuildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid filter for contract 'a'. Got event name 'Event2', expected one of ['Event0'].",
   );
 });
 
-test("buildConfig() validates against specifying both factory and address", async () => {
+test("buildConfigAndIndexingFunctions() validates against specifying both factory and address", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("https://cloudflare-eth.com") },
@@ -321,15 +374,19 @@ test("buildConfig() validates against specifying both factory and address", asyn
     },
   });
 
-  const result = await safeBuildConfig({ config, options });
+  const result = await safeBuildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Contract 'a' cannot specify both 'factory' and 'address' options.",
   );
 });
 
-test("buildConfig() validates address prefix", async () => {
+test("buildConfigAndIndexingFunctions() validates address prefix", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("https://cloudflare-eth.com") },
@@ -344,15 +401,19 @@ test("buildConfig() validates address prefix", async () => {
     },
   }) as Config;
 
-  const result = await safeBuildConfig({ config, options });
+  const result = await safeBuildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [],
+    options,
+  });
 
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid prefix for address '0b0000000000000000000000000000000000000001'. Got '0b', expected '0x'.",
   );
 });
 
-test("buildConfig() validates address length", async () => {
+test("buildConfigAndIndexingFunctions() validates address length", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("https://cloudflare-eth.com") },
@@ -366,15 +427,19 @@ test("buildConfig() validates address length", async () => {
     },
   });
 
-  const result = await safeBuildConfig({ config, options });
+  const result = await safeBuildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid length for address '0x000000000001'. Got 14, expected 42 characters.",
   );
 });
 
-test("buildConfig() coerces NaN startBlock to 0", async () => {
+test("buildConfigAndIndexingFunctions() coerces NaN startBlock to 0", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("http://127.0.0.1:8545") },
@@ -388,12 +453,16 @@ test("buildConfig() coerces NaN startBlock to 0", async () => {
     },
   });
 
-  const { sources } = await buildConfig({ config, options });
+  const { sources } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
   expect(sources[0].startBlock).toBe(0);
 });
 
-test("buildConfig() coerces NaN endBlock to undefined", async () => {
+test("buildConfigAndIndexingFunctions() coerces NaN endBlock to undefined", async () => {
   const config = createConfig({
     networks: {
       mainnet: { chainId: 1, transport: http("http://127.0.0.1:8545") },
@@ -407,12 +476,16 @@ test("buildConfig() coerces NaN endBlock to undefined", async () => {
     },
   });
 
-  const { sources } = await buildConfig({ config, options });
+  const { sources } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
 
   expect(sources[0].endBlock).toBe(undefined);
 });
 
-test("buildConfig() database uses sqlite by default", async () => {
+test("buildConfigAndIndexingFunctions() database uses sqlite by default", async () => {
   const config = createConfig({
     networks: { mainnet: { chainId: 1, transport: http() } },
     contracts: { a: { network: "mainnet", abi: [event0] } },
@@ -422,7 +495,11 @@ test("buildConfig() database uses sqlite by default", async () => {
   // biome-ignore lint/performance/noDelete: Required to test default behavior.
   delete process.env.DATABASE_URL;
 
-  const { databaseConfig } = await buildConfig({ config, options });
+  const { databaseConfig } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [{ name: "a:Event0", fn: () => {} }],
+    options,
+  });
   expect(databaseConfig).toMatchObject({
     kind: "sqlite",
     directory: expect.stringContaining(path.join(".ponder", "sqlite")),
@@ -431,7 +508,7 @@ test("buildConfig() database uses sqlite by default", async () => {
   process.env.DATABASE_URL = prev;
 });
 
-test("buildConfig() database uses sqlite if specified even if DATABASE_URL env var present", async () => {
+test("buildConfigAndIndexingFunctions() database uses sqlite if specified even if DATABASE_URL env var present", async () => {
   const config = createConfig({
     database: { kind: "sqlite" },
     networks: { mainnet: { chainId: 1, transport: http() } },
@@ -440,7 +517,11 @@ test("buildConfig() database uses sqlite if specified even if DATABASE_URL env v
 
   vi.stubEnv("DATABASE_URL", "postgres://username@localhost:5432/database");
 
-  const { databaseConfig } = await buildConfig({ config, options });
+  const { databaseConfig } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [],
+    options,
+  });
   expect(databaseConfig).toMatchObject({
     kind: "sqlite",
     directory: expect.stringContaining(path.join(".ponder", "sqlite")),
@@ -449,7 +530,7 @@ test("buildConfig() database uses sqlite if specified even if DATABASE_URL env v
   vi.unstubAllEnvs();
 });
 
-test("buildConfig() database uses postgres if DATABASE_URL env var present", async () => {
+test("buildConfigAndIndexingFunctions() database uses postgres if DATABASE_URL env var present", async () => {
   const config = createConfig({
     networks: { mainnet: { chainId: 1, transport: http() } },
     contracts: { a: { network: "mainnet", abi: [event0] } },
@@ -457,7 +538,11 @@ test("buildConfig() database uses postgres if DATABASE_URL env var present", asy
 
   vi.stubEnv("DATABASE_URL", "postgres://username@localhost:5432/database");
 
-  const { databaseConfig } = await buildConfig({ config, options });
+  const { databaseConfig } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [],
+    options,
+  });
   expect(databaseConfig).toMatchObject({
     kind: "postgres",
     poolConfig: {
@@ -469,7 +554,7 @@ test("buildConfig() database uses postgres if DATABASE_URL env var present", asy
   vi.unstubAllEnvs();
 });
 
-test("buildConfig() database uses postgres if DATABASE_PRIVATE_URL env var present", async () => {
+test("buildConfigAndIndexingFunctions() database uses postgres if DATABASE_PRIVATE_URL env var present", async () => {
   const config = createConfig({
     networks: { mainnet: { chainId: 1, transport: http() } },
     contracts: { a: { network: "mainnet", abi: [event0] } },
@@ -481,7 +566,11 @@ test("buildConfig() database uses postgres if DATABASE_PRIVATE_URL env var prese
     "postgres://username@localhost:5432/better_database",
   );
 
-  const { databaseConfig } = await buildConfig({ config, options });
+  const { databaseConfig } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [],
+    options,
+  });
   expect(databaseConfig).toMatchObject({
     kind: "postgres",
     poolConfig: {
@@ -493,7 +582,7 @@ test("buildConfig() database uses postgres if DATABASE_PRIVATE_URL env var prese
   vi.unstubAllEnvs();
 });
 
-test("buildConfig() throws for postgres database with no connection string", async () => {
+test("buildConfigAndIndexingFunctions() throws for postgres database with no connection string", async () => {
   const config = createConfig({
     database: { kind: "postgres" },
     networks: { mainnet: { chainId: 1, transport: http() } },
@@ -504,14 +593,20 @@ test("buildConfig() throws for postgres database with no connection string", asy
   // biome-ignore lint/performance/noDelete: Required to test default behavior.
   delete process.env.DATABASE_URL;
 
-  await expect(() => buildConfig({ config, options })).rejects.toThrow(
+  await expect(() =>
+    buildConfigAndIndexingFunctions({
+      config,
+      rawIndexingFunctions: [],
+      options,
+    }),
+  ).rejects.toThrow(
     "Invalid database configuration: 'kind' is set to 'postgres' but no connection string was provided.",
   );
 
   process.env.DATABASE_URL = prev;
 });
 
-test("buildConfig() database with postgres uses RAILWAY_DEPLOYMENT_ID if defined", async () => {
+test("buildConfigAndIndexingFunctions() database with postgres uses RAILWAY_DEPLOYMENT_ID if defined", async () => {
   const config = createConfig({
     networks: { mainnet: { chainId: 1, transport: http() } },
     contracts: { a: { network: "mainnet", abi: [event0] } },
@@ -521,7 +616,11 @@ test("buildConfig() database with postgres uses RAILWAY_DEPLOYMENT_ID if defined
   vi.stubEnv("RAILWAY_DEPLOYMENT_ID", "b39cb9b7-7ef8-4dc4-8035-74344c11c4f2");
   vi.stubEnv("RAILWAY_SERVICE_NAME", "multichain-indexer");
 
-  const { databaseConfig } = await buildConfig({ config, options });
+  const { databaseConfig } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [],
+    options,
+  });
   expect(databaseConfig).toMatchObject({
     kind: "postgres",
     poolConfig: {
@@ -533,7 +632,7 @@ test("buildConfig() database with postgres uses RAILWAY_DEPLOYMENT_ID if defined
   vi.unstubAllEnvs();
 });
 
-test("buildConfig() database throws with RAILWAY_DEPLOYMENT_ID but no RAILWAY_SERVICE_NAME", async () => {
+test("buildConfigAndIndexingFunctions() database throws with RAILWAY_DEPLOYMENT_ID but no RAILWAY_SERVICE_NAME", async () => {
   const config = createConfig({
     networks: { mainnet: { chainId: 1, transport: http() } },
     contracts: { a: { network: "mainnet", abi: [event0] } },
@@ -542,14 +641,20 @@ test("buildConfig() database throws with RAILWAY_DEPLOYMENT_ID but no RAILWAY_SE
   vi.stubEnv("DATABASE_URL", "postgres://username@localhost:5432/database");
   vi.stubEnv("RAILWAY_DEPLOYMENT_ID", "b39cb9b7-7ef8-4dc4-8035-74344c11c4f2");
 
-  await expect(() => buildConfig({ config, options })).rejects.toThrow(
+  await expect(() =>
+    buildConfigAndIndexingFunctions({
+      config,
+      rawIndexingFunctions: [],
+      options,
+    }),
+  ).rejects.toThrow(
     "Invalid database configuration: RAILWAY_DEPLOYMENT_ID env var is defined, but RAILWAY_SERVICE_NAME env var is not.",
   );
 
   vi.unstubAllEnvs();
 });
 
-test("buildConfig() database with postgres uses publish schema defined in config", async () => {
+test("buildConfigAndIndexingFunctions() database with postgres uses publish schema defined in config", async () => {
   const config = createConfig({
     database: { kind: "postgres", publishSchema: "custom-publish" },
     networks: { mainnet: { chainId: 1, transport: http() } },
@@ -560,7 +665,11 @@ test("buildConfig() database with postgres uses publish schema defined in config
   vi.stubEnv("RAILWAY_DEPLOYMENT_ID", "b39cb9b7-7ef8-4dc4-8035-74344c11c4f2");
   vi.stubEnv("RAILWAY_SERVICE_NAME", "multichain-indexer");
 
-  const { databaseConfig } = await buildConfig({ config, options });
+  const { databaseConfig } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [],
+    options,
+  });
   expect(databaseConfig).toMatchObject({
     kind: "postgres",
     poolConfig: {
@@ -573,7 +682,7 @@ test("buildConfig() database with postgres uses publish schema defined in config
   vi.unstubAllEnvs();
 });
 
-test("buildConfig() database with postgres uses 'public' as publish schema if on Railway", async () => {
+test("buildConfigAndIndexingFunctions() database with postgres uses 'public' as publish schema if on Railway", async () => {
   const config = createConfig({
     networks: { mainnet: { chainId: 1, transport: http() } },
     contracts: { a: { network: "mainnet", abi: [event0] } },
@@ -583,7 +692,11 @@ test("buildConfig() database with postgres uses 'public' as publish schema if on
   vi.stubEnv("RAILWAY_DEPLOYMENT_ID", "b39cb9b7-7ef8-4dc4-8035-74344c11c4f2");
   vi.stubEnv("RAILWAY_SERVICE_NAME", "multichain-indexer");
 
-  const { databaseConfig } = await buildConfig({ config, options });
+  const { databaseConfig } = await buildConfigAndIndexingFunctions({
+    config,
+    rawIndexingFunctions: [],
+    options,
+  });
   expect(databaseConfig).toMatchObject({
     kind: "postgres",
     poolConfig: {
@@ -596,7 +709,7 @@ test("buildConfig() database with postgres uses 'public' as publish schema if on
   vi.unstubAllEnvs();
 });
 
-test("buildConfig() database with postgres throws if schema and publishSchema are the same", async () => {
+test("buildConfigAndIndexingFunctions() database with postgres throws if schema and publishSchema are the same", async () => {
   const config = createConfig({
     database: { kind: "postgres", schema: "public", publishSchema: "public" },
     networks: { mainnet: { chainId: 1, transport: http() } },
@@ -605,7 +718,13 @@ test("buildConfig() database with postgres throws if schema and publishSchema ar
 
   vi.stubEnv("DATABASE_URL", "postgres://username@localhost:5432/database");
 
-  await expect(() => buildConfig({ config, options })).rejects.toThrow(
+  await expect(() =>
+    buildConfigAndIndexingFunctions({
+      config,
+      rawIndexingFunctions: [],
+      options,
+    }),
+  ).rejects.toThrow(
     "Invalid database configuration: 'publishSchema' cannot be the same as 'schema' ('public').",
   );
 
diff --git a/packages/core/src/build/schema/schema.test.ts b/packages/core/src/build/schema.test.ts
similarity index 92%
rename from packages/core/src/build/schema/schema.test.ts
rename to packages/core/src/build/schema.test.ts
index 992f08e2..7afe579e 100644
--- a/packages/core/src/build/schema/schema.test.ts
+++ b/packages/core/src/build/schema.test.ts
@@ -9,7 +9,7 @@ test("safeBuildSchema() returns error for duplicate enum values", () => {
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Enum 'myEnum' contains duplicate value 'duplicate'.",
   );
@@ -22,7 +22,7 @@ test("safeBuildSchema() returns error for table without ID column", () => {
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Table 'myTable' does not have an 'id' column.",
   );
@@ -38,7 +38,7 @@ test("safeBuildSchema() returns error for ID column typed as an enum", () => {
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid type for ID column 'myTable.id'. Got 'enum', expected one of ['string', 'hex', 'bigint', 'int'].",
   );
@@ -57,7 +57,7 @@ test("safeBuildSchema() returns error for ID column typed as a 'one' relationshi
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid type for ID column 'myTable.id'. Got 'one', expected one of ['string', 'hex', 'bigint', 'int'].",
   );
@@ -76,7 +76,7 @@ test("safeBuildSchema() returns error for ID column typed as a 'many' relationsh
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid type for ID column 'myTable.id'. Got 'many', expected one of ['string', 'hex', 'bigint', 'int'].",
   );
@@ -94,7 +94,7 @@ test("safeBuildSchema() returns error for ID column with the references modifier
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid type for ID column 'myTable.id'. ID columns cannot use the '.references' modifier.",
   );
@@ -109,7 +109,7 @@ test("safeBuildSchema() returns error for invalid ID column type boolean", () =>
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid type for ID column 'myTable.id'. Got 'boolean', expected one of ['string', 'hex', 'bigint', 'int'].",
   );
@@ -124,7 +124,7 @@ test("safeBuildSchema() returns error for invalid ID column type float", () => {
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid type for ID column 'myTable.id'. Got 'float', expected one of ['string', 'hex', 'bigint', 'int'].",
   );
@@ -139,7 +139,7 @@ test("safeBuildSchema() returns error for ID column with optional modifier", ()
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid type for ID column 'myTable.id'. ID columns cannot be optional.",
   );
@@ -154,7 +154,7 @@ test("safeBuildSchema() returns error for ID column with list modifier", () => {
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Invalid type for ID column 'myTable.id'. ID columns cannot be a list.",
   );
@@ -169,7 +169,7 @@ test("safeBuildSchema() returns error for empty table or enum name", () => {
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Enum name can't be an empty string.",
   );
@@ -184,7 +184,7 @@ test("safeBuildSchema() returns error for table or enum name with invalid charac
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toBe(
     "Validation failed: Enum name 'invalid-name' contains an invalid character.",
   );
@@ -200,7 +200,7 @@ test("safeBuildSchema() returns error for 'one' relationship with non-existent r
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toContain("uses a column that does not exist");
 });
 
@@ -214,7 +214,7 @@ test("safeBuildSchema() returns error for 'one' relationship with reference to n
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toContain(
     "uses a column that is not foreign key column",
   );
@@ -230,7 +230,7 @@ test("safeBuildSchema() returns error for 'many' relationship with non-existent
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toContain("uses a table that does not exist");
 });
 
@@ -247,7 +247,7 @@ test("safeBuildSchema() returns error for 'many' relationship with non-existent
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toContain("uses a column that does not exist");
 });
 
@@ -265,7 +265,7 @@ test("safeBuildSchema() returns error for 'many' relationship with reference to
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toContain(
     "uses a column that is not foreign key column",
   );
@@ -281,7 +281,7 @@ test("safeBuildSchema() returns error for enum column referencing non-existent e
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toContain("doesn't reference a valid enum");
 });
 
@@ -295,7 +295,7 @@ test("safeBuildSchema() returns error for foreign key column referencing non-exi
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toContain(
     "does not reference a valid ID column",
   );
@@ -313,7 +313,7 @@ test("safeBuildSchema() returns error for foreign key column type mismatch", ()
   }));
 
   const result = safeBuildSchema({ schema });
-  expect(result.success).toBe(false);
+  expect(result.status).toBe("error");
   expect(result.error?.message).toContain(
     "type does not match the referenced table's ID column type",
   );
diff --git a/packages/core/src/indexing/events.test.ts b/packages/core/src/indexing/events.test.ts
index d80a564d..4b3599b0 100644
--- a/packages/core/src/indexing/events.test.ts
+++ b/packages/core/src/indexing/events.test.ts
@@ -4,20 +4,17 @@ import { getEventsErc20 } from "@/_test/utils.js";
 import { parseEther, zeroAddress } from "viem";
 import { beforeEach, expect, test } from "vitest";
 import { decodeEvents } from "./events.js";
-import type { IndexingService } from "./service.js";
+import type { Service } from "./service.js";
 
 beforeEach((context) => setupAnvil(context));
 
 test("decodeEvents()", async (context) => {
   const { common, sources } = context;
 
-  const sourceById = sources.reduce<IndexingService["sourceById"]>(
-    (acc, cur) => {
-      acc[cur.id] = cur;
-      return acc;
-    },
-    {},
-  );
+  const sourceById = sources.reduce<Service["sourceById"]>((acc, cur) => {
+    acc[cur.id] = cur;
+    return acc;
+  }, {});
 
   const rawEvents = await getEventsErc20(sources);
   const events = decodeEvents({ common, sourceById }, rawEvents);
@@ -38,13 +35,10 @@ test("decodeEvents()", async (context) => {
 test("decodeEvents() error", async (context) => {
   const { common, sources } = context;
 
-  const sourceById = sources.reduce<IndexingService["sourceById"]>(
-    (acc, cur) => {
-      acc[cur.id] = cur;
-      return acc;
-    },
-    {},
-  );
+  const sourceById = sources.reduce<Service["sourceById"]>((acc, cur) => {
+    acc[cur.id] = cur;
+    return acc;
+  }, {});
 
   const rawEvents = await getEventsErc20(sources);
 
diff --git a/packages/core/src/indexing/service.test.ts b/packages/core/src/indexing/service.test.ts
index 1a95af22..615a6181 100644
--- a/packages/core/src/indexing/service.test.ts
+++ b/packages/core/src/indexing/service.test.ts
@@ -15,7 +15,7 @@ import { beforeEach, expect, test, vi } from "vitest";
 import { decodeEvents } from "./events.js";
 import {
   type Context,
-  createIndexingService,
+  create,
   kill,
   processEvents,
   processSetupEvents,
@@ -44,7 +44,7 @@ test("createIndexing()", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {},
     common,
     sources,
@@ -69,7 +69,7 @@ test("processSetupEvents() empty", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {},
     common,
     sources,
@@ -104,7 +104,7 @@ test("processSetupEvents()", async (context) => {
     },
   };
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions,
     common,
     sources,
@@ -164,7 +164,7 @@ test("processEvent() log events", async (context) => {
     },
   };
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions,
     common,
     sources,
@@ -231,7 +231,7 @@ test("processEvents killed", async (context) => {
     },
   };
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions,
     common,
     sources,
@@ -269,7 +269,7 @@ test("processEvents eventCount", async (context) => {
     },
   };
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions,
     common,
     sources,
@@ -314,7 +314,7 @@ test("executeSetup() context.client", async (context) => {
     },
   };
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions,
     common,
     sources,
@@ -366,7 +366,7 @@ test("executeSetup() context.db", async (context) => {
     },
   };
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions,
     common,
     sources,
@@ -415,7 +415,7 @@ test("executeSetup() metrics", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {
       Erc20: {
         setup: vi.fn(),
@@ -458,7 +458,7 @@ test("executeSetup() retry", async (context) => {
 
   const revertSpy = vi.spyOn(indexingStore, "revert");
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions,
     common,
     sources,
@@ -508,7 +508,7 @@ test("executeSetup() error", async (context) => {
 
   const revertSpy = vi.spyOn(indexingStore, "revert");
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions,
     common,
     sources,
@@ -541,7 +541,7 @@ test("executeLog() context.client", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {
       Erc20: {
         Transfer: async ({ context }: { context: Context }) => {
@@ -588,7 +588,7 @@ test("executeLog() context.db", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {
       Erc20: {
         Transfer: async ({
@@ -644,7 +644,7 @@ test("executeLog() metrics", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {
       Erc20: {
         Transfer: vi.fn(),
@@ -687,7 +687,7 @@ test("executeLog() retry", async (context) => {
 
   const revertSpy = vi.spyOn(indexingStore, "revert");
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions,
     common,
     sources,
@@ -736,7 +736,7 @@ test("executeLog() error", async (context) => {
 
   const revertSpy = vi.spyOn(indexingStore, "revert");
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions,
     common,
     sources,
@@ -780,7 +780,7 @@ test("executeLog() error after killed", async (context) => {
     },
   };
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions,
     common,
     sources,
@@ -812,7 +812,7 @@ test("ponderActions getBalance()", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {},
     common,
     sources,
@@ -840,7 +840,7 @@ test("ponderActions getBytecode()", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {},
     common,
     sources,
@@ -868,7 +868,7 @@ test("ponderActions getStorageAt()", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {},
     common,
     sources,
@@ -898,7 +898,7 @@ test("ponderActions readContract()", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {},
     common,
     sources,
@@ -928,7 +928,7 @@ test("ponderActions readContract() blockNumber", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {},
     common,
     sources,
@@ -960,7 +960,7 @@ test.skip("ponderActions multicall()", async (context) => {
 
   const syncService = new SyncService({ common, syncStore, networks, sources });
 
-  const indexingService = createIndexingService({
+  const indexingService = create({
     indexingFunctions: {},
     common,
     sources,
diff --git a/packages/core/src/sync-store/store.test.ts b/packages/core/src/sync-store/store.test.ts
index bdfd552a..776186ce 100644
--- a/packages/core/src/sync-store/store.test.ts
+++ b/packages/core/src/sync-store/store.test.ts
@@ -57,7 +57,7 @@ test("insertLogFilterInterval inserts block, transactions, and logs", async (con
 
   await syncStore.insertLogFilterInterval({
     chainId: 1,
-    logFilter: { address: erc20.address },
+    logFilter: { address: erc20.address, topics: [] },
     ...rpcData.block1,
     interval: {
       startBlock: blockNumber - 3n,
@@ -87,7 +87,7 @@ test("insertLogFilterInterval updates sync db metrics", async (context) => {
 
   await syncStore.insertLogFilterInterval({
     chainId: 1,
-    logFilter: { address: erc20.address },
+    logFilter: { address: erc20.address, topics: [] },
     ...rpcData.block1,
     interval: {
       startBlock: blockNumber - 3n,
@@ -141,7 +141,7 @@ test("insertLogFilterInterval merges ranges on insertion", async (context) => {
 
   await syncStore.insertLogFilterInterval({
     chainId: 1,
-    logFilter: { address: erc20.address },
+    logFilter: { address: erc20.address, topics: [] },
     ...rpcData.block1,
     interval: {
       startBlock: hexToBigInt(rpcData.block1.block.number!),
@@ -151,7 +151,7 @@ test("insertLogFilterInterval merges ranges on insertion", async (context) => {
 
   await syncStore.insertLogFilterInterval({
     chainId: 1,
-    logFilter: { address: erc20.address },
+    logFilter: { address: erc20.address, topics: [] },
     ...rpcData.block3,
     interval: {
       startBlock: hexToBigInt(rpcData.block3.block.number!),
@@ -161,7 +161,7 @@ test("insertLogFilterInterval merges ranges on insertion", async (context) => {
 
   let logFilterRanges = await syncStore.getLogFilterIntervals({
     chainId: 1,
-    logFilter: { address: erc20.address },
+    logFilter: { address: erc20.address, topics: [] },
   });
 
   expect(logFilterRanges).toMatchObject([
@@ -177,7 +177,7 @@ test("insertLogFilterInterval merges ranges on insertion", async (context) => {
 
   await syncStore.insertLogFilterInterval({
     chainId: 1,
-    logFilter: { address: erc20.address },
+    logFilter: { address: erc20.address, topics: [] },
     ...rpcData.block2,
     interval: {
       startBlock: hexToBigInt(rpcData.block2.block.number!),
@@ -187,7 +187,7 @@ test("insertLogFilterInterval merges ranges on insertion", async (context) => {
 
   logFilterRanges = await syncStore.getLogFilterIntervals({
     chainId: 1,
-    logFilter: { address: erc20.address },
+    logFilter: { address: erc20.address, topics: [] },
   });
 
   expect(logFilterRanges).toMatchObject([
@@ -207,7 +207,7 @@ test("insertLogFilterInterval merges log intervals inserted concurrently", async
   await Promise.all([
     syncStore.insertLogFilterInterval({
       chainId: 1,
-      logFilter: { address: erc20.address },
+      logFilter: { address: erc20.address, topics: [] },
       ...rpcData.block1,
       interval: {
         startBlock: hexToBigInt(rpcData.block1.block.number!),
@@ -216,7 +216,7 @@ test("insertLogFilterInterval merges log intervals inserted concurrently", async
     }),
     syncStore.insertLogFilterInterval({
       chainId: 1,
-      logFilter: { address: erc20.address },
+      logFilter: { address: erc20.address, topics: [] },
       ...rpcData.block2,
       interval: {
         startBlock: hexToBigInt(rpcData.block2.block.number!),
@@ -225,7 +225,7 @@ test("insertLogFilterInterval merges log intervals inserted concurrently", async
     }),
     syncStore.insertLogFilterInterval({
       chainId: 1,
-      logFilter: { address: erc20.address },
+      logFilter: { address: erc20.address, topics: [] },
       ...rpcData.block3,
       interval: {
         startBlock: hexToBigInt(rpcData.block3.block.number!),
@@ -236,7 +236,7 @@ test("insertLogFilterInterval merges log intervals inserted concurrently", async
 
   const logFilterRanges = await syncStore.getLogFilterIntervals({
     chainId: 1,
-    logFilter: { address: erc20.address },
+    logFilter: { address: erc20.address, topics: [] },
   });
 
   expect(logFilterRanges).toMatchObject([
@@ -264,7 +264,7 @@ test("insertLogFilterInterval updates log checkpoints on conflict", async (conte
 
   await syncStore.insertLogFilterInterval({
     chainId: 1,
-    logFilter: { address: erc20.address },
+    logFilter: { address: erc20.address, topics: [] },
     ...rpcData.block2,
     interval: {
       startBlock: hexToBigInt(rpcData.block2.block.number!),
@@ -327,7 +327,7 @@ test("getLogFilterRanges handles complex log filter inclusivity rules", async (c
 
   await syncStore.insertLogFilterInterval({
     chainId: 1,
-    logFilter: {},
+    logFilter: { topics: [] },
     ...rpcData.block1,
     interval: { startBlock: 0n, endBlock: 100n },
   });
@@ -342,7 +342,7 @@ test("getLogFilterRanges handles complex log filter inclusivity rules", async (c
   // Broad criteria only includes broad intervals.
   let logFilterIntervals = await syncStore.getLogFilterIntervals({
     chainId: 1,
-    logFilter: {},
+    logFilter: { topics: [] },
   });
   expect(logFilterIntervals).toMatchObject([[0, 100]]);
 
@@ -428,6 +428,7 @@ test("getFactoryChildAddresses gets child addresses for topic location", async (
     eventSelector:
       "0x0000000000000000000000000000000000000000000factoryeventsignature",
     childAddressLocation: "topic1",
+    topics: [],
   } satisfies FactoryCriteria;
 
   await syncStore.insertFactoryChildAddressLogs({
@@ -496,6 +497,7 @@ test("getFactoryChildAddresses gets child addresses for offset location", async
     eventSelector:
       "0x0000000000000000000000000000000000000000000factoryeventsignature",
     childAddressLocation: "offset32",
+    topics: [],
   } satisfies FactoryCriteria;
 
   await syncStore.insertFactoryChildAddressLogs({
@@ -548,6 +550,7 @@ test("getFactoryChildAddresses respects upToBlockNumber argument", async (contex
     eventSelector:
       "0x0000000000000000000000000000000000000000000factoryeventsignature",
     childAddressLocation: "topic1",
+    topics: [],
   } satisfies FactoryCriteria;
 
   await syncStore.insertFactoryChildAddressLogs({
@@ -611,6 +614,7 @@ test("getFactoryChildAddresses paginates correctly", async (context) => {
     eventSelector:
       "0x0000000000000000000000000000000000000000000factoryeventsignature",
     childAddressLocation: "topic1",
+    topics: [],
   } satisfies FactoryCriteria;
 
   await syncStore.insertFactoryChildAddressLogs({
@@ -682,6 +686,7 @@ test("getFactoryChildAddresses does not yield empty list", async (context) => {
     eventSelector:
       "0x0000000000000000000000000000000000000000000factoryeventsignature",
     childAddressLocation: "topic1",
+    topics: [],
   } satisfies FactoryCriteria;
 
   const iterator = syncStore.getFactoryChildAddresses({
@@ -709,6 +714,7 @@ test("insertFactoryLogFilterInterval inserts block, transactions, and logs", asy
     eventSelector:
       "0x0000000000000000000000000000000000000000000factoryeventsignature",
     childAddressLocation: "topic1",
+    topics: [],
   } satisfies FactoryCriteria;
 
   await syncStore.insertFactoryLogFilterInterval({
@@ -742,6 +748,7 @@ test("insertFactoryLogFilterInterval inserts and merges child contract intervals
     eventSelector:
       "0x0000000000000000000000000000000000000000000factoryeventsignature",
     childAddressLocation: "topic1",
+    topics: [],
   } satisfies FactoryCriteria;
 
   await syncStore.insertFactoryLogFilterInterval({
@@ -794,6 +801,7 @@ test("getFactoryLogFilterIntervals handles topic filtering rules", async (contex
     eventSelector:
       "0x0000000000000000000000000000000000000000000factoryeventsignature",
     childAddressLocation: "topic1",
+    topics: [],
   } satisfies FactoryCriteria;
 
   await syncStore.insertFactoryLogFilterInterval({
@@ -857,18 +865,21 @@ test("insertRealtimeInterval inserts log filter intervals", async (context) => {
 
   const logFilterCriteria = {
     address: erc20.address,
+    topics: [],
   } satisfies LogFilterCriteria;
 
   const factoryCriteriaOne = {
     address: "0xparent",
     eventSelector: "0xa",
     childAddressLocation: "topic1",
+    topics: [],
   } satisfies FactoryCriteria;
 
   const factoryCriteriaTwo = {
     address: "0xparent",
     eventSelector: "0xa",
     childAddressLocation: "offset64",
+    topics: [],
   } satisfies FactoryCriteria;
 
   await syncStore.insertRealtimeInterval({
@@ -928,7 +939,7 @@ test("deleteRealtimeData deletes blocks, transactions and logs", async (context)
 
   await syncStore.insertLogFilterInterval({
     chainId: 1,
-    logFilter: { address: erc20.address },
+    logFilter: { address: erc20.address, topics: [] },
     ...rpcData.block1,
     interval: {
       startBlock: hexToBigInt(rpcData.block1.block.number!),
@@ -938,7 +949,7 @@ test("deleteRealtimeData deletes blocks, transactions and logs", async (context)
 
   await syncStore.insertLogFilterInterval({
     chainId: 1,
-    logFilter: { address: erc20.address },
+    logFilter: { address: erc20.address, topics: [] },
     ...rpcData.block2,
     interval: {
       startBlock: hexToBigInt(rpcData.block2.block.number!),
@@ -984,12 +995,14 @@ test("deleteRealtimeData updates interval data", async (context) => {
 
   const logFilterCriteria = {
     address: erc20.address,
+    topics: [],
   } satisfies LogFilterCriteria;
 
   const factoryCriteria = {
     address: "0xparent",
     eventSelector: "0xa",
     childAddressLocation: "topic1",
+    topics: [],
   } satisfies FactoryCriteria;
 
   await syncStore.insertLogFilterInterval({
@@ -1365,42 +1378,6 @@ test("getLogEvents filters on log filter with multiple topics", async (context)
   await cleanup();
 });
 
-test("getLogEvents no topics", async (context) => {
-  const { sources } = context;
-  const { syncStore, cleanup } = await setupDatabaseServices(context);
-  const rpcData = await getRawRPCData(sources);
-
-  await syncStore.insertRealtimeBlock({
-    chainId: 1,
-    ...rpcData.block1,
-  });
-  await syncStore.insertRealtimeBlock({
-    chainId: 1,
-    ...rpcData.block2,
-  });
-  await syncStore.insertRealtimeBlock({
-    chainId: 1,
-    ...rpcData.block3,
-  });
-
-  const ag = syncStore.getLogEvents({
-    sources: [
-      {
-        ...sources[0],
-        criteria: { ...sources[0].criteria, topics: undefined },
-      },
-    ],
-    fromCheckpoint: zeroCheckpoint,
-    toCheckpoint: maxCheckpoint,
-    limit: 100,
-  });
-  const events = await drainAsyncGenerator(ag);
-
-  expect(events).toHaveLength(0);
-
-  await cleanup();
-});
-
 test("getLogEvents filters on simple factory", async (context) => {
   const { sources } = context;
   const { syncStore, cleanup } = await setupDatabaseServices(context);
@@ -1796,29 +1773,3 @@ test("getLastEventCheckpoint empty", async (context) => {
 
   await cleanup();
 });
-
-test("getLastEventCheckpoint no topics", async (context) => {
-  const { sources } = context;
-  const { syncStore, cleanup } = await setupDatabaseServices(context);
-  const rpcData = await getRawRPCData(sources);
-
-  await syncStore.insertRealtimeBlock({
-    chainId: 1,
-    ...rpcData.block1,
-  });
-
-  const lastEventCheckpoint = await syncStore.getLastEventCheckpoint({
-    sources: [
-      {
-        ...sources[0],
-        criteria: { ...sources[0].criteria, topics: undefined },
-      },
-    ],
-    fromCheckpoint: zeroCheckpoint,
-    toCheckpoint: maxCheckpoint,
-  });
-
-  expect(lastEventCheckpoint).toBe(undefined);
-
-  await cleanup();
-});
diff --git a/packages/core/src/utils/extend.test.ts b/packages/core/src/utils/extend.test.ts
new file mode 100644
index 00000000..dbe935f3
--- /dev/null
+++ b/packages/core/src/utils/extend.test.ts
@@ -0,0 +1,42 @@
+import { assertType, expect, test } from "vitest";
+import { extend } from "./extend.js";
+
+test("extend", () => {
+  const create = (n: number) => ({ type: "created", n }) as const;
+
+  const extended = extend(create, {
+    add: (params: ReturnType<typeof create>, x: number) => params.n + x,
+  });
+
+  const created = extended(5);
+
+  expect(created.type).toBe("created");
+  expect(created.n).toBe(5);
+  expect(created.add(1)).toBe(6);
+
+  assertType<
+    (n: number) => { type: "created"; n: number; add: (x: number) => number }
+  >(extended);
+});
+
+test("extend with promise", async () => {
+  const create = (n: number) =>
+    Promise.resolve({ type: "created", n } as const);
+
+  const extended = extend(create, {
+    add: (params: Awaited<ReturnType<typeof create>>, x: number) =>
+      params.n + x,
+  });
+
+  const created = await extended(5);
+
+  expect(created.type).toBe("created");
+  expect(created.n).toBe(5);
+  expect(created.add(1)).toBe(6);
+
+  assertType<
+    (
+      n: number,
+    ) => Promise<{ type: "created"; n: number; add: (x: number) => number }>
+  >(extended);
+});
diff --git a/packages/core/src/utils/fragments.test.ts b/packages/core/src/utils/fragments.test.ts
index 3e7b96fd..af16258b 100644
--- a/packages/core/src/utils/fragments.test.ts
+++ b/packages/core/src/utils/fragments.test.ts
@@ -10,7 +10,10 @@ const llamaFactoryEventAbiItem = parseAbiItem(
 );
 
 test("buildLogFilterFragments generates 1 log filter fragment for null filter", () => {
-  const logFilterFragments = buildLogFilterFragments({ chainId: 1 });
+  const logFilterFragments = buildLogFilterFragments({
+    chainId: 1,
+    topics: [null, null, null, null],
+  });
 
   expect(logFilterFragments).toMatchObject([
     {
@@ -28,6 +31,7 @@ test("buildLogFilterFragments generates 1 log filter fragment for simple filter"
   const logFilterFragments = buildLogFilterFragments({
     chainId: 1,
     address: "0xa",
+    topics: [null, null, null, null],
   });
 
   expect(logFilterFragments).toMatchObject([
@@ -102,7 +106,13 @@ test("buildFactoryFragments builds id containing topic", () => {
     parameter: "deployer",
   });
 
-  expect(buildFactoryFragments({ chainId: 1, ...criteria })[0].id).toBe(
+  expect(
+    buildFactoryFragments({
+      chainId: 1,
+      topics: [null, null, null, null],
+      ...criteria,
+    })[0].id,
+  ).toBe(
     "1_0xa_0x00fef2d461a2fabbb523f9f42752c61336f03b17a602af52cc6c83cb8b110599_topic1_null_null_null_null",
   );
 });
@@ -114,7 +124,13 @@ test("buildFactoryFragments builds id containing offset", () => {
     parameter: "llamaPolicy",
   });
 
-  expect(buildFactoryFragments({ chainId: 115511, ...criteria })[0].id).toBe(
+  expect(
+    buildFactoryFragments({
+      chainId: 115511,
+      topics: [null, null, null, null],
+      ...criteria,
+    })[0].id,
+  ).toBe(
     "115511_0xa_0x00fef2d461a2fabbb523f9f42752c61336f03b17a602af52cc6c83cb8b110599_offset64_null_null_null_null",
   );
 });