aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/names.js
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 /src/features/names.js
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]>
Diffstat (limited to 'src/features/names.js')
-rw-r--r--src/features/names.js84
1 files changed, 41 insertions, 43 deletions
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();