Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cross-chain-indexing): enable multiple concurrent plugins #24

Merged
merged 42 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
618e8d3
feat(cross-subname-indexing): allow activating multiple plugins together
tk-o Jan 12, 2025
7d8f978
feat(cross-subname-indexing): demonstrate retrieving cross-subname index
tk-o Jan 12, 2025
76754e4
refactor(handlers): stremline logic
tk-o Jan 12, 2025
0ffa78c
refactor(ponder.config): simplify structure
tk-o Jan 12, 2025
b118dfb
fix: apply pr feedback
tk-o Jan 13, 2025
c71479f
fix: typos
tk-o Jan 13, 2025
ceb03cc
fix: apply pr feedback
tk-o Jan 13, 2025
fa57a34
feat(plugins): introduce ACTIVE_PLUGINS validation
tk-o Jan 13, 2025
3b2c07b
Merge remote-tracking branch 'origin/main' into feat/cross-chain-inde…
tk-o Jan 13, 2025
748dfa4
fix(schema): use proper schema import
tk-o Jan 13, 2025
5365dd6
feat(utils): import deep merge npm lib
tk-o Jan 13, 2025
43b1dc7
fix(codestyle): apply formatting
tk-o Jan 13, 2025
ac141f5
docs: apply text edits
tk-o Jan 14, 2025
46ab32d
feat(database-entites): ensure domain entity exists
tk-o Jan 14, 2025
273cd63
fix(upserts): make `ensureDomainExists` to always return a DB entity`…
tk-o Jan 14, 2025
5e1b695
docs: update description of `ensureDomainExists`
tk-o Jan 14, 2025
f63ba6a
fix(defaults): make all available plugins active
tk-o Jan 14, 2025
8d2bb1b
feat(deps): update ponder
tk-o Jan 14, 2025
c478ad5
docs(handlers): update Registry descriptions
tk-o Jan 14, 2025
6e19c7d
fix(handlers-base.eth): remove dead code
tk-o Jan 14, 2025
38e9c15
feat(rpc): define env vars with rate limits
tk-o Jan 14, 2025
fd2d567
docs: update descriptions as suggested by PR feedback
tk-o Jan 14, 2025
6da8beb
docs: update .env.local.example
tk-o Jan 14, 2025
b6e75af
docs(helpers): define default values as named consts
tk-o Jan 14, 2025
7334d9d
refactor(db-helpers): rename `upserts into db-helpers
tk-o Jan 14, 2025
dc94a32
refactor(db-helpers): drop `ensureDomainExists`
tk-o Jan 14, 2025
eb7aa6b
chore: trigger rebuild with fresh deployment id
tk-o Jan 15, 2025
e18f392
fix(readme): update title
tk-o Jan 16, 2025
7a7a70b
refactor(helpers): update `rpcRequestRateLimit` config facotry
tk-o Jan 17, 2025
a42be85
docs: typos
tk-o Jan 17, 2025
03529a3
docs(helpers): move the docs around
tk-o Jan 17, 2025
19394cb
refactor(ponder.config): renames
tk-o Jan 17, 2025
b0575f5
docs(handlers): update registry setup description
tk-o Jan 17, 2025
141e57c
docs: replacements
tk-o Jan 17, 2025
77cc78b
feat(helpers): introduce `rpcEndpointUrl` factory
tk-o Jan 17, 2025
f35a828
fix(codestyle): apply auto-formatting
tk-o Jan 17, 2025
7d060be
docs: update descriptions
tk-o Jan 17, 2025
b3f5146
fix(helpers): update env var parsing logic
tk-o Jan 17, 2025
4b586eb
docs: being more specific and correct
tk-o Jan 17, 2025
a7099a4
fix(plugin-helpers): rename types
tk-o Jan 17, 2025
b7704f4
fix: apply pr feedback
tk-o Jan 17, 2025
ba17d36
docs: update env example
tk-o Jan 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database

# Plugin configuration
# Identify which indexer plugins to activate (see `src/plugins` for available plugins)
# This is a comma separated list of one or more available plugin names.
# This is a comma separated list of one or more available plugin names (case-sensitive).
ACTIVE_PLUGINS=eth,base.eth,linea.eth
20 changes: 10 additions & 10 deletions ponder.config.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
import { deepMergeRecursive } from "./src/lib/helpers";
import { type IntersectionOf, getActivePlugins } from "./src/lib/plugin-helpers";
import { type MergedTypes, getActivePlugins } from "./src/lib/plugin-helpers";
import * as baseEthPlugin from "./src/plugins/base.eth/ponder.config";
import * as ethPlugin from "./src/plugins/eth/ponder.config";
import * as lineaEthPlugin from "./src/plugins/linea.eth/ponder.config";

