spec via #1908 (comment)
Move encoded-referrer helpers to @namehash/ens-referrals; hoist Referrer type to enssdk
Context
PR review flagged that buildEncodedReferrer / decodeEncodedReferrer live in @ensnode/ensnode-sdk but are really community-facing utilities for the ENS Referral Program — they belong in @namehash/ens-referrals. Also: buildEncodedReferrer should accept Address (not NormalizedAddress) and normalize internally via toNormalizedAddress, which already throws on invalid input.
Naive "move the file" creates a circular dep (ens-referrals already depends on ensnode-sdk, so ensnode-sdk can't import EncodedReferrer back). We sidestep it by hoisting the one-line type alias to enssdk, the leaf package both sides already depend on. The zod invariant check in ensnode-sdk that currently uses decodeEncodedReferrer inlines its ~10 lines of decode logic to avoid needing a runtime import from ens-referrals.
No dep inversion: ens-referrals → ensnode-sdk stays as today.
Design decisions
- Raw type lives in
enssdk, renamed EncodedReferrer → Referrer (type Referrer = Hex). Unbranded, consistent with the NormalizedAddress policy. Represents raw 32-byte onchain referrer bytes — contracts may emit arbitrary bytes in that slot, so the type can't promise more than "hex".
- Branded
EncodedReferrer lives in ens-referrals as type EncodedReferrer = Referrer & { readonly __brand: "EncodedReferrer" }. Represents "a Referrer that is guaranteed to be validly encoded" — 32-byte hex with 12 bytes of zero padding followed by a 20-byte lowercase address. Constructible only through buildEncodedReferrer (and ZERO_ENCODED_REFERRER). The intersection means it trivially downcasts to Referrer/Hex/string when passed to viem APIs or contract calls.
- Runtime helpers live in
ens-referrals at packages/ens-referrals/src/encoded-referrer.ts (top-level, not under v1/ — useful across versions). Exported from packages/ens-referrals/src/index.ts only (NOT re-exported from src/v1/index.ts). Consumer import path is @namehash/ens-referrals.
ens-referrals does not re-export Referrer — consumers get it from enssdk directly.
buildEncodedReferrer(address: Address): EncodedReferrer — takes Address, normalizes via toNormalizedAddress (throws on invalid), pads to 32 bytes, returns the branded type (internal as EncodedReferrer assertion is the only cast).
decodeEncodedReferrer renamed to decodeReferrer; runtime behavior unchanged — (referrer: Referrer): NormalizedAddress. Throws on wrong byte length, throws on invalid trailing address bytes, returns zeroAddress on malformed 12-byte padding. Only the name changes; callers keep their current patterns (no ?? zeroAddress coalesce needed). Accepts the unbranded Referrer because typical inputs come straight from protocol bytes; branded EncodedReferrer also works via subtyping.
- Asymmetric pair
buildEncodedReferrer + decodeReferrer by design.
- Constants keep their names.
ZERO_ENCODED_REFERRER is typed as EncodedReferrer (it is a valid encoding of the zero address). ENCODED_REFERRER_BYTE_LENGTH, ENCODED_REFERRER_BYTE_OFFSET, EXPECTED_ENCODED_REFERRER_PADDING describe the encoding format.
RegistrarAction.encodedReferrer field name stays (rename deferred to a follow-up). Its type stays Referrer (unbranded) since the stored value comes from raw protocol bytes and may not satisfy the branded invariant.
File changes
enssdk
packages/enssdk/src/lib/types/evm.ts — append export type Referrer = Hex; alongside Hex/Address/NormalizedAddress. Doc comment stays ENS-level: "raw 32-byte onchain referrer value as emitted by ENS registrar controllers". Verify the existing barrel chain (src/index.ts → src/lib/index.ts → src/lib/types/evm.ts) picks it up automatically.
ens-referrals — create runtime helpers
packages/ens-referrals/src/encoded-referrer.ts:
import type { Address, NormalizedAddress, Referrer } from "enssdk";
import { toNormalizedAddress } from "enssdk";
import { pad, size, slice, zeroAddress } from "viem";
export type EncodedReferrer = Referrer & { readonly __brand: "EncodedReferrer" }; — doc comment explains the invariant (32-byte, 12-byte zero padding, 20-byte lowercase address) and that the brand is only produced by the helpers in this module.
- Constants:
ENCODED_REFERRER_BYTE_OFFSET = 12, ENCODED_REFERRER_BYTE_LENGTH = 32, EXPECTED_ENCODED_REFERRER_PADDING = pad("0x", { size: 12, dir: "left" }), ZERO_ENCODED_REFERRER: EncodedReferrer = pad("0x", { size: 32, dir: "left" }) as EncodedReferrer.
export function buildEncodedReferrer(address: Address): EncodedReferrer { return pad(toNormalizedAddress(address), { size: ENCODED_REFERRER_BYTE_LENGTH, dir: "left" }) as EncodedReferrer; }
export function decodeReferrer(referrer: Referrer): NormalizedAddress — body ported verbatim from the current decodeEncodedReferrer: throws on wrong length, returns zeroAddress on malformed padding, throws when trailing bytes aren't a valid address. Parameter is unbranded Referrer; a branded EncodedReferrer upcasts to it via subtyping.
- Do not re-export
Referrer from this file. EncodedReferrer is exported (it's this module's own type).
packages/ens-referrals/src/encoded-referrer.test.ts — port the existing tests. Update the building encoded referrer block to pass Address directly (both lowercase and checksummed) without first casting to NormalizedAddress. Add a case for buildEncodedReferrer("0xnotavalidaddress" as Address) throwing from toNormalizedAddress. Rename internal references from decodeEncodedReferrer to decodeReferrer.
packages/ens-referrals/src/index.ts — add export * from "./encoded-referrer";. Do not add to src/v1/index.ts.
packages/ens-referrals/README.md — append a buildEncodedReferrer subsection to "Other Utilities" (currently lines 162-175). Import path in the example: @namehash/ens-referrals (root). Do not document decodeReferrer.
ensnode-sdk — delete the module, inline the zod check, fix the field type
- Delete
packages/ensnode-sdk/src/registrars/encoded-referrer.ts.
- Delete
packages/ensnode-sdk/src/registrars/encoded-referrer.test.ts.
packages/ensnode-sdk/src/registrars/index.ts — remove export * from "./encoded-referrer";.
packages/ensnode-sdk/src/registrars/registrar-action.ts:
- Drop
import type { EncodedReferrer } from "./encoded-referrer" (line 4) and the two re-export lines (lines 6, 7).
- Add
import type { Referrer } from "enssdk";.
- Change
RegistrarActionReferralAvailable.encodedReferrer: EncodedReferrer → : Referrer (field name stays).
- Change
RegistrarActionReferralAvailable.decodedReferrer: NormalizedAddress (field name stays)
packages/ensnode-sdk/src/registrars/zod-schemas.ts:
- Drop
import { decodeEncodedReferrer, ENCODED_REFERRER_BYTE_LENGTH } from "./encoded-referrer".
- Add local
const ENCODED_REFERRER_BYTE_LENGTH = 32; and const ENCODED_REFERRER_BYTE_OFFSET = 12;.
- In
invariant_registrarActionDecodedReferrerBasedOnRawReferrer (lines 89-116), inline the decode: check 32-byte size, slice the 12-byte padding, compare to the zero-padded constant, slice the trailing 20 bytes, coerce via toNormalizedAddress. Match the current throw-then-custom-issue pattern. Use size/slice/zeroAddress from viem and toNormalizedAddress from enssdk.
Consumer import updates
Referrer type imports — swap import { type EncodedReferrer, ... } from "@ensnode/ensnode-sdk" to import type { Referrer } from "enssdk" (other symbols on the same import line remain from @ensnode/ensnode-sdk):
apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts — lines 13 import, 64 referrer: EncodedReferrer, 141 same.
apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts — line 12 import, 41 referrer?: EncodedReferrer, 94 same.
apps/ensindexer/src/plugins/registrars/shared/lib/registrar-controller-events.ts — import + let encodedReferrer: EncodedReferrer | null; → Referrer | null (variable name preserved).
apps/ensindexer/src/plugins/registrars/shared/lib/universal-registrar-renewal-with-referrer-events.ts — same.
packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts — line 20 import; $type<EncodedReferrer>() → $type<Referrer>() at lines 364 and 446.
packages/ensdb-sdk/src/ensindexer-abstract/registrars.schema.ts — any $type<EncodedReferrer>() with matching import swap.
Runtime helper imports — swap from @ensnode/ensnode-sdk to @namehash/ens-referrals, and rename decodeEncodedReferrer → decodeReferrer:
apps/ensapi/src/lib/registrar-actions/find-registrar-actions.ts — ZERO_ENCODED_REFERRER. (ensapi already depends on @namehash/ens-referrals.)
apps/ensindexer/src/plugins/registrars/ethnames/handlers/Ethnames_UniversalRegistrarRenewalWithReferrer.ts — rename decodeEncodedReferrer(...) → decodeReferrer(...) at line 39; update import.
apps/ensindexer/src/plugins/registrars/ethnames/handlers/Ethnames_RegistrarController.ts — same at lines 265 and 317.
packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx — ZERO_ENCODED_REFERRER.
Package dependency additions (workspace:*)
apps/ensindexer/package.json — add @namehash/ens-referrals.
packages/namehash-ui/package.json — add @namehash/ens-referrals.
packages/ensdb-sdk/package.json — no new dep (Referrer comes from enssdk, already a dep).
Changeset
.changeset/<slug>.md:
---
"enssdk": minor
"@namehash/ens-referrals": minor
"@ensnode/ensnode-sdk": minor
---
Added `Referrer` type to `enssdk` (raw 32-byte onchain referrer bytes). Runtime helpers (`buildEncodedReferrer`, `decodeReferrer` — renamed from `decodeEncodedReferrer`, `ZERO_ENCODED_REFERRER`, and related constants) moved from `@ensnode/ensnode-sdk` to `@namehash/ens-referrals`, which now owns a branded `EncodedReferrer` type returned by `buildEncodedReferrer`. `buildEncodedReferrer` now accepts `Address` (previously `NormalizedAddress`) and normalizes internally.
Fixed-group version bumps cascade to the other workspace packages automatically.
Critical files to read before editing
packages/ensnode-sdk/src/registrars/encoded-referrer.ts — source being moved.
packages/ensnode-sdk/src/registrars/encoded-referrer.test.ts — tests being ported.
packages/ensnode-sdk/src/registrars/zod-schemas.ts:89-116 — invariant check to rewrite inline.
packages/ensnode-sdk/src/registrars/registrar-action.ts:1-7, 110-130 — imports and RegistrarActionReferralAvailable field.
packages/enssdk/src/lib/types/evm.ts — destination for Referrer type.
packages/enssdk/src/index.ts / src/lib/index.ts — barrel chain verification.
packages/ens-referrals/src/index.ts, README.md:162-175.
packages/enssdk/src/lib/address.ts — confirm toNormalizedAddress throws on invalid input (it does).
Reuse
toNormalizedAddress (enssdk) — inside buildEncodedReferrer and the inlined zod invariant.
pad, size, slice, zeroAddress (viem) — unchanged from the current implementation.
Verification
pnpm install — confirm new workspace deps resolve.
- Typecheck in parallel:
pnpm -F enssdk typecheck
pnpm -F @namehash/ens-referrals typecheck
pnpm -F @ensnode/ensnode-sdk typecheck
pnpm -F @ensnode/ensdb-sdk typecheck
pnpm -F ensindexer typecheck
pnpm -F ensapi typecheck
pnpm -F @namehash/namehash-ui typecheck
pnpm lint
- Tests:
pnpm test --project ens-referrals — ported + new buildEncodedReferrer(Address) / invalid-input tests pass.
pnpm test --project ensnode-sdk — registrar-action zod invariant still validates after inlining.
pnpm test --project ensindexer — registrar event handler regressions.
- Grep for lingering
EncodedReferrer identifier and lingering decodeEncodedReferrer reference — should return zero hits.
spec via #1908 (comment)
Move encoded-referrer helpers to
@namehash/ens-referrals; hoistReferrertype toenssdkContext
PR review flagged that
buildEncodedReferrer/decodeEncodedReferrerlive in@ensnode/ensnode-sdkbut are really community-facing utilities for the ENS Referral Program — they belong in@namehash/ens-referrals. Also:buildEncodedReferrershould acceptAddress(notNormalizedAddress) and normalize internally viatoNormalizedAddress, which already throws on invalid input.Naive "move the file" creates a circular dep (
ens-referralsalready depends onensnode-sdk, soensnode-sdkcan't importEncodedReferrerback). We sidestep it by hoisting the one-line type alias toenssdk, the leaf package both sides already depend on. The zod invariant check inensnode-sdkthat currently usesdecodeEncodedReferrerinlines its ~10 lines of decode logic to avoid needing a runtime import fromens-referrals.No dep inversion:
ens-referrals → ensnode-sdkstays as today.Design decisions
enssdk, renamedEncodedReferrer→Referrer(type Referrer = Hex). Unbranded, consistent with theNormalizedAddresspolicy. Represents raw 32-byte onchain referrer bytes — contracts may emit arbitrary bytes in that slot, so the type can't promise more than "hex".EncodedReferrerlives inens-referralsastype EncodedReferrer = Referrer & { readonly __brand: "EncodedReferrer" }. Represents "aReferrerthat is guaranteed to be validly encoded" — 32-byte hex with 12 bytes of zero padding followed by a 20-byte lowercase address. Constructible only throughbuildEncodedReferrer(andZERO_ENCODED_REFERRER). The intersection means it trivially downcasts toReferrer/Hex/stringwhen passed to viem APIs or contract calls.ens-referralsatpackages/ens-referrals/src/encoded-referrer.ts(top-level, not underv1/— useful across versions). Exported frompackages/ens-referrals/src/index.tsonly (NOT re-exported fromsrc/v1/index.ts). Consumer import path is@namehash/ens-referrals.ens-referralsdoes not re-exportReferrer— consumers get it fromenssdkdirectly.buildEncodedReferrer(address: Address): EncodedReferrer— takesAddress, normalizes viatoNormalizedAddress(throws on invalid), pads to 32 bytes, returns the branded type (internalas EncodedReferrerassertion is the only cast).decodeEncodedReferrerrenamed todecodeReferrer; runtime behavior unchanged —(referrer: Referrer): NormalizedAddress. Throws on wrong byte length, throws on invalid trailing address bytes, returnszeroAddresson malformed 12-byte padding. Only the name changes; callers keep their current patterns (no?? zeroAddresscoalesce needed). Accepts the unbrandedReferrerbecause typical inputs come straight from protocol bytes; brandedEncodedReferreralso works via subtyping.buildEncodedReferrer+decodeReferrerby design.ZERO_ENCODED_REFERRERis typed asEncodedReferrer(it is a valid encoding of the zero address).ENCODED_REFERRER_BYTE_LENGTH,ENCODED_REFERRER_BYTE_OFFSET,EXPECTED_ENCODED_REFERRER_PADDINGdescribe the encoding format.RegistrarAction.encodedReferrerfield name stays (rename deferred to a follow-up). Its type staysReferrer(unbranded) since the stored value comes from raw protocol bytes and may not satisfy the branded invariant.File changes
enssdkpackages/enssdk/src/lib/types/evm.ts— appendexport type Referrer = Hex;alongsideHex/Address/NormalizedAddress. Doc comment stays ENS-level: "raw 32-byte onchain referrer value as emitted by ENS registrar controllers". Verify the existing barrel chain (src/index.ts→src/lib/index.ts→src/lib/types/evm.ts) picks it up automatically.ens-referrals— create runtime helperspackages/ens-referrals/src/encoded-referrer.ts:import type { Address, NormalizedAddress, Referrer } from "enssdk";import { toNormalizedAddress } from "enssdk";import { pad, size, slice, zeroAddress } from "viem";export type EncodedReferrer = Referrer & { readonly __brand: "EncodedReferrer" };— doc comment explains the invariant (32-byte, 12-byte zero padding, 20-byte lowercase address) and that the brand is only produced by the helpers in this module.ENCODED_REFERRER_BYTE_OFFSET = 12,ENCODED_REFERRER_BYTE_LENGTH = 32,EXPECTED_ENCODED_REFERRER_PADDING = pad("0x", { size: 12, dir: "left" }),ZERO_ENCODED_REFERRER: EncodedReferrer = pad("0x", { size: 32, dir: "left" }) as EncodedReferrer.export function buildEncodedReferrer(address: Address): EncodedReferrer { return pad(toNormalizedAddress(address), { size: ENCODED_REFERRER_BYTE_LENGTH, dir: "left" }) as EncodedReferrer; }export function decodeReferrer(referrer: Referrer): NormalizedAddress— body ported verbatim from the currentdecodeEncodedReferrer: throws on wrong length, returnszeroAddresson malformed padding, throws when trailing bytes aren't a valid address. Parameter is unbrandedReferrer; a brandedEncodedReferrerupcasts to it via subtyping.Referrerfrom this file.EncodedReferreris exported (it's this module's own type).packages/ens-referrals/src/encoded-referrer.test.ts— port the existing tests. Update thebuilding encoded referrerblock to passAddressdirectly (both lowercase and checksummed) without first casting toNormalizedAddress. Add a case forbuildEncodedReferrer("0xnotavalidaddress" as Address)throwing fromtoNormalizedAddress. Rename internal references fromdecodeEncodedReferrertodecodeReferrer.packages/ens-referrals/src/index.ts— addexport * from "./encoded-referrer";. Do not add tosrc/v1/index.ts.packages/ens-referrals/README.md— append abuildEncodedReferrersubsection to "Other Utilities" (currently lines 162-175). Import path in the example:@namehash/ens-referrals(root). Do not documentdecodeReferrer.ensnode-sdk— delete the module, inline the zod check, fix the field typepackages/ensnode-sdk/src/registrars/encoded-referrer.ts.packages/ensnode-sdk/src/registrars/encoded-referrer.test.ts.packages/ensnode-sdk/src/registrars/index.ts— removeexport * from "./encoded-referrer";.packages/ensnode-sdk/src/registrars/registrar-action.ts:import type { EncodedReferrer } from "./encoded-referrer"(line 4) and the two re-export lines (lines 6, 7).import type { Referrer } from "enssdk";.RegistrarActionReferralAvailable.encodedReferrer: EncodedReferrer→: Referrer(field name stays).RegistrarActionReferralAvailable.decodedReferrer: NormalizedAddress(field name stays)packages/ensnode-sdk/src/registrars/zod-schemas.ts:import { decodeEncodedReferrer, ENCODED_REFERRER_BYTE_LENGTH } from "./encoded-referrer".const ENCODED_REFERRER_BYTE_LENGTH = 32;andconst ENCODED_REFERRER_BYTE_OFFSET = 12;.invariant_registrarActionDecodedReferrerBasedOnRawReferrer(lines 89-116), inline the decode: check 32-byte size, slice the 12-byte padding, compare to the zero-padded constant, slice the trailing 20 bytes, coerce viatoNormalizedAddress. Match the current throw-then-custom-issue pattern. Usesize/slice/zeroAddressfromviemandtoNormalizedAddressfromenssdk.Consumer import updates
Referrertype imports — swapimport { type EncodedReferrer, ... } from "@ensnode/ensnode-sdk"toimport type { Referrer } from "enssdk"(other symbols on the same import line remain from@ensnode/ensnode-sdk):apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts— lines 13 import, 64referrer: EncodedReferrer, 141 same.apps/ensindexer/src/plugins/ensv2/handlers/ensv1/RegistrarController.ts— line 12 import, 41referrer?: EncodedReferrer, 94 same.apps/ensindexer/src/plugins/registrars/shared/lib/registrar-controller-events.ts— import +let encodedReferrer: EncodedReferrer | null;→Referrer | null(variable name preserved).apps/ensindexer/src/plugins/registrars/shared/lib/universal-registrar-renewal-with-referrer-events.ts— same.packages/ensdb-sdk/src/ensindexer-abstract/ensv2.schema.ts— line 20 import;$type<EncodedReferrer>()→$type<Referrer>()at lines 364 and 446.packages/ensdb-sdk/src/ensindexer-abstract/registrars.schema.ts— any$type<EncodedReferrer>()with matching import swap.Runtime helper imports — swap from
@ensnode/ensnode-sdkto@namehash/ens-referrals, and renamedecodeEncodedReferrer→decodeReferrer:apps/ensapi/src/lib/registrar-actions/find-registrar-actions.ts—ZERO_ENCODED_REFERRER. (ensapi already depends on@namehash/ens-referrals.)apps/ensindexer/src/plugins/registrars/ethnames/handlers/Ethnames_UniversalRegistrarRenewalWithReferrer.ts— renamedecodeEncodedReferrer(...)→decodeReferrer(...)at line 39; update import.apps/ensindexer/src/plugins/registrars/ethnames/handlers/Ethnames_RegistrarController.ts— same at lines 265 and 317.packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx—ZERO_ENCODED_REFERRER.Package dependency additions (
workspace:*)apps/ensindexer/package.json— add@namehash/ens-referrals.packages/namehash-ui/package.json— add@namehash/ens-referrals.packages/ensdb-sdk/package.json— no new dep (Referrercomes fromenssdk, already a dep).Changeset
.changeset/<slug>.md:Fixed-group version bumps cascade to the other workspace packages automatically.
Critical files to read before editing
packages/ensnode-sdk/src/registrars/encoded-referrer.ts— source being moved.packages/ensnode-sdk/src/registrars/encoded-referrer.test.ts— tests being ported.packages/ensnode-sdk/src/registrars/zod-schemas.ts:89-116— invariant check to rewrite inline.packages/ensnode-sdk/src/registrars/registrar-action.ts:1-7, 110-130— imports andRegistrarActionReferralAvailablefield.packages/enssdk/src/lib/types/evm.ts— destination forReferrertype.packages/enssdk/src/index.ts/src/lib/index.ts— barrel chain verification.packages/ens-referrals/src/index.ts,README.md:162-175.packages/enssdk/src/lib/address.ts— confirmtoNormalizedAddressthrows on invalid input (it does).Reuse
toNormalizedAddress(enssdk) — insidebuildEncodedReferrerand the inlined zod invariant.pad,size,slice,zeroAddress(viem) — unchanged from the current implementation.Verification
pnpm install— confirm new workspace deps resolve.pnpm -F enssdk typecheckpnpm -F @namehash/ens-referrals typecheckpnpm -F @ensnode/ensnode-sdk typecheckpnpm -F @ensnode/ensdb-sdk typecheckpnpm -F ensindexer typecheckpnpm -F ensapi typecheckpnpm -F @namehash/namehash-ui typecheckpnpm lintpnpm test --project ens-referrals— ported + newbuildEncodedReferrer(Address)/ invalid-input tests pass.pnpm test --project ensnode-sdk— registrar-action zod invariant still validates after inlining.pnpm test --project ensindexer— registrar event handler regressions.EncodedReferreridentifier and lingeringdecodeEncodedReferrerreference — should return zero hits.