// Log in with Nostr.
//
// Three ways to prove control of a nostr identity, in descending order of how
// much the browser gets to see:
//
// extension NIP-07 (Alby, nos2x, …) — the key stays in the extension
// bunker NIP-46 remote signer (Amber, Amethyst, nsec.app) — the key
// stays on the signing device
// key a pasted nsec / hex private key — held in memory for this
// session only, NEVER written to storage
//
// HOW A NOSTR ACCOUNT GETS A WALLET
// Two mechanisms, because the two situations are genuinely different:
//
// 1. We hold the private key (pasted nsec). The wallet seed is DERIVED from
// it: entropy = sha256("coinos:wallet-seed:v1" | privkey). Deterministic,
// so the same nostr key always opens the same wallet, on any device,
// with nothing stored anywhere by anyone.
//
// 2. We only have a signer (extension/bunker). Deriving is impossible — we
// never see the key, and neither signatures (random aux) nor NIP-44
// ciphertexts (random nonce) are reproducible. So instead the wallet seed
// is ASSOCIATED: a random seed is generated once and published as a
// replaceable event encrypted to the user's own nostr key, which only
// their signer can decrypt. Logging in later anywhere finds and decrypts
// it, opening the same wallet.
//
// THE TRADEOFF, PLAINLY: mechanism 2 means whoever controls the nostr key can
// recover the wallet seed from public relays. That is a real escalation —
// a nostr key is usually treated as an identity, not as money. It is the
// price of "log in on a new device and your wallet is there" without the key
// itself ever reaching us. Mechanism 1 has no such exposure. The UI says so
// before publishing anything.
import { hex, bech32 } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
import { entropyToMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { getPublicKey, finalizeEvent, nip44 } from './nostr.js';
import { SimplePool } from 'nostr-tools/pool';
import { generateSecretKey } from 'nostr-tools/pure';
import { decode as nip19decode } from 'nostr-tools/nip19';
import { assetUrl } from './asset-url.js';
// NIP-46 loads lazily from dist/nip46.js (same pattern as the QR decoder):
// bunker logins are rare, and the module is heavy. Tests running outside a
// browser preset globalThis.__nip46 with the real module instead.
let _nip46 = null;
function loadNip46() {
if (_nip46) return _nip46;
if (typeof globalThis !== 'undefined' && globalThis.__nip46) return (_nip46 = Promise.resolve(globalThis.__nip46));
_nip46 = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = assetUrl('nip46.js');
s.onload = () => (window.__nip46 ? resolve(window.__nip46) : reject(new Error('signer module unavailable')));
s.onerror = () => { _nip46 = null; reject(new Error('could not load the remote-signer module')); };
document.head.appendChild(s);
});
return _nip46;
}
const SEED_DTAG = 'coinos:wallet:v1';
const BACKUP_KIND = 30078;
const DERIVE_TAG = 'coinos:wallet-seed:v1';
export const BACKUP_RELAYS = [
'wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net',
];
// nsec1… or 64-hex → 32-byte secret key, else null.
export function parseNostrSecret(input) {
const s = String(input || '').trim();
if (/^[0-9a-f]{64}$/i.test(s)) return hex.decode(s.toLowerCase());
try {
const { prefix, words } = bech32.decode(s, 1000);
if (prefix !== 'nsec') return null;
const b = bech32.fromWords(words);
return b.length === 32 ? Uint8Array.from(b) : null;
} catch { return null; }
}
// Mechanism 1: the same nostr key always yields the same wallet.
export function seedFromNostrKey(sk) {
const tag = new TextEncoder().encode(DERIVE_TAG);
const buf = new Uint8Array(tag.length + sk.length);
buf.set(tag, 0);
buf.set(sk, tag.length);
return entropyToMnemonic(sha256(buf).slice(0, 16), wordlist); // 12 words
}
// ---- signers -------------------------------------------------------------
// Every signer exposes: pubkey, signEvent, encryptSelf, decryptSelf, label.
export async function extensionSigner() {
const nostr = typeof window !== 'undefined' && window.nostr;
if (!nostr) throw new Error('no nostr extension found in this browser');
const pubkey = await nostr.getPublicKey();
const canNip44 = !!(nostr.nip44 && nostr.nip44.encrypt && nostr.nip44.decrypt);
if (!canNip44) throw new Error('this extension cannot encrypt (NIP-44 support required)');
return {
kind: 'extension', pubkey, label: 'browser extension',
signEvent: (e) => nostr.signEvent(e),
encryptSelf: (txt) => nostr.nip44.encrypt(pubkey, txt),
decryptSelf: (ct) => nostr.nip44.decrypt(pubkey, ct),
// peer variants: NIP-17 DMs seal to the recipient, not to self
encryptTo: (peer, txt) => nostr.nip44.encrypt(peer, txt),
decryptFrom: (peer, ct) => nostr.nip44.decrypt(peer, ct),
};
}
// The relay pool a remote signer talks through. nostr-tools' own default
// never reconnects: a phone freezing the tab for a minute cut the socket,
// every request after that hung to its deadline, and the app declared the
// signer gone — "my signer keeps disconnecting", reconnect tapped over and
// over. Pinged and reconnecting, the socket comes back on its own and the
// subscription is re-fired, so the next request simply gets its answer.
const bunkerPool = () => new SimplePool({ enablePing: true, enableReconnect: true });
export async function bunkerSigner(uri, { onAuth } = {}) {
const { BunkerSigner, parseBunkerInput } = await loadNip46();
const bp = await parseBunkerInput(String(uri || '').trim());
if (!bp) throw new Error('not a bunker:// address');
if (!bp.relays || !bp.relays.length) throw new Error('that bunker address lists no relays');
const local = generateSecretKey();
// NB the pointer goes through fromBunker — the constructor takes params
// only, and passing the pointer there leaves the signer without one.
const signer = BunkerSigner.fromBunker(local, bp, {
pool: bunkerPool(),
onauth: (url) => { if (onAuth) onAuth(url); else if (typeof window !== 'undefined') window.open(url, '_blank'); },
});
await signer.connect();
const pubkey = await signer.getPublicKey();
return bunkerAdapter(signer, pubkey, local);
}
// Reconnect a remote signer a page reload dropped. The remote end granted
// permission to this client key, not to this page load, so rebuilding from
// the same client key and pointer picks the session back up — usually without
// prompting the user again.
export async function resumeBunker(session, { onAuth, timeoutMs = 15000 } = {}) {
if (!session || !session.local || !session.bp || !session.bp.pubkey) return null;
const { BunkerSigner } = await loadNip46();
const local = hex.decode(session.local);
const signer = BunkerSigner.fromBunker(local, session.bp, {
pool: bunkerPool(),
onauth: (url) => { if (onAuth) onAuth(url); },
});
const give = new Promise((_, rej) => setTimeout(() => rej(new Error('signer did not answer')), timeoutMs));
try {
await Promise.race([signer.connect(), give]);
const pubkey = await Promise.race([signer.getPublicKey(), give]);
return bunkerAdapter(signer, pubkey, local);
} catch (e) {
try { signer.close(); } catch {}
throw e;
}
}
function bunkerAdapter(signer, pubkey, local) {
// A dead transport (relay socket gone, signer app killed in the background)
// makes a NIP-46 call hang forever, not fail — so every operation carries
// its own deadline. The distinct message lets callers tell "the signer
// never answered" (connection is dead, reconnect) from "the signer said
// no" (an answered error — the connection is fine).
const answered = (p, ms = 20_000) => Promise.race([p,
new Promise((_, rej) => setTimeout(() => rej(new Error('signer did not answer')), ms))]);
return {
kind: 'bunker', pubkey, label: 'remote signer',
// Everything needed to rebuild this connection later. `local` is the
// client key the remote signer authorised — not the user's identity key,
// which never leaves the signer.
session: local ? { local: hex.encode(local), bp: signer.bp } : null,
signEvent: (e) => answered(signer.signEvent(e)),
encryptSelf: (txt) => answered(signer.nip44Encrypt(pubkey, txt)),
decryptSelf: (ct) => answered(signer.nip44Decrypt(pubkey, ct)),
encryptTo: (peer, txt) => answered(signer.nip44Encrypt(peer, txt)),
decryptFrom: (peer, ct) => answered(signer.nip44Decrypt(peer, ct)),
// Is anyone there? A quick round trip after the app comes back from the
// background, so a dead session is replaced before the user's next
// action runs into a twenty-second silence.
ping: () => answered(signer.getPublicKey(), 7_000),
close: () => { try { signer.close(); } catch {} },
};
}
// Client-initiated NIP-46: WE mint a nostrconnect:// URI; a signer app
// (Amber, nsec.app, …) opens or scans it and connects back to us over the
// relay — no bunker URL to copy. Returns the URI (render it as a deep link
// and a QR), a promise that resolves to a signer adapter when the app
// answers, and a cancel.
//
// SURVIVAL: launching the signer app on a phone FREEZES this tab — its relay
// sockets die exactly when the approval is being published, and a limit:0
// subscription never replays what it slept through. Worse, a signer's
// callbackUrl can NAVIGATE the tab home, reloading the page and discarding
// the client key mid-handshake. So the pending handshake is persisted
// (sessionStorage) and the wait runs two paths: the library's live
// subscription, raced against a stored-event poller that re-queries the
// relays (kicked on every return to visibility) — whichever sees the
// approval first wins. `nostrConnectPending()` hands a fresh boot the
// persisted handshake so a reloaded page resumes instead of shrugging.
const NC_PENDING_KEY = 'nc-pending';
export function nostrConnectPending(timeoutMs = 180_000) {
try {
const p = JSON.parse(sessionStorage.getItem(NC_PENDING_KEY) || 'null');
if (p && p.local && p.secret && p.uri && Date.now() - p.ts < timeoutMs) return p;
if (p) sessionStorage.removeItem(NC_PENDING_KEY);
} catch {}
return null;
}
export async function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol'], timeoutMs = 180_000, resume = null } = {}) {
const { BunkerSigner, createNostrConnectURI } = await loadNip46();
const local = resume ? hex.decode(resume.local) : generateSecretKey();
const secret = resume ? resume.secret : hex.encode(crypto.getRandomValues(new Uint8Array(16)));
const startedAt = resume ? resume.ts : Date.now();
if (resume && resume.relays) relays = resume.relays;
const uri = resume ? resume.uri : createNostrConnectURI({
clientPubkey: getPublicKey(local),
relays,
secret,
name: 'coinos',
url: 'https://v3.coinos.io',
perms: ['get_public_key', 'sign_event', 'nip44_encrypt', 'nip44_decrypt'],
}) + '&callbackUrl=' + encodeURIComponent('https://v3.coinos.io/');
try {
sessionStorage.setItem(NC_PENDING_KEY, JSON.stringify({
local: hex.encode(local), secret, relays, uri, ts: startedAt,
}));
} catch {}
const clientPub = getPublicKey(local);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('signer connect timed out')),
Math.max(5_000, timeoutMs - (Date.now() - startedAt)));
// Path 1: the library's live wait — instant while the tab stays awake.
const live = (async () => BunkerSigner.fromURI(local, uri, { pool: bunkerPool() }, controller.signal))();
live.catch(() => {});
// Path 2: the replay poller — finds an approval published while we slept.
let visKick = null;
const onVis = () => { if (document.visibilityState === 'visible' && visKick) visKick(); };
document.addEventListener('visibilitychange', onVis);
const replay = (async () => {
const seen = new Set();
while (!controller.signal.aborted) {
await new Promise((r) => {
visKick = r;
const t2 = setTimeout(r, 5000);
controller.signal.addEventListener('abort', () => { clearTimeout(t2); r(); }, { once: true });
});
visKick = null;
if (controller.signal.aborted) break;
const pool = new SimplePool();
try {
const evs = await Promise.any(relays.map(async (url) => {
await pool.ensureRelay(url, { connectionTimeout: 4000 });
const got = await pool.querySync([url], {
kinds: [24133], '#p': [clientPub], since: Math.floor(startedAt / 1000) - 120,
}, { maxWait: 4500 });
if (!got || !got.length) throw new Error('nothing yet');
return got;
})).catch(() => []);
for (const ev of evs || []) {
if (seen.has(ev.id)) continue;
seen.add(ev.id);
try {
const key = nip44.getConversationKey(local, ev.pubkey);
const resp = JSON.parse(nip44.decrypt(ev.content, key));
// some signers echo the secret, others answer a bare "ack"
if (resp.result === secret || resp.result === 'ack') {
return BunkerSigner.fromBunker(local, { pubkey: ev.pubkey, relays, secret }, { pool: bunkerPool() });
}
} catch {}
}
} catch {} finally { try { pool.close(relays); } catch {} }
}
throw controller.signal.reason || new Error('cancelled');
})();
replay.catch(() => {});
const ready = (async () => {
try {
const signer = await Promise.any([live, replay]);
const pubkey = await signer.getPublicKey();
try { sessionStorage.removeItem(NC_PENDING_KEY); } catch {}
return bunkerAdapter(signer, pubkey, local);
} catch (e) {
// an aborted wait names its real cause (timeout, user cancel); a raw
// AggregateError from the race names nothing useful
throw (controller.signal.aborted && controller.signal.reason)
|| (e && e.errors && e.errors[0]) || e;
} finally {
clearTimeout(timer);
document.removeEventListener('visibilitychange', onVis);
controller.abort(new Error('settled'));
}
})();
ready.catch(() => {});
return {
uri, ready,
cancel: (reason) => {
try { sessionStorage.removeItem(NC_PENDING_KEY); } catch {}
controller.abort(reason || new Error('cancelled'));
},
};
}
// A pasted key: signing happens locally, and the key is never persisted.
// ---- NIP-55: an Android signer app (Amber), by intent --------------------
// Amber without a bunker (offline, no relays) can still sign for a web
// page: the page opens a `nostrsigner:` URL and the app answers. A web page
// cannot receive an intent result, so the answer comes back one of two
// ways — appended to a callback URL, which NAVIGATES the tab (our TWA would
// reload, losing the promise that asked), or copied to the clipboard when
// no callback is given. We take the clipboard: the tab stays alive, every
// caller's await survives, and when the user comes back here the answer is
// read off the clipboard. If the browser won't hand the clipboard over,
// the login card offers a box to paste it into (amberDeliver). Each request
// is a round trip through the app — NIP-55 is honest that this means a
// popup per signature, which is why it is a login door, not the default.
export const amberAvailable = () => typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent || '');
let amberReq = null; // { type, ts, resolve, reject, timer }
let amberWatch = null;
const amberListeners = new Set();
export const onAmberChange = (fn) => { amberListeners.add(fn); return () => amberListeners.delete(fn); };
const amberChanged = () => { for (const fn of amberListeners) { try { fn(); } catch {} } };
export const amberPending = () => (amberReq ? { type: amberReq.type, since: amberReq.ts, needsTap: !!amberReq.needsTap } : null);
const HEX64 = /^[0-9a-f]{64}$/i;
// What an answer of this type looks like — anything else on the clipboard
// is somebody's grocery list, not a signature.
function amberParse(type, text, payload) {
const v = String(text || '').trim();
if (!v || v === payload) return null;
if (type === 'get_public_key') {
if (HEX64.test(v)) return v.toLowerCase();
if (/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(v)) { try { const d = nip19decode(v); if (d.type === 'npub') return d.data; } catch {} }
try { const j = JSON.parse(v); if (j && HEX64.test(j.pubkey || '')) return j.pubkey.toLowerCase(); } catch {}
return null;
}
if (type === 'sign_event') {
try { const ev = JSON.parse(v); return ev && ev.sig && ev.id && HEX64.test(ev.pubkey || '') ? ev : null; } catch { return null; }
}
// the clipboard still holds the LAST answer (an npub, say) when the next
// request goes out: an answer must look like its own kind, and never
// repeat the one before
if (v === amberLastAnswer) return null;
if (type === 'nip44_encrypt') return /^Ag[A-Za-z0-9+/]{100,}={0,2}$/.test(v) ? v : null; // version byte 2, nonce, body, mac
if (type === 'nip04_encrypt') return /^[A-Za-z0-9+/=]+\?iv=[A-Za-z0-9+/=]+$/.test(v) ? v : null;
if (/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(v) || HEX64.test(v)) return null; // a key is never a plaintext
return v; // a decrypt: the caller knows what it expects and will say if this isn't it
}
let amberLastAnswer = null;
// Resolve the waiting request with an answer, however it arrived.
export function amberDeliver(text) {
if (!amberReq) return false;
const got = amberParse(amberReq.type, text, amberReq.payload);
if (got == null) return false;
amberLastAnswer = String(text || '').trim();
const r = amberReq;
amberStop();
r.resolve(got);
return true;
}
export function amberCancel(reason = 'signer request cancelled') {
if (!amberReq) return;
const r = amberReq;
amberStop();
r.reject(new Error(reason));
}
function amberStop() {
if (amberReq) clearTimeout(amberReq.timer);
amberReq = null;
if (amberWatch) { amberWatch(); amberWatch = null; }
amberChanged();
}
// Read the clipboard whenever the tab comes back to the front, and every
// so often while it is — Amber copies its answer just before the user
// switches back here.
function amberWatchClipboard() {
const read = async () => {
if (!amberReq || typeof navigator === 'undefined' || !navigator.clipboard) return;
try { // a hidden or unfocused page is simply refused by the browser
const txt = await navigator.clipboard.readText();
if (amberReq && amberDeliver(txt)) return;
} catch {} // no permission: the paste box is the way in
};
const onVis = () => {
if (document.visibilityState === 'hidden') { if (amberReq) amberReq.hidden = true; return; } // the signer came up
setTimeout(read, 250);
};
document.addEventListener('visibilitychange', onVis);
window.addEventListener('focus', onVis);
const iv = setInterval(read, 800);
setTimeout(read, 300);
return () => { document.removeEventListener('visibilitychange', onVis); window.removeEventListener('focus', onVis); clearInterval(iv); };
}
function amberLaunch(url) {
if (typeof window !== 'undefined' && typeof window.__coinosAmberLaunch === 'function') { window.__coinosAmberLaunch(url); return; } // tests
window.location.href = url;
}
// Chrome lets a page open another app only on the heels of a tap: without
// one the `nostrsigner:` navigation is dropped with a console warning the
// page never sees ("Navigation is blocked"). The first request rides the
// Sign-in tap; the ones the login fires once an answer is back (decrypt the
// backup, sign the relay list…) have no tap behind them, so they wait for
// one — the card shows an Open button — instead of spinning forever. A
// launch that leaves the page in front is taken as blocked too.
export function amberOpen() {
const r = amberReq;
if (!r) return false;
r.needsTap = false;
r.hidden = false;
clearTimeout(r.timer);
r.timer = setTimeout(() => amberCancel('signer did not answer'), r.timeoutMs);
amberChanged();
amberLaunch(r.url);
if (typeof window !== 'undefined' && typeof window.__coinosAmberLaunch === 'function') return true; // tests: no app to come up
setTimeout(() => {
if (amberReq !== r || r.hidden || typeof document === 'undefined' || document.visibilityState !== 'visible') return;
r.needsTap = true;
amberChanged();
}, 2500);
return true;
}
const amberTapAvailable = () => {
if (typeof window !== 'undefined' && typeof window.__coinosAmberLaunch === 'function') return true; // tests
const ua = typeof navigator !== 'undefined' ? navigator.userActivation : null;
return ua ? ua.isActive : true;
};
// One trip at a time: the app fires several signatures at once right after a
// sign-in (the backup, the relay list, the follows), and Amber can only be
// asked for one; the rest wait their turn in this chain.
let amberQueue = Promise.resolve();
function amberRequest(type, payload, params = {}, { timeoutMs = 180_000 } = {}) {
const q = new URLSearchParams({ type, compressionType: 'none', returnType: 'event' });
if (params.pubkey) q.set('pubkey', params.pubkey);
const url = 'nostrsigner:' + encodeURIComponent(payload || '') + '?' + q.toString();
const run = () => new Promise((resolve, reject) => {
amberReq = { type, payload: payload || '', url, ts: Date.now(), resolve, reject, timeoutMs,
timer: setTimeout(() => amberCancel('signer did not answer'), timeoutMs) };
amberWatch = amberWatchClipboard();
if (amberTapAvailable()) amberOpen();
else { amberReq.needsTap = true; amberChanged(); }
});
const p = amberQueue.then(run, run);
amberQueue = p.catch(() => {});
return p;
}
export async function amberSigner({ pubkey = null } = {}) {
if (!pubkey) pubkey = await amberRequest('get_public_key', '');
return amberAdapter(pubkey);
}
function amberAdapter(pubkey) {
return {
kind: 'amber', pubkey, label: 'Amber', interactive: true, // every call is a trip through the app: nothing in the background may use it
session: { amber: pubkey }, // the pubkey is all a later boot needs; no round trip to come back
signEvent: async (e) => {
const ev = await amberRequest('sign_event', JSON.stringify({ ...e, pubkey }), { pubkey });
if (ev.pubkey !== pubkey) throw new Error('the signer answered with a different account');
return ev;
},
encryptSelf: (txt) => amberRequest('nip44_encrypt', txt, { pubkey }),
decryptSelf: (ct) => amberRequest('nip44_decrypt', ct, { pubkey }),
encryptTo: (peer, txt) => amberRequest('nip44_encrypt', txt, { pubkey: peer }),
decryptFrom: (peer, ct) => amberRequest('nip44_decrypt', ct, { pubkey: peer }),
ping: async () => true,
};
}
export function keySigner(sk) {
const pubkey = getPublicKey(sk);
const conv = nip44.getConversationKey(sk, pubkey);
return {
kind: 'key', pubkey, label: 'private key', secret: sk,
signEvent: async (e) => finalizeEvent(e, sk),
encryptSelf: async (txt) => nip44.encrypt(txt, conv),
decryptSelf: async (ct) => nip44.decrypt(ct, conv),
encryptTo: async (peer, txt) => nip44.encrypt(txt, nip44.getConversationKey(sk, peer)),
decryptFrom: async (peer, ct) => nip44.decrypt(ct, nip44.getConversationKey(sk, peer)),
};
}
// ---- the encrypted wallet association (mechanism 2) ----------------------
// Look for a wallet this nostr account already published. Returns the
// mnemonic, or null when the account has no wallet yet.
//
// "No wallet" is a claim, not a default: acting on it mints a NEW wallet
// whose backup would replace this one on every relay it reaches. So absence
// is only believed when at least one relay actually answered, and a backup
// we can see but not decrypt is an error, never a shrug — each query is
// per-relay because a pooled query swallows individual connection failures.
export async function fetchWalletBackup(signer, relays = BACKUP_RELAYS) {
const pool = new SimplePool();
const filter = { kinds: [BACKUP_KIND], authors: [signer.pubkey], '#d': [SEED_DTAG] };
try {
const results = await Promise.allSettled(relays.map(async (url) => {
await pool.ensureRelay(url, { connectionTimeout: 5000 });
return pool.querySync([url], filter, { maxWait: 6000 });
}));
const answered = results.filter((r) => r.status === 'fulfilled');
if (!answered.length) throw new Error('could not reach any relay to look for an existing wallet. Check your connection and try again');
const newest = answered.flatMap((r) => r.value).sort((a, b) => b.created_at - a.created_at)[0];
if (!newest) return null;
let body;
try { body = JSON.parse(await signer.decryptSelf(newest.content)); }
catch { throw new Error('found this account’s wallet but could not decrypt it. Approve the decryption prompt in your signer and try again'); }
if (!body || !body.mnemonic) throw new Error('found this account’s wallet backup but it is unreadable');
return body;
} finally { try { pool.close(relays); } catch {} }
}
// Publish (or replace) the encrypted wallet association for this account.
// `spending` rides along as a hint: a restored wallet paints its Spending
// side from the first frame instead of reflowing when the name lookup proves
// it seconds later.
export async function publishWalletBackup(signer, { mnemonic, passphrase, spending }, relays = BACKUP_RELAYS) {
const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '', ...(spending ? { spending: true } : {}) }));
const evt = await signer.signEvent({
kind: BACKUP_KIND, created_at: Math.floor(Date.now() / 1000),
tags: [['d', SEED_DTAG]], content,
});
const pool = new SimplePool();
try {
const res = await Promise.allSettled(pool.publish(relays, evt));
if (!res.some((r) => r.status === 'fulfilled')) throw new Error('no relay accepted the backup');
} finally { try { pool.close(relays); } catch {} }
return true;
}
// 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) {
const found = await fetchWalletBackup(signer);
if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, 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.