// list of all available plugins
// any of them can be activated in the runtime
const plugins = [baseEthPlugin, ethPlugin, lineaEthPlugin] as const;
// any available plugin can be activated at runtime
const availablePlugins = [baseEthPlugin, ethPlugin, lineaEthPlugin] as const;

// intersection of all available plugin configs to support correct typechecking
// merge of all available plugin configs to support correct typechecking
// of the indexing handlers
type AllPluginConfigs = IntersectionOf<(typeof plugins)[number]["config"]>;
type AllPluginConfigs = MergedTypes<(typeof availablePlugins)[number]["config"]>;

// Activates the indexing handlers of activated plugins and
// returns the intersection of their combined config.
function getActivePluginConfigs(): AllPluginConfigs {
const activePlugins = getActivePlugins(plugins);
function activatePluginsAndGetConfig(): AllPluginConfigs {
const activePlugins = getActivePlugins(availablePlugins);

// load indexing handlers from the active plugins into the runtime
activePlugins.forEach((plugin) => plugin.activate());

const config = activePlugins
const activePluginsConfig = activePlugins
.map((plugin) => plugin.config)
.reduce((acc, val) => deepMergeRecursive(acc, val), {} as AllPluginConfigs);

return config as AllPluginConfigs;
return activePluginsConfig as AllPluginConfigs;
}

// The type of the default export is the intersection of all active plugin configs
// configs so that each plugin can be correctly typechecked
export default getActivePluginConfigs();
export default activatePluginsAndGetConfig();
1 change: 0 additions & 1 deletion src/handlers/Registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ export async function setupRootNode({ context }: { context: Context }) {
isMigrated: false,
})
// only insert the domain entity into the database if it doesn't already exist
// only if it doesn't already exist
.onConflictDoNothing();
}

