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
This commit is contained in:
2026-08-09 14:45:29 -04:00
commit e90426ff43
14 changed files with 8231 additions and 0 deletions

104
test/namehash.test.ts Normal file
View File

@@ -0,0 +1,104 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { namehash, uLabel } from '../src/namehash.ts';
/**
* Every expected node below is GENERATED by the registry's own dsld_node(), not computed here and
* not reasoned about. That is the whole value of this file: it pins this implementation to the one
* that actually minted the tokens. If a vector needs changing, regenerate it from the registry - do
* not adjust it to match new TypeScript.
*/
const VECTORS: [string, string][] = [
['e.xp', '0x1c757df1d88d9dce1e45729d70449b42c6f99a0e0475155e676e68cb91783b55'],
['yo.xp', '0x7dae4412876316b0981abd680f94c693fc56bb40864cf9569d5c7af2613d14d0'],
['a.b.xp', '0x59a22041d87ddfc222ca8ef537dfffb237c8f41c04dd1e436bb98c08d4f1bb78'],
['xp', '0xca6be387bd1d023a035f36cf21918265350763acfd55558bcbdf3a9035066b8d'],
];
test('matches dsld_node() on ASCII names', () => {
for (const [name, expected] of VECTORS) {
assert.equal(namehash(name), expected, name);
}
});
test('the empty name is the ENS root, not a hash of nothing', () => {
assert.equal(namehash(''), '0x' + '0'.repeat(64));
assert.equal(namehash(' '), '0x' + '0'.repeat(64));
});
/**
* The reason this snap exists in the form it does. A user types the name as it is DISPLAYED -
* emoji-qualified, unicode - while the registry stores punycode. Both must reach the same node or
* every emoji name is unresolvable in the send field, silently.
*/
test('typed unicode, stored punycode and uppercase all reach one node', () => {
const expected = '0x61c4c02197848e39f678d802cfea098b6cf874c1682022fab3e7df523c028059';
assert.equal(namehash('xn--qei.xp'), expected, 'stored punycode');
assert.equal(namehash('❤️.xp'), expected, 'typed, emoji-qualified with U+FE0F');
assert.equal(namehash('❤.xp'), expected, 'typed, bare U+2764');
assert.equal(namehash('XN--QEI.XP'), expected, 'uppercase punycode');
});
test('case folding matches PHP for ASCII too', () => {
assert.equal(
namehash('E.XP'),
'0x1c757df1d88d9dce1e45729d70449b42c6f99a0e0475155e676e68cb91783b55',
);
});
test('rocket emoji, typed and stored', () => {
const expected = '0x65a9d2d706704f1f0314f66c2fdcf596261fbf3981fd888ea4b4666148bd7ff8';
assert.equal(namehash('xn--ls8h.xp'), expected);
});
/**
* TRANSITIONAL PROCESSING. These two vectors catch a wrong tr46 flag. Under nontransitional UTS-46 -
* tr46's default - eszett and final sigma survive instead of folding, producing different nodes with
* no visible symptom beyond "name not found".
*/
test('eszett folds to ss (transitional UTS-46, as PHP does)', () => {
assert.equal(uLabel('straße'), 'strasse');
assert.equal(
namehash('straße.xp'),
'0x3909ab8de875371725851b0c792dcc620597d6b5124c065334d4321e2b7feaab',
);
assert.equal(namehash('straße.xp'), namehash('strasse.xp'), 'both spellings, one name');
});
test('final sigma folds to medial sigma (transitional UTS-46)', () => {
assert.equal(uLabel('ς'), 'σ');
assert.equal(
namehash('ς.xp'),
'0x1f6610ef06d9604315423331b871e2693b47c6f97f12a08372848ce82c7fcf00',
);
});
/** ZWJ sequences are stripped of their joiners by UTS-46, so the family emoji is three people. */
test('ZWJ emoji sequences drop their joiners', () => {
const family = '\u{1F468}\u{1F469}\u{1F467}';
assert.equal(uLabel(family), '\u{1F468}\u{1F469}\u{1F467}');
assert.equal(
namehash(`${family}.xp`),
'0x0d1658f41aa11928cab892e4f436ad1d25c770e892990c0eff8d10803a63d5f0',
);
});
test('regional indicator flags survive intact', () => {
assert.equal(
namehash('\u{1F1FA}\u{1F1F8}.xp'),
'0x602309b5fcddcad430755ea8a315e2c7a1c392c29f46744cffd397a0f289d297',
);
});
/** Trailing dots only, matching PHP's rtrim - a leading dot is NOT silently forgiven. */
test('trailing dots are stripped, a leading dot is not', () => {
const exp = '0x1c757df1d88d9dce1e45729d70449b42c6f99a0e0475155e676e68cb91783b55';
assert.equal(namehash('e.xp.'), exp);
assert.equal(namehash('e.xp...'), exp);
assert.notEqual(namehash('.e.xp'), exp, 'a leading dot must not resolve as the same name');
});
test('a label that does not decode hashes as itself rather than vanishing', () => {
assert.equal(uLabel('xn--'), 'xn--');
assert.notEqual(namehash('xn--.xp'), namehash('.xp'));
});