// "Log in with Nostr" — open (or create) a wallet from a nostr identity, and // link an existing wallet to one after the fact. // // The mechanics and their tradeoffs live in ../nostr-login.js. In short: a // pasted key DERIVES its wallet deterministically; an extension or remote // signer ASSOCIATES one via a seed published encrypted to the user's own key. // The second is what makes "log in on a new device" work without the key // ever reaching us, and it is spelled out in the UI before anything is // published, because it means the nostr key can recover the money. import { newMnemonic } from '../wallet.js'; import { npubOf, nsecOf } from '../nostr.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { extensionSigner, bunkerSigner, keySigner, parseNostrSecret, walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, resumeBunker } from '../nostr-login.js'; // The nostr ostrich, in the brand's monochrome. const OSTRICH = ''; export const NOSTR_MARK = OSTRICH; export function nostrLoginFeature(ctx) { const { h, ui, render, wallet, toast, copyBtn } = ctx; const load = () => wallet.loadFeatureState('nostrlogin', {}); const save = (st) => wallet.saveFeatureState('nostrlogin', st); // The signer that opened this session, kept so the names feature can claim // the user's real npub as their payment address (and nominate the wallet // key as manager, so later updates work without the signer). let live = null; // true while a sign-in is mid-flight: the signer is live but its link to // the wallet hasn't been persisted yet, so init() must not sever it let attaching = false; let resuming = null; let resumeFailedAt = 0; // A remote signer's connection can die quietly — the app is left holding a // signer object that will never answer again, and every send after that // fails until the page is reloaded. Notice the first failure and drop it, so // the next attempt resumes a fresh connection instead. const selfHealing = (signer) => { if (!signer || signer.kind !== 'bunker') return signer; const guard = (fn) => async (...args) => { try { return await fn(...args); } catch (e) { if (live === wrapped) live = null; throw e; } }; const wrapped = { ...signer, signEvent: guard(signer.signEvent.bind(signer)), encryptSelf: guard(signer.encryptSelf.bind(signer)), decryptSelf: guard(signer.decryptSelf.bind(signer)), encryptTo: guard(signer.encryptTo.bind(signer)), decryptFrom: guard(signer.decryptFrom.bind(signer)), }; return wrapped; }; // What it takes to be this identity again after a reload. Without it the app // shows your face in the header but signs everything else with the wallet's // own key — a different pubkey, which is exactly the mismatch people notice // when their chat messages come out under a stranger's name. // // A remote signer stores its NIP-46 client key: the signer authorised that // key for this app, and it cannot sign anything on its own. // // A pasted key stores NOTHING. The UI promises it is used once and never // kept, and that promise is worth more than saving someone a paste after a // reload — they reconnect from the nostr settings card instead. const sessionOf = (signer) => { if (!signer) return {}; return { session: signer.session || null }; }; const busy = (v) => { ui.nostrLoginBusy = v; render(); }; const fail = (e) => { attaching = false; ui.nostrLoginError = e.message || String(e); busy(false); }; // Open the wallet a signer identifies. New accounts confirm first, because // publishing an encrypted seed to relays deserves an explicit yes. async function loginWith(makeSigner) { ui.nostrLoginError = ''; busy(true); let signer; try { signer = await makeSigner(); const res = await walletForSigner(signer); if (res.mode === 'new') { // no wallet on this identity yet — ask before publishing one ui.nostrLoginNew = { signer, npub: npubOf(signer.pubkey) }; busy(false); return; } // Same wallet however you log in: a key-derived seed is published too, // so an extension login later finds this wallet instead of making a new // one. Costs nothing — anyone with the key could derive it anyway. if (res.publish) await publishWalletBackup(signer, { mnemonic: res.mnemonic }).catch(() => {}); attaching = true; live = selfHealing(signer); await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey }); save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), ...sessionOf(signer) }); attaching = false; // claim the real npub as the payment address while this signer is live ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {}); ctx.onbNostrLogin(res.mode === 'restored'); toast(res.mode === 'derived' ? t('nlOpenedDerived') : t('nlOpenedRestored')); busy(false); } catch (e) { fail(e); } } async function createForSigner() { const st = ui.nostrLoginNew; if (!st) return; busy(true); try { // One last look before publishing a fresh seed: if a backup surfaces // now (a relay that was unreachable a moment ago), open that wallet // instead of replacing it on the relays that can see it. const existing = await fetchWalletBackup(st.signer).catch(() => null); const mnemonic = existing ? existing.mnemonic : newMnemonic(); if (!existing) await publishWalletBackup(st.signer, { mnemonic }); ui.nostrLoginNew = null; attaching = true; live = selfHealing(st.signer); a
<script>
  import { goto } from "$app/navigation";
  import Avatar from "$comp/Avatar.svelte";
  import { get, f, s, si, sat, loc, sats, types } from "$lib/utils";
  import { format } from "date-fns";
  import { t } from "$lib/translations";
  import locales from "$lib/locales";
  const { fund, locale, user, payments } = $props();
  const language = $derived(user?.language || "en");
