aboutsummaryrefslogtreecommitdiffstats
path: root/src/nostr-login.js
blob: fe602610999bf6c86010edca96f127eed1809965 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
// 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 } : 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 === 'visible') 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;
}
// 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 || '', ts: Date.now(), resolve, reject,
      timer: setTimeout(() => amberCancel('signer did not answer'), timeoutMs) };
    amberWatch = amberWatchClipboard();
    amberChanged();
    amberLaunch(url);
  });
  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.