// "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">
+{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>