aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/zaps.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/zaps.js')
-rw-r--r--src/features/zaps.js69
1 files changed, 66 insertions, 3 deletions
diff --git a/src/features/zaps.js b/src/features/zaps.js
index d327f9d..f29fe83 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -13,7 +13,9 @@
import { t } from '../i18n.js';
import { npubOf } from '../nostr.js';
-import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice } from '../lnurl.js';
+import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin } from '../lnurl.js';
+import { HDKey } from '@scure/bip32';
+import { mnemonicToSeedSync } from '@scure/bip39';
import { decodeNoffer } from '../noffer.js';
import { requestOfferInvoice } from '../clink.js';
import { decodeOffer } from '../bolt12.js';
@@ -290,6 +292,64 @@ export function zapsFeature(ctx) {
}
}
+ // ---- Lightning login (LNURL-auth) ----
+ // A site's login lnurl pasted (or tapped) into Send: not a payment but a
+ // challenge to sign. Ask first — it is an identity act, naming the site —
+ // then sign with the key this wallet keeps for that site alone (LUD-05)
+ // and tell the site. (Adam, 2026-10-16: a Stacker News login lnurl in Send
+ // got "no sig query variable provided" — it was being paid.)
+ function beginLogin(auth) {
+ ui.zap = null; ui.sendError = '';
+ ui.lnAuth = { auth, status: 'ask' };
+ render();
+ }
+ const masterKey = () => {
+ if (wallet.mnemonic) return HDKey.fromMasterSeed(mnemonicToSeedSync(wallet.mnemonic, wallet.passphrase || ''));
+ if (wallet.xprv) return HDKey.fromExtendedKey(wallet.xprv);
+ return null;
+ };
+ async function doLogin(a) {
+ a.status = 'busy'; render();
+ try {
+ const master = masterKey();
+ if (!master) throw new Error(t('lnAuthNoKey'));
+ await lnurlAuthLogin(a.auth, lnurlAuthKey(master, a.auth.domain));
+ if (ui.lnAuth !== a) return;
+ a.status = 'done';
+ } catch (e) {
+ if (ui.lnAuth !== a) return;
+ a.status = 'error'; a.error = e.message || String(e);
+ }
+ render();
+ }
+ function lnAuthView() {
+ const a = ui.lnAuth;
+ if (!a) return null;
+ const { domain, action } = a.auth;
+ const back = () => { ui.lnAuth = null; ui.sendError = ''; ui.send = blankSend(); render(); };
+ const heading = '🔑 ' + t('lnAuthTitle');
+ if (a.status === 'busy') {
+ return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' },
+ h('span', { class: 'spinner' }),
+ h('p', { class: 'muted', style: 'margin:0' }, t('lnAuthSigning', { domain })));
+ }
+ if (a.status === 'done') {
+ return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' },
+ h('div', { class: 'check-badge' }, '✓'),
+ h('h3', { style: 'margin:0' }, t('lnAuthDoneTitle', { domain })),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthDoneHint')),
+ h('button', { class: 'btn-primary btn-block', onClick: back }, t('done')));
+ }
+ const ask = t(action === 'register' ? 'lnAuthAskRegister' : action === 'link' ? 'lnAuthAskLink' : action === 'auth' ? 'lnAuthAskAuth' : 'lnAuthAskLogin', { domain });
+ return h('div', { class: 'card col', style: 'gap:12px' },
+ h('h3', {}, heading),
+ h('div', { class: 'small', style: 'font-size:18px;font-weight:600;word-break:break-all' }, domain),
+ h('p', { class: 'small muted', style: 'margin:0' }, ask),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthHow')),
+ a.status === 'error' ? h('div', { class: 'notice err' }, a.error || t('lnAuthFailed')) : null,
+ h('button', { class: 'btn-primary btn-block', onClick: () => doLogin(a) }, a.status === 'error' ? t('lnAuthRetry') : t(action === 'register' ? 'lnAuthGoRegister' : 'lnAuthGo')),
+ h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')));
+ }
function zapView() {
const z = ui.zap;
if (!z) return null;
@@ -391,6 +451,9 @@ export function zapsFeature(ctx) {
id: 'zaps',
// A lightning address / lnurl (always), or an npub that Ark didn't claim.
matchSendText(text, typed) {
+ // a login challenge needs no balance, only the wallet's key
+ const auth = parseLnurlAuth(text);
+ if (auth) { beginLogin(auth); return true; }
if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false;
const tap = !typed; // scanned/pasted (a tap), not a keystroke
const nof = maybeNoffer(text);
@@ -472,8 +535,8 @@ export function zapsFeature(ctx) {
begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk)));
return true;
},
- resetSend() { ui.zap = null; },
- sendView() { return zapView(); },
+ resetSend() { ui.zap = null; ui.lnAuth = null; },
+ sendView() { return lnAuthView() || zapView(); },
// Settings → Nostr: the one-tap zap amount.
nostrSettingsCards() {
const cur = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;