aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 21:56:02 -0700
committerAdam Soltys <[email protected]>2026-08-01 21:56:02 -0700
commit5dbd42fa330da23924335d8287b01bd23b1fcff9 (patch)
treebb0bfaa9717d08d21886d5070a0d7257c54533b3
parent2d1cce8b6b0e7454f187e7144d22f76466065528 (diff)
downloadcoinosv3-5dbd42fa330da23924335d8287b01bd23b1fcff9.tar.gz
coinosv3-5dbd42fa330da23924335d8287b01bd23b1fcff9.zip
One wallet per Nostr identity, NIP-98 auth, npub payment addresses
Consistency fix with teeth: logging in with a pasted key derived one wallet while an extension login for the SAME identity found no backup and generated a different one. Now every signer kind runs one flow — look for the published association first (that's where the money is), then derive (key in hand) or generate (signer only) — and a derived seed is published too. That costs nothing, since the derivation is public and anyone holding the key could compute it anyway, and it makes the identity open the same wallet however you log in. Registrar auth is now NIP-98 (HTTP Auth, kind 27235) instead of a homemade envelope: the signature binds the URL, method and body hash, so a captured header can't be replayed elsewhere. NIP-78 already covered the encrypted association; NIP-26 delegation is deprecated, so owner→manager nomination lives in the registrar's own model: an npub-shaped name may only be claimed by that identity, which nominates the wallet key to keep the record updated afterwards (the wallet key is the only one always available — offline, in the worker). Payment addresses therefore default to the user's REAL npub in all three login methods, falling back to the wallet's own identity when no Nostr account is linked. Co-Authored-By: Claude Fable 5 <[email protected]>
-rw-r--r--build.js2
-rw-r--r--names/server.js82
-rw-r--r--src/features/index.js2
-rw-r--r--src/features/names.js84
-rw-r--r--src/features/nostrlogin.js27
-rw-r--r--src/nostr-login.js28
-rw-r--r--tools/nostr-login-test.js17
7 files changed, 165 insertions, 77 deletions
diff --git a/build.js b/build.js
index 6a596e8..02eb13d 100644
--- a/build.js
+++ b/build.js
@@ -181,7 +181,7 @@ export async function buildSwNwc({ minify = true } = {}) {
// feature's code (and its network endpoints) never enters the bundle.
// NB order matters and must match src/features/index.js — nwc sits after ark
// because it drives ark's headless pay/balance seam.
-const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', nostrlogin: 'nostrLoginFeature', sync: 'syncFeature' };
+const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', nostrlogin: 'nostrLoginFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', sync: 'syncFeature' };
export function enabledFeatures(spec = process.env.HAL_FEATURES) {
if (spec == null) return Object.keys(ALL_FEATURES);
diff --git a/names/server.js b/names/server.js
index a43191e..bd693b2 100644
--- a/names/server.js
+++ b/names/server.js
@@ -1,6 +1,8 @@
// BIP-353 name registrar for halwallet.app — gives every hal user a
// ₿[email protected] payment address.
//
+// Auth on every write is NIP-98 (HTTP Auth, kind 27235).
+//
// A name is a DNSSEC-signed TXT record at
// <name>.user._bitcoin-payment.halwallet.app
// whose content is a BIP-21 `bitcoin:` URI (for hal users: an ark address,
@@ -16,6 +18,8 @@
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { verifyEvent } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+import { createHash } from 'node:crypto';
import { HDKey } from '@scure/bip32';
import { mnemonicToSeedSync } from '@scure/bip39';
import { lnBackend } from '../bridge/ln.js';
@@ -31,8 +35,6 @@ const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG
const DOMAIN = CFG.domain || 'coinos.io'; // the default domain for claims
const DOMAINS = CFG.domains || { [DOMAIN]: { zoneId: CFG.zoneId } };
const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'names.json');
-const AUTH_KIND = 21353; // arbitrary custom kind for signed claims
-const MAX_SKEW_SEC = 600; // claim events must be fresh
const TTL = 300; // record TTL: BIP-353 wallets cache by this
const log = (...a) => console.log(new Date().toISOString(), ...a);
@@ -182,15 +184,35 @@ setInterval(async () => {
// claims
// ---------------------------------------------------------------------------
-// The claim is a signed nostr event: proof the caller controls the wallet's
-// nostr key, which becomes (or must match) the name's owner.
-function checkAuth(auth) {
- if (!auth || auth.kind !== AUTH_KIND) return 'bad auth kind';
- if (Math.abs(Date.now() / 1000 - (auth.created_at || 0)) > MAX_SKEW_SEC) return 'auth event too old';
- if (!verifyEvent(auth)) return 'bad signature';
- return null;
+// NIP-98 HTTP Auth (kind 27235). The signature covers this exact URL, method
+// and body hash, so a captured header is useless anywhere else.
+async function checkNip98(req, url, bodyText) {
+ const h = req.headers.get('authorization') || '';
+ const m = h.match(/^Nostr\s+(.+)$/i);
+ if (!m) return { error: 'missing Nostr authorization' };
+ let evt;
+ try { evt = JSON.parse(Buffer.from(m[1], 'base64').toString('utf8')); } catch { return { error: 'bad auth encoding' }; }
+ if (evt.kind !== 27235) return { error: 'auth must be kind 27235' };
+ if (Math.abs(Date.now() / 1000 - (evt.created_at || 0)) > 60) return { error: 'auth event expired' };
+ if (!verifyEvent(evt)) return { error: 'bad signature' };
+ const tag = (k) => (evt.tags.find((x) => x[0] === k) || [])[1];
+ if ((tag('method') || '').toUpperCase() !== req.method) return { error: 'method mismatch' };
+ let got;
+ try { got = new URL(tag('u') || ''); } catch { return { error: 'bad url tag' }; }
+ if (got.pathname !== url.pathname) return { error: 'url mismatch' };
+ if (bodyText) {
+ const want = createHash('sha256').update(bodyText).digest('hex');
+ if (tag('payload') !== want) return { error: 'payload hash mismatch' };
+ }
+ return { pubkey: evt.pubkey };
}
+// npub-prefix names are the free default everyone gets, so they must not be
+// squattable: a name that looks like one may only be claimed by that very
+// identity — or by a key that identity nominated as its manager (the wallet
+// key, which is always available, even offline and in the service worker).
+const npubPrefixOwner = (name) => (/^npub1[a-z0-9]+$/.test(name) ? name : null);
+
// coinos.io names that belong to existing coinos users are reserved for
// them until the migration gives those users a way to claim their own.
async function takenByCoinosUser(domain, name) {
@@ -271,24 +293,33 @@ Bun.serve({
if (url.pathname === '/register' && req.method === 'POST') {
if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const body = await req.json().catch(() => null);
- const auth = body?.auth;
- const authErr = checkAuth(auth);
- if (authErr) return json({ error: authErr }, 400);
+ const bodyText = await req.text();
+ const a = await checkNip98(req, url, bodyText);
+ if (a.error) return json({ error: a.error }, 401);
let claim;
- try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); }
+ try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
+ const auth = { pubkey: a.pubkey };
const name = String(claim.name || '').toLowerCase();
const domain = String(claim.domain || 'halwallet.app').toLowerCase();
if (!DOMAINS[domain]) return json({ error: 'unknown domain' }, 400);
if (!NAME_RE.test(name)) return json({ error: 'invalid name (a-z, 0-9, . _ -, max 30)' }, 400);
if (RESERVED.has(name)) return json({ error: 'name is reserved' }, 400);
- if (claim.action !== 'register') return json({ error: 'wrong action' }, 400);
if (!validUri(claim.uri)) return json({ error: 'uri must be a bitcoin: URI' }, 400);
const key = `${name}@${domain}`;
const existing = state.names[key];
- if (existing && existing.pubkey !== auth.pubkey) return json({ error: 'name is taken' }, 409);
+ // owner OR the manager key the owner nominated may update a record
+ if (existing && existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) {
+ return json({ error: 'name is taken' }, 409);
+ }
if (!existing && await takenByCoinosUser(domain, name)) return json({ error: 'name is taken' }, 409);
+ // an npub-shaped name belongs to that identity alone
+ if (npubPrefixOwner(name)) {
+ const managerOk = existing && existing.manager === auth.pubkey;
+ if (!npubEncode(auth.pubkey).startsWith(name) && !managerOk) {
+ return json({ error: 'that name belongs to another Nostr identity' }, 403);
+ }
+ }
if (/[?&]lno=/.test(claim.uri)) return json({ error: 'lno is added by the registrar' }, 400);
// Every name also gets a static Lightning offer on our node; payments
@@ -299,7 +330,11 @@ Bun.serve({
const recordId = await cfWrite(name, domain, published, existing?.recordId);
state.names[key] = {
- pubkey: auth.pubkey, uri: published, recordId, updated: Date.now(), domain,
+ pubkey: existing?.pubkey || auth.pubkey,
+ // the owner may nominate a key that manages the record from here on
+ manager: (claim.manager && /^[0-9a-f]{64}$/.test(claim.manager) && auth.pubkey !== claim.manager)
+ ? claim.manager : existing?.manager,
+ uri: published, recordId, updated: Date.now(), domain,
offerId: offer?.offerId, bolt12: offer?.bolt12,
};
persist();
@@ -309,19 +344,18 @@ Bun.serve({
if (url.pathname === '/register' && req.method === 'DELETE') {
if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const body = await req.json().catch(() => null);
- const auth = body?.auth;
- const authErr = checkAuth(auth);
- if (authErr) return json({ error: authErr }, 400);
+ const bodyText = await req.text();
+ const a = await checkNip98(req, url, bodyText);
+ if (a.error) return json({ error: a.error }, 401);
let claim;
- try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); }
+ try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
+ const auth = { pubkey: a.pubkey };
const name = String(claim.name || '').toLowerCase();
const domain = String(claim.domain || 'halwallet.app').toLowerCase();
- if (claim.action !== 'delete') return json({ error: 'wrong action' }, 400);
const key = `${name}@${domain}`;
const existing = state.names[key];
if (!existing) return json({ error: 'unknown name' }, 404);
- if (existing.pubkey !== auth.pubkey) return json({ error: 'not your name' }, 403);
+ if (existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) return json({ error: 'not your name' }, 403);
await cfDelete(domain, existing.recordId).catch(() => {});
if (existing.offerId && ln) await ln.call('disableoffer', { offer_id: existing.offerId }).catch(() => {});
delete state.names[key];
diff --git a/src/features/index.js b/src/features/index.js
index 8f3e5d1..70f8421 100644
--- a/src/features/index.js
+++ b/src/features/index.js
@@ -20,5 +20,5 @@ import { nwcFeature } from './nwc.js';
// names sits between ark and zaps: a pasted user@domain resolves over DNS
// (BIP-353) before the zaps feature treats it as a plain Lightning address.
export function buildFeatures(ctx) {
- return [giftsFeature(ctx), arkFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), nostrLoginFeature(ctx), syncFeature(ctx)];
+ return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)];
}
diff --git a/src/features/names.js b/src/features/names.js
index 970a690..33bddd7 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -12,6 +12,7 @@
// record falls back to the Lightning-address flow (zaps feature).
import { resolveBip353, parsePaymentName, parseBip21 } from '../bip353.js';
+import { nip98Header } from '../nostr-login.js';
import { qrSvg } from '../qr.js';
import { isArkAddress } from './ark.js';
import { getNetwork } from '../api.js';
@@ -24,6 +25,25 @@ const AUTH_KIND = 21353;
export function namesFeature(ctx) {
const { h, ui, render, wallet, hook, toast, copyBtn } = ctx;
+ // The wallet's own key signs registrar requests: it's the one key that is
+ // always available — offline, in the background, with no signer attached.
+ const walletSigner = () => ({
+ pubkey: wallet.nostrPubkey && wallet.nostrPubkey(),
+ signEvent: async (e) => wallet.nostrSign(e),
+ });
+
+ async function post(path, method, payload, signer) {
+ const url = `${REGISTRAR}${path}`;
+ const body = JSON.stringify(payload);
+ const auth = await nip98Header(signer || walletSigner(), url, method, body);
+ const r = await fetch(url, {
+ method, headers: { 'content-type': 'application/json', authorization: auth }, body,
+ });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
+ return j;
+ }
+
const load = () => wallet.loadFeatureState('names', {});
const save = (st) => wallet.saveFeatureState('names', st);
@@ -35,31 +55,19 @@ export function namesFeature(ctx) {
return addr ? `bitcoin:?ark=${encodeURIComponent(addr)}` : null;
}
- async function claim(name) {
+ async function claim(name, { signer, manager } = {}) {
const uri = await currentUri();
if (!uri) throw new Error(t('namesNeedArk'));
- const auth = wallet.nostrSign({
- kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [],
- content: JSON.stringify({ action: 'register', name, uri, domain: DOMAIN }),
- });
- if (!auth) throw new Error('wallet cannot sign');
- const r = await fetch(`${REGISTRAR}/register`, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ auth }),
- });
- const j = await r.json();
- if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
+ const j = await post('/register', 'POST', {
+ name, uri, domain: DOMAIN,
+ // when the login identity claims its own npub name, it nominates the
+ // wallet key to keep the record updated afterwards
+ ...(manager ? { manager } : {}),
+ }, signer);
const prev = load().name;
save({ name, domain: DOMAIN, uri, updated: Date.now() });
if (prev && prev !== name) {
- const bye = wallet.nostrSign({
- kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [],
- content: JSON.stringify({ action: 'delete', name: prev, domain: DOMAIN }),
- });
- fetch(`${REGISTRAR}/register`, {
- method: 'DELETE', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ auth: bye }),
- }).catch(() => {});
+ post('/register', 'DELETE', { name: prev, domain: DOMAIN }).catch(() => {});
}
return j;
}
@@ -67,30 +75,10 @@ export function namesFeature(ctx) {
async function release() {
const st = load();
if (!st.name) return;
- const auth = wallet.nostrSign({
- kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [],
- content: JSON.stringify({ action: 'delete', name: st.name, domain: st.domain || DOMAIN }),
- });
- await fetch(`${REGISTRAR}/register`, {
- method: 'DELETE', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ auth }),
- }).catch(() => {});
+ await post('/register', 'DELETE', { name: st.name, domain: st.domain || DOMAIN }).catch(() => {});
save({});
}
- // An imported seed doesn't know its username — the registrar does. Ask by
- // the wallet's nostr pubkey and adopt what it says before deciding whether
- // onboarding needs to prompt.
- let checked = false; // lookup completed (gate stays closed until then)
- async function lookupMine() {
- try {
- const pk = wallet.nostrPubkey && wallet.nostrPubkey();
- if (!pk) return;
- const r = await fetch(`${REGISTRAR}/pubkey/${pk}`).then((x) => x.json());
- if (r && r.name) save({ ...load(), name: r.name, domain: r.domain || DOMAIN, uri: r.uri });
- } catch {}
- }
-
// Everyone gets an address without being asked: the default username is a
// prefix of the wallet's npub — unique, boring, and free. Custom names are
// an optional change (and may cost something one day, to keep squatters
@@ -101,8 +89,18 @@ export function namesFeature(ctx) {
let st = load();
if (!st.name) { await lookupMine(); st = load(); }
if (!st.name) {
- const npub = wallet.nostrNpub && wallet.nostrNpub();
- if (npub) { await claim(npub.slice(0, 20)); st = load(); }
+ // The address people see should be the identity they know the user
+ // by: their real npub when a nostr account is linked, else the
+ // wallet's own. Both are claimed the same way; the registrar checks
+ // that an npub-shaped name really belongs to its claimant.
+ const linked = hook('nostrLoginIdentity');
+ const npub = (linked && linked.npub) || (wallet.nostrNpub && wallet.nostrNpub());
+ if (npub) {
+ await claim(npub.slice(0, 20), linked
+ ? { signer: linked.signer, manager: wallet.nostrPubkey() }
+ : {});
+ st = load();
+ }
}
checked = true;
render();
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index 60e3759..c5369e9 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -22,6 +22,11 @@ export function nostrLoginFeature(ctx) {
const load = () => wallet.loadFeatureState('nostrlogin', {});
const save = (st) => wallet.saveFeatureState('nostrlogin', st);
+ // The signer that opened this session, kept so the names feature can claim
+ // the user's real npub as their payment address (and nominate the wallet
+ // key as manager, so later updates work without the signer).
+ let live = null;
+
const busy = (v) => { ui.nostrLoginBusy = v; render(); };
const fail = (e) => { ui.nostrLoginError = e.message || String(e); busy(false); };
@@ -40,11 +45,16 @@ export function nostrLoginFeature(ctx) {
busy(false);
return;
}
- await ctx.hook('openMnemonic', res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey });
+ // Same wallet however you log in: a key-derived seed is published too,
+ // so an extension login later finds this wallet instead of making a new
+ // one. Costs nothing — anyone with the key could derive it anyway.
+ if (res.publish) await publishWalletBackup(signer, { mnemonic: res.mnemonic }).catch(() => {});
+ live = signer;
+ await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey });
+ save({ ...load(), pubkey: signer.pubkey, linked: Date.now() });
toast(res.mode === 'derived' ? t('nlOpenedDerived') : t('nlOpenedRestored'));
busy(false);
} catch (e) { fail(e); }
- finally { if (signer && signer.close && !ui.nostrLoginNew) signer.close(); }
}
async function createForSigner() {
@@ -55,7 +65,9 @@ export function nostrLoginFeature(ctx) {
const mnemonic = newMnemonic();
await publishWalletBackup(st.signer, { mnemonic });
ui.nostrLoginNew = null;
- await ctx.hook('openMnemonic', mnemonic, '', { nostrPubkey: st.signer.pubkey });
+ live = st.signer;
+ await ctx.openMnemonic(mnemonic, '', { nostrPubkey: st.signer.pubkey });
+ save({ ...load(), pubkey: st.signer.pubkey, linked: Date.now() });
toast(t('nlCreated'));
busy(false);
} catch (e) { fail(e); }
@@ -75,11 +87,11 @@ export function nostrLoginFeature(ctx) {
throw new Error(t('nlAlreadyLinked'));
}
await publishWalletBackup(signer, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' });
+ live = signer;
save({ ...load(), pubkey: signer.pubkey, linked: Date.now() });
toast(t('nlLinked'));
busy(false);
} catch (e) { fail(e); }
- finally { if (signer && signer.close) signer.close(); }
}
// ---- UI ---------------------------------------------------------------
@@ -157,6 +169,13 @@ export function nostrLoginFeature(ctx) {
return {
id: 'nostrlogin',
+ // The nostr identity this session is logged in as, for features that
+ // should speak as the user (the payment address defaults to this npub).
+ nostrLoginIdentity() {
+ const st = load();
+ if (!st.pubkey) return null;
+ return { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live };
+ },
unlockExtra() { return unlockExtra(); },
settingsCards() { return [settingsCard()]; },
};
diff --git a/src/nostr-login.js b/src/nostr-login.js
index 6f23e2a..00106c4 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -150,12 +150,32 @@ export async function publishWalletBackup(signer, { mnemonic, passphrase }, rela
// The whole login: given a signer, produce the wallet mnemonic to open.
// `mode` reports what happened so the UI can be honest about it.
+//
+// ONE flow for all three signer kinds, because an identity must open the
+// SAME wallet however you log into it: look for the published association
+// FIRST (that's where the money already is), and only then fall back to
+// deriving (key in hand) or generating (signer only).
export async function walletForSigner(signer) {
- // A pasted key derives its wallet — no lookup, no storage, no exposure.
- if (signer.secret) {
- return { mnemonic: seedFromNostrKey(signer.secret), mode: 'derived' };
- }
const found = await fetchWalletBackup(signer);
if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, mode: 'restored' };
+ if (signer.secret) {
+ // Publishing the derived seed costs nothing: the derivation is public,
+ // so anyone holding this key could compute it anyway. It buys the thing
+ // that matters — an extension login later finds this same wallet.
+ return { mnemonic: seedFromNostrKey(signer.secret), mode: 'derived', publish: true };
+ }
return { mnemonic: null, mode: 'new' }; // caller decides + confirms publishing
}
+
+// NIP-98 HTTP Auth (kind 27235): the standard way to authenticate an HTTP
+// request with a nostr key. `u` and `method` bind the signature to this exact
+// request and `payload` to its body, so a captured header can't be replayed
+// against a different endpoint or a tampered body.
+export async function nip98Header(signer, url, method, bodyText) {
+ const tags = [['u', url], ['method', method.toUpperCase()]];
+ if (bodyText) tags.push(['payload', hex.encode(sha256(new TextEncoder().encode(bodyText)))]);
+ const evt = await signer.signEvent({
+ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: '',
+ });
+ return 'Nostr ' + btoa(JSON.stringify(evt));
+}
diff --git a/tools/nostr-login-test.js b/tools/nostr-login-test.js
index 039ff84..42580fe 100644
--- a/tools/nostr-login-test.js
+++ b/tools/nostr-login-test.js
@@ -56,5 +56,22 @@ let denied = false;
try { await stranger.decryptSelf(ct); } catch { denied = true; }
check('another account cannot decrypt it', denied);
+console.log('\n[NIP-98 auth]');
+{
+ const { nip98Header } = await import('../src/nostr-login.js');
+ const { verifyEvent } = await import('nostr-tools/pure');
+ const { sha256: sha } = await import('@noble/hashes/sha256');
+ const body = JSON.stringify({ name: 'x' });
+ const hdr = await nip98Header(keySigner(sk), 'https://names.coinos.io/register', 'POST', body);
+ check('header uses the Nostr scheme', hdr.startsWith('Nostr '));
+ const evt = JSON.parse(Buffer.from(hdr.slice(6), 'base64').toString('utf8'));
+ check('is kind 27235', evt.kind === 27235);
+ check('signature verifies', verifyEvent(evt));
+ const tag = (k) => (evt.tags.find((x) => x[0] === k) || [])[1];
+ check('binds the url', tag('u') === 'https://names.coinos.io/register');
+ check('binds the method', tag('method') === 'POST');
+ check('binds the body hash', tag('payload') === hex.encode(sha(new TextEncoder().encode(body))));
+}
+
console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ nostr login behaves');
process.exit(fails ? 1 : 0);