Expand Down
18 changes: 8 additions & 10 deletions src/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const DEFAULT_RPC_RATE_LIMIT = 50;
* @param chainId the chain ID to get the rate limit for
* @returns the rate limit in requests per second (rps)
*/
export const rpcRequestRateLimit = (chainId: number): number => {
export const rpcMaxRequestsPerSecond = (chainId: number): number => {
/**
* Reads the RPC request rate limit for a given chain ID from the environment
* variable: RPC_REQUEST_RATE_LIMIT_{chainId}.
Expand All @@ -79,18 +79,16 @@ export const rpcRequestRateLimit = (chainId: number): number => {
// otherwise
try {
// parse the rate limit value from the environment variable
const rpcRequestRateLimit = parseInt(envVarValue, 10);
if (Number.isNaN(rpcRequestRateLimit)) {
throw new Error(`Could not parse rate limit value '${rpcRequestRateLimit}'`);
const parsedEnvVarValue = parseInt(envVarValue, 10);

if (Number.isNaN(parsedEnvVarValue) || parsedEnvVarValue <= 0) {
throw new Error(`Rate limit value must be an integer greater than 0.`);
}

return rpcRequestRateLimit;
} catch (e) {
console.log(e);

return parsedEnvVarValue;
} catch (e: any) {
throw new Error(
`Invalid '${envVarName}' environment variable value: '${envVarValue}'. Please provide a valid RPC RATE LIMIT integer.`,
`Invalid '${envVarName}' environment variable value: '${envVarValue}'. ${e.message}`,
);
}
};
Expand Down
38 changes: 23 additions & 15 deletions src/lib/plugin-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,28 +79,29 @@ type PluginNamespacePath<T extends PluginNamespacePath = "/"> =
| `/${string}`
| `/${string}${T}`;

/** @var comma separated list of the requested active plugin names (see `src/plugins` for available plugins) */
export const ACTIVE_PLUGINS = process.env.ACTIVE_PLUGINS;

/**
* Returns the list of 1 or more active plugins based on the `ACTIVE_PLUGINS` environment variable.
* Returns a list of 1 or more distinct active plugins based on the `ACTIVE_PLUGINS` environment variable.
*
* The `ACTIVE_PLUGINS` environment variable is a comma-separated list of plugin
* names. The function returns the plugins that are included in the list.
*
* @param plugins is a list of available plugins
* @param availablePlugins is a list of available plugins
* @returns the active plugins
*/
export function getActivePlugins<T extends { ownedName: string }>(plugins: readonly T[]): T[] {
const pluginsToActivateByOwnedName = ACTIVE_PLUGINS ? ACTIVE_PLUGINS.split(",") : [];
export function getActivePlugins<T extends { ownedName: string }>(
availablePlugins: readonly T[],
): T[] {
/** @var comma separated list of the requested plugin names (see `src/plugins` for available plugins) */
const requestedPluginsEnvVar = process.env.ACTIVE_PLUGINS;
const requestedPlugins = requestedPluginsEnvVar ? requestedPluginsEnvVar.split(",") : [];

if (!pluginsToActivateByOwnedName.length) {
throw new Error("No active plugins found. Please set the ACTIVE_PLUGINS environment variable.");
if (!requestedPlugins.length) {
throw new Error("Set the ACTIVE_PLUGINS environment variable to activate one or more plugins.");
}

// Check if the requested plugins are valid and can become active
const invalidPlugins = pluginsToActivateByOwnedName.filter(
(plugin) => !plugins.some((p) => p.ownedName === plugin),
const invalidPlugins = requestedPlugins.filter(
(plugin) => !availablePlugins.some((p) => p.ownedName === plugin),
);

if (invalidPlugins.length) {
Expand All @@ -112,12 +113,19 @@ export function getActivePlugins<T extends { ownedName: string }>(plugins: reado
);
}

// Return the active plugins
return plugins.filter((plugin) => pluginsToActivateByOwnedName.includes(plugin.ownedName));
const uniquePluginsToActivate = availablePlugins.reduce((acc, plugin) => {
// Only add the plugin if it's not already in the map
if (acc.has(plugin.ownedName) === false) {
acc.set(plugin.ownedName, plugin);
}
return acc;
}, new Map<string, T>());

return Array.from(uniquePluginsToActivate.values());
}

// Helper type to get the intersection of all config types
export type IntersectionOf<T> = (T extends any ? (x: T) => void : never) extends (
// Helper type to merge multiple types into one
export type MergedTypes<T> = (T extends any ? (x: T) => void : never) extends (
x: infer R,
) => void
? R
Expand Down
20 changes: 10 additions & 10 deletions src/plugins/base.eth/handlers/Registrar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ export default function () {

ponder.on(pluginNamespace("BaseRegistrar:NameRegistered"), async ({ context, event }) => {
await upsertAccount(context, event.args.owner);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please document why we are calling upsertAccount here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is directly reflecting the reference subgraph implementation. I've just moved it couple lines up, compared to the previous version of the src/plugins/base.eth/handlers/Registrar.ts file.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tk-o Thanks for the background info.

I'm still not clear here however.

The reason why is because I see the call to handleNameRegistered at the end of this function. My understanding is that handleNameRegistered will perform this upsertAccount to match the subgraph equivalency you linked in your reply above.

So my question is still open: Why are we calling upsertAccount again here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tk-o This is still an open question for me. Can you please clarify?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shrugs, please confirm:

When we insert a record into domains table, we set its ownerId to some value (as it is notNull column). Because of how we have the ponder schema relations defined, the ownerId must have a reference in the accounts table.

That's why we first insert a record into accounts table, followed by a record added into domains table. The later must point to the former.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct.

side note: ponder doesn't enforce foreign key constraints in the postgres schema, so technically either order works but we should prefer the order you've suggested here, since domain records should depend on account record existing.

// base has 'preminted' names via Registrar#registerOnly, which explicitly does not update Registry.
// this breaks a subgraph assumption, as it expects a domain to exist (via Registry:NewOwner) before
// any Registrar:NameRegistered events. in the future we will likely happily upsert domains, but
// in order to avoid prematurely drifting from subgraph equivalancy, we insert the domain entity here,
// Base has 'preminted' names via Registrar#registerOnly, which explicitly
// does not update the Registry. This breaks a subgraph assumption, as it
// expects a domain to exist (via Registry:NewOwner) before any
// Registrar:NameRegistered events. We insert the domain entity here,
// allowing the base indexer to progress.
await context.db.insert(schema.domain).values({
id: makeSubnodeNamehash(ownedSubnameNode, tokenIdToLabel(event.args.id)),
Expand All @@ -45,12 +45,12 @@ export default function () {

if (event.args.from === zeroAddress) {
// The ens-subgraph `handleNameTransferred` handler implementation
// assumes the domain record exists. However, when an NFT token is
// minted, there's no domain entity in the database yet. The very first
// transfer event has to ensure the domain entity for the requested
// token ID has been inserted into the database. This is a workaround to
// meet expectations of the `handleNameTransferred` subgraph
// implementation.
// assumes an indexed record for the domain already exists. However,
// when an NFT token is minted (transferred from `0x0` address),
// there's no domain entity in the database yet. That very first transfer
// event has to ensure the domain entity for the requested token ID
// has been inserted into the database. This is a workaround to meet
// expectations of the `handleNameTransferred` subgraph implementation.
await context.db.insert(schema.domain).values({
id: makeSubnodeNamehash(ownedSubnameNode, tokenIdToLabel(tokenId)),
ownerId: to,
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/base.eth/ponder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type ContractConfig, createConfig, factory } from "ponder";
import { http, getAbiItem } from "viem";
import { base } from "viem/chains";

import { blockConfig, rpcEndpointUrl, rpcRequestRateLimit } from "../../lib/helpers";
import { blockConfig, rpcEndpointUrl, rpcMaxRequestsPerSecond } from "../../lib/helpers";
import { createPluginNamespace } from "../../lib/plugin-helpers";
import { BaseRegistrar } from "./abis/BaseRegistrar";
import { EarlyAccessRegistrarController } from "./abis/EARegistrarController";
Expand All @@ -24,7 +24,7 @@ export const config = createConfig({
base: {
chainId: base.id,
transport: http(rpcEndpointUrl(base.id)),
maxRequestsPerSecond: rpcRequestRateLimit(base.id),
maxRequestsPerSecond: rpcMaxRequestsPerSecond(base.id),
},
},
contracts: {
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/eth/ponder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ContractConfig, createConfig, factory, mergeAbis } from "ponder";
import { http, getAbiItem } from "viem";

import { mainnet } from "viem/chains";
import { blockConfig, rpcEndpointUrl, rpcRequestRateLimit } from "../../lib/helpers";
import { blockConfig, rpcEndpointUrl, rpcMaxRequestsPerSecond } from "../../lib/helpers";
import { createPluginNamespace } from "../../lib/plugin-helpers";
import { BaseRegistrar } from "./abis/BaseRegistrar";
import { EthRegistrarController } from "./abis/EthRegistrarController";
Expand All @@ -28,7 +28,7 @@ export const config = createConfig({
mainnet: {
chainId: mainnet.id,
transport: http(rpcEndpointUrl(mainnet.id)),
maxRequestsPerSecond: rpcRequestRateLimit(mainnet.id),
maxRequestsPerSecond: rpcMaxRequestsPerSecond(mainnet.id),
},
},
contracts: {
Expand Down
12 changes: 6 additions & 6 deletions src/plugins/linea.eth/handlers/EthRegistrar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ export default function () {

if (event.args.from === zeroAddress) {
// The ens-subgraph `handleNameTransferred` handler implementation
// assumes the domain record exists. However, when an NFT token is
// minted, there's no domain entity in the database yet. The very first
// transfer event has to ensure the domain entity for the requested
// token ID has been inserted into the database. This is a workaround to
// meet expectations of the `handleNameTransferred` subgraph
// implementation.
// assumes an indexed record for the domain already exists. However,
// when an NFT token is minted (transferred from `0x0` address),
// there's no domain entity in the database yet. That very first transfer
// event has to ensure the domain entity for the requested token ID
// has been inserted into the database. This is a workaround to meet
// expectations of the `handleNameTransferred` subgraph implementation.
await context.db.insert(schema.domain).values({
id: makeSubnodeNamehash(ownedSubnameNode, tokenIdToLabel(tokenId)),
ownerId: to,
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/linea.eth/ponder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ContractConfig, createConfig, factory, mergeAbis } from "ponder";
import { http, getAbiItem } from "viem";

import { linea } from "viem/chains";
import { blockConfig, rpcEndpointUrl, rpcRequestRateLimit } from "../../lib/helpers";
import { blockConfig, rpcEndpointUrl, rpcMaxRequestsPerSecond } from "../../lib/helpers";
import { createPluginNamespace } from "../../lib/plugin-helpers";
import { BaseRegistrar } from "./abis/BaseRegistrar";
import { EthRegistrarController } from "./abis/EthRegistrarController";
Expand All @@ -24,7 +24,7 @@ export const config = createConfig({
linea: {
chainId: linea.id,
transport: http(rpcEndpointUrl(linea.id)),
maxRequestsPerSecond: rpcRequestRateLimit(linea.id),
maxRequestsPerSecond: rpcMaxRequestsPerSecond(linea.id),
},
},
contracts: {
Expand Down
Loading