wallet (web + Android)
aboutsummaryrefslogblamecommitdiffstats
path: root/src/nostr-login.js
blob: 3357f61ccd46defeb06452b11240f9e3bacb7e4a (plain) (tree)







































                                                                              
                                                     
                                                          
                                          
 








                                                                                                                     
                                 






                                                                                                                 










































                                                                                             


                                                                   


    







                                                                                     
                                                          
                                                               

                                                              
                                                                                              
                                    


                                                                            
                       



                                                                                                                      
                                              

 



                                                                                
                                                                                 
                                                                                   
                                             

                                                             
                       













                                                                                                             






                                                                                             

                                                   



                                                                               




                                                                       



                                                                            



                                                      




                                                                             




















                                                                                                                                      
                                                                    




                                                                                                 





                                                                              
                                                                     





                                                                   
                                           



                                                                                         
                                                                                                           

































                                                                                                      
                                                                                                                   








                                                                 
                              














                                                                           
       







                                                                 

 
                                                                         

















                                                                                                                   
                                                                                                                                 


























































                                                                                                                                                  



                                                                                                                      









                                                                                                                                              




























                                                                                                                                 








                                                                                        
                                                                                               

                                                                                 

                                                      

























                                                                                                                                           







                                                          

                                                                                           






                                                                             





                                                                            

                                                                         
                                                                                       
       




                                                                        
                                                                                                                                           
                                                                                                   
                             

                                                                        
                                                                                                                                                    


                                                                                                              


                                                                          




                                                                                                                                                













                                                                                                    




                                                                          
                                               
                                                
                                                                                                                             





                                                                                         

                                                                                 




                                                                              
// 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.