</script>

<div class="text-base">
  {#each payments as p, i}
    {@const amount = fund ? -p.amount : p.amount}
    <div
      class="grid grid-cols-12 border-b border-base-200 hover:bg-base-200 px-1 py-2 lg:p-4 cursor-pointer"
      class:border-b-0={i === payments.length - 1}
      class:text-error={amount < 0}
      onclick={() => goto(`/payment/${p.id}`)}
    >
      <div class="whitespace-nowrap my-auto col-span-3">
        <div class="font-bold flex items-center">
          <div class="flex items-center">
            <iconify-icon
              noobserver
              icon="ph:lightning-fill"
              width="24"
              class="text-yellow-300"
            ></iconify-icon>
            <div>{s(Math.abs(amount), locale)}</div>
          </div>
        </div>

        <div class="text-secondary flex items-center text-base">
          {f(Math.abs(amount) * (p.rate / sats), p.currency, locale)}

          {#if p.tip}
            <span class="text-sm text-secondary">
              &nbsp;+{Math.round((p.tip / Math.abs(amount)) * 100)}%
            </span>
          {/if}
        </div>
      </div>

      <div
        class="flex my-auto col-span-6 truncate text-ellipsis overflow-hidden mx-auto"
      >
        {#if p.type === types.fund}
          {#if fund}
            <div class="flex">
              <div class="my-auto">
                <Avatar user={p.user} size={12} disabled={true} />
              </div>
              <div class="my-auto ml-1 text-secondary">
                {p.user.username}
              </div>
            </div>
          {:else}
            <a href={`/fund/${p.memo}`}>
              <div class="text-secondary flex">
                <iconify-icon
                  noobserver
                  icon="ph:lightning-fill"
                  class="text-yellow-300 text-3xl"
                ></iconify-icon>

                <div class="my-auto">{$t("payments.fund")}</div>
              </div>
            </a>
          {/if}
        {:else if p.with}
          <div class="flex">
            <div class="my-auto">
              <Avatar user={p.with} size={12} disabled={true} />
            </div>
            <div class="my-auto ml-1 text-secondary">
              {p.with.username}
            </div>
          </div>
        {:else}
          <div class="text-secondary flex items-center gap-1">
            {#if p.type === types.lightning || p.type === types.bolt12}
              <iconify-icon
                noobserver
                icon="ph:lightning-fill"
                class="text-yellow-300 text-3xl"
              ></iconify-icon>
            {:else if p.type === types.ecash}
              <img src="/images/cash.png" class="w-12" />
            {:else if p.type === types.reconcile}
              <iconify-icon noobserver icon="ph:scales-bold" width="32"
              ></iconify-icon>
            {:else if p.type === types.bitcoin}
              <iconify-icon noobserver icon="logos:bitcoin" class="text-3xl"
              ></iconify-icon>
            {:else if p.type === types.liquid}
              <div class="my-auto">
                <img
                  src="/images/liquid.svg"
                  class="w-10 border-4 border-transparent"
                  alt="Liquid"
                />
              </div>
            {/if}

            <div class="my-auto">
              {#if p.type === types.ecash}
                {amount > 0 ? $t("payments.redeemed") : $t("payments.minted")}
              {:else}
                {amount > 0
                  ? p.confirmed
                    ? $t("payments.received")
                    : $t("payments.pending")
                  : $t("payments.sent")}
              {/if}
            </div>
          </div>
        {/if}
      </div>

      <div class="text-secondary text-right text-sm my-auto col-span-3">
        <div>
          {format(new Date(p.created), "h:mm aaa", {
            locale: locales[language],
          })}
        </div>
        <div>
          {format(new Date(p.created), "MMM d, yy", {
            locale: locales[language],
          })}
        </div>
      </div>
    </div>
  {:else}
    <p class="text-secondary text-lg text-center">{$t("payments.empty")}</p>
  {/each}
</div>
n null; const st = load(); if (st.pubkey) { return h('div', { class: 'card col' }, h('div', { class: 'row between' }, h('h3', {}, t('nlLinkTitle')), h('span', { class: 'badge dot' + (live ? ' live' : ' off') }, live ? t('nlConnected') : t('nlDisconnected'))), h('div', { class: 'small muted break' }, npubOf(st.pubkey) || st.pubkey), h('p', { class: 'small faint', style: 'margin:0' }, t('nlLinkedDesc')), // Signing needs a live signer, and a reload always drops a remote one. // Without this the app can tell you it isn't connected and offer you // nowhere to fix it. live ? null : h('div', { class: 'col', style: 'gap:8px' }, h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectDesc')), signerButtons(reconnectSigner))); } // No external account linked — but the wallet still HAS a nostr account: // the one derived from its seed, already signing chat and holding the // payment address. A bare login form here read as "no account yet", so // the built-in identity comes first and both exporting its key and // switching to another account are explicit steps behind buttons. const npub = wallet.nostrNpub && wallet.nostrNpub(); return h('div', { class: 'card col' }, h('h3', {}, t('nlLinkTitle')), npub ? h('div', { class: 'small muted break' }, npub) : null, h('p', { class: 'small muted', style: 'margin:0' }, t('nlBuiltinDesc')), exportSection(), linkSection()); } // The reconnect takeover: opened by any feature whose action just found // the signer missing (a reload dropped it; Amethyst and friends can't // reattach silently). Same guarded buttons as the settings card — an // extension, the signer-app deeplink/QR, or a pasted key — and the run // refuses a different account. function reconnectScreen() { const st = load(); const run = (makeSigner) => reconnectSigner(makeSigner).then(() => { if (live) { ui.nostrReconnect = null; render(); } }); return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card col', style: 'gap:12px' }, h('h3', { style: 'margin:0' }, t('nlReconnectTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectBody', { npub: (npubOf(st.pubkey) || '').slice(0, 12) })), signerButtons(run), ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null), h('button', { class: 'btn-ghost btn-block', onClick: () => { stopNostrConnect(); ui.nostrReconnect = null; ui.nostrConnectOpen = false; ui.nostrLoginError = ''; render(); } }, t('back'))); } return { id: 'nostrlogin', stop() { stopNostrConnect(); }, screenView() { if (ui.screen !== 'wallet' || !ui.nostrReconnect) return null; return reconnectScreen(); }, // An action needed the login signer and it isn't there: open the // reconnect screen (and try the silent extension reattach in the // background — it closes the screen by itself when it lands). nostrReconnectPrompt() { const st = load(); if (!st.pubkey || live) return false; ui.nostrReconnect = true; ui.nostrLoginError = ''; this.nostrLoginResume().then((sg) => { if (sg && ui.nostrReconnect) { ui.nostrReconnect = null; render(); } }).catch(() => {}); render(); return true; }, init() { // A live signer is bound to the wallet that linked it. Switching to an // account that never did must not inherit the login identity — it used // to, and the fresh wallet then claimed names under the login npub, // showed its profile, and even wore its hats. (Mid-sign-in the link // isn't saved yet; `attaching` keeps this from severing it.) if (live && !attaching && load().pubkey !== live.pubkey) live = null; }, // The nostr identity this session is logged in as, for features that // should speak as the user (the payment address defaults to this npub). nostrLoginIdentity() { // A just-connected signer counts immediately — the persisted link lands // only after the wallet opens, and the header avatar shouldn't show the // wallet key's face in the gap. if (live) return { pubkey: live.pubkey, npub: npubOf(live.pubkey), signer: live }; const st = load(); if (!st.pubkey) return null; return { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live }; }, // A page reload loses the signer. An installed extension can usually be // re-attached without prompting, which is what lets the payment address // stay tied to the user's real identity across reloads. Returns null for // signers we cannot silently reattach (pasted keys, bunkers). async nostrLoginResume() { if (live) return live; const st = load(); if (!st.pubkey) return null; // One attempt at a time, and a pause after a failure: identity() asks on // every send, and a signer that isn't answering shouldn't mean a new // relay connection per keystroke. if (resuming) return resuming; if (Date.now() - resumeFailedAt < 20_000) return null; resuming = (async () => { if (typeof window !== 'undefined' && window.nostr) { try { const s = await extensionSigner(); if (s.pubkey === st.pubkey) return (live = s); } catch {} } if (st.session) { try { const s = await resumeBunker(st.session, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }); if (s && s.pubkey === st.pubkey) return (live = selfHealing(s)); } catch {} } return null; })(); try { const s = await resuming; if (!s) resumeFailedAt = Date.now(); return s; } finally { resuming = null; } }, unlockExtra() { return unlockExtra(); }, nostrSettingsCards() { return [settingsCard()]; }, }; }