diff --git a/.cursor/plans/auto-cleanup_architecture_plan_50f1bdfb.plan.md b/.cursor/plans/auto-cleanup_architecture_plan_50f1bdfb.plan.md new file mode 100644 index 000000000..09558cf43 --- /dev/null +++ b/.cursor/plans/auto-cleanup_architecture_plan_50f1bdfb.plan.md @@ -0,0 +1,597 @@ +--- +name: Auto-Cleanup Architecture Plan +overview: A comprehensive design specification for the resolver record normalization feature for ENS, covering the pure in-memory data model (Phase 1), the transform registry, and the API integration (Phase 3). +todos: [] +isProject: false +--- + +# Resolver Record Normalization — Architecture Specification + +## Goals (from issue #1061) + +1. Clients can request that all resolver records are **validated** (value matches expected format) and **normalized** (value is in a single canonical form regardless of how it was stored). +2. Keys are also normalized: legacy/fallback key variants (e.g. `com.twitter`, `vnd.twitter`, `twitter`) are resolved and the value is returned under the canonical key (e.g. `com.x`). +3. Unknown keys (no normalization logic defined) pass through unchanged. +4. Full normalization metadata is optionally returned so UIs can inspect and explain how each record was processed. +5. Normalized records carry UI-friendly enrichment: `displayKey`, `displayValue`, `url`. + +**Phase 1 scope (this implementation):** Pure in-memory library only. Input = a raw set of key/value pairs (already resolved from a resolver). Output = a normalized record set and optionally stripped/enriched output. No I/O, no resolution logic, no API changes, no client key-request behavior. + +**Assumption — unique keys in resolver records:** Resolver records (the key/value set passed into the normalizer) are assumed to have unique keys: each key appears at most once. Duplicate handling in this spec applies only to *recognized* keys: multiple *different* raw keys (e.g. `com.twitter`, `vnd.twitter`) can map to the same normalized key (e.g. `com.x`); only one record wins per normalized key. The `DuplicateNormalizedKey` op is used only for those recognized-key losers. If the input violates the uniqueness assumption (same unrecognized `rawKey` appears more than once), subsequent occurrences are marked `DuplicateRawKey` and placed in `excludedRecords`—this should not be possible when resolver keys are unique. By contrast, the *requested* keys (e.g. the list passed to key expansion / multicall) may contain duplicates—the user may ask for the same key more than once—and the generated query should deduplicate so each key is requested at most once. + +--- + +## Architecture: Phase separation + +The feature is split into three phases that can be developed in parallel: + +- **Phase 1** (current focus): Pure in-memory data model and functions in `@ensnode/ensnode-sdk`. No I/O, no resolution logic. This layer is independently testable and reusable. +- **Phase 2**: Resolution API refactors (tracked separately in issue #1471). +- **Phase 3**: Integrate Phase 1 logic into the APIs built in Phase 2. + +Phases 1 and 2 proceed in parallel. Phase 3 begins once both are sufficiently mature. + +--- + +## Phase 1: Pure in-memory data model and functions + +All code lives in `packages/ensnode-sdk/src/resolution/normalization/`. +Every function is a pure in-memory data operation with no I/O dependencies. + +### 1.1 Text record key definitions + +Each logical record type has a **normalized key** (the canonical primary key) and an ordered list of **unnormalized key variants** (legacy/alternative names tried as fallbacks): + +```typescript +type TextRecordNormalizationDefs = { + byNormalizedKey: Map; + byUnnormalizedKey: Map; +}; + +type TextRecordKeyDef = { + /** Canonical key used in API responses and UI labels. e.g. "com.x" */ + normalizedKey: string; + /** Human-friendly label for UIs. e.g. "Twitter" */ + displayKey: string; + /** + * Ordered list of unnormalized key variants. + * The primary key (normalizedKey) itself is NOT in this list. + * Order determines priority if multiple variants have a value. + */ + unnormalizedKeys: readonly string[]; + /** Validate raw value. Returns ok + trimmed value, or ok:false with reason. */ + validate: (rawValue: string) => ValidationResult; + /** Convert a validated value to canonical form. */ + normalize: (validatedValue: string) => NormalizationResult; + /** Human-friendly value for UIs. e.g. "@alice" from "alice" */ + displayValue: (normalizedValue: string) => string; + /** Full URL to the related resource, or null if no URL can be derived (e.g. Discord, NFT avatar). */ + url: (normalizedValue: string) => string | null; +}; +``` + +**Key lookup is case-sensitive and exact-match only.** A `rawKey` of `TWITTER` does not match `twitter` or `Twitter`. Every cased variant that a production ENS resolver may use must be listed explicitly in `unnormalizedKeys`. There is no implicit case folding at lookup time. + +Invariants (enforced at `TextRecordNormalizationDefs` construction): + +- No two `normalizedKey` values are the same. +- No two `unnormalizedKey` values are the same across all definitions. +- No `unnormalizedKey` equals any `normalizedKey`. + +### 1.1a Supporting return types + +These types are used by the `validate` and `normalize` functions on each `TextRecordKeyDef`: + +```typescript +type ValidationResult = + | { ok: true; value: string } // trimmed, sanitized value ready for normalization + | { ok: false; reason: string }; // human-readable reason for rejection + +type NormalizationResult = + | { ok: true; value: string } // canonical normalized value + | { ok: false; reason: string }; // human-readable reason normalization failed +``` + +**Null value handling**: if `rawValue` is `null` (the resolver has no value for that key), `normalizeRecord` returns an `IndividualRecordNormalizationResult` whose `valueResult` has `op: "Unnormalizable"` with `reason: "no value set"`. At the set level, a null-value record is treated identically to any other `Unnormalizable` record: it only participates in Pass 2 (which runs only when no candidate successfully normalizes), and loses to any record whose value op is `AlreadyNormalized` or `Normalized`. + +### 1.2 Key normalization types (per key) + +```typescript +type KeyNormalizationOp = "AlreadyNormalized" | "Normalized" | "Unrecognized"; + +type KeyNormalizationResult = + /** rawKey is the canonical normalizedKey; no change was needed */ + | { op: "AlreadyNormalized"; rawKey: string; normalizedKey: string } + /** rawKey was a fallback variant; it was mapped to normalizedKey in the response */ + | { op: "Normalized"; rawKey: string; normalizedKey: string } + /** rawKey has no known definition; passed through as-is */ + | { op: "Unrecognized"; rawKey: string; normalizedKey: null }; +``` + +### 1.3 Value normalization types (per value) + +```typescript +type ValueNormalizationOp = + | "AlreadyNormalized" + | "Normalized" + | "Unnormalizable" + | "Unrecognized"; + +type ValueNormalizationResult = + /** Value was already in canonical form; rawValue === normalizedValue */ + | { op: "AlreadyNormalized"; rawValue: string; normalizedValue: string } + /** Value was successfully transformed; rawValue !== normalizedValue */ + | { op: "Normalized"; rawValue: string; normalizedValue: string } + /** + * Key was recognized but value could not be normalized. + * rawValue is null when the resolver had no value for this key. + * reason carries the validation/normalization failure message. + */ + | { op: "Unnormalizable"; rawValue: string | null; normalizedValue: null; reason: string } + /** + * Key is unrecognized; value passed through without validation/normalization. + * rawValue is null when the resolver had no value for this unrecognized key. + */ + | { op: "Unrecognized"; rawValue: string | null; normalizedValue: null }; +``` + +### 1.4 Layer 1: Individual record normalization + +An individual record normalization result pairs key and value ops: + +```typescript +type IndividualRecordNormalizationResult = { + keyResult: KeyNormalizationResult; + valueResult: ValueNormalizationResult; +}; +// Invariant: if keyResult.op === "Unrecognized" then valueResult.op === "Unrecognized". +// A recognized key can never produce a valueResult with op "Unrecognized". +``` + +**Key function — normalize one record:** + +```typescript +function normalizeRecord( + rawKey: string, + rawValue: string | null, + defs: TextRecordNormalizationDefs, +): IndividualRecordNormalizationResult +``` + +Logic: + +- Look up `rawKey` in `defs` (via `byNormalizedKey` or `byUnnormalizedKey`). +- If found: determine `keyResult` (AlreadyNormalized if rawKey === normalizedKey, else Normalized). + - If `rawValue` is null: `valueResult` is Unnormalizable with reason `"no value set"`. + - If `rawValue` is a string: run validate + normalize on rawValue. + - If succeeded: `valueResult` is `AlreadyNormalized` iff `normalizedValue === rawValue` (validate + normalize produced a value identical to the original rawValue); otherwise `valueResult` is `Normalized` (normalizedValue differs from rawValue). + - If failed: `valueResult` is `Unnormalizable` with the reason from the failing step. +- If not found: `keyResult` is Unrecognized; `valueResult` is Unrecognized. + +### 1.5 Layer 2: Set-level normalization + +After individually normalizing each record, the set is consolidated so only one normalized key is retained as the "winner" when multiple records map to the same normalized key. Duplicate detection requires set context: if a key is not found in defs, at the set level the first record with that unrecognized `rawKey` is written into `records` as `UnrecognizedKeyAndValue`; subsequent records with the same `rawKey` are marked `op: "DuplicateRawKey"` and placed into `excludedRecords` (this should not occur when resolver keys are unique; see assumption above). + +```typescript +type RecordNormalizationOp = + /** Key recognized, value valid — this record is the winner for its normalized key */ + | "Normalized" + /** Key unrecognized — both key and value are passed through unchanged */ + | "UnrecognizedKeyAndValue" + /** + * Key recognized, value could not be normalized — excluded from clean output. + * Covers all failure cases for a recognized key: null value, format mismatch, + * validation failure, etc. Note: a separate "UnrecognizedValue" op is not needed + * because ValueNormalizationOp "Unrecognized" is by definition only reachable + * when the key itself is unrecognized (captured by "UnrecognizedKeyAndValue" above). + */ + | "UnnormalizableValue" + /** Another record already claimed this normalized key (lower priority variant) */ + | "DuplicateNormalizedKey" + /** + * Same unrecognized rawKey appeared again; first occurrence won. Defensive only — should not + * occur when resolver records have unique keys (see assumption above). + */ + | "DuplicateRawKey"; + +type RecordNormalizationResult = + | { + op: "Normalized"; + individual: IndividualRecordNormalizationResult; + normalizedKey: string; + normalizedValue: string; + displayKey: string; + displayValue: string; + url: string | null; + } + | { + op: "UnrecognizedKeyAndValue" | "UnnormalizableValue" | "DuplicateNormalizedKey" | "DuplicateRawKey"; + individual: IndividualRecordNormalizationResult; + }; + +type NormalizedRecordSet = { + /** + * Records that appear in the output. Two distinct kinds of entries are keyed here: + * - op "Normalized": keyed by normalizedKey (the canonical key for the winner). + * - op "UnrecognizedKeyAndValue": keyed by rawKey (passed through as-is). + * No other op values appear in this map. + */ + records: Record< + string, + Extract + >; + /** + * Records excluded from the main output: + * UnnormalizableValue, DuplicateNormalizedKey, and DuplicateRawKey. + */ + excludedRecords: Extract< + RecordNormalizationResult, + { op: "UnnormalizableValue" | "DuplicateNormalizedKey" | "DuplicateRawKey" } + >[]; +}; +``` + +**Priority rule** when multiple records share the same normalized key — two-pass algorithm: + +**Pass 1 — normalizable candidates** (value op is `AlreadyNormalized` or `Normalized`): + +1. Among these, the record whose `rawKey === normalizedKey` wins first. +2. If none match the normalized key, the first in `unnormalizedKeys` order wins. +3. The winner gets `op: "Normalized"` and is placed in `records`. +4. Pass-1 losers (normalizable but not the winner) get `op: "DuplicateNormalizedKey"` and go into `excludedRecords`. +5. All `Unnormalizable` candidates are excluded from Pass 1. If Pass 1 found a winner, each excluded `Unnormalizable` record gets `op: "UnnormalizableValue"` and goes into `excludedRecords`. Their raw values remain accessible via `individual.valueResult.rawValue`. + +**Pass 2 — only if Pass 1 found no winner** (all candidates are `Unnormalizable`): + +1. Among Unnormalizable candidates, the record whose `rawKey === normalizedKey` wins first. +2. If none match the normalized key, the first in `unnormalizedKeys` order wins. +3. The winner gets `op: "UnnormalizableValue"` and goes into `excludedRecords` (no valid value exists, so `records` has no entry for this normalized key). +4. Pass-2 losers get `op: "DuplicateNormalizedKey"` and go into `excludedRecords`. + +This ensures a valid value from any fallback key always beats an invalid or null value on the canonical key. Example: if `com.x = ""` (invalid) and `vnd.twitter = "alice"` (valid), Pass 1 selects `vnd.twitter` as winner; `com.x` gets `op: "UnnormalizableValue"` in `excludedRecords` with its bad raw value still accessible via `individual.valueResult.rawValue`. + +**Key function — build the set:** + +```typescript +function normalizeRecordSet( + records: Array<{ rawKey: string; rawValue: string | null }>, + defs: TextRecordNormalizationDefs, +): NormalizedRecordSet +``` + +### 1.6 Layer 3: Stripped output + +For clients that only want clean values without metadata: + +```typescript +function stripNormalizationMetadata( + set: NormalizedRecordSet, +): Record +``` + +**Return type note:** The type is intentionally `Record` because the result is a single flat object that mixes (1) entries from "Normalized" records, where the value is always a string, and (2) entries from "UnrecognizedKeyAndValue" or "UnnormalizableValue", where the value can be null. TypeScript cannot express this per-key distinction in one record type, so the union `string | null` is used for all keys. + +Returns only the `normalizedKey → normalizedValue` pairs from the "Normalized" records, plus `rawKey → rawValue` passthrough for "UnrecognizedKeyAndValue" records. For recognized keys where no candidate produced a valid value (winner is "UnnormalizableValue" in `excludedRecords`), the key is included with value `null` (e.g. `{ "com.x": null }`), so callers can distinguish "key present but invalid" from "key not present." Unrecognized keys are always included even when `rawValue` is null — producing `{ [rawKey]: null }` — so the caller receives a complete picture of every key that was present in the input. Thus the result is built from `set.records` first; then, for each "UnnormalizableValue" in `set.excludedRecords`, add `normalizedKey → null` **only when** `set.records[normalizedKey]` is absent (i.e. when no candidate successfully normalized for that key). This avoids overwriting a valid winner with null when `excludedRecords` contains Pass-1 losers (unnormalizable candidates for a key that had a normalizable winner). + +For clients that want UI-friendly enrichment (displayValue, url) without holding the full `NormalizedRecordSet`: + +```typescript +type EnrichedRecord = { + value: string | null; + displayValue: string | null; + url: string | null; +}; + +function stripNormalizationMetadataWithEnrichment( + set: NormalizedRecordSet, +): Record +``` + +Returns the same key set as `stripNormalizationMetadata`, but each entry includes `displayValue` and `url` derived from the def's enrichment functions when the key is recognized and the value is normalized. For "UnrecognizedKeyAndValue" and "UnnormalizableValue" (key present but invalid) records, `displayValue` and `url` are `null`. This allows UI callers to render enriched records without needing to inspect the full metadata. + +### 1.7 Pre-resolution: key expansion *(Phase 2+ — out of scope for Phase 1)* + +> **Note:** Key expansion and client-requested key behavior are out of scope for Phase 1. Phase 1 assumes input is already a set of raw key/value pairs obtained from some prior resolution step. Key expansion into candidate keys before resolution is handled in Phase 2/3. + +Before resolution, normalized keys are expanded into the full set of candidate keys that the resolver should be queried for: + +```typescript +function expandNormalizedKeys( + normalizedKeys: readonly string[], + defs: TextRecordNormalizationDefs, +): string[] +``` + +**Precondition**: no element of `normalizedKeys` may be an unnormalized key variant (i.e. present in `defs.byUnnormalizedKey` but not in `defs.byNormalizedKey`). Passing `vnd.twitter` where `com.x` is expected is a caller error and must throw synchronously with a clear message listing the offending keys. Completely unknown keys (absent from both maps) are not an error — they are passed through as-is, supporting arbitrary user-defined keys. + +Returns: `[normalizedKey, ...unnormalizedKeys]` for each key found in `defs.byNormalizedKey`, followed by any unrecognized keys as-is. The result is deduplicated by first-occurrence: the caller may request the same key more than once (e.g. `["com.x", "com.x"]`); the generated query list must contain each key at most once, so the first occurrence is kept and subsequent duplicates are dropped. Ordering is otherwise stable and deterministic, ensuring consistent multicall construction and reproducible traces. + +--- + +## Phase 1: Initial `TextRecordNormalizationDefs` + +The initial definitions cover the 9 most common ENS text record key types. All lookups support both normalized keys and unnormalized variants via the two maps on `TextRecordNormalizationDefs`. + +**Initial set of recognized keys:** + + +| Normalized key | Display key | Unnormalized key variants | +| --------------- | ----------- | -------------------------------------------------- | +| `com.x` | X (Twitter) | `com.twitter`, `vnd.twitter`, `twitter`, `Twitter` | +| `com.github` | GitHub | `vnd.github`, `github` | +| `xyz.farcaster` | Farcaster | `com.warpcast`, `Farcaster`, `farcaster` | +| `com.discord` | Discord | `discord` | +| `org.telegram` | Telegram | `telegram`, `com.telegram`, `Telegram` | +| `com.reddit` | Reddit | `reddit` | +| `url` | Website | `URL`, `Website`, `website` | +| `email` | Email | `Email` | +| `avatar` | Avatar | `Avatar` | + + +### `TextRecordNormalizationDefs` construction + +A `TextRecordNormalizationDefs` is built once from the array of `TextRecordKeyDef` objects. Its two maps provide O(1) lookup by either key form: + +- `byNormalizedKey` — keyed by each `normalizedKey`. +- `byUnnormalizedKey` — keyed by each entry in `unnormalizedKeys`, pointing to the owning def. + +At construction time the invariants are validated and any violation throws synchronously (fail fast). No lazy initialization. + +### Per-key transform specifications + +The following specifies validation, normalization, and UI enrichment for each of the 9 initial keys. "Accepted input formats" lists formats that pass validation. "Canonical form" is the `normalizedValue` stored and returned. Values are first stripped of leading/trailing whitespace before validation. + +--- + +#### X / Twitter (`com.x`) + +Unnormalized variants: `com.twitter`, `vnd.twitter`, `twitter`, `Twitter` + +**Accepted input formats**: + +- Plain username: `alice` +- Prefixed: `@alice` +- twitter.com URL: `https://twitter.com/alice`, `http://twitter.com/alice`, `twitter.com/alice` +- x.com URL: `https://x.com/alice`, `http://x.com/alice`, `x.com/alice` + +**Validation**: extracted username must match `^[a-zA-Z0-9_]{4,15}$`. + +**Canonical form**: lowercase username without `@` prefix (e.g. `alice`). + +**displayValue**: `@{username}` (e.g. `@alice`). + +**url**: `https://x.com/{username}`. + +--- + +#### GitHub (`com.github`) + +Unnormalized variants: `vnd.github`, `github` + +**Accepted input formats**: + +- Plain username: `alice` +- Prefixed: `@alice` +- github.com URL: `https://github.com/alice`, `http://github.com/alice`, `github.com/alice` + +**Validation**: extracted username must match `^(?!.*--)[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$` (1–39 chars, alphanumeric and hyphens, no leading/trailing hyphen, no consecutive hyphens). + +**Canonical form**: lowercase username (e.g. `alice`). + +**displayValue**: `@{username}`. + +**url**: `https://github.com/{username}`. + +--- + +#### Farcaster (`xyz.farcaster`) + +Unnormalized variants: `com.warpcast`, `Farcaster`, `farcaster` + +**Accepted input formats**: + +- Plain username: `alice` +- Prefixed: `@alice` +- Warpcast URL: `https://warpcast.com/alice`, `http://warpcast.com/alice`, `warpcast.com/alice` + +**Validation**: extracted username must match `^[a-z0-9][a-z0-9-]{0,15}$` (Farcaster usernames are lowercase-only, 1–16 chars). + +**Canonical form**: lowercase username (e.g. `alice`). + +**displayValue**: `@{username}`. + +**url**: `https://warpcast.com/{username}`. + +--- + +#### Discord (`com.discord`) + +Unnormalized variants: `discord` + +Discord supports two username formats: the new format (post-2023, no discriminator) and the legacy format (with `#NNNN` discriminator). Both are accepted and preserved as-is. + +**Accepted input formats**: + +- New username: `alice` (2–32 chars, lowercase alphanumeric, underscores, periods) +- Legacy username: `alice#1234` + +**Validation**: + +- New format: must match `^(?!.*\.\.)[a-z0-9_.]{2,32}$` (no consecutive periods). +- Legacy format: must match `^[^\x00-\x1F\x7F#]{2,32}#[0-9]{4}$` (printable characters only before the `#` discriminator; `#` is excluded from the username portion so inputs like `alice#bob#1234` are rejected). + +**Canonical form**: the username as provided (lowercased for new format, `username#NNNN` preserved for legacy). + +**displayValue**: same as canonical form. + +**url**: Discord does not provide a reliable public profile URL by username (profile URLs use numeric user IDs). Returns `null`. + +--- + +#### Telegram (`org.telegram`) + +Unnormalized variants: `telegram`, `com.telegram`, `Telegram` + +**Accepted input formats**: + +- Plain username: `alice` +- Prefixed: `@alice` +- t.me URL: `https://t.me/alice`, `http://t.me/alice`, `t.me/alice` +- telegram.me URL: `https://telegram.me/alice`, `http://telegram.me/alice`, `telegram.me/alice` + +**Validation**: extracted username must match `^(?!_)(?!.*_$)(?!.*__)[a-zA-Z0-9_]{5,32}$` (5–32 chars from `[a-zA-Z0-9_]`, but no leading underscore, no trailing underscore, and no consecutive underscores; e.g. `_alice`, `alice_`, and `alice__bob` are rejected). + +**Canonical form**: lowercase username (e.g. `alice`). + +**displayValue**: `@{username}`. + +**url**: `https://t.me/{username}`. + +--- + +#### Reddit (`com.reddit`) + +Unnormalized variants: `reddit` + +**Accepted input formats**: + +- Plain username: `alice` +- Prefixed: `u/alice`, `/u/alice` +- reddit.com URL: `https://reddit.com/u/alice`, `https://www.reddit.com/u/alice`, `https://reddit.com/user/alice` + +**Validation**: extracted username must match `^[a-zA-Z0-9_-]{3,20}$`. + +**Canonical form**: username only, case preserved (e.g. `alice`). + +**displayValue**: `u/{username}`. + +**url**: `https://www.reddit.com/u/{username}`. + +--- + +#### Website URL (`url`) + +Unnormalized variants: `URL`, `Website`, `website` + +**Accepted input formats**: any string that parses as a valid URL with `http` or `https` scheme. + +**Validation**: `new URL(value)` must not throw and `url.protocol` must be `"http:"` or `"https:"`. + +**Canonical form**: `new URL(value).href` (the browser-canonical URL string, e.g. trailing slash normalized). + +**displayValue**: same as canonical form. + +**url**: same as canonical form. + +--- + +#### Email (`email`) + +Unnormalized variants: `Email` + +**Accepted input formats**: any string matching a standard email format. + +**Validation**: must match `^[^\s@]+@[^\s@]+\.[^\s@]+$` (basic structural check; full RFC 5322 compliance is not required). + +**Canonical form**: lowercased email address. + +**displayValue**: same as canonical form. + +**url**: `mailto:{email}`. + +--- + +#### Avatar (`avatar`) + +Unnormalized variants: `Avatar` + +Avatar values are complex — they can be HTTPS URLs, IPFS URIs, NFT references (EIP-155), or data URIs. Normalization preserves the value as-is after validation. + +**Accepted input formats**: + +- HTTPS/HTTP URL: `https://example.com/avatar.png` +- IPFS URI: `ipfs://Qm...`, `ipfs://bafy...` +- EIP-155 NFT reference: `eip155:1/erc721:0x.../1`, `eip155:1/erc1155:0x.../1` +- Data URI: `data:image/png;base64,...` + +**Validation**: must begin with one of the recognized prefixes (`https://`, `http://`, `ipfs://`, `eip155:`, `data:image/`). + +**Canonical form**: value as-is (no transformation applied). + +**displayValue**: same as canonical form. + +**url**: for `https://`/`http://` — same as value; for `ipfs://` — convert to `https://ipfs.io/ipfs/{cid}`; for `eip155:` and `data:` URIs — `null` (requires off-chain resolution beyond this layer). + +## Phase 3: API integration + +### Design decisions + +#### Key expansion in the RPC path + +When `normalize=true`, client-requested keys are expanded to include all unnormalized variants before resolution. This expansion must happen regardless of the resolution path (indexed or RPC). + +For the RPC path, ENS resolution already uses a multicall pattern: all record lookups for a given name are batched into a single `eth_call`. Key expansion therefore does **not** require extra round trips — the expanded key list is simply added to the same multicall batch. The overall overhead is one additional text slot per unnormalized variant per expanded key, within the same single RPC call. + +### Parameters + +Two query parameters on `GET /records/:name`: + +> **Breaking change:** The default `normalize=true` alters the response shape (e.g. `records.texts` will contain normalized keys and values instead of raw resolver output). Callers that rely on the previous raw key/value shape must pass `normalize=false` explicitly. + +| Parameter | Type | Default | Description | +| ----------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------- | +| `normalize` | boolean | `true` | Normalize keys and values. If true, expand keys pre-resolution and run normalization pipeline post-resolution. | +| `normalizationMetadata` | boolean | `false` | Include the full `NormalizedRecordSet` metadata in the response. Only meaningful when `normalize=true`. | + + +### Key expansion behavior + +When `normalize=true`: + +1. The requested text keys are passed through `expandNormalizedKeys` to produce a full candidate list. +2. **Indexed path**: the index is queried for all candidate keys directly (no extra cost, single query). +3. **RPC path**: all candidate keys are included in the same multicall batch used to resolve all other records. No additional RPC round trips are incurred. +4. The resolved raw records (potentially including fallback key variants) are then passed through the normalization pipeline. + +### Response shape + +```typescript +interface ResolveRecordsResponse { + records: ResolverRecordsResponse; + /** Only present when normalize=true AND normalizationMetadata=true */ + normalizationMetadata?: NormalizedRecordSet; + accelerationRequested: boolean; + accelerationAttempted: boolean; + trace?: TracingTrace; +} +``` + +When `normalize=true`, the `records.texts` field contains the stripped, clean output (normalized keys mapping to normalized values, unrecognized keys passed through). When `normalize=false`, the expansion/normalization pipeline is not run and `records` keep the original resolved shape. + +--- + +## Scope of initial implementation + +When this plan is accepted, **only Phase 1** is implemented. Phases 2 and 3 are not in scope for the current implementation. + +**In scope:** + +- Pure in-memory data model and functions in `@ensnode/ensnode-sdk` (`packages/ensnode-sdk/src/resolution/normalization/`). +- `normalizeRecord`, `normalizeRecordSet`, `stripNormalizationMetadata`, `stripNormalizationMetadataWithEnrichment`. +- Initial `TextRecordNormalizationDefs` covering the 9 recognized key types. +- Unit tests for all functions and transform definitions. + +**Out of scope:** + +- API changes, query parameters, HTTP handlers. +- Client-requested key behavior and `expandNormalizedKeys` usage in request handling. +- Integration with the indexed or RPC resolution paths (Phase 3). + +--- + +## Open questions + +1. **Parameter name for metadata field** *(Phase 3 only — not applicable to Phase 1)*: `normalizationMetadata`, `includeNormalizationMetadata`, or another name? +2. **Client requesting an unnormalized key directly** *(Phase 2/3 — out of scope for Phase 1)*: If a client passes `texts=vnd.twitter` (an unnormalized variant) instead of `texts=com.x`, should `expandNormalizedKeys` throw immediately (current spec — fail fast, caller error), or silently map it to the canonical key and expand from there (more forgiving for legacy integrations)? The issue does not address this case. +3. **Verify validation rules against each service's official constraints**: The regexes and accepted input formats in this spec were derived from best-effort research and may not match each platform's current actual rules. Before finalising implementation, verify against official documentation or source code for: Twitter/X (username charset, 15-char limit), GitHub (39-char limit, hyphen rules), Farcaster (lowercase-only, length), Discord (new-format charset, period rules, legacy discriminator format), Telegram (5–32 chars, charset), Reddit (3–20 chars, charset), email (RFC compliance level), avatar (supported URI schemes). Flag any discrepancy as a bug in the transform definition. Rules will be verified using actual data. +