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

217
test/bundle.test.ts Normal file
View File

@@ -0,0 +1,217 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
/**
* Exercises the BUILT BUNDLE, not the source. That distinction is the entire reason this file
* exists: tr46 reaches for the Node `punycode` builtin, which is what turns a stored xn-- label
* back into the emoji a user typed. If that polyfill is missing from snap.config.ts the bundle
* still compiles, still passes `mm-snap build`, and still "evaluates successfully" - it just
* silently fails to resolve every non-ASCII name. Only running the bundle catches that.
*
* Run `npm run build` before this. The provider is mocked, so no network and no real chain reads;
* what is asserted is the exact calldata the snap would put on the wire.
*/
const BUNDLE = fileURLToPath(new URL('../dist/bundle.js', import.meta.url));
type Call = { to: string; data: string };
/**
* The bundle is CommonJS while this package is type:module, so it cannot be require()d or
* import()ed. Evaluating it in a fresh vm context sidesteps that and is closer to what the Snaps
* runtime does anyway - and gives every test a clean module instance, so the snap's internal
* resolution cache never leaks between cases.
*/
function evaluateBundle(ethereum: unknown) {
const module_ = { exports: {} as Record<string, Function> };
const sandbox = {
module: module_,
exports: module_.exports,
ethereum,
console,
TextEncoder,
TextDecoder,
URL,
crypto: globalThis.crypto,
setTimeout,
clearTimeout,
};
vm.runInNewContext(readFileSync(BUNDLE, 'utf8'), vm.createContext(sandbox), {
filename: 'bundle.js',
});
return module_.exports;
}
/** Stands in for MetaMask's injected provider, recording calldata and replying with ABI words. */
function mockProvider(replies: Record<string, string>) {
const calls: Call[] = [];
const ethereum = {
async request({ method, params }: { method: string; params: unknown[] }) {
assert.equal(method, 'eth_call');
const [tx] = params as [Call, string];
calls.push({ to: tx.to.toLowerCase(), data: tx.data.toLowerCase() });
return replies[tx.data.toLowerCase()] ?? null;
},
};
return { ethereum, calls };
}
const word = (addr: string) => '0x' + '0'.repeat(24) + addr.replace(/^0x/u, '').toLowerCase();
const REGISTRY = '0x667ab1d9f98817ffb28cd61b911f921181c669b3';
const RESOLVER = '0xbc621963e531b0980aa754bd86bdde611b82be9c';
const HOLDER = '0xf1f2ddeb9a90f42499ac68109d0b10bed33a1cd7';
// dsld_node("e.xp") and dsld_node("xn--qei.xp"), from the production registry.
const NODE_E = '1c757df1d88d9dce1e45729d70449b42c6f99a0e0475155e676e68cb91783b55';
const NODE_HEART = '61c4c02197848e39f678d802cfea098b6cf874c1682022fab3e7df523c028059';
function loadSnap(replies: Record<string, string>) {
const { ethereum, calls } = mockProvider(replies);
return { snap: evaluateBundle(ethereum), calls };
}
function repliesFor(nodeHex: string) {
return {
[`0x0178b8bf${nodeHex}`]: word(RESOLVER), // resolver(bytes32)
[`0x3b3b57de${nodeHex}`]: word(HOLDER), // addr(bytes32)
};
}
test('resolves an ASCII name through the bundle', async () => {
const { snap, calls } = loadSnap(repliesFor(NODE_E));
const result = await snap.onNameLookup({ chainId: 'eip155:1', domain: 'e.xp' });
// Compared field-by-field, not with deepEqual: the result is built inside the vm context, so its
// prototype is that realm's Object and a strict structural compare rejects it as non-identical.
assert.deepEqual(JSON.parse(JSON.stringify(result)), {
resolvedAddresses: [{ resolvedAddress: HOLDER, protocol: 'Namebase', domainName: 'e.xp' }],
});
assert.equal(calls[0].to, REGISTRY, 'first call goes to the registry');
assert.equal(calls[0].data, `0x0178b8bf${NODE_E}`, 'resolver(bytes32) with the right node');
assert.equal(calls[1].to, RESOLVER, 'second call goes to the resolver the registry named');
});
/** The polyfill test. A typed, emoji-qualified name must produce the stored label's node. */
test('resolves a typed emoji name - proves the punycode polyfill is present', async () => {
const { snap, calls } = loadSnap(repliesFor(NODE_HEART));
const result = await snap.onNameLookup({ chainId: 'eip155:1', domain: '❤️.xp' });
assert.equal(result?.resolvedAddresses[0].resolvedAddress, HOLDER);
assert.equal(
calls[0].data,
`0x0178b8bf${NODE_HEART}`,
'U+FE0F must be stripped and the label punycode-folded, or this is a different node',
);
});
test('an unminted name resolves to nothing rather than the zero address', async () => {
const zero = '0x' + '0'.repeat(64);
const { snap } = loadSnap({
[`0x0178b8bf${NODE_E}`]: word(RESOLVER),
[`0x3b3b57de${NODE_E}`]: zero,
});
assert.equal(await snap.onNameLookup({ chainId: 'eip155:1', domain: 'e.xp' }), null);
});
test('a provider error is "unknown", not a bad name', async () => {
const snap = evaluateBundle({
async request() {
throw new Error('rpc exploded');
},
});
assert.equal(await snap.onNameLookup({ chainId: 'eip155:1', domain: 'e.xp' }), null);
});
test('other chains are refused even though the manifest already restricts them', async () => {
const { snap, calls } = loadSnap(repliesFor(NODE_E));
assert.equal(await snap.onNameLookup({ chainId: 'eip155:10', domain: 'e.xp' }), null);
assert.equal(calls.length, 0, 'and it must not have called out at all');
});
test('a reverse lookup (address, no domain) is declined', async () => {
const { snap } = loadSnap({});
assert.equal(await snap.onNameLookup({ chainId: 'eip155:1', address: HOLDER }), null);
});
/**
* The manifest carries no matchers.tlds, so MetaMask routes EVERY domain here and these rules are
* the whole filter.
*/
test('.eth is refused without touching the chain', async () => {
const { snap, calls } = loadSnap({});
for (const name of ['vitalik.eth', 'VITALIK.ETH', 'a.b.eth', 'vitalik.eth.']) {
assert.equal(await snap.onNameLookup({ chainId: 'eip155:1', domain: name }), null, name);
}
assert.equal(calls.length, 0, 'and it must not have called out at all');
});
test('a bare TLD and malformed names are refused before any chain read', async () => {
const { snap, calls } = loadSnap({});
for (const name of ['xp', 'xp.', '', ' ', '.xp', '.e.xp', 'a..xp']) {
assert.equal(await snap.onNameLookup({ chainId: 'eip155:1', domain: name }), null, JSON.stringify(name));
}
assert.equal(calls.length, 0);
});
test('ordinary names still go through now that the TLD list is gone', async () => {
const { snap, calls } = loadSnap(repliesFor(NODE_E));
const result = await snap.onNameLookup({ chainId: 'eip155:1', domain: 'e.xp' });
assert.equal(result?.resolvedAddresses[0].resolvedAddress, HOLDER);
assert.equal(calls.length, 2, 'registry then resolver');
});
/**
* A resolver that answers with the zero address is worse than one that stays quiet: anything sent
* there is destroyed. When we do not know, we answer null and let MetaMask's own resolvers reply.
*/
test('never resolves to the zero address, and never falls back to ENS', async () => {
const zero = '0x' + '0'.repeat(64);
const noResolver = loadSnap({ [`0x0178b8bf${NODE_E}`]: zero });
assert.equal(await noResolver.snap.onNameLookup({ chainId: 'eip155:1', domain: 'e.xp' }), null,
'a zero resolver is not a resolution');
const noAddr = loadSnap({ [`0x0178b8bf${NODE_E}`]: word(RESOLVER), [`0x3b3b57de${NODE_E}`]: zero });
assert.equal(await noAddr.snap.onNameLookup({ chainId: 'eip155:1', domain: 'e.xp' }), null,
'a zero address is not a resolution');
const empty = loadSnap({});
assert.equal(await empty.snap.onNameLookup({ chainId: 'eip155:1', domain: 'e.xp' }), null,
'an empty reply is not a resolution');
});
/**
* onNameLookup fires per keystroke, so every prefix that happens to look like a name leaves a cache
* entry and nothing expires them on its own. Unbounded, that grows for as long as the snap lives.
*/
test('the resolution cache is bounded and still serves recent names', async () => {
const replies: Record<string, string> = {};
const { snap, calls } = loadSnap(replies);
// Far more distinct names than the cache holds. Each misses, so each is one registry call.
for (let i = 0; i < 400; i++) {
await snap.onNameLookup({ chainId: 'eip155:1', domain: `n${i}.xp` });
}
const afterFill = calls.length;
assert.equal(afterFill, 400, 'one registry call per distinct name, all misses');
// The most recent name is still cached: asking again must not hit the chain.
await snap.onNameLookup({ chainId: 'eip155:1', domain: 'n399.xp' });
assert.equal(calls.length, afterFill, 'a recent name is served from cache');
// The oldest was evicted rather than retained forever, so it costs a lookup again.
await snap.onNameLookup({ chainId: 'eip155:1', domain: 'n0.xp' });
assert.equal(calls.length, afterFill + 1, 'the oldest entry was evicted');
});
test('trailing dots share one cache entry', async () => {
const { snap, calls } = loadSnap(repliesFor(NODE_E));
await snap.onNameLookup({ chainId: 'eip155:1', domain: 'e.xp' });
const after = calls.length;
await snap.onNameLookup({ chainId: 'eip155:1', domain: 'e.xp.' });
assert.equal(calls.length, after, '"e.xp." must not pay for the same lookup twice');
});

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'));
});