diff --git a/.changeset/config.json b/.changeset/config.json index 64f132b826..e9e4ad191f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -28,7 +28,8 @@ "@docs/ensrainbow", "@namehash/ens-referrals", "@namehash/namehash-ui", - "@ensnode/ensindexer-perf-testing" + "@ensnode/ensindexer-perf-testing", + "@ensnode/ens-test-kit" ] ], "updateInternalDependencies": "patch", diff --git a/.changeset/tidy-states-sell.md b/.changeset/tidy-states-sell.md new file mode 100644 index 0000000000..62d3bdf105 --- /dev/null +++ b/.changeset/tidy-states-sell.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ens-test-kit": patch +--- + +Add new ens-test-kit package - tool to describe ens test cases diff --git a/.memory-bank/tasks/0006-ens-test-kit/IDEA.md b/.memory-bank/tasks/0006-ens-test-kit/IDEA.md new file mode 100644 index 0000000000..5918c5d824 --- /dev/null +++ b/.memory-bank/tasks/0006-ens-test-kit/IDEA.md @@ -0,0 +1,405 @@ +# Task 0006: `@ensnode/ens-test-kit` — declarative ENS testing framework (Idea) + +## Status + +Proposal for review. Authored after iterative design conversation. Once approved, see [IMPL.md](./IMPL.md) for the PR-by-PR rollout. + +--- + +## Why we need this + +Previous idea described in [#1994](https://github.com/namehash/ensnode/pull/1994) with integration tests are imperative and triple-coupled (transport, fixture, assertion). + +Look at the heaviest case in [resolve-records.integration.test.ts](apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts): + +```ts +{ + description: "resolves every supported record type for test.eth", + name: "test.eth", + query: ["name=true", "addresses=60,0,2", "texts=avatar,...", ...].join("&"), + expectedStatus: 200, + expectedBody: { + records: { addresses: { 60: accounts.owner.address, 0: fixtures.bitcoinAddress, ... } }, + accelerationRequested: false, accelerationAttempted: false, + }, +} +``` + +Problems: + +1. **What is seeded on devnet** (in [packages/integration-test-env/src/seed/resolver-records.ts](packages/integration-test-env/src/seed/resolver-records.ts)) is implicit. You need to always go to check what data we have onchain to write testcase. + +2. **The query and expected body are HTTP-shaped.** Once we add resolution to Omnigraph, the same protocol-level case ("test.eth's avatar resolves to X") gets reimplemented as a GraphQL query with a different expected shape. + +3. **Adding a new edge case requires editing two distant places** (the seeder + the test file). There is no canonical "what does ENSNode's test environment contain?" catalog. + +4. We probably also want to test the `enssdk` wrapper, which would be yet another boilerplate layer. + +5. This approach cannot be reused by other ENS teams. A single shared catalog of test cases would invite the wider ENS community to contribute coverage upstream (PRs adding cases, forks adapting cases for their own resolvers) and grow our test suite collectively. + +So, summing it up, current idea does not scale. The omnigraph surface alone (Domains, Accounts, Resolvers, Registrations, plus future Resolution) will produce hundreds of cases. We need a structure that absorbs them without quadratic per-transport duplication. + +## What `@ensnode/ens-test-kit` is + +A new package with a single responsibility: **declarative ENS testing primitives**. + +It contains: +- **Narrow API interfaces** (`ResolutionsApi`, `DomainsApi`, `AccountsApi`, `ResolversApi`) — typed query surface that test cases call against. +- **Fixtures** — declarative chain-state preconditions. Each fixture's declaration type, builder, and on-chain handler live together in one file. +- **Seeder runtime** — applies fixtures to an `ens-test-env` devnet over RPC. +- **Test case catalog** — typed `TestCase[]` collections, organized by concern. +- **Vitest helper** — `runSuite(adapter, cases)` wires cases into a vitest suite. +- **`seed` CLI** — single binary that takes an RPC URL and applies the canonical fixture set. +- **Seeded-devnet Docker image** — extends `contracts-v2` and runs `seed` on container start, so a seeded devnet is one `docker compose up devnet` away. + +## What it is NOT + +- Not a production SDK. Interfaces exist *only* for testing; they are not how applications should talk to ENSNode. +- Not wired to ENSNode. The kit knows nothing about `postgres`, `ponder`, `ENSIndexer`, `ENSApi`. It runs against `devnet` with the contracts-v2 deployment and exports tooling to write ens test easily +- Not consrtaint to a transport. Each interface admits multiple implementations. + +## Where things live + +> NOTE: naming is in draft. I just want to show basic idea + +``` +packages/ + ens-test-kit/ NEW — universal, transport-agnostic + src/ + interfaces/ ResolutionsApi, DomainsApi, AccountsApi, ResolversApi + types/ shared shapes: Domain, Account, Resolver, Registration, ... + cases/ typed TestCase[] collections + resolution/{forward,reverse}.ts + domains/{by-name,subdomains,listing}.ts + accounts/owned-domains.ts + resolvers/indexed-records.ts + seeder/ + index.ts applies fixtures to a devnet + fixtures/ declaration + builder + on-chain handler per fixture kind, one file each + common.ts common canonical fixtures — the union of fixtures cases reference + primary-name.ts + text-record.ts + multicoin-address.ts + contenthash.ts + registration.ts + ... + vitest/ runSuite() helper + cli/ `seed` command implementation + bin/ + ens-test-kit CLI binary (single subcommand: `seed`) + devnet/ + Dockerfile extends contracts-v2; seeds on startup + entrypoint.sh runs runDevnet.ts + waits for health + runs seed + + integration-test-env/ EXISTING — slimmed + src/ + orchestrator.ts no longer seeds; devnet container does it + adapters/ NEW — implementations of kit's interfaces + omnigraph-adapter.ts implements DomainsApi, AccountsApi, ResolversApi + rest-adapter.ts implements ResolutionsApi + tests/ NEW — runSuite calls per concern × adapter + resolution-rest.integration.test.ts + domains-omnigraph.integration.test.ts + accounts-omnigraph.integration.test.ts + resolvers-omnigraph.integration.test.ts + +docker/ + services/ + devnet.yml UPDATED — points at the kit's seeded dockerfile +``` + +Dependency direction: + +``` +@ensnode/ens-test-kit (no ENSNode dependencies) + ▲ + │ + └── @ensnode/integration-test-env (orchestrator + adapters + test wiring) +``` + +## How a test case looks + +### The narrow interfaces + +```ts +interface ResolutionsApi { + resolveRecords(name: NormalizedName, selection: RecordsSelection): Promise; + resolvePrimaryName(address: Hex, chainId: ChainId): Promise; + resolvePrimaryNames(address: Hex, chainIds?: ChainId[]): Promise>; +} + +interface DomainsApi { + getDomainByName(name: NormalizedName): Promise; + getDomainByNamehash(node: Hex): Promise; + listDomains(where: DomainsWhere): Promise>; + getRegistration(name: NormalizedName): Promise; +} + +interface AccountsApi { + getAccount(address: Hex): Promise; +} + +interface ResolversApi { + getResolver(id: ResolverId): Promise; + listResolverRecords(name: NormalizedName): Promise<{ keys: string[]; coinTypes: ChainId[] }>; +} +``` + +Selection params are deliberately omitted in `ens-test-kit V1`. Adapters always over-fetch a stable shape (e.g. `Domain` includes `owner`, `registration`, `subdomains`). Tests use partial matching (`toMatchObject`) and only assert on what they care about. Field selection can be added later if the over-fetch becomes painful. + +### A `TestCase` is just data + +```ts +type TestCase = { + id: string; + description: string; + fixtures: Fixture[]; // declarative preconditions + call: (api: Api) => Promise; // what to perform over API. should be simple logic + expected: Expectation | unknown; // plain value → partial-object match; wrap for other matchers +}; +``` + +The case is generic in the API it requires. TypeScript enforces case-vs-adapter compatibility at compile time. No runtime tagging. + +`expected` is intentionally plain data — **no Vitest `expect` matchers may appear inside a case**. Embedding `expect.*` would tie the catalog to a test runner and break module-load for any non-Vitest consumer (the `seed` CLI, a docs generator, a future JSON exporter). For asymmetric matching the kit ships a small serialisable DSL: + +```ts +// ens-test-kit/src/cases/expectation.ts +const EXPECTATION = Symbol.for("ens-test-kit.expectation"); + +export type Expectation = + | { [EXPECTATION]: "partial"; value: unknown } + | { [EXPECTATION]: "equals"; value: unknown } + | { [EXPECTATION]: "arrayContains"; items: unknown[] }; + +export const expectation = { + partial: (value: unknown): Expectation => ({ [EXPECTATION]: "partial", value }), + equals: (value: unknown): Expectation => ({ [EXPECTATION]: "equals", value }), + arrayContains: (...items: unknown[]): Expectation => ({ [EXPECTATION]: "arrayContains", items }), +}; +``` + +`runSuite` inspects `expected`: if it's an `Expectation`, it translates to the appropriate Vitest matcher (`toMatchObject` / `toEqual` / `toEqual(expect.arrayContaining(...))`); otherwise it treats the value as a partial-object expectation (the common case) and calls `toMatchObject`. Cases stay framework-agnostic. + +### Example: a resolution case + +```ts +// ens-test-kit/src/cases/resolution/forward.ts +import { textRecord } from "../../seeder/fixtures/text-record"; +import type { ResolutionsApi, TestCase } from "../.."; + +const testEthAvatar = textRecord({ + id: "test-eth-avatar", + name: "test.eth", + key: "avatar", + value: "https://example.com/avatar.png", +}); + +export const forwardResolutionCases: TestCase[] = [ + { + id: "forward.text.test-eth-avatar", + description: "resolves avatar text record for test.eth", + fixtures: [testEthAvatar], + call: (api) => api.resolveRecords("test.eth", { texts: ["avatar"] }), + expected: { texts: { avatar: testEthAvatar.value } }, + }, + { + id: "forward.text.unset", + description: "returns null for unset text record", + fixtures: [], + call: (api) => api.resolveRecords("test.eth", { texts: ["nonexistent.key"] }), + expected: { texts: { "nonexistent.key": null } }, + }, +]; +``` + +`expected` derives from the fixture (`testEthAvatar.value`), so a change to seeded data automatically updates the expectation. Drift is impossible. + +### Example: a domains case (omnigraph-only) + +```ts +// ens-test-kit/src/cases/domains/subdomains.ts +import { registration } from "../../seeder/fixtures/registration"; +import type { DomainsApi, TestCase } from "../.."; + +const parentEth = registration({ id: "parent-eth", name: "parent.eth", owner: "owner" }); +const subParentEth = registration({ + id: "sub-parent-eth", + name: "sub.parent.eth", + owner: "owner", + parent: parentEth, +}); + +export const subdomainCases: TestCase[] = [ + { + id: "domains.subdomains.parent-eth-has-sub", + description: "parent.eth lists sub.parent.eth as a subdomain", + fixtures: [parentEth, subParentEth], + call: (api) => api.getDomainByName("parent.eth"), + expected: { + name: "parent.eth", + subdomains: [{ name: "sub.parent.eth" }], + }, + }, +]; +``` + +A case using both interfaces declares the intersection explicitly: + +```ts +// ens-test-kit/src/cases/accounts/owned-domains.ts +import { expectation } from "../expectation"; + +export const ownershipCases: TestCase[] = [ + { + id: "accounts.owns.owner-owns-test-eth", + description: "owner account's domains include test.eth", + fixtures: [/* references existing seeded fixtures */], + call: async (api) => { + const account = await api.getAccount(OWNER_ADDRESS); + return account?.domains.map((d) => d.name); + }, + expected: expectation.arrayContains("test.eth"), + }, +]; +``` + +## How a fixture looks + +A fixture's **declaration type**, **builder**, and **on-chain handler** all live in one file under `src/seeder/fixtures/`. Adding a new fixture kind = one new file (three exports) + one entry in the seeder dispatcher. + +```ts +// ens-test-kit/src/seeder/fixtures/text-record.ts +import { ResolverABI } from "@ensnode/datasources"; +import { contracts } from "@ensnode/datasources/devnet"; +import { namehash } from "viem"; +import type { NormalizedName } from "../../types"; +import type { SeederContext } from "../types"; + +export type TextRecordFixture = { + kind: "text-record"; + id: string; + name: NormalizedName; + resolverAddress?: Hex; + key: string; + value: string; +}; + +export function textRecord(args: Omit): TextRecordFixture { + return { kind: "text-record", ...args }; +} + +export async function applyTextRecord( + fixture: TextRecordFixture, + ctx: SeederContext, +): Promise { + const node = namehash(fixture.name); + const hash = await ctx.clients.owner.writeContract({ + address: fixture.resolverAddress ?? contracts.permissionedResolver, + abi: ResolverABI, + functionName: "setText", + args: [node, fixture.key, fixture.value], + }); + await ctx.clients.owner.waitForTransactionReceipt({ hash }); +} +``` + +This is mechanically the same logic that lives today in [packages/integration-test-env/src/seed/resolver-records.ts](packages/integration-test-env/src/seed/resolver-records.ts). The migration is purely a relocation + interface change (handler takes a `Fixture`, not raw args). + +### How seeding works end-to-end + +```ts +// ens-test-kit/src/seeder/index.ts +import { applyTextRecord } from "./fixtures/text-record"; +import { applyPrimaryName } from "./fixtures/primary-name"; +// ... + +const HANDLERS = { + "text-record": applyTextRecord, + "primary-name": applyPrimaryName, + "multicoin-address": applyMulticoinAddress, + "contenthash": applyContenthash, + "registration": applyRegistration, + // ... +} as const; + +export async function seedFixtures(rpcUrl: string, fixtures: Fixture[]): Promise { + const ctx = createSeederContext(rpcUrl); + const deduped = dedupeFixtures(fixtures); // by id; throws if same id has unequal content + const ordered = topologicallySort(deduped); // registrations before records, etc. + for (const fixture of ordered) { + const handler = HANDLERS[fixture.kind]; + await handler(fixture as never, ctx); + } +} +``` + +The seeder is idempotent at the fixture level: a given `id` is applied at most once. `dedupeFixtures` enforces *content consistency* — if two cases contribute a fixture with the same `id` but different fields, the seeder throws at startup rather than silently dropping one and making a downstream case test the wrong on-chain state. Same id + same content → safe reuse; same id + different content → developer error, fail loud. Topological ordering handles dependencies (you must register `parent.eth` before setting records on `sub.parent.eth`). + +## How seeding plugs into Docker + +We do not seed from `orchestrator.ts` anymore. The devnet container seeds itself on startup. + +1. `packages/ens-test-kit/devnet/Dockerfile` is multi-stage. The build stage compiles the kit (TypeScript → JS). The runtime stage extends `ghcr.io/ensdomains/contracts-v2:main-9f26a8f`, layers in Node and the built kit, and ships an `entrypoint.sh` that: + - Starts `./script/runDevnet.ts --testNames` in the background. + - Waits for Anvil to accept JSON-RPC on `localhost:8545`. + - Runs `ens-test-kit seed --rpc http://localhost:8545` against the local Anvil. + - Only then exposes the `localhost:8000/health` endpoint as healthy (so dependents wait for *seeded*, not just *anvil-booted*). + - `wait`s on the devnet process so the container stays up. +2. [docker/services/devnet.yml](docker/services/devnet.yml) is updated to build from this Dockerfile (build context = monorepo root, so the workspace lockfile is available). Once we publish a tagged image, this service can switch back to `image:`. +3. Anyone — CI, local devs, external users — gets a seeded devnet with `docker compose -f docker/services/devnet.yml up devnet`. No orchestrator, no ENSNode stack, no kit binary install. +4. [packages/integration-test-env/src/orchestrator.ts](packages/integration-test-env/src/orchestrator.ts) drops its `seedDevnet()` call entirely. The container handles seeding before its healthcheck reports healthy. + +The kit's only standalone CLI entry point is `ens-test-kit seed --rpc `. There is no `up` command — bringing up the chain is `docker compose ... up devnet`'s job. + +## What stays where + +1. **Fixture declarations + builders + handlers** + Today: [seed/primary-names.ts](packages/integration-test-env/src/seed/primary-names.ts), [seed/resolver-records.ts](packages/integration-test-env/src/seed/resolver-records.ts) + After: `ens-test-kit/seeder/fixtures/*` (one file per fixture kind, type+builder+handler combined). + +2. **`seedDevnet()` entry** + Today: [seed/index.ts](packages/integration-test-env/src/seed/index.ts), called from orchestrator + After: `ens-test-kit/seeder/index.ts`, called by the devnet container's entrypoint. + +3. **Devnet startup seeding** + Today: orchestrator phase 2 + After: devnet container entrypoint (before health passes). + +4. **Devnet constants (contract addrs, accounts, fixture values)** + Today: `@ensnode/datasources/devnet` + After: `@ensnode/ens-test-kit/devnet` is the source of truth; both kit internals and adapters import from it. + +5. **Positive-path integration test cases** + Today: `apps/ensapi/src/handlers/api/.../*.integration.test.ts` + After: `ens-test-kit/cases/*`; runners in `integration-test-env/tests/`. + +6. **Validation/4xx tests** + Today: same files + After: stay in `apps/ensapi` (transport-specific, not part of kit). + +7. **Orchestrator** + Today: seeds + runs services + After: runs services only; depends on devnet container being healthy = seeded. + +8. **`services/devnet.yml`** + Today: uses upstream contracts-v2 image + After: builds from kit's Dockerfile. + +## Design choices and rationale + +- **One global devnet, namespace-by-name.** Per-test isolation is impossible because Ponder reindexes on data change and snapshot/revert doesn't reset the indexer DB. All fixtures coexist on one devnet; conflicting scenarios use different names (`with-avatar.example.eth` vs `without-avatar.example.eth`). This matches what reference test envs (ensjs deploy scripts) effectively do. +- **Interfaces are test-only.** Production SDK stays focused; we don't accidentally promise these shapes to external consumers. If they prove valuable as a public API later, promotion is a separate decision. +- **Adapters live in `integration-test-env`.** They're glue between the kit and a running ENSNode stack — the only place they're meaningful. No `ensnode-sdk` coupling. +- **Over-fetch in V1, no `select` param.** Cases assert with `toMatchObject`. Adds selection later if/when over-fetched payloads get unwieldy. +- **TypeScript-first cases, no JSON/YAML format.** All target consumers are TS/JS. Compile-time validation of fixture references is worth more than format-language portability. +- **Seed inside the devnet container, not from the orchestrator.** A seeded devnet is the deliverable; once the kit's image is up, *any* consumer (CI, dev, external resolver team) gets the same seeded chain with one compose command. Removes orchestrator complexity and removes the dev-loop need to "boot the full ENSNode stack to get test data". +- **Single CLI command (`seed`).** No `up`, since `docker compose up devnet` already does that perfectly. +- **One file per fixture kind.** Declaration + builder + on-chain handler co-located: trivial to add a new fixture kind, easy to review, no file-jumping between declaration and runtime. + +## Out of scope + +- Real-chain (mainnet) validation — that's a follow-up; the kit's resolution adapter could later target viem/UniversalResolver to enable `resolution-eq`-style diffing in CI. +- ENSDb direct-access adapter (asserting indexed state by reading Postgres directly) — possible follow-up; not needed for V1. +- GraphQL subgraph-compat schema testing — possible future use of the kit; out of scope here. +- Schema-agnostic "fact assertions" DSL — over-engineering for the current case load. diff --git a/.memory-bank/tasks/0006-ens-test-kit/IMPL.md b/.memory-bank/tasks/0006-ens-test-kit/IMPL.md new file mode 100644 index 0000000000..6685f14ee2 --- /dev/null +++ b/.memory-bank/tasks/0006-ens-test-kit/IMPL.md @@ -0,0 +1,200 @@ +# Task 0006: `@ensnode/ens-test-kit` — Implementation Steps + +See [IDEA.md](./IDEA.md) for the design rationale. + +**Steps, not PRs.** This document is broken into implementation *steps*. How these steps map to pull requests is **the user's decision** — some steps may ship as a single PR, others may be combined, and a single step may be split across multiple PRs if it grows. The user will announce the PR boundary (e.g. "ship a PR now after step 2", "combine steps 3 and 4 into one PR") as work progresses. Do not open a PR per step automatically. + +Steps are ordered by dependency: later steps assume earlier steps are in place. Each step ends with an **Acceptance** checklist that must hold before moving on. + +Implementation preference: if an existing tool/library already solves the problem (argument parsing, sorting, data ops, etc.), use it instead of writing custom code. +Implementation quality: avoid repetition (DRY). Shared logic and tunable constants (timeouts, confirmations, polling intervals, etc.) must be centralized behind one helper/config instead of duplicated across handlers. + +--- + +## Step 1 — Skeleton package + interfaces + types + +**Goal:** ship the package shell with the contract types, no runtime behavior yet. + +**Scope:** +- Create `packages/ens-test-kit/` with `package.json`, `tsconfig.json`, `vitest.config.ts`, `tsup.config.ts`, `README.md`. +- Add to monorepo: `pnpm-workspace.yaml`, root `tsconfig` references if applicable, `biome.json` if needed. +- Define `src/interfaces/`: + - `resolutions.ts` — `ResolutionsApi` + - `domains.ts` — `DomainsApi` + - `accounts.ts` — `AccountsApi` + - `resolvers.ts` — `ResolversApi` +- Define `src/types/`: + - `Domain`, `Account`, `Resolver`, `Registration`, `Connection`, `DomainsWhere`, `RecordsSelection`, `ResolvedRecords`, `NormalizedName`, `Hex`, `ChainId`, `ResolverId`. +- Define `src/seeder/types.ts`: + - `Fixture` discriminated union (re-exported from per-fixture files), `FixtureKind`, `FixtureBase`, `SeederContext`. +- Define `src/cases/types.ts`: + - `TestCase`. +- Public exports via subpath: `interfaces`, `types`, `cases`, `seeder`, `vitest`, `cli`. + +**Acceptance:** package builds, exports type-check, no runtime code yet. + +--- + +## Step 2 — Fixtures + seeder runtime + `seed` CLI + seeded devnet image + +**Goal:** the kit owns all seeding, top to bottom; the devnet container seeds itself on startup; orchestrator no longer seeds. + +**Scope:** + +*Fixtures (one file per fixture kind, each exporting type + builder + handler):* +- `src/seeder/fixtures/reverse-name.ts` — `ReverseNameFixture`, `reverseName(args)`, `applyReverseNameFixture(fixture, ctx)`. +- `src/seeder/fixtures/text-record.ts` +- `src/seeder/fixtures/multicoin-address.ts` +- `src/seeder/fixtures/contenthash.ts` +- `src/seeder/fixtures/pubkey.ts` +- `src/seeder/fixtures/abi.ts` +- `src/seeder/fixtures/interface-record.ts` +- Each handler ports the existing logic from [packages/integration-test-env/src/seed/primary-names.ts](packages/integration-test-env/src/seed/primary-names.ts) and [packages/integration-test-env/src/seed/resolver-records.ts](packages/integration-test-env/src/seed/resolver-records.ts). + +*Seeder runtime:* +- `src/seeder/index.ts`: + - `createSeederContext(rpcUrl)` — wallet clients (deployer, owner, user, user2). + - `seedFixtures(rpcUrl, fixtures)` — dedupe by id, preserve fixture input order, dispatch to handlers. + - `dedupeFixtures` **must throw** when two fixtures share an `id` but are not deeply equal (use structural equality on fixture fields, ignoring object identity). Silent dedup is disallowed — it would let a second case override a first case's on-chain state and pass against the wrong fixture. Include both fixtures' JSON in the thrown error message for fast diagnosis. +- `src/seeder/fixtures/common.ts`: + - `canonicalFixtures` — the union of fixtures that match what's seeded today. + +*CLI:* +- `src/cli/seed.ts`: parses `--rpc ` and `--fixtures ` (defaults to canonical) using an existing parser utility/library, calls `seedFixtures`, prints a readable per-name summary on success. +- `bin/ens-test-kit` declared in `package.json#bin`. Single subcommand: `seed`. No `up`. + +*Docker image:* +- `packages/ens-test-kit/devnet/Dockerfile` — multi-stage: + - Build stage: pull workspace lockfile + kit source from monorepo build context, run `pnpm -F ens-test-kit build`. + - Runtime stage: `FROM ghcr.io/ensdomains/contracts-v2:main-9f26a8f`, install Node, copy in built kit + `entrypoint.sh`. +- `packages/ens-test-kit/devnet/entrypoint.sh`: + - Start `./script/runDevnet.ts --testNames` in the background. + - Wait until Anvil JSON-RPC at `localhost:8545` is responsive. + - Run `node /opt/ens-test-kit/cli.js seed --rpc http://localhost:8545`. + - Only after seeding succeeds, expose the contracts-v2 health endpoint as healthy. Implementation hook: contracts-v2's `runDevnet.ts` already serves `/health` on `:8000`; either hold a small proxy in front of it that returns 503 until seeding completes, or set a sentinel file the existing `/health` checker reads. Decide during implementation; flag for review. + - `wait` on the devnet process so the container stays up. +- Update [docker/services/devnet.yml](docker/services/devnet.yml): + - Replace `image: ghcr.io/ensdomains/contracts-v2:main-9f26a8f` with `build: { context: ../.., dockerfile: packages/ens-test-kit/devnet/Dockerfile }`. + - Keep healthcheck config; semantics are now "anvil up *and* seeded". + - Once we publish a tagged image of this Dockerfile (out of scope here), revert to `image:` with that tag. + +*Orchestrator:* +- Remove the `seedDevnet()` call from [packages/integration-test-env/src/orchestrator.ts](packages/integration-test-env/src/orchestrator.ts) phase 2. The container's `service_healthy` wait is now sufficient. +- Delete now-unused [packages/integration-test-env/src/seed/primary-names.ts](packages/integration-test-env/src/seed/primary-names.ts), [packages/integration-test-env/src/seed/resolver-records.ts](packages/integration-test-env/src/seed/resolver-records.ts), [packages/integration-test-env/src/seed/index.ts](packages/integration-test-env/src/seed/index.ts). + +**Acceptance:** +- `docker compose -f docker/services/devnet.yml up devnet` produces a seeded chain reachable at `localhost:8545`. `cast call` against the resolver returns the expected records; `cast call` against the reverse resolver returns the expected primary name. +- `pnpm -F integration-test-env start` produces identical end-to-end behavior; existing integration tests pass without modification. +- Devnet container's healthcheck only flips green after seeding completes (verifiable by tailing entrypoint logs vs `docker inspect`'s `Health.Status` transitions). + +--- + +## Step 3 — Test case framework + first port (resolution via REST) + +**Goal:** prove the case-and-runner abstraction end-to-end with one concern. + +**Scope:** +- Implement `src/cases/expectation.ts` — `Expectation` discriminated union + `expectation.{partial,equals,arrayContains}` builders (see [IDEA.md](./IDEA.md#a-testcase-is-just-data)). `EXPECTATION` sentinel is a `Symbol.for(...)` so the tag survives module-boundary crossing in workspaces. +- Implement `src/vitest/run-suite.ts` — `runSuite(adapter, cases)`: + ```ts + import { describe, it, expect } from "vitest"; + import { type Expectation, isExpectation } from "../cases/expectation"; + + export function runSuite(adapter: Api, cases: TestCase[]): void { + describe(`suite (${cases.length} cases)`, () => { + it.each(cases)("$id — $description", async (tc) => { + const actual = await tc.call(adapter); + if (isExpectation(tc.expected)) { + assertExpectation(actual, tc.expected); + } else { + expect(actual).toMatchObject(tc.expected as object); + } + }); + }); + } + + function assertExpectation(actual: unknown, e: Expectation): void { + switch (e[EXPECTATION]) { + case "partial": expect(actual).toMatchObject(e.value as object); return; + case "equals": expect(actual).toEqual(e.value); return; + case "arrayContains": expect(actual).toEqual(expect.arrayContaining(e.items)); return; + } + } + ``` + Cases never import Vitest. The translation from the data DSL to Vitest matchers happens only here, inside the runner. +- Add a lint check (or unit test) that scans the `src/cases/` tree and fails if any file imports `vitest` or references `expect.` — the "no Vitest in cases" invariant is worth enforcing mechanically. +- Implement `src/cases/resolution/forward.ts` and `reverse.ts` — port positive-path cases from: + - [resolve-records.integration.test.ts](apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts) (positive paths) + - [resolve-primary-name.integration.test.ts](apps/ensapi/src/handlers/api/resolution/resolve-primary-name.integration.test.ts) (positive paths) + - [resolve-primary-names.integration.test.ts](apps/ensapi/src/handlers/api/resolution/resolve-primary-names.integration.test.ts) (positive paths) + - Cases derive `expected` values from referenced fixtures. +- Add `RestAdapter` in `packages/integration-test-env/src/adapters/rest-adapter.ts` implementing `ResolutionsApi`. +- Add `packages/integration-test-env/src/tests/resolution-rest.integration.test.ts` calling `runSuite(restAdapter, [...forwardResolutionCases, ...reverseResolutionCases])`. +- Trim ported cases out of the original `apps/ensapi/src/handlers/api/resolution/*.integration.test.ts` files; **keep** the validation/400 cases there (they're transport-specific). + +**Acceptance:** new tests pass against running ENSNode; old test files reduced to validation-only with all positive paths now driven by the kit; CI green. + +--- + +## Step 4 — Domains, Accounts, Resolvers cases via Omnigraph adapter + +**Goal:** widen the kit beyond resolution to cover the omnigraph surface. + +**Scope:** +- Implement `OmnigraphAdapter` in `packages/integration-test-env/src/adapters/omnigraph-adapter.ts`: + - One method per interface method. + - Hand-written GraphQL queries (codegen optional, defer if straightforward). + - Implements `DomainsApi`, `AccountsApi`, `ResolversApi`. +- Build initial cases: + - `src/cases/domains/by-name.ts` — getDomainByName for known seeded names. + - `src/cases/domains/subdomains.ts` — parent/sub relationships from existing devnet (parent.eth, sub.parent.eth, sub1.sub2.parent.eth). + - `src/cases/domains/listing.ts` — `listDomains` with where clauses. + - `src/cases/accounts/owned-domains.ts` — owner account's domain list (uses `DomainsApi & AccountsApi`). + - `src/cases/resolvers/indexed-records.ts` — `listResolverRecords` returns the keys/coinTypes set on test.eth. +- Add per-concern test files in `packages/integration-test-env/src/tests/`. + +**Acceptance:** four new test files run cases through `OmnigraphAdapter`; existing test files unchanged. + +--- + +## Step 5 — Backfill missing scenarios + +**Goal:** close the coverage gaps documented in [Task 0004](.memory-bank/tasks/0004-ensnode-tests/PLAN.md). + +**Scope (each item adds fixtures + cases; some require contracts-v2 capability checks):** +- Wildcard resolver scenario. +- Wrapped name (NameWrapper). +- Expired name (use `evm_setNextBlockTimestamp` in seeder, or contracts-v2 helper if available). +- Multi-coin reverse resolvers (Base, Linea) on owner address. +- Custom CCIP/offchain resolver (if devnet supports — investigate during implementation; otherwise punt to its own task). +- ENSv1 vs ENSv2 reverse resolver variants for the same address. + +Cases are added file-by-file per scenario; fixtures gain new builders as needed. Each scenario claims its own name to avoid collisions. + +**Acceptance:** new cases pass against the omnigraph and (where applicable) the REST adapter; documentation in the kit's README enumerates available fixture types and seeded names. + +--- + +## Step 6 — Resolution adapter for omnigraph (after [Task 0003](.memory-bank/tasks/0003-omnigraph-resolution-api/PLAN.md)) + +**Goal:** once omnigraph exposes resolution, run the same resolution cases through it for free. + +**Scope:** +- `OmnigraphAdapter` additionally implements `ResolutionsApi`. +- Add `packages/integration-test-env/src/tests/resolution-omnigraph.integration.test.ts` running the same case set through `OmnigraphAdapter`. +- Cases are unchanged. The same `forwardResolutionCases` array now exercises both REST and GraphQL. + +**Acceptance:** one source of resolution truth, two transports validated; any divergence between REST and GraphQL surfaces as a test failure. + +--- + +## Definition of done for the proposal + +This plan is ready for implementation when: +- The two-package shape is approved (`ens-test-kit` + slimmed `integration-test-env`). +- The interface segregation (4 narrow interfaces) is approved. +- One-file-per-fixture-kind layout (declaration + builder + handler combined) is approved. +- "Over-fetch in V1, no `select`" is approved. +- The Docker-side seeding model (devnet image self-seeds; orchestrator doesn't seed; no kit `up` command) is approved. +- The `seed` CLI as the kit's single binary entry point is approved. +- The step sequence is approved (or a different sequencing is agreed). diff --git a/apps/ensapi/src/handlers/api/resolution/resolve-primary-name.integration.test.ts b/apps/ensapi/src/handlers/api/resolution/resolve-primary-name.integration.test.ts index d126cd16e5..c85b6fd3b5 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolve-primary-name.integration.test.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolve-primary-name.integration.test.ts @@ -6,38 +6,12 @@ import { describe, expect, it } from "vitest"; -import { DevnetAccounts } from "@ensnode/ensnode-sdk/internal"; +import { accounts } from "@ensnode/datasources/devnet"; const BASE_URL = process.env.ENSNODE_URL!; describe("GET /api/resolve/primary-name/:address/:chainId", () => { it.each([ - { - description: - "resolves primary name for owner address on chain 1 (no primary name set in devnet)", - address: DevnetAccounts.owner.address, - chainId: "1", - query: "", - expectedStatus: 200, - expectedBody: { name: null, accelerationRequested: false, accelerationAttempted: false }, - }, - { - description: - "resolves primary name for user address on chain 1 (no primary name set in devnet)", - address: DevnetAccounts.user.address, - chainId: "1", - query: "", - expectedStatus: 200, - expectedBody: { name: null, accelerationRequested: false, accelerationAttempted: false }, - }, - { - description: "owner address with accelerate=true returns accelerationRequested: true", - address: DevnetAccounts.owner.address, - chainId: "1", - query: "accelerate=true", - expectedStatus: 200, - expectedBody: { accelerationRequested: true, accelerationAttempted: false }, - }, { description: "returns 400 for invalid (non-hex) address", address: "notanaddress", @@ -58,7 +32,7 @@ describe("GET /api/resolve/primary-name/:address/:chainId", () => { }, { description: "returns 400 for non-numeric chainId", - address: DevnetAccounts.owner.address, + address: accounts.owner.address, chainId: "notachainid", query: "", expectedStatus: 400, diff --git a/apps/ensapi/src/handlers/api/resolution/resolve-primary-names.integration.test.ts b/apps/ensapi/src/handlers/api/resolution/resolve-primary-names.integration.test.ts index 3f95be0504..10521ffea4 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolve-primary-names.integration.test.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolve-primary-names.integration.test.ts @@ -6,35 +6,12 @@ import { describe, expect, it } from "vitest"; -import { DevnetAccounts } from "@ensnode/ensnode-sdk/internal"; +import { accounts } from "@ensnode/datasources/devnet"; const BASE_URL = process.env.ENSNODE_URL!; describe("GET /api/resolve/primary-names/:address", () => { it.each([ - { - description: - "resolves primary names for owner address on chain 1 (no primary name set in devnet)", - address: DevnetAccounts.owner.address, - query: "chainIds=1", - expectedStatus: 200, - expectedBody: { - names: { "1": null }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, - { - description: "resolves all primary names", - address: DevnetAccounts.owner.address, - query: "", - expectedStatus: 200, - expectedBody: { - names: { "1": null }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, { description: "returns 400 for invalid (non-hex) address", address: "notanaddress", @@ -54,7 +31,7 @@ describe("GET /api/resolve/primary-names/:address", () => { }, { description: "returns 400 when chainIds contains the default chain id (0)", - address: DevnetAccounts.owner.address, + address: accounts.owner.address, query: "chainIds=0", expectedStatus: 400, expectedBody: { @@ -76,7 +53,7 @@ describe("GET /api/resolve/primary-names/:address", () => { }, { description: "returns 400 when chainIds contains duplicate chain ids", - address: DevnetAccounts.owner.address, + address: accounts.owner.address, query: "chainIds=1,1", expectedStatus: 400, expectedBody: { diff --git a/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts b/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts index 9f6c500dcb..5ce46b7308 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts @@ -6,140 +6,10 @@ import { describe, expect, it } from "vitest"; -import { DevnetAccounts } from "@ensnode/ensnode-sdk/internal"; - const BASE_URL = process.env.ENSNODE_URL!; describe("GET /api/resolve/records/:name", () => { it.each([ - { - description: "resolves ETH address (coin 60) for test.eth", - name: "test.eth", - query: "addresses=60", - expectedStatus: 200, - expectedBody: { - records: { addresses: { 60: DevnetAccounts.owner.address } }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, - { - description: - "resolves ETH address for newowner.eth (coin 60 stays as original registrant after token transfer)", - name: "newowner.eth", - query: "addresses=60", - expectedStatus: 200, - expectedBody: { - records: { addresses: { 60: DevnetAccounts.owner.address } }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, - { - description: "resolves description text record for example.eth", - name: "example.eth", - query: "texts=description", - expectedStatus: 200, - expectedBody: { - records: { texts: { description: "example.eth" } }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, - { - description: - "resolves description text record for alias.eth (resolves via alias to test.eth)", - name: "alias.eth", - query: "texts=description", - expectedStatus: 200, - expectedBody: { - records: { texts: { description: "test.eth" } }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, - { - description: "resolves both address and text records for example.eth", - name: "example.eth", - query: "addresses=60&texts=description", - expectedStatus: 200, - expectedBody: { - records: { - addresses: { 60: DevnetAccounts.owner.address }, - texts: { description: "example.eth" }, - }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, - { - description: "returns null address for reserved.eth (no resolver)", - name: "reserved.eth", - query: "addresses=60", - expectedStatus: 200, - expectedBody: { - records: { addresses: { 60: null } }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, - { - description: - "returns old coin 60 record for sub.unregistered.eth (token burned but resolver records persist)", - name: "sub.unregistered.eth", - query: "addresses=60", - expectedStatus: 200, - expectedBody: { - records: { addresses: { 60: DevnetAccounts.owner.address } }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, - { - description: "returns null address for nonexistent name", - name: "thisnamedoesnotexist.eth", - query: "addresses=60", - expectedStatus: 200, - expectedBody: { - records: { addresses: { 60: null } }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, - { - description: "resolves ETH address for linked.parent.eth (alias to sub1.sub2.parent.eth)", - name: "linked.parent.eth", - query: "addresses=60", - expectedStatus: 200, - expectedBody: { - records: { addresses: { 60: DevnetAccounts.owner.address } }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, - { - description: - "resolves ETH address for wallet.linked.parent.eth (alias to wallet.sub1.sub2.parent.eth)", - name: "wallet.linked.parent.eth", - query: "addresses=60", - expectedStatus: 200, - expectedBody: { - records: { addresses: { 60: DevnetAccounts.owner.address } }, - accelerationRequested: false, - accelerationAttempted: false, - }, - }, - { - description: "test.eth with accelerate=true returns accelerationRequested: true", - name: "test.eth", - query: "addresses=60&accelerate=true", - expectedStatus: 200, - expectedBody: { - records: { addresses: { 60: DevnetAccounts.owner.address } }, - accelerationRequested: true, - accelerationAttempted: false, - }, - }, { description: "returns 400 when selection is empty (no addresses, texts, or name)", name: "test.eth", diff --git a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts index 460fdc34c3..a1ac66c3d6 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -1,7 +1,7 @@ import type { InterpretedName } from "enssdk"; import { beforeAll, describe, expect, it } from "vitest"; -import { DevnetAccounts } from "@ensnode/ensnode-sdk/internal"; +import { accounts } from "@ensnode/datasources/devnet"; import { AccountDomainsPaginated, @@ -37,7 +37,7 @@ describe("Account.domains", () => { it("returns domains owned by the devnet owner", async () => { const result = await request(AccountDomains, { - address: DevnetAccounts.owner.address, + address: accounts.owner.address, }); const domains = flattenConnection(result.account.domains); const names = domains.map((d) => d.name); @@ -64,7 +64,7 @@ describe("Account.domains", () => { it("returns domains owned by the new owner", async () => { const result = await request(AccountDomains, { - address: DevnetAccounts.user.address, + address: accounts.user.address, }); const domains = flattenConnection(result.account.domains); const names = domains.map((d) => d.name); @@ -77,7 +77,7 @@ describe("Account.domains pagination", () => { testDomainPagination(async (variables) => { const result = await request<{ account: { domains: PaginatedGraphQLConnection }; - }>(AccountDomainsPaginated, { address: DevnetAccounts.owner.address, ...variables }); + }>(AccountDomainsPaginated, { address: accounts.owner.address, ...variables }); return result.account.domains; }); }); @@ -94,14 +94,14 @@ describe("Account.events", () => { it("returns events for the devnet deployer", async () => { const result = await request(AccountEvents, { - address: DevnetAccounts.deployer.address, + address: accounts.deployer.address, }); const events = flattenConnection(result.account.events); expect(events.length).toBeGreaterThan(0); for (const event of events) { - expect(event.sender).toBe(DevnetAccounts.deployer.address); + expect(event.sender).toBe(accounts.deployer.address); } }); }); @@ -110,7 +110,7 @@ describe("Account.events pagination", () => { testEventPagination(async (variables) => { const result = await request<{ account: { events: PaginatedGraphQLConnection }; - }>(AccountEventsPaginated, { address: DevnetAccounts.deployer.address, ...variables }); + }>(AccountEventsPaginated, { address: accounts.deployer.address, ...variables }); return result.account.events; }); }); @@ -129,7 +129,7 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { beforeAll(async () => { const result = await request(AccountEventsFiltered, { - address: DevnetAccounts.deployer.address, + address: accounts.deployer.address, first: 1000, }); // events are returned in ascending order, so first/last access yields min/max values @@ -141,7 +141,7 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { const targetSelector = allEvents[0].topics[0]; const result = await request(AccountEventsFiltered, { - address: DevnetAccounts.deployer.address, + address: accounts.deployer.address, where: { selector_in: [targetSelector] }, }); const events = flattenConnection(result.account.events); @@ -154,7 +154,7 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { it("filters by selector_in with unknown topic returns no results", async () => { const result = await request(AccountEventsFiltered, { - address: DevnetAccounts.deployer.address, + address: accounts.deployer.address, where: { selector_in: ["0x0000000000000000000000000000000000000000000000000000000000000001"], }, @@ -165,7 +165,7 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { it("filters by empty selector_in returns no results", async () => { const result = await request(AccountEventsFiltered, { - address: DevnetAccounts.deployer.address, + address: accounts.deployer.address, where: { selector_in: [] }, }); const events = flattenConnection(result.account.events); @@ -176,7 +176,7 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { const midTimestamp = allEvents[Math.floor(allEvents.length / 2)].timestamp; const result = await request(AccountEventsFiltered, { - address: DevnetAccounts.deployer.address, + address: accounts.deployer.address, where: { timestamp_gte: midTimestamp }, }); const events = flattenConnection(result.account.events); @@ -192,7 +192,7 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { const midTimestamp = allEvents[Math.floor(allEvents.length / 2)].timestamp; const result = await request(AccountEventsFiltered, { - address: DevnetAccounts.deployer.address, + address: accounts.deployer.address, where: { timestamp_lte: midTimestamp }, }); const events = flattenConnection(result.account.events); @@ -209,7 +209,7 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { const maxTs = allEvents[allEvents.length - 1].timestamp; const result = await request(AccountEventsFiltered, { - address: DevnetAccounts.deployer.address, + address: accounts.deployer.address, where: { timestamp_gte: minTs, timestamp_lte: maxTs }, first: 1000, }); @@ -226,7 +226,7 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { const midTimestamp = seedEvent.timestamp; const result = await request(AccountEventsFiltered, { - address: DevnetAccounts.deployer.address, + address: accounts.deployer.address, where: { selector_in: [targetSelector], timestamp_gte: midTimestamp }, }); const events = flattenConnection(result.account.events); @@ -243,7 +243,7 @@ describe("Account.events filtering (AccountEventsWhereInput)", () => { const maxTimestamp = BigInt(allEvents[allEvents.length - 1].timestamp); const result = await request(AccountEventsFiltered, { - address: DevnetAccounts.deployer.address, + address: accounts.deployer.address, where: { timestamp_gte: (maxTimestamp + 1n).toString() }, }); const events = flattenConnection(result.account.events); diff --git a/apps/ensindexer/src/lib/ponder-helpers.ts b/apps/ensindexer/src/lib/ponder-helpers.ts index c03e259fa0..f5904b3722 100644 --- a/apps/ensindexer/src/lib/ponder-helpers.ts +++ b/apps/ensindexer/src/lib/ponder-helpers.ts @@ -11,7 +11,7 @@ import { type ContractConfig, type DatasourceName, type ENSNamespaceId, - ensTestEnvChain, + ENSNamespaceIds, maybeGetDatasource, } from "@ensnode/datasources"; import { type BlockNumberRange, buildBlockNumberRange, type ChainId } from "@ensnode/ponder-sdk"; @@ -96,6 +96,7 @@ export const constrainBlockrange = ( export function chainsConnectionConfig( rpcConfigs: ENSIndexerConfig["rpcConfigs"], chainId: ChainId, + namespace?: ENSNamespaceId, ) { const rpcConfig = rpcConfigs.get(chainId); @@ -105,8 +106,9 @@ export function chainsConnectionConfig( ); } - // NOTE: disable cache on ens-test-env - const disableCache = chainId === ensTestEnvChain.id; + // NOTE: disable cache on local chains (e.g. ganache, anvil, ens-test-env) + const disableCache = + chainId === 31337 || chainId === 1337 || namespace === ENSNamespaceIds.EnsTestEnv; return { [chainId.toString()]: { @@ -165,7 +167,7 @@ export function chainsConnectionConfigForDatasources( .reduce>( (memo, chain) => ({ ...memo, - ...chainsConnectionConfig(rpcConfigs, chain.id), + ...chainsConnectionConfig(rpcConfigs, chain.id, namespace), }), {}, ); diff --git a/apps/ensindexer/src/plugins/tokenscope/plugin.ts b/apps/ensindexer/src/plugins/tokenscope/plugin.ts index e844ca10e7..56708e3deb 100644 --- a/apps/ensindexer/src/plugins/tokenscope/plugin.ts +++ b/apps/ensindexer/src/plugins/tokenscope/plugin.ts @@ -55,12 +55,12 @@ export default createPlugin({ return ponder.createConfig({ chains: { - ...chainsConnectionConfig(config.rpcConfigs, seaport.chain.id), - ...chainsConnectionConfig(config.rpcConfigs, ensroot.chain.id), - ...chainsConnectionConfig(config.rpcConfigs, basenames.chain.id), - ...chainsConnectionConfig(config.rpcConfigs, lineanames.chain.id), - ...chainsConnectionConfig(config.rpcConfigs, threednsOptimism.chain.id), - ...chainsConnectionConfig(config.rpcConfigs, threednsBase.chain.id), + ...chainsConnectionConfig(config.rpcConfigs, seaport.chain.id, config.namespace), + ...chainsConnectionConfig(config.rpcConfigs, ensroot.chain.id, config.namespace), + ...chainsConnectionConfig(config.rpcConfigs, basenames.chain.id, config.namespace), + ...chainsConnectionConfig(config.rpcConfigs, lineanames.chain.id, config.namespace), + ...chainsConnectionConfig(config.rpcConfigs, threednsOptimism.chain.id, config.namespace), + ...chainsConnectionConfig(config.rpcConfigs, threednsBase.chain.id, config.namespace), }, contracts: { [namespaceContract(pluginName, "Seaport")]: { diff --git a/docker/services/devnet.yml b/docker/services/devnet.yml index 20b5cc36ba..f51260e2ba 100644 --- a/docker/services/devnet.yml +++ b/docker/services/devnet.yml @@ -1,15 +1,20 @@ services: devnet: container_name: devnet - image: ghcr.io/ensdomains/contracts-v2:main-580c60a - command: ./script/runDevnet.ts --testNames - pull_policy: always + build: + context: ../.. + dockerfile: packages/ens-test-kit/devnet/Dockerfile ports: - "8545:8545" environment: ANVIL_IP_ADDR: "0.0.0.0" + SEED_SENTINEL_PATH: "/tmp/ens-test-kit-seeded" healthcheck: - test: [ "CMD", "curl", "--fail", "-s", "http://localhost:8000/health" ] + test: + [ + "CMD-SHELL", + "test -f /tmp/ens-test-kit-seeded && curl --fail -s http://localhost:8000/health", + ] interval: 10s timeout: 5s retries: 5 diff --git a/packages/datasources/package.json b/packages/datasources/package.json index 9eb98c5545..2bfe527d91 100644 --- a/packages/datasources/package.json +++ b/packages/datasources/package.json @@ -18,14 +18,21 @@ "dist" ], "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./devnet": "./src/devnet/index.ts" }, "sideEffects": false, "publishConfig": { "access": "public", "exports": { - "default": "./dist/index.js", - "types": "./dist/index.d.ts" + ".": { + "default": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./devnet": { + "default": "./dist/devnet/index.js", + "types": "./dist/devnet/index.d.ts" + } }, "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -49,6 +56,7 @@ "vitest": "catalog:" }, "dependencies": { - "@ponder/utils": "^0.2.18" + "@ponder/utils": "^0.2.18", + "enssdk": "workspace:*" } } diff --git a/packages/datasources/src/abis/root/L2ReverseRegistrar.ts b/packages/datasources/src/abis/root/L2ReverseRegistrar.ts new file mode 100644 index 0000000000..1838b9764e --- /dev/null +++ b/packages/datasources/src/abis/root/L2ReverseRegistrar.ts @@ -0,0 +1,643 @@ +import type { Abi } from "viem"; + +export const L2ReverseRegistrar = [ + { + type: "constructor", + inputs: [ + { + name: "chainId", + type: "uint256", + internalType: "uint256", + }, + { + name: "label", + type: "string", + internalType: "string", + }, + ], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "CHAIN_ID", + inputs: [], + outputs: [ + { + name: "", + type: "uint256", + internalType: "uint256", + }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "PARENT_NODE", + inputs: [], + outputs: [ + { + name: "", + type: "bytes32", + internalType: "bytes32", + }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "inceptionOf", + inputs: [ + { + name: "addr", + type: "address", + internalType: "address", + }, + ], + outputs: [ + { + name: "inception", + type: "uint256", + internalType: "uint256", + }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "name", + inputs: [ + { + name: "node", + type: "bytes32", + internalType: "bytes32", + }, + ], + outputs: [ + { + name: "", + type: "string", + internalType: "string", + }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "nameForAddr", + inputs: [ + { + name: "addr", + type: "address", + internalType: "address", + }, + ], + outputs: [ + { + name: "", + type: "string", + internalType: "string", + }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "resolve", + inputs: [ + { + name: "name_", + type: "bytes", + internalType: "bytes", + }, + { + name: "data", + type: "bytes", + internalType: "bytes", + }, + ], + outputs: [ + { + name: "", + type: "bytes", + internalType: "bytes", + }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "setName", + inputs: [ + { + name: "name", + type: "string", + internalType: "string", + }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "setNameForAddr", + inputs: [ + { + name: "addr", + type: "address", + internalType: "address", + }, + { + name: "name", + type: "string", + internalType: "string", + }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "setNameForAddrWithSignature", + inputs: [ + { + name: "claim", + type: "tuple", + internalType: "struct IL2ReverseRegistrar.NameClaim", + components: [ + { + name: "name", + type: "string", + internalType: "string", + }, + { + name: "addr", + type: "address", + internalType: "address", + }, + { + name: "chainIds", + type: "uint256[]", + internalType: "uint256[]", + }, + { + name: "signedAt", + type: "uint256", + internalType: "uint256", + }, + ], + }, + { + name: "signature", + type: "bytes", + internalType: "bytes", + }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "setNameForOwnableWithSignature", + inputs: [ + { + name: "claim", + type: "tuple", + internalType: "struct IL2ReverseRegistrar.NameClaim", + components: [ + { + name: "name", + type: "string", + internalType: "string", + }, + { + name: "addr", + type: "address", + internalType: "address", + }, + { + name: "chainIds", + type: "uint256[]", + internalType: "uint256[]", + }, + { + name: "signedAt", + type: "uint256", + internalType: "uint256", + }, + ], + }, + { + name: "owner", + type: "address", + internalType: "address", + }, + { + name: "signature", + type: "bytes", + internalType: "bytes", + }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "supportsInterface", + inputs: [ + { + name: "interfaceID", + type: "bytes4", + internalType: "bytes4", + }, + ], + outputs: [ + { + name: "", + type: "bool", + internalType: "bool", + }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "syncName", + inputs: [ + { + name: "addr", + type: "address", + internalType: "address", + }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "event", + name: "ExpiryUpdated", + inputs: [ + { + name: "tokenId", + type: "uint256", + indexed: true, + internalType: "uint256", + }, + { + name: "newExpiry", + type: "uint64", + indexed: true, + internalType: "uint64", + }, + { + name: "sender", + type: "address", + indexed: true, + internalType: "address", + }, + ], + anonymous: false, + }, + { + type: "event", + name: "LabelRegistered", + inputs: [ + { + name: "tokenId", + type: "uint256", + indexed: true, + internalType: "uint256", + }, + { + name: "labelHash", + type: "bytes32", + indexed: true, + internalType: "bytes32", + }, + { + name: "label", + type: "string", + indexed: false, + internalType: "string", + }, + { + name: "owner", + type: "address", + indexed: false, + internalType: "address", + }, + { + name: "expiry", + type: "uint64", + indexed: false, + internalType: "uint64", + }, + { + name: "sender", + type: "address", + indexed: true, + internalType: "address", + }, + ], + anonymous: false, + }, + { + type: "event", + name: "LabelReserved", + inputs: [ + { + name: "tokenId", + type: "uint256", + indexed: true, + internalType: "uint256", + }, + { + name: "labelHash", + type: "bytes32", + indexed: true, + internalType: "bytes32", + }, + { + name: "label", + type: "string", + indexed: false, + internalType: "string", + }, + { + name: "expiry", + type: "uint64", + indexed: false, + internalType: "uint64", + }, + { + name: "sender", + type: "address", + indexed: true, + internalType: "address", + }, + ], + anonymous: false, + }, + { + type: "event", + name: "LabelUnregistered", + inputs: [ + { + name: "tokenId", + type: "uint256", + indexed: true, + internalType: "uint256", + }, + { + name: "sender", + type: "address", + indexed: true, + internalType: "address", + }, + ], + anonymous: false, + }, + { + type: "event", + name: "NameChanged", + inputs: [ + { + name: "node", + type: "bytes32", + indexed: true, + internalType: "bytes32", + }, + { + name: "name", + type: "string", + indexed: false, + internalType: "string", + }, + ], + anonymous: false, + }, + { + type: "event", + name: "NameForAddrChanged", + inputs: [ + { + name: "addr", + type: "address", + indexed: true, + internalType: "address", + }, + { + name: "name", + type: "string", + indexed: false, + internalType: "string", + }, + ], + anonymous: false, + }, + { + type: "event", + name: "ParentUpdated", + inputs: [ + { + name: "parent", + type: "address", + indexed: true, + internalType: "contract IRegistry", + }, + { + name: "label", + type: "string", + indexed: false, + internalType: "string", + }, + { + name: "sender", + type: "address", + indexed: true, + internalType: "address", + }, + ], + anonymous: false, + }, + { + type: "event", + name: "ResolverUpdated", + inputs: [ + { + name: "tokenId", + type: "uint256", + indexed: true, + internalType: "uint256", + }, + { + name: "resolver", + type: "address", + indexed: true, + internalType: "address", + }, + { + name: "sender", + type: "address", + indexed: true, + internalType: "address", + }, + ], + anonymous: false, + }, + { + type: "event", + name: "SubregistryUpdated", + inputs: [ + { + name: "tokenId", + type: "uint256", + indexed: true, + internalType: "uint256", + }, + { + name: "subregistry", + type: "address", + indexed: true, + internalType: "contract IRegistry", + }, + { + name: "sender", + type: "address", + indexed: true, + internalType: "address", + }, + ], + anonymous: false, + }, + { + type: "event", + name: "TokenRegenerated", + inputs: [ + { + name: "oldTokenId", + type: "uint256", + indexed: true, + internalType: "uint256", + }, + { + name: "newTokenId", + type: "uint256", + indexed: true, + internalType: "uint256", + }, + ], + anonymous: false, + }, + { + type: "error", + name: "ChainIdsNotAscending", + inputs: [], + }, + { + type: "error", + name: "CurrentChainNotFound", + inputs: [ + { + name: "chainId", + type: "uint256", + internalType: "uint256", + }, + ], + }, + { + type: "error", + name: "InvalidSignature", + inputs: [], + }, + { + type: "error", + name: "LabelIsEmpty", + inputs: [], + }, + { + type: "error", + name: "LabelIsTooLong", + inputs: [ + { + name: "label", + type: "string", + internalType: "string", + }, + ], + }, + { + type: "error", + name: "NotOwnerOfContract", + inputs: [], + }, + { + type: "error", + name: "SignatureNotValidYet", + inputs: [ + { + name: "signedAt", + type: "uint256", + internalType: "uint256", + }, + { + name: "currentTime", + type: "uint256", + internalType: "uint256", + }, + ], + }, + { + type: "error", + name: "StaleSignature", + inputs: [ + { + name: "signedAt", + type: "uint256", + internalType: "uint256", + }, + { + name: "inception", + type: "uint256", + internalType: "uint256", + }, + ], + }, + { + type: "error", + name: "TimestampOutOfRange", + inputs: [ + { + name: "timestamp", + type: "uint256", + internalType: "uint256", + }, + ], + }, + { + type: "error", + name: "Unauthorized", + inputs: [], + }, + { + type: "error", + name: "UnreachableName", + inputs: [ + { + name: "name", + type: "bytes", + internalType: "bytes", + }, + ], + }, + { + type: "error", + name: "UnsupportedResolverProfile", + inputs: [ + { + name: "selector", + type: "bytes4", + internalType: "bytes4", + }, + ], + }, +] as const satisfies Abi; diff --git a/packages/datasources/src/devnet/constants.ts b/packages/datasources/src/devnet/constants.ts new file mode 100644 index 0000000000..e3acb523c8 --- /dev/null +++ b/packages/datasources/src/devnet/constants.ts @@ -0,0 +1,127 @@ +import type { NormalizedAddress } from "enssdk"; +import { toNormalizedAddress } from "enssdk"; +import type { Address, Hex } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; + +/** + * Deterministic contract addresses for the ENS contracts-v2 devnet used by ens-test-env. + * Source of truth is the devnet deployment used by this repository's test harness and compose setup: + * @see https://github.com/ensdomains/contracts-v2 + * @see https://github.com/ensdomains/ens-test-env + */ +export const contracts = { + // -- DNS -- + dnssecGatewayProvider: "0x5fbdb2315678afecb367f032d93f642f64180aa3", + dnsTxtResolver: "0xe7f1725e7734ce288f8367e1bb143e90bb3f0512", + dnsAliasResolver: "0x322813fd9a801c5507c9de605d63cea4f2ce6c44", + dnsTldResolver: "0x998abeb3e57409262ae5b751f60747921b33613e", + offchainDnsResolver: "0x851356ae760d987e095750cceb3bc6014560891c", + simplePublicSuffixList: "0xf5059a5d33d5853360d16c683c16e67980206f36", + dnsRegistrar: "0x202cce504e04bed6fc0521238ddf04bc9e8e15ab", + extendedDnsResolver: "0x4631bcabd6df18d94796344963cb60d44a4136b6", + + // -- Registries -- + legacyEnsRegistry: "0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0", + ensRegistry: "0xcf7ed3acca5a467e9e704c703e8d87f634fb0fc9", + rootRegistry: "0x2279b7a0a67db372996a5fab50d91eaa73d2ebe6", + ethRegistry: "0x8f86403a4de0bb5791fa46b8e795c547942fe4cf", + reverseRegistry: "0xcd8a1c3ba11cf5ecfa6267617243239504a98d90", + + // -- Registrars & Controllers -- + baseRegistrar: "0x8a791620dd6260079bf849dc5567adc3f2fdc318", + ethRegistrar: "0x4c4a2f8c81640e47606d3fd77b353e87ba015584", + legacyEthRegistrarController: "0xfbc22278a96299d91d41c453234d97b4f5eb9b2d", + wrappedEthRegistrarController: "0x253553366da8546fc250f225fe3d25d0c782303b", + ethRegistrarController: "0x1c85638e118b37167e9298c2268758e058ddfda0", + batchRegistrar: "0xd8a5a9b31c3c0232e196d518e89fd8bf83acad43", + + // -- Reverse Resolution -- + ethReverseRegistrar: "0x59b670e9fa9d0a427751af201d676719a970857b", + defaultReverseRegistrar: "0x4c5859f0f772848b2d91f1d83e2fe57935348029", + defaultReverseResolver: "0x5f3f1dbd7b74c6b46e8c44f98792a1daf8d69154", + ethReverseResolver: "0x7bc06c482dead17c0e297afbc32f6e63d3846650", + reverseRegistrar: "0x162a433068f51e18b7d13932f27e66a3f99e6890", + l2ReverseRegistrar: "0x49fd2be640db2910c2fab69bb8531ab6e76127ff", + + // -- Resolvers -- + ensv1Resolver: "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707", + ensv2Resolver: "0xc6e7df5e7b4f2a278906862b61205850344d4e7d", + ownedResolver: "0x68b1d87f95878fe05b998f19b66f4baba5de1aed", + permissionedResolver: "0x5ea90acf6555276660760fe629d72932c91f4b8e", + legacyPublicResolver: "0x86a2ee8faf9a840f7a2c64ca3d51209f9a02081d", + publicResolver: "0xa4899d35897033b927acfcf422bc745916139776", + permissionedResolverImpl: "0x809d550fca64d94bd9f66e60752a544199cfac3d", + universalResolver: "0x5067457698fd6fa1c6964e416b3f42713513b3dd", + universalResolverV2: "0x8198f5d8f8cffe8f9c413d98a0a55aeb8ab9fbb7", + upgradableUniversalResolverProxy: "0x0355b7b8cb128fa5692729ab3aaa199c1753f726", + + // -- L2 Reverse Resolvers -- + arbitrumReverseResolver: "0xf953b3a269d80e3eb0f2947630da976b896a8c5b", + baseReverseResolver: "0xaa292e8611adf267e563f334ee42320ac96d0463", + lineaReverseResolver: "0x5c74c94173f05da1720953407cbb920f3df9f887", + optimismReverseResolver: "0x720472c8ce72c2a2d711333e064abd3e6bbeadd3", + scrollReverseResolver: "0xe8d2a1e88c91dcd5433208d4152cc4f399a7e91d", + + // -- Infrastructure -- + batchGatewayProvider: "0xdc64a140aa3e981100a9beca4e685f962f0cf6c9", + hcaFactory: "0x0165878a594ca255338adfa4d48449f69242eb8f", + simpleRegistryMetadata: "0xa513e6e4b8f2a923d98304ec87f64353c4d5c853", + root: "0x610178da211fef7d417bc0e6fed39f05609ad788", + rootSecurityController: "0xb7f8bc63bbcad18155201308c8f3540b07f84f5e", + registrarSecurityController: "0x0b306bf915c4d645ff596e518faf3f9669b97016", + verifiableFactory: "0x4ed7c70f96b99c776995fb64377f0d4ab3b0e1c1", + nameWrapper: "0x5081a39b8a5f0e35a8d959395a630b68b74dd30f", + unlockedMigrationController: "0xdbc43ba45381e02825b14322cddd15ec4b3164e6", + wrapperRegistry: "0x2e2ed0cfd3ad2f1d34481277b3204d807ca2f8c2", + lockedMigrationController: "0x51a1ceb83b83f1985a81c295d1ff28afef186e02", + userRegistry: "0x7969c5ed335650692bc04293b07f5bf2e7a673c0", + staticMetadataService: "0xb0d4afd8879ed9f52b28595d31b441d079b2ca07", + multicall3: "0xca11bde05977b3631167028862be2a173976ca11", + migrationHelper: "0x18e317a7d70d8fbf8e6e893616b52390ebbdb629", + + // -- DNSSEC Algorithms & Digests -- + rsasha1Algorithm: "0xa85233c63b9ee964add6f2cffe00fd84eb32338f", + rsasha256Algorithm: "0x4a679253410272dd5232b3ff7cf5dbb88f295319", + p256sha256Algorithm: "0x7a2088a1bfc9d81c55368ae168c2c02570cb814f", + sha1Digest: "0x09635f643e140090a9a8dcd712ed6285858cebef", + sha256Digest: "0xc5a5c42992decbae36851359345fe25997f5c42d", + dnssecImpl: "0x67d269191c92caf3cd7723f116c85e6e9bf55933", + + // -- Pricing -- + standardRentPriceOracle: "0x1429859428c0abc9c2c47c8ee9fbaf82cfa0f20f", + staticBulkRenewal: "0x4c2f7092c2ae51d986befee378e50bd4db99c901", + dummyOracle: "0xd84379ceae14aa33c123af12424a37803f885889", + exponentialPremiumPriceOracle: "0x2b0d36facd61b71cc05ab8f3d2355ec3631c0dd5", + + // -- Mock Tokens -- + mockUsdc: "0xfd471836031dc5108809d173a067e8486b9047a3", + mockDai: "0xcbeaf3bde82155f56486fb5a1072cb8baaf547cc", +} as const satisfies Record; + +const mnemonic = "test test test test test test test test test test test junk"; + +function createAccount(addressIndex: number) { + const account = mnemonicToAccount(mnemonic, { addressIndex }); + return { ...account, address: toNormalizedAddress(account.address) }; +} + +export const accounts = { + deployer: createAccount(0), + owner: createAccount(1), + user: createAccount(2), + user2: createAccount(3), +} as const; + +export const addresses = { + one: toNormalizedAddress(`0x${"1".repeat(40)}` as Address), +} as const satisfies Record; + +export const fixtures = { + abiBytes: `0x${"01".repeat(32)}`, + fourBytesInterface: "0x11100111", + publicKeyX: `0x${"02".repeat(32)}`, + publicKeyY: `0x${"03".repeat(32)}`, + contenthash: `0x${"04".repeat(32)}`, + bitcoinAddress: `0x${"05".repeat(25)}`, + litecoinAddress: `0x${"06".repeat(25)}`, +} as const satisfies Record; diff --git a/packages/datasources/src/devnet/index.ts b/packages/datasources/src/devnet/index.ts new file mode 100644 index 0000000000..b04bfcf75e --- /dev/null +++ b/packages/datasources/src/devnet/index.ts @@ -0,0 +1 @@ +export * from "./constants"; diff --git a/packages/datasources/src/ens-test-env.ts b/packages/datasources/src/devnet/namespace.ts similarity index 65% rename from packages/datasources/src/ens-test-env.ts rename to packages/datasources/src/devnet/namespace.ts index cdde9805c5..692a2dac1e 100644 --- a/packages/datasources/src/ens-test-env.ts +++ b/packages/datasources/src/devnet/namespace.ts @@ -1,24 +1,25 @@ import { zeroAddress } from "viem"; -import { EnhancedAccessControl } from "./abis/ensv2/EnhancedAccessControl"; -import { ETHRegistrar } from "./abis/ensv2/ETHRegistrar"; -import { Registry } from "./abis/ensv2/Registry"; -import { UniversalResolverV2 } from "./abis/ensv2/UniversalResolverV2"; +import { EnhancedAccessControl } from "../abis/ensv2/EnhancedAccessControl"; +import { ETHRegistrar } from "../abis/ensv2/ETHRegistrar"; +import { Registry } from "../abis/ensv2/Registry"; +import { UniversalResolverV2 } from "../abis/ensv2/UniversalResolverV2"; // ABIs for ENSRoot Datasource -import { BaseRegistrar as root_BaseRegistrar } from "./abis/root/BaseRegistrar"; -import { LegacyEthRegistrarController as root_LegacyEthRegistrarController } from "./abis/root/LegacyEthRegistrarController"; -import { NameWrapper as root_NameWrapper } from "./abis/root/NameWrapper"; -import { Registry as root_Registry } from "./abis/root/Registry"; -import { UniversalRegistrarRenewalWithReferrer as root_UniversalRegistrarRenewalWithReferrer } from "./abis/root/UniversalRegistrarRenewalWithReferrer"; -import { UniversalResolverV1 } from "./abis/root/UniversalResolverV1"; -import { UnwrappedEthRegistrarController as root_UnwrappedEthRegistrarController } from "./abis/root/UnwrappedEthRegistrarController"; -import { WrappedEthRegistrarController as root_WrappedEthRegistrarController } from "./abis/root/WrappedEthRegistrarController"; -import { StandaloneReverseRegistrar } from "./abis/shared/StandaloneReverseRegistrar"; -import { ensTestEnvChain } from "./lib/chains"; +import { BaseRegistrar as root_BaseRegistrar } from "../abis/root/BaseRegistrar"; +import { LegacyEthRegistrarController as root_LegacyEthRegistrarController } from "../abis/root/LegacyEthRegistrarController"; +import { NameWrapper as root_NameWrapper } from "../abis/root/NameWrapper"; +import { Registry as root_Registry } from "../abis/root/Registry"; +import { UniversalRegistrarRenewalWithReferrer as root_UniversalRegistrarRenewalWithReferrer } from "../abis/root/UniversalRegistrarRenewalWithReferrer"; +import { UniversalResolverV1 } from "../abis/root/UniversalResolverV1"; +import { UnwrappedEthRegistrarController as root_UnwrappedEthRegistrarController } from "../abis/root/UnwrappedEthRegistrarController"; +import { WrappedEthRegistrarController as root_WrappedEthRegistrarController } from "../abis/root/WrappedEthRegistrarController"; +import { StandaloneReverseRegistrar } from "../abis/shared/StandaloneReverseRegistrar"; +import { ensTestEnvChain } from "../lib/chains"; // Shared ABIs -import { ResolverABI } from "./lib/ResolverABI"; +import { ResolverABI } from "../lib/ResolverABI"; // Types -import { DatasourceNames, type ENSNamespace } from "./lib/types"; +import { DatasourceNames, type ENSNamespace } from "../lib/types"; +import { contracts } from "./constants"; /** * The ens-test-env ENSNamespace @@ -45,13 +46,13 @@ export default { // NOTE: named LegacyENSRegistry in devnet ENSv1RegistryOld: { abi: root_Registry, // Registry was redeployed, same abi - address: "0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0", + address: contracts.legacyEnsRegistry, startBlock: 0, }, // NOTE: named ENSRegistry in devnet ENSv1Registry: { abi: root_Registry, // Registry was redeployed, same abi - address: "0xcf7ed3acca5a467e9e704c703e8d87f634fb0fc9", + address: contracts.ensRegistry, startBlock: 0, }, Resolver: { @@ -61,25 +62,25 @@ export default { // NOTE: named BaseRegistrarImplementation in devnet BaseRegistrar: { abi: root_BaseRegistrar, - address: "0x8a791620dd6260079bf849dc5567adc3f2fdc318", + address: contracts.baseRegistrar, startBlock: 0, }, // NOTE: named LegacyETHRegistrarController in devnet LegacyEthRegistrarController: { abi: root_LegacyEthRegistrarController, - address: "0x46b142dd1e924fab83ecc3c08e4d46e82f005e0e", + address: contracts.legacyEthRegistrarController, startBlock: 0, }, // NOTE: named WrappedETHRegistrarController in devnet WrappedEthRegistrarController: { abi: root_WrappedEthRegistrarController, - address: "0x253553366da8546fc250f225fe3d25d0c782303b", + address: contracts.wrappedEthRegistrarController, startBlock: 0, }, // NOTE: named ETHRegistrarController in devnet UnwrappedEthRegistrarController: { abi: root_UnwrappedEthRegistrarController, - address: "0x367761085bf3c12e5da2df99ac6e1a824612b8fb", + address: contracts.ethRegistrarController, startBlock: 0, }, // NOTE: not in devnet, set to zeroAddress @@ -90,18 +91,18 @@ export default { }, NameWrapper: { abi: root_NameWrapper, - address: "0x5081a39b8a5f0e35a8d959395a630b68b74dd30f", + address: contracts.nameWrapper, startBlock: 0, }, UniversalResolver: { abi: UniversalResolverV1, - address: "0xaa292e8611adf267e563f334ee42320ac96d0463", + address: contracts.universalResolver, startBlock: 0, }, // NOTE: named UniversalResolverV2 in devnet UniversalResolverV2: { abi: UniversalResolverV2, - address: "0x0355b7b8cb128fa5692729ab3aaa199c1753f726", + address: contracts.universalResolverV2, startBlock: 0, }, }, @@ -115,17 +116,17 @@ export default { EnhancedAccessControl: { abi: EnhancedAccessControl, startBlock: 0 }, RootRegistry: { abi: Registry, - address: "0x2279b7a0a67db372996a5fab50d91eaa73d2ebe6", + address: contracts.rootRegistry, startBlock: 0, }, ETHRegistry: { abi: Registry, - address: "0x8f86403a4de0bb5791fa46b8e795c547942fe4cf", + address: contracts.ethRegistry, startBlock: 0, }, ETHRegistrar: { abi: ETHRegistrar, - address: "0x21df544947ba3e8b3c32561399e88b52dc8b2823", + address: contracts.ethRegistrar, startBlock: 0, }, ENSv1Resolver: { @@ -141,28 +142,28 @@ export default { contracts: { DefaultReverseRegistrar: { abi: StandaloneReverseRegistrar, - address: "0x4c5859f0f772848b2d91f1d83e2fe57935348029", + address: contracts.defaultReverseRegistrar, startBlock: 0, }, // NOTE: named DefaultReverseResolver in devnet DefaultReverseResolver3: { abi: ResolverABI, - address: "0x5f3f1dbd7b74c6b46e8c44f98792a1daf8d69154", + address: contracts.defaultReverseResolver, startBlock: 0, }, // NOTE: named LegacyPublicResolver in devnet DefaultPublicResolver4: { abi: ResolverABI, - address: "0xa4899d35897033b927acfcf422bc745916139776", + address: contracts.legacyPublicResolver, startBlock: 0, }, // NOTE: named PublicResolver in devnet DefaultPublicResolver5: { abi: ResolverABI, - address: "0xf953b3a269d80e3eb0f2947630da976b896a8c5b", + address: contracts.publicResolver, startBlock: 0, }, }, diff --git a/packages/datasources/src/index.ts b/packages/datasources/src/index.ts index dd6d5c1672..37319822b5 100644 --- a/packages/datasources/src/index.ts +++ b/packages/datasources/src/index.ts @@ -1,6 +1,7 @@ export { EnhancedAccessControl as EnhancedAccessControlABI } from "./abis/ensv2/EnhancedAccessControl"; export { ETHRegistrar as ETHRegistrarABI } from "./abis/ensv2/ETHRegistrar"; export { Registry as RegistryABI } from "./abis/ensv2/Registry"; +export { L2ReverseRegistrar as L2ReverseRegistrarABI } from "./abis/root/L2ReverseRegistrar"; export { StandaloneReverseRegistrar as StandaloneReverseRegistrarABI } from "./abis/shared/StandaloneReverseRegistrar"; export { UniversalResolverABI } from "./abis/shared/UniversalResolver"; export { ThreeDNSToken as ThreeDNSTokenABI } from "./abis/threedns/ThreeDNSToken"; diff --git a/packages/datasources/src/lib/chains.ts b/packages/datasources/src/lib/chains.ts index 48eac61a4f..f4dab981d6 100644 --- a/packages/datasources/src/lib/chains.ts +++ b/packages/datasources/src/lib/chains.ts @@ -1,11 +1,15 @@ -import { anvil, type Chain, sepolia } from "viem/chains"; +import { type Chain, localhost, sepolia } from "viem/chains"; + +/** + * The ens-test-env chain id is 1: + * @see https://github.com/ensdomains/contracts-v2/blob/9f26a8f01f1f87db1c5d57b9faa8e76f0c5043ef/contracts/script/setup.ts#L91 + */ export const ensTestEnvChain = { - ...anvil, - // NOTE: devnet uses anvil's default chain id of 31337, but we over-specify it here for documentation - // https://github.com/ensdomains/contracts-v2/blob/580c60a20e80decce21cf15aafd762f96a96d544/contracts/script/setup.ts#L55 - id: 31337, + ...localhost, + id: 1, name: "ens-test-env", + rpcUrls: { default: { http: ["http://localhost:8545"] } }, } as const satisfies Chain; /** diff --git a/packages/datasources/src/namespaces.ts b/packages/datasources/src/namespaces.ts index 2eeba45f82..05f9491b10 100644 --- a/packages/datasources/src/namespaces.ts +++ b/packages/datasources/src/namespaces.ts @@ -1,4 +1,4 @@ -import ensTestEnv from "./ens-test-env"; +import ensTestEnv from "./devnet/namespace"; import { type DatasourceName, DatasourceNames, diff --git a/packages/datasources/tsup.config.ts b/packages/datasources/tsup.config.ts index 8fe92ca937..370bf11678 100644 --- a/packages/datasources/tsup.config.ts +++ b/packages/datasources/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/devnet/index.ts"], platform: "browser", format: ["esm"], target: "es2022", diff --git a/packages/ens-test-kit/README.md b/packages/ens-test-kit/README.md new file mode 100644 index 0000000000..23d105e7d8 --- /dev/null +++ b/packages/ens-test-kit/README.md @@ -0,0 +1,13 @@ +# `@ensnode/ens-test-kit` + +Declarative ENS testing primitives and fixtures for ENSNode. + +This package currently ships the initial type contracts and module layout for: + +- API interfaces (`ResolutionsApi`, `DomainsApi`, `AccountsApi`, `ResolversApi`) +- Shared testing types (`Domain`, `Account`, `Resolver`, `Registration`, and related aliases) +- Case types (`TestCase`) +- Seeder fixture type contracts +- Vitest and CLI type entrypoints + +Runtime seeding and test-runner behavior will be added in follow-up steps. diff --git a/packages/ens-test-kit/bin/ens-test-kit.mjs b/packages/ens-test-kit/bin/ens-test-kit.mjs new file mode 100755 index 0000000000..ad064d51e0 --- /dev/null +++ b/packages/ens-test-kit/bin/ens-test-kit.mjs @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +import("../dist/cli/seed.js") + .then(({ runSeedCli }) => runSeedCli(process.argv.slice(2))) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[ens-test-kit] ${message}`); + process.exit(1); + }); diff --git a/packages/ens-test-kit/devnet/Dockerfile b/packages/ens-test-kit/devnet/Dockerfile new file mode 100644 index 0000000000..6ed24b7aeb --- /dev/null +++ b/packages/ens-test-kit/devnet/Dockerfile @@ -0,0 +1,37 @@ +FROM node:24-slim AS build +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable +WORKDIR /workspace + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY patches ./patches +COPY packages/ens-test-kit ./packages/ens-test-kit +COPY packages/datasources ./packages/datasources +COPY packages/enssdk ./packages/enssdk +COPY packages/shared-configs ./packages/shared-configs + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile +RUN pnpm -F @ensnode/ens-test-kit build + +FROM ghcr.io/ensdomains/contracts-v2:main-9f26a8f AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl nodejs \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app/contracts + +COPY --from=build /workspace/node_modules /workspace/node_modules +COPY --from=build /workspace/packages/ens-test-kit /workspace/packages/ens-test-kit +COPY --from=build /workspace/packages/datasources /workspace/packages/datasources +COPY --from=build /workspace/packages/enssdk /workspace/packages/enssdk +COPY packages/ens-test-kit/devnet/entrypoint.sh /usr/local/bin/ens-test-kit-entrypoint.sh + +RUN chmod +x /usr/local/bin/ens-test-kit-entrypoint.sh + +ENV ANVIL_IP_ADDR=0.0.0.0 +ENV DEVNET_RPC_URL=http://localhost:8545 +ENV SEED_SENTINEL_PATH=/tmp/ens-test-kit-seeded + +ENTRYPOINT ["/usr/local/bin/ens-test-kit-entrypoint.sh"] diff --git a/packages/ens-test-kit/devnet/entrypoint.sh b/packages/ens-test-kit/devnet/entrypoint.sh new file mode 100644 index 0000000000..570415c9c6 --- /dev/null +++ b/packages/ens-test-kit/devnet/entrypoint.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +SEED_SENTINEL_PATH="${SEED_SENTINEL_PATH:-/tmp/ens-test-kit-seeded}" +DEVNET_RPC_URL="${DEVNET_RPC_URL:-http://localhost:8545}" + +rm -f "$SEED_SENTINEL_PATH" + +bun ./script/runDevnet.ts --testNames & +DEVNET_PID=$! + +cleanup() { + if kill -0 "$DEVNET_PID" 2>/dev/null; then + kill "$DEVNET_PID" || true + fi +} + +trap cleanup INT TERM + +echo "[entrypoint] Waiting for Anvil JSON-RPC at ${DEVNET_RPC_URL}..." +ANVIL_READY=0 +for _ in $(seq 1 120); do + if curl -s -X POST "$DEVNET_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + >/dev/null; then + ANVIL_READY=1 + break + fi + sleep 1 +done + +if [[ "$ANVIL_READY" -ne 1 ]]; then + echo "[entrypoint] Anvil did not become ready in time" >&2 + exit 1 +fi + +echo "[entrypoint] Waiting for contracts-v2 health endpoint..." +DEVNET_HEALTHY=0 +for _ in $(seq 1 120); do + if curl --fail -s http://localhost:8000/health >/dev/null; then + DEVNET_HEALTHY=1 + break + fi + sleep 1 +done + +if [[ "$DEVNET_HEALTHY" -ne 1 ]]; then + echo "[entrypoint] contracts-v2 health endpoint did not become ready in time" >&2 + exit 1 +fi + +echo "[entrypoint] Seeding devnet..." +bun /workspace/packages/ens-test-kit/bin/ens-test-kit.mjs seed --rpc "$DEVNET_RPC_URL" --fixtures canonical +touch "$SEED_SENTINEL_PATH" +echo "[entrypoint] Devnet seeded" + +wait "$DEVNET_PID" diff --git a/packages/ens-test-kit/package.json b/packages/ens-test-kit/package.json new file mode 100644 index 0000000000..5139db2caf --- /dev/null +++ b/packages/ens-test-kit/package.json @@ -0,0 +1,91 @@ +{ + "name": "@ensnode/ens-test-kit", + "version": "1.10.1", + "type": "module", + "description": "Declarative ENS testing primitives and fixtures for ENSNode", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/namehash/ensnode.git", + "directory": "packages/ens-test-kit" + }, + "homepage": "https://github.com/namehash/ensnode/tree/main/packages/ens-test-kit", + "keywords": [ + "ENS", + "ENSNode", + "testing" + ], + "files": [ + "dist", + "bin" + ], + "bin": { + "ens-test-kit": "./bin/ens-test-kit.mjs" + }, + "exports": { + ".": "./src/index.ts", + "./interfaces": "./src/interfaces/index.ts", + "./types": "./src/types/index.ts", + "./cases": "./src/cases/index.ts", + "./seeder": "./src/seeder/index.ts", + "./vitest": "./src/vitest/index.ts", + "./cli": "./src/cli/index.ts" + }, + "sideEffects": false, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "default": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./interfaces": { + "default": "./dist/interfaces/index.js", + "types": "./dist/interfaces/index.d.ts" + }, + "./types": { + "default": "./dist/types/index.js", + "types": "./dist/types/index.d.ts" + }, + "./cases": { + "default": "./dist/cases/index.js", + "types": "./dist/cases/index.d.ts" + }, + "./seeder": { + "default": "./dist/seeder/index.js", + "types": "./dist/seeder/index.d.ts" + }, + "./vitest": { + "default": "./dist/vitest/index.js", + "types": "./dist/vitest/index.d.ts" + }, + "./cli": { + "default": "./dist/cli/index.js", + "types": "./dist/cli/index.d.ts" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts" + }, + "scripts": { + "build": "tsup", + "prepublish": "tsup", + "lint": "biome check --write .", + "lint:ci": "biome ci", + "test": "vitest", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@ensnode/datasources": "workspace:*", + "viem": "catalog:" + }, + "devDependencies": { + "@ensnode/shared-configs": "workspace:*", + "@types/node": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "viem": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/ens-test-kit/src/cases/expectation.ts b/packages/ens-test-kit/src/cases/expectation.ts new file mode 100644 index 0000000000..b201ff0d20 --- /dev/null +++ b/packages/ens-test-kit/src/cases/expectation.ts @@ -0,0 +1,24 @@ +export const EXPECTATION = Symbol.for("ens-test-kit.expectation"); + +export type Expectation = + | { [EXPECTATION]: "partial"; value: unknown } + | { [EXPECTATION]: "equals"; value: unknown } + | { [EXPECTATION]: "arrayContains"; items: unknown[] }; + +export const expectation = { + partial(value: unknown): Expectation { + return { [EXPECTATION]: "partial", value }; + }, + equals(value: unknown): Expectation { + return { [EXPECTATION]: "equals", value }; + }, + arrayContains(...items: unknown[]): Expectation { + return { [EXPECTATION]: "arrayContains", items }; + }, +}; + +export function isExpectation(value: unknown): value is Expectation { + if (typeof value !== "object" || value === null) return false; + const marker = (value as Record)[EXPECTATION]; + return marker === "partial" || marker === "equals" || marker === "arrayContains"; +} diff --git a/packages/ens-test-kit/src/cases/index.ts b/packages/ens-test-kit/src/cases/index.ts new file mode 100644 index 0000000000..60f3b26c8a --- /dev/null +++ b/packages/ens-test-kit/src/cases/index.ts @@ -0,0 +1,5 @@ +export type { Expectation } from "./expectation"; +export { EXPECTATION, expectation, isExpectation } from "./expectation"; +export { forwardResolutionCases } from "./resolution/forward"; +export { reverseResolutionCases } from "./resolution/reverse"; +export type { TestCase } from "./types"; diff --git a/packages/ens-test-kit/src/cases/resolution/forward.ts b/packages/ens-test-kit/src/cases/resolution/forward.ts new file mode 100644 index 0000000000..51ace836d9 --- /dev/null +++ b/packages/ens-test-kit/src/cases/resolution/forward.ts @@ -0,0 +1,175 @@ +import { accounts, addresses, fixtures } from "@ensnode/datasources/devnet"; + +import type { ResolutionsApi } from "../../interfaces/resolutions"; +import { + abiRecord, + contenthashRecord, + interfaceRecord, + multicoinAddressRecord, + pubkeyRecord, + textRecord, +} from "../../seeder"; +import type { TestCase } from "../types"; + +const textAvatar = textRecord({ + id: "resolution-forward-text-avatar", + name: "test.eth", + key: "avatar", + value: "https://example.com/avatar.png", +}); +const textTwitter = textRecord({ + id: "resolution-forward-text-com-twitter", + name: "test.eth", + key: "com.twitter", + value: "ensdomains", +}); +const textGithub = textRecord({ + id: "resolution-forward-text-com-github", + name: "test.eth", + key: "com.github", + value: "ensdomains", +}); +const textUrl = textRecord({ + id: "resolution-forward-text-url", + name: "test.eth", + key: "url", + value: "https://ens.domains", +}); +const textEmail = textRecord({ + id: "resolution-forward-text-email", + name: "test.eth", + key: "email", + value: "test@ens.domains", +}); +const textDescription = textRecord({ + id: "resolution-forward-text-description", + name: "test.eth", + key: "description", + value: "test.eth", +}); +const btcAddress = multicoinAddressRecord({ + id: "resolution-forward-address-btc", + name: "test.eth", + coinType: 0, + value: fixtures.bitcoinAddress, +}); +const ltcAddress = multicoinAddressRecord({ + id: "resolution-forward-address-ltc", + name: "test.eth", + coinType: 2, + value: fixtures.litecoinAddress, +}); +const testEthContenthash = contenthashRecord({ + id: "resolution-forward-contenthash", + name: "test.eth", + value: fixtures.contenthash, +}); +const testEthPubkey = pubkeyRecord({ + id: "resolution-forward-pubkey", + name: "test.eth", + x: fixtures.publicKeyX, + y: fixtures.publicKeyY, +}); +const testEthAbi = abiRecord({ + id: "resolution-forward-abi-content-type-1", + name: "test.eth", + contentType: 1, + value: fixtures.abiBytes, +}); +const testEthInterface = interfaceRecord({ + id: "resolution-forward-interface-record", + name: "test.eth", + interfaceId: fixtures.fourBytesInterface, + value: addresses.one, +}); + +export const forwardResolutionCases: TestCase[] = [ + { + id: "resolution.forward.text-avatar", + description: "resolves avatar text record for test.eth", + fixtures: [textAvatar], + call: (api) => api.resolveRecords("test.eth", { texts: [textAvatar.key] }), + expected: { texts: { [textAvatar.key]: textAvatar.value } }, + }, + { + id: "resolution.forward.text-unset", + description: "returns null for unset text record on test.eth", + fixtures: [], + call: (api) => api.resolveRecords("test.eth", { texts: ["nonexistent.key"] }), + expected: { texts: { "nonexistent.key": null } }, + }, + { + id: "resolution.forward.multicoin-selection", + description: "resolves selected multicoin addresses for test.eth", + fixtures: [btcAddress, ltcAddress], + call: (api) => api.resolveRecords("test.eth", { addresses: [0, 2, 777777] }), + expected: { + addresses: { + [btcAddress.coinType]: btcAddress.value, + [ltcAddress.coinType]: ltcAddress.value, + 777777: null, + }, + }, + }, + { + id: "resolution.forward.combined-supported-records", + description: "resolves all supported record types for test.eth", + fixtures: [ + textAvatar, + textTwitter, + textGithub, + textUrl, + textEmail, + textDescription, + btcAddress, + ltcAddress, + testEthContenthash, + testEthPubkey, + testEthAbi, + testEthInterface, + ], + call: (api) => + api.resolveRecords("test.eth", { + addresses: [60, btcAddress.coinType, ltcAddress.coinType], + texts: [ + textAvatar.key, + textDescription.key, + textUrl.key, + textEmail.key, + textTwitter.key, + textGithub.key, + ], + contenthash: true, + pubkey: true, + abi: true, + interfaceIds: [testEthInterface.interfaceId], + }), + expected: { + addresses: { + 60: accounts.owner.address, + [btcAddress.coinType]: btcAddress.value, + [ltcAddress.coinType]: ltcAddress.value, + }, + texts: { + [textAvatar.key]: textAvatar.value, + [textDescription.key]: textDescription.value, + [textUrl.key]: textUrl.value, + [textEmail.key]: textEmail.value, + [textTwitter.key]: textTwitter.value, + [textGithub.key]: textGithub.value, + }, + contenthash: testEthContenthash.value, + pubkey: { + x: testEthPubkey.x, + y: testEthPubkey.y, + }, + abi: { + contentType: String(testEthAbi.contentType), + data: testEthAbi.value, + }, + interfaces: { + [testEthInterface.interfaceId]: testEthInterface.value, + }, + }, + }, +]; diff --git a/packages/ens-test-kit/src/cases/resolution/reverse.ts b/packages/ens-test-kit/src/cases/resolution/reverse.ts new file mode 100644 index 0000000000..c272f8bd14 --- /dev/null +++ b/packages/ens-test-kit/src/cases/resolution/reverse.ts @@ -0,0 +1,43 @@ +import { accounts } from "@ensnode/datasources/devnet"; + +import type { ResolutionsApi } from "../../interfaces/resolutions"; +import { reverseName } from "../../seeder"; +import type { TestCase } from "../types"; + +const ownerReverseFixture = reverseName({ + id: "resolution-reverse-owner-test-eth", + address: accounts.owner.address, + chainId: 1, + name: "test.eth", +}); + +export const reverseResolutionCases: TestCase[] = [ + { + id: "resolution.reverse.single-owner-chain-1", + description: "resolves owner primary name on chain 1", + fixtures: [ownerReverseFixture], + call: (api) => api.resolvePrimaryName(accounts.owner.address, 1), + expected: ownerReverseFixture.name, + }, + { + id: "resolution.reverse.single-user-null", + description: "returns null for user without a primary name", + fixtures: [], + call: (api) => api.resolvePrimaryName(accounts.user.address, 1), + expected: null, + }, + { + id: "resolution.reverse.multi-owner-chain-1", + description: "resolves owner primary names map for explicit chain set", + fixtures: [ownerReverseFixture], + call: (api) => api.resolvePrimaryNames(accounts.owner.address, [1]), + expected: { 1: ownerReverseFixture.name }, + }, + { + id: "resolution.reverse.multi-owner-all", + description: "resolves owner primary names map for default chain selection", + fixtures: [ownerReverseFixture], + call: (api) => api.resolvePrimaryNames(accounts.owner.address), + expected: { 1: ownerReverseFixture.name }, + }, +]; diff --git a/packages/ens-test-kit/src/cases/types.ts b/packages/ens-test-kit/src/cases/types.ts new file mode 100644 index 0000000000..19cacd5503 --- /dev/null +++ b/packages/ens-test-kit/src/cases/types.ts @@ -0,0 +1,9 @@ +import type { Fixture } from "../seeder/types"; + +export interface TestCase { + id: string; + description: string; + fixtures: Fixture[]; + call: (api: Api) => Promise; + expected: unknown; +} diff --git a/packages/ens-test-kit/src/cli/index.ts b/packages/ens-test-kit/src/cli/index.ts new file mode 100644 index 0000000000..7e69970084 --- /dev/null +++ b/packages/ens-test-kit/src/cli/index.ts @@ -0,0 +1,6 @@ +export interface SeedCliOptions { + rpcUrl: string; + fixtures?: string; +} + +export { runSeedCli } from "./seed"; diff --git a/packages/ens-test-kit/src/cli/seed.ts b/packages/ens-test-kit/src/cli/seed.ts new file mode 100644 index 0000000000..0790cfa951 --- /dev/null +++ b/packages/ens-test-kit/src/cli/seed.ts @@ -0,0 +1,55 @@ +import { parseArgs } from "node:util"; + +import { getFixtureSet, seedFixtures } from "../seeder"; + +type ParsedArgs = { + rpcUrl: string; + fixtureSet: string; +}; + +function parseSeedArgs(argv: string[]): ParsedArgs { + const [subcommand, ...rest] = argv; + if (subcommand !== "seed") { + throw new Error(`Unknown command "${subcommand ?? ""}". Expected: seed.`); + } + + const { values } = parseArgs({ + args: rest, + options: { + rpc: { + type: "string", + }, + fixtures: { + type: "string", + default: "canonical", + }, + }, + strict: true, + allowPositionals: false, + }); + + if (!values.rpc) { + throw new Error("Missing required argument: --rpc ."); + } + + return { + rpcUrl: values.rpc, + fixtureSet: values.fixtures, + }; +} + +export async function runSeedCli(argv: string[]): Promise { + const { rpcUrl, fixtureSet } = parseSeedArgs(argv); + const fixtures = getFixtureSet(fixtureSet); + const applied = await seedFixtures(rpcUrl, fixtures); + + const names = [ + ...new Set( + applied.map((fixture) => ("name" in fixture ? fixture.name : undefined)).filter(Boolean), + ), + ]; + console.log(`[seed] Applied ${applied.length} fixture(s) from set "${fixtureSet}".`); + if (names.length > 0) { + console.log(`[seed] Names: ${names.join(", ")}`); + } +} diff --git a/packages/ens-test-kit/src/index.ts b/packages/ens-test-kit/src/index.ts new file mode 100644 index 0000000000..10a0c65af8 --- /dev/null +++ b/packages/ens-test-kit/src/index.ts @@ -0,0 +1,6 @@ +export type * from "./cases"; +export type * from "./cli"; +export type * from "./interfaces"; +export type * from "./seeder"; +export type * from "./types"; +export type * from "./vitest"; diff --git a/packages/ens-test-kit/src/interfaces/accounts.ts b/packages/ens-test-kit/src/interfaces/accounts.ts new file mode 100644 index 0000000000..04fc338436 --- /dev/null +++ b/packages/ens-test-kit/src/interfaces/accounts.ts @@ -0,0 +1,5 @@ +import type { Account, Hex } from "../types"; + +export interface AccountsApi { + getAccount(address: Hex): Promise; +} diff --git a/packages/ens-test-kit/src/interfaces/domains.ts b/packages/ens-test-kit/src/interfaces/domains.ts new file mode 100644 index 0000000000..89b4b8cc91 --- /dev/null +++ b/packages/ens-test-kit/src/interfaces/domains.ts @@ -0,0 +1,8 @@ +import type { Connection, Domain, DomainsWhere, Hex, NormalizedName, Registration } from "../types"; + +export interface DomainsApi { + getDomainByName(name: NormalizedName): Promise; + getDomainByNamehash(node: Hex): Promise; + listDomains(where: DomainsWhere): Promise>; + getRegistration(name: NormalizedName): Promise; +} diff --git a/packages/ens-test-kit/src/interfaces/index.ts b/packages/ens-test-kit/src/interfaces/index.ts new file mode 100644 index 0000000000..690be42593 --- /dev/null +++ b/packages/ens-test-kit/src/interfaces/index.ts @@ -0,0 +1,4 @@ +export type { AccountsApi } from "./accounts"; +export type { DomainsApi } from "./domains"; +export type { ResolutionsApi } from "./resolutions"; +export type { ResolversApi } from "./resolvers"; diff --git a/packages/ens-test-kit/src/interfaces/resolutions.ts b/packages/ens-test-kit/src/interfaces/resolutions.ts new file mode 100644 index 0000000000..b25e94b606 --- /dev/null +++ b/packages/ens-test-kit/src/interfaces/resolutions.ts @@ -0,0 +1,7 @@ +import type { ChainId, Hex, NormalizedName, RecordsSelection, ResolvedRecords } from "../types"; + +export interface ResolutionsApi { + resolveRecords(name: NormalizedName, selection: RecordsSelection): Promise; + resolvePrimaryName(address: Hex, chainId: ChainId): Promise; + resolvePrimaryNames(address: Hex, chainIds?: ChainId[]): Promise>; +} diff --git a/packages/ens-test-kit/src/interfaces/resolvers.ts b/packages/ens-test-kit/src/interfaces/resolvers.ts new file mode 100644 index 0000000000..0978541891 --- /dev/null +++ b/packages/ens-test-kit/src/interfaces/resolvers.ts @@ -0,0 +1,6 @@ +import type { ChainId, NormalizedName, Resolver, ResolverId } from "../types"; + +export interface ResolversApi { + getResolver(id: ResolverId): Promise; + listResolverRecords(name: NormalizedName): Promise<{ keys: string[]; coinTypes: ChainId[] }>; +} diff --git a/packages/ens-test-kit/src/seeder/fixtures/abi.ts b/packages/ens-test-kit/src/seeder/fixtures/abi.ts new file mode 100644 index 0000000000..b11c478528 --- /dev/null +++ b/packages/ens-test-kit/src/seeder/fixtures/abi.ts @@ -0,0 +1,36 @@ +import type { Hex } from "../../types"; +import type { SeederContext } from "../types"; +import { + buildFixture, + type FixtureArgs, + type FixtureMeta, + type ResolverRecordFields, +} from "./base"; +import { assertExpectedResolver, getResolverAndNode, writeResolverTx } from "./resolver-utils"; +import { getSenderClient } from "./sender-client"; + +type AbiRecordFields = ResolverRecordFields & { + contentType: number; + value: Hex; +}; + +export type AbiRecordFixture = FixtureMeta<"abiRecord"> & AbiRecordFields; + +export function abiRecord(args: FixtureArgs<"abiRecord", AbiRecordFields>): AbiRecordFixture { + return buildFixture("abiRecord", args); +} + +export async function applyAbiRecordFixture( + fixture: AbiRecordFixture, + ctx: SeederContext, +): Promise { + const senderClient = getSenderClient(ctx, fixture.sender); + const { node, resolver } = getResolverAndNode(fixture.name, fixture.resolverAddress); + await assertExpectedResolver(senderClient, fixture.name, resolver); + await writeResolverTx(senderClient, { + resolver, + functionName: "setABI", + data: [node, BigInt(fixture.contentType), fixture.value], + log: `abi ${fixture.name} contentType=${fixture.contentType}`, + }); +} diff --git a/packages/ens-test-kit/src/seeder/fixtures/base.ts b/packages/ens-test-kit/src/seeder/fixtures/base.ts new file mode 100644 index 0000000000..6803ddbb55 --- /dev/null +++ b/packages/ens-test-kit/src/seeder/fixtures/base.ts @@ -0,0 +1,28 @@ +import type { Hex, NormalizedName } from "../../types"; +import type { SeederSender } from "../types"; + +export type FixtureMeta = { + kind: K; + id: string; + sender?: SeederSender; +}; + +export type FixtureArgs = Omit< + FixtureMeta & Fields, + "kind" +>; + +export function buildFixture( + kind: K, + args: FixtureArgs, +): FixtureMeta & Fields { + return { + kind, + ...args, + } as FixtureMeta & Fields; +} + +export type ResolverRecordFields = { + name: NormalizedName; + resolverAddress?: Hex; +}; diff --git a/packages/ens-test-kit/src/seeder/fixtures/common.ts b/packages/ens-test-kit/src/seeder/fixtures/common.ts new file mode 100644 index 0000000000..796c8f7432 --- /dev/null +++ b/packages/ens-test-kit/src/seeder/fixtures/common.ts @@ -0,0 +1,90 @@ +import { accounts, addresses, fixtures } from "@ensnode/datasources/devnet"; + +import type { Fixture } from "../types"; +import { abiRecord } from "./abi"; +import { contenthashRecord } from "./contenthash"; +import { interfaceRecord } from "./interface-record"; +import { multicoinAddressRecord } from "./multicoin-address"; +import { pubkeyRecord } from "./pubkey"; +import { reverseName } from "./reverse-name"; +import { textRecord } from "./text-record"; + +export const canonicalFixtures: Fixture[] = [ + reverseName({ + id: "reverse-name-owner-test-eth", + address: accounts.owner.address, + chainId: 1, + name: "test.eth", + }), + textRecord({ + id: "text-record-test-eth-avatar", + name: "test.eth", + key: "avatar", + value: "https://example.com/avatar.png", + }), + textRecord({ + id: "text-record-test-eth-com-twitter", + name: "test.eth", + key: "com.twitter", + value: "ensdomains", + }), + textRecord({ + id: "text-record-test-eth-com-github", + name: "test.eth", + key: "com.github", + value: "ensdomains", + }), + textRecord({ + id: "text-record-test-eth-url", + name: "test.eth", + key: "url", + value: "https://ens.domains", + }), + textRecord({ + id: "text-record-test-eth-email", + name: "test.eth", + key: "email", + value: "test@ens.domains", + }), + textRecord({ + id: "text-record-test-eth-description", + name: "test.eth", + key: "description", + value: "test.eth", + }), + multicoinAddressRecord({ + id: "multicoin-address-test-eth-0", + name: "test.eth", + coinType: 0, + value: fixtures.bitcoinAddress, + }), + multicoinAddressRecord({ + id: "multicoin-address-test-eth-2", + name: "test.eth", + coinType: 2, + value: fixtures.litecoinAddress, + }), + contenthashRecord({ + id: "contenthash-test-eth", + name: "test.eth", + value: fixtures.contenthash, + }), + pubkeyRecord({ + id: "pubkey-test-eth", + name: "test.eth", + x: fixtures.publicKeyX, + y: fixtures.publicKeyY, + }), + abiRecord({ + id: "abi-test-eth-content-type-1", + name: "test.eth", + contentType: 1, + value: fixtures.abiBytes, + }), + interfaceRecord({ + id: "interface-record-test-eth-0x11100111", + name: "test.eth", + interfaceId: fixtures.fourBytesInterface, + value: addresses.one, + }), +]; diff --git a/packages/ens-test-kit/src/seeder/fixtures/contenthash.ts b/packages/ens-test-kit/src/seeder/fixtures/contenthash.ts new file mode 100644 index 0000000000..46819d558f --- /dev/null +++ b/packages/ens-test-kit/src/seeder/fixtures/contenthash.ts @@ -0,0 +1,37 @@ +import type { Hex } from "../../types"; +import type { SeederContext } from "../types"; +import { + buildFixture, + type FixtureArgs, + type FixtureMeta, + type ResolverRecordFields, +} from "./base"; +import { assertExpectedResolver, getResolverAndNode, writeResolverTx } from "./resolver-utils"; +import { getSenderClient } from "./sender-client"; + +type ContenthashRecordFields = ResolverRecordFields & { + value: Hex; +}; + +export type ContenthashRecordFixture = FixtureMeta<"contenthashRecord"> & ContenthashRecordFields; + +export function contenthashRecord( + args: FixtureArgs<"contenthashRecord", ContenthashRecordFields>, +): ContenthashRecordFixture { + return buildFixture("contenthashRecord", args); +} + +export async function applyContenthashRecordFixture( + fixture: ContenthashRecordFixture, + ctx: SeederContext, +): Promise { + const senderClient = getSenderClient(ctx, fixture.sender); + const { node, resolver } = getResolverAndNode(fixture.name, fixture.resolverAddress); + await assertExpectedResolver(senderClient, fixture.name, resolver); + await writeResolverTx(senderClient, { + resolver, + functionName: "setContenthash", + data: [node, fixture.value], + log: `contenthash ${fixture.name}`, + }); +} diff --git a/packages/ens-test-kit/src/seeder/fixtures/interface-record.ts b/packages/ens-test-kit/src/seeder/fixtures/interface-record.ts new file mode 100644 index 0000000000..43e2ab9372 --- /dev/null +++ b/packages/ens-test-kit/src/seeder/fixtures/interface-record.ts @@ -0,0 +1,38 @@ +import type { Hex } from "../../types"; +import type { SeederContext } from "../types"; +import { + buildFixture, + type FixtureArgs, + type FixtureMeta, + type ResolverRecordFields, +} from "./base"; +import { assertExpectedResolver, getResolverAndNode, writeResolverTx } from "./resolver-utils"; +import { getSenderClient } from "./sender-client"; + +type InterfaceRecordFields = ResolverRecordFields & { + interfaceId: Hex; + value: Hex; +}; + +export type InterfaceRecordFixture = FixtureMeta<"interfaceRecord"> & InterfaceRecordFields; + +export function interfaceRecord( + args: FixtureArgs<"interfaceRecord", InterfaceRecordFields>, +): InterfaceRecordFixture { + return buildFixture("interfaceRecord", args); +} + +export async function applyInterfaceRecordFixture( + fixture: InterfaceRecordFixture, + ctx: SeederContext, +): Promise { + const senderClient = getSenderClient(ctx, fixture.sender); + const { node, resolver } = getResolverAndNode(fixture.name, fixture.resolverAddress); + await assertExpectedResolver(senderClient, fixture.name, resolver); + await writeResolverTx(senderClient, { + resolver, + functionName: "setInterface", + data: [node, fixture.interfaceId, fixture.value], + log: `interface-record ${fixture.name} interfaceId=${fixture.interfaceId}`, + }); +} diff --git a/packages/ens-test-kit/src/seeder/fixtures/multicoin-address.ts b/packages/ens-test-kit/src/seeder/fixtures/multicoin-address.ts new file mode 100644 index 0000000000..485fa4f80c --- /dev/null +++ b/packages/ens-test-kit/src/seeder/fixtures/multicoin-address.ts @@ -0,0 +1,39 @@ +import type { ChainId, Hex } from "../../types"; +import type { SeederContext } from "../types"; +import { + buildFixture, + type FixtureArgs, + type FixtureMeta, + type ResolverRecordFields, +} from "./base"; +import { assertExpectedResolver, getResolverAndNode, writeResolverTx } from "./resolver-utils"; +import { getSenderClient } from "./sender-client"; + +type MulticoinAddressRecordFields = ResolverRecordFields & { + coinType: ChainId; + value: Hex; +}; + +export type MulticoinAddressRecordFixture = FixtureMeta<"multicoinAddressRecord"> & + MulticoinAddressRecordFields; + +export function multicoinAddressRecord( + args: FixtureArgs<"multicoinAddressRecord", MulticoinAddressRecordFields>, +): MulticoinAddressRecordFixture { + return buildFixture("multicoinAddressRecord", args); +} + +export async function applyMulticoinAddressRecordFixture( + fixture: MulticoinAddressRecordFixture, + ctx: SeederContext, +): Promise { + const senderClient = getSenderClient(ctx, fixture.sender); + const { node, resolver } = getResolverAndNode(fixture.name, fixture.resolverAddress); + await assertExpectedResolver(senderClient, fixture.name, resolver); + await writeResolverTx(senderClient, { + resolver, + functionName: "setAddr", + data: [node, BigInt(fixture.coinType), fixture.value], + log: `multicoin-address ${fixture.name} coinType=${fixture.coinType}`, + }); +} diff --git a/packages/ens-test-kit/src/seeder/fixtures/pubkey.ts b/packages/ens-test-kit/src/seeder/fixtures/pubkey.ts new file mode 100644 index 0000000000..d1c43c81e6 --- /dev/null +++ b/packages/ens-test-kit/src/seeder/fixtures/pubkey.ts @@ -0,0 +1,38 @@ +import type { Hex } from "../../types"; +import type { SeederContext } from "../types"; +import { + buildFixture, + type FixtureArgs, + type FixtureMeta, + type ResolverRecordFields, +} from "./base"; +import { assertExpectedResolver, getResolverAndNode, writeResolverTx } from "./resolver-utils"; +import { getSenderClient } from "./sender-client"; + +type PubkeyRecordFields = ResolverRecordFields & { + x: Hex; + y: Hex; +}; + +export type PubkeyRecordFixture = FixtureMeta<"pubkeyRecord"> & PubkeyRecordFields; + +export function pubkeyRecord( + args: FixtureArgs<"pubkeyRecord", PubkeyRecordFields>, +): PubkeyRecordFixture { + return buildFixture("pubkeyRecord", args); +} + +export async function applyPubkeyRecordFixture( + fixture: PubkeyRecordFixture, + ctx: SeederContext, +): Promise { + const senderClient = getSenderClient(ctx, fixture.sender); + const { node, resolver } = getResolverAndNode(fixture.name, fixture.resolverAddress); + await assertExpectedResolver(senderClient, fixture.name, resolver); + await writeResolverTx(senderClient, { + resolver, + functionName: "setPubkey", + data: [node, fixture.x, fixture.y], + log: `pubkey ${fixture.name}`, + }); +} diff --git a/packages/ens-test-kit/src/seeder/fixtures/resolver-utils.ts b/packages/ens-test-kit/src/seeder/fixtures/resolver-utils.ts new file mode 100644 index 0000000000..fda271cc5d --- /dev/null +++ b/packages/ens-test-kit/src/seeder/fixtures/resolver-utils.ts @@ -0,0 +1,63 @@ +import type { Address, Hex } from "viem"; +import { namehash, toHex } from "viem"; +import { packetToBytes } from "viem/ens"; + +import { ResolverABI, UniversalResolverABI } from "@ensnode/datasources"; +import { contracts } from "@ensnode/datasources/devnet"; + +import { waitForTransactionReceipt } from "../tx-receipts"; +import type { DevnetWalletClient } from "../types"; + +export function getResolverAndNode( + name: string, + resolverAddress?: Hex, +): { node: Hex; resolver: Address } { + return { + node: namehash(name), + resolver: (resolverAddress ?? contracts.permissionedResolver) as Address, + }; +} + +export async function assertExpectedResolver( + client: DevnetWalletClient, + name: string, + expectedResolver: Address, +): Promise { + const [actualResolver] = await client.readContract({ + address: contracts.universalResolverV2, + abi: UniversalResolverABI, + functionName: "findResolver", + args: [toHex(packetToBytes(name))], + }); + + if (actualResolver.toLowerCase() !== expectedResolver.toLowerCase()) { + throw new Error( + `${name} resolver mismatch: active=${actualResolver}, expected=${expectedResolver}.`, + ); + } +} + +export async function writeResolverTx( + client: DevnetWalletClient, + args: { + resolver: Address; + functionName: + | "setText" + | "setAddr" + | "setContenthash" + | "setPubkey" + | "setABI" + | "setInterface"; + data: unknown[]; + log: string; + }, +): Promise { + const hash = await client.writeContract({ + address: args.resolver, + abi: ResolverABI, + functionName: args.functionName, + args: args.data as never, + }); + await waitForTransactionReceipt(client, hash); + console.log(`[seed] ${args.log} tx=${hash}`); +} diff --git a/packages/ens-test-kit/src/seeder/fixtures/reverse-name.ts b/packages/ens-test-kit/src/seeder/fixtures/reverse-name.ts new file mode 100644 index 0000000000..8cc8d09a45 --- /dev/null +++ b/packages/ens-test-kit/src/seeder/fixtures/reverse-name.ts @@ -0,0 +1,45 @@ +import { L2ReverseRegistrarABI } from "@ensnode/datasources"; +import { contracts } from "@ensnode/datasources/devnet"; + +import type { ChainId, Hex, NormalizedName } from "../../types"; +import { waitForTransactionReceipt } from "../tx-receipts"; +import type { SeederContext } from "../types"; +import { buildFixture, type FixtureArgs, type FixtureMeta } from "./base"; +import { getSenderClient } from "./sender-client"; + +type ReverseNameFields = { + address: Hex; + chainId: ChainId; + name: NormalizedName; +}; + +export type ReverseNameFixture = FixtureMeta<"reverseName"> & ReverseNameFields; + +export function reverseName( + args: FixtureArgs<"reverseName", ReverseNameFields>, +): ReverseNameFixture { + return buildFixture("reverseName", args); +} + +export async function applyReverseNameFixture( + fixture: ReverseNameFixture, + ctx: SeederContext, +): Promise { + const senderClient = getSenderClient(ctx, fixture.sender); + const senderAddress = senderClient.account.address.toLowerCase(); + if (fixture.address.toLowerCase() !== senderAddress) { + throw new Error( + `Reverse-name fixture "${fixture.id}" targets ${fixture.address}, but selected sender is ${senderClient.account.address}.`, + ); + } + + const hash = await senderClient.writeContract({ + address: contracts.ethReverseRegistrar, + abi: L2ReverseRegistrarABI, + functionName: "setName", + args: [fixture.name], + }); + + await waitForTransactionReceipt(senderClient, hash); + console.log(`[seed] reverse-name ${fixture.name} tx=${hash}`); +} diff --git a/packages/ens-test-kit/src/seeder/fixtures/sender-client.ts b/packages/ens-test-kit/src/seeder/fixtures/sender-client.ts new file mode 100644 index 0000000000..13ba10d387 --- /dev/null +++ b/packages/ens-test-kit/src/seeder/fixtures/sender-client.ts @@ -0,0 +1,8 @@ +import type { SeederContext, SeederSender } from "../types"; + +export function getSenderClient( + ctx: SeederContext, + sender: SeederSender | undefined, +): SeederContext["clients"][SeederSender] { + return ctx.clients[sender ?? "owner"]; +} diff --git a/packages/ens-test-kit/src/seeder/fixtures/text-record.ts b/packages/ens-test-kit/src/seeder/fixtures/text-record.ts new file mode 100644 index 0000000000..5d47d03076 --- /dev/null +++ b/packages/ens-test-kit/src/seeder/fixtures/text-record.ts @@ -0,0 +1,35 @@ +import type { SeederContext } from "../types"; +import { + buildFixture, + type FixtureArgs, + type FixtureMeta, + type ResolverRecordFields, +} from "./base"; +import { assertExpectedResolver, getResolverAndNode, writeResolverTx } from "./resolver-utils"; +import { getSenderClient } from "./sender-client"; + +type TextRecordFields = ResolverRecordFields & { + key: string; + value: string; +}; + +export type TextRecordFixture = FixtureMeta<"textRecord"> & TextRecordFields; + +export function textRecord(args: FixtureArgs<"textRecord", TextRecordFields>): TextRecordFixture { + return buildFixture("textRecord", args); +} + +export async function applyTextRecordFixture( + fixture: TextRecordFixture, + ctx: SeederContext, +): Promise { + const senderClient = getSenderClient(ctx, fixture.sender); + const { node, resolver } = getResolverAndNode(fixture.name, fixture.resolverAddress); + await assertExpectedResolver(senderClient, fixture.name, resolver); + await writeResolverTx(senderClient, { + resolver, + functionName: "setText", + data: [node, fixture.key, fixture.value], + log: `text-record ${fixture.name} key=${fixture.key}`, + }); +} diff --git a/packages/ens-test-kit/src/seeder/index.ts b/packages/ens-test-kit/src/seeder/index.ts new file mode 100644 index 0000000000..52c10e5d6e --- /dev/null +++ b/packages/ens-test-kit/src/seeder/index.ts @@ -0,0 +1,23 @@ +import { canonicalFixtures } from "./fixtures/common"; +export { canonicalFixtures }; + +export type { AbiRecordFixture } from "./fixtures/abi"; +export { abiRecord } from "./fixtures/abi"; +export type { ContenthashRecordFixture } from "./fixtures/contenthash"; +export { contenthashRecord } from "./fixtures/contenthash"; +export type { InterfaceRecordFixture } from "./fixtures/interface-record"; +export { interfaceRecord } from "./fixtures/interface-record"; +export type { MulticoinAddressRecordFixture } from "./fixtures/multicoin-address"; +export { multicoinAddressRecord } from "./fixtures/multicoin-address"; +export type { PubkeyRecordFixture } from "./fixtures/pubkey"; +export { pubkeyRecord } from "./fixtures/pubkey"; +export type { ReverseNameFixture } from "./fixtures/reverse-name"; +export { reverseName } from "./fixtures/reverse-name"; +export type { TextRecordFixture } from "./fixtures/text-record"; +export { textRecord } from "./fixtures/text-record"; +export { createSeederContext, dedupeFixtures, getFixtureSet, seedFixtures } from "./runtime"; +export { + seedReceiptWaitOptions, + waitForTransactionReceipt, +} from "./tx-receipts"; +export type { DevnetWalletClient, Fixture, FixtureBase, FixtureKind, SeederContext } from "./types"; diff --git a/packages/ens-test-kit/src/seeder/runtime.ts b/packages/ens-test-kit/src/seeder/runtime.ts new file mode 100644 index 0000000000..56346706de --- /dev/null +++ b/packages/ens-test-kit/src/seeder/runtime.ts @@ -0,0 +1,130 @@ +import { + type Account, + type Chain, + createWalletClient, + http, + type PublicActions, + publicActions, + type Transport, + type WalletClient, +} from "viem"; + +import { ensTestEnvChain } from "@ensnode/datasources"; +import { accounts } from "@ensnode/datasources/devnet"; + +import { applyAbiRecordFixture } from "./fixtures/abi"; +import { canonicalFixtures } from "./fixtures/common"; +import { applyContenthashRecordFixture } from "./fixtures/contenthash"; +import { applyInterfaceRecordFixture } from "./fixtures/interface-record"; +import { applyMulticoinAddressRecordFixture } from "./fixtures/multicoin-address"; +import { applyPubkeyRecordFixture } from "./fixtures/pubkey"; +import { applyReverseNameFixture } from "./fixtures/reverse-name"; +import { applyTextRecordFixture } from "./fixtures/text-record"; +import type { Fixture, SeederContext } from "./types"; + +function createDevnetWalletClient(transport: Transport, account: Account) { + return createWalletClient({ + chain: ensTestEnvChain, + transport, + account, + }).extend(publicActions); +} + +type DevnetWalletClient = WalletClient & PublicActions; + +function createDevnetWalletClients(rpcUrl: string): SeederContext["clients"] { + const transport = http(rpcUrl); + const makeClient = (account: Account): DevnetWalletClient => + createDevnetWalletClient(transport, account); + + return { + deployer: makeClient(accounts.deployer), + owner: makeClient(accounts.owner), + user: makeClient(accounts.user), + user2: makeClient(accounts.user2), + }; +} + +export function createSeederContext(rpcUrl: string): SeederContext { + return { + rpcUrl, + clients: createDevnetWalletClients(rpcUrl), + }; +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + if (value !== null && typeof value === "object") { + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b), + ); + return `{${entries + .map(([key, innerValue]) => `${JSON.stringify(key)}:${stableStringify(innerValue)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function dedupeFixtures(fixtures: Fixture[]): Fixture[] { + const byId = new Map(); + + for (const fixture of fixtures) { + const existing = byId.get(fixture.id); + if (!existing) { + byId.set(fixture.id, fixture); + continue; + } + + const incomingComparable = stableStringify(fixture); + const existingComparable = stableStringify(existing); + + if (incomingComparable !== existingComparable) { + throw new Error( + [ + `Conflicting fixtures with id "${fixture.id}".`, + `existing=${JSON.stringify(existing)}`, + `incoming=${JSON.stringify(fixture)}`, + ].join(" "), + ); + } + } + + return [...byId.values()]; +} + +const HANDLERS: { + [K in Fixture["kind"]]: ( + fixture: Extract, + ctx: SeederContext, + ) => Promise; +} = { + reverseName: applyReverseNameFixture, + textRecord: applyTextRecordFixture, + multicoinAddressRecord: applyMulticoinAddressRecordFixture, + contenthashRecord: applyContenthashRecordFixture, + pubkeyRecord: applyPubkeyRecordFixture, + abiRecord: applyAbiRecordFixture, + interfaceRecord: applyInterfaceRecordFixture, +}; + +export async function seedFixtures(rpcUrl: string, fixtures: Fixture[]): Promise { + const context = createSeederContext(rpcUrl); + const deduped = dedupeFixtures(fixtures); + + for (const fixture of deduped) { + const handler = HANDLERS[fixture.kind] as ( + fixture: Fixture, + ctx: SeederContext, + ) => Promise; + await handler(fixture, context); + } + + return deduped; +} + +export function getFixtureSet(name: string): Fixture[] { + if (name === "canonical") return canonicalFixtures; + throw new Error(`Unknown fixture set "${name}". Supported sets: canonical.`); +} diff --git a/packages/ens-test-kit/src/seeder/tx-receipts.ts b/packages/ens-test-kit/src/seeder/tx-receipts.ts new file mode 100644 index 0000000000..af83d78be6 --- /dev/null +++ b/packages/ens-test-kit/src/seeder/tx-receipts.ts @@ -0,0 +1,19 @@ +import type { Hex } from "viem"; + +import type { DevnetWalletClient } from "./types"; + +export const seedReceiptWaitOptions = { + pollingInterval: 50, + confirmations: 1, + timeout: 15_000, +} as const; + +export async function waitForTransactionReceipt( + client: DevnetWalletClient, + hash: Hex, +): Promise { + await client.waitForTransactionReceipt({ + hash, + ...seedReceiptWaitOptions, + }); +} diff --git a/packages/ens-test-kit/src/seeder/types.ts b/packages/ens-test-kit/src/seeder/types.ts new file mode 100644 index 0000000000..bf0dc8c2d5 --- /dev/null +++ b/packages/ens-test-kit/src/seeder/types.ts @@ -0,0 +1,37 @@ +import type { Account, Chain, PublicActions, Transport, WalletClient } from "viem"; + +import type { AbiRecordFixture } from "./fixtures/abi"; +import type { ContenthashRecordFixture } from "./fixtures/contenthash"; +import type { InterfaceRecordFixture } from "./fixtures/interface-record"; +import type { MulticoinAddressRecordFixture } from "./fixtures/multicoin-address"; +import type { PubkeyRecordFixture } from "./fixtures/pubkey"; +import type { ReverseNameFixture } from "./fixtures/reverse-name"; +import type { TextRecordFixture } from "./fixtures/text-record"; + +export type Fixture = + | ReverseNameFixture + | TextRecordFixture + | MulticoinAddressRecordFixture + | ContenthashRecordFixture + | PubkeyRecordFixture + | AbiRecordFixture + | InterfaceRecordFixture; + +export type FixtureKind = Fixture["kind"]; +export type FixtureBase = Pick; + +export type DevnetWalletClient = WalletClient & PublicActions; + +export type SeederClients = { + deployer: DevnetWalletClient; + owner: DevnetWalletClient; + user: DevnetWalletClient; + user2: DevnetWalletClient; +}; + +export type SeederSender = keyof SeederClients; + +export interface SeederContext { + rpcUrl: string; + clients: SeederClients; +} diff --git a/packages/ens-test-kit/src/types/index.ts b/packages/ens-test-kit/src/types/index.ts new file mode 100644 index 0000000000..8709643c98 --- /dev/null +++ b/packages/ens-test-kit/src/types/index.ts @@ -0,0 +1,69 @@ +import type { Hex as ViemHex } from "viem"; + +export type Hex = ViemHex; +export type ChainId = number; +export type NormalizedName = string; +export type ResolverId = string; + +export interface Registration { + name: NormalizedName; + registrant: Hex | null; + registrationDate: string | null; + expiryDate: string | null; +} + +export interface Domain { + name: NormalizedName; + namehash: Hex; + owner: Hex | null; + resolverId: ResolverId | null; + registration: Registration | null; + parentName: NormalizedName | null; +} + +export interface Account { + address: Hex; + domains: Domain[]; + primaryNames?: Partial>; +} + +export interface Resolver { + id: ResolverId; + address: Hex; + domainNames: NormalizedName[]; +} + +export interface Connection { + items: T[]; + totalCount: number; +} + +export interface DomainsWhere { + owner?: Hex; + parentName?: NormalizedName; + resolverId?: ResolverId; + nameContains?: string; +} + +export interface RecordsSelection { + name?: boolean; + addresses?: ChainId[]; + texts?: string[]; + contenthash?: boolean; + pubkey?: boolean; + abi?: boolean; + interfaceIds?: Hex[]; +} + +export interface ResolvedRecords { + name?: NormalizedName | null; + addresses?: Partial>; + texts?: Record; + contenthash?: string | null; + pubkey?: { + x: Hex; + y: Hex; + } | null; + abi?: string | null; + interfaces?: Record; +} diff --git a/packages/ens-test-kit/src/vitest/index.ts b/packages/ens-test-kit/src/vitest/index.ts new file mode 100644 index 0000000000..c147d1fa9b --- /dev/null +++ b/packages/ens-test-kit/src/vitest/index.ts @@ -0,0 +1 @@ +export { runSuite } from "./run-suite"; diff --git a/packages/ens-test-kit/src/vitest/run-suite.ts b/packages/ens-test-kit/src/vitest/run-suite.ts new file mode 100644 index 0000000000..69b1df48df --- /dev/null +++ b/packages/ens-test-kit/src/vitest/run-suite.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { EXPECTATION, type Expectation, isExpectation } from "../cases/expectation"; +import type { TestCase } from "../cases/types"; + +export function runSuite(adapter: Api, cases: TestCase[]): void { + describe(`suite (${cases.length} cases)`, () => { + it.each(cases)("$id - $description", async (testCase) => { + const actual = await testCase.call(adapter); + if (isExpectation(testCase.expected)) { + assertExpectation(actual, testCase.expected); + } else { + expect(actual).toMatchObject(testCase.expected as object); + } + }); + }); +} + +function assertExpectation(actual: unknown, e: Expectation): void { + switch (e[EXPECTATION]) { + case "partial": + expect(actual).toMatchObject(e.value as object); + return; + case "equals": + expect(actual).toEqual(e.value); + return; + case "arrayContains": + expect(actual).toEqual(expect.arrayContaining(e.items)); + return; + } +} diff --git a/packages/ens-test-kit/tsconfig.json b/packages/ens-test-kit/tsconfig.json new file mode 100644 index 0000000000..a33791a208 --- /dev/null +++ b/packages/ens-test-kit/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@ensnode/shared-configs/tsconfig.lib.json", + "include": ["src/**/*"], + "exclude": ["dist"] +} diff --git a/packages/ens-test-kit/tsup.config.ts b/packages/ens-test-kit/tsup.config.ts new file mode 100644 index 0000000000..ab0aff1939 --- /dev/null +++ b/packages/ens-test-kit/tsup.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + "interfaces/index": "src/interfaces/index.ts", + "types/index": "src/types/index.ts", + "cases/index": "src/cases/index.ts", + "seeder/index": "src/seeder/index.ts", + "vitest/index": "src/vitest/index.ts", + "cli/index": "src/cli/index.ts", + "cli/seed": "src/cli/seed.ts", + }, + platform: "neutral", + format: ["esm"], + target: "es2022", + bundle: true, + splitting: false, + sourcemap: true, + dts: true, + clean: true, + external: ["viem"], + outDir: "./dist", +}); diff --git a/packages/ens-test-kit/vitest.config.ts b/packages/ens-test-kit/vitest.config.ts new file mode 100644 index 0000000000..ce487d95b8 --- /dev/null +++ b/packages/ens-test-kit/vitest.config.ts @@ -0,0 +1,8 @@ +import { configDefaults, defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: [...configDefaults.exclude, "**/*.integration.test.ts"], + }, +}); diff --git a/packages/ensnode-sdk/src/internal.ts b/packages/ensnode-sdk/src/internal.ts index 01743d2375..15510afee2 100644 --- a/packages/ensnode-sdk/src/internal.ts +++ b/packages/ensnode-sdk/src/internal.ts @@ -39,7 +39,6 @@ export * from "./shared/config/zod-schemas"; export * from "./shared/config-templates"; export * from "./shared/datasources-with-ensv2-contracts"; export * from "./shared/datasources-with-resolvers"; -export * from "./shared/devnet-accounts"; export * from "./shared/interpretation/interpret-address"; export * from "./shared/interpretation/interpret-record-values"; export * from "./shared/interpretation/interpret-resolver-values"; diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index a9e5e4df1d..d4e0127fdb 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -1,9 +1,9 @@ import { asInterpretedName, toNormalizedAddress } from "enssdk"; import { DatasourceNames, ENSNamespaceIds } from "@ensnode/datasources"; +import { accounts } from "@ensnode/datasources/devnet"; import { maybeGetDatasourceContract } from "../shared/datasource-contract"; -import { DevnetAccounts } from "../shared/devnet-accounts"; import type { NamespaceSpecificValue } from "../shared/namespace-specific-value"; const SEPOLIA_V2_V2_ETH_REGISTRY = maybeGetDatasourceContract( @@ -193,7 +193,7 @@ query AccountDomains( }`, variables: { default: { address: VITALIK_ADDRESS }, - [ENSNamespaceIds.EnsTestEnv]: { address: DevnetAccounts.owner.address }, + [ENSNamespaceIds.EnsTestEnv]: { address: accounts.owner.address }, [ENSNamespaceIds.SepoliaV2]: { address: SEPOLIA_V2_USER_ADDRESS }, }, }, @@ -212,7 +212,7 @@ query AccountEvents( }`, variables: { default: { address: VITALIK_ADDRESS }, - [ENSNamespaceIds.EnsTestEnv]: { address: DevnetAccounts.deployer.address }, + [ENSNamespaceIds.EnsTestEnv]: { address: accounts.deployer.address }, [ENSNamespaceIds.SepoliaV2]: { address: SEPOLIA_V2_USER_ADDRESS }, }, }, @@ -296,7 +296,7 @@ query PermissionsByUser($address: Address!) { } }`, variables: { - default: { address: DevnetAccounts.deployer.address }, + default: { address: accounts.deployer.address }, [ENSNamespaceIds.SepoliaV2]: { address: SEPOLIA_V2_USER_ADDRESS }, }, }, @@ -322,7 +322,7 @@ query AccountResolverPermissions($address: Address!) { } }`, variables: { - default: { address: DevnetAccounts.deployer.address }, + default: { address: accounts.deployer.address }, [ENSNamespaceIds.SepoliaV2]: { address: SEPOLIA_V2_USER_ADDRESS }, }, }, diff --git a/packages/ensnode-sdk/src/shared/config/rpc-configs-from-env.ts b/packages/ensnode-sdk/src/shared/config/rpc-configs-from-env.ts index e4dd604229..70a17d0a95 100644 --- a/packages/ensnode-sdk/src/shared/config/rpc-configs-from-env.ts +++ b/packages/ensnode-sdk/src/shared/config/rpc-configs-from-env.ts @@ -3,6 +3,7 @@ import type { ChainIdString } from "enssdk"; import { type Datasource, type ENSNamespaceId, + ENSNamespaceIds, ensTestEnvChain, getENSNamespace, } from "@ensnode/datasources"; @@ -129,7 +130,7 @@ export function buildRpcConfigsFromEnv( } // ens-test-env Chain - if (chain.id === ensTestEnvChain.id) { + if (namespace === ENSNamespaceIds.EnsTestEnv && chain.id === ensTestEnvChain.id) { rpcConfigs[serializeChainId(ensTestEnvChain.id)] = ensTestEnvChain.rpcUrls.default.http[0]; continue; } diff --git a/packages/integration-test-env/package.json b/packages/integration-test-env/package.json index b71cd8eff0..842298d7ff 100644 --- a/packages/integration-test-env/package.json +++ b/packages/integration-test-env/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@ensnode/ens-test-kit": "workspace:*", "@ensnode/shared-configs": "workspace:*", "@ensnode/datasources": "workspace:*", "@ensnode/ensdb-sdk": "workspace:*", diff --git a/packages/integration-test-env/src/adapters/rest-adapter.ts b/packages/integration-test-env/src/adapters/rest-adapter.ts new file mode 100644 index 0000000000..441cad044e --- /dev/null +++ b/packages/integration-test-env/src/adapters/rest-adapter.ts @@ -0,0 +1,76 @@ +import type { ResolutionsApi } from "@ensnode/ens-test-kit/interfaces"; +import type { ChainId, Hex, NormalizedName, RecordsSelection, ResolvedRecords } from "@ensnode/ens-test-kit/types"; + +type ResolveRecordsResponse = { + records: ResolvedRecords; +}; + +type ResolvePrimaryNameResponse = { + name: string | null; +}; + +type ResolvePrimaryNamesResponse = { + names: Record; +}; + +export class RestAdapter implements ResolutionsApi { + constructor(private readonly baseUrl: string) {} + + async resolveRecords(name: NormalizedName, selection: RecordsSelection): Promise { + const query = new URLSearchParams(); + if (selection.name) query.set("name", "true"); + if (selection.addresses && selection.addresses.length > 0) { + query.set("addresses", selection.addresses.join(",")); + } + if (selection.texts && selection.texts.length > 0) { + query.set("texts", selection.texts.join(",")); + } + if (selection.contenthash) query.set("contenthash", "true"); + if (selection.pubkey) query.set("pubkey", "true"); + if (selection.abi) query.set("abi", "1"); + if (selection.interfaceIds && selection.interfaceIds.length > 0) { + query.set("interfaces", selection.interfaceIds.join(",")); + } + + const encodedName = encodeURIComponent(name); + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + const body = await this.fetchJson( + `/api/resolve/records/${encodedName}${suffix}`, + ); + + return body.records; + } + + async resolvePrimaryName(address: Hex, chainId: ChainId): Promise { + const body = await this.fetchJson( + `/api/resolve/primary-name/${address}/${chainId}`, + ); + return body.name; + } + + async resolvePrimaryNames( + address: Hex, + chainIds?: ChainId[], + ): Promise> { + const query = + chainIds && chainIds.length > 0 ? `?chainIds=${encodeURIComponent(chainIds.join(","))}` : ""; + const body = await this.fetchJson( + `/api/resolve/primary-names/${address}${query}`, + ); + + const parsed: Record = {}; + for (const [rawChainId, name] of Object.entries(body.names)) { + parsed[Number(rawChainId)] = name; + } + return parsed; + } + + private async fetchJson(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`); + const body = (await response.json()) as TBody; + if (!response.ok) { + throw new Error(`REST adapter request failed (${response.status}) for ${path}`); + } + return body; + } +} diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index a035099b90..b050884291 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -264,8 +264,9 @@ async function main() { log(`ENSDb is ready (port ${ENSDB_PORT})`); // Devnet Chain Id check + const devnetRpcUrl = ensTestEnvChain.rpcUrls.default.http[0]; const publicClient = createPublicClient({ - transport: http(ensTestEnvChain.rpcUrls.default.http[0]), + transport: http(devnetRpcUrl), }); const devnetChainId = await publicClient.getChainId(); if (devnetChainId !== ensTestEnvChain.id) { @@ -274,10 +275,9 @@ async function main() { ); } - log("Devnet is ready"); + log(`Devnet is ready (RPC URL: ${devnetRpcUrl})`); - // Phase 2: Start ENSRainbow via the entrypoint command, which downloads and - // extracts the prebuilt database in the background and serves once attached. + // Phase 2: Download ENSRainbow database and start from source const DB_SCHEMA_VERSION = "3"; const LABEL_SET_ID = "ens-test-env"; const LABEL_SET_VERSION = "0"; diff --git a/packages/integration-test-env/src/tests/resolution-rest.integration.test.ts b/packages/integration-test-env/src/tests/resolution-rest.integration.test.ts new file mode 100644 index 0000000000..c7d832aab7 --- /dev/null +++ b/packages/integration-test-env/src/tests/resolution-rest.integration.test.ts @@ -0,0 +1,8 @@ +import { forwardResolutionCases, reverseResolutionCases } from "@ensnode/ens-test-kit/cases"; +import { runSuite } from "@ensnode/ens-test-kit/vitest"; + +import { RestAdapter } from "../adapters/rest-adapter"; + +const restAdapter = new RestAdapter(process.env.ENSNODE_URL ?? "http://localhost:4334"); + +runSuite(restAdapter, [...forwardResolutionCases, ...reverseResolutionCases]); diff --git a/packages/integration-test-env/vitest.integration.config.ts b/packages/integration-test-env/vitest.integration.config.ts new file mode 100644 index 0000000000..a0417bb3a8 --- /dev/null +++ b/packages/integration-test-env/vitest.integration.config.ts @@ -0,0 +1,7 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + include: ["src/tests/**/*.integration.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8503052f9..12d39f38af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -846,6 +846,9 @@ importers: '@ponder/utils': specifier: ^0.2.18 version: 0.2.18(typescript@5.9.3)(viem@2.38.5(typescript@5.9.3)(zod@4.3.6)) + enssdk: + specifier: workspace:* + version: link:../enssdk devDependencies: '@ensnode/shared-configs': specifier: workspace:* @@ -897,6 +900,31 @@ importers: specifier: 'catalog:' version: 4.0.5(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.12))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3) + packages/ens-test-kit: + dependencies: + '@ensnode/datasources': + specifier: workspace:* + version: link:../datasources + viem: + specifier: 'catalog:' + version: 2.38.5(typescript@5.9.3)(zod@4.3.6) + devDependencies: + '@ensnode/shared-configs': + specifier: workspace:* + version: link:../shared-configs + '@types/node': + specifier: 'catalog:' + version: 24.10.9 + tsup: + specifier: 'catalog:' + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.5(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.3) + packages/enscli: {} packages/ensdb-sdk: @@ -1130,6 +1158,9 @@ importers: '@ensnode/datasources': specifier: workspace:* version: link:../datasources + '@ensnode/ens-test-kit': + specifier: workspace:* + version: link:../ens-test-kit '@ensnode/ensdb-sdk': specifier: workspace:* version: link:../ensdb-sdk @@ -1148,9 +1179,6 @@ importers: tsx: specifier: ^4.7.1 version: 4.20.6 - viem: - specifier: 'catalog:' - version: 2.38.5(typescript@5.9.3)(zod@4.3.6) packages/namehash-ui: dependencies: