diff --git a/docs/ensnode.io/astro.config.mjs b/docs/ensnode.io/astro.config.mjs index 9ed94d704..32cd25578 100644 --- a/docs/ensnode.io/astro.config.mjs +++ b/docs/ensnode.io/astro.config.mjs @@ -35,6 +35,8 @@ export default defineConfig({ redirects: { "/docs": "/docs/integrate", "/ensnode": "/docs/integrate", + // EFP page moved under the Complementary Protocols group + "/docs/integrate/omnigraph/efp": "/docs/integrate/omnigraph/complementary/efp", "/examples": "/docs/integrate/omnigraph/examples", "/ensnode/deploying/railway": "/docs/services/ensrainbow/deploying/railway", // ENS Subgraph docs moved from /docs/integrate/subgraph to /docs/integrate/ens-subgraph diff --git a/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts b/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts index 0fc00d46e..c1fe2ebdf 100644 --- a/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts +++ b/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts @@ -100,6 +100,20 @@ export const integrateSidebarTopic = { label: "Protocol Acceleration", link: "/docs/integrate/omnigraph/protocol-acceleration", }, + { + label: "Complementary Protocols", + collapsed: true, + items: [ + { + label: "EFP (Social Graph)", + link: "/docs/integrate/omnigraph/complementary/efp", + }, + { + label: "AI Agents / ERC-8004", + link: "/docs/integrate/omnigraph/complementary/erc-8004", + }, + ], + }, { label: "FAQ", link: "/docs/integrate/omnigraph/faq", diff --git a/docs/ensnode.io/src/components/molecules/omnigraph-static-example/ExampleRequirementAside.astro b/docs/ensnode.io/src/components/molecules/omnigraph-static-example/ExampleRequirementAside.astro new file mode 100644 index 000000000..0221e78e0 --- /dev/null +++ b/docs/ensnode.io/src/components/molecules/omnigraph-static-example/ExampleRequirementAside.astro @@ -0,0 +1,32 @@ +--- +// Renders a plugin-requirement Aside for an Omnigraph example, driven by the example's category. +// Example pages pass their `id`; the component looks the example up and renders the matching aside +// (or nothing) so requirement notices stay consistent and live next to the example definition. +import { Aside } from "@astrojs/starlight/components"; + +import { getOmnigraphExampleConfigById } from "@data/omnigraph-examples/config"; + +interface Props { + id: string; +} + +const { id } = Astro.props; +const category = getOmnigraphExampleConfigById(id)?.category; +--- + +{ + category === "EFP" && ( + + ) +} diff --git a/docs/ensnode.io/src/components/organisms/OmnigraphStaticExampleSet.astro b/docs/ensnode.io/src/components/organisms/OmnigraphStaticExampleSet.astro index 885e2a6b3..1e0a5d4a1 100644 --- a/docs/ensnode.io/src/components/organisms/OmnigraphStaticExampleSet.astro +++ b/docs/ensnode.io/src/components/organisms/OmnigraphStaticExampleSet.astro @@ -6,6 +6,7 @@ import StaticExampleCard from "@components/molecules/omnigraph-static-example/St import CurlStaticCodeExample from "@components/molecules/omnigraph-static-example/CurlStaticCodeExample.astro"; import SdkStaticCodeExample from "@components/molecules/omnigraph-static-example/SdkStaticCodeExample.astro"; import GraphqlStaticQueryExample from "@components/molecules/omnigraph-static-example/GraphqlStaticQueryExample.astro"; +import ExampleRequirementAside from "@components/molecules/omnigraph-static-example/ExampleRequirementAside.astro"; import { exampleTabClass } from "@components/molecules/omnigraph-static-example/constants"; import { resolveOmnigraphStaticExample } from "@lib/examples/omnigraph/resolve-static-example"; @@ -37,6 +38,8 @@ const { description } = getOmnigraphExampleById(id); const data = resolveOmnigraphStaticExample(id); --- + + { !hideDescription && (
diff --git a/docs/ensnode.io/src/content/docs/docs/hosted-instances.mdx b/docs/ensnode.io/src/content/docs/docs/hosted-instances.mdx index 5fe581916..c3684c991 100644 --- a/docs/ensnode.io/src/content/docs/docs/hosted-instances.mdx +++ b/docs/ensnode.io/src/content/docs/docs/hosted-instances.mdx @@ -101,7 +101,7 @@ These ENSNode instances do not constrain themselves to exclusively activating th connectWithENSAdminURL="https://admin.ensnode.io/connection?connection=https%3A%2F%2Fapi.alpha.ensnode.io" namespace="mainnet" ensVersions="ENSv1 only (not also ENSv2 yet)" - plugins={`${pluginLink("unigraph")}, ${pluginLink("protocol-acceleration")}, ${pluginLink("subgraph")}, ${pluginLink("basenames")}, ${pluginLink("lineanames")}, ${pluginLink("threedns")}, ${pluginLink("registrars")}, ${pluginLink("tokenscope")}`} + plugins={`${pluginLink("unigraph")}, ${pluginLink("protocol-acceleration")}, ${pluginLink("subgraph")}, ${pluginLink("basenames")}, ${pluginLink("lineanames")}, ${pluginLink("threedns")}, ${pluginLink("registrars")}, ${pluginLink("tokenscope")}, ${pluginLink("efp")}`} > diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensnode-plugins.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensnode-plugins.mdx index 20099c149..95aebff63 100644 --- a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensnode-plugins.mdx +++ b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensnode-plugins.mdx @@ -30,23 +30,20 @@ When new ENSNode plugins are created, it becomes possible for the [ENS Omnigraph [ENSIndexer](/docs/services/ensindexer) is a reference implementation of an [ENSDb Writer](/docs/services/ensdb/concepts/glossary#ensdb-writer) that implements multiple existing ENSNode Plugin specifications. The code for each plugin inside ENSIndexer is a reference implementation of that plugin's abstract specification. [The ENSNode Plugins implemented in ENSIndexer today](https://github.com/namehash/ensnode/tree/main/apps/ensindexer/src/plugins) are: -| Plugin | Description | -| ----------------------- | ---------------------------------------------------------- | -| `unigraph` | Unified ENSv1 + ENSv2 data model with polymorphic entities | -| `protocol-acceleration` | Accelerated lookups for ENS resolution | -| `subgraph` | Legacy ENS Subgraph-compatible data model | -| `basenames` | Basenames (`.base.eth`) subname indexing | -| `lineanames` | Lineanames (`.linea.eth`) subname indexing | -| `threedns` | 3DNS (`.box`) name indexing | -| `registrars` | Registration and renewal lifecycle tracking | -| `tokenscope` | NFT tokenization of ENS names | +| Plugin | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `unigraph` | Unified ENSv1 + ENSv2 data model with polymorphic entities | +| `protocol-acceleration` | Accelerated lookups for ENS resolution | +| `subgraph` | Legacy ENS Subgraph-compatible data model | +| `basenames` | Basenames (`.base.eth`) subname indexing | +| `lineanames` | Lineanames (`.linea.eth`) subname indexing | +| `threedns` | 3DNS (`.box`) name indexing | +| `registrars` | Registration and renewal lifecycle tracking | +| `tokenscope` | NFT tokenization of ENS names | +| `efp` | [Ethereum Follow Protocol](https://efp.app/) onchain social graph (lists, records, tags) — see [EFP in the Omnigraph](/docs/integrate/omnigraph/complementary/efp) | ## New Plugins -### Under Development - -An `efp` plugin for the [Ethereum Follow Protocol](https://efp.app/) is [already under active development](https://github.com/Quantumlyy/efpnode/tree/main/packages/ensnode-plugin-efp) by the [EthId team](https://ethid.org/). - ### Build Your Own If you are interested in defining a new ENSNode plugin or implementing an existing one, reach out to the NameHash Labs team — we are happy to provide support and additional info. Join us on [Telegram](https://t.me/ensnode) for any questions. diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/complementary/efp.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/complementary/efp.mdx new file mode 100644 index 000000000..2fa882a76 --- /dev/null +++ b/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/complementary/efp.mdx @@ -0,0 +1,76 @@ +--- +title: Ethereum Follow Protocol (EFP) +description: Query the Ethereum Follow Protocol (EFP) onchain social graph through the ENS Omnigraph API by enabling the efp plugin. +sidebar: + label: EFP (Social Graph) +--- + +import { Aside, LinkCard } from "@astrojs/starlight/components"; +import OmnigraphStaticExampleSet from "@components/organisms/OmnigraphStaticExampleSet.astro"; + +The [Ethereum Follow Protocol (EFP)](https://efp.app) is an onchain social graph for Ethereum accounts — a decentralized "who follows whom" primitive. Because EFP composes directly with ENS identity, the ENS Omnigraph API exposes it alongside ENS data, so you can read an account's follow graph **and** resolve each followed account's ENS name in a single query. + +## A complementary protocol for ENS + + + +## Enabling the plugin + +EFP is indexed by the **`efp` plugin**. The Omnigraph's `Query.efp` and `Account.efp` fields return data only when the connected ENSIndexer has this plugin enabled; otherwise they resolve to `null` — treat that like any other unsupported-instance case, not a query error. + +To enable it on your own instance, add `efp` to the ENSIndexer `PLUGINS` environment variable (a comma-separated list), for example: + +```bash +PLUGINS=unigraph,efp +``` + +The `efp` plugin's datasources are defined for the **mainnet** namespace (the EFP contracts on Base, OP Mainnet, and Ethereum Mainnet) and the local **ens-test-env** devnet. + +:::note[Already enabled on the Alpha instance] +The hosted [ENSNode 'Alpha'](/docs/hosted-instances#ensnode-alpha) instance has the `efp` plugin enabled, so you can run every query on this page against it without hosting anything yourself. +::: + +## What it indexes + +The plugin indexes the full EFP onchain state into ENSDb: + +- **Lists** — the ERC-721 NFTs that hold follow relationships, with their `owner` / `manager` / `user` roles and storage location. +- **List records** — the individual follows (address records), each with its **tags** (`block`, `mute`, `top8`, and custom tags). +- **Account metadata** — the onchain `(address, key) → value` store, including the `primary-list` key used to validate an account's primary list. + +## The API surface + +EFP is exposed through two entry points: + +- **`Account.efp`** — an account's EFP presence: validated, block/mute-filtered `following` / `followers` connections (whose edges are full `Account`s you can resolve straight into ENS names), the validated `primaryList`, and raw account `metadata`. +- **`Query.efp`** — list-centric queries: `list(by: { tokenId })`, `lists(where:)` by role address, and `listRecords(where:)` (the raw record set, including `block`/`mute`). + +Prefer `Account.efp.following` / `followers` for the social-graph answer — they apply EFP's primary-list validation and block/mute filtering for you. Drop to the raw `Query.efp.listRecords` / `EfpList.records` only when you specifically need tags, storage location, or non-primary lists. + +## Example + + + +## Learn more + + + + diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/complementary/erc-8004.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/complementary/erc-8004.mdx new file mode 100644 index 000000000..72467d06d --- /dev/null +++ b/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/complementary/erc-8004.mdx @@ -0,0 +1,197 @@ +--- +title: ERC-8004 (AI Agent Identity, Discovery, and Reputation) +description: ERC-8004 AI agent identity, discovery, and reputation in the ENS Omnigraph API — find agents associated with an ENS name, look up the names behind an agent, and read agent service profiles. +sidebar: + label: AI Agents / ERC-8004 +--- + +import { Aside, LinkCard } from "@astrojs/starlight/components"; + + + +[ERC-8004](https://eips.ethereum.org/EIPS/eip-8004) is a new standard that gives an onchain AI agent a portable identity (an NFT in an Identity Registry) plus an **agent card** describing what the agent does and how to reach it. On the ENS side, [ENSIP-25](https://docs.ens.domains/ensip/25) and [ENSIP-26](https://docs.ens.domains/ensip/26) are ENS standards that let an **ENS name** publish agent discovery records and **attest** which ERC-8004 agent(s) it controls. + +The ENS Omnigraph API makes it easy to bring both sides together: start from an **ENS name** or from an **ERC-8004 agent**, and get the agent card, reputation, and the attested name-to-agent relationship in one query, with no need for RPC calls to contracts or IPFS fetching on your side. + +## A complementary protocol for ENS + + + +## Plugin + +Technically, ERC-8004 is distinct from the ENS protocol. The fields on this page come from the optional `erc8004` [ENSNode plugin](/docs/integrate/integration-options/ensnode-plugins): + +- Each ENSNode operator chooses which plugins to activate on their instance. +- The ERC-8004 fields described here always resolve to `null` values on any ENSNode instance that has not activated the optional `erc8004` plugin. + + + +## Who this feature is for + +- **AI agent developers** — you publish an **agent** under ERC-8004 and want a human-readable ENS **name** to be shown in ENS related services. +- **AI Apps and services** — you want to discover **agents** and show trustworthy identity, reputation, endpoints and connection to ENS. +- Apps that want to extend their display of onchain AI agent related identities with complementary data from ENS. +- Apps that want to extend their display of ENS identities to include AI agent related attributes of that identity where relevant. + +## Example Queries + +### Find ERC-8004 agents associated with an ENS name + +Start from an ENS name. The agent data lives under `domain.resolve.profile.erc8004` — this is **interpreted ENS Resolution** (see [ENS Resolution](/docs/integrate/omnigraph/ens-resolution)): the ENS Omnigraph API reads the underlying ENSIP-25/26 records and returns clean, structured fields instead of raw text records. If you want the raw records instead, the ENS Omnigraph API exposes them too via `domain.resolve.records`. + +Each entry in `attestations` is one ENSIP-25 attestation, with its `verification` status and the indexed ERC-8004 agent nested inline. By default `attestations` returns only `VERIFIED` attestations; pass `where` to include the partial ones. + +```graphql ins={8-29} +query AgentsForName { + domain(by: { name: "myagent.eth" }) { + canonical { + name { beautified } + } + resolve { + profile { + erc8004 { + context + attestations(where: { verification: [VERIFIED, ONLY_ENS, ONLY_AGENT] }) { + verification # ONLY_ENS | ONLY_AGENT | VERIFIED + identityRegistry { chainId address } + agentId + agent { + agentId + chain { name } + card { + name + description + x402Support + supportedTrust + } + reputation { + feedbackCount + averageScore + } + } + } + } + } + } + } +} +``` + +- **Gate on `verification`** — since ENSIP-25 is a **bidirectional** relationship, an ENS name can attest to an agent, and an agent can reference an ENS name. This enum covers three cases: + - **`VERIFIED`** — both sides agree: the ENS name attests the agent, and the agent references the name. + - **`ONLY_ENS`** — the ENS name attests the agent, but the agent does not reference the name back. + - **`ONLY_AGENT`** — the agent references the name, but the ENS name does not attest the agent. +- **A name may have several attestations** — in theory, a name can have several agents, so `attestations` is a list. Iterate and pick what you need. +- **Everything in one round trip** — the agent card and reputation come back nested under each attestation; no second request. + +### Look up the ENS names associated with an ERC-8004 agent + +When you already have an agent (from a registry, a wallet, or search), query it directly. +The reverse view `domains.attestations` returns the ENS names that attest to this agent. + +```graphql +query AgentById { + erc8004agent(by: { + chainName: ETHEREUM, + agentId: "34820" + }) { + agentId + owner { address } + card { + name + description + supportedTrust + } + reputation { + feedbackCount + averageScore + } + domains { + attestations(where: { verification: [VERIFIED, ONLY_ENS, ONLY_AGENT] }) { + verification + context + name { beautified } + } + } + } +} +``` + +### Search for ERC-8004 agents by indexed metadata + +Find agents by chain, card name, or ENS name. + +```graphql +query FindErc8004Agents { + erc8004agents( + first: 20 + where: { + chainName: { eq: ETHEREUM } + cardName: { contains: "trading" } + ensName: { contains: "eth" } + } + ) { + totalCount + edges { + node { + agentId + card { name description } + domains { + attestations { verification name { beautified } } + } + } + } + } +} +``` + +### Read an ERC-8004 agent service profile + +Read the endpoint you want to talk to from the agent card. `attestations` returns only `VERIFIED` attestations by default, so no `where` filter is needed here. `protocol` is an `AgentServiceProtocol` enum (`MCP`, `A2A`, `X402`, `WEB`, ...), and you can pass `protocols` to fetch only the services you care about. + +```graphql +query AgentServices { + domain(by: { name: "myagent.eth" }) { + resolve { + profile { + erc8004 { + attestations { + verification + agent { + card { + services(protocols: [MCP, A2A]) { + protocol + endpoint + } + } + } + } + } + } + } + } +} +``` + +Since only `VERIFIED` attestations are returned by default, pass the `protocols` you need (for example `[MCP]`) and use the returned `endpoint`. diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/examples/efp-follow-graph.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/examples/efp-follow-graph.mdx new file mode 100644 index 000000000..5f5420e43 --- /dev/null +++ b/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/examples/efp-follow-graph.mdx @@ -0,0 +1,7 @@ +--- +title: EFP Follow Graph +--- + +import OmnigraphStaticExampleSet from "@components/organisms/OmnigraphStaticExampleSet.astro"; + + diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/examples/efp-list.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/examples/efp-list.mdx new file mode 100644 index 000000000..ab0fc7ab2 --- /dev/null +++ b/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/examples/efp-list.mdx @@ -0,0 +1,7 @@ +--- +title: EFP List +--- + +import OmnigraphStaticExampleSet from "@components/organisms/OmnigraphStaticExampleSet.astro"; + + diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/examples/efp-who-follows.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/examples/efp-who-follows.mdx new file mode 100644 index 000000000..eeee2bea6 --- /dev/null +++ b/docs/ensnode.io/src/content/docs/docs/integrate/omnigraph/examples/efp-who-follows.mdx @@ -0,0 +1,7 @@ +--- +title: EFP Who Follows +--- + +import OmnigraphStaticExampleSet from "@components/organisms/OmnigraphStaticExampleSet.astro"; + + diff --git a/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx b/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx index a9f7a322f..b665f2c76 100644 --- a/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx +++ b/docs/ensnode.io/src/content/docs/docs/services/ensdb/concepts/database-schemas.mdx @@ -528,3 +528,98 @@ After an NFT is indexed, it is never deleted from the index. When an indexed NFT | `mint_status` | `text` | no | Either `minted` or `burned`. | **Indexes:** `domain_id`, `owner`. + +--- + +### EFP + +Defined in [`efp.schema.ts`](https://github.com/namehash/ensnode/blob/main/packages/ensdb-sdk/src/ensindexer-abstract/efp.schema.ts). + +Indexes the [Ethereum Follow Protocol (EFP)](/docs/integrate/omnigraph/complementary/efp) onchain social graph — list NFTs, the records and tags they hold, and account metadata. Tables are prefixed `efp_`. The model mirrors the [ethereumfollowprotocol/api-v2](https://github.com/ethereumfollowprotocol/api) reference indexer, with two adaptations for ENSNode's primary-key-only access pattern: `efp_list_storage_locations` is a reverse index so list-metadata events resolve the owning list NFT by primary key, and a record's tags are embedded as an array on `efp_list_records` (rather than a separate join table) so removing a record is a single primary-key delete. See the [EFP Omnigraph reference](/docs/integrate/omnigraph/complementary/efp) for the higher-level API over this data. + +#### efp_lists + +One row per minted `ListRegistry` NFT (a "list"). EFP separates the NFT `owner`, the `user` allowed to post records, and the `manager` allowed to administer the list. The `list_storage_location_*` columns describe which `(chain_id, contract_address, slot)` tuple in `efp_list_records` stores this list's records. + +| Column | Type | Nullable | Description | +| ---------------------------------------- | ------------- | -------- | ---------------------------------------------------------------------- | +| `id` | `numeric(78)` | no | ERC-721 token id of the list NFT (a `uint256`). Primary key. | +| `owner` | `text` | no | Current ERC-721 owner of the list NFT. | +| `nft_chain_id` | `bigint` | no | Chain id of the `ListRegistry` NFT (Base / `8453` on mainnet). | +| `nft_contract_address` | `text` | no | `ListRegistry` contract address on `nft_chain_id`. | +| `list_storage_location` | `text` | yes | Raw `UpdateListStorageLocation` payload. | +| `list_storage_location_chain_id` | `bigint` | yes | Decoded list storage location: target chain id. `null` if undecodable. | +| `list_storage_location_contract_address` | `text` | yes | Decoded list storage location: target contract address. | +| `list_storage_location_slot` | `text` | yes | Decoded list storage location: target slot (`bytes32`). | +| `user` | `text` | yes | Address allowed to post records to this list (the EFP "user"). | +| `manager` | `text` | yes | Address allowed to administer this list (the EFP "manager"). | +| `created_at` | `numeric(78)` | no | Unix timestamp when the list was first indexed. | +| `updated_at` | `numeric(78)` | no | Unix timestamp of the most recent update. | + +**Indexes:** `owner`, `user`, `manager`, `(list_storage_location_chain_id, list_storage_location_contract_address, list_storage_location_slot)`. The `numeric` primary key already orders numerically, so list pagination needs no extra index. + +#### efp_list_storage_locations + +Reverse index from a storage location `(chain_id, contract_address, slot)` to the list NFT that points at it, so `UpdateListMetadata` events (keyed only by slot) can resolve the owning list NFT by primary key instead of scanning `efp_lists`. The slot is arbitrary, attacker-settable bytes, so multiple list NFTs can point at the same location; this records the first list to claim it (first writer wins). + +| Column | Type | Nullable | Description | +| ------------------ | ------------- | -------- | ---------------------------------------------------------------------------- | +| `id` | `text` | no | Composite key `{chain_id}-{contract_address}-{slot}`. Primary key. | +| `chain_id` | `bigint` | no | Target chain id of the storage location. | +| `contract_address` | `text` | no | `ListRecords` contract address. | +| `slot` | `text` | no | Storage slot (`bytes32`). | +| `token_id` | `numeric(78)` | no | Token id of the list NFT that owns this reverse mapping (first writer wins). | +| `updated_at` | `numeric(78)` | no | Unix timestamp of the most recent update. | + +#### efp_list_records + +One row per record currently in a list. `record` is the canonical `version | type | address` 22-byte prefix (any trailing junk after the address truncated), which is also what tag and remove ops reference. A record's `tags` are embedded here as a set, so removing a record drops its tags in the same primary-key delete. + +| Column | Type | Nullable | Description | +| ------------------ | ------------- | -------- | --------------------------------------------------------------------------------------- | +| `id` | `text` | no | Composite key `{chain_id}-{contract_address}-{slot}-{record}`. Primary key. | +| `chain_id` | `bigint` | no | Chain id where the records are stored. | +| `contract_address` | `text` | no | `ListRecords` contract address. | +| `slot` | `text` | no | Storage slot (`bytes32`) of the owning list. | +| `record` | `text` | no | Canonical record prefix `version \| type \| address` (22 bytes). | +| `record_version` | `integer` | no | Decoded record header — version byte. | +| `record_type` | `integer` | no | Decoded record header — type byte (only `1` = address record is indexed). | +| `record_data` | `text` | no | Decoded record data — the 20-byte target address. | +| `tags` | `text[]` | no | UTF-8 tags attached to this record (a set; e.g. `block`, `mute`, `top8`). Default `[]`. | +| `created_at` | `numeric(78)` | no | Unix timestamp when the record was added. | + +**Indexes:** `(chain_id, contract_address, slot)`, `record_data`. + +#### efp_account_metadata + +Most-recent `value` per `(chain_id, address, key)` account-metadata pair (today only `primary-list`). + +| Column | Type | Nullable | Description | +| ----------------------- | ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `text` | no | Composite key `{chain_id}-{address}-{key}`. Primary key. | +| `chain_id` | `bigint` | no | Chain id of the `AccountMetadata` contract. | +| `contract_address` | `text` | no | `AccountMetadata` contract address. | +| `address` | `text` | no | Account whose metadata this is. | +| `key` | `text` | no | Metadata key (UTF-8 string), e.g. `primary-list`. | +| `value` | `text` | no | Metadata value (raw bytes). | +| `primary_list_token_id` | `numeric(78)` | yes | For the `primary-list` key only: the decoded token id of the account's primary list. `null` for any other key, or a malformed value. | +| `created_at` | `numeric(78)` | no | Unix timestamp when first indexed. | +| `updated_at` | `numeric(78)` | no | Unix timestamp of the most recent update. | + +**Indexes:** `address`, `(address, key)`, `primary_list_token_id`. + +#### efp_list_metadata + +EFP List Metadata (`user` / `manager`), keyed by the storage location it is set at (`{chain_id}-{contract_address}-{slot}-{key}`), not by list NFT. `UpdateListMetadata` is emitted on the `ListRecords` contract while the storage-location mapping is created by `UpdateListStorageLocation` on the `ListRegistry` contract (a different contract, sometimes on a different chain), so the two can arrive in either order. The value here is durable: it survives a list re-pointing its storage location. + +| Column | Type | Nullable | Description | +| ------------------ | ------------- | -------- | ------------------------------------------------------------------------ | +| `id` | `text` | no | Composite key `{chain_id}-{contract_address}-{slot}-{key}`. Primary key. | +| `chain_id` | `bigint` | no | Target chain id of the storage location. | +| `contract_address` | `text` | no | `ListRecords` contract address. | +| `slot` | `text` | no | Storage slot (`bytes32`). | +| `key` | `text` | no | Metadata key (`user` or `manager`). | +| `value` | `text` | no | Metadata value (raw bytes). | +| `created_at` | `numeric(78)` | no | Unix timestamp when first indexed. | + +**Indexes:** `(chain_id, contract_address, slot)`. diff --git a/docs/ensnode.io/src/data/omnigraph-examples/config.ts b/docs/ensnode.io/src/data/omnigraph-examples/config.ts index 1aaa478cb..5f08fe47b 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/config.ts +++ b/docs/ensnode.io/src/data/omnigraph-examples/config.ts @@ -213,6 +213,33 @@ export const OMNIGRAPH_EXAMPLES_CONFIG: OmnigraphExampleConfig[] = [ namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: false, }, + { + id: "efp-follow-graph", + title: "EFP Follow Graph", + description: + "This query loads an account's validated EFP social graph — its primary list, following, and followers — and resolves each followed account's ENS primary name. Requires the efp plugin.", + category: "EFP", + namespace: ENSNamespaceIds.Mainnet, + hostSeparatePage: true, + }, + { + id: "efp-who-follows", + title: "EFP Who Follows", + description: + 'This query finds the raw EFP list records pointing at an address (any list, including block/mute tags), to answer "which lists follow this address?". Requires the efp plugin.', + category: "EFP", + namespace: ENSNamespaceIds.Mainnet, + hostSeparatePage: true, + }, + { + id: "efp-list", + title: "EFP List", + description: + "This query loads an EFP list by tokenId — its roles, storage location, and raw records with tags. Requires the efp plugin.", + category: "EFP", + namespace: ENSNamespaceIds.Mainnet, + hostSeparatePage: true, + }, ]; export const OMNIGRAPH_EXAMPLES_INDEX_PATH = "/docs/integrate/omnigraph/examples" as const; diff --git a/docs/ensnode.io/src/data/omnigraph-examples/examples.json b/docs/ensnode.io/src/data/omnigraph-examples/examples.json index 6b8b4bef5..614439f4b 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/examples.json +++ b/docs/ensnode.io/src/data/omnigraph-examples/examples.json @@ -109,7 +109,7 @@ "variables": { "registry": { "chainId": 11155111, - "address": "0x64c81210d0e580cfc7746f3fb910bf0e8f6378e1" + "address": "0xdedb92913a25abe1f7bcdd85d8a344a43b398b67" } } }, @@ -119,7 +119,7 @@ "variables": { "contract": { "chainId": 11155111, - "address": "0x64c81210d0e580cfc7746f3fb910bf0e8f6378e1" + "address": "0xdedb92913a25abe1f7bcdd85d8a344a43b398b67" } } }, @@ -167,5 +167,26 @@ "variables": { "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" } + }, + { + "id": "efp-follow-graph", + "query": "query EfpFollowGraph($address: Address!) {\n account(by: { address: $address }) {\n efp {\n # The validated primary list, or null if unset/unvalidated.\n primaryList { tokenId }\n # following/followers are validated and block/mute-filtered, so the\n # edges are full Accounts you can walk straight into ENS names.\n following(first: 10) {\n totalCount\n edges {\n node {\n address\n resolve {\n primaryName(by: { chainName: ETHEREUM }) {\n name { beautified }\n }\n }\n }\n }\n }\n followers(first: 10) {\n totalCount\n edges { node { address } }\n }\n }\n }\n}", + "variables": { + "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" + } + }, + { + "id": "efp-who-follows", + "query": "# Raw record view: includes block/mute and non-primary lists — inspect the tags.\n# For the validated social graph, prefer Account.efp.followers instead.\nquery EfpWhoFollows($address: Address!) {\n efp {\n listRecords(where: { recordData: $address }, first: 25) {\n totalCount\n edges {\n node {\n tags\n list { tokenId user }\n }\n }\n }\n }\n}", + "variables": { + "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" + } + }, + { + "id": "efp-list", + "query": "query EfpList($tokenId: TokenId!) {\n efp {\n list(by: { tokenId: $tokenId }) {\n tokenId\n owner\n user\n manager\n storageLocation { chainId address slot }\n # Raw records (any tag, no primary-list validation).\n records(first: 25) {\n totalCount\n edges { node { recordData tags } }\n }\n }\n }\n}", + "variables": { + "tokenId": "1" + } } ] diff --git a/docs/ensnode.io/src/data/omnigraph-examples/responses.json b/docs/ensnode.io/src/data/omnigraph-examples/responses.json index febc201df..44b8a295b 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/responses.json +++ b/docs/ensnode.io/src/data/omnigraph-examples/responses.json @@ -6470,5 +6470,569 @@ } } } + }, + "efp-follow-graph": { + "data": { + "account": { + "efp": { + "primaryList": { + "tokenId": "6509" + }, + "following": { + "totalCount": 10, + "edges": [ + { + "node": { + "address": "0x1b63142628311395ceafeea5667e7c9026c862ca", + "resolve": { + "primaryName": { + "name": { + "beautified": "tgerring.eth" + } + } + } + } + }, + { + "node": { + "address": "0x2b888954421b424c5d3d9ce9bb67c9bd47537d12", + "resolve": { + "primaryName": { + "name": { + "beautified": "lefteris.eth" + } + } + } + } + }, + { + "node": { + "address": "0x50ec05ade8280758e2077fcbc08d878d4aef79c3", + "resolve": { + "primaryName": { + "name": { + "beautified": "hayden.eth" + } + } + } + } + }, + { + "node": { + "address": "0x54becc7560a7be76d72ed76a1f5fee6c5a2a7ab6", + "resolve": { + "primaryName": { + "name": { + "beautified": "simona.eth" + } + } + } + } + }, + { + "node": { + "address": "0x5b76f5b8fc9d700624f78208132f91ad4e61a1f0", + "resolve": { + "primaryName": { + "name": { + "beautified": "barmstrong.eth" + } + } + } + } + }, + { + "node": { + "address": "0x648aa14e4424e0825a5ce739c8c68610e143fb79", + "resolve": { + "primaryName": { + "name": { + "beautified": "sassal.eth" + } + } + } + } + }, + { + "node": { + "address": "0x983110309620d911731ac0932219af06091b6744", + "resolve": { + "primaryName": { + "name": { + "beautified": "brantly.eth" + } + } + } + } + }, + { + "node": { + "address": "0xb8c2c29ee19d8307cb7255e1cd9cbde883a267d5", + "resolve": { + "primaryName": { + "name": { + "beautified": "nick.eth" + } + } + } + } + }, + { + "node": { + "address": "0xd7029bdea1c17493893aafe29aad69ef892b8ff2", + "resolve": { + "primaryName": { + "name": null + } + } + } + }, + { + "node": { + "address": "0xf56345338cb4cddaf915ebef3bfde63e70fe3053", + "resolve": { + "primaryName": { + "name": { + "beautified": "bored.eth" + } + } + } + } + } + ] + }, + "followers": { + "edges": [ + { + "node": { + "address": "0x00000066ced4c62a0f740de74eb08bc2a1552764" + } + }, + { + "node": { + "address": "0x000000dcf1190af44f7149b85299f18ce7221024" + } + }, + { + "node": { + "address": "0x000066b5a8e1e35b7e2f64e24715829647f80000" + } + }, + { + "node": { + "address": "0x00076993122809055293d8210a60d8265a7e17f3" + } + }, + { + "node": { + "address": "0x000f16ca1e73a0b6b10e1ed4cc5f8232336c1482" + } + }, + { + "node": { + "address": "0x0011e9582ac18d9b80204817eaa2e3b544e727aa" + } + }, + { + "node": { + "address": "0x00173c4cb23e6d876fcb036ba954a2f9cfcafa19" + } + }, + { + "node": { + "address": "0x00197ff7abdac8ad42152ff9d5bd9e521701a944" + } + }, + { + "node": { + "address": "0x002153708f11f2651215059eea30820ee4d49ff3" + } + }, + { + "node": { + "address": "0x003e7b75ff09615ad4a6f763dc7d28e48ed513d9" + } + } + ], + "totalCount": 5338 + } + } + } + } + }, + "efp-who-follows": { + "data": { + "efp": { + "listRecords": { + "totalCount": 6085, + "edges": [ + { + "node": { + "tags": [], + "list": null + } + }, + { + "node": { + "tags": [], + "list": null + } + }, + { + "node": { + "tags": [], + "list": null + } + }, + { + "node": { + "tags": [], + "list": null + } + }, + { + "node": { + "tags": ["top8"], + "list": { + "tokenId": "2400", + "user": "0x9760721a78fcff74a61440a06dbcb2a0ab2e4f89" + } + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "31294", + "user": "0x3a4bbb42c18e1bb24edbd6e00aef4ea023da960c" + } + } + }, + { + "node": { + "tags": [], + "list": null + } + }, + { + "node": { + "tags": [], + "list": null + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "39897", + "user": "0x8e79ef9e545fa14e205d89970d50e7caa3456683" + } + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "388", + "user": "0xbe4f0cdf3834bd876813a1037137dcfad79acd99" + } + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "55439", + "user": "0xe4ab754481e555fdd76d93d85b42dcbf56a8ab1e" + } + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "39191", + "user": "0xff2d23446f7726da0512c19525262612c4e83d27" + } + } + }, + { + "node": { + "tags": [], + "list": null + } + }, + { + "node": { + "tags": [], + "list": null + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "16149", + "user": "0x225f137127d9067788314bc7fcc1f36746a3c3b5" + } + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "33148", + "user": "0x3ce7241a8c5c291e50a0d027dd9badff9db1fd7c" + } + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "5056", + "user": "0x52fc2a810b96b333c303e48f95f6f88a2acf5d69" + } + } + }, + { + "node": { + "tags": [], + "list": null + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "52365", + "user": "0xb5a9b9e2aec901d1b857138fc93c932f350a60ed" + } + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "55011", + "user": "0x2a811f1e11636c144a2a062d3d402245a43d4074" + } + } + }, + { + "node": { + "tags": [], + "list": null + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "45610", + "user": "0x7f7720bdb2cb5c13dd30a0c8ab8d0dd553b31caa" + } + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "43738", + "user": "0xdf095ca41af452ed9ed390d8fac260fbdad20976" + } + } + }, + { + "node": { + "tags": [], + "list": null + } + }, + { + "node": { + "tags": [], + "list": { + "tokenId": "45079", + "user": "0x2e6728f76d949753f2e7dcee33f8136cf7e4e5f0" + } + } + } + ] + } + } + } + }, + "efp-list": { + "data": { + "efp": { + "list": { + "tokenId": "1", + "owner": "0xc983ebc9db969782d994627bdffec0ae6efee1b3", + "user": "0xc983ebc9db969782d994627bdffec0ae6efee1b3", + "manager": "0xc983ebc9db969782d994627bdffec0ae6efee1b3", + "storageLocation": { + "chainId": 8453, + "address": "0x41aa48ef3c0446b46a5b1cc6337ff3d3716e2a33", + "slot": "0x5659892f4e1f565ebd9f7504a28177ca927325e4104d0c6787c7e8a00dcda60c" + }, + "records": { + "totalCount": 452, + "edges": [ + { + "node": { + "recordData": "0x0000ce08fa224696a819877070bf378e8b131acf", + "tags": [] + } + }, + { + "node": { + "recordData": "0x000f16ca1e73a0b6b10e1ed4cc5f8232336c1482", + "tags": [] + } + }, + { + "node": { + "recordData": "0x009ae2d1a7240cfc89095fab06ed24f1641d9169", + "tags": [] + } + }, + { + "node": { + "recordData": "0x00c7ecc4187f239e09d4cfea5abe6d3c18f923bc", + "tags": [] + } + }, + { + "node": { + "recordData": "0x021021ccee934b346160342f8d7f59f514c08c56", + "tags": [] + } + }, + { + "node": { + "recordData": "0x025376e7e7f161a198fb5fc90a220a553836d11a", + "tags": [] + } + }, + { + "node": { + "recordData": "0x027fc636db3ced7342581baed6eee79f7d1123c3", + "tags": [] + } + }, + { + "node": { + "recordData": "0x02c5b7c40daaaf3aac3dd2ff39013cdd0f1fb4ae", + "tags": [] + } + }, + { + "node": { + "recordData": "0x0331969e189d63fbc31d771bb04ab44227d748d8", + "tags": [] + } + }, + { + "node": { + "recordData": "0x035ebd096afa6b98372494c7f08f3402324117d3", + "tags": [] + } + }, + { + "node": { + "recordData": "0x041d311ac26e478ea6e1753ec354676c452db9d8", + "tags": [] + } + }, + { + "node": { + "recordData": "0x0446bc2beabb16aa542cd99eaff3e63af37125b4", + "tags": [] + } + }, + { + "node": { + "recordData": "0x049569adb8a1e8a9349e9f1111c7b7993a4612eb", + "tags": [] + } + }, + { + "node": { + "recordData": "0x054becf10c79afa4e60f75b187837be9fb83867d", + "tags": [] + } + }, + { + "node": { + "recordData": "0x05977b2fb9b7ab3f3733b34350044a01a388579c", + "tags": [] + } + }, + { + "node": { + "recordData": "0x05a1ff0a32bc24265bcb39499d0c5d9a6cb2011c", + "tags": [] + } + }, + { + "node": { + "recordData": "0x06ce84f051ce93c2fe99344bc8c9d2bf92e64409", + "tags": [] + } + }, + { + "node": { + "recordData": "0x074470b9a32f68de86fac393a10d5cea01c54269", + "tags": [] + } + }, + { + "node": { + "recordData": "0x0865c5de7fe349aa5b7f6ef721f8c2b9cfe5e40d", + "tags": [] + } + }, + { + "node": { + "recordData": "0x092ad3842dced68b5b6ef9cc602be66924baecdd", + "tags": [] + } + }, + { + "node": { + "recordData": "0x09a78e1abe7a893c43986dbf7864c79d03860bde", + "tags": [] + } + }, + { + "node": { + "recordData": "0x0c077910e1dc0b5bea1054cce14143e0971f95ce", + "tags": [] + } + }, + { + "node": { + "recordData": "0x0d1f2c041b7f9702903b89057c1f57f1f01a79af", + "tags": [] + } + }, + { + "node": { + "recordData": "0x0d3f5a7a1ee78e743e25c18e66942fcbcd84ccad", + "tags": [] + } + }, + { + "node": { + "recordData": "0x0de5a5d50453e702ad66d3fcf7c986f408019ffd", + "tags": [] + } + } + ] + } + } + } + } } } diff --git a/docs/ensnode.io/src/data/omnigraph-examples/schema.graphql b/docs/ensnode.io/src/data/omnigraph-examples/schema.graphql index 9fbe48746..ed4321927 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/schema.graphql +++ b/docs/ensnode.io/src/data/omnigraph-examples/schema.graphql @@ -34,6 +34,11 @@ type Account { where: AccountDomainsWhereInput ): AccountDomainsConnection + """ + This Account's Ethereum Follow Protocol (EFP) presence: its lists, validated primary list, and account metadata. Null when the connected ENSIndexer does not have the `efp` plugin enabled. + """ + efp: AccountEfp + """ All Events for which this Account is the HCA-aware `sender` (i.e. `Event.sender`). """ @@ -50,6 +55,17 @@ type Account { """ id: Address! + """ + The Names whose indexed `addr()` record points at this Account, optionally scoped to a single CoinType. Reflects literally-indexed, Canonical Domains only: records whose node has no Canonical Domain are omitted. + """ + nameReferences( + after: String + before: String + first: Int + last: Int + where: AccountNameReferencesWhereInput + ): AccountNameReferencesConnection + """ The Permissions granted to this Account, optionally filtered to Permissions in a specific contract. """ @@ -132,6 +148,85 @@ input AccountDomainsWhereInput { version: ENSProtocolVersion } +""" +An account's Ethereum Follow Protocol (EFP) presence. +""" +type AccountEfp { + """ + The accounts that follow this account: those whose validated primary EFP list holds this account as a non-`block`/`mute` record. + """ + followers(after: String, before: String, first: Int, last: Int): AccountEfpFollowersConnection + + """ + The accounts this account follows: the address records in its validated primary EFP list, excluding `block`/`mute`-tagged records. Empty when the account has no validated primary list. + """ + following(after: String, before: String, first: Int, last: Int): AccountEfpFollowingConnection + + """ + The EFP lists this account is the `user` of (the lists representing it). + """ + lists(after: String, before: String, first: Int, last: Int): AccountEfpListsConnection + + """ + Get one of this account's EFP account-metadata values by key (e.g. "primary-list"). + """ + metadata(key: String!): EfpAccountMetadata + + """ + All of this account's EFP account-metadata entries. + """ + metadatas(after: String, before: String, first: Int, last: Int): AccountEfpMetadatasConnection + + """ + The account's validated primary EFP list: the list named by its `primary-list` metadata, returned only if that list's `user` role matches the account (the EFP two-step Primary List validation). Null if unset or unvalidated. + """ + primaryList: EfpList +} + +type AccountEfpFollowersConnection { + edges: [AccountEfpFollowersConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type AccountEfpFollowersConnectionEdge { + cursor: String! + node: Account! +} + +type AccountEfpFollowingConnection { + edges: [AccountEfpFollowingConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type AccountEfpFollowingConnectionEdge { + cursor: String! + node: Account! +} + +type AccountEfpListsConnection { + edges: [AccountEfpListsConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type AccountEfpListsConnectionEdge { + cursor: String! + node: EfpList! +} + +type AccountEfpMetadatasConnection { + edges: [AccountEfpMetadatasConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type AccountEfpMetadatasConnectionEdge { + cursor: String! + node: EfpAccountMetadata! +} + type AccountEventsConnection { edges: [AccountEventsConnectionEdge!]! pageInfo: PageInfo! @@ -179,6 +274,27 @@ input AccountIdInput { chainId: ChainId! } +type AccountNameReferencesConnection { + edges: [AccountNameReferencesConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type AccountNameReferencesConnectionEdge { + cursor: String! + node: NameReference! +} + +""" +Filter for Account.nameReferences. +""" +input AccountNameReferencesWhereInput { + """ + If set, scopes matches to a single CoinType. When omitted, returns matches across all CoinTypes. + """ + coinType: CoinType +} + type AccountPermissionsConnection { edges: [AccountPermissionsConnectionEdge!]! pageInfo: PageInfo! @@ -379,6 +495,19 @@ CoinType represents an enssdk#CoinType. """ scalar CoinType +""" +The ENSIP-7 contenthash protocol type (e.g. IPFS, IPNS, Swarm, Arweave). +""" +enum ContenthashProtocol { + ARWEAVE + IPFS + IPNS + ONION + ONION3 + SKYNET + SWARM +} + """ DogecoinAddress represents a Base58Check-encoded Dogecoin address (coin type 3). """ @@ -561,6 +690,11 @@ type DomainProfile { """ avatar: ProfileAvatar + """ + The interpreted ENSIP-7 contenthash on the profile of an ENS name. Returns null when the raw contenthash record is unset, empty, or cannot be decoded (unknown codec or malformed bytes). See https://docs.ens.domains/ensip/7. + """ + contenthash: ProfileContenthash + """ The interpreted description on the profile of an ENS name, or null when unset. """ @@ -1166,6 +1300,269 @@ type ENSv2RegistryReservation implements Registration { unregistrant: Account } +""" +An EFP `(address, key) -> value` account-metadata entry (e.g. "primary-list"). +""" +type EfpAccountMetadata { + """ + The account this metadata belongs to. + """ + address: Address! + + """ + The CAIP-10 account id of the AccountMetadata contract. + """ + contract: AccountId! + + """ + When this metadata entry was first indexed (Unix timestamp, seconds). + """ + createdAt: BigInt! + id: ID! + + """ + The metadata key (UTF-8 string). + """ + key: String! + + """ + When this metadata entry was last updated (Unix timestamp, seconds). + """ + updatedAt: BigInt! + + """ + The metadata value (raw bytes). + """ + value: Hex! +} + +""" +An EFP list NFT (a ListRegistry token) and the records it holds. +""" +type EfpList { + """ + When this list was first indexed (Unix timestamp, seconds). + """ + createdAt: BigInt! + + """ + The address allowed to administer this list. + """ + manager: Address + + """ + The list NFT's AccountId. + """ + nft: AccountId! + + """ + The current ERC-721 owner of the list NFT. + """ + owner: Address! + + """ + The records currently in this list (the addresses it follows). + """ + records(after: String, before: String, first: Int, last: Int): EfpListRecordsConnection + + """ + The decoded onchain location of this list's records, or null if not known. + """ + storageLocation: EfpListStorageLocation + + """ + The ERC-721 TokenId of the list NFT. + """ + tokenId: TokenId! + + """ + When this list was last updated (Unix timestamp, seconds). + """ + updatedAt: BigInt! + + """ + The address allowed to post records to this list. + """ + user: Address +} + +""" +Identify an EFP list by its NFT token id. +""" +input EfpListByInput { + """ + The ERC-721 token id of the list NFT. + """ + tokenId: TokenId! +} + +""" +A single record within an EFP list (an address it follows), with its tags. +""" +type EfpListRecord { + """ + The Account this record points to (its `recordData`). Always resolvable: an Account exists for any address (see `query.account`), so a record's target can always be walked into its ENS names and own EFP presence. + """ + account: Account! + + """ + The CAIP-10 account id of the ListRecords contract holding this record. + """ + contract: AccountId! + + """ + When this record was first indexed (Unix timestamp, seconds). + """ + createdAt: BigInt! + id: ID! + + """ + The EFP list this record belongs to. + """ + list: EfpList + + """ + Canonical record bytes (version | type | address), 0x-prefixed (exactly 22 bytes), with any trailing junk after the 20-byte address truncated. + """ + record: Hex! + + """ + The followed/target address (the record's 20-byte payload). EFP indexes only address records (recordType 1). + """ + recordData: Address! + + """ + The EFP record type (1 = address). + """ + recordType: Int! + + """ + The list's storage slot (bytes32) within the ListRecords contract. + """ + slot: Hex! + + """ + UTF-8 tags attached to this record (e.g. "close-friend", "block"). + """ + tags: [String!]! +} + +type EfpListRecordsConnection { + edges: [EfpListRecordsConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EfpListRecordsConnectionEdge { + cursor: String! + node: EfpListRecord! +} + +""" +Filter EFP list records. +""" +input EfpListRecordsWhereInput { + """ + The target address of an address record (recordType 1). Filtering by this answers 'which lists follow this address?'. + """ + recordData: Address + + """ + The EFP record type (1 = address). + """ + recordType: Int +} + +""" +The decoded onchain location of a list's records: a (chainId, ListRecords contract address, slot) tuple. +""" +type EfpListStorageLocation { + """ + The ListRecords contract address holding the list's records. + """ + address: Address! + chainId: ChainId! + + """ + The list's storage slot (bytes32) within the ListRecords contract. + """ + slot: Hex! +} + +""" +Filter EFP lists by their owner, user, or manager address. +""" +input EfpListsWhereInput { + """ + The address allowed to administer the list. + """ + manager: Address + + """ + The ERC-721 owner of the list NFT. + """ + owner: Address + + """ + The address allowed to post records. + """ + user: Address +} + +""" +Queries for Ethereum Follow Protocol (EFP) data. +""" +type EfpQuery { + """ + Get an EFP list by its NFT token id. + """ + list(by: EfpListByInput!): EfpList + + """ + Find EFP list records. Filter by `recordData` to answer 'which lists follow this address?'. + """ + listRecords( + after: String + before: String + first: Int + last: Int + where: EfpListRecordsWhereInput + ): EfpQueryListRecordsConnection + + """ + Find EFP lists, optionally filtered by owner / user / manager. + """ + lists( + after: String + before: String + first: Int + last: Int + where: EfpListsWhereInput + ): EfpQueryListsConnection +} + +type EfpQueryListRecordsConnection { + edges: [EfpQueryListRecordsConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EfpQueryListRecordsConnectionEdge { + cursor: String! + node: EfpListRecord! +} + +type EfpQueryListsConnection { + edges: [EfpQueryListsConnectionEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type EfpQueryListsConnectionEdge { + cursor: String! + node: EfpList! +} + """ Email represents a validated contact email address. """ @@ -1432,6 +1829,31 @@ input NameOrNodeInput @oneOf { node: Node } +""" +A Name whose indexed `addr()` record points at an Account. +""" +type NameReference { + """ + The CoinType of the matching `addr()` record. + """ + coinType: CoinType! + + """ + The canonical Domain whose `addr(coinType)` record points at this Account. + """ + domain: Domain! + + """ + Whether this name is the ENSIP-19 Primary Name of this Account for this `coinType`. i.e. whether reverse resolution of `(address, coinType)` resolves to this exact name. Protocol Accelerated when supported. + """ + match: Boolean! + + """ + The Resolver holding the matching `addr()` record. + """ + resolver: Resolver! +} + """ A NameWrapperRegistration represents a Registration initiated by the ENSv1 NameWrapper. """ @@ -1816,6 +2238,31 @@ type ProfileAvatar { httpUrl: String } +""" +The interpreted ENSIP-7 contenthash on the profile of an ENS name. +""" +type ProfileContenthash { + """ + The decoded, human-readable content identifier (e.g. a CID for IPFS, transaction ID for Arweave). + """ + decoded: String! + + """ + The default public HTTP gateway URL for fetching this content in a browser (e.g. https://ipfs.io/ipfs/…). Null for protocols with no well-known public gateway (onion, skynet). + """ + httpUrl: String + + """ + The ENSIP-7 contenthash protocol type (e.g. IPFS, IPNS, Swarm, Arweave). + """ + protocolType: ContenthashProtocol! + + """ + The canonical protocol-native URI (e.g. "ipfs://bafy…", "ar://…", "bzz://…"). + """ + uri: String! +} + """ The interpreted header image on the profile of an ENS name. """ @@ -1904,6 +2351,11 @@ type Query { where: DomainsWhereInput! ): QueryDomainsConnection + """ + Ethereum Follow Protocol (EFP) queries. Null when the connected ENSIndexer does not have the `efp` plugin enabled. + """ + efp: EfpQuery + """ Identify Permissions by ID or AccountId. """ @@ -2558,6 +3010,11 @@ type ThreeDNSRegistration implements Registration { unregistrant: Account } +""" +TokenId represents an enssdk#TokenId. +""" +scalar TokenId + """ A resolvable-but-unindexed Domain: not present in the index, but resolvable because an ancestor in its namegraph path has an ENSIP-10 wildcard Resolver (e.g. off-chain / CCIP-Read names, unindexed 3DNS names, wildcard subnames). """ diff --git a/docs/ensnode.io/src/data/omnigraph-examples/snapshot.json b/docs/ensnode.io/src/data/omnigraph-examples/snapshot.json index dae7fee69..7667419bf 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/snapshot.json +++ b/docs/ensnode.io/src/data/omnigraph-examples/snapshot.json @@ -1,7 +1,7 @@ { - "version": "v1.15.2", - "commit": "97b5093a6", - "sdkVersion": "1.15.2", - "schemaTag": "v1.15.2", - "snapshottedAt": "2026-06-10" + "version": "v1.16.0", + "commit": "ad03dc2af", + "sdkVersion": "1.16.0", + "schemaTag": "v1.16.0", + "snapshottedAt": "2026-06-19" } diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index 1dee7a614..2d0636a7d 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -3,6 +3,7 @@ import { asInterpretedName, toNormalizedAddress } from "enssdk"; import { DatasourceNames, ENSNamespaceIds } from "@ensnode/datasources"; import { accounts } from "@ensnode/integration-test-env/devnet"; +import { PluginName } from "../ensindexer/config/types"; import { getDatasourceContract } from "../shared/datasource-contract"; import type { NamespaceSpecificValue } from "../shared/namespace-specific-value"; @@ -63,6 +64,8 @@ const SEPOLIA_V2_RESOLVER_WITH_RECORDS = { export type GraphqlApiExampleQuery = { id: string; + /** The plugin that must be enabled on the connected ENSIndexer for this query to return data. */ + plugin: PluginName; query: string; variables: NamespaceSpecificValue>; }; @@ -81,6 +84,7 @@ export const GRAPHQL_API_EXAMPLE_QUERIES: GraphqlApiExampleQuery[] = [ //////////////// { id: "hello-world", + plugin: PluginName.Unigraph, query: `query HelloWorld($address: Address!) { # Lookup an Account by address. account(by: { address: $address }) { @@ -128,6 +132,7 @@ export const GRAPHQL_API_EXAMPLE_QUERIES: GraphqlApiExampleQuery[] = [ ///////////////// { id: "find-domains", + plugin: PluginName.Unigraph, query: ` query FindDomains( $name: DomainsNameFilter! @@ -165,6 +170,7 @@ query FindDomains( { id: "domain-by-name", + plugin: PluginName.Unigraph, query: ` query DomainByName($name: InterpretedName!) { domain(by: { name: $name }) { @@ -192,6 +198,7 @@ query DomainByName($name: InterpretedName!) { //////////////////////////////// { id: "domain-by-name-type-condition", + plugin: PluginName.Unigraph, query: ` query DomainByName($name: InterpretedName!) { domain(by: {name: $name}) { @@ -218,6 +225,7 @@ query DomainByName($name: InterpretedName!) { /////////////////////// { id: "domain-registration", + plugin: PluginName.Unigraph, query: ` query DomainRegistration($name: InterpretedName!) { domain(by: { name: $name }) { @@ -264,6 +272,7 @@ query DomainRegistration($name: InterpretedName!) { //////////////////// { id: "domain-records", + plugin: PluginName.Unigraph, query: ` query DomainRecords($name: InterpretedName!) { domain(by: {name: $name}) { @@ -300,6 +309,7 @@ query DomainRecords($name: InterpretedName!) { { id: "domain-profile", + plugin: PluginName.Unigraph, query: ` query DomainProfile($name: InterpretedName!) { domain(by: {name: $name}) { @@ -341,6 +351,7 @@ query DomainProfile($name: InterpretedName!) { { id: "domain-profile-and-records", + plugin: PluginName.Unigraph, query: ` query DomainProfileAndRecords($name: InterpretedName!) { domain(by: { name: $name }) { @@ -388,6 +399,7 @@ query DomainProfileAndRecords($name: InterpretedName!) { //////////////////////////////////// { id: "offchain-name", + plugin: PluginName.Unigraph, query: ` query OffchainName($name: InterpretedName!) { domain(by: { name: $name }) { @@ -418,6 +430,7 @@ query OffchainName($name: InterpretedName!) { ////////////////////// { id: "domain-subdomains", + plugin: PluginName.Unigraph, query: ` query DomainSubdomains($name: InterpretedName!) { domain(by: {name: $name}) { @@ -446,6 +459,7 @@ query DomainSubdomains($name: InterpretedName!) { //////////////////////////////////// { id: "domain-subdomains-recently-registered", + plugin: PluginName.Unigraph, query: ` query RecentlyRegisteredSubdomains($name: InterpretedName!) { domain(by: {name: $name}) { @@ -467,6 +481,7 @@ query RecentlyRegisteredSubdomains($name: InterpretedName!) { //////////////////////// { id: "subdomains-pagination", + plugin: PluginName.Unigraph, query: ` query SubdomainsPagination($first: Int!, $after: String) { domain(by: { name: "eth" }) { @@ -493,6 +508,7 @@ query SubdomainsPagination($first: Int!, $after: String) { ///////////////// { id: "domain-events", + plugin: PluginName.Unigraph, query: ` query DomainEvents($name: InterpretedName!) { domain(by: {name: $name}) { @@ -522,6 +538,7 @@ query DomainEvents($name: InterpretedName!) { //////////////////// { id: "domains-by-address", + plugin: PluginName.Unigraph, query: ` query AccountDomains( $address: Address! @@ -549,6 +566,7 @@ query AccountDomains( ///////////////////////// { id: "account-primary-names", + plugin: PluginName.Unigraph, query: ` query AccountPrimaryNames($address: Address!) { account(by: { address: $address }) { @@ -579,6 +597,7 @@ query AccountPrimaryNames($address: Address!) { }, { id: "account-primary-name-records", + plugin: PluginName.Unigraph, query: ` query AccountPrimaryNameRecords($address: Address!) { account(by: { address: $address }) { @@ -611,6 +630,7 @@ query AccountPrimaryNameRecords($address: Address!) { //////////////////// { id: "account-events", + plugin: PluginName.Unigraph, query: ` query AccountEvents( $address: Address! @@ -631,6 +651,7 @@ query AccountEvents( ///////////////////// { id: "registry-domains", + plugin: PluginName.Unigraph, query: ` query RegistryDomains( $registry: AccountIdInput! @@ -658,6 +679,7 @@ query RegistryDomains( //////////////////////////// { id: "permissions-by-contract", + plugin: PluginName.Unigraph, query: ` query PermissionsByContract( $contract: AccountIdInput! @@ -695,6 +717,7 @@ query PermissionsByContract( //////////////////////// { id: "permissions-by-user", + plugin: PluginName.Unigraph, query: ` query PermissionsByUser($address: Address!) { account(by: { address: $address }) { @@ -719,6 +742,7 @@ query PermissionsByUser($address: Address!) { ////////////////////////////////// { id: "account-resolver-permissions", + plugin: PluginName.Unigraph, query: ` query AccountResolverPermissions($address: Address!) { account(by: { address: $address }) { @@ -746,6 +770,7 @@ query AccountResolverPermissions($address: Address!) { ////////////////////////////// { id: "domain-resolver", + plugin: PluginName.Unigraph, query: ` query DomainResolver($name: InterpretedName!) { domain(by: { name: $name }) { @@ -774,6 +799,7 @@ query DomainResolver($name: InterpretedName!) { //////////////////////// { id: "resolver-by-address", + plugin: PluginName.Unigraph, query: ` query ResolverByAddress($contract: AccountIdInput!) { resolver(by: { contract: $contract }) { @@ -807,6 +833,7 @@ query ResolverByAddress($contract: AccountIdInput!) { ////////////// { id: "namegraph", + plugin: PluginName.Unigraph, query: ` query Namegraph { domain(by: { name: "eth" }) { @@ -832,6 +859,7 @@ query Namegraph { ///////////////////////////// { id: "account-migrated-names", + plugin: PluginName.Unigraph, query: ` query AccountMigratedNames($address: Address!) { account(by: { address: $address }) { @@ -849,6 +877,7 @@ query AccountMigratedNames($address: Address!) { }, { id: "eth-by-version", + plugin: PluginName.Unigraph, query: ` query GetEthDomains { domains(where: { name: { eq: "eth" } }) { @@ -864,6 +893,7 @@ query GetEthDomains { }, { id: "accelerate-resolve", + plugin: PluginName.Unigraph, query: ` query AccelerateResolve($address: Address!) { account(by: { address: $address }) { @@ -887,6 +917,97 @@ query AccelerateResolve($address: Address!) { [ENSNamespaceIds.SepoliaV2]: { address: SEPOLIA_V2_ACCOUNT_WITH_V1_AND_V2 }, }, }, + + /////////////////////////////////////// + // Ethereum Follow Protocol (EFP) + // + // Require the `efp` plugin on the connected ENSIndexer; `Query.efp` / `Account.efp` + // are null otherwise. Indexed only in the mainnet namespace. + /////////////////////////////////////// + { + id: "efp-follow-graph", + plugin: PluginName.EFP, + query: ` +query EfpFollowGraph($address: Address!) { + account(by: { address: $address }) { + efp { + # The validated primary list, or null if unset/unvalidated. + primaryList { tokenId } + # following/followers are validated and block/mute-filtered, so the + # edges are full Accounts you can walk straight into ENS names. + following(first: 10) { + totalCount + edges { + node { + address + resolve { + primaryName(by: { chainName: ETHEREUM }) { + name { beautified } + } + } + } + } + } + followers(first: 10) { + totalCount + edges { node { address } } + } + } + } +}`, + variables: { + default: { address: VITALIK_ADDRESS }, + }, + }, + + { + id: "efp-who-follows", + plugin: PluginName.EFP, + query: ` +# Raw record view: includes block/mute and non-primary lists — inspect the tags. +# For the validated social graph, prefer Account.efp.followers instead. +query EfpWhoFollows($address: Address!) { + efp { + listRecords(where: { recordData: $address }, first: 25) { + totalCount + edges { + node { + tags + list { tokenId user } + } + } + } + } +}`, + variables: { + default: { address: VITALIK_ADDRESS }, + }, + }, + + { + id: "efp-list", + plugin: PluginName.EFP, + query: ` +query EfpList($tokenId: TokenId!) { + efp { + list(by: { tokenId: $tokenId }) { + tokenId + owner + user + manager + storageLocation { chainId address slot } + # Raw records (any tag, no primary-list validation). + records(first: 25) { + totalCount + edges { node { recordData tags } } + } + } + } +}`, + variables: { + default: { tokenId: "1" }, + }, + }, ]; const graphqlApiExampleQueryById = new Map( diff --git a/packages/ensskills/scripts/generate.ts b/packages/ensskills/scripts/generate.ts index 076b480d5..13703ee3b 100644 --- a/packages/ensskills/scripts/generate.ts +++ b/packages/ensskills/scripts/generate.ts @@ -27,15 +27,18 @@ import prettier from "prettier"; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const SDL_PATH = resolve(SCRIPT_DIR, "../../enssdk/src/omnigraph/generated/schema.graphql"); const SKILL_PATH = resolve(SCRIPT_DIR, "../skills/omnigraph/SKILL.md"); +const EFP_SKILL_PATH = resolve(SCRIPT_DIR, "../skills/efp-protocol/SKILL.md"); const EXAMPLE_QUERIES_PATH = resolve( SCRIPT_DIR, "../../ensnode-sdk/src/omnigraph-api/example-queries.ts", ); +const PLUGIN_TYPES_PATH = resolve(SCRIPT_DIR, "../../ensnode-sdk/src/ensindexer/config/types.ts"); const ENSCLI_SKILL_PATH = resolve(SCRIPT_DIR, "../skills/enscli/SKILL.md"); const ENSCLI_EXAMPLE_COMMANDS_PATH = resolve(SCRIPT_DIR, "../../enscli/src/example-commands.ts"); interface ExampleQuery { id: string; + plugin: string; query: string; variables: { default: Record }; } @@ -125,13 +128,20 @@ function buildSchemaReference(schema: GraphQLSchema): string { return sections.join("\n\n"); } -async function buildExamples(): Promise { +/** + * Renders the example queries tagged with `plugin` into a markdown block. The base `omnigraph` skill + * gets the `unigraph` queries; the `efp-protocol` skill gets the `efp` queries — so each skill ships + * only the examples that run against the plugin it documents. + */ +async function buildExamples(plugin: string): Promise { // load dynamically to avoid tsconfig root error const { GRAPHQL_API_EXAMPLE_QUERIES } = (await import(EXAMPLE_QUERIES_PATH)) as { GRAPHQL_API_EXAMPLE_QUERIES: ExampleQuery[]; }; // Skip "hello-world": it's the playground welcome blurb, not a reusable query pattern. - return GRAPHQL_API_EXAMPLE_QUERIES.filter((example) => example.id !== "hello-world") + return GRAPHQL_API_EXAMPLE_QUERIES.filter( + (example) => example.plugin === plugin && example.id !== "hello-world", + ) .map((example) => { const query = example.query.trim(); const variables = JSON.stringify(example.variables.default, null, 2); @@ -200,13 +210,33 @@ async function writeFormatted(path: string, content: string): Promise { } async function main(): Promise { + // load dynamically to avoid tsconfig root error + const { PluginName } = (await import(PLUGIN_TYPES_PATH)) as { + PluginName: { Unigraph: string; EFP: string }; + }; + const schema = buildSchema(readFileSync(SDL_PATH, "utf8")); let content = readFileSync(SKILL_PATH, "utf8"); content = replaceRegion(content, "SCHEMA", buildSchemaReference(schema), SKILL_PATH); - content = replaceRegion(content, "EXAMPLES", await buildExamples(), SKILL_PATH); + content = replaceRegion( + content, + "EXAMPLES", + await buildExamples(PluginName.Unigraph), + SKILL_PATH, + ); await writeFormatted(SKILL_PATH, content); console.log(`Updated ${SKILL_PATH}`); + let efpContent = readFileSync(EFP_SKILL_PATH, "utf8"); + efpContent = replaceRegion( + efpContent, + "EXAMPLES", + await buildExamples(PluginName.EFP), + EFP_SKILL_PATH, + ); + await writeFormatted(EFP_SKILL_PATH, efpContent); + console.log(`Updated ${EFP_SKILL_PATH}`); + let enscliContent = readFileSync(ENSCLI_SKILL_PATH, "utf8"); for (const [group, block] of await buildEnscliExamplesByGroup()) { enscliContent = replaceRegion(enscliContent, group, block, ENSCLI_SKILL_PATH); diff --git a/packages/ensskills/skills/efp-protocol/SKILL.md b/packages/ensskills/skills/efp-protocol/SKILL.md index 973ddfc98..6199cfa7f 100644 --- a/packages/ensskills/skills/efp-protocol/SKILL.md +++ b/packages/ensskills/skills/efp-protocol/SKILL.md @@ -96,15 +96,20 @@ Prefer this for "who does X follow / who follows X" — it applies primary-list Run these with the **omnigraph**/**enscli** skill. They assume an instance with the `efp` plugin enabled. -### Following & followers, with names + + +### efp-follow-graph ```graphql query EfpFollowGraph($address: Address!) { account(by: { address: $address }) { efp { + # The validated primary list, or null if unset/unvalidated. primaryList { tokenId } + # following/followers are validated and block/mute-filtered, so the + # edges are full Accounts you can walk straight into ENS names. following(first: 10) { totalCount edges { @@ -133,10 +138,19 @@ query EfpFollowGraph($address: Address!) { } ``` -### Who follows this address (raw records, with tags) +Variables: + +```json +{ + "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" +} +``` + +### efp-who-follows ```graphql -# Raw record view: includes block/mute and non-primary lists — inspect `tags`. +# Raw record view: includes block/mute and non-primary lists — inspect the tags. +# For the validated social graph, prefer Account.efp.followers instead. query EfpWhoFollows($address: Address!) { efp { listRecords(where: { recordData: $address }, first: 25) { @@ -155,7 +169,15 @@ query EfpWhoFollows($address: Address!) { } ``` -### A list by token id, with its records and storage location +Variables: + +```json +{ + "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" +} +``` + +### efp-list ```graphql query EfpList($tokenId: TokenId!) { @@ -170,6 +192,7 @@ query EfpList($tokenId: TokenId!) { address slot } + # Raw records (any tag, no primary-list validation). records(first: 25) { totalCount edges { @@ -184,6 +207,16 @@ query EfpList($tokenId: TokenId!) { } ``` +Variables: + +```json +{ + "tokenId": "1" +} +``` + + + ## Related skills - **omnigraph** — the GraphQL API EFP is exposed through; author and run EFP queries there.