Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
130 changes: 0 additions & 130 deletions apps/ensindexer/src/lib/ensv2/registrar-lib.ts

This file was deleted.

72 changes: 72 additions & 0 deletions apps/ensindexer/src/lib/managed-names.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { namehash, zeroAddress } from "viem";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { DatasourceNames } from "@ensnode/datasources";
import { type AccountId, ENSNamespaceIds, getDatasourceContract } from "@ensnode/ensnode-sdk";

import { getManagedName } from "./managed-names";

const { spy } = vi.hoisted(() => {
return { spy: vi.fn() };
});

vi.mock("viem", async () => {
const actual = await vi.importActual<typeof import("viem")>("viem");
return {
...actual,
namehash: (name: string) => {
spy(name);
return actual.namehash(name);
},
};
});

// mock config.namespace as mainnet
vi.mock("@/config", () => ({ default: { namespace: ENSNamespaceIds.Mainnet } }));

const registrar = getDatasourceContract(
ENSNamespaceIds.Mainnet,
DatasourceNames.ENSRoot,
"BaseRegistrar",
);

const controller = getDatasourceContract(
ENSNamespaceIds.Mainnet,
DatasourceNames.ENSRoot,
"LegacyEthRegistrarController",
);

const ETH_NODE = namehash("eth");

describe("managed-names", () => {
beforeEach(() => {
vi.resetAllMocks();
});

// NOTE: because the cache isn't resettable between test runs (exporting a reset method isn't worth),
// we simply enforce that the cache test case be run first via .sequential
describe.sequential("getManagedName", () => {
it("should cache the result of viem#namehash", () => {
expect(spy.mock.calls).toHaveLength(0);

expect(getManagedName(registrar)).toStrictEqual({ name: "eth", node: ETH_NODE });

// first call should invoke namehash
expect(spy.mock.calls).toHaveLength(1);

expect(getManagedName(controller)).toStrictEqual({ name: "eth", node: ETH_NODE });

// second call should not invoke namehash
expect(spy.mock.calls).toHaveLength(1);
});

it("should return the managed name and node for the BaseRegistrar contract", () => {
expect(getManagedName(registrar)).toStrictEqual({ name: "eth", node: ETH_NODE });
});

it("should throw an error for a contract without a managed name", () => {
const unknownContract: AccountId = { chainId: 1, address: zeroAddress };
expect(() => getManagedName(unknownContract)).toThrow();
});
});
});
165 changes: 165 additions & 0 deletions apps/ensindexer/src/lib/managed-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import config from "@/config";

import { namehash } from "viem";

import { DatasourceNames, type ENSNamespaceId } from "@ensnode/datasources";
import {
type AccountId,
accountIdEqual,
getDatasourceContract,
maybeGetDatasourceContract,
type Name,
type Node,
} from "@ensnode/ensnode-sdk";

import { toJson } from "@/lib/json-stringify-with-bigints";

/**
* Many contracts within the ENSv1 Ecosystem are relative to a parent Name. For example,
* the .eth BaseRegistrar (and RegistrarControllers) manage direct subnames of .eth. As such, they
* operate on relative Labels, not fully qualified Names. We must know the parent name whose subnames
* they manage in order to index them correctly.
*
* Because we use shared indexing logic for each instance of these contracts (BaseRegistrar,
* RegistrarControllers, NameWrapper), the concept of "which name is this contract operating in the
* context of" must be generalizable: this is the contract's 'Managed Name'.
*
* Concretely, a .eth RegistrarController will emit a _LabelHash_ indicating a new Registration, but
* correlating that LabelHash with the NameHash of the Name requires knowing the NameHash of the
* Registrar's Managed Name ('eth' in this case).
*
* The NameWrapper contracts are relevant here as well because they include specialized logic for
* wrapping direct subnames of specific Managed Names.
*/

const ethnamesNameWrapper = getDatasourceContract(
config.namespace,
DatasourceNames.ENSRoot,
"NameWrapper",
);

const lineanamesNameWrapper = maybeGetDatasourceContract(
config.namespace,
DatasourceNames.Lineanames,
"NameWrapper",
);

/**
* Mapping of a Managed Name to contracts that operate in the context of a (sub)Registry associated
* with that Name.
*/
const CONTRACTS_BY_MANAGED_NAME: Record<Name, AccountId[]> = {
eth: [
getDatasourceContract(
config.namespace, //
DatasourceNames.ENSRoot,
"BaseRegistrar",
),
getDatasourceContract(
config.namespace,
DatasourceNames.ENSRoot,
"LegacyEthRegistrarController",
),
getDatasourceContract(
config.namespace,
DatasourceNames.ENSRoot,
"WrappedEthRegistrarController",
),
getDatasourceContract(
config.namespace,
DatasourceNames.ENSRoot,
"UnwrappedEthRegistrarController",
),
ethnamesNameWrapper,
],
"base.eth": [
maybeGetDatasourceContract(
config.namespace, //
DatasourceNames.Basenames,
"BaseRegistrar",
),
maybeGetDatasourceContract(
config.namespace,
DatasourceNames.Basenames,
"EARegistrarController",
),
maybeGetDatasourceContract(
config.namespace, //
DatasourceNames.Basenames,
"RegistrarController",
),
maybeGetDatasourceContract(
config.namespace,
DatasourceNames.Basenames,
"UpgradeableRegistrarController",
),
].filter((c) => !!c),
"linea.eth": [
maybeGetDatasourceContract(
config.namespace, //
DatasourceNames.Lineanames,
"BaseRegistrar",
),
maybeGetDatasourceContract(
config.namespace,
DatasourceNames.Lineanames,
"EthRegistrarController",
),
lineanamesNameWrapper,
].filter((c) => !!c),
};

/**
* Certain Managed Names are different depending on the ENSNamespace — this encodes that relationship.
*/
const MANAGED_NAME_BY_NAMESPACE: Partial<Record<ENSNamespaceId, Record<Name, Name>>> = {
sepolia: {
"base.eth": "basetest.eth",
"linea.eth": "linea-sepolia.eth",
},
};

// Because we access a contract's Managed Name (and Node) frequently in event handlers, it's likely
// that caching the namehash() fn for these few values is beneficial, so we do so here.
const namehashCache = new Map<Name, Node>();
const cachedNamehash = (name: Name): Node => {
const cached = namehashCache.get(name);
if (cached !== undefined) return cached;

const node = namehash(name);
namehashCache.set(name, node);
return node;
};

/**
* Given a `contract`, identify its Managed Name and Node.
*
* @dev Caches the result of namehash(name).
*/
export const getManagedName = (contract: AccountId): { name: Name; node: Node } => {
Copy link
Member

Choose a reason for hiding this comment

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

I'd like to see the ideas being advanced here fully integrated / harmonized with existing related ideas that I cited in #1433 (comment)

Specifically how I referenced the existing related logic in apps/ensindexer/src/lib/tokenscope/nft-issuers.ts

Here's how I understand it:

  1. We have a concept of a "SupportedNFTIssuer".
  2. Each "SupportedNFTIssuer" could then have:
    1. A "managed name" (which is a function of namespace)
    2. A "managed node" (a function of the "managed name")
    3. An AccountId identifying the contract that actually issues the NFT (the "BaseRegistrar") (also a function of namespace)
    4. Some set of additional AccountId identifying all the contracts that are known to perform updates on this NFT Issuer, including the related "BaseRegistrar" from point 3 above as well as all "RegistrarControllers" associated with the "BaseRegistrar".

The idea in point 4 should then allow for a refined implementation of getManagedName where instead of the returned value being a tuple of name / node, it would return a SupportedNFTIssuer.

The getManagedName function should then also be renamed based on the ideas shared above.

Goal: I see high value in consolidating all our special logic and rules about specific NFT issuers together and not distributing it across many places. This should include the existing related work done in TokenScope.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I understand that's your preference, but I'd like to avoid that level of abstraction and introduction of another conceptual layer: I think it harms understandability, and the usage of interpretTokenIdAs(LabelHash|NameHash) within the indexing handlers for that specific contract is a perfectly concise implementation of the same statements.

Copy link
Member

Choose a reason for hiding this comment

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

@shrugs Sure open to that. What do you suggest for next steps then?

for (const [managedName, contracts] of Object.entries(CONTRACTS_BY_MANAGED_NAME)) {
const isAnyOfTheContracts = contracts.some((_contract) => accountIdEqual(_contract, contract));
if (isAnyOfTheContracts) {
const namespaceSpecific = MANAGED_NAME_BY_NAMESPACE[config.namespace]?.[managedName];

// use the namespace-specific Managed Name if specified, otherwise use the default from CONTRACTS_BY_MANAGED_NAME
const name = namespaceSpecific ?? managedName;
const node = cachedNamehash(name);

return { name, node };
}
}

throw new Error(
`The following contract ${toJson(contract)} does not have a configured Managed Name. See apps/ensindexer/src/lib/managed-names.ts.`,
);
};

/**
* Determines whether `contract` is a NameWrapper.
*/
export function isNameWrapper(contract: AccountId) {
if (accountIdEqual(ethnamesNameWrapper, contract)) return true;
if (lineanamesNameWrapper && accountIdEqual(lineanamesNameWrapper, contract)) return true;
return false;
}
Loading