diff --git a/packages/core/src/build/ponderApp.test-d.ts b/packages/core/src/build/ponderApp.test-d.ts new file mode 100644 index 000000000..a1d70db9e --- /dev/null +++ b/packages/core/src/build/ponderApp.test-d.ts @@ -0,0 +1,165 @@ +import { AbiEvent, ParseAbi } from "abitype"; +import { assertType, test } from "vitest"; + +import { ExtractAddress, ExtractAllAddresses, PonderApp } from "./ponderApp"; + +type OneAbi = ParseAbi< + ["event Event0(bytes32 indexed arg3)", "event Event1(bytes32 indexed)"] +>; +type TwoAbi = ParseAbi<["event Event(bytes32 indexed)", "event Event()"]>; + +test("ExtractAddress", () => { + type a = ExtractAddress<{ address: "0x2" }>; + // ^? + assertType("" as "0x2"); + + type b = ExtractAddress<{ + // ^? + factory: { address: "0x2"; event: AbiEvent; parameter: string }; + }>; + assertType("" as never); +}); + +test("ExtractAllAddress", () => { + type a = ExtractAllAddresses< + // ^? + [ + { name: "optimism"; address: "0x2" }, + { + name: "optimism"; + factory: { address: "0x2"; event: AbiEvent; parameter: string }; + } + ] + >[never]; + // ^? + assertType("" as "0x2"); +}); + +test("PonderApp non intersecting event names", () => { + type p = PonderApp<{ + // ^? + networks: any; + contracts: readonly [{ name: "One"; network: any; abi: OneAbi }]; + }>; + + type name = Parameters[0]; + // ^? + + assertType("" as "One:Event0" | "One:Event1"); +}); + +test("PonderApp intersecting event names", () => { + type p = PonderApp<{ + // ^? + networks: any; + contracts: readonly [{ name: "Two"; network: any; abi: TwoAbi }]; + }>; + + type name = Parameters[0]; + // ^? + + assertType("" as "Two:Event(bytes32 indexed)" | "Two:Event()"); +}); + +test("PonderApp multiple contracts", () => { + type p = PonderApp<{ + // ^? + networks: any; + contracts: readonly [ + { name: "One"; network: any; abi: OneAbi }, + { name: "Two"; network: any; abi: TwoAbi } + ]; + }>; + + // Events should only correspond to their contract + type name = Exclude< + // ^? + Parameters[0], + "One:Event0" | "One:Event1" | "Two:Event(bytes32 indexed)" | "Two:Event()" + >; + + assertType("" as name); +}); + +test("PonderApp event type"), + () => { + type p = PonderApp<{ + // ^? + networks: any; + contracts: readonly [{ name: "One"; network: any; abi: OneAbi }]; + }>; + + type name = Parameters[1]>[0]["event"]["name"]; + // ^? + + assertType("" as "Event0" | "Event1"); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (({}) as p).on("One:Event1", ({ event }) => {}); + // ^? + }; + +test("PonderApp context network type", () => { + type p = PonderApp<{ + // ^? + networks: any; + contracts: readonly [ + { + name: "One"; + network: [{ name: "mainnet" }, { name: "optimism" }]; + abi: OneAbi; + } + ]; + }>; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (({}) as p).on("One:Event1", ({ context }) => {}); + // ^? +}); + +test("PonderApp context client type", () => { + type p = PonderApp<{ + // ^? + networks: any; + contracts: readonly [ + { + name: "One"; + network: [{ name: "mainnet" }, { name: "optimism" }]; + abi: OneAbi; + } + ]; + }>; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (({}) as p).on("One:Event1", ({ context: { client } }) => {}); + // ^? +}); + +test("PonderApp context contracts type", () => { + type p = PonderApp<{ + // ^? + networks: any; + contracts: readonly [ + { + name: "One"; + network: [{ name: "mainnet"; address: "0x1" }, { name: "optimism" }]; + abi: OneAbi; + address: "0x2"; + startBlock: 1; + endBlock: 2; + } + ]; + }>; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (({}) as p).on("One:Event1", ({ context: { contracts } }) => {}); + // ^? +}); diff --git a/packages/core/src/build/ponderApp.ts b/packages/core/src/build/ponderApp.ts new file mode 100644 index 000000000..7b1ae09f4 --- /dev/null +++ b/packages/core/src/build/ponderApp.ts @@ -0,0 +1,171 @@ +import { AbiEvent } from "abitype"; +import { Abi, GetEventArgs } from "viem"; + +import { + Config, + ContractFilter, + ContractRequired, + FilterAbiEvents, + RecoverAbiEvent, + SafeEventNames, +} from "@/config/config"; +import { ReadOnlyClient } from "@/indexing/ponderActions"; +import { Block } from "@/types/block"; +import { Log } from "@/types/log"; +import { Transaction } from "@/types/transaction"; + +/** "{ContractName}:{EventName}". */ +export type Name = + `${TContract["name"]}:${SafeEventNames< + FilterAbiEvents + >[number]}`; + +/** All possible names for a list of contracts. */ +export type Names = + TContracts extends readonly [ + infer First extends Config["contracts"][number], + ...infer Rest extends Config["contracts"] + ] + ? [Name, ...Names] + : []; + +/** Recover the `contract` element at the index where {@link TName} is equal to {@link TContracts}[index]. */ +export type RecoverContract< + TContracts extends Config["contracts"], + TName extends string +> = TContracts extends readonly [ + infer First extends Config["contracts"][number], + ...infer Rest extends Config["contracts"] +] + ? First["name"] extends TName + ? First + : RecoverContract + : never; + +type ContractNetworkOverrides = ContractRequired< + string, + readonly AbiEvent[], + string +>["network"]; + +/** Extract the address type from a Contract. */ +export type ExtractAddress< + TContract extends + | ContractNetworkOverrides + | ContractFilter +> = Extract["address"]; + +/** Extract the startBlock type from a Contract. */ +export type ExtractStartBlock< + TContract extends + | ContractNetworkOverrides + | ContractFilter +> = Extract["startBlock"]; + +/** Extract the endBlock type from a Contract. */ +export type ExtractEndBlock< + TContract extends + | ContractNetworkOverrides + | ContractFilter +> = Extract["endBlock"]; + +/** Extract all address from a list of Contracts. */ +export type ExtractAllAddresses = + TContracts extends readonly [ + infer First extends ContractNetworkOverrides[number], + ...infer Rest extends ContractNetworkOverrides + ] + ? readonly [ExtractAddress, ...ExtractAllAddresses] + : []; + +/** Extract all startBlocks from a list of Contracts. */ +export type ExtractAllStartBlocks = + TContracts extends readonly [ + infer First extends ContractNetworkOverrides[number], + ...infer Rest extends ContractNetworkOverrides + ] + ? readonly [ExtractStartBlock, ...ExtractAllStartBlocks] + : []; + +/** Extract all endBlocks from a list of Contracts. */ +export type ExtractAllEndBlocks = + TContracts extends readonly [ + infer First extends ContractNetworkOverrides[number], + ...infer Rest extends ContractNetworkOverrides + ] + ? readonly [ExtractEndBlock, ...ExtractAllEndBlocks] + : []; + +/** Transform Contracts into the appropriate type for PonderApp. */ +type AppContracts = + TContracts extends readonly [ + infer First extends Config["contracts"][number], + ...infer Rest extends Config["contracts"] + ] + ? Record< + First["name"], + { + abi: First["abi"]; + address: + | ExtractAddress + | ExtractAllAddresses[number]; + startBlock: + | ExtractStartBlock + | ExtractAllStartBlocks[number]; + endBlock: + | ExtractEndBlock + | ExtractAllEndBlocks[number]; + } + > & + AppContracts + : {}; + +export type PonderApp = { + on: [number]>( + name: TName, + indexingFunction: ({ + event, + context, + }: { + event: { + name: TName extends `${string}:${infer EventName}` ? EventName : string; + params: GetEventArgs< + Abi, + string, + { + EnableUnion: false; + IndexedOnly: false; + Required: true; + }, + TName extends `${infer ContractName}:${infer EventName}` + ? RecoverAbiEvent< + RecoverContract extends { + abi: infer _abi extends Abi; + } + ? FilterAbiEvents<_abi> + : never, + EventName + > + : never + >; + log: Log; + block: Block; + transaction: Transaction; + }; + context: { + contracts: AppContracts; + network: { + chainId: number; + name: TName extends `${infer ContractName}:${string}` + ? RecoverContract< + TConfig["contracts"], + ContractName + >["network"][number]["name"] + : never; + }; + client: ReadOnlyClient; + models: any; // use ts-schema to infer types + }; + }) => Promise | void + ) => void; +}; diff --git a/packages/core/src/build/service.ts b/packages/core/src/build/service.ts index 2e1f960f9..57ca1e934 100644 --- a/packages/core/src/build/service.ts +++ b/packages/core/src/build/service.ts @@ -10,7 +10,7 @@ import type { ViteNodeRunner } from "vite-node/client"; // @ts-ignore import type { ViteNodeServer } from "vite-node/server"; -import type { ResolvedConfig } from "@/config/config"; +import type { Config } from "@/config/config"; import { UserError } from "@/errors/user"; import type { Common } from "@/Ponder"; import { buildSchema } from "@/schema/schema"; @@ -22,7 +22,7 @@ import { readGraphqlSchema } from "./schema"; import { parseViteNodeError, ViteNodeError } from "./stacktrace"; type BuildServiceEvents = { - newConfig: { config: ResolvedConfig }; + newConfig: { config: Config }; newIndexingFunctions: { indexingFunctions: RawIndexingFunctions }; newSchema: { schema: Schema; graphqlSchema: GraphQLSchema }; }; @@ -196,7 +196,7 @@ export class BuildService extends Emittery { const rawConfig = result.exports.config; const resolvedConfig = ( typeof rawConfig === "function" ? await rawConfig() : await rawConfig - ) as ResolvedConfig; + ) as Config; // TODO: Validate config lol diff --git a/packages/core/src/config/config.test-d.ts b/packages/core/src/config/config.test-d.ts index c2a5b11ad..b15528585 100644 --- a/packages/core/src/config/config.test-d.ts +++ b/packages/core/src/config/config.test-d.ts @@ -2,10 +2,10 @@ import { http } from "viem"; import { assertType, test } from "vitest"; import { + Config, createConfig, - FilterEvents, + FilterAbiEvents, RecoverAbiEvent, - ResolvedConfig, SafeEventNames, } from "./config"; @@ -84,7 +84,7 @@ export const abiWithSameEvent = [ ] as const; test("filter events", () => { - type t = FilterEvents; + type t = FilterAbiEvents; // ^? assertType([ @@ -97,15 +97,13 @@ test("filter events", () => { test("safe event names", () => { type a = SafeEventNames< // ^? - FilterEvents, - FilterEvents + FilterAbiEvents >; assertType(["Approve", "Transfer"] as const); type b = SafeEventNames< // ^? - FilterEvents, - FilterEvents + FilterAbiEvents >; assertType([ "Approve(address indexed from, address indexed to, uint256 amount)", @@ -115,7 +113,7 @@ test("safe event names", () => { }); test("ResolvedConfig default values", () => { - type a = NonNullable[number]["filter"]; + type a = NonNullable[number]["filter"]; // ^? assertType({} as { event: string[] } | { event: string } | undefined); }); @@ -123,11 +121,7 @@ test("ResolvedConfig default values", () => { test("RecoverAbiEvent", () => { type a = RecoverAbiEvent< // ^? - FilterEvents, - SafeEventNames< - FilterEvents, - FilterEvents - >, + FilterAbiEvents, "Approve" >; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c73e5ba9c..7c918955f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1,20 +1,17 @@ import type { Abi, AbiEvent, FormatAbiItem } from "abitype"; import type { GetEventArgs, Transport } from "viem"; -/** - * Keep only AbiEvents from an Abi - */ -export type FilterEvents = T extends readonly [ +export type FilterAbiEvents = T extends readonly [ infer First, ...infer Rest extends Abi ] ? First extends AbiEvent - ? readonly [First, ...FilterEvents] - : FilterEvents + ? readonly [First, ...FilterAbiEvents] + : FilterAbiEvents : []; /** - * Remove TElement from TArr + * Remove TElement from TArr. */ type FilterElement< TElement, @@ -26,17 +23,17 @@ type FilterElement< : []; /** - * Return an array of safe event names that handle multiple events with the same name + * Return an array of safe event names that handle event overridding. */ export type SafeEventNames< TAbi extends readonly AbiEvent[], - TArr extends readonly AbiEvent[] + TArr extends readonly AbiEvent[] = TAbi > = TAbi extends readonly [ infer First extends AbiEvent, ...infer Rest extends readonly AbiEvent[] ] ? First["name"] extends FilterElement[number]["name"] - ? // Name collisions exist, format long name + ? // Overriding occurs, use full name FormatAbiItem extends `event ${infer LongEvent extends string}` ? readonly [LongEvent, ...SafeEventNames] : never @@ -44,10 +41,13 @@ export type SafeEventNames< readonly [First["name"], ...SafeEventNames] : []; +/** + * Recover the element from {@link TAbi} at the index where {@link TSafeName} is equal to {@link TSafeNames}[index]. + */ export type RecoverAbiEvent< TAbi extends readonly AbiEvent[], - TSafeNames extends readonly string[], - TSafeName extends string + TSafeName extends string, + TSafeNames extends readonly string[] = SafeEventNames > = TAbi extends readonly [ infer FirstAbi, ...infer RestAbi extends readonly AbiEvent[] @@ -58,33 +58,36 @@ export type RecoverAbiEvent< ] ? FirstName extends TSafeName ? FirstAbi - : RecoverAbiEvent - : [] - : []; + : RecoverAbiEvent + : never + : never; -type ContractRequired< +/** Required fields for a contract. */ +export type ContractRequired< TNetworkNames extends string, TAbi extends readonly AbiEvent[], TEventName extends string > = { /** Contract name. Must be unique across `contracts` and `filters`. */ name: string; + /** Contract application byte interface. */ + abi: Abi; /** * Network that this contract is deployed to. Must match a network name in `networks`. - * Any filter information overrides the values in the higher level "contracts" property. Factories cannot override an address and vice versa. + * Any filter information overrides the values in the higher level "contracts" property. + * Factories cannot override an address and vice versa. */ network: readonly ({ name: TNetworkNames } & Partial< ContractFilter >)[]; - abi: Abi; }; -type ContractFilter< +/** Fields for a contract used to filter down which events indexed. */ +export type ContractFilter< TAbi extends readonly AbiEvent[], TEventName extends string > = ( | { - /** Contract address. */ address?: `0x${string}` | readonly `0x${string}`[]; } | { @@ -112,17 +115,11 @@ type ContractFilter< | { event: string; args?: GetEventArgs } : | { - event: readonly SafeEventNames< - FilterEvents, - FilterEvents - >[number][]; + event: readonly SafeEventNames>[number][]; args?: never; } | { - event: SafeEventNames< - FilterEvents, - FilterEvents - >[number]; + event: SafeEventNames>[number]; args?: GetEventArgs< Abi, string, @@ -133,8 +130,8 @@ type ContractFilter< }, RecoverAbiEvent< TAbi, - SafeEventNames, FilterEvents>, - TEventName + TEventName, + SafeEventNames> > extends infer _abiEvent extends AbiEvent ? _abiEvent : AbiEvent @@ -142,6 +139,14 @@ type ContractFilter< }; }; +/** Contract in Ponder config. */ +export type Contract< + TNetworkNames extends string, + TAbi extends readonly AbiEvent[], + TEventName extends string +> = ContractRequired & + ContractFilter; + type Database = | { kind: "sqlite"; @@ -154,7 +159,8 @@ type Database = connectionString?: string; }; -type Network = { +/** Network in Ponder config. */ +export type Network = { /** Network name. Must be unique across all networks. */ name: string; /** Chain ID of the network. */ @@ -180,25 +186,18 @@ type Network = { maxRpcRequestConcurrency?: number; }; -type Contract< - TNetworkNames extends string, - TAbi extends readonly AbiEvent[], - TEventName extends string -> = ContractRequired & - ContractFilter; - type Option = { /** Maximum number of seconds to wait for event processing to be complete before responding as healthy. If event processing exceeds this duration, the API may serve incomplete data. Default: `240` (4 minutes). */ maxHealthcheckDuration?: number; }; -export type ResolvedConfig = { +export type Config = { /** Database to use for storing blockchain & entity data. Default: `"postgres"` if `DATABASE_URL` env var is present, otherwise `"sqlite"`. */ database?: Database; /** List of blockchain networks. */ networks: readonly Network[]; /** List of contracts to sync & index events from. Contracts defined here will be present in `context.contracts`. */ - contracts?: readonly Contract[]; + contracts: readonly Contract[]; /** Configuration for Ponder internals. */ options?: Option; }; @@ -213,7 +212,7 @@ export const createConfig = < contracts: { [key in keyof TConfig["contracts"] & number]: Contract< TConfig["networks"][number]["name"], - FilterEvents, + FilterAbiEvents, TConfig["contracts"][key]["filter"] extends { event: infer _event extends string; } diff --git a/packages/core/src/config/database.ts b/packages/core/src/config/database.ts index 85e79024a..b7c3e20e1 100644 --- a/packages/core/src/config/database.ts +++ b/packages/core/src/config/database.ts @@ -2,7 +2,7 @@ import Sqlite from "better-sqlite3"; import path from "node:path"; import pg, { Client, DatabaseError, Pool } from "pg"; -import type { ResolvedConfig } from "@/config/config"; +import type { Config } from "@/config/config"; import { PostgresError } from "@/errors/postgres"; import { SqliteError } from "@/errors/sqlite"; import type { Common } from "@/Ponder"; @@ -91,9 +91,9 @@ export const buildDatabase = ({ config, }: { common: Common; - config: ResolvedConfig; + config: Config; }): Database => { - let resolvedDatabaseConfig: NonNullable; + let resolvedDatabaseConfig: NonNullable; const defaultSqliteFilename = path.join(common.options.ponderDir, "cache.db"); diff --git a/packages/core/src/config/networks.ts b/packages/core/src/config/networks.ts index 8ecd9bd61..c3552e8f2 100644 --- a/packages/core/src/config/networks.ts +++ b/packages/core/src/config/networks.ts @@ -1,7 +1,7 @@ import { type Client, type PublicClient, createPublicClient } from "viem"; import * as chains from "viem/chains"; -import type { ResolvedConfig } from "@/config/config"; +import type { Config } from "@/config/config"; import type { Common } from "@/Ponder"; export type Network = { @@ -18,7 +18,7 @@ export function buildNetwork({ network, common, }: { - network: ResolvedConfig["networks"][0]; + network: Config["networks"][0]; common: Common; }) { const { name, chainId, transport } = network; diff --git a/packages/core/src/config/sources.test.ts b/packages/core/src/config/sources.test.ts index 161449038..2ca45008d 100644 --- a/packages/core/src/config/sources.test.ts +++ b/packages/core/src/config/sources.test.ts @@ -1,7 +1,7 @@ import { http } from "viem"; import { expect, test } from "vitest"; -import { createConfig, ResolvedConfig } from "./config"; +import { Config, createConfig } from "./config"; import { abiSimple, abiWithSameEvent } from "./config.test-d"; import { buildSources } from "./sources"; @@ -27,7 +27,7 @@ test("buildSources() builds topics for multiple events", () => { maxBlockRange: 10, }, ], - }) as unknown as ResolvedConfig, + }) as unknown as Config, }); expect(sources[0].criteria.topics).toMatchObject([ @@ -65,7 +65,7 @@ test("buildSources() for duplicate event", () => { maxBlockRange: 10, }, ], - }) as unknown as ResolvedConfig, + }) as unknown as Config, }); expect(sources[0].criteria.topics).toMatchObject([ @@ -103,7 +103,7 @@ test("buildSources() builds topics for event with args", () => { maxBlockRange: 10, }, ], - }) as unknown as ResolvedConfig, + }) as unknown as Config, }); expect(sources[0].criteria.topics).toMatchObject([ @@ -140,7 +140,7 @@ test("buildSources() overrides default values with network values", () => { maxBlockRange: 10, }, ], - }) as unknown as ResolvedConfig, + }) as unknown as Config, }); expect(sources[0].criteria.address).toBe( diff --git a/packages/core/src/config/sources.ts b/packages/core/src/config/sources.ts index 4afad74e4..092136113 100644 --- a/packages/core/src/config/sources.ts +++ b/packages/core/src/config/sources.ts @@ -11,7 +11,7 @@ import { import { toLowerCase } from "@/utils/lowercase"; import { AbiEvents, getEvents } from "./abi"; -import { ResolvedConfig } from "./config"; +import { Config } from "./config"; import { buildFactoryCriteria } from "./factories"; /** @@ -70,11 +70,7 @@ export const sourceIsLogFilter = (source: Source): source is LogFilter => export const sourceIsFactory = (source: Source): source is Factory => source.type === "factory"; -export const buildSources = ({ - config, -}: { - config: ResolvedConfig; -}): Source[] => { +export const buildSources = ({ config }: { config: Config }): Source[] => { const contracts = config.contracts ?? []; return contracts @@ -155,9 +151,7 @@ export const buildSources = ({ const buildTopics = ( abi: Abi, - filter: NonNullable< - NonNullable[number]["filter"] - > + filter: NonNullable[number]["filter"]> ): Topics => { if (Array.isArray(filter.event)) { // List of event signatures diff --git a/packages/core/src/indexing/ponderActions.ts b/packages/core/src/indexing/ponderActions.ts index 722e9d14c..b72adb751 100644 --- a/packages/core/src/indexing/ponderActions.ts +++ b/packages/core/src/indexing/ponderActions.ts @@ -11,6 +11,7 @@ import { GetStorageAtReturnType, MulticallParameters, MulticallReturnType, + PublicRpcSchema, ReadContractParameters, ReadContractReturnType, Transport, @@ -23,11 +24,50 @@ import { readContract as viemReadContract, } from "viem/actions"; +import { Prettify } from "@/types/utils"; + +export type PonderActions = { + getBalance: ( + args: Omit + ) => Promise; + getBytecode: ( + args: Omit + ) => Promise; + getStorageAt: ( + args: Omit + ) => Promise; + multicall: < + TContracts extends ContractFunctionConfig[], + TAllowFailure extends boolean = true + >( + args: Omit< + MulticallParameters, + "blockTag" | "blockNumber" + > + ) => Promise>; + readContract: < + TAbi extends Abi | readonly unknown[], + TFunctionName extends string + >( + args: Omit< + ReadContractParameters, + "blockTag" | "blockNumber" + > + ) => Promise>; +}; + +export type ReadOnlyClient< + transport extends Transport = Transport, + chain extends Chain | undefined = Chain | undefined +> = Prettify< + Client +>; + export const ponderActions = (getCurrentBlockNumber: () => bigint) => ( client: Client - ) => ({ + ): PonderActions => ({ getBalance: ( args: Omit ): Promise =>