Skip to content

feat(ens): @libid/ens, the ENS name of a handle - #109

Open
SupremaLex wants to merge 19 commits into
mainfrom
feat/ens-names
Open

SupremaLex wants to merge 19 commits into
mainfrom
feat/ens-names

Conversation

@SupremaLex

@SupremaLex SupremaLex commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

A new package, @libid/ens: the ENS name of a libID handle, for applications that want to show users the name they can paste into a bio or a wallet.

import { ensName } from '@libid/ens'

ensName('x', '@Some_Handle')                              // 'some-handle.x.handles.link'
ensName('google', 'Alice.Smith@Gmail.com')                // 'alice.smith.google.handles.link'
ensName('google', 'alice@company.com')                    // 'alice._at.company.com.google.handles.link'
ensName('x', 'alice', { chain: 'base' })                  // 'alice.x.base.handles.link'
ensName('x', 'alice', { parent: 'testnet.handles.link' }) // 'alice.x.testnet.handles.link'
ensName('x', 'ab__cd')                                    // null

What it does

  • Input. It normalizes the handle with the released rules from @libid/contracts. Those are the rules the gateway applies on every chain, so a name it builds is one the gateway reads back.
  • Labels. It applies the label rules of the ENS integration spec (REQ-ENS-LABEL-01 to -04, docs(specs): specify the ENS integration #93):
    • GitHub: the handle unchanged.
    • X: every _ becomes -.
    • Gmail: the local part, split at dots.
    • Other Google domains: the local part, _at, then the domain.
  • No name. A handle gets none when any label would fail ENSIP-15 or DNS:
    • -- at the third and fourth characters, including xn-- domains;
    • Gmail +, - or _;
    • an empty piece;
    • _at as a piece;
    • more than 63 bytes.
  • Results. null means the handle has no name. Text that is not a handle on the platform throws HandleError, which is re-exported.
  • Options.
    • chain narrows the name to one chain.
    • parent (default handles.link) targets a subname deployment such as testnet.handles.link.
    • Both are checked as labels. A chain label can't be a platform key.
    • A chain label must not name a subname that has its own deployment. That is documented, not enforced, because no single call can see which subnames do.
  • Constants. Platform keys and handle rules come from @libid/contracts. @libid/contracts is pinned once, in the pnpm catalog that ceremony also uses.
  • viem. It is a peer dependency, because @libid/contracts/identity loads viem. libID-contracts popup: mobile-WebKit flake in close-right-after-navigate (CI only) #86 adds a viem-free @libid/contracts/handle, which lets this package drop viem once released.

Checks

  • 139 tests, 2 files:
    • each rule and refusal;
    • chain and parent validation, and null options;
    • vectors/names.json: names checked forward through ensName and back through a test-only inverse written from REQ-ENS-LABEL-05;
    • every handle in that file is already normalized;
    • every name is ENSIP-15 normal under viem's normalize.
  • Gateway compatibility, once by hand: an early set of names parsed back correctly through the gateway's own parser (Name::query in usernames-indexer). The gateway does not read vectors/names.json yet.
  • Repo checks:
    • typecheck, Biome, oxlint and build;
    • test:packages, which imports @libid/ens in the packed-package consumer;
    • the same consumer installed first without @libid/ens, to check that ledger, popup and ceremony load without viem. An injected viem import in ceremony makes this check fail.

Known gaps

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ar21wPcHzNprswTzLJd3MA

ensName(platform, handle, { chain }) normalizes the handle with the
registry's rules from @libid/contracts and returns its name under
handles.link, per the ENS integration spec's REQ-ENS-LABEL-01 to -04:
GitHub unchanged, X with `_` as `-`, Gmail's local part split at dots,
other Google domains with an `_at` marker. A handle with no name gives
null. Tests cover each rule and check every name is ENSIP-15 normal
with @adraffy/ens-normalize.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
One check now decides whether a piece of a name is a label: lowercase
ASCII letters, digits and `-`, 1 to 63 bytes, and not `--` at the
third and fourth characters. Handle labels, chain labels and the X
substitution all go through it.

- A Workspace local part or domain is split and checked piece by piece
  before `_at` is inserted, so `alice._at@company.com` and
  `alice@_at.company.com` no longer produce names the gateway refuses
  or that collide.
- A Workspace piece with `--` at the third and fourth characters,
  including an IDN `xn--` domain, has no name, as an X handle with
  `__` there has none: ENSIP-15 rejects it.
- A chain label is held to the same check, so a 64-byte label, a
  `--` label or a non-string (`chain: null`) throws instead of giving
  `alice.x..handles.link`.
- `handleLabels` is no longer exported: it trusted its input to be
  normalized, and `ensName` always normalizes first.
- The platform list comes from @libid/contracts' PLATFORM_*_KEY
  constants and the Platform type from it; the duplicate chain regex
  is gone.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
`ensName` normalized with the rules @libid/contracts was released with
and always put the name under handles.link. A chain's owner can change
a platform's rules, and a deployment can answer under a subname such
as testnet.handles.link. `options.rules` (for example from `rulesOf`)
and `options.parent` now cover both; the defaults are unchanged. The
parent name's labels pass the same check as every other label.

The 63-byte label ceiling is now tested through a path production can
take: rules whose Google length limit is raised past the released 62.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Ceremony pins @libid/contracts 0.15.1 exactly; ens asked for ^0.17.0,
so an app with both installed two copies and a HandleError thrown by
one failed `instanceof` against the other. Ens now pins the same
0.15.1 (its handle normalizer and rule table are byte-identical to
0.17.0's) and re-exports HandleError and the Rules type, so callers
catch the class ens throws.

Ens imports `@libid/contracts/identity` instead of the package root,
which also loaded every ABI and call builder. viem stays a required
peer: `identity` re-exports its node and resolve modules, both of
which import viem at load, and @libid/contracts exports no viem-free
subpath for the normalizer. Loading the built package without viem
fails with ERR_MODULE_NOT_FOUND for viem from
@libid/contracts/dist/identity/node.js. The README and RELEASING.md
say so.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
PARENT_NAME was exported as a second statement of the Parent Name,
which the spec defines and `options.parent` now defaults to. It is
module-private, with its one default and a comment naming where the
spec defines it. Platform keys and handle rules already come from
@libid/contracts.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
vectors/names.json lists, for REQ-ENS-LABEL-01 to -05 and
REQ-ENS-NAME-01 to -02:

- names: platform, normalized handle, chain label and parent, and the
  name they give;
- unnamed: normalized handles with no name, each with the reason;
- nonNames: names that read back as no handle.

The TS tests run every name forward through `ensName`, back through
an inverse written from REQ-ENS-LABEL-05 alone, and through
@adraffy/ens-normalize; they check unnamed handles give null and
nonNames read back as nothing. The gateway's inverse in
usernames-indexer (crates/usernames-core/src/ens.rs) should read the
same file; it does not yet.

The table stays out of the package tarball. index.test.ts keeps only
what the table cannot hold: raw input, options and errors. The tests
read the table under Node, so the package's tsconfig includes the
Node types; the build's sets none.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The README and the source header linked specs/ens-integration.md by a
relative path that is not on main and is dead on npm. Both now link
the spec on the docs/ens-integration branch at a pinned commit. The
README lists every refusal, including the Workspace `--` and 63-byte
cases, and points at the vector table.

RELEASING.md said "all three packages" and listed three first
publications; it now counts four and publishes ens.tgz too.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The consumer installed ens's tarball but never imported it, so its
packed declarations were neither typechecked nor bundled. The browser
entry now re-exports ens's public API, so both TypeScript versions
check it and vite bundles it with the viem it requires as a peer.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
SupremaLex added a commit that referenced this pull request Oct 5, 2026
The current deployment holds the Handle Resolver's owner, the deployer
and the Parent Name's owner as one key, so it does not meet
REQ-ENS-KEY-01; Security Considerations say so. Provenance records the
forward transform in @libid/ens (libID PR #109, unmerged) and what it
lacks, the unimplemented SHOULD of REQ-ENS-LABEL-05, where REQ-ENS-RES-05
is enforced, and the new REQ-ENS-GW-11.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
That revision has the Workspace refusal and the Parent Name parameter
the package implements.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The gateway reads every chain's names with the handle rules
@libid/contracts was released with, so a name built with other rules
could be one the gateway reads as a different handle or none. The
`rules` option is gone, along with the README's rulesOf example and
the `Rules` re-export; `ensName` always normalizes with `rulesFor`.
The 63-byte ceiling stays tested through the paths that reach it,
chain and parent labels.

- `ensName(p, h, null)` reads null options as none instead of
  throwing a TypeError.
- The platform switch uses the PLATFORM_*_KEY constants.
- The unreachable "no rules" branch is gone: every Platform has
  released rules.
- The ENSIP-15 block that never called `ensName` is gone; the vector
  test runs every name through ENSIP-15.
- The README lists Gmail's empty pieces (`.alice@`, `alice.@`,
  `a..b@`) as having no name, says the gateway does not read the
  vector table yet, and states that a chain label naming a subname
  with its own deployment (`testnet`) is answered by that deployment,
  which `ensName` cannot see.

BREAKING CHANGE: NameOptions no longer has `rules`, and `Rules` is no
longer exported.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
- Every handle in the vector table must already be what the released
  rules normalize it to, so a vector cannot test input the transform
  never receives.
- Gmail local parts with an empty piece (`.alice@`, `alice.@`,
  `a..b@`) join the handles with no name.
- The ENSIP-15 check uses `normalize` from viem/ens, which is
  @adraffy/ens-normalize, so that devDependency is gone.
- The test header says plainly that the inverse is this package's own
  and the gateway does not read the file yet.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Ceremony and ens each pinned @libid/contracts 0.15.1 by hand, so a
bump to one could leave two copies and two HandleError classes. Both
now take `catalog:`, and pnpm-workspace.yaml holds the one exact
version. pnpm pack writes 0.15.1 into both packed manifests, which
the package check already requires.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The consumer has viem because ens requires it as a peer, so it can no
longer show that an app without ens needs no viem. A second consumer
installs the ledger, popup and ceremony tarballs alone, asserts viem
was not installed, and imports every runtime entry under Node. A
module of theirs that starts importing viem, such as
@libid/contracts/identity, fails it with ERR_MODULE_NOT_FOUND. The
ens consumer drops the `Rules` re-export ens no longer has.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The README linked vectors/names.json on main, where it does not exist
until this branch merges, and where it can change under the link. It
now links the commit that last changed the table.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
isPlatform replaces the two spellings of the platform-key test, the
parent name reuses pieces, the label regex is a module constant, and a
Gmail local part is its pieces with no `-` rather than a second regex.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The npm consumer is installed first without ens, where ledger, popup and
ceremony must load with no viem, then again with ens for the type and
bundle checks. This drops the second fixture directory and its manifest.
The contracts catalog entry moves above the playwright comment that
describes the entry after it.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Drop the forward-looking notes about the gateway's inverse and reflow a
broken paragraph in RELEASING.md.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant