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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Namebase
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
111
README.md
Normal file
111
README.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# 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_call`s 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
16
images/icon.svg
Normal file
16
images/icon.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg version="1.2" baseProfile="tiny-ps" viewBox="0 0 320 320" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Namebase</title>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="nbdot" gradientUnits="userSpaceOnUse" x1="21.6047" y1="4.34818" x2="27.1338" y2="9.87731">
|
||||||
|
<stop offset="0" stop-color="#FF5980"/>
|
||||||
|
<stop offset="1" stop-color="#FFA882"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="320" height="320" fill="#FFFFFF"/>
|
||||||
|
<g transform="translate(64,64) scale(6)">
|
||||||
|
<path d="M16 5C23.1797 5 29 10.8203 29 18V30H21V18C21 15.2386 18.7614 13 16 13V5Z" fill="#007FFF"/>
|
||||||
|
<path d="M16 5C8.8203 5 3 10.8203 3 18V30H11V18C11 15.2386 13.2386 13 16 13V5Z" fill="#FFB700"/>
|
||||||
|
<path d="M27.1428 7.21429C28.7207 7.21429 29.9999 5.9351 29.9999 4.35714C29.9999 2.77919 28.7207 1.5 27.1428 1.5C25.5648 1.5 24.2856 2.77919 24.2856 4.35714C24.2856 5.9351 25.5648 7.21429 27.1428 7.21429Z" fill="url(#nbdot)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 912 B |
7311
package-lock.json
generated
Normal file
7311
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@namebase.io/metamask",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Resolve Namebase decentralized domains in MetaMask, by reading the chain directly",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.eskimo.dev/namebase/metamask.git"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"images",
|
||||||
|
"snap.manifest.json"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "mm-snap build",
|
||||||
|
"serve": "mm-snap serve",
|
||||||
|
"test": "node --test test/*.test.ts",
|
||||||
|
"site": "python3 -m http.server 8000 --directory site",
|
||||||
|
"prepublishOnly": "npm run build && npm test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@metamask/snaps-sdk": "^11.2.0",
|
||||||
|
"@noble/hashes": "^2.3.0",
|
||||||
|
"tr46": "^6.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@metamask/snaps-cli": "^8.4.1",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
|
},
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
92
site/index.html
Normal file
92
site/index.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Install - Namebase Domains snap</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; --ink:#101418; --mute:#5b6570; --line:#d8dde3; --bg:#fff; --ok:#0a7d32; --bad:#b0202b; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { --ink:#e9edf1; --mute:#98a3ae; --line:#2a3138; --bg:#0d1114; --ok:#5fd48a; --bad:#ff8189; }
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin:0; padding:48px 24px; background:var(--bg); color:var(--ink);
|
||||||
|
font:16px/1.6 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
|
||||||
|
main { max-width: 640px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 1.6rem; margin: 0 0 .25rem; letter-spacing: -0.01em; }
|
||||||
|
p.sub { color: var(--mute); margin: 0 0 2rem; }
|
||||||
|
button { font: inherit; font-weight: 600; padding: .7rem 1.4rem; border-radius: 10px;
|
||||||
|
border: 0; background: var(--ink); color: var(--bg); cursor: pointer; }
|
||||||
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
#status { margin-top: 1.25rem; padding: .9rem 1.1rem; border:1px solid var(--line);
|
||||||
|
border-radius: 10px; white-space: pre-wrap; display: none; }
|
||||||
|
#status.show { display: block; }
|
||||||
|
.ok { color: var(--ok); } .bad { color: var(--bad); }
|
||||||
|
ol { color: var(--mute); padding-left: 1.2rem; }
|
||||||
|
code { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: .9em;
|
||||||
|
background: color-mix(in srgb, var(--ink) 8%, transparent); padding: .1em .35em; border-radius: 4px; }
|
||||||
|
hr { border:0; border-top:1px solid var(--line); margin: 2rem 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Namebase Domains</h1>
|
||||||
|
<p class="sub">Install the local build into MetaMask Flask.</p>
|
||||||
|
|
||||||
|
<button id="install">Install snap</button>
|
||||||
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<p class="sub" style="margin-bottom:.75rem">Then test it:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Open Flask and switch to <b>Ethereum Mainnet</b> - the snap only answers on <code>eip155:1</code>.</li>
|
||||||
|
<li>Press <b>Send</b>, and type <code>e.xp</code> into the To field.</li>
|
||||||
|
<li>It should resolve to <code>0xf1F2Dde…33A1Cd7</code>.</li>
|
||||||
|
</ol>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Local snaps are served by `npm run serve` on 8080; this page is served separately, so the ID is
|
||||||
|
// absolute rather than relative to wherever you opened it from.
|
||||||
|
const SNAP_ID = 'local:http://localhost:8080';
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const button = document.getElementById('install');
|
||||||
|
|
||||||
|
function say(message, kind) {
|
||||||
|
statusEl.textContent = message;
|
||||||
|
statusEl.className = 'show ' + (kind || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
if (!window.ethereum) {
|
||||||
|
say('No wallet found. MetaMask Flask must be installed and enabled for this page.', 'bad');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
say('Waiting for MetaMask…');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.ethereum.request({
|
||||||
|
method: 'wallet_requestSnaps',
|
||||||
|
params: { [SNAP_ID]: {} },
|
||||||
|
});
|
||||||
|
const installed = result?.[SNAP_ID];
|
||||||
|
say('Installed: ' + (installed?.version ?? 'ok') +
|
||||||
|
'\nNow open Send in Flask and type a .xp name.', 'ok');
|
||||||
|
} catch (error) {
|
||||||
|
// 4001 is the user dismissing the prompt, which is not a failure worth shouting about.
|
||||||
|
const message = error?.code === 4001
|
||||||
|
? 'Installation was dismissed.'
|
||||||
|
: (error?.message || String(error));
|
||||||
|
say(message + '\n\nIf this says the method is not supported, you are on regular MetaMask - ' +
|
||||||
|
'local snaps need Flask.', 'bad');
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
snap.config.ts
Normal file
18
snap.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { SnapConfig } from '@metamask/snaps-cli';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tr46 pulls in the Node `punycode` builtin and Buffer - and punycode is the exact code path that
|
||||||
|
* turns a stored xn-- label back into the emoji a user typed. Without these polyfills the bundle
|
||||||
|
* still loads and still evaluates cleanly; it just fails to resolve any non-ASCII name at runtime.
|
||||||
|
* Do not "clean up" these flags because the build is quiet without them.
|
||||||
|
*/
|
||||||
|
const config: SnapConfig = {
|
||||||
|
input: './src/index.ts',
|
||||||
|
server: { port: 8080 },
|
||||||
|
polyfills: {
|
||||||
|
buffer: true,
|
||||||
|
punycode: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
28
snap.manifest.json
Normal file
28
snap.manifest.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Resolve Namebase decentralized domains to the address their holder is paid at, read straight from Ethereum.",
|
||||||
|
"proposedName": "Namebase",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.eskimo.dev/namebase/metamask.git"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"shasum": "FWoRKTN/csXGg7tCXRyoLltTBgFWO5UO6tjyUjDFlWU=",
|
||||||
|
"location": {
|
||||||
|
"npm": {
|
||||||
|
"filePath": "dist/bundle.js",
|
||||||
|
"iconPath": "images/icon.svg",
|
||||||
|
"packageName": "@namebase.io/metamask",
|
||||||
|
"registry": "https://registry.npmjs.org"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"initialPermissions": {
|
||||||
|
"endowment:name-lookup": {
|
||||||
|
"chains": ["eip155:1"]
|
||||||
|
},
|
||||||
|
"endowment:ethereum-provider": {}
|
||||||
|
},
|
||||||
|
"platformVersion": "11.2.0",
|
||||||
|
"manifestVersion": "0.1"
|
||||||
|
}
|
||||||
176
src/index.ts
Normal file
176
src/index.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import type { OnNameLookupHandler } from '@metamask/snaps-sdk';
|
||||||
|
import { namehash } from './namehash';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a Namebase decentralized domain to the address its holder is paid at, by reading the
|
||||||
|
* chain directly.
|
||||||
|
*
|
||||||
|
* THIS SNAP TALKS TO NO SERVER, AND THAT IS THE POINT. Whatever is returned here is where the user's
|
||||||
|
* money goes, so it is read from the chain through the user's own provider. Nothing of Namebase's is
|
||||||
|
* in that path: nobody here can redirect a payment, and resolution keeps working whatever happens to
|
||||||
|
* our infrastructure. It is also why the snap needs no network permission.
|
||||||
|
*
|
||||||
|
* THERE IS NO TLD ALLOWLIST. The manifest declares chains only, so every domain reaches this
|
||||||
|
* handler, filtered by two rules that live here - where enabling minting on a new TLD needs no
|
||||||
|
* change to this snap at all:
|
||||||
|
*
|
||||||
|
* DENIED_TLDS is the first. 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. It is 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.
|
||||||
|
*
|
||||||
|
* The second is simply that anything without at least two labels is not a name someone sends funds
|
||||||
|
* to.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** NamebaseRegistry, Ethereum mainnet. Immutable in practice: it is in every TLD's on-chain record. */
|
||||||
|
const REGISTRY = '0x667AB1d9F98817ffb28cD61b911F921181C669b3';
|
||||||
|
|
||||||
|
/** ENS interface selectors - our contracts implement them, so these are the standard values. */
|
||||||
|
const SEL_RESOLVER = '0x0178b8bf'; // resolver(bytes32)
|
||||||
|
const SEL_ADDR = '0x3b3b57de'; // addr(bytes32)
|
||||||
|
|
||||||
|
const PROTOCOL = 'Namebase';
|
||||||
|
const CHAIN = 'eip155:1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See the note at the top. Only `.eth`, and adding to this set should need the same argument: that
|
||||||
|
* another naming system is already authoritative for the TLD.
|
||||||
|
*/
|
||||||
|
const DENIED_TLDS = new Set(['eth']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cheap pre-filter, before any chain read. Every domain in the send field now reaches this snap,
|
||||||
|
* so refusing the obvious cases here is what keeps the eth_call count down - and returning false
|
||||||
|
* means "not ours", which lets MetaMask's own resolvers answer instead.
|
||||||
|
* @param domain - The domain as MetaMask supplied it.
|
||||||
|
* @returns True when this snap should attempt to resolve the name.
|
||||||
|
*/
|
||||||
|
function isResolvable(domain: string): boolean {
|
||||||
|
const fqdn = domain.trim().toLowerCase().replace(/\.+$/u, '');
|
||||||
|
if (fqdn === '') return false;
|
||||||
|
|
||||||
|
const labels = fqdn.split('.');
|
||||||
|
// A bare TLD is not something anyone sends funds to, and an empty label is a malformed name -
|
||||||
|
// ".e.xp" must not be quietly treated as "e.xp".
|
||||||
|
if (labels.length < 2 || labels.some((label) => label === '')) return false;
|
||||||
|
|
||||||
|
return !DENIED_TLDS.has(labels[labels.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onNameLookup fires as the user types, so an uncached implementation issues two eth_calls per
|
||||||
|
* keystroke against their RPC provider. Cache both hits and misses: a typo is looked up as often as
|
||||||
|
* a real name. Short TTL because a sale moves addr() with no transaction from anyone, via the
|
||||||
|
* holder fallback - a stale hit would name the previous owner as the payee.
|
||||||
|
*/
|
||||||
|
const TTL_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded, because this fires on every keystroke: typing one name leaves an entry for each prefix
|
||||||
|
* that happens to look like a name, and nothing here expires entries on its own. Map preserves
|
||||||
|
* insertion order, so deleting the first key is a plain FIFO eviction - not LRU, but the working set
|
||||||
|
* is whatever is being typed right now, and a wrong eviction costs one extra pair of eth_calls.
|
||||||
|
*/
|
||||||
|
const CACHE_MAX = 256;
|
||||||
|
const cache = new Map<string, { address: string | null; expires: number }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a resolution, evicting the oldest entries once the cache is full.
|
||||||
|
* @param key - The normalized domain name.
|
||||||
|
* @param address - The resolved address, or null when the name does not resolve.
|
||||||
|
*/
|
||||||
|
function cacheSet(key: string, address: string | null): void {
|
||||||
|
// Re-inserting must move the key to the end, or a repeatedly-refreshed entry keeps its original
|
||||||
|
// position and is evicted while still in use.
|
||||||
|
cache.delete(key);
|
||||||
|
cache.set(key, { address, expires: Date.now() + TTL_MS });
|
||||||
|
|
||||||
|
while (cache.size > CACHE_MAX) {
|
||||||
|
const oldest = cache.keys().next();
|
||||||
|
if (oldest.done) break;
|
||||||
|
cache.delete(oldest.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One eth_call through the user's own provider.
|
||||||
|
* @param to - The contract to call.
|
||||||
|
* @param data - ABI-encoded calldata.
|
||||||
|
* @returns The raw hex result, or null if the provider could not answer.
|
||||||
|
*/
|
||||||
|
async function ethCall(to: string, data: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await ethereum.request({
|
||||||
|
method: 'eth_call',
|
||||||
|
params: [{ to, data }, 'latest'],
|
||||||
|
});
|
||||||
|
return typeof result === 'string' ? result : null;
|
||||||
|
} catch {
|
||||||
|
// A provider error is "we do not know", never "no such name". Returning null here lets MetaMask
|
||||||
|
// fall through to its other resolvers instead of asserting the name is bad.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last 20 bytes of an ABI word, rejecting the zero address and anything malformed.
|
||||||
|
* @param word - A 32-byte ABI-encoded word as hex.
|
||||||
|
* @returns The address, or null if it is malformed or zero.
|
||||||
|
*/
|
||||||
|
function wordToAddress(word: string | null): string | null {
|
||||||
|
if (typeof word !== 'string') return null;
|
||||||
|
const hex = word.startsWith('0x') ? word.slice(2) : word;
|
||||||
|
if (hex.length < 64) return null;
|
||||||
|
const addr = '0x' + hex.slice(-40);
|
||||||
|
if (!/^0x[0-9a-f]{40}$/iu.test(addr)) return null;
|
||||||
|
if (/^0x0{40}$/iu.test(addr)) return null;
|
||||||
|
return addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a name to its payout address, via the registry and its resolver.
|
||||||
|
* @param domain - The domain to resolve.
|
||||||
|
* @returns The address, or null when the name has none.
|
||||||
|
*/
|
||||||
|
async function resolveDomain(domain: string): Promise<string | null> {
|
||||||
|
// Trailing dots are stripped here too, so "e.xp" and "e.xp." share one entry rather than paying
|
||||||
|
// for the same lookup twice under two keys.
|
||||||
|
const key = domain.trim().toLowerCase().replace(/\.+$/u, '');
|
||||||
|
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit && hit.expires > Date.now()) return hit.address;
|
||||||
|
|
||||||
|
let address: string | null = null;
|
||||||
|
const node = namehash(key);
|
||||||
|
|
||||||
|
// The registry answers for ANY node, minted or not, handing back the default resolver - so a
|
||||||
|
// non-empty resolver here says nothing about whether the name exists. addr() is the real test.
|
||||||
|
const resolver = wordToAddress(await ethCall(REGISTRY, SEL_RESOLVER + node.slice(2)));
|
||||||
|
if (resolver) {
|
||||||
|
address = wordToAddress(await ethCall(resolver, SEL_ADDR + node.slice(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheSet(key, address);
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onNameLookup: OnNameLookupHandler = async ({ chainId, domain }) => {
|
||||||
|
// Only the domain->address direction. Reverse lookup would need an address->name index, which
|
||||||
|
// neither the registry nor the minter keeps (the minter deliberately omits ERC721Enumerable).
|
||||||
|
if (!domain) return null;
|
||||||
|
|
||||||
|
// The manifest already restricts this, but a gate that matters is re-checked where it is used.
|
||||||
|
if (chainId !== CHAIN) return null;
|
||||||
|
|
||||||
|
// Before any chain read: this snap sees every domain, not just ones under a listed TLD.
|
||||||
|
if (!isResolvable(domain)) return null;
|
||||||
|
|
||||||
|
const resolvedAddress = await resolveDomain(domain);
|
||||||
|
if (!resolvedAddress) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolvedAddresses: [{ resolvedAddress, protocol: PROTOCOL, domainName: domain }],
|
||||||
|
};
|
||||||
|
};
|
||||||
85
src/namehash.ts
Normal file
85
src/namehash.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// @noble/hashes v2 requires the file extension in the specifier; the bare path is not exported.
|
||||||
|
import { keccak_256 } from '@noble/hashes/sha3.js';
|
||||||
|
import tr46 from 'tr46';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The node is an ERC-721 token id, so getting this wrong does not fail loudly - it computes a
|
||||||
|
* DIFFERENT, perfectly valid-looking node that simply has no records, and the name reports as
|
||||||
|
* unresolvable. Every rule below mirrors dsld_node() in the registry's lib/dsld.php, and the
|
||||||
|
* vectors in test/namehash.test.ts are generated from that PHP rather than reasoned about.
|
||||||
|
*
|
||||||
|
* TRANSITIONAL PROCESSING IS LOAD-BEARING. The registry normalizes with UTS-46 transitional:
|
||||||
|
* strasse and stra(eszett)e fold together, and a final sigma folds to a medial one. Nontransitional
|
||||||
|
* UTS-46 - tr46's default - keeps them apart and hashes those names to nodes the contract has never
|
||||||
|
* heard of. Confirmed against the registry, not assumed.
|
||||||
|
*
|
||||||
|
* UTS-46, NOT ENSIP-15. @adraffy/ens-normalize is the obvious library to reach for here and is the
|
||||||
|
* wrong one: it normalizes differently, notably preserving the emoji variation selectors UTS-46
|
||||||
|
* strips. These nodes were minted under UTS-46, so UTS-46 is what resolves them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowercase hex for a byte array, without a 0x prefix.
|
||||||
|
* @param bytes - The bytes to encode.
|
||||||
|
* @returns The hex encoding.
|
||||||
|
*/
|
||||||
|
function toHex(bytes: Uint8Array): string {
|
||||||
|
let out = '';
|
||||||
|
for (const b of bytes) out += b.toString(16).padStart(2, '0');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One label to its UTS-46 U-label. Falls back to the input when the label does not decode, so a
|
||||||
|
* malformed label hashes as itself rather than as '' - the same degradation PHP's `$u === false`
|
||||||
|
* branch has, and the reason a junk label yields "no records" instead of colliding with the root.
|
||||||
|
* @param label - One label of a domain name, punycode or unicode.
|
||||||
|
* @returns The label's UTS-46 U-label form.
|
||||||
|
*/
|
||||||
|
export function uLabel(label: string): string {
|
||||||
|
const l = label.trim().toLowerCase();
|
||||||
|
if (l === '') return l;
|
||||||
|
|
||||||
|
const result = tr46.toUnicode(l, {
|
||||||
|
transitionalProcessing: true,
|
||||||
|
useSTD3ASCIIRules: false,
|
||||||
|
checkHyphens: false,
|
||||||
|
checkBidi: false,
|
||||||
|
checkJoiners: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-fatal errors are tolerated deliberately: IDNA_DEFAULT is lenient, and refusing a name here
|
||||||
|
// would be stricter than the registry that minted it.
|
||||||
|
const domain = result?.domain;
|
||||||
|
return typeof domain === 'string' && domain !== '' ? domain : l;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ENS namehash over the U-label form -> '0x' + 64 hex. Accepts either what a user typed (unicode,
|
||||||
|
* possibly emoji-qualified) or the stored punycode; both normalize to the same U-label and so to
|
||||||
|
* the same node. That equivalence is what lets the send field accept a name as it is displayed.
|
||||||
|
* @param name - The domain name, as typed or as stored.
|
||||||
|
* @returns The ENS namehash, '0x' followed by 64 hex characters.
|
||||||
|
*/
|
||||||
|
export function namehash(name: string): string {
|
||||||
|
let node = new Uint8Array(32);
|
||||||
|
|
||||||
|
// rtrim(strtolower(trim($fqdn)), '.') - trailing dots only, matching PHP. A leading dot is left
|
||||||
|
// alone and becomes an empty label, which hashes to something with no records rather than to the
|
||||||
|
// parent - refusing to silently resolve ".e.xp" as "e.xp".
|
||||||
|
const fqdn = name.trim().toLowerCase().replace(/\.+$/u, '');
|
||||||
|
if (fqdn === '') return '0x' + toHex(node);
|
||||||
|
|
||||||
|
const labels = fqdn.split('.');
|
||||||
|
for (let i = labels.length - 1; i >= 0; i--) {
|
||||||
|
const labelHash = keccak_256(encoder.encode(uLabel(labels[i])));
|
||||||
|
const buf = new Uint8Array(64);
|
||||||
|
buf.set(node, 0);
|
||||||
|
buf.set(labelHash, 32);
|
||||||
|
node = keccak_256(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '0x' + toHex(node);
|
||||||
|
}
|
||||||
217
test/bundle.test.ts
Normal file
217
test/bundle.test.ts
Normal 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
104
test/namehash.test.ts
Normal 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'));
|
||||||
|
});
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "es2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["es2022"],
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user