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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-23 14:53:51 -04:00
1 parent c5589a1514
commit f7f246835a
6 files changed
+254 -14

No files matched your search

+42
View File
@@ -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" | <error msg>
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.");
+33 -11
View File
@@ -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(
@@ -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 = {
@@ -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);
}
}
+54
View File
@@ -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("[email protected]", {
fetcher: okFetcher({ allowsNostr: true }),
});
assert.equal(result.ok, true);
assert.equal(result.reason, "ok");
assert.equal(result.address, "[email protected]");
});
test("checkLightningAddressZappable: CORS/offline host -> not ok, coded", async () => {
const result = await checkLightningAddressZappable("[email protected]", {
fetcher: async () => {
throw new TypeError("Failed to fetch");
},
});
assert.equal(result.ok, false);
assert.equal(result.reason, "lnurl-unreachable");
assert.equal(result.address, "[email protected]");
});
test("checkLightningAddressZappable: reachable but not Nostr-aware -> ok with no-nostr", async () => {
const result = await checkLightningAddressZappable("[email protected]", {
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");
});
+50 -3
View File
@@ -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 () => {