From f7f246835ac3b4ddaba670ce933a28d6c74d22f8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:53:51 -0400 Subject: [PATCH] zap/wallet: flag non-zappable Lightning addresses; per-item settings restore A static client can't fetch LNURL hosts that omit CORS headers, so zaps to those creators fail. Add a browser-side reachability probe (checkLightningAddressZappable) and surface it on both sides: the creator is warned at wallet-connect time if their own address isn't browser- zappable, and the zapper sees a heads-up when opening a non-reachable creator's zap dialog. Also fix encrypted settings restore: the "already offered" flag was per-pubkey, so once storage was offered the wallet note was never offered later. Track it per-item so the wallet is offered independently. Zappability check extracted to walletZappabilityCheck.js for the size cap. Co-Authored-By: Claude Opus 4.8 --- js/payments/lnurl.js | 42 +++++++++++++ js/services/settingsRestorePrompt.js | 44 +++++++++---- js/ui/profileModal/ProfileWalletController.js | 12 ++++ js/ui/profileModal/walletZappabilityCheck.js | 63 +++++++++++++++++++ tests/lnurl-fetch-error.test.mjs | 54 ++++++++++++++++ tests/settings-restore-prompt.test.mjs | 53 +++++++++++++++- 6 files changed, 254 insertions(+), 14 deletions(-) create mode 100644 js/ui/profileModal/walletZappabilityCheck.js diff --git a/js/payments/lnurl.js b/js/payments/lnurl.js index 1329be1f..00a9cc5f 100644 --- a/js/payments/lnurl.js +++ b/js/payments/lnurl.js @@ -306,6 +306,48 @@ export async function fetchPayServiceData(url, { fetcher } = {}) { }; } +// Browser-side reachability probe for a Lightning address. Does exactly what a +// zapper's browser would do — resolve the address and fetch its LNURL pay-service +// data. If that fails (host offline, TLS-broken, or — most common — missing CORS +// headers), nobody can zap this address from a static client like bitvid either. +// Use it to proactively warn a creator at wallet-connect time. +// Returns: { ok, reason, address, allowsNostr }. +// reason: "ok" | "invalid-address" | "lnurl-unreachable" | "no-nostr" | +export async function checkLightningAddressZappable(address, { fetcher } = {}) { + let resolved; + try { + resolved = resolveLightningAddress(address); + } catch (error) { + return { + ok: false, + reason: "invalid-address", + address: typeof address === "string" ? address.trim() : "", + }; + } + + try { + const metadata = await fetchPayServiceData(resolved.url, { fetcher }); + // Reachable from a browser. A pay endpoint that doesn't advertise Nostr can + // still take a normal payment but won't produce a zap receipt — surface it. + if (metadata && metadata.allowsNostr !== true) { + return { + ok: true, + reason: "no-nostr", + address: resolved.address, + allowsNostr: false, + }; + } + return { ok: true, reason: "ok", address: resolved.address, allowsNostr: true }; + } catch (error) { + return { + ok: false, + reason: error?.code || (typeof error?.message === "string" ? error.message : "lnurl-unreachable"), + address: resolved.address, + error, + }; + } +} + export function validateInvoiceAmount(metadata, amountSats) { if (!metadata || typeof metadata !== "object") { throw new Error("LNURL metadata is required to validate invoice amounts."); diff --git a/js/services/settingsRestorePrompt.js b/js/services/settingsRestorePrompt.js index d24eaa09..f1072211 100644 --- a/js/services/settingsRestorePrompt.js +++ b/js/services/settingsRestorePrompt.js @@ -22,21 +22,37 @@ function readOffered() { } } -function markOffered(pubkey) { +// Per-pubkey, PER-ITEM offered state. Tracking only per-pubkey meant the first +// item offered (usually storage) flagged the whole pubkey, so a wallet note +// published later was never offered — "the wallet doesn't restore like storage". +// Returns { storage?: true, wallet?: true }. Legacy boolean `true` (pre per-item) +// is read as storage-only so existing users still get the wallet offer. +function readOfferedKinds(pubkey) { + const entry = readOffered()[pubkey]; + if (entry === true) { + return { storage: true }; + } + return entry && typeof entry === "object" ? entry : {}; +} + +function markOffered(pubkey, kind) { try { if (typeof localStorage === "undefined") { return; } const map = readOffered(); - map[pubkey] = true; + const current = map[pubkey] === true ? { storage: true } : map[pubkey]; + const kinds = current && typeof current === "object" ? { ...current } : {}; + kinds[kind] = true; + map[pubkey] = kinds; localStorage.setItem(OFFERED_KEY, JSON.stringify(map)); } catch (error) { // Best-effort. } } -function hasOffered(pubkey) { - return readOffered()[pubkey] === true; +function hasOffered(pubkey, kind) { + return readOfferedKinds(pubkey)[kind] === true; } const ITEM_LABELS = { @@ -53,7 +69,9 @@ export function createSettingsRestorePrompt({ : false, logger = null, } = {}) { - // Items not already enabled on THIS device that have a remote copy to restore. + // Items not already enabled / not already offered on THIS device that have a + // remote copy to restore. Each kind is considered INDEPENDENTLY so a wallet note + // published after storage was already offered is still surfaced. async function collectCandidates(pubkey) { const candidates = []; const services = [ @@ -61,7 +79,11 @@ export function createSettingsRestorePrompt({ ["wallet", walletSync], ]; for (const [kind, service] of services) { - if (!service?.isAvailable?.() || service.isEnabled(pubkey)) { + if ( + !service?.isAvailable?.() || + service.isEnabled(pubkey) || + hasOffered(pubkey, kind) + ) { continue; } try { @@ -80,9 +102,6 @@ export function createSettingsRestorePrompt({ if (!key) { return { offered: false, reason: "no-pubkey" }; } - if (hasOffered(key)) { - return { offered: false, reason: "already-offered" }; - } const candidates = await collectCandidates(key); if (!candidates.length) { @@ -91,8 +110,11 @@ export function createSettingsRestorePrompt({ return { offered: false, reason: "no-remote" }; } - // We are about to prompt — mark so we never nag again on this device. - markOffered(key); + // We are about to prompt — mark EACH offered kind so we never nag again for it + // on this device (but other kinds stay eligible for a future login). + for (const kind of candidates) { + markOffered(key, kind); + } const list = candidates.map((kind) => ITEM_LABELS[kind]).join(" and "); const accepted = confirm( diff --git a/js/ui/profileModal/ProfileWalletController.js b/js/ui/profileModal/ProfileWalletController.js index 752e36ca..317fdd31 100644 --- a/js/ui/profileModal/ProfileWalletController.js +++ b/js/ui/profileModal/ProfileWalletController.js @@ -1,6 +1,7 @@ import { devLogger } from "../../utils/logger.js"; import { NWC_URI_SCHEME } from "../profileModalContract.js"; import { createWalletSyncService } from "../../services/walletSyncService.js"; +import { runWalletZappabilityCheck } from "./walletZappabilityCheck.js"; const noop = () => {}; const SECRET_PLACEHOLDER = "*****"; @@ -632,6 +633,11 @@ export class ProfileWalletController { finalVariant = "success"; this.mainController.showSuccess("Wallet settings saved."); context.reason = "saved"; + // Proactively check whether THIS creator's own Lightning address is + // reachable from a browser. If their LNURL host is offline or blocks + // browser requests (no CORS), nobody can zap them on bitvid — warn now. + // Non-blocking: the network probe must not delay the save. + void this.warnIfLightningAddressUnzappable(normalizedActive); // Keep the encrypted synced copy current if the user opted in. Warn // before overwriting a newer copy changed on another device. const sync = this.getWalletSyncService(); @@ -691,6 +697,12 @@ export class ProfileWalletController { return context; } + // Warn the creator if their OWN Lightning address can't be reached from a browser + // (delegated; runs after a successful wallet save). See walletZappabilityCheck.js. + async warnIfLightningAddressUnzappable(pubkey) { + return runWalletZappabilityCheck(this, pubkey); + } + async handleWalletTest() { const { uri, defaultZap, error } = this.getWalletFormValues(); const context = { diff --git a/js/ui/profileModal/walletZappabilityCheck.js b/js/ui/profileModal/walletZappabilityCheck.js new file mode 100644 index 00000000..b17f7636 --- /dev/null +++ b/js/ui/profileModal/walletZappabilityCheck.js @@ -0,0 +1,63 @@ +import { checkLightningAddressZappable } from "../../payments/lnurl.js"; +import { devLogger } from "../../utils/logger.js"; + +// Warn the creator if their OWN receiving Lightning address can't be reached from +// a browser (so people can't zap them on bitvid). Best-effort, non-blocking; runs +// after a successful wallet save. The probe is the exact fetch a zapper's browser +// would make, so a failure here means a failure for everyone. `controller` is the +// ProfileWalletController (provides mainController + updateWalletStatus). +export async function runWalletZappabilityCheck(controller, pubkey) { + try { + const main = controller?.mainController; + const entry = + typeof main?.getProfileCacheEntry === "function" + ? main.getProfileCacheEntry(pubkey) + : null; + const profile = entry?.profile || null; + // No cached profile -> can't determine; don't guess (avoid a false warning). + if (!profile) { + return; + } + + const address = + (typeof profile.lud16 === "string" && profile.lud16.trim()) || + (typeof profile.lud06 === "string" && profile.lud06.trim()) || + ""; + + if (!address) { + controller.updateWalletStatus( + "Wallet saved. Tip: add a Lightning address to your Nostr profile so people can zap you on bitvid.", + "info", + ); + return; + } + + const result = await checkLightningAddressZappable(address); + if (!result.ok) { + const message = + `Heads up: your Lightning address (${address}) can't be reached from a browser ` + + "— its host may be offline or blocking browser requests (no CORS). People may " + + "not be able to zap you on bitvid. A host with proper CORS support (e.g. Alby, " + + "Coinos, Wallet of Satoshi) fixes this."; + controller.updateWalletStatus(message, "error"); + main.showError(message); + return; + } + + if (result.reason === "no-nostr") { + controller.updateWalletStatus( + `Your Lightning address (${address}) is reachable, but its host doesn't advertise ` + + "Nostr zaps — payments may work without a zap receipt.", + "info", + ); + return; + } + + controller.updateWalletStatus( + `Wallet saved. Your Lightning address (${address}) is reachable — people can zap you on bitvid.`, + "success", + ); + } catch (error) { + devLogger.warn("[ProfileModal] Lightning zappability check failed:", error); + } +} diff --git a/tests/lnurl-fetch-error.test.mjs b/tests/lnurl-fetch-error.test.mjs index e3e96b53..2f7493bf 100644 --- a/tests/lnurl-fetch-error.test.mjs +++ b/tests/lnurl-fetch-error.test.mjs @@ -45,3 +45,57 @@ test("a valid LNURL pay response parses (no false friendly error)", async () => }); assert.equal(meta.callback, "https://example.com/cb"); }); + +import { checkLightningAddressZappable } from "../js/payments/lnurl.js"; + +// Helper: a fetcher that returns a valid LNURL pay-service-data JSON response. +function okFetcher({ allowsNostr = true } = {}) { + return async () => ({ + ok: true, + status: 200, + json: async () => ({ + tag: "payRequest", + callback: "https://host/callback", + minSendable: 1000, + maxSendable: 1000000, + metadata: "[]", + allowsNostr, + nostrPubkey: allowsNostr ? "a".repeat(64) : "", + }), + }); +} + +test("checkLightningAddressZappable: reachable + Nostr-enabled host -> ok", async () => { + const result = await checkLightningAddressZappable("name@host.example", { + fetcher: okFetcher({ allowsNostr: true }), + }); + assert.equal(result.ok, true); + assert.equal(result.reason, "ok"); + assert.equal(result.address, "name@host.example"); +}); + +test("checkLightningAddressZappable: CORS/offline host -> not ok, coded", async () => { + const result = await checkLightningAddressZappable("name@host.example", { + fetcher: async () => { + throw new TypeError("Failed to fetch"); + }, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, "lnurl-unreachable"); + assert.equal(result.address, "name@host.example"); +}); + +test("checkLightningAddressZappable: reachable but not Nostr-aware -> ok with no-nostr", async () => { + const result = await checkLightningAddressZappable("name@host.example", { + fetcher: okFetcher({ allowsNostr: false }), + }); + assert.equal(result.ok, true); + assert.equal(result.reason, "no-nostr"); + assert.equal(result.allowsNostr, false); +}); + +test("checkLightningAddressZappable: invalid address -> not ok", async () => { + const result = await checkLightningAddressZappable("", { fetcher: okFetcher() }); + assert.equal(result.ok, false); + assert.equal(result.reason, "invalid-address"); +}); diff --git a/tests/settings-restore-prompt.test.mjs b/tests/settings-restore-prompt.test.mjs index 3c49b3e2..7f44913f 100644 --- a/tests/settings-restore-prompt.test.mjs +++ b/tests/settings-restore-prompt.test.mjs @@ -65,7 +65,7 @@ test("declining the prompt restores nothing", async () => { assert.equal(storageSync.pulls, 0, "decline must not pull"); }); -test("does not offer twice once it has prompted (per pubkey)", async () => { +test("does not offer the SAME item twice once it has prompted", async () => { localStorage.clear(); let confirmCount = 0; const prompt = createSettingsRestorePrompt({ @@ -79,9 +79,56 @@ test("does not offer twice once it has prompted (per pubkey)", async () => { await prompt.maybeOffer(PUBKEY); const second = await prompt.maybeOffer(PUBKEY); - assert.equal(confirmCount, 1, "must prompt only once"); + assert.equal(confirmCount, 1, "must prompt only once for storage"); assert.equal(second.offered, false); - assert.equal(second.reason, "already-offered"); + // Storage was offered+marked, wallet has no remote copy -> no candidates remain. + assert.equal(second.reason, "no-remote"); +}); + +test("offers the WALLET later even after storage was already offered (per-item)", async () => { + // Regression: a wallet note published AFTER storage was already offered must + // still be surfaced. The old per-pubkey flag flagged the whole account on the + // first offer, so the wallet never restored "like storage does". + localStorage.clear(); + const offeredKinds = []; + const storageSync = makeSyncStub({ remote: true }); + // Wallet has no remote copy yet on the first login, then appears later. + let walletRemote = false; + const walletSync = { + pulls: 0, + isAvailable: () => true, + isEnabled: () => false, + hasRemote: async () => walletRemote, + pull: async function () { + this.pulls += 1; + return { found: true, imported: true }; + }, + }; + const prompt = createSettingsRestorePrompt({ + storageSync, + walletSync, + confirm: (message) => { + offeredKinds.push(message); + return true; + }, + }); + + const first = await prompt.maybeOffer(PUBKEY); // offers + restores storage only + assert.deepEqual(first.restored, ["storage"]); + assert.equal(walletSync.pulls, 0); + + // Wallet sync gets set up on another device -> a note now exists. + walletRemote = true; + const second = await prompt.maybeOffer(PUBKEY); + assert.equal(second.offered, true, "wallet must still be offered on a later login"); + assert.deepEqual(second.restored, ["wallet"]); + assert.equal(walletSync.pulls, 1, "wallet is pulled the first time it appears"); + assert.equal(storageSync.pulls, 1, "storage is not pulled again"); + + // And it is not offered a third time. + const third = await prompt.maybeOffer(PUBKEY); + assert.equal(third.offered, false); + assert.equal(third.reason, "no-remote"); }); test("skips items already enabled on this device", async () => {