Files
metamask/README.md
eskimo e90426ff43 Resolve Namebase decentralized domains in MetaMask
Namebase domains are Handshake names minted onto Ethereum as non-expiring
ERC-721s. MetaMask cannot resolve them on its own: it asks the ENS registry,
which has never heard of them. This snap knows where to look instead.

It talks to no server. Whatever a name resolves to is where the user's money
goes, so it is read from the chain through the user's own provider - which is
also why no network permission is needed. addr() falls back to whoever holds
the token, so a name keeps paying the right person after a sale.

The manifest declares chains only and carries no TLD list, so enabling minting
on a new TLD needs no change here. Two rules in src/index.ts filter instead:
stay out of TLDs another naming system is authoritative for, and ignore
anything without two labels. When in doubt the answer is null, never an
address.

Hashing mirrors the registry's dsld_node() and every test vector is generated
from it. Two requirements are pinned because breaking either is silent: UTS-46
must be transitional, and it must not be ENSIP-15. Either mistake yields a
valid-looking node with no records and a name that never resolves. The built
bundle is tested separately from the source because tr46 reaches for the Node
punycode builtin - without that polyfill the build is clean and every
non-ASCII name fails at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YUVyawKbjSWw57md791LUu
2026-08-09 15:05:18 -04:00

5.1 KiB

Namebase Domains - MetaMask Snap

Type alice.xp into MetaMask's send field and have it resolve to the address its holder is paid at.

Namebase decentralized domains are Handshake names minted onto Ethereum as non-expiring ERC-721s. MetaMask cannot resolve them on its own: it asks the ENS registry at 0x0000...2e1e, which has never heard of them. This snap knows where to look instead.

It talks to no server

Whatever this snap returns is where the user's money goes. Resolution is two eth_calls through the user's own MetaMask provider:

registry.resolver(namehash(name))   ->  the resolver for that name
resolver.addr(node)                 ->  the address to pay

Nothing of Namebase's is in that path, so nobody at Namebase can redirect a payment and resolution keeps working whatever happens to our infrastructure. The snap needs no network permission for the same reason.

addr() falls back to whoever currently holds the token, so a name keeps paying the right person after a sale with no transaction from anyone.

There is no TLD allowlist

The manifest declares chains only, so every domain in the send field reaches the handler. Two rules in src/index.ts decide what to answer for:

  • DENIED_TLDS - just .eth. Names are keyed by namehash alone, so nothing about a node says which naming system ought to own it. Where another system is already authoritative for a TLD, this snap stays out of it rather than offering a second answer in the send field. Kept deliberately small: a Handshake TLD may legitimately share a string with some other naming system, and refusing those would break names we are supposed to serve.
  • At least two labels. A bare TLD is not something anyone sends funds to.

Both run before any chain read. Everything else is left to registry policy, which will not sell a mint under a TLD it does not host.

Keeping the list here rather than in matchers.tlds means enabling minting on a new TLD needs no change to this snap at all.

When we do not know, we answer null and let MetaMask's own resolvers reply. A resolver that answers with the zero address is worse than one that stays quiet: anything sent there is destroyed.

Hashing has to match the registry exactly

The node is an ERC-721 token id. Getting it wrong does not fail loudly - it computes a different, perfectly valid-looking node with no records, and the name simply reports as unresolvable.

src/namehash.ts mirrors dsld_node() in the Namebase registry, and every expected value in test/namehash.test.ts is generated from it rather than computed here. Two requirements are pinned by tests, because breaking either is silent:

  • UTS-46 must be transitional. straße folds to strasse and a final sigma folds to a medial one. Nontransitional UTS-46 - which is tr46's default - keeps them apart and hashes to nodes the contract has never heard of.
  • UTS-46, not ENSIP-15. @adraffy/ens-normalize is the obvious library to reach for here and is the wrong one: it normalizes differently and preserves the emoji variation selectors UTS-46 strips. These tokens were minted under UTS-46.

A user types the name as it is displayed - emoji-qualified, unicode - while the registry stores punycode. namehash('❤️.xp'), namehash('❤.xp') and namehash('xn--qei.xp') all reach one node.

Build and test

npm install
npm run build     # mm-snap build -> dist/bundle.js
npm test          # 23 tests, source and bundle

test/bundle.test.ts runs the built bundle in a vm with a mocked provider. That is not redundant with the source tests: tr46 reaches for the Node punycode builtin, and without the polyfill in snap.config.ts the bundle still compiles, still passes mm-snap build, and still reports "evaluated successfully" - it just fails to resolve every non-ASCII name at runtime.

Local development

npm run serve     # the snap, on http://localhost:8080
npm run site      # the install page, on http://localhost:8000

Open http://localhost:8000 in MetaMask Flask and click Install, then type a .xp name into the send field. Local snaps require Flask; the stable extension installs allowlisted snaps only.

Manifest changes (name, icon, permissions) need a remove-and-reinstall in Flask to take effect; code changes only need npm run build and a reconnect.

Deployed contracts

NamebaseRegistry 0x667AB1d9F98817ffb28cD61b911F921181C669b3
NamebaseResolver 0xbc621963e531b0980aA754bD86bDDE611b82bE9c
NamebaseMinter 0x890C43092AfF84Dc07074c28bA158543c6d50cAE
chain Ethereum mainnet (eip155:1)

The registry address is also what each TLD publishes on Handshake as its HIP-5 record (NS 0x667ab1d9...._eth.), which is what makes it effectively permanent - changing it would mean every TLD owner republishing an on-chain record.

Scope

Domain to address only. Reverse lookup needs an address-to-name index, which neither the registry nor the minter keeps.

Not DNS. This resolves payment addresses, not websites. Reaching a minted site in a browser needs a HIP-5-aware DNS resolver, which is a separate problem.