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