mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 12:22:28 +00:00
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:
1 parent
c5589a1514
commit
f7f246835a
6 files changed
+254
-14
No files matched your search
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user