diff --git a/js/payments/lnurl.js b/js/payments/lnurl.js new file mode 100644 index 00000000..52d536b8 --- /dev/null +++ b/js/payments/lnurl.js @@ -0,0 +1,372 @@ +// js/payments/lnurl.js + +const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const CHARKEY = new Map( + Array.from(CHARSET).map((char, index) => [char, index]) +); +const GENERATORS = [ + 0x3b6a57b2, + 0x26508e6d, + 0x1ea119fa, + 0x3d4233dd, + 0x2a1462b3, +]; + +const DEFAULT_COMMENT_MAX = 0; + +function hrpExpand(hrp) { + const chars = Array.from(hrp); + const left = chars.map((char) => char.charCodeAt(0) >> 5); + const right = chars.map((char) => char.charCodeAt(0) & 31); + return [...left, 0, ...right]; +} + +function polymod(values) { + let chk = 1; + for (const value of values) { + const top = chk >>> 25; + chk = ((chk & 0x1ffffff) << 5) ^ value; + for (let i = 0; i < GENERATORS.length; i += 1) { + if (((top >> i) & 1) !== 0) { + chk ^= GENERATORS[i]; + } + } + } + return chk; +} + +function verifyChecksum(hrp, data) { + return polymod([...hrpExpand(hrp), ...data]) === 1; +} + +function bech32Decode(input) { + if (typeof input !== "string") { + throw new Error("LNURL must be a string."); + } + + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("LNURL cannot be empty."); + } + + const lower = trimmed.toLowerCase(); + const upper = trimmed.toUpperCase(); + if (trimmed !== lower && trimmed !== upper) { + throw new Error("LNURL cannot mix upper and lower case characters."); + } + + const normalized = lower; + const separatorIndex = normalized.lastIndexOf("1"); + if (separatorIndex <= 0 || separatorIndex + 7 > normalized.length) { + throw new Error("Invalid LNURL bech32 payload."); + } + + const hrp = normalized.slice(0, separatorIndex); + const dataPart = normalized.slice(separatorIndex + 1); + + const data = []; + for (const char of dataPart) { + if (!CHARKEY.has(char)) { + throw new Error("LNURL payload includes invalid characters."); + } + data.push(CHARKEY.get(char)); + } + + if (!verifyChecksum(hrp, data)) { + throw new Error("LNURL checksum is invalid."); + } + + return { + prefix: hrp, + words: data.slice(0, -6), + }; +} + +function convertWords(words, fromBits, toBits, { pad = true } = {}) { + let value = 0; + let bits = 0; + const maxValue = (1 << toBits) - 1; + const result = []; + + for (const word of words) { + if (word < 0 || word >> fromBits !== 0) { + throw new Error("Invalid bech32 word value."); + } + value = (value << fromBits) | word; + bits += fromBits; + + while (bits >= toBits) { + bits -= toBits; + result.push((value >> bits) & maxValue); + } + } + + if (pad) { + if (bits > 0) { + result.push((value << (toBits - bits)) & maxValue); + } + } else if (bits >= fromBits || ((value << (toBits - bits)) & maxValue) !== 0) { + throw new Error("Excess padding in bech32 payload."); + } + + return result; +} + +function decodeLnurlBech32(encoded) { + const { prefix, words } = bech32Decode(encoded); + if (prefix !== "lnurl") { + throw new Error("Only bech32 LNURL encodings are supported."); + } + + const bytes = convertWords(words, 5, 8, { pad: false }); + const decoder = new TextDecoder("utf-8", { fatal: true }); + return decoder.decode(Uint8Array.from(bytes)); +} + +function ensureFetchFunction(fetcher) { + if (typeof fetcher === "function") { + return fetcher; + } + + if (typeof fetch === "function") { + return fetch; + } + + if (typeof globalThis.fetch === "function") { + return globalThis.fetch.bind(globalThis); + } + + throw new Error("Fetch API is not available in this environment."); +} + +function sanitizeUrl(url) { + const trimmed = typeof url === "string" ? url.trim() : ""; + if (!trimmed) { + throw new Error("LNURL endpoint is missing."); + } + return trimmed; +} + +export function resolveLightningAddress(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) { + throw new Error("Lightning address is required."); + } + + if (trimmed.toLowerCase().startsWith("lnurl")) { + const decoded = decodeLnurlBech32(trimmed); + return { + type: "lud06", + url: sanitizeUrl(decoded), + address: trimmed, + }; + } + + if (trimmed.includes("@")) { + const [namePart, domainPart] = trimmed.split("@"); + const name = (namePart || "").trim(); + const domain = (domainPart || "").trim(); + if (!name || !domain) { + throw new Error("Invalid Lightning address format."); + } + const url = `https://${domain}/.well-known/lnurlp/${encodeURIComponent( + name.toLowerCase() + )}`; + return { + type: "lud16", + url, + address: `${name}@${domain}`, + }; + } + + if (/^https?:\/\//i.test(trimmed)) { + return { + type: "url", + url: trimmed, + address: trimmed, + }; + } + + throw new Error("Unsupported Lightning address format."); +} + +function normalizeNumeric(value, fallback = 0) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return fallback; + } + return numeric; +} + +export async function fetchPayServiceData(url, { fetcher } = {}) { + const targetUrl = sanitizeUrl(url); + const fetchFn = ensureFetchFunction(fetcher); + + const response = await fetchFn(targetUrl, { + method: "GET", + headers: { Accept: "application/json" }, + }); + + if (!response.ok) { + throw new Error(`Failed to load LNURL metadata (${response.status}).`); + } + + let payload; + try { + payload = await response.json(); + } catch (error) { + throw new Error("LNURL endpoint did not return JSON."); + } + + if (!payload || typeof payload !== "object") { + throw new Error("LNURL metadata response was invalid."); + } + + if (typeof payload.status === "string" && payload.status.toUpperCase() === "ERROR") { + const reason = + typeof payload.reason === "string" && payload.reason.trim() + ? payload.reason.trim() + : "LNURL endpoint returned an error."; + throw new Error(reason); + } + + const callback = sanitizeUrl(payload.callback); + const minSendable = Math.max(0, normalizeNumeric(payload.minSendable, 0)); + const maxSendable = Math.max(minSendable, normalizeNumeric(payload.maxSendable, minSendable)); + const commentAllowed = Math.max(0, Math.round(normalizeNumeric(payload.commentAllowed, DEFAULT_COMMENT_MAX))); + const allowsNostr = payload.allowsNostr === true; + const nostrPubkey = + typeof payload.nostrPubkey === "string" ? payload.nostrPubkey.trim() : ""; + + let metadata = []; + if (typeof payload.metadata === "string" && payload.metadata.trim()) { + try { + const parsed = JSON.parse(payload.metadata); + if (Array.isArray(parsed)) { + metadata = parsed; + } + } catch (error) { + // Ignore metadata parse errors – it is optional. + } + } + + return { + tag: typeof payload.tag === "string" ? payload.tag : "payRequest", + callback, + minSendable, + maxSendable, + metadata, + commentAllowed, + allowsNostr, + nostrPubkey, + raw: payload, + }; +} + +export function validateInvoiceAmount(metadata, amountSats) { + if (!metadata || typeof metadata !== "object") { + throw new Error("LNURL metadata is required to validate invoice amounts."); + } + + const amount = Math.round(Number(amountSats)); + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error("Zap amount must be a positive integer."); + } + + const amountMsats = amount * 1000; + const min = Math.max(0, Math.round(Number(metadata.minSendable || 0))); + const max = Math.max(min, Math.round(Number(metadata.maxSendable || amountMsats))); + + if (min && amountMsats < min) { + throw new Error(`Amount is below the minimum sendable value (${min / 1000} sats).`); + } + + if (max && amountMsats > max) { + throw new Error(`Amount exceeds the maximum sendable value (${max / 1000} sats).`); + } + + return { amountMsats }; +} + +function truncateComment(comment, limit) { + const trimmed = typeof comment === "string" ? comment.trim() : ""; + if (!limit || limit <= 0) { + return ""; + } + if (trimmed.length <= limit) { + return trimmed; + } + return trimmed.slice(0, limit); +} + +export async function requestInvoice( + metadata, + { amountSats, amountMsats, comment, zapRequest, fetcher } = {} +) { + if (!metadata || typeof metadata !== "object") { + throw new Error("LNURL metadata is required to request an invoice."); + } + + const fetchFn = ensureFetchFunction(fetcher); + const targetAmount = Number.isFinite(amountMsats) + ? Math.round(amountMsats) + : validateInvoiceAmount(metadata, amountSats).amountMsats; + + const callbackUrl = new URL(metadata.callback); + callbackUrl.searchParams.set("amount", String(targetAmount)); + + if (comment && metadata.commentAllowed > 0) { + const truncated = truncateComment(comment, metadata.commentAllowed); + if (truncated) { + callbackUrl.searchParams.set("comment", truncated); + } + } + + if (zapRequest) { + callbackUrl.searchParams.set("nostr", zapRequest); + } + + const response = await fetchFn(callbackUrl.toString(), { + method: "GET", + headers: { Accept: "application/json" }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch LNURL invoice (${response.status}).`); + } + + let payload; + try { + payload = await response.json(); + } catch (error) { + throw new Error("LNURL invoice response was not JSON."); + } + + if (!payload || typeof payload !== "object") { + throw new Error("LNURL invoice response was invalid."); + } + + if (typeof payload.status === "string" && payload.status.toUpperCase() === "ERROR") { + const reason = + typeof payload.reason === "string" && payload.reason.trim() + ? payload.reason.trim() + : "LNURL invoice request failed."; + throw new Error(reason); + } + + const invoice = typeof payload.pr === "string" ? payload.pr.trim() : ""; + if (!invoice) { + throw new Error("LNURL endpoint did not return an invoice."); + } + + return { + invoice, + raw: payload, + }; +} + +export const __TESTING__ = Object.freeze({ + bech32Decode, + decodeLnurlBech32, + convertWords, +}); diff --git a/js/payments/nwcClient.js b/js/payments/nwcClient.js new file mode 100644 index 00000000..75863ab0 --- /dev/null +++ b/js/payments/nwcClient.js @@ -0,0 +1,478 @@ +// js/payments/nwcClient.js + +const HEX64_REGEX = /^[0-9a-f]{64}$/i; +const URI_SCHEMES = [ + "nostr+walletconnect://", + "walletconnect://", + "nwc://", +]; +const REQUEST_KIND = 23194; +const RESPONSE_KIND = 23195; +const DEFAULT_REQUEST_TIMEOUT_MS = 25_000; + +let activeState = null; +let pendingRequests = new Map(); +let socket = null; +let subscriptionId = null; +let connectionPromise = null; +let requestCounter = 0; + +function getGlobalWindow() { + if (typeof window !== "undefined") { + return window; + } + if (typeof globalThis !== "undefined") { + return globalThis; + } + return {}; +} + +function getNostrTools() { + const win = getGlobalWindow(); + return win?.NostrTools || null; +} + +function assertNostrTools(methods = []) { + const tools = getNostrTools(); + if (!tools) { + throw new Error("NostrTools is required for NWC operations."); + } + for (const method of methods) { + if (typeof tools?.[method] !== "function") { + throw new Error(`NostrTools.${method} is unavailable.`); + } + } + return tools; +} + +function decodePubkey(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) { + throw new Error("Wallet pubkey is missing from the NWC URI."); + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + const tools = getNostrTools(); + const decoder = tools?.nip19?.decode; + if (typeof decoder === "function") { + try { + const decoded = decoder(trimmed); + if (decoded?.type === "npub" && typeof decoded.data === "string") { + const hex = decoded.data.trim(); + if (HEX64_REGEX.test(hex)) { + return hex.toLowerCase(); + } + } + } catch (error) { + // Ignore decode errors and fall through to failure. + } + } + + throw new Error("Wallet pubkey in the NWC URI is invalid."); +} + +function parseNwcUri(uri) { + const trimmed = typeof uri === "string" ? uri.trim() : ""; + if (!trimmed) { + throw new Error("Wallet URI is required."); + } + + let stripped = trimmed; + let scheme = ""; + for (const candidate of URI_SCHEMES) { + if (trimmed.toLowerCase().startsWith(candidate)) { + stripped = trimmed.slice(candidate.length); + scheme = candidate; + break; + } + } + + if (!scheme) { + throw new Error("Unsupported NWC URI scheme."); + } + + const [identifierPart, queryPart] = stripped.split("?"); + const walletPubkey = decodePubkey(identifierPart); + + const params = new URLSearchParams(queryPart || ""); + const relay = params.get("relay") || params.get("r"); + const secret = params.get("secret") || params.get("s"); + + if (!relay) { + throw new Error("NWC URI is missing a relay parameter."); + } + + if (!secret || !HEX64_REGEX.test(secret)) { + throw new Error("NWC URI secret must be a 64 character hex string."); + } + + const relayUrl = decodeURIComponent(relay); + const secretKey = secret.toLowerCase(); + + const tools = assertNostrTools(["getPublicKey"]); + const clientPubkey = tools.getPublicKey(secretKey); + + return { + normalizedUri: `nostr+walletconnect://${walletPubkey}?relay=${encodeURIComponent( + relayUrl + )}&secret=${secretKey}`, + relayUrl, + walletPubkey, + secretKey, + clientPubkey, + }; +} + +function closeSocket({ keepState = false } = {}) { + if (socket) { + try { + socket.close(); + } catch (error) { + // Ignore close errors. + } + } + socket = null; + connectionPromise = null; + subscriptionId = null; + + if (!keepState) { + activeState = null; + } + + for (const [, entry] of pendingRequests.entries()) { + try { + entry.reject(new Error("Wallet connection closed.")); + } catch (error) { + // ignore + } + } + pendingRequests.clear(); +} + +function isSocketOpen() { + return socket && socket.readyState === socket.OPEN; +} + +function handleSocketError(error) { + console.warn("[nwcClient] WebSocket error", error); + closeSocket({ keepState: true }); +} + +function handleSocketClose() { + console.warn("[nwcClient] Wallet connection closed."); + closeSocket({ keepState: true }); +} + +function resolveWebSocketImplementation() { + if (typeof WebSocket !== "undefined") { + return WebSocket; + } + if (typeof globalThis !== "undefined" && typeof globalThis.WebSocket !== "undefined") { + return globalThis.WebSocket; + } + throw new Error("WebSocket is not available in this environment."); +} + +function subscribeToResponses(context) { + if (!socket) { + return; + } + subscriptionId = `nwc-${Math.random().toString(36).slice(2, 10)}`; + const filters = { + kinds: [RESPONSE_KIND], + authors: [context.walletPubkey], + "#p": [context.clientPubkey], + }; + socket.send(JSON.stringify(["REQ", subscriptionId, filters])); +} + +async function decryptResponse(event) { + const tools = assertNostrTools(["nip04"]); + if (typeof tools.nip04?.decrypt !== "function") { + throw new Error("NostrTools.nip04.decrypt is not available."); + } + const context = activeState?.context; + if (!context) { + throw new Error("Wallet context is unavailable for decrypting responses."); + } + const plaintext = await tools.nip04.decrypt( + context.secretKey, + context.walletPubkey, + event.content + ); + return JSON.parse(plaintext); +} + +async function handleSocketMessage(messageEvent) { + let payload; + try { + payload = JSON.parse(messageEvent.data); + } catch (error) { + console.warn("[nwcClient] Failed to parse relay message", error); + return; + } + + if (!Array.isArray(payload) || payload.length < 2) { + return; + } + + const [type] = payload; + if (type === "EVENT" && payload.length >= 3) { + const event = payload[2]; + if (event?.kind !== RESPONSE_KIND || !Array.isArray(event.tags)) { + return; + } + + const eTag = event.tags.find((tag) => Array.isArray(tag) && tag[0] === "e"); + const requestId = eTag?.[1]; + if (!requestId || !pendingRequests.has(requestId)) { + return; + } + + const pending = pendingRequests.get(requestId); + pendingRequests.delete(requestId); + clearTimeout(pending.timeoutId); + + try { + const response = await decryptResponse(event); + if (response?.error) { + const message = + typeof response.error.message === "string" && response.error.message.trim() + ? response.error.message.trim() + : "Wallet reported an error."; + const error = new Error(message); + error.code = response.error.code || null; + pending.reject(error); + return; + } + pending.resolve({ + requestId, + result: response?.result || null, + response, + event, + }); + } catch (error) { + pending.reject(error); + } + return; + } + + if (type === "NOTICE" && payload.length >= 2) { + console.warn("[nwcClient] Relay notice:", payload[1]); + return; + } +} + +function connectSocket(context) { + if (connectionPromise) { + return connectionPromise; + } + + const WebSocketImpl = resolveWebSocketImplementation(); + const url = context.relayUrl; + + connectionPromise = new Promise((resolve, reject) => { + try { + socket = new WebSocketImpl(url); + } catch (error) { + connectionPromise = null; + reject(new Error("Failed to open wallet WebSocket.")); + return; + } + + const handleOpen = () => { + socket.removeEventListener("open", handleOpen); + socket.removeEventListener("error", handleError); + socket.removeEventListener("close", handleCloseDuringConnect); + socket.addEventListener("message", handleSocketMessage); + socket.addEventListener("error", handleSocketError); + socket.addEventListener("close", handleSocketClose); + subscribeToResponses(context); + resolve(); + }; + + const handleError = (event) => { + socket.removeEventListener("open", handleOpen); + socket.removeEventListener("error", handleError); + socket.removeEventListener("close", handleCloseDuringConnect); + connectionPromise = null; + reject(event instanceof Error ? event : new Error("Wallet WebSocket failed.")); + }; + + const handleCloseDuringConnect = () => { + socket.removeEventListener("open", handleOpen); + socket.removeEventListener("error", handleError); + socket.removeEventListener("close", handleCloseDuringConnect); + connectionPromise = null; + reject(new Error("Wallet WebSocket closed before opening.")); + }; + + socket.addEventListener("open", handleOpen); + socket.addEventListener("error", handleError); + socket.addEventListener("close", handleCloseDuringConnect); + }); + + return connectionPromise; +} + +function ensureActiveState(settings) { + if (!settings || typeof settings !== "object") { + throw new Error("Wallet settings are required."); + } + + const uri = + typeof settings.nwcUri === "string" && settings.nwcUri.trim() + ? settings.nwcUri.trim() + : ""; + if (!uri) { + throw new Error("Wallet URI is required."); + } + + const parsed = parseNwcUri(uri); + if (activeState && activeState.normalizedUri === parsed.normalizedUri) { + activeState.settings = { ...settings, nwcUri: parsed.normalizedUri }; + return activeState.context; + } + + closeSocket(); + + activeState = { + normalizedUri: parsed.normalizedUri, + settings: { ...settings, nwcUri: parsed.normalizedUri }, + context: { + relayUrl: parsed.relayUrl, + walletPubkey: parsed.walletPubkey, + secretKey: parsed.secretKey, + clientPubkey: parsed.clientPubkey, + uri: parsed.normalizedUri, + }, + }; + + pendingRequests = new Map(); + return activeState.context; +} + +export async function ensureWallet({ settings } = {}) { + const candidateSettings = + settings || activeState?.settings || activeState?.context?.settings || null; + + const context = ensureActiveState(candidateSettings); + + if (!isSocketOpen()) { + await connectSocket(context); + } + + return context; +} + +async function encryptRequestPayload(context, payload) { + const tools = assertNostrTools(["nip04", "getEventHash", "signEvent"]); + if (typeof tools.nip04?.encrypt !== "function") { + throw new Error("NostrTools.nip04.encrypt is not available."); + } + + const plaintext = JSON.stringify(payload); + const encrypted = await tools.nip04.encrypt( + context.secretKey, + context.walletPubkey, + plaintext + ); + + const event = { + kind: REQUEST_KIND, + created_at: Math.floor(Date.now() / 1000), + content: encrypted, + pubkey: context.clientPubkey, + tags: [["p", context.walletPubkey]], + }; + + event.id = tools.getEventHash(event); + event.tags.push(["e", event.id]); + event.sig = tools.signEvent(event, context.secretKey); + + return event; +} + +function registerPendingRequest(eventId, { resolve, reject, timeoutMs }) { + const timeoutId = setTimeout(() => { + if (pendingRequests.has(eventId)) { + pendingRequests.delete(eventId); + reject(new Error("Wallet request timed out.")); + } + }, timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS); + + pendingRequests.set(eventId, { resolve, reject, timeoutId }); +} + +async function sendWalletRequest(context, payload, { timeoutMs } = {}) { + if (!isSocketOpen()) { + await connectSocket(context); + } + + const event = await encryptRequestPayload(context, payload); + + const promise = new Promise((resolve, reject) => { + registerPendingRequest(event.id, { resolve, reject, timeoutMs }); + }); + + socket.send(JSON.stringify(["EVENT", event])); + return promise; +} + +function sanitizeInvoice(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) { + throw new Error("Invoice is required to send a payment."); + } + return trimmed; +} + +function sanitizeAmount(amount) { + if (!Number.isFinite(amount)) { + return null; + } + const rounded = Math.round(amount); + return Math.max(0, rounded); +} + +export async function sendPayment(bolt11, { settings, amountSats, zapRequest, timeoutMs } = {}) { + const context = await ensureWallet({ settings }); + const invoice = sanitizeInvoice(bolt11); + const payload = { + id: `req-${Date.now()}-${++requestCounter}`, + method: "pay_invoice", + params: { + invoice, + }, + }; + + const amount = sanitizeAmount(amountSats); + if (amount && amount > 0) { + payload.params.amount = amount; + } + + if (zapRequest) { + payload.params.zap_request = zapRequest; + } + + const response = await sendWalletRequest(context, payload, { timeoutMs }); + return response; +} + +export function getActiveWalletContext() { + return activeState?.context || null; +} + +export function resetWalletClient() { + closeSocket(); +} + +export const __TESTING__ = Object.freeze({ + parseNwcUri, + closeSocket, + pendingRequests, + ensureActiveState, +}); diff --git a/js/payments/platformAddress.js b/js/payments/platformAddress.js new file mode 100644 index 00000000..bbed4a48 --- /dev/null +++ b/js/payments/platformAddress.js @@ -0,0 +1,158 @@ +// js/payments/platformAddress.js + +import { PLATFORM_LUD16_OVERRIDE, ADMIN_SUPER_NPUB } from "../config.js"; +import { nostrClient } from "../nostr.js"; +import { setProfileCacheEntry } from "../state/cache.js"; + +const HEX64_REGEX = /^[0-9a-f]{64}$/i; +const CACHE_TTL_MS = 10 * 60 * 1000; + +let cachedAddress = null; + +function getGlobalWindow() { + if (typeof window !== "undefined") { + return window; + } + if (typeof globalThis !== "undefined") { + return globalThis; + } + return {}; +} + +function getNostrTools() { + const win = getGlobalWindow(); + return win?.NostrTools || null; +} + +function decodeAdminPubkey() { + const trimmed = typeof ADMIN_SUPER_NPUB === "string" ? ADMIN_SUPER_NPUB.trim() : ""; + if (!trimmed) { + return null; + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + const tools = getNostrTools(); + const decoder = tools?.nip19?.decode; + if (typeof decoder === "function") { + try { + const decoded = decoder(trimmed); + if (decoded?.type === "npub" && typeof decoded.data === "string") { + const hex = decoded.data.trim(); + if (HEX64_REGEX.test(hex)) { + return hex.toLowerCase(); + } + } + } catch (error) { + console.warn("[platformAddress] Failed to decode ADMIN_SUPER_NPUB", error); + } + } + + return null; +} + +function shouldUseOverride() { + return typeof PLATFORM_LUD16_OVERRIDE === "string" && PLATFORM_LUD16_OVERRIDE.trim(); +} + +function getOverrideAddress() { + const trimmed = PLATFORM_LUD16_OVERRIDE.trim(); + return trimmed || null; +} + +function isCacheFresh(entry) { + if (!entry) { + return false; + } + if (typeof entry.expiresAt !== "number") { + return false; + } + return entry.expiresAt > Date.now(); +} + +async function fetchAdminMetadata(adminPubkey) { + if (!adminPubkey) { + return null; + } + + if (!nostrClient?.pool || !Array.isArray(nostrClient?.relays)) { + console.warn("[platformAddress] Nostr client is not ready."); + return null; + } + + try { + const events = await nostrClient.pool.list(nostrClient.relays, [ + { kinds: [0], authors: [adminPubkey], limit: 1 }, + ]); + + if (!Array.isArray(events) || events.length === 0) { + return null; + } + + let newest = null; + for (const event of events) { + if (!event || event.pubkey !== adminPubkey || !event.content) { + continue; + } + if (!newest || event.created_at > newest.created_at) { + newest = event; + } + } + + if (!newest?.content) { + return null; + } + + const data = JSON.parse(newest.content); + const lightningAddress = + typeof data.lud16 === "string" && data.lud16.trim() + ? data.lud16.trim() + : typeof data.lud06 === "string" && data.lud06.trim() + ? data.lud06.trim() + : ""; + + if (lightningAddress) { + setProfileCacheEntry(adminPubkey, data, { persist: false }); + } + + return lightningAddress || null; + } catch (error) { + console.warn("[platformAddress] Failed to fetch admin metadata", error); + return null; + } +} + +export async function getPlatformLightningAddress({ forceRefresh = false } = {}) { + if (shouldUseOverride()) { + return getOverrideAddress(); + } + + if (!forceRefresh && isCacheFresh(cachedAddress)) { + return cachedAddress.value; + } + + const adminPubkey = decodeAdminPubkey(); + if (!adminPubkey) { + return null; + } + + if (forceRefresh !== true && cachedAddress?.value) { + return cachedAddress.value; + } + + const lightningAddress = await fetchAdminMetadata(adminPubkey); + if (lightningAddress) { + cachedAddress = { + value: lightningAddress, + expiresAt: Date.now() + CACHE_TTL_MS, + }; + } + + return lightningAddress || cachedAddress?.value || null; +} + +export function __resetPlatformAddressCache() { + cachedAddress = null; +} diff --git a/js/payments/zapSplit.js b/js/payments/zapSplit.js new file mode 100644 index 00000000..77ad714d --- /dev/null +++ b/js/payments/zapSplit.js @@ -0,0 +1,326 @@ +// js/payments/zapSplit.js + +import { PLATFORM_FEE_PERCENT, ADMIN_SUPER_NPUB } from "../config.js"; +import { + resolveLightningAddress, + fetchPayServiceData, + validateInvoiceAmount, + requestInvoice, +} from "./lnurl.js"; +import { ensureWallet, sendPayment } from "./nwcClient.js"; +import { getPlatformLightningAddress } from "./platformAddress.js"; + +const HEX64_REGEX = /^[0-9a-f]{64}$/i; +const ZAP_KIND = 9734; +const DEFAULT_DEPS = Object.freeze({ + lnurl: Object.freeze({ + resolveLightningAddress, + fetchPayServiceData, + validateInvoiceAmount, + requestInvoice, + }), + wallet: Object.freeze({ ensureWallet, sendPayment }), + platformAddress: Object.freeze({ getPlatformLightningAddress }), +}); + +function getOverridePlatformFee() { + const override = globalThis?.__BITVID_PLATFORM_FEE_OVERRIDE__; + if (Number.isFinite(override)) { + return override; + } + return PLATFORM_FEE_PERCENT; +} + +function clampPercent(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return 0; + } + return Math.min(100, Math.max(0, numeric)); +} + +function sanitizeAmount(amount) { + const numeric = Number(amount); + if (!Number.isFinite(numeric)) { + throw new Error("Zap amount must be a positive integer."); + } + const rounded = Math.round(numeric); + if (rounded <= 0) { + throw new Error("Zap amount must be greater than zero."); + } + return rounded; +} + +function sanitizeComment(comment) { + if (typeof comment !== "string") { + return ""; + } + return comment.trim(); +} + +function sanitizeAddress(address) { + const trimmed = typeof address === "string" ? address.trim() : ""; + return trimmed || ""; +} + +function mergeDependencies(overrides = {}) { + const lnurlDeps = { + ...DEFAULT_DEPS.lnurl, + ...(overrides.lnurl || {}), + }; + const walletDeps = { + ...DEFAULT_DEPS.wallet, + ...(overrides.wallet || {}), + }; + const platformDeps = { + ...DEFAULT_DEPS.platformAddress, + ...(overrides.platformAddress || {}), + }; + + return { lnurl: lnurlDeps, wallet: walletDeps, platformAddress: platformDeps }; +} + +function getNostrTools() { + if (typeof window !== "undefined" && window?.NostrTools) { + return window.NostrTools; + } + if (typeof globalThis !== "undefined" && globalThis?.NostrTools) { + return globalThis.NostrTools; + } + return null; +} + +function decodeAdminPubkey() { + const trimmed = typeof ADMIN_SUPER_NPUB === "string" ? ADMIN_SUPER_NPUB.trim() : ""; + if (!trimmed) { + return null; + } + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + const tools = getNostrTools(); + const decoder = tools?.nip19?.decode; + if (typeof decoder === "function") { + try { + const decoded = decoder(trimmed); + if (decoded?.type === "npub" && typeof decoded.data === "string") { + const hex = decoded.data.trim(); + if (HEX64_REGEX.test(hex)) { + return hex.toLowerCase(); + } + } + } catch (error) { + return null; + } + } + return null; +} + +function derivePointerTag(videoEvent) { + if (!videoEvent || typeof videoEvent !== "object") { + return null; + } + if (!Array.isArray(videoEvent.tags)) { + return null; + } + const dTag = videoEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "d" && tag[1]); + if (!dTag) { + return null; + } + if (!Number.isFinite(videoEvent.kind) || typeof videoEvent.pubkey !== "string") { + return null; + } + const value = `${videoEvent.kind}:${videoEvent.pubkey}:${dTag[1]}`; + return ["a", value]; +} + +function buildZapRequest({ + wallet, + recipientPubkey, + videoEvent, + amountSats, + comment, + lnurl, +}) { + const tools = getNostrTools(); + if (!tools?.getEventHash || !tools?.signEvent) { + throw new Error("NostrTools is required to sign zap requests."); + } + + const tags = []; + if (recipientPubkey) { + tags.push(["p", recipientPubkey]); + } + if (videoEvent?.id) { + tags.push(["e", videoEvent.id]); + } + const pointerTag = derivePointerTag(videoEvent); + if (pointerTag) { + tags.push(pointerTag); + } + if (lnurl) { + tags.push(["lnurl", lnurl]); + } + if (Number.isFinite(amountSats)) { + tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]); + } + + const event = { + kind: ZAP_KIND, + content: comment || "", + created_at: Math.floor(Date.now() / 1000), + tags, + pubkey: wallet?.clientPubkey || "", + }; + + event.id = tools.getEventHash(event); + event.sig = tools.signEvent(event, wallet?.secretKey); + + return JSON.stringify(event); +} + +function determineRecipientPubkey({ recipientType, metadata, videoEvent }) { + if (metadata?.nostrPubkey && HEX64_REGEX.test(metadata.nostrPubkey)) { + return metadata.nostrPubkey.toLowerCase(); + } + if (recipientType === "creator" && typeof videoEvent?.pubkey === "string") { + return videoEvent.pubkey; + } + if (recipientType === "platform") { + return decodeAdminPubkey(); + } + return null; +} + +async function processShare({ + recipientType, + address, + amountSats, + comment, + videoEvent, + wallet, + deps, +}) { + const resolved = deps.lnurl.resolveLightningAddress(address); + const metadata = await deps.lnurl.fetchPayServiceData(resolved.url); + const { amountMsats } = deps.lnurl.validateInvoiceAmount(metadata, amountSats); + + let zapRequest = null; + if (metadata.allowsNostr === true || metadata.nostrPubkey) { + const recipientPubkey = determineRecipientPubkey({ + recipientType, + metadata, + videoEvent, + }); + if (recipientPubkey) { + zapRequest = buildZapRequest({ + wallet, + recipientPubkey, + videoEvent, + amountSats, + comment, + lnurl: resolved.url, + }); + } + } + + const invoice = await deps.lnurl.requestInvoice(metadata, { + amountMsats, + comment, + zapRequest, + }); + + const payment = await deps.wallet.sendPayment(invoice.invoice, { + amountSats, + zapRequest, + }); + + return { + recipientType, + address, + amount: amountSats, + lnurl: resolved, + metadata, + invoice, + payment, + zapRequest, + }; +} + +export async function splitAndZap( + { videoEvent, amountSats, comment = "", walletSettings } = {}, + dependencies = {} +) { + if (!videoEvent || typeof videoEvent !== "object") { + throw new Error("A video event is required to zap."); + } + + const amount = sanitizeAmount(amountSats); + const note = sanitizeComment(comment); + const deps = mergeDependencies(dependencies); + + const wallet = await deps.wallet.ensureWallet({ settings: walletSettings }); + + const creatorAddress = sanitizeAddress(videoEvent.lightningAddress); + if (!creatorAddress) { + throw new Error("This creator has not configured a Lightning address yet."); + } + + const platformFee = clampPercent(getOverridePlatformFee()); + const platformShare = Math.floor((amount * platformFee) / 100); + const creatorShare = amount - platformShare; + + let platformAddress = null; + if (platformShare > 0) { + platformAddress = sanitizeAddress( + await deps.platformAddress.getPlatformLightningAddress({ forceRefresh: false }) + ); + if (!platformAddress) { + throw new Error("Platform Lightning address is unavailable."); + } + } + + const receipts = []; + + if (creatorShare > 0) { + receipts.push( + await processShare({ + recipientType: "creator", + address: creatorAddress, + amountSats: creatorShare, + comment: note, + videoEvent, + wallet, + deps, + }) + ); + } + + if (platformShare > 0) { + receipts.push( + await processShare({ + recipientType: "platform", + address: platformAddress, + amountSats: platformShare, + comment: note, + videoEvent, + wallet, + deps, + }) + ); + } + + return { + totalAmount: amount, + creatorShare, + platformShare, + receipts, + }; +} + +export const __TESTING__ = Object.freeze({ + buildZapRequest, + determineRecipientPubkey, + derivePointerTag, + mergeDependencies, +}); diff --git a/tests/zap-split.test.mjs b/tests/zap-split.test.mjs new file mode 100644 index 00000000..22962299 --- /dev/null +++ b/tests/zap-split.test.mjs @@ -0,0 +1,269 @@ +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (typeof globalThis.localStorage === "undefined") { + const storage = new Map(); + globalThis.localStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(String(key), String(value)); + }, + removeItem(key) { + storage.delete(String(key)); + }, + clear() { + storage.clear(); + }, + }; +} + +if (typeof window.localStorage === "undefined") { + window.localStorage = globalThis.localStorage; +} + +const nostrToolsStub = { + getEventHash(event) { + const base = JSON.stringify(event); + let hash = 0; + for (let i = 0; i < base.length; i += 1) { + hash = (hash * 33 + base.charCodeAt(i)) % 0xffffffff; + } + return hash.toString(16).padStart(8, "0"); + }, + signEvent(event, secret) { + return `sig-${(secret || "").slice(0, 8)}-${event.kind}`; + }, + nip19: { + decode(value) { + if (typeof value !== "string") { + throw new Error("Invalid nip19 input"); + } + if (value.startsWith("npub")) { + return { type: "npub", data: "f".repeat(64) }; + } + return null; + }, + }, +}; + +window.NostrTools = nostrToolsStub; +globalThis.NostrTools = nostrToolsStub; + +globalThis.fetch = async () => { + throw new Error("Unexpected fetch call during zap split tests."); +}; + +const { splitAndZap } = await import("../js/payments/zapSplit.js"); + +function createDeps({ + commentAllowed = 120, + platformAddress = "platform@example.com", + minSendable = 1000, + maxSendable = 2_000_000, + allowsNostr = true, +} = {}) { + let ensureWalletCalls = 0; + const sendCalls = []; + + return { + deps: { + lnurl: { + resolveLightningAddress(address) { + return { + type: address.includes("platform") ? "platform" : "creator", + url: `https://lnurl.test/${address}`, + address, + }; + }, + async fetchPayServiceData(url) { + const isPlatform = url.includes("platform"); + return { + callback: `${url}/callback`, + minSendable, + maxSendable, + commentAllowed, + allowsNostr, + nostrPubkey: isPlatform ? "b".repeat(64) : "a".repeat(64), + raw: {}, + }; + }, + validateInvoiceAmount(metadata, amount) { + if (amount * 1000 < metadata.minSendable) { + throw new Error("below-minimum"); + } + if (amount * 1000 > metadata.maxSendable) { + throw new Error("above-maximum"); + } + return { amountMsats: amount * 1000 }; + }, + async requestInvoice(metadata, { amountMsats, comment, zapRequest }) { + return { + invoice: `bolt11-${metadata.callback}-${amountMsats}-${comment}-${Boolean( + zapRequest + )}`, + raw: {}, + }; + }, + }, + wallet: { + async ensureWallet() { + ensureWalletCalls += 1; + return { + clientPubkey: "c".repeat(64), + secretKey: "1".repeat(64), + }; + }, + async sendPayment(invoice, { amountSats, zapRequest }) { + sendCalls.push({ invoice, amountSats, zapRequest }); + return { + invoice, + amountSats, + zapRequest, + }; + }, + }, + platformAddress: { + async getPlatformLightningAddress() { + return platformAddress; + }, + }, + }, + getEnsureWalletCalls() { + return ensureWalletCalls; + }, + getSendCalls() { + return sendCalls; + }, + }; +} + +async function testSplitMath() { + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 10; + const { deps, getSendCalls, getEnsureWalletCalls } = createDeps(); + + const videoEvent = { + id: "event-id", + pubkey: "c".repeat(64), + lightningAddress: "creator@example.com", + tags: [["d", "pointer"]], + kind: 30078, + }; + + const result = await splitAndZap( + { videoEvent, amountSats: 1000, comment: "Great video!" }, + deps + ); + + assert.equal(result.totalAmount, 1000); + assert.equal(result.creatorShare, 900); + assert.equal(result.platformShare, 100); + assert.equal(result.receipts.length, 2); + assert.equal(getEnsureWalletCalls(), 1); + + const sendCalls = getSendCalls(); + assert.equal(sendCalls.length, 2, "should send two payments"); + assert.equal(sendCalls[0].amountSats, 900); + assert.equal(sendCalls[1].amountSats, 100); + + const creatorReceipt = result.receipts[0]; + assert.equal(creatorReceipt.recipientType, "creator"); + assert(creatorReceipt.zapRequest, "creator zap request should be present"); + const parsedZap = JSON.parse(creatorReceipt.zapRequest); + assert.equal(parsedZap.kind, 9734); + assert(parsedZap.tags.some((tag) => tag[0] === "amount" && tag[1] === "900000")); + + delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; +} + +async function testLnurlBounds() { + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0; + const depsWrapper = createDeps({ minSendable: 5_000 }); + + const videoEvent = { + id: "event-id", + pubkey: "c".repeat(64), + lightningAddress: "creator@example.com", + kind: 30078, + tags: [], + }; + + await assert.rejects( + () => splitAndZap({ videoEvent, amountSats: 4 }, depsWrapper.deps), + /below-minimum/ + ); + + delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; +} + +async function testMissingAddress() { + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 15; + const depsWrapper = createDeps({ platformAddress: null }); + + const noCreatorEvent = { + id: "event-id", + pubkey: "c".repeat(64), + lightningAddress: "", + kind: 30078, + tags: [], + }; + + await assert.rejects( + () => splitAndZap({ videoEvent: noCreatorEvent, amountSats: 100 }, depsWrapper.deps), + /Lightning address/ + ); + + const creatorEvent = { + id: "event-id", + pubkey: "c".repeat(64), + lightningAddress: "creator@example.com", + kind: 30078, + tags: [], + }; + + await assert.rejects( + () => splitAndZap({ videoEvent: creatorEvent, amountSats: 100 }, depsWrapper.deps), + /Platform Lightning address is unavailable/ + ); + + delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; +} + +async function testWalletFailure() { + const deps = { + lnurl: createDeps().deps.lnurl, + wallet: { + async ensureWallet() { + throw new Error("wallet offline"); + }, + async sendPayment() { + throw new Error("should not call sendPayment"); + }, + }, + platformAddress: createDeps().deps.platformAddress, + }; + + const videoEvent = { + id: "event-id", + pubkey: "c".repeat(64), + lightningAddress: "creator@example.com", + kind: 30078, + tags: [], + }; + + await assert.rejects( + () => splitAndZap({ videoEvent, amountSats: 100 }, deps), + /wallet offline/ + ); +} + +await testSplitMath(); +await testLnurlBounds(); +await testMissingAddress(); +await testWalletFailure(); + +console.log("zap-split tests passed");