From a99660628370d3b5ddc6366458aa3c5058ffb999 Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 15 Jul 2026 21:08:29 +0200 Subject: [PATCH 01/11] feat: add secure nostr provider discovery and wrapping Replace the bundled HTTP relay flow with automatic nostr offer discovery, adaptive Bitcoin network selection, random eligible provider choice, strict BOLT11 and offer verification, bounded asynchronous request PoW, and detailed privacy-conscious browser diagnostics. Include focused unit coverage. --- .gitignore | 5 + assets/deployment.json | 3 + assets/diagnostics.js | 46 ++ assets/discovery.js | 74 ++++ assets/invoice.js | 203 +++++++++ assets/main.js | 840 +++++++++++++++++++++++------------- assets/manifest.json | 2 +- assets/network.js | 30 ++ assets/nostr-bundle.js | 1 + assets/nostr-relays.json | 6 + assets/nostr-tools-entry.js | 15 + assets/nostr.js | 653 ++++++++++++++++++++++++++++ assets/relays.json | 6 - assets/style.css | 128 +++++- bun.lock | 35 ++ bunfig.toml | 6 + index.html | 53 ++- package.json | 15 + signet.html | 13 + test/diagnostics.test.js | 23 + test/invoice.test.js | 77 ++++ test/network.test.js | 61 +++ test/nostr.test.js | 369 ++++++++++++++++ 23 files changed, 2323 insertions(+), 341 deletions(-) create mode 100644 .gitignore create mode 100644 assets/deployment.json create mode 100644 assets/diagnostics.js create mode 100644 assets/discovery.js create mode 100644 assets/invoice.js create mode 100644 assets/network.js create mode 100644 assets/nostr-bundle.js create mode 100644 assets/nostr-relays.json create mode 100644 assets/nostr-tools-entry.js create mode 100644 assets/nostr.js delete mode 100644 assets/relays.json create mode 100644 bun.lock create mode 100644 bunfig.toml create mode 100644 package.json create mode 100644 signet.html create mode 100644 test/diagnostics.test.js create mode 100644 test/invoice.test.js create mode 100644 test/network.test.js create mode 100644 test/nostr.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a590a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +e2e/tmp/ +e2e/playwright/test-results/ +e2e/playwright/playwright-report/ +e2e/playwright/bun.lock diff --git a/assets/deployment.json b/assets/deployment.json new file mode 100644 index 0000000..0321fbf --- /dev/null +++ b/assets/deployment.json @@ -0,0 +1,3 @@ +{ + "network": "mainnet" +} diff --git a/assets/diagnostics.js b/assets/diagnostics.js new file mode 100644 index 0000000..9dd9cae --- /dev/null +++ b/assets/diagnostics.js @@ -0,0 +1,46 @@ +const startedAt = performance.now(); + +function write(method, event, details) { + try { + const elapsedMs = Math.round(performance.now() - startedAt); + const prefix = `[lnproxy +${elapsedMs}ms] ${event}`; + const logger = console?.[method]; + if (typeof logger !== "function") return; + if (details === undefined) { + logger.call(console, prefix); + } else { + logger.call(console, prefix, details); + } + } catch (_error) { + // Diagnostics must never interrupt the user workflow. + } +} + +export const diagnostics = { + debug(event, details) { + write("debug", event, details); + }, + info(event, details) { + write("info", event, details); + }, + warn(event, details) { + write("warn", event, details); + }, + error(event, details) { + write("error", event, details); + }, +}; + +export function errorDetails(error) { + return { + name: error?.name || "Error", + message: error?.message || String(error), + stack: error?.stack, + }; +} + +export function shortID(value) { + return typeof value === "string" && value.length > 16 + ? `${value.slice(0, 12)}...${value.slice(-4)}` + : value; +} diff --git a/assets/discovery.js b/assets/discovery.js new file mode 100644 index 0000000..5adf021 --- /dev/null +++ b/assets/discovery.js @@ -0,0 +1,74 @@ +import { DEFAULT_RELAYS } from "./nostr.js"; +import { resolveNetwork } from "./network.js"; +import { diagnostics, errorDetails } from "./diagnostics.js"; + +function csv(value) { + return (value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +async function loadJSON(fetcher, path, fallback) { + try { + const response = await fetcher(path, { cache: "no-store" }); + if (!response.ok) { + diagnostics.warn("config.fetch.fallback", { path, status: response.status }); + return fallback; + } + return await response.json(); + } catch (error) { + diagnostics.warn("config.fetch.fallback", { path, error: errorDetails(error) }); + return fallback; + } +} + +// loadDiscoveryConfig resolves deployment defaults first, then applies +// per-browser relay overrides and query parameters used by tests and operators. +// Network is deliberately not read from localStorage: a stale hidden setting +// must never make a signet deployment look like mainnet (or vice versa). +export async function loadDiscoveryConfig({ + search = window.location.search, + storage = window.localStorage, + fetcher = window.fetch.bind(window), +} = {}) { + const params = new URLSearchParams(search); + diagnostics.info("config.load.started", { + requestedNetwork: params.get("network"), + hasRelayOverride: params.has("nostr_relays"), + }); + const loadedDeployment = await loadJSON(fetcher, "assets/deployment.json", {}); + const deployment = loadedDeployment && typeof loadedDeployment === "object" && !Array.isArray(loadedDeployment) + ? loadedDeployment + : {}; + const queryRelays = csv(params.get("nostr_relays")); + let storedRelays = []; + try { + storedRelays = csv(storage?.getItem("nostrRelays")); + } catch (_error) { + /* localStorage can be unavailable in hardened browser contexts */ + } + const deployedRelays = Array.isArray(deployment.nostr_relays) ? deployment.nostr_relays : []; + const bundledRelays = await loadJSON(fetcher, "assets/nostr-relays.json", DEFAULT_RELAYS); + const relayCandidates = [ + ["query", queryRelays], + ["localStorage", storedRelays], + ["deployment", deployedRelays], + ["bundled", bundledRelays], + ["built-in", DEFAULT_RELAYS], + ]; + const [relaySource, relays] = relayCandidates + .find(([, list]) => Array.isArray(list) && list.length > 0); + const network = resolveNetwork(search, deployment.network); + diagnostics.info("config.load.completed", { + network, + relaySource, + relays, + }); + + return { + network, + relays, + relaySource, + }; +} diff --git a/assets/invoice.js b/assets/invoice.js new file mode 100644 index 0000000..f838b4c --- /dev/null +++ b/assets/invoice.js @@ -0,0 +1,203 @@ +import { invoiceNetwork } from "./network.js"; +import { secp256k1, sha256 } from "./nostr-bundle.js"; + +const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32_GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; +const UNITS = { + p: 0.1, + n: 100, + u: 100_000, + m: 100_000_000, +}; + +function normalizeInvoice(invoice) { + if (typeof invoice !== "string") return ""; + const withoutScheme = invoice.trim().replace(/^lightning:/i, ""); + if (withoutScheme !== withoutScheme.toLowerCase() && withoutScheme !== withoutScheme.toUpperCase()) return ""; + return withoutScheme.toLowerCase(); +} + +function polymod(values) { + let checksum = 1; + for (const value of values) { + const top = checksum >>> 25; + checksum = ((checksum & 0x1ffffff) << 5) ^ value; + for (let i = 0; i < BECH32_GENERATORS.length; i++) { + if ((top >>> i) & 1) checksum ^= BECH32_GENERATORS[i]; + } + } + return checksum; +} + +function validChecksum(invoice) { + const separator = invoice.lastIndexOf("1"); + if (separator <= 0) return false; + const hrp = invoice.slice(0, separator); + const words = Array.from(invoice.slice(separator + 1), (char) => CHARSET.indexOf(char)); + if (words.some((word) => word < 0)) return false; + const expanded = [ + ...Array.from(hrp, (char) => char.charCodeAt(0) >>> 5), + 0, + ...Array.from(hrp, (char) => char.charCodeAt(0) & 31), + ]; + return polymod([...expanded, ...words]) === 1; +} + +function wordsToBytes(words, pad) { + const output = []; + let accumulator = 0; + let bits = 0; + for (const word of words) { + if (word < 0 || word > 31) throw new Error("invalid bech32 word"); + accumulator = (accumulator << 5) | word; + bits += 5; + while (bits >= 8) { + bits -= 8; + output.push((accumulator >>> bits) & 0xff); + accumulator &= (1 << bits) - 1; + } + } + if (pad && bits > 0) { + output.push((accumulator << (8 - bits)) & 0xff); + } else if (!pad && (bits >= 5 || ((accumulator << (8 - bits)) & 0xff) !== 0)) { + throw new Error("invalid bech32 padding"); + } + return new Uint8Array(output); +} + +function concatBytes(a, b) { + const output = new Uint8Array(a.length + b.length); + output.set(a); + output.set(b, a.length); + return output; +} + +function toHex(bytes) { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function invoiceDestination(invoice, separator, explicitPayeeWords) { + try { + const signatureWords = Array.from(invoice.slice(-110, -6), (char) => CHARSET.indexOf(char)); + const signature = wordsToBytes(signatureWords, false); + if (signature.length !== 65 || signature[64] > 3) return ""; + const dataWords = Array.from(invoice.slice(separator + 1, -110), (char) => CHARSET.indexOf(char)); + const signingData = concatBytes( + new TextEncoder().encode(invoice.slice(0, separator)), + wordsToBytes(dataWords, true), + ); + const digest = sha256(signingData); + const compactSignature = signature.slice(0, 64); + if (explicitPayeeWords) { + const explicitPayee = wordsToBytes(explicitPayeeWords, false); + if (explicitPayee.length !== 33) return ""; + return secp256k1.verify(compactSignature, digest, explicitPayee, { lowS: false }) + ? toHex(explicitPayee) + : ""; + } + const recovered = secp256k1.Signature.fromCompact(compactSignature) + .addRecoveryBit(signature[64]) + .recoverPublicKey(digest) + .toRawBytes(true); + return toHex(recovered); + } catch (_error) { + return ""; + } +} + +export function validInvoice(invoice, expectedNetwork) { + const normalized = normalizeInvoice(invoice); + if (normalized === "") return ""; + if (expectedNetwork && invoiceNetwork(normalized) !== expectedNetwork) return ""; + if (!/^ln(?:bc|tbs?|bcrt)(?:[1-9][0-9]*[munp]?)?1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{117,}$/.test(normalized)) { + return ""; + } + return validChecksum(normalized) ? normalized : ""; +} + +export function parseInvoice(invoice) { + invoice = normalizeInvoice(invoice); + if (invoice === "") throw new Error("invalid BOLT11 invoice casing"); + const pos = invoice.lastIndexOf("1"); + if (pos < 0 || invoice.length - pos - 1 < 117) throw new Error("truncated BOLT11 invoice"); + let hrpEnd = 2; + while (hrpEnd < pos && !/[0-9]/.test(invoice[hrpEnd])) hrpEnd++; + + const prefix = invoice.slice(0, hrpEnd); + let spanned = prefix; + let amount = invoice.slice(hrpEnd, pos); + if (amount === "") { + amount = 0; + } else { + spanned += `${invoice.slice(hrpEnd, pos)}`; + const unit = amount.slice(-1); + const hasUnit = unit in UNITS; + const digits = hasUnit ? amount.slice(0, -1) : amount; + amount = Number(digits) * (hasUnit ? UNITS[unit] : 100_000_000_000); + if (!Number.isSafeInteger(amount) || amount <= 0) throw new Error("invalid BOLT11 amount"); + } + spanned += invoice.slice(pos, pos + 8); + + const data = invoice.slice(pos + 8, -110); + let hash = ""; + let description = ""; + let descriptionHash = false; + let explicitPayeeWords = null; + let i = 0; + while (i < data.length) { + if (i + 3 > data.length) throw new Error("truncated BOLT11 tagged field"); + const dataLength = CHARSET.indexOf(data[i + 1]) * 32 + CHARSET.indexOf(data[i + 2]); + if (dataLength < 0 || i + 3 + dataLength > data.length) throw new Error("invalid BOLT11 tagged field length"); + spanned += data.slice(i, i + 3); + if (data[i] === "p" && data.slice(i + 1, i + 3) === "p5") { + if (hash !== "") throw new Error("duplicate BOLT11 payment hash"); + hash = data.slice(i + 3, i + 55); + spanned += `${hash}`; + } else if (data[i] === "d") { + description = data.slice(i + 3, i + 3 + dataLength); + spanned += `${description}`; + } else if (data[i] === "h" && data.slice(i + 1, i + 3) === "p5") { + descriptionHash = true; + description = data.slice(i + 3, i + 55); + spanned += `${description}`; + } else if (data[i] === "n" && dataLength === 53 && explicitPayeeWords === null) { + explicitPayeeWords = Array.from(data.slice(i + 3, i + 3 + dataLength), (char) => CHARSET.indexOf(char)); + spanned += data.slice(i + 3, i + 3 + dataLength); + } else { + spanned += data.slice(i + 3, i + 3 + dataLength); + } + i += 3 + dataLength; + } + if (hash.length !== 52) throw new Error("missing BOLT11 payment hash"); + + const signature = invoice.slice(-110, -6); + const destination = invoiceDestination(invoice, pos, explicitPayeeWords); + spanned += `${signature}`; + spanned += invoice.slice(-6); + return { + msat_amount: amount, + hash, + description, + description_hash: descriptionHash, + signature, + destination, + as_spans: spanned, + }; +} + +export function decodeBech32(bech32String) { + const fiveBitArray = Array.from(bech32String).map((char) => CHARSET.indexOf(char)); + const eightBitArray = []; + let accumulator = 0; + let bits = 0; + for (const value of fiveBitArray) { + accumulator = (accumulator << 5) | value; + bits += 5; + if (bits >= 8) { + eightBitArray.push((accumulator >> (bits - 8)) & 0xff); + accumulator &= (1 << bits) - 1; + bits -= 8; + } + } + return new TextDecoder("utf-8").decode(new Uint8Array(eightBitArray)); +} diff --git a/assets/main.js b/assets/main.js index 70d16f2..627865a 100644 --- a/assets/main.js +++ b/assets/main.js @@ -1,341 +1,599 @@ -const formDiv = document.getElementById("mainform"); -const loadingDiv = document.getElementById("loading"); -const resultDiv = document.getElementById("result"); -const formInvoice = document.getElementById("invoice"); -const formDescription = document.getElementById("description"); -const formRouting = document.getElementById("routing"); -const formRelay = document.getElementById("relay"); -const relayList = document.getElementById("known_relays"); +import { loadDiscoveryConfig } from "./discovery.js"; +import { diagnostics, errorDetails } from "./diagnostics.js"; +import { decodeBech32, parseInvoice, validInvoice } from "./invoice.js"; +import { invoiceNetwork, invoicePrefix, normalizeNetwork } from "./network.js"; +import { + discoverOffers, + effectiveFeeMsat, + feeWithinAdvertised, + normalizePubkey, + randomOffer, + selectOffers, + wrapViaNostr, +} from "./nostr.js"; + +const form = document.getElementById("mainform"); +const result = document.getElementById("result"); +const invoiceInput = document.getElementById("invoice"); +const descriptionInput = document.getElementById("description"); +const routingInput = document.getElementById("routing"); +const pinProviderInput = document.getElementById("pin_provider"); +const minPowInput = document.getElementById("min_pow"); const toggleButton = document.getElementById("atoggle"); const advancedOptions = document.getElementById("advanced"); const wrapButton = document.getElementById("wrap"); +const refreshButton = document.getElementById("refresh_offers"); const loading = document.getElementById("loading_message"); +const providerStatus = document.getElementById("provider_status"); +const providerPanel = document.getElementById("provider_panel"); +const networkSelect = document.getElementById("network_select"); +const homeLink = document.getElementById("home_link"); -const storageKey = 'defaultRelay'; -const saved = localStorage.getItem(storageKey); -if (saved !== null) formRelay.value = saved; - -let availableRelays = []; -let failedRelays = new Set(); +const state = { + config: null, + offers: [], + discovering: false, + discoveryError: null, + selectedOffer: null, + selectionKey: "", + wrapping: false, + discoveryGeneration: 0, + lastFilterLogKey: "", +}; -formRelay.addEventListener("click", function() { - this.value = ''; -}); -formRelay.addEventListener("input", function() { - localStorage.setItem(storageKey, formRelay.value); +diagnostics.info("app.started", { + origin: window.location.origin, + pathname: window.location.pathname, + queryKeys: Array.from(new URLSearchParams(window.location.search).keys()), + privacy: "invoice text, memo, payment hash, ciphertext, and ephemeral keys are not logged", }); -toggleButton.addEventListener("click", function() { - if (advancedOptions.style.display === "block") { - advancedOptions.style.display = "none"; - toggleButton.textContent = "more options ◀"; +function appendError(message) { + diagnostics.debug("ui.error.displayed", { messageLength: String(message).length }); + const div = document.createElement("div"); + div.className = "error"; + div.textContent = message; + result.appendChild(div); +} + +function appendHeading(message) { + const heading = document.createElement("h2"); + heading.textContent = message; + result.appendChild(heading); +} + +function satStr(msat) { + return (msat / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); +} + +function setNetworkUI(network) { + document.documentElement.dataset.network = network; + networkSelect.dataset.network = network; + networkSelect.value = network; + document.title = `lnproxy (${network})`; + invoiceInput.placeholder = `${invoicePrefix(network)}...`; + const configuredByQuery = new URLSearchParams(window.location.search).has("network"); + homeLink.href = configuredByQuery ? `./?network=${encodeURIComponent(network)}` : "./"; +} + +function syncWrapButton() { + wrapButton.disabled = state.wrapping || state.selectedOffer === null; + refreshButton.disabled = state.discovering || state.wrapping || state.config === null; + networkSelect.disabled = state.wrapping || state.config === null; +} + +function credentialCell(offer) { + const badge = document.createElement("span"); + badge.className = "cred"; + if (offer.attested) { + badge.classList.add("verified"); + badge.textContent = "node-verified"; + badge.title = `Signature recovers to ${offer.node_pubkey}`; + } else if (offer.node_pubkey) { + badge.classList.add("unverified"); + badge.textContent = "UNVERIFIED"; + badge.title = `Claimed node ${offer.node_pubkey} but signature did not verify`; + } else if (offer.identity_pow_bits > 0) { + badge.classList.add("anon"); + badge.textContent = `anon pow ${offer.identity_pow_bits}`; } else { - advancedOptions.style.display = "block"; - toggleButton.textContent = "less options ▼"; + badge.classList.add("anon"); + badge.textContent = "anonymous"; } -}); + return badge; +} -formDiv.addEventListener("keydown", function(event) { - if (event.key === "Enter") { - event.preventDefault(); - wrapInvoice(); - } -}); +function addCell(row, value, className = "") { + const cell = row.insertCell(); + cell.textContent = value; + if (className) cell.className = className; + return cell; +} -formDiv.addEventListener("submit", function(event) { - event.preventDefault(); - wrapInvoice(); -}); +function renderProviderTable(offers, amountMsat) { + const table = document.createElement("table"); + table.className = "providers"; + const header = table.createTHead().insertRow(); + for (const label of ["", "Fee", "Limits (sat)", "Credential", "PoW", "Provider"]) { + const th = document.createElement("th"); + th.textContent = label; + header.appendChild(th); + } -function populateRelayList(relays) { - availableRelays = relays; - relays - .filter(relay => !relay.includes(".onion")) - .forEach((relay) => { - const option = document.createElement("option"); - option.value = relay; - relayList.appendChild(option); + const body = table.createTBody(); + for (const offer of offers) { + const row = body.insertRow(); + const selectionCell = row.insertCell(); + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "provider"; + radio.value = offer.pubkey; + radio.checked = offer.pubkey === state.selectedOffer?.pubkey; + radio.addEventListener("change", () => { + state.selectedOffer = offer; + diagnostics.info("provider.selected", { + reason: "user", + provider: offer.pubkey, + committedPow: offer.committed_pow, + attested: offer.attested, + }); + syncWrapButton(); }); + selectionCell.appendChild(radio); + addCell(row, `${satStr(effectiveFeeMsat(offer, amountMsat))} sat`); + addCell(row, `${satStr(offer.min_amount_msat)}-${satStr(offer.max_amount_msat)}`); + row.insertCell().appendChild(credentialCell(offer)); + addCell(row, String(offer.committed_pow)); + addCell(row, `${offer.pubkey.slice(0, 12)}...`, "mono"); + } + providerPanel.replaceChildren(table); } -function getRandomRelay() { - let candidateRelays = availableRelays - .filter(relay => !relay.includes(".onion")) - .filter(relay => !failedRelays.has(relay)); - if (candidateRelays.length === 0) { - candidateRelays = availableRelays - } - const randomIndex = Math.floor(Math.random() * candidateRelays.length); - return candidateRelays[randomIndex]; - } - -function fetchRelayList() { - fetch('assets/relays.json') - .then(response => response.json()) - .then(data => populateRelayList(data)) - .catch(error => { - resultDiv.innerHTML += ` -
- Unable to fetch relay list. -
- `; - }) +function clearSelection(status) { + state.selectedOffer = null; + providerPanel.replaceChildren(); + providerStatus.textContent = status; + syncWrapButton(); } -// Ensure the relay list is populated before the user interacts with the page -document.addEventListener('DOMContentLoaded', fetchRelayList); +function updateProviders() { + if (!state.config) { + clearSelection("Loading deployment configuration..."); + return; + } -function wrapInvoice() { - wrapButton.disabled = true; - loading.style.display = "inline-block"; - const invoice = validInvoice(formInvoice.value); + const rawInvoice = invoiceInput.value.trim(); + if (rawInvoice === "") { + const status = state.discovering + ? `Searching ${state.config.network} provider offers...` + : `${state.offers.length} ${state.config.network} provider offer(s) available`; + clearSelection(status); + return; + } + + const invoice = validInvoice(rawInvoice, state.config.network); if (invoice === "") { - resultDiv.innerHTML += `
Error: Invalid invoice.
`; - loading.style.display = "none"; - wrapButton.disabled = false; + const detected = invoiceNetwork(rawInvoice); + const status = detected && detected !== state.config.network + ? `${detected} invoice does not match this ${state.config.network} deployment` + : "Invalid BOLT11 invoice"; + clearSelection(status); + return; + } + + let parsedInvoice; + try { + parsedInvoice = parseInvoice(invoice); + } catch (_error) { + clearSelection("Invalid BOLT11 invoice"); return; } - const data = { - invoice: invoice, - }; - let relay = formRelay.value || getRandomRelay(); - if (advancedOptions.style.display === "block") { - data.description = formDescription.value; - const routing_sat = formRouting.value; - if (routing_sat != "") { - data.routing_msat = Math.round(1000*routing_sat).toString(); + if (parsedInvoice.destination === "") { + clearSelection("Invalid BOLT11 invoice signature"); + return; + } + const amountMsat = parsedInvoice.msat_amount; + if (amountMsat === 0) { + clearSelection("Invoice amount is required"); + return; + } + if (state.discovering) { + clearSelection(`Searching ${state.config.network} provider offers...`); + return; + } + if (state.discoveryError) { + clearSelection(`Provider discovery failed: ${state.discoveryError.message}`); + return; + } + + const minPow = Math.max(0, Number.parseInt(minPowInput.value, 10) || 0); + let offers = selectOffers(state.offers, { amountMsat, wrap: "bolt11", minPow }); + const pinValue = pinProviderInput.value.trim(); + if (pinValue !== "") { + const pinned = normalizePubkey(pinValue); + if (!pinned) { + clearSelection("Pinned provider is not a valid npub or hex pubkey"); + return; + } + offers = offers.filter((offer) => offer.pubkey === pinned); + } + if (offers.length === 0) { + const noOffersKey = `${state.config.network}|${amountMsat}|${minPow}|${pinValue}|0`; + if (state.lastFilterLogKey !== noOffersKey) { + state.lastFilterLogKey = noOffersKey; + diagnostics.warn("provider.filter.empty", { + network: state.config.network, + amountMsat, + minPow, + pinnedProvider: pinValue ? normalizePubkey(pinValue) : null, + availableOfferCount: state.offers.length, + }); } + clearSelection("No eligible nostr providers found"); + return; + } + state.lastFilterLogKey = ""; + + const offerVersion = offers.map((offer) => `${offer.pubkey}:${offer.created_at}`).join(","); + const selectionKey = `${invoice}|${minPow}|${pinValue}|${offerVersion}`; + if (selectionKey !== state.selectionKey || !offers.some((offer) => offer.pubkey === state.selectedOffer?.pubkey)) { + state.selectedOffer = randomOffer(offers); + state.selectionKey = selectionKey; + diagnostics.info("provider.filter.completed", { + network: state.config.network, + amountMsat, + minPow, + pinnedProvider: pinValue ? normalizePubkey(pinValue) : null, + availableOfferCount: state.offers.length, + eligibleOfferCount: offers.length, + }); + diagnostics.info("provider.selected", { + reason: "random", + provider: state.selectedOffer.pubkey, + effectiveFeeMsat: effectiveFeeMsat(state.selectedOffer, amountMsat), + committedPow: state.selectedOffer.committed_pow, + attested: state.selectedOffer.attested, + }); } - resultDiv.innerHTML += `

Proxying through ${relay}

`; - - fetch(relay, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(data), - }) - .then(response => response.json()) - .then(x => { - if (x.status === "ERROR") { - resultDiv.innerHTML += `
${relay} error: ${x.reason}
`; - failedRelays.add(relay); - formRelay.value = getRandomRelay(); - loading.style.display = "none"; - wrapButton.disabled = false; + providerStatus.textContent = `${offers.length} eligible ${state.config.network} provider(s)`; + renderProviderTable(offers, amountMsat); + syncWrapButton(); +} + +async function refreshOffers(reason = "refresh") { + if (!state.config) return; + const generation = ++state.discoveryGeneration; + const network = state.config.network; + const startedAt = performance.now(); + diagnostics.info("discovery.started", { + reason, + generation, + network, + relays: state.config.relays, + }); + state.discovering = true; + state.discoveryError = null; + state.selectionKey = ""; + updateProviders(); + try { + const offers = await discoverOffers(state.config.relays, { network }); + if (generation !== state.discoveryGeneration) { + diagnostics.info("discovery.stale-result.ignored", { generation, network }); return; } - - const parsed_invoice = parseInvoice(data.invoice); - const parsed_proxy_invoice = parseInvoice(x.proxy_invoice); - - resultDiv.innerHTML += ` -
-
Original invoice:
- ${parsed_invoice.as_spans} -
Wrapped invoice:
- ${parsed_proxy_invoice.as_spans} -
-
- `; - - if (parsed_invoice.hash !== parsed_proxy_invoice.hash) { - resultDiv.innerHTML += ` -
- Hashes do not match, ${relay} might be evil! -
- `; - loading.style.display = "none"; - wrapButton.disabled = false; - failedRelays.add(relay); - formRelay.value = getRandomRelay(); + state.offers = offers; + diagnostics.info("discovery.completed", { + generation, + network, + offerCount: offers.length, + durationMs: Math.round(performance.now() - startedAt), + }); + } catch (error) { + if (generation !== state.discoveryGeneration) { + diagnostics.info("discovery.stale-error.ignored", { generation, network }); return; } + state.offers = []; + state.discoveryError = error; + diagnostics.error("discovery.failed", { + generation, + network, + durationMs: Math.round(performance.now() - startedAt), + error: errorDetails(error), + }); + } finally { + if (generation !== state.discoveryGeneration) return; + state.discovering = false; + updateProviders(); + } +} - if (parsed_invoice.signature === parsed_proxy_invoice.signature) { - resultDiv.innerHTML += ` -
- Destination is the same, try a different relay! -
- `; - loading.style.display = "none"; - wrapButton.disabled = false; - failedRelays.add(relay); - formRelay.value = getRandomRelay(); +async function selectNetwork(network, { updateURL = true, reason = "selector" } = {}) { + network = normalizeNetwork(network); + if (!network || !state.config) return; + const previousNetwork = state.config.network; + diagnostics.info("network.selection.requested", { + reason, + previousNetwork, + network, + updateURL, + }); + if (updateURL) { + const url = new URL(window.location.href); + url.searchParams.set("network", network); + window.history.replaceState(null, "", url); + } + if (network === state.config.network && state.offers.length > 0) { + setNetworkUI(network); + updateProviders(); + diagnostics.debug("network.selection.reused", { network, offerCount: state.offers.length }); + return; + } + state.config.network = network; + state.offers = []; + state.selectedOffer = null; + state.selectionKey = ""; + setNetworkUI(network); + diagnostics.info(previousNetwork === network ? "network.activated" : "network.changed", { + reason, + previousNetwork, + network, + }); + await refreshOffers(`network:${reason}`); +} + +function invoiceChanged() { + const detectedNetwork = invoiceNetwork(invoiceInput.value); + if (detectedNetwork && state.config && detectedNetwork !== state.config.network) { + diagnostics.info("invoice.network.detected", { + currentNetwork: state.config.network, + detectedNetwork, + }); + selectNetwork(detectedNetwork, { reason: "invoice" }); + return; + } + updateProviders(); +} + +toggleButton.addEventListener("click", () => { + const open = advancedOptions.style.display === "block"; + advancedOptions.style.display = open ? "none" : "block"; + toggleButton.textContent = open ? "more options ◀" : "less options ▼"; +}); + +form.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (!wrapButton.disabled) wrapInvoice(); + } +}); +form.addEventListener("submit", (event) => { + event.preventDefault(); + if (!wrapButton.disabled) wrapInvoice(); +}); +invoiceInput.addEventListener("input", invoiceChanged); +minPowInput.addEventListener("input", updateProviders); +pinProviderInput.addEventListener("input", updateProviders); +refreshButton.addEventListener("click", () => refreshOffers("button")); +networkSelect.addEventListener("change", () => selectNetwork(networkSelect.value, { reason: "selector" })); + +function appendInvoiceDetails(parsedInvoice, parsedProxyInvoice) { + const list = document.createElement("dl"); + for (const [label, parsed] of [ + ["Original invoice:", parsedInvoice], + ["Wrapped invoice:", parsedProxyInvoice], + ]) { + const term = document.createElement("dt"); + term.textContent = label; + const detail = document.createElement("dd"); + detail.className = "invoice"; + detail.innerHTML = parsed.as_spans; + list.append(term, detail); + } + result.appendChild(list); +} + +async function wrapInvoice() { + const offer = state.selectedOffer; + const requestNetwork = state.config?.network; + const invoice = validInvoice(invoiceInput.value, requestNetwork); + if (!offer || invoice === "") { + appendError("A valid invoice and eligible nostr provider are required."); + return; + } + const requestRelays = state.config.relays; + const wrapStartedAt = performance.now(); + let outcome = "failed"; + let parsedInvoice; + try { + parsedInvoice = parseInvoice(invoice); + } catch (_error) { + appendError("The original invoice is malformed."); + return; + } + if (parsedInvoice.destination === "") { + appendError("The original invoice signature is invalid."); + return; + } + + const data = { invoice }; + if (advancedOptions.style.display === "block" && descriptionInput.value !== "") { + data.description = descriptionInput.value; + } + if (advancedOptions.style.display === "block" && routingInput.value !== "") { + const routingMsat = Math.round(1000 * Number(routingInput.value)); + if (!Number.isSafeInteger(routingMsat) || routingMsat < 0) { + appendError("Routing budget must be a non-negative number of satoshis."); return; } + data.routing_msat = routingMsat.toString(); + } + diagnostics.info("wrap.started", { + network: requestNetwork, + provider: offer.pubkey, + amountMsat: parsedInvoice.msat_amount, + effectiveFeeMsat: effectiveFeeMsat(offer, parsedInvoice.msat_amount), + requestPow: offer.min_request_pow, + committedOfferPow: offer.committed_pow, + attestedProvider: offer.attested, + relays: requestRelays, + hasCustomDescription: Object.hasOwn(data, "description"), + customRoutingMsat: data.routing_msat || null, + }); - if (("description" in data) && decodeBech32(parsed_proxy_invoice.description) !== data.description) { - resultDiv.innerHTML += ` -
- Description does not match request, try a different relay! -
- `; - loading.style.display = "none"; - wrapButton.disabled = false; - failedRelays.add(relay); - formRelay.value = getRandomRelay(); + state.wrapping = true; + syncWrapButton(); + loading.style.display = "inline-block"; + const providerName = `nostr:${offer.pubkey.slice(0, 12)}...`; + result.replaceChildren(); + appendHeading(`Proxying through ${providerName}`); + + try { + const response = await wrapViaNostr(offer, data, { relays: requestRelays }); + diagnostics.info("wrap.response.received", { + provider: offer.pubkey, + status: response.status === "ERROR" ? "ERROR" : "OK", + }); + if (response.status === "ERROR") { + outcome = "provider-error"; + appendError(`${providerName} error: ${response.reason}`); return; } - const routing_budget = parsed_proxy_invoice.msat_amount-parsed_invoice.msat_amount - if ((parsed_invoice.msat_amount !== 0) && ("routing_msat" in data) && (routing_budget != data.routing_msat)) { - resultDiv.innerHTML += ` -
- Routing budget does not match request, try a different relay! -
- `; - loading.style.display = "none"; - wrapButton.disabled = false; - failedRelays.add(relay); - formRelay.value = getRandomRelay(); + const proxyInvoice = validInvoice(response?.proxy_invoice, requestNetwork); + if (proxyInvoice === "") { + outcome = "invalid-proxy-invoice"; + appendError(`${providerName} returned an invalid or wrong-network invoice.`); + return; + } + const parsedProxyInvoice = parseInvoice(proxyInvoice); + if (parsedProxyInvoice.destination === "") { + outcome = "invalid-proxy-signature"; + appendError(`${providerName} returned an invoice with an invalid signature.`); + return; + } + appendInvoiceDetails(parsedInvoice, parsedProxyInvoice); + if (parsedInvoice.hash !== parsedProxyInvoice.hash) { + outcome = "payment-hash-mismatch"; + appendError(`Payment hashes do not match; ${providerName} returned an invalid invoice.`); + return; + } + if (parsedInvoice.destination === parsedProxyInvoice.destination) { + outcome = "destination-unchanged"; + appendError("Destination was not proxied."); + return; + } + if ("description" in data && decodeBech32(parsedProxyInvoice.description) !== data.description) { + outcome = "description-mismatch"; + appendError("Description does not match the request."); return; } - resultDiv.innerHTML += ` -
✅ - Payment hashes match. -
-
📍 - Destination proxied. -
- `; - - if (parsed_proxy_invoice.description_hash) { - resultDiv.innerHTML += ` -
🏷️ - Description hash is - ${parsed_proxy_invoice.description}. -
- `; - } else { - resultDiv.innerHTML += ` -
🏷️ - Description is - "${decodeBech32(parsed_proxy_invoice.description)}". -
- `; + const routingBudget = parsedProxyInvoice.msat_amount - parsedInvoice.msat_amount; + if (parsedInvoice.msat_amount !== 0 && "routing_msat" in data && routingBudget !== Number(data.routing_msat)) { + outcome = "routing-budget-mismatch"; + appendError("Routing budget does not match the request."); + return; } + const routingMsat = "routing_msat" in data ? Number.parseInt(data.routing_msat, 10) : undefined; + if (parsedInvoice.msat_amount !== 0 && !feeWithinAdvertised( + offer, + parsedInvoice.msat_amount, + parsedProxyInvoice.msat_amount, + routingMsat, + )) { + outcome = "advertised-fee-exceeded"; + appendError("Provider charged more than it advertised."); + return; + } + diagnostics.info("wrap.response.validated", { + provider: offer.pubkey, + network: requestNetwork, + originalAmountMsat: parsedInvoice.msat_amount, + proxyAmountMsat: parsedProxyInvoice.msat_amount, + routingBudgetMsat: routingBudget, + paymentHashMatched: true, + destinationChanged: true, + customDescriptionValidated: Object.hasOwn(data, "description"), + feeWithinAdvertised: true, + }); - if (parsed_invoice.msat_amount !== 0) { - resultDiv.innerHTML += ` -
💸 - Routing budget - is ${Math.round(routing_budget/1000)} sats. -
- `; + for (const message of ["Payment hashes match.", "Destination proxied."]) { + const div = document.createElement("div"); + div.textContent = message; + result.appendChild(div); + } + const description = document.createElement("div"); + description.textContent = parsedProxyInvoice.description_hash + ? `Description hash: ${parsedProxyInvoice.description}` + : `Description: ${decodeBech32(parsedProxyInvoice.description)}`; + result.appendChild(description); + if (parsedInvoice.msat_amount !== 0) { + const budget = document.createElement("div"); + budget.textContent = `Routing budget: ${Math.round(routingBudget / 1000)} sats`; + result.appendChild(budget); } - resultDiv.innerHTML += ` - -
- `; - new QRCode(document.getElementById("qrcode"), { - text: x.proxy_invoice.toUpperCase(), + const link = document.createElement("a"); + link.href = `lightning:${proxyInvoice.toUpperCase()}`; + const qr = document.createElement("div"); + qr.id = "qrcode"; + link.appendChild(qr); + result.appendChild(link); + new QRCode(qr, { + text: proxyInvoice.toUpperCase(), width: 400, height: 400, - colorDark : "#000000", - colorLight : "rgba(0, 0, 0, 0)", - correctLevel : QRCode.CorrectLevel.M + colorDark: "#000000", + colorLight: "rgba(0, 0, 0, 0)", + correctLevel: QRCode.CorrectLevel.M, }); - - - loading.style.display = "none"; - wrapButton.disabled = false; - }) - .catch(error => { - resultDiv.innerHTML += `
Error: Could not connect to ${relay}
`; + outcome = "success"; + diagnostics.info("wrap.completed", { + outcome, + provider: offer.pubkey, + durationMs: Math.round(performance.now() - wrapStartedAt), + }); + } catch (error) { + outcome = "exception"; + diagnostics.error("wrap.failed", { + provider: offer.pubkey, + durationMs: Math.round(performance.now() - wrapStartedAt), + error: errorDetails(error), + }); + appendError(`Could not connect to ${providerName}: ${error.message}`); + } finally { + if (outcome !== "success") { + diagnostics.warn("wrap.completed", { + outcome, + provider: offer.pubkey, + durationMs: Math.round(performance.now() - wrapStartedAt), + }); + } + state.wrapping = false; loading.style.display = "none"; - wrapButton.disabled = false; - failedRelays.add(relay); - formRelay.value = getRandomRelay(); - }); - -}; - -function validInvoice(invoice) { - let i = invoice.trim(); - i = i.toLowerCase().replace(/^lightning:/, ""); - if (! /^lnbc(?:[1-9][0-9]*[munp])?1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{111,}$/.test(i)) { - return ""; + syncWrapButton(); } - return i; -}; - -const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; -const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; - -const units = { - p: 10 ** 12, - n: 10 ** 9, - u: 10 ** 6, - m: 10 ** 3 -}; +} -function parseInvoice(invoice) { - var spanned = "lnbc"; - const pos = invoice.lastIndexOf('1'); - let amount = invoice.slice(4, pos); - if (amount === '') { - amount = 0; - } else { - spanned += `${invoice.slice(4, pos)}`; - amount = parseFloat(amount.slice(0, -1)) / units[amount.slice(-1)]; - } - spanned += invoice.slice(pos, pos + 1 + 7); - const data = invoice.slice(pos + 1 + 7, -110); - var hash = ""; - var description = ""; - let description_hash = false; - let i = 0; - while (i < data.length) { - const data_length = CHARSET.indexOf(data[i + 1]) * 32 + CHARSET.indexOf(data[i + 1 + 1]); - spanned += data.slice(i, i + 3) - if (data[i] === 'p' && data.slice(i + 1, i + 1 + 2) === 'p5') { - hash = data.slice(i + 3, i + 3 + 52); - spanned += `${hash}`; - } else if (data[i] === 'd') { - description = data.slice(i + 3, i + 3 + data_length); - spanned += `${description}`; - } else if (data[i] === 'h' && data.slice(i + 1, i + 1 + 2) === 'p5') { - description_hash = true; - description = data.slice(i + 3, i + 3 + 52); - spanned += `${description}`; - } else { - spanned += data.slice(i + 3, i + 3 + data_length) - } - i += 3 + data_length; +async function initialize() { + try { + state.config = await loadDiscoveryConfig(); + diagnostics.info("app.configured", { + network: state.config.network, + relaySource: state.config.relaySource, + relays: state.config.relays, + }); + const detectedNetwork = invoiceNetwork(invoiceInput.value); + await selectNetwork(detectedNetwork || state.config.network, { + updateURL: detectedNetwork !== null && detectedNetwork !== state.config.network, + reason: detectedNetwork ? "initial-invoice" : "deployment", + }); + } catch (error) { + diagnostics.error("app.startup.failed", errorDetails(error)); + state.discoveryError = error; + clearSelection(`Startup failed: ${error.message}`); } - const signature = invoice.substr(-110).slice(0, 104); - spanned += `${signature}`; - spanned += invoice.substr(-6); - return { - msat_amount: amount*1e11, - hash: hash, - description: description, - description_hash: description_hash, - signature: signature, - as_spans: spanned, - }; -}; +} -function decodeBech32(bech32String) { - const fiveBitArray = Array.from(bech32String).map((char) => CHARSET.indexOf(char)); - const eightBitArray = []; - let out_index = 0; - let accumulator = 0; - let bits = 0; - for (let in_index = 0; in_index < fiveBitArray.length; in_index++) { - accumulator <<= 5 - accumulator |= fiveBitArray[in_index]; - bits += 5; - if (bits >= 8) { - eightBitArray.push((accumulator >> (bits - 8)) & 0xFF); - accumulator &= (1 << bits) - 1; - bits -= 8; - } - } - const decodedBytes = new Uint8Array(eightBitArray); - const decoder = new TextDecoder('utf-8'); - return decoder.decode(decodedBytes); -}; +initialize(); diff --git a/assets/manifest.json b/assets/manifest.json index b740e54..7aad6df 100644 --- a/assets/manifest.json +++ b/assets/manifest.json @@ -58,7 +58,7 @@ "type": "image/png" } ], - "start_url": "/", + "start_url": "./", "display": "standalone", "theme_color": "#eaffff", "background_color": "#ffffea" diff --git a/assets/network.js b/assets/network.js new file mode 100644 index 0000000..bf878f3 --- /dev/null +++ b/assets/network.js @@ -0,0 +1,30 @@ +export const NETWORKS = ["mainnet", "testnet", "signet", "regtest"]; + +export function normalizeNetwork(value) { + return NETWORKS.includes(value) ? value : null; +} + +export function resolveNetwork(search, configuredNetwork = "mainnet") { + const fromQuery = new URLSearchParams(search).get("network"); + return normalizeNetwork(fromQuery) || normalizeNetwork(configuredNetwork) || "mainnet"; +} + +export function invoiceNetwork(invoice) { + const normalized = invoice.trim().toLowerCase().replace(/^lightning:/, ""); + const hrp = normalized.match(/^(lnbcrt|lntbs|lntb|lnbc)(?:\d+[munp]?)?1/)?.[1]; + return { + lnbc: "mainnet", + lntb: "testnet", + lntbs: "signet", + lnbcrt: "regtest", + }[hrp] || null; +} + +export function invoicePrefix(network) { + return { + mainnet: "lnbc", + testnet: "lntb", + signet: "lntbs", + regtest: "lnbcrt", + }[network] || "lnbc"; +} diff --git a/assets/nostr-bundle.js b/assets/nostr-bundle.js new file mode 100644 index 0000000..5f557f6 --- /dev/null +++ b/assets/nostr-bundle.js @@ -0,0 +1 @@ +var Vc=Object.defineProperty;var jc=(r)=>r;function Hc(r,e){this[r]=jc.bind(null,e)}var mr=(r,e)=>{for(var n in e)Vc(r,n,{get:e[n],enumerable:!0,configurable:!0,set:Hc.bind(e,n)})};function Ee(r){if(!Number.isSafeInteger(r)||r<0)throw Error(`Wrong positive integer: ${r}`)}function An(r,...e){if(!(r instanceof Uint8Array))throw Error("Expected Uint8Array");if(e.length>0&&!e.includes(r.length))throw Error(`Expected Uint8Array of length ${e}, not of length=${r.length}`)}function Ke(r){if(typeof r!=="function"||typeof r.create!=="function")throw Error("Hash should be wrapped by utils.wrapConstructor");Ee(r.outputLen),Ee(r.blockLen)}function Vr(r,e=!0){if(r.destroyed)throw Error("Hash instance has been destroyed");if(e&&r.finished)throw Error("Hash#digest() has already been called")}function We(r,e){An(r);let n=e.outputLen;if(r.lengthr instanceof Uint8Array;var Fr=(r)=>new DataView(r.buffer,r.byteOffset,r.byteLength),cr=(r,e)=>r<<32-e|r>>>e,uc=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;if(!uc)throw Error("Non little-endian hardware is not supported");function Rc(r){if(typeof r!=="string")throw Error(`utf8ToBytes expected string, got ${typeof r}`);return new Uint8Array(new TextEncoder().encode(r))}function Br(r){if(typeof r==="string")r=Rc(r);if(!Ne(r))throw Error(`expected Uint8Array, got ${typeof r}`);return r}function Se(...r){let e=new Uint8Array(r.reduce((c,f)=>c+f.length,0)),n=0;return r.forEach((c)=>{if(!Ne(c))throw Error("Uint8Array expected");e.set(c,n),n+=c.length}),e}class vr{clone(){return this._cloneInto()}}var m0={}.toString;function Xe(r){let e=(c)=>r().update(Br(c)).digest(),n=r();return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=()=>r(),e}function hr(r=32){if(tr&&typeof tr.getRandomValues==="function")return tr.getRandomValues(new Uint8Array(r));throw Error("crypto.getRandomValues must be defined")}function Pc(r,e,n,c){if(typeof r.setBigUint64==="function")return r.setBigUint64(e,n,c);let f=BigInt(32),w=BigInt(4294967295),i=Number(n>>f&w),l=Number(n&w),$=c?4:0,I=c?0:4;r.setUint32(e+$,i,c),r.setUint32(e+I,l,c)}class dn extends vr{constructor(r,e,n,c){super();this.blockLen=r,this.outputLen=e,this.padOffset=n,this.isLE=c,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(r),this.view=Fr(this.buffer)}update(r){Vr(this);let{view:e,buffer:n,blockLen:c}=this;r=Br(r);let f=r.length;for(let w=0;wc-w)this.process(n,0),w=0;for(let J=w;JI.length)throw Error("_sha2: outputLen bigger than state");for(let J=0;J<$;J++)i.setUint32(4*J,I[J],f)}digest(){let{buffer:r,outputLen:e}=this;this.digestInto(r);let n=r.slice(0,e);return this.destroy(),n}_cloneInto(r){r||(r=new this.constructor),r.set(...this.get());let{blockLen:e,buffer:n,length:c,finished:f,destroyed:w,pos:i}=this;if(r.length=c,r.pos=i,r.finished=f,r.destroyed=w,c%e)r.buffer.set(n);return r}}var Bc=(r,e,n)=>r&e^~r&n,vc=(r,e,n)=>r&e^r&n^e&n,oc=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),Mr=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),Gr=new Uint32Array(64);class Ye extends dn{constructor(){super(64,32,8,!1);this.A=Mr[0]|0,this.B=Mr[1]|0,this.C=Mr[2]|0,this.D=Mr[3]|0,this.E=Mr[4]|0,this.F=Mr[5]|0,this.G=Mr[6]|0,this.H=Mr[7]|0}get(){let{A:r,B:e,C:n,D:c,E:f,F:w,G:i,H:l}=this;return[r,e,n,c,f,w,i,l]}set(r,e,n,c,f,w,i,l){this.A=r|0,this.B=e|0,this.C=n|0,this.D=c|0,this.E=f|0,this.F=w|0,this.G=i|0,this.H=l|0}process(r,e){for(let J=0;J<16;J++,e+=4)Gr[J]=r.getUint32(e,!1);for(let J=16;J<64;J++){let Q=Gr[J-15],M=Gr[J-2],K=cr(Q,7)^cr(Q,18)^Q>>>3,z=cr(M,17)^cr(M,19)^M>>>10;Gr[J]=z+Gr[J-7]+K+Gr[J-16]|0}let{A:n,B:c,C:f,D:w,E:i,F:l,G:$,H:I}=this;for(let J=0;J<64;J++){let Q=cr(i,6)^cr(i,11)^cr(i,25),M=I+Q+Bc(i,l,$)+oc[J]+Gr[J]|0,z=(cr(n,2)^cr(n,13)^cr(n,22))+vc(n,c,f)|0;I=$,$=l,l=i,i=w+M|0,w=f,f=c,c=n,n=M+z|0}n=n+this.A|0,c=c+this.B|0,f=f+this.C|0,w=w+this.D|0,i=i+this.E|0,l=l+this.F|0,$=$+this.G|0,I=I+this.H|0,this.set(n,c,f,w,i,l,$,I)}roundClean(){Gr.fill(0)}destroy(){this.set(0,0,0,0,0,0,0,0),this.buffer.fill(0)}}var sr=Xe(()=>new Ye);var Zn={};mr(Zn,{validateObject:()=>Er,utf8ToBytes:()=>kc,numberToVarBytesBE:()=>yc,numberToHexUnpadded:()=>Ue,numberToBytesLE:()=>rn,numberToBytesBE:()=>fr,hexToNumber:()=>gn,hexToBytes:()=>Ar,equalBytes:()=>bc,ensureBytes:()=>b,createHmacDrbg:()=>On,concatBytes:()=>Jr,bytesToNumberLE:()=>pr,bytesToNumberBE:()=>m,bytesToHex:()=>Yr,bitSet:()=>tc,bitMask:()=>or,bitLen:()=>_c,bitGet:()=>mc});/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var de=BigInt(0),xr=BigInt(1),Cc=BigInt(2),ar=(r)=>r instanceof Uint8Array,Lc=Array.from({length:256},(r,e)=>e.toString(16).padStart(2,"0"));function Yr(r){if(!ar(r))throw Error("Uint8Array expected");let e="";for(let n=0;nc+f.length,0)),n=0;return r.forEach((c)=>{if(!ar(c))throw Error("Uint8Array expected");e.set(c,n),n+=c.length}),e}function bc(r,e){if(r.length!==e.length)return!1;for(let n=0;nde;r>>=xr,e+=1);return e}function mc(r,e){return r>>BigInt(e)&xr}var tc=(r,e,n)=>{return r|(n?xr:de)<(Cc<new Uint8Array(r),Ae=(r)=>Uint8Array.from(r);function On(r,e,n){if(typeof r!=="number"||r<2)throw Error("hashLen must be a number");if(typeof e!=="number"||e<2)throw Error("qByteLen must be a number");if(typeof n!=="function")throw Error("hmacFn must be a function");let c=Un(r),f=Un(r),w=0,i=()=>{c.fill(1),f.fill(0),w=0},l=(...Q)=>n(f,c,...Q),$=(Q=Un())=>{if(f=l(Ae([0]),Q),c=l(),Q.length===0)return;f=l(Ae([1]),Q),c=l()},I=()=>{if(w++>=1000)throw Error("drbg: tried 1000 values");let Q=0,M=[];while(Q{i(),$(Q);let K=void 0;while(!(K=M(I())))$();return i(),K}}var Fc={bigint:(r)=>typeof r==="bigint",function:(r)=>typeof r==="function",boolean:(r)=>typeof r==="boolean",string:(r)=>typeof r==="string",stringOrUint8Array:(r)=>typeof r==="string"||r instanceof Uint8Array,isSafeInteger:(r)=>Number.isSafeInteger(r),array:(r)=>Array.isArray(r),field:(r,e)=>e.Fp.isValid(r),hash:(r)=>typeof r==="function"&&Number.isSafeInteger(r.outputLen)};function Er(r,e,n={}){let c=(f,w,i)=>{let l=Fc[w];if(typeof l!=="function")throw Error(`Invalid validator "${w}", expected function`);let $=r[f];if(i&&$===void 0)return;if(!l($,r))throw Error(`Invalid param ${String(f)}=${$} (${typeof $}), expected ${w}`)};for(let[f,w]of Object.entries(e))c(f,w,!1);for(let[f,w]of Object.entries(n))c(f,w,!0);return r}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var _=BigInt(0),C=BigInt(1),dr=BigInt(2),hc=BigInt(3),Dn=BigInt(4),ge=BigInt(5),Oe=BigInt(8),sc=BigInt(9),xc=BigInt(16);function k(r,e){let n=r%e;return n>=_?n:e+n}function ac(r,e,n){if(n<=_||e<_)throw Error("Expected power/modulo > 0");if(n===C)return _;let c=C;while(e>_){if(e&C)c=c*r%n;r=r*r%n,e>>=C}return c}function a(r,e,n){let c=r;while(e-- >_)c*=c,c%=n;return c}function nn(r,e){if(r===_||e<=_)throw Error(`invert: expected positive integers, got n=${r} mod=${e}`);let n=k(r,e),c=e,f=_,w=C,i=C,l=_;while(n!==_){let I=c/n,J=c%n,Q=f-i*I,M=w-l*I;c=n,n=J,f=i,w=l,i=Q,l=M}if(c!==C)throw Error("invert: does not exist");return k(f,e)}function pc(r){let e=(r-C)/dr,n,c,f;for(n=r-C,c=0;n%dr===_;n/=dr,c++);for(f=dr;f{return c[f]="function",c},e);return Er(r,n)}function ef(r,e,n){if(n<_)throw Error("Expected power > 0");if(n===_)return r.ONE;if(n===C)return e;let c=r.ONE,f=e;while(n>_){if(n&C)c=r.mul(c,f);f=r.sqr(f),n>>=C}return c}function cf(r,e){let n=Array(e.length),c=e.reduce((w,i,l)=>{if(r.is0(i))return w;return n[l]=w,r.mul(w,i)},r.ONE),f=r.inv(c);return e.reduceRight((w,i,l)=>{if(r.is0(i))return w;return n[l]=r.mul(w,n[l]),r.mul(w,i)},f),n}function jn(r,e){let n=e!==void 0?e:r.toString(2).length,c=Math.ceil(n/8);return{nBitLength:n,nByteLength:c}}function Ze(r,e,n=!1,c={}){if(r<=_)throw Error(`Expected Field ORDER > 0, got ${r}`);let{nBitLength:f,nByteLength:w}=jn(r,e);if(w>2048)throw Error("Field lengths over 2048 bytes are not supported");let i=rf(r),l=Object.freeze({ORDER:r,BITS:f,BYTES:w,MASK:or(f),ZERO:_,ONE:C,create:($)=>k($,r),isValid:($)=>{if(typeof $!=="bigint")throw Error(`Invalid field element: expected bigint, got ${typeof $}`);return _<=$&&$$===_,isOdd:($)=>($&C)===C,neg:($)=>k(-$,r),eql:($,I)=>$===I,sqr:($)=>k($*$,r),add:($,I)=>k($+I,r),sub:($,I)=>k($-I,r),mul:($,I)=>k($*I,r),pow:($,I)=>ef(l,$,I),div:($,I)=>k($*nn(I,r),r),sqrN:($)=>$*$,addN:($,I)=>$+I,subN:($,I)=>$-I,mulN:($,I)=>$*I,inv:($)=>nn($,r),sqrt:c.sqrt||(($)=>i(l,$)),invertBatch:($)=>cf(l,$),cmov:($,I,J)=>J?I:$,toBytes:($)=>n?rn($,w):fr($,w),fromBytes:($)=>{if($.length!==w)throw Error(`Fp.fromBytes: expected ${w}, got ${$.length}`);return n?pr($):m($)}});return Object.freeze(l)}function De(r){if(typeof r!=="bigint")throw Error("field order must be bigint");let e=r.toString(2).length;return Math.ceil(e/8)}function Hn(r){let e=De(r);return e+Math.ceil(e/2)}function Ve(r,e,n=!1){let c=r.length,f=De(e),w=Hn(e);if(c<16||c1024)throw Error(`expected ${w}-1024 bytes of input, got ${c}`);let i=n?m(r):pr(r),l=k(i,e-C)+C;return n?rn(l,f):fr(l,f)}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var wf=BigInt(0),un=BigInt(1);function je(r,e){let n=(f,w)=>{let i=w.negate();return f?i:w},c=(f)=>{let w=Math.ceil(e/f)+1,i=2**(f-1);return{windows:w,windowSize:i}};return{constTimeNegate:n,unsafeLadder(f,w){let i=r.ZERO,l=f;while(w>wf){if(w&un)i=i.add(l);l=l.double(),w>>=un}return i},precomputeWindow(f,w){let{windows:i,windowSize:l}=c(w),$=[],I=f,J=I;for(let Q=0;Q>=K,G>$)G-=M,i+=un;let E=q,S=q+Math.abs(G)-1,U=z%2!==0,A=G<0;if(G===0)J=J.add(n(U,w[E]));else I=I.add(n(A,w[S]))}return{p:I,f:J}},wNAFCached(f,w,i,l){let $=f._WINDOW_SIZE||1,I=w.get(f);if(!I){if(I=this.precomputeWindow(f,$),$!==1)w.set(f,l(I))}return this.wNAF($,I,i)}}}function Rn(r){return Vn(r.Fp),Er(r,{n:"bigint",h:"bigint",Gx:"field",Gy:"field"},{nBitLength:"isSafeInteger",nByteLength:"isSafeInteger"}),Object.freeze({...jn(r.n,r.nBitLength),...r,...{p:r.Fp.ORDER}})}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function $f(r){let e=Rn(r);Er(e,{a:"field",b:"field"},{allowedPrivateKeyLengths:"array",wrapPrivateKey:"boolean",isTorsionFree:"function",clearCofactor:"function",allowInfinityPoint:"boolean",fromBytes:"function",toBytes:"function"});let{endo:n,Fp:c,a:f}=e;if(n){if(!c.eql(f,c.ZERO))throw Error("Endomorphism can only be defined for Koblitz curves that have a=0");if(typeof n!=="object"||typeof n.beta!=="bigint"||typeof n.splitScalar!=="function")throw Error("Expected endomorphism with beta: bigint and splitScalar: function")}return Object.freeze({...e})}var{bytesToNumberBE:lf,hexToBytes:Jf}=Zn,Ur={Err:class extends Error{constructor(e=""){super(e)}},_parseInt(r){let{Err:e}=Ur;if(r.length<2||r[0]!==2)throw new e("Invalid signature integer tag");let n=r[1],c=r.subarray(2,n+2);if(!n||c.length!==n)throw new e("Invalid signature integer: wrong length");if(c[0]&128)throw new e("Invalid signature integer: negative");if(c[0]===0&&!(c[1]&128))throw new e("Invalid signature integer: unnecessary leading zero");return{d:lf(c),l:r.subarray(n+2)}},toSig(r){let{Err:e}=Ur,n=typeof r==="string"?Jf(r):r;if(!(n instanceof Uint8Array))throw Error("ui8a expected");let c=n.length;if(c<2||n[0]!=48)throw new e("Invalid signature tag");if(n[1]!==c-2)throw new e("Invalid signature: incorrect length");let{d:f,l:w}=Ur._parseInt(n.subarray(2)),{d:i,l}=Ur._parseInt(w);if(l.length)throw new e("Invalid signature: left bytes after parsing");return{r:f,s:i}},hexFromSig(r){let e=(I)=>Number.parseInt(I[0],16)&8?"00"+I:I,n=(I)=>{let J=I.toString(16);return J.length&1?`0${J}`:J},c=e(n(r.s)),f=e(n(r.r)),w=c.length/2,i=f.length/2,l=n(w),$=n(i);return`30${n(i+w+4)}02${$}${f}02${l}${c}`}},Ir=BigInt(0),rr=BigInt(1),iw=BigInt(2),He=BigInt(3),$w=BigInt(4);function If(r){let e=$f(r),{Fp:n}=e,c=e.toBytes||((z,q,G)=>{let E=q.toAffine();return Jr(Uint8Array.from([4]),n.toBytes(E.x),n.toBytes(E.y))}),f=e.fromBytes||((z)=>{let q=z.subarray(1),G=n.fromBytes(q.subarray(0,n.BYTES)),E=n.fromBytes(q.subarray(n.BYTES,2*n.BYTES));return{x:G,y:E}});function w(z){let{a:q,b:G}=e,E=n.sqr(z),S=n.mul(E,z);return n.add(n.add(S,n.mul(z,q)),G)}if(!n.eql(n.sqr(e.Gy),w(e.Gx)))throw Error("bad generator point: equation left != right");function i(z){return typeof z==="bigint"&&Irn.eql(S,n.ZERO);if(E(q)&&E(G))return Q.ZERO;return new Q(q,G,n.ONE)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}static normalizeZ(z){let q=n.invertBatch(z.map((G)=>G.pz));return z.map((G,E)=>G.toAffine(q[E])).map(Q.fromAffine)}static fromHex(z){let q=Q.fromAffine(f(b("pointHex",z)));return q.assertValidity(),q}static fromPrivateKey(z){return Q.BASE.multiply($(z))}_setWindowSize(z){this._WINDOW_SIZE=z,I.delete(this)}assertValidity(){if(this.is0()){if(e.allowInfinityPoint&&!n.is0(this.py))return;throw Error("bad point: ZERO")}let{x:z,y:q}=this.toAffine();if(!n.isValid(z)||!n.isValid(q))throw Error("bad point: x or y not FE");let G=n.sqr(q),E=w(z);if(!n.eql(G,E))throw Error("bad point: equation left != right");if(!this.isTorsionFree())throw Error("bad point: not in prime-order subgroup")}hasEvenY(){let{y:z}=this.toAffine();if(n.isOdd)return!n.isOdd(z);throw Error("Field doesn't support isOdd")}equals(z){J(z);let{px:q,py:G,pz:E}=this,{px:S,py:U,pz:A}=z,g=n.eql(n.mul(q,A),n.mul(S,E)),Y=n.eql(n.mul(G,A),n.mul(U,E));return g&&Y}negate(){return new Q(this.px,n.neg(this.py),this.pz)}double(){let{a:z,b:q}=e,G=n.mul(q,He),{px:E,py:S,pz:U}=this,A=n.ZERO,g=n.ZERO,Y=n.ZERO,O=n.mul(E,E),B=n.mul(S,S),D=n.mul(U,U),Z=n.mul(E,S);return Z=n.add(Z,Z),Y=n.mul(E,U),Y=n.add(Y,Y),A=n.mul(z,Y),g=n.mul(G,D),g=n.add(A,g),A=n.sub(B,g),g=n.add(B,g),g=n.mul(A,g),A=n.mul(Z,A),Y=n.mul(G,Y),D=n.mul(z,D),Z=n.sub(O,D),Z=n.mul(z,Z),Z=n.add(Z,Y),Y=n.add(O,O),O=n.add(Y,O),O=n.add(O,D),O=n.mul(O,Z),g=n.add(g,O),D=n.mul(S,U),D=n.add(D,D),O=n.mul(D,Z),A=n.sub(A,O),Y=n.mul(D,B),Y=n.add(Y,Y),Y=n.add(Y,Y),new Q(A,g,Y)}add(z){J(z);let{px:q,py:G,pz:E}=this,{px:S,py:U,pz:A}=z,g=n.ZERO,Y=n.ZERO,O=n.ZERO,B=e.a,D=n.mul(e.b,He),Z=n.mul(q,S),u=n.mul(G,U),R=n.mul(E,A),v=n.add(q,G),j=n.add(S,U);v=n.mul(v,j),j=n.add(Z,u),v=n.sub(v,j),j=n.add(q,E);let T=n.add(S,A);return j=n.mul(j,T),T=n.add(Z,R),j=n.sub(j,T),T=n.add(G,E),g=n.add(U,A),T=n.mul(T,g),g=n.add(u,R),T=n.sub(T,g),O=n.mul(B,j),g=n.mul(D,R),O=n.add(g,O),g=n.sub(u,O),O=n.add(u,O),Y=n.mul(g,O),u=n.add(Z,Z),u=n.add(u,Z),R=n.mul(B,R),j=n.mul(D,j),u=n.add(u,R),R=n.sub(Z,R),R=n.mul(B,R),j=n.add(j,R),Z=n.mul(u,j),Y=n.add(Y,Z),Z=n.mul(T,j),g=n.mul(v,g),g=n.sub(g,Z),Z=n.mul(v,u),O=n.mul(T,O),O=n.add(O,Z),new Q(g,Y,O)}subtract(z){return this.add(z.negate())}is0(){return this.equals(Q.ZERO)}wNAF(z){return K.wNAFCached(this,I,z,(q)=>{let G=n.invertBatch(q.map((E)=>E.pz));return q.map((E,S)=>E.toAffine(G[S])).map(Q.fromAffine)})}multiplyUnsafe(z){let q=Q.ZERO;if(z===Ir)return q;if(l(z),z===rr)return this;let{endo:G}=e;if(!G)return K.unsafeLadder(this,z);let{k1neg:E,k1:S,k2neg:U,k2:A}=G.splitScalar(z),g=q,Y=q,O=this;while(S>Ir||A>Ir){if(S&rr)g=g.add(O);if(A&rr)Y=Y.add(O);O=O.double(),S>>=rr,A>>=rr}if(E)g=g.negate();if(U)Y=Y.negate();return Y=new Q(n.mul(Y.px,G.beta),Y.py,Y.pz),g.add(Y)}multiply(z){l(z);let q=z,G,E,{endo:S}=e;if(S){let{k1neg:U,k1:A,k2neg:g,k2:Y}=S.splitScalar(q),{p:O,f:B}=this.wNAF(A),{p:D,f:Z}=this.wNAF(Y);O=K.constTimeNegate(U,O),D=K.constTimeNegate(g,D),D=new Q(n.mul(D.px,S.beta),D.py,D.pz),G=O.add(D),E=B.add(Z)}else{let{p:U,f:A}=this.wNAF(q);G=U,E=A}return Q.normalizeZ([G,E])[0]}multiplyAndAddUnsafe(z,q,G){let E=Q.BASE,S=(A,g)=>g===Ir||g===rr||!A.equals(E)?A.multiplyUnsafe(g):A.multiply(g),U=S(this,q).add(S(z,G));return U.is0()?void 0:U}toAffine(z){let{px:q,py:G,pz:E}=this,S=this.is0();if(z==null)z=S?n.ONE:n.inv(E);let U=n.mul(q,z),A=n.mul(G,z),g=n.mul(E,z);if(S)return{x:n.ZERO,y:n.ZERO};if(!n.eql(g,n.ONE))throw Error("invZ was invalid");return{x:U,y:A}}isTorsionFree(){let{h:z,isTorsionFree:q}=e;if(z===rr)return!0;if(q)return q(Q,this);throw Error("isTorsionFree() has not been declared for the elliptic curve")}clearCofactor(){let{h:z,clearCofactor:q}=e;if(z===rr)return this;if(q)return q(Q,this);return this.multiplyUnsafe(e.h)}toRawBytes(z=!0){return this.assertValidity(),c(Q,this,z)}toHex(z=!0){return Yr(this.toRawBytes(z))}}Q.BASE=new Q(e.Gx,e.Gy,n.ONE),Q.ZERO=new Q(n.ZERO,n.ONE,n.ZERO);let M=e.nBitLength,K=je(Q,e.endo?Math.ceil(M/2):M);return{CURVE:e,ProjectivePoint:Q,normPrivateKeyToScalar:$,weierstrassEquation:w,isWithinCurveOrder:i}}function zf(r){let e=Rn(r);return Er(e,{hash:"hash",hmac:"function",randomBytes:"function"},{bits2int:"function",bits2int_modN:"function",lowS:"boolean"}),Object.freeze({lowS:!0,...e})}function ue(r){let e=zf(r),{Fp:n,n:c}=e,f=n.BYTES+1,w=2*n.BYTES+1;function i(T){return IrYr(fr(T,e.nByteLength));function z(T){let N=c>>rr;return T>N}function q(T){return z(T)?l(-T):T}let G=(T,N,d)=>m(T.slice(N,d));class E{constructor(T,N,d){this.r=T,this.s=N,this.recovery=d,this.assertValidity()}static fromCompact(T){let N=e.nByteLength;return T=b("compactSignature",T,N*2),new E(G(T,0,N),G(T,N,2*N))}static fromDER(T){let{r:N,s:d}=Ur.toSig(b("DER",T));return new E(N,d)}assertValidity(){if(!M(this.r))throw Error("r must be 0 < r < CURVE.n");if(!M(this.s))throw Error("s must be 0 < s < CURVE.n")}addRecoveryBit(T){return new E(this.r,this.s,T)}recoverPublicKey(T){let{r:N,s:d,recovery:W}=this,V=O(b("msgHash",T));if(W==null||![0,1,2,3].includes(W))throw Error("recovery id invalid");let H=W===2||W===3?N+e.n:N;if(H>=n.ORDER)throw Error("recovery id 2 or 3 invalid");let o=(W&1)===0?"02":"03",P=I.fromHex(o+K(H)),y=$(H),F=l(-V*y),x=l(d*y),h=I.BASE.multiplyAndAddUnsafe(P,F,x);if(!h)throw Error("point at infinify");return h.assertValidity(),h}hasHighS(){return z(this.s)}normalizeS(){return this.hasHighS()?new E(this.r,l(-this.s),this.recovery):this}toDERRawBytes(){return Ar(this.toDERHex())}toDERHex(){return Ur.hexFromSig({r:this.r,s:this.s})}toCompactRawBytes(){return Ar(this.toCompactHex())}toCompactHex(){return K(this.r)+K(this.s)}}let S={isValidPrivateKey(T){try{return J(T),!0}catch(N){return!1}},normPrivateKeyToScalar:J,randomPrivateKey:()=>{let T=Hn(e.n);return Ve(e.randomBytes(T),e.n)},precompute(T=8,N=I.BASE){return N._setWindowSize(T),N.multiply(BigInt(3)),N}};function U(T,N=!0){return I.fromPrivateKey(T).toRawBytes(N)}function A(T){let N=T instanceof Uint8Array,d=typeof T==="string",W=(N||d)&&T.length;if(N)return W===f||W===w;if(d)return W===2*f||W===2*w;if(T instanceof I)return!0;return!1}function g(T,N,d=!0){if(A(T))throw Error("first arg must be private key");if(!A(N))throw Error("second arg must be public key");return I.fromHex(N).multiply(J(T)).toRawBytes(d)}let Y=e.bits2int||function(T){let N=m(T),d=T.length*8-e.nBitLength;return d>0?N>>BigInt(d):N},O=e.bits2int_modN||function(T){return l(Y(T))},B=or(e.nBitLength);function D(T){if(typeof T!=="bigint")throw Error("bigint expected");if(!(Ir<=T&&T(Xr in d)))throw Error("sign() legacy options not supported");let{hash:W,randomBytes:V}=e,{lowS:H,prehash:o,extraEntropy:P}=d;if(H==null)H=!0;if(T=b("msgHash",T),o)T=b("prehashed msgHash",W(T));let y=O(T),F=J(N),x=[D(F),D(y)];if(P!=null){let Xr=P===!0?V(n.BYTES):P;x.push(b("extraEntropy",Xr))}let h=Jr(...x),er=y;function $r(Xr){let Zr=Y(Xr);if(!M(Zr))return;let Te=$(Zr),lr=I.BASE.multiply(Zr).toAffine(),Dr=l(lr.x);if(Dr===Ir)return;let _r=l(Te*l(er+Dr*F));if(_r===Ir)return;let Me=(lr.x===Dr?0:2)|Number(lr.y&rr),Ge=_r;if(H&&z(_r))Ge=q(_r),Me^=1;return new E(Dr,Ge,Me)}return{seed:h,k2sig:$r}}let u={lowS:e.lowS,prehash:!1},R={lowS:e.lowS,prehash:!1};function v(T,N,d=u){let{seed:W,k2sig:V}=Z(T,N,d),H=e;return On(H.hash.outputLen,H.nByteLength,H.hmac)(W,V)}I.BASE._setWindowSize(8);function j(T,N,d,W=R){let V=T;if(N=b("msgHash",N),d=b("publicKey",d),"strict"in W)throw Error("options.strict was renamed to lowS");let{lowS:H,prehash:o}=W,P=void 0,y;try{if(typeof V==="string"||V instanceof Uint8Array)try{P=E.fromDER(V)}catch(lr){if(!(lr instanceof Ur.Err))throw lr;P=E.fromCompact(V)}else if(typeof V==="object"&&typeof V.r==="bigint"&&typeof V.s==="bigint"){let{r:lr,s:Dr}=V;P=new E(lr,Dr)}else throw Error("PARSE");y=I.fromHex(d)}catch(lr){if(lr.message==="PARSE")throw Error("signature must be Signature instance, Uint8Array or hex string");return!1}if(H&&P.hasHighS())return!1;if(o)N=e.hash(N);let{r:F,s:x}=P,h=O(N),er=$(x),$r=l(h*er),Xr=l(F*er),Zr=I.BASE.multiplyAndAddUnsafe(y,$r,Xr)?.toAffine();if(!Zr)return!1;return l(Zr.x)===F}return{CURVE:e,getPublicKey:U,getSharedSecret:g,sign:v,verify:j,ProjectivePoint:I,Signature:E,utils:S}}class Pn extends vr{constructor(r,e){super();this.finished=!1,this.destroyed=!1,Ke(r);let n=Br(e);if(this.iHash=r.create(),typeof this.iHash.update!=="function")throw Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen,this.outputLen=this.iHash.outputLen;let c=this.blockLen,f=new Uint8Array(c);f.set(n.length>c?r.create().update(n).digest():n);for(let w=0;wnew Pn(r,e).update(n).digest();Bn.create=(r,e)=>new Pn(r,e);/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function Qf(r){return{hash:r,hmac:(e,...n)=>Bn(r,e,Se(...n)),randomBytes:hr}}function Re(r,e){let n=(c)=>ue({...r,...Qf(c)});return Object.freeze({...n(e),create:n})}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var wn=BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),en=BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),ve=BigInt(1),cn=BigInt(2),Pe=(r,e)=>(r+e/cn)/e;function oe(r){let e=wn,n=BigInt(3),c=BigInt(6),f=BigInt(11),w=BigInt(22),i=BigInt(23),l=BigInt(44),$=BigInt(88),I=r*r*r%e,J=I*I*r%e,Q=a(J,n,e)*J%e,M=a(Q,n,e)*J%e,K=a(M,cn,e)*I%e,z=a(K,f,e)*K%e,q=a(z,w,e)*z%e,G=a(q,l,e)*q%e,E=a(G,$,e)*G%e,S=a(E,l,e)*q%e,U=a(S,n,e)*J%e,A=a(U,i,e)*z%e,g=a(A,c,e)*I%e,Y=a(g,cn,e);if(!on.eql(on.sqr(Y),r))throw Error("Cannot find square root");return Y}var on=Ze(wn,void 0,void 0,{sqrt:oe}),jr=Re({a:BigInt(0),b:BigInt(7),Fp:on,n:en,Gx:BigInt("55066263022277343669578718895168534326250603453777594175500187360389116729240"),Gy:BigInt("32670510020758816978083085130507043184471273380659243275938904335757337482424"),h:BigInt(1),lowS:!0,endo:{beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),splitScalar:(r)=>{let e=en,n=BigInt("0x3086d221a7d46bcde86c90e49284eb15"),c=-ve*BigInt("0xe4437ed6010e88286f547fa90abfe4c3"),f=BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),w=n,i=BigInt("0x100000000000000000000000000000000"),l=Pe(w*r,e),$=Pe(-c*r,e),I=k(r-l*n-$*f,e),J=k(-l*c-$*w,e),Q=I>i,M=J>i;if(Q)I=e-I;if(M)J=e-J;if(I>i||J>i)throw Error("splitScalar: Endomorphism failed, k="+r);return{k1neg:Q,k1:I,k2neg:M,k2:J}}}},sr),$n=BigInt(0),Ce=(r)=>typeof r==="bigint"&&$ntypeof r==="bigint"&&$nf.charCodeAt(0)));n=Jr(c,c),Be[r]=n}return sr(Jr(n,...e))}var yn=(r)=>r.toRawBytes(!0).slice(1),Cn=(r)=>fr(r,32),vn=(r)=>k(r,wn),Cr=(r)=>k(r,en),bn=jr.ProjectivePoint,Tf=(r,e,n)=>bn.BASE.multiplyAndAddUnsafe(r,e,n);function Ln(r){let e=jr.utils.normPrivateKeyToScalar(r),n=bn.fromPrivateKey(e);return{scalar:n.hasEvenY()?e:Cr(-e),bytes:yn(n)}}function Le(r){if(!Ce(r))throw Error("bad x: need 0 < x < p");let e=vn(r*r),n=vn(e*r+BigInt(7)),c=oe(n);if(c%cn!==$n)c=vn(-c);let f=new bn(r,c,ve);return f.assertValidity(),f}function ye(...r){return Cr(m(fn("BIP0340/challenge",...r)))}function Mf(r){return Ln(r).bytes}function Gf(r,e,n=hr(32)){let c=b("message",r),{bytes:f,scalar:w}=Ln(e),i=b("auxRand",n,32),l=Cn(w^m(fn("BIP0340/aux",i))),$=fn("BIP0340/nonce",l,f,c),I=Cr(m($));if(I===$n)throw Error("sign failed: k is zero");let{bytes:J,scalar:Q}=Ln(I),M=ye(J,f,c),K=new Uint8Array(64);if(K.set(J,0),K.set(Cn(Cr(Q+M*w)),32),!be(K,c,f))throw Error("sign: Invalid signature produced");return K}function be(r,e,n){let c=b("signature",r,64),f=b("message",e),w=b("publicKey",n,32);try{let i=Le(m(w)),l=m(c.subarray(0,32));if(!Ce(l))return!1;let $=m(c.subarray(32,64));if(!qf($))return!1;let I=ye(Cn(l),yn(i),f),J=Tf(i,$,Cr(-I));if(!J||!J.hasEvenY()||J.toAffine().x!==l)return!1;return!0}catch(i){return!1}}var nr=(()=>({getPublicKey:Mf,sign:Gf,verify:be,utils:{randomPrivateKey:jr.utils.randomPrivateKey,lift_x:Le,pointToBytes:yn,numberToBytesBE:fr,bytesToNumberBE:m,taggedHash:fn,mod:k}}))();var ln=typeof globalThis==="object"&&"crypto"in globalThis?globalThis.crypto:void 0;/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */var kn=(r)=>r instanceof Uint8Array;var Jn=(r)=>new DataView(r.buffer,r.byteOffset,r.byteLength),wr=(r,e)=>r<<32-e|r>>>e,Ef=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;if(!Ef)throw Error("Non little-endian hardware is not supported");var Kf=Array.from({length:256},(r,e)=>e.toString(16).padStart(2,"0"));function L(r){if(!kn(r))throw Error("Uint8Array expected");let e="";for(let n=0;nc+f.length,0)),n=0;return r.forEach((c)=>{if(!kn(c))throw Error("Uint8Array expected");e.set(c,n),n+=c.length}),e}class Lr{clone(){return this._cloneInto()}}function _n(r){let e=(c)=>r().update(Kr(c)).digest(),n=r();return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=()=>r(),e}function ke(r=32){if(ln&&typeof ln.getRandomValues==="function")return ln.getRandomValues(new Uint8Array(r));throw Error("crypto.getRandomValues must be defined")}function mn(r){if(!Number.isSafeInteger(r)||r<0)throw Error(`Wrong positive integer: ${r}`)}function Nf(r){if(typeof r!=="boolean")throw Error(`Expected boolean, not ${r}`)}function _e(r,...e){if(!(r instanceof Uint8Array))throw Error("Expected Uint8Array");if(e.length>0&&!e.includes(r.length))throw Error(`Expected Uint8Array of length ${e}, not of length=${r.length}`)}function Sf(r){if(typeof r!=="function"||typeof r.create!=="function")throw Error("Hash should be wrapped by utils.wrapConstructor");mn(r.outputLen),mn(r.blockLen)}function Xf(r,e=!0){if(r.destroyed)throw Error("Hash instance has been destroyed");if(e&&r.finished)throw Error("Hash#digest() has already been called")}function Yf(r,e){_e(r);let n=e.outputLen;if(r.length>f&w),l=Number(n&w),$=c?4:0,I=c?0:4;r.setUint32(e+$,i,c),r.setUint32(e+I,l,c)}class tn extends Lr{constructor(r,e,n,c){super();this.blockLen=r,this.outputLen=e,this.padOffset=n,this.isLE=c,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(r),this.view=Jn(this.buffer)}update(r){p.exists(this);let{view:e,buffer:n,blockLen:c}=this;r=Kr(r);let f=r.length;for(let w=0;wc-w)this.process(n,0),w=0;for(let J=w;JI.length)throw Error("_sha2: outputLen bigger than state");for(let J=0;J<$;J++)i.setUint32(4*J,I[J],f)}digest(){let{buffer:r,outputLen:e}=this;this.digestInto(r);let n=r.slice(0,e);return this.destroy(),n}_cloneInto(r){r||(r=new this.constructor),r.set(...this.get());let{blockLen:e,buffer:n,length:c,finished:f,destroyed:w,pos:i}=this;if(r.length=c,r.pos=i,r.finished=f,r.destroyed=w,c%e)r.buffer.set(n);return r}}var Uf=(r,e,n)=>r&e^~r&n,gf=(r,e,n)=>r&e^r&n^e&n,Of=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),Wr=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),Nr=new Uint32Array(64);class Fn extends tn{constructor(){super(64,32,8,!1);this.A=Wr[0]|0,this.B=Wr[1]|0,this.C=Wr[2]|0,this.D=Wr[3]|0,this.E=Wr[4]|0,this.F=Wr[5]|0,this.G=Wr[6]|0,this.H=Wr[7]|0}get(){let{A:r,B:e,C:n,D:c,E:f,F:w,G:i,H:l}=this;return[r,e,n,c,f,w,i,l]}set(r,e,n,c,f,w,i,l){this.A=r|0,this.B=e|0,this.C=n|0,this.D=c|0,this.E=f|0,this.F=w|0,this.G=i|0,this.H=l|0}process(r,e){for(let J=0;J<16;J++,e+=4)Nr[J]=r.getUint32(e,!1);for(let J=16;J<64;J++){let Q=Nr[J-15],M=Nr[J-2],K=wr(Q,7)^wr(Q,18)^Q>>>3,z=wr(M,17)^wr(M,19)^M>>>10;Nr[J]=z+Nr[J-7]+K+Nr[J-16]|0}let{A:n,B:c,C:f,D:w,E:i,F:l,G:$,H:I}=this;for(let J=0;J<64;J++){let Q=wr(i,6)^wr(i,11)^wr(i,25),M=I+Q+Uf(i,l,$)+Of[J]+Nr[J]|0,z=(wr(n,2)^wr(n,13)^wr(n,22))+gf(n,c,f)|0;I=$,$=l,l=i,i=w+M|0,w=f,f=c,c=n,n=M+z|0}n=n+this.A|0,c=c+this.B|0,f=f+this.C|0,w=w+this.D|0,i=i+this.E|0,l=l+this.F|0,$=$+this.G|0,I=I+this.H|0,this.set(n,c,f,w,i,l,$,I)}roundClean(){Nr.fill(0)}destroy(){this.set(0,0,0,0,0,0,0,0),this.buffer.fill(0)}}class me extends Fn{constructor(){super();this.A=-1056596264,this.B=914150663,this.C=812702999,this.D=-150054599,this.E=-4191439,this.F=1750603025,this.G=1694076839,this.H=-1090891868,this.outputLen=28}}var ir=_n(()=>new Fn),Vw=_n(()=>new me);var Or=Symbol("verified"),Zf=(r)=>r instanceof Object;function Df(r){if(!Zf(r))return!1;if(typeof r.kind!=="number")return!1;if(typeof r.content!=="string")return!1;if(typeof r.created_at!=="number")return!1;if(typeof r.pubkey!=="string")return!1;if(!r.pubkey.match(/^[a-f0-9]{64}$/))return!1;if(!Array.isArray(r.tags))return!1;for(let e=0;ew===n.slice(1)&&f.indexOf(i)!==-1))return!1}if(r.since&&e.created_atr.until)return!1;return!0}function of(r,e){for(let n=0;n{let e=new MessageChannel,n=()=>{e.port1.removeEventListener("message",n),r()};e.port1.addEventListener("message",n),e.port2.postMessage(0),e.port1.start()})}var kf=(r)=>{return r[Or]=!0,!0},te=class{url;_connected=!1;onclose=null;onnotice=(r)=>console.debug(`NOTICE from ${this.url}: ${r}`);_onauth=null;baseEoseTimeout=4400;connectionTimeout=4400;publishTimeout=4400;openSubs=new Map;connectionTimeoutHandle;connectionPromise;openCountRequests=new Map;openEventPublishes=new Map;ws;incomingMessageQueue=new Hf;queueRunning=!1;challenge;serial=0;verifyEvent;_WebSocket;constructor(r,e){this.url=yr(r),this.verifyEvent=e.verifyEvent,this._WebSocket=e.websocketImplementation||WebSocket}static async connect(r,e){let n=new te(r,e);return await n.connect(),n}closeAllSubscriptions(r){for(let[e,n]of this.openSubs)n.close(r);this.openSubs.clear();for(let[e,n]of this.openEventPublishes)n.reject(Error(r));this.openEventPublishes.clear();for(let[e,n]of this.openCountRequests)n.reject(Error(r));this.openCountRequests.clear()}get connected(){return this._connected}async connect(){if(this.connectionPromise)return this.connectionPromise;return this.challenge=void 0,this.connectionPromise=new Promise((r,e)=>{this.connectionTimeoutHandle=setTimeout(()=>{e("connection timed out"),this.connectionPromise=void 0,this.onclose?.(),this.closeAllSubscriptions("relay connection timed out")},this.connectionTimeout);try{this.ws=new this._WebSocket(this.url)}catch(n){e(n);return}this.ws.onopen=()=>{clearTimeout(this.connectionTimeoutHandle),this._connected=!0,r()},this.ws.onerror=(n)=>{if(e(n.message||"websocket error"),this._connected)this._connected=!1,this.connectionPromise=void 0,this.onclose?.(),this.closeAllSubscriptions("relay connection errored")},this.ws.onclose=async()=>{if(this._connected)this._connected=!1,this.connectionPromise=void 0,this.onclose?.(),this.closeAllSubscriptions("relay connection closed")},this.ws.onmessage=this._onmessage.bind(this)}),this.connectionPromise}async runQueue(){this.queueRunning=!0;while(!0){if(this.handleNext()===!1)break;await bf()}this.queueRunning=!1}handleNext(){let r=this.incomingMessageQueue.dequeue();if(!r)return!1;let e=Lf(r);if(e){let n=this.openSubs.get(e);if(!n)return;let c=Cf(r,"id"),f=n.alreadyHaveEvent?.(c);if(n.receivedEvent?.(this,c),f)return}try{let n=JSON.parse(r);switch(n[0]){case"EVENT":{let c=this.openSubs.get(n[1]),f=n[2];if(this.verifyEvent(f)&&of(c.filters,f))c.onevent(f);return}case"COUNT":{let c=n[1],f=n[2],w=this.openCountRequests.get(c);if(w)w.resolve(f.count),this.openCountRequests.delete(c);return}case"EOSE":{let c=this.openSubs.get(n[1]);if(!c)return;c.receivedEose();return}case"OK":{let c=n[1],f=n[2],w=n[3],i=this.openEventPublishes.get(c);if(i){if(f)i.resolve(w);else i.reject(Error(w));this.openEventPublishes.delete(c)}return}case"CLOSED":{let c=n[1],f=this.openSubs.get(c);if(!f)return;f.closed=!0,f.close(n[2]);return}case"NOTICE":this.onnotice(n[1]);return;case"AUTH":{this.challenge=n[1],this._onauth?.(n[1]);return}}}catch(n){return}}async send(r){if(!this.connectionPromise)throw Error("sending on closed connection");this.connectionPromise.then(()=>{this.ws?.send(r)})}async auth(r){if(!this.challenge)throw Error("can't perform auth, no challenge was received");let e=await r(yf(this.url,this.challenge)),n=new Promise((c,f)=>{this.openEventPublishes.set(e.id,{resolve:c,reject:f})});return this.send('["AUTH",'+JSON.stringify(e)+"]"),n}async publish(r){let e=new Promise((n,c)=>{this.openEventPublishes.set(r.id,{resolve:n,reject:c})});return this.send('["EVENT",'+JSON.stringify(r)+"]"),setTimeout(()=>{let n=this.openEventPublishes.get(r.id);if(n)n.reject(Error("publish timed out")),this.openEventPublishes.delete(r.id)},this.publishTimeout),e}async count(r,e){this.serial++;let n=e?.id||"count:"+this.serial,c=new Promise((f,w)=>{this.openCountRequests.set(n,{resolve:f,reject:w})});return this.send('["COUNT","'+n+'",'+JSON.stringify(r).substring(1)),c}subscribe(r,e){let n=this.prepareSubscription(r,e);return n.fire(),n}prepareSubscription(r,e){this.serial++;let n=e.id||"sub:"+this.serial,c=new _f(this,n,r,e);return this.openSubs.set(n,c),c}close(){this.closeAllSubscriptions("relay connection closed by us"),this._connected=!1,this.ws?.close()}_onmessage(r){if(this.incomingMessageQueue.enqueue(r.data),!this.queueRunning)this.runQueue()}},_f=class{relay;id;closed=!1;eosed=!1;filters;alreadyHaveEvent;receivedEvent;onevent;oneose;onclose;eoseTimeout;eoseTimeoutHandle;constructor(r,e,n,c){this.relay=r,this.filters=n,this.id=e,this.alreadyHaveEvent=c.alreadyHaveEvent,this.receivedEvent=c.receivedEvent,this.eoseTimeout=c.eoseTimeout||r.baseEoseTimeout,this.oneose=c.oneose,this.onclose=c.onclose,this.onevent=c.onevent||((f)=>{console.warn(`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,f)})}fire(){this.relay.send('["REQ","'+this.id+'",'+JSON.stringify(this.filters).substring(1)),this.eoseTimeoutHandle=setTimeout(this.receivedEose.bind(this),this.eoseTimeout)}receivedEose(){if(this.eosed)return;clearTimeout(this.eoseTimeoutHandle),this.eosed=!0,this.oneose?.()}close(r="closed by caller"){if(!this.closed&&this.relay.connected)this.relay.send('["CLOSE",'+JSON.stringify(this.id)+"]"),this.closed=!0;this.relay.openSubs.delete(this.id),this.onclose?.(r)}},mf=class{relays=new Map;seenOn=new Map;trackRelays=!1;verifyEvent;trustedRelayURLs=new Set;_WebSocket;constructor(r){this.verifyEvent=r.verifyEvent,this._WebSocket=r.websocketImplementation}async ensureRelay(r,e){r=yr(r);let n=this.relays.get(r);if(!n){if(n=new te(r,{verifyEvent:this.trustedRelayURLs.has(r)?kf:this.verifyEvent,websocketImplementation:this._WebSocket}),e?.connectionTimeout)n.connectionTimeout=e.connectionTimeout;this.relays.set(r,n)}return await n.connect(),n}close(r){r.map(yr).forEach((e)=>{this.relays.get(e)?.close()})}subscribeMany(r,e,n){return this.subscribeManyMap(Object.fromEntries(r.map((c)=>[c,e])),n)}subscribeManyMap(r,e){if(this.trackRelays)e.receivedEvent=(Q,M)=>{let K=this.seenOn.get(M);if(!K)K=new Set,this.seenOn.set(M,K);K.add(Q)};let n=new Set,c=[],f=Object.keys(r).length,w=[],i=(Q)=>{if(w[Q]=!0,w.filter((M)=>M).length===f)e.oneose?.(),i=()=>{}},l=[],$=(Q,M)=>{if(i(Q),l[Q]=M,l.filter((K)=>K).length===f)e.onclose?.(l),$=()=>{}},I=(Q)=>{if(e.alreadyHaveEvent?.(Q))return!0;let M=n.has(Q);return n.add(Q),M},J=Promise.all(Object.entries(r).map(async(Q,M,K)=>{if(K.indexOf(Q)!==M){$(M,"duplicate url");return}let[z,q]=Q;z=yr(z);let G;try{G=await this.ensureRelay(z,{connectionTimeout:e.maxWait?Math.max(e.maxWait*0.8,e.maxWait-1000):void 0})}catch(S){$(M,S?.message||String(S));return}let E=G.subscribe(q,{...e,oneose:()=>i(M),onclose:(S)=>$(M,S),alreadyHaveEvent:I,eoseTimeout:e.maxWait});c.push(E)}));return{async close(){await J,c.forEach((Q)=>{Q.close()})}}}subscribeManyEose(r,e,n){let c=this.subscribeMany(r,e,{...n,oneose(){c.close()}});return c}async querySync(r,e,n){return new Promise(async(c)=>{let f=[];this.subscribeManyEose(r,[e],{...n,onevent(w){f.push(w)},onclose(w){c(f)}})})}async get(r,e,n){e.limit=1;let c=await this.querySync(r,e,n);return c.sort((f,w)=>w.created_at-f.created_at),c[0]||null}publish(r,e){return r.map(yr).map(async(n,c,f)=>{if(f.indexOf(n)!==c)return Promise.reject("duplicate url");let w=await this.ensureRelay(n);return w.publish(e).then((i)=>{if(this.trackRelays){let l=this.seenOn.get(e.id);if(!l)l=new Set,this.seenOn.set(e.id,l);l.add(w)}return i})})}listConnectionStatus(){let r=new Map;return this.relays.forEach((e,n)=>r.set(n,e.connected)),r}destroy(){this.relays.forEach((r)=>r.close()),this.relays=new Map}},Fe;try{Fe=WebSocket}catch{}var tf=class extends mf{constructor(){super({verifyEvent:Pf,websocketImplementation:Fe})}};var ur=Symbol("verified"),Ff=(r)=>r instanceof Object;function hf(r){if(!Ff(r))return!1;if(typeof r.kind!=="number")return!1;if(typeof r.content!=="string")return!1;if(typeof r.created_at!=="number")return!1;if(typeof r.pubkey!=="string")return!1;if(!r.pubkey.match(/^[a-f0-9]{64}$/))return!1;if(!Array.isArray(r.tags))return!1;for(let e=0;eZ0,getConversationKey:()=>Sc,encrypt:()=>Ac,decrypt:()=>dc});function Qn(r){if(!Number.isSafeInteger(r)||r<0)throw Error(`positive integer expected, not ${r}`)}function xn(r){if(typeof r!=="boolean")throw Error(`boolean expected, not ${r}`)}function an(r){return r instanceof Uint8Array||r!=null&&typeof r==="object"&&r.constructor.name==="Uint8Array"}function s(r,...e){if(!an(r))throw Error("Uint8Array expected");if(e.length>0&&!e.includes(r.length))throw Error(`Uint8Array expected of length ${e}, not of length=${r.length}`)}function pn(r,e=!0){if(r.destroyed)throw Error("Hash instance has been destroyed");if(e&&r.finished)throw Error("Hash#digest() has already been called")}function he(r,e){s(r);let n=e.outputLen;if(r.lengthnew Uint32Array(r.buffer,r.byteOffset,Math.floor(r.byteLength/4)),se=(r)=>new DataView(r.buffer,r.byteOffset,r.byteLength),c0=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;if(!c0)throw Error("Non little-endian hardware is not supported");function f0(r){if(typeof r!=="string")throw Error(`string expected, got ${typeof r}`);return new Uint8Array(new TextEncoder().encode(r))}function qn(r){if(typeof r==="string")r=f0(r);else if(an(r))r=r.slice();else throw Error(`Uint8Array expected, got ${typeof r}`);return r}function xe(r,e){if(e==null||typeof e!=="object")throw Error("options must be defined");return Object.assign(r,e)}function Tn(r,e){if(r.length!==e.length)return!1;let n=0;for(let c=0;c{return Object.assign(e,r),e};function ne(r,e,n,c){if(typeof r.setBigUint64==="function")return r.setBigUint64(e,n,c);let f=BigInt(32),w=BigInt(4294967295),i=Number(n>>f&w),l=Number(n&w),$=c?4:0,I=c?0:4;r.setUint32(e+$,i,c),r.setUint32(e+I,l,c)}var t=(r,e)=>r[e++]&255|(r[e++]&255)<<8;class ae{constructor(r){this.blockLen=16,this.outputLen=16,this.buffer=new Uint8Array(16),this.r=new Uint16Array(10),this.h=new Uint16Array(10),this.pad=new Uint16Array(8),this.pos=0,this.finished=!1,r=qn(r),s(r,32);let e=t(r,0),n=t(r,2),c=t(r,4),f=t(r,6),w=t(r,8),i=t(r,10),l=t(r,12),$=t(r,14);this.r[0]=e&8191,this.r[1]=(e>>>13|n<<3)&8191,this.r[2]=(n>>>10|c<<6)&7939,this.r[3]=(c>>>7|f<<9)&8191,this.r[4]=(f>>>4|w<<12)&255,this.r[5]=w>>>1&8190,this.r[6]=(w>>>14|i<<2)&8191,this.r[7]=(i>>>11|l<<5)&8065,this.r[8]=(l>>>8|$<<8)&8191,this.r[9]=$>>>5&127;for(let I=0;I<8;I++)this.pad[I]=t(r,16+2*I)}process(r,e,n=!1){let c=n?0:2048,{h:f,r:w}=this,i=w[0],l=w[1],$=w[2],I=w[3],J=w[4],Q=w[5],M=w[6],K=w[7],z=w[8],q=w[9],G=t(r,e+0),E=t(r,e+2),S=t(r,e+4),U=t(r,e+6),A=t(r,e+8),g=t(r,e+10),Y=t(r,e+12),O=t(r,e+14),B=f[0]+(G&8191),D=f[1]+((G>>>13|E<<3)&8191),Z=f[2]+((E>>>10|S<<6)&8191),u=f[3]+((S>>>7|U<<9)&8191),R=f[4]+((U>>>4|A<<12)&8191),v=f[5]+(A>>>1&8191),j=f[6]+((A>>>14|g<<2)&8191),T=f[7]+((g>>>11|Y<<5)&8191),N=f[8]+((Y>>>8|O<<8)&8191),d=f[9]+(O>>>5|c),W=0,V=W+B*i+D*(5*q)+Z*(5*z)+u*(5*K)+R*(5*M);W=V>>>13,V&=8191,V+=v*(5*Q)+j*(5*J)+T*(5*I)+N*(5*$)+d*(5*l),W+=V>>>13,V&=8191;let H=W+B*l+D*i+Z*(5*q)+u*(5*z)+R*(5*K);W=H>>>13,H&=8191,H+=v*(5*M)+j*(5*Q)+T*(5*J)+N*(5*I)+d*(5*$),W+=H>>>13,H&=8191;let o=W+B*$+D*l+Z*i+u*(5*q)+R*(5*z);W=o>>>13,o&=8191,o+=v*(5*K)+j*(5*M)+T*(5*Q)+N*(5*J)+d*(5*I),W+=o>>>13,o&=8191;let P=W+B*I+D*$+Z*l+u*i+R*(5*q);W=P>>>13,P&=8191,P+=v*(5*z)+j*(5*K)+T*(5*M)+N*(5*Q)+d*(5*J),W+=P>>>13,P&=8191;let y=W+B*J+D*I+Z*$+u*l+R*i;W=y>>>13,y&=8191,y+=v*(5*q)+j*(5*z)+T*(5*K)+N*(5*M)+d*(5*Q),W+=y>>>13,y&=8191;let F=W+B*Q+D*J+Z*I+u*$+R*l;W=F>>>13,F&=8191,F+=v*i+j*(5*q)+T*(5*z)+N*(5*K)+d*(5*M),W+=F>>>13,F&=8191;let x=W+B*M+D*Q+Z*J+u*I+R*$;W=x>>>13,x&=8191,x+=v*l+j*i+T*(5*q)+N*(5*z)+d*(5*K),W+=x>>>13,x&=8191;let h=W+B*K+D*M+Z*Q+u*J+R*I;W=h>>>13,h&=8191,h+=v*$+j*l+T*i+N*(5*q)+d*(5*z),W+=h>>>13,h&=8191;let er=W+B*z+D*K+Z*M+u*Q+R*J;W=er>>>13,er&=8191,er+=v*I+j*$+T*l+N*i+d*(5*q),W+=er>>>13,er&=8191;let $r=W+B*q+D*z+Z*K+u*M+R*Q;W=$r>>>13,$r&=8191,$r+=v*J+j*I+T*$+N*l+d*i,W+=$r>>>13,$r&=8191,W=(W<<2)+W|0,W=W+V|0,V=W&8191,W=W>>>13,H+=W,f[0]=V,f[1]=H,f[2]=o,f[3]=P,f[4]=y,f[5]=F,f[6]=x,f[7]=h,f[8]=er,f[9]=$r}finalize(){let{h:r,pad:e}=this,n=new Uint16Array(10),c=r[1]>>>13;r[1]&=8191;for(let i=2;i<10;i++)r[i]+=c,c=r[i]>>>13,r[i]&=8191;r[0]+=c*5,c=r[0]>>>13,r[0]&=8191,r[1]+=c,c=r[1]>>>13,r[1]&=8191,r[2]+=c,n[0]=r[0]+5,c=n[0]>>>13,n[0]&=8191;for(let i=1;i<10;i++)n[i]=r[i]+c,c=n[i]>>>13,n[i]&=8191;n[9]-=8192;let f=(c^1)-1;for(let i=0;i<10;i++)n[i]&=f;f=~f;for(let i=0;i<10;i++)r[i]=r[i]&f|n[i];r[0]=(r[0]|r[1]<<13)&65535,r[1]=(r[1]>>>3|r[2]<<10)&65535,r[2]=(r[2]>>>6|r[3]<<7)&65535,r[3]=(r[3]>>>9|r[4]<<4)&65535,r[4]=(r[4]>>>12|r[5]<<1|r[6]<<14)&65535,r[5]=(r[6]>>>2|r[7]<<11)&65535,r[6]=(r[7]>>>5|r[8]<<8)&65535,r[7]=(r[8]>>>8|r[9]<<5)&65535;let w=r[0]+e[0];r[0]=w&65535;for(let i=1;i<8;i++)w=(r[i]+e[i]|0)+(w>>>16)|0,r[i]=w&65535}update(r){pn(this);let{buffer:e,blockLen:n}=this;r=qn(r);let c=r.length;for(let f=0;f>>0,r[f++]=n[w]>>>8;return r}digest(){let{buffer:r,outputLen:e}=this;this.digestInto(r);let n=r.slice(0,e);return this.destroy(),n}}function w0(r){let e=(c,f)=>r(f).update(qn(c)).digest(),n=r(new Uint8Array(32));return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=(c)=>r(c),e}var pe=w0((r)=>new ae(r));var nc=(r)=>Uint8Array.from(r.split("").map((e)=>e.charCodeAt(0))),i0=nc("expand 16-byte k"),$0=nc("expand 32-byte k"),l0=zr(i0),ec=zr($0),li=ec.slice();function X(r,e){return r<>>32-e}function ee(r){return r.byteOffset%4===0}var Mn=64,J0=16,cc=4294967295,rc=new Uint32Array;function I0(r,e,n,c,f,w,i,l){let $=f.length,I=new Uint8Array(Mn),J=zr(I),Q=ee(f)&&ee(w),M=Q?zr(f):rc,K=Q?zr(w):rc;for(let z=0;z<$;i++){if(r(e,n,c,J,i,l),i>=cc)throw Error("arx: counter overflow");let q=Math.min(Mn,$-z);if(Q&&q===Mn){let G=z/4;if(z%4!==0)throw Error("arx: invalid block position");for(let E=0,S;E{s(l),s($),s(I);let M=I.length;if(!J)J=new Uint8Array(M);if(s(J),Qn(Q),Q<0||Q>=cc)throw Error("arx: counter overflow");if(J.length0)K.pop().fill(0);return J}}function ic(r,e,n,c,f,w=20){let i=r[0],l=r[1],$=r[2],I=r[3],J=e[0],Q=e[1],M=e[2],K=e[3],z=e[4],q=e[5],G=e[6],E=e[7],S=f,U=n[0],A=n[1],g=n[2],Y=i,O=l,B=$,D=I,Z=J,u=Q,R=M,v=K,j=z,T=q,N=G,d=E,W=S,V=U,H=A,o=g;for(let y=0;y{r.update(e);let n=e.length%16;if(n)r.update(q0.subarray(n))},T0=new Uint8Array(32);function wc(r,e,n,c,f){let w=r(e,n,T0),i=pe.create(w);if(f)fc(i,f);fc(i,c);let l=new Uint8Array(16),$=se(l);ne($,0,BigInt(f?f.length:0),!0),ne($,8,BigInt(c.length),!0),i.update(l);let I=i.digest();return w.fill(0),I}var $c=(r)=>(e,n,c)=>{return s(e,32),s(n),{encrypt:(w,i)=>{let l=w.length,$=l+16;if(i)s(i,$);else i=new Uint8Array($);r(e,n,w,i,1);let I=wc(r,e,n,i.subarray(0,-16),c);return i.set(I,l),i},decrypt:(w,i)=>{let l=w.length,$=l-16;if(l<16)throw Error("encrypted data must be at least 16 bytes");if(i)s(i,$);else i=new Uint8Array($);let I=w.subarray(0,-16),J=w.subarray(-16),Q=wc(r,e,n,I,c);if(!Tn(J,Q))throw Error("invalid tag");return r(e,n,I,i,1),i}}},Ti=re({blockSize:64,nonceLength:12,tagLength:16},$c(Gn)),Mi=re({blockSize:64,nonceLength:24,tagLength:16},$c(Q0));class fe extends Lr{constructor(r,e){super();this.finished=!1,this.destroyed=!1,p.hash(r);let n=Kr(e);if(this.iHash=r.create(),typeof this.iHash.update!=="function")throw Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen,this.outputLen=this.iHash.outputLen;let c=this.blockLen,f=new Uint8Array(c);f.set(n.length>c?r.create().update(n).digest():n);for(let w=0;wnew fe(r,e).update(n).digest();Rr.create=(r,e)=>new fe(r,e);function Jc(r,e,n){if(p.hash(r),n===void 0)n=new Uint8Array(r.outputLen);return Rr(r,Kr(n),Kr(e))}var we=new Uint8Array([0]),lc=new Uint8Array;function Ic(r,e,n,c=32){if(p.hash(r),p.number(c),c>255*r.outputLen)throw Error("Length should be <= 255*HashLen");let f=Math.ceil(c/r.outputLen);if(n===void 0)n=lc;let w=new Uint8Array(f*r.outputLen),i=Rr.create(r,e),l=i._cloneInto(),$=new Uint8Array(i.outputLen);for(let I=0;I(i)=>f(w(i)),n=Array.from(r).reverse().reduce((f,w)=>f?e(f,w.encode):w.encode,void 0),c=r.reduce((f,w)=>f?e(f,w.decode):w.decode,void 0);return{encode:n,decode:c}}function qr(r){return{encode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="number")throw Error("alphabet.encode input should be an array of numbers");return e.map((n)=>{if(Pr(n),n<0||n>=r.length)throw Error(`Digit index outside alphabet: ${n} (alphabet: ${r.length})`);return r[n]})},decode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="string")throw Error("alphabet.decode input should be array of strings");return e.map((n)=>{if(typeof n!=="string")throw Error(`alphabet.decode: not string element=${n}`);let c=r.indexOf(n);if(c===-1)throw Error(`Unknown letter: "${n}". Allowed: ${r}`);return c})}}}function Tr(r=""){if(typeof r!=="string")throw Error("join separator should be string");return{encode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="string")throw Error("join.encode input should be array of strings");for(let n of e)if(typeof n!=="string")throw Error(`join.encode: non-string input=${n}`);return e.join(r)},decode:(e)=>{if(typeof e!=="string")throw Error("join.decode input should be string");return e.split(r)}}}function Kn(r,e="="){if(Pr(r),typeof e!=="string")throw Error("padding chr should be string");return{encode(n){if(!Array.isArray(n)||n.length&&typeof n[0]!=="string")throw Error("padding.encode input should be array of strings");for(let c of n)if(typeof c!=="string")throw Error(`padding.encode: non-string input=${c}`);while(n.length*r%8)n.push(e);return n},decode(n){if(!Array.isArray(n)||n.length&&typeof n[0]!=="string")throw Error("padding.encode input should be array of strings");for(let f of n)if(typeof f!=="string")throw Error(`padding.decode: non-string input=${f}`);let c=n.length;if(c*r%8)throw Error("Invalid padding: string should have whole number of bytes");for(;c>0&&n[c-1]===e;c--)if(!((c-1)*r%8))throw Error("Invalid padding: string has too much padding");return n.slice(0,c)}}}function Gc(r){if(typeof r!=="function")throw Error("normalize fn should be function");return{encode:(e)=>e,decode:(e)=>r(e)}}function zc(r,e,n){if(e<2)throw Error(`convertRadix: wrong from=${e}, base cannot be less than 2`);if(n<2)throw Error(`convertRadix: wrong to=${n}, base cannot be less than 2`);if(!Array.isArray(r))throw Error("convertRadix: data should be array");if(!r.length)return[];let c=0,f=[],w=Array.from(r);w.forEach((i)=>{if(Pr(i),i<0||i>=e)throw Error(`Wrong integer: ${i}`)});while(!0){let i=0,l=!0;for(let $=c;$!e?r:Ec(e,r%e),En=(r,e)=>r+(e-Ec(r,e));function ie(r,e,n,c){if(!Array.isArray(r))throw Error("convertRadix2: data should be array");if(e<=0||e>32)throw Error(`convertRadix2: wrong from=${e}`);if(n<=0||n>32)throw Error(`convertRadix2: wrong to=${n}`);if(En(e,n)>32)throw Error(`convertRadix2: carry overflow from=${e} to=${n} carryBits=${En(e,n)}`);let f=0,w=0,i=2**n-1,l=[];for(let $ of r){if(Pr($),$>=2**e)throw Error(`convertRadix2: invalid data word=${$} from=${e}`);if(f=f<32)throw Error(`convertRadix2: carry overflow pos=${w} from=${e}`);w+=e;for(;w>=n;w-=n)l.push((f>>w-n&i)>>>0);f&=2**w-1}if(f=f<=e)throw Error("Excess padding");if(!c&&f)throw Error(`Non-zero padding: ${f}`);if(c&&w>0)l.push(f>>>0);return l}function M0(r){return Pr(r),{encode:(e)=>{if(!(e instanceof Uint8Array))throw Error("radix.encode input should be Uint8Array");return zc(Array.from(e),256,r)},decode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="number")throw Error("radix.decode input should be array of strings");return Uint8Array.from(zc(e,r,256))}}}function Sr(r,e=!1){if(Pr(r),r<=0||r>32)throw Error("radix2: bits should be in (0..32]");if(En(8,r)>32||En(r,8)>32)throw Error("radix2: carry overflow");return{encode:(n)=>{if(!(n instanceof Uint8Array))throw Error("radix2.encode input should be Uint8Array");return ie(Array.from(n),8,r,!e)},decode:(n)=>{if(!Array.isArray(n)||n.length&&typeof n[0]!=="number")throw Error("radix2.decode input should be array of strings");return Uint8Array.from(ie(n,r,8,e))}}}function Qc(r){if(typeof r!=="function")throw Error("unsafeWrapper fn should be function");return function(...e){try{return r.apply(null,e)}catch(n){}}}var G0=Qr(Sr(4),qr("0123456789ABCDEF"),Tr("")),E0=Qr(Sr(5),qr("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"),Kn(5),Tr("")),Ai=Qr(Sr(5),qr("0123456789ABCDEFGHIJKLMNOPQRSTUV"),Kn(5),Tr("")),di=Qr(Sr(5),qr("0123456789ABCDEFGHJKMNPQRSTVWXYZ"),Tr(""),Gc((r)=>r.toUpperCase().replace(/O/g,"0").replace(/[IL]/g,"1"))),Wn=Qr(Sr(6),qr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"),Kn(6),Tr("")),K0=Qr(Sr(6),qr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"),Kn(6),Tr("")),Je=(r)=>Qr(M0(58),qr(r),Tr("")),$e=Je("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"),Ui=Je("123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"),gi=Je("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"),qc=[0,2,3,5,6,7,9,10,11],W0={encode(r){let e="";for(let n=0;n>25,n=(r&33554431)<<5;for(let c=0;c>c&1)===1)n^=Tc[c];return n}function Mc(r,e,n=1){let c=r.length,f=1;for(let w=0;w126)throw Error(`Invalid prefix (${r})`);f=br(f)^i>>5}f=br(f);for(let w=0;wM)throw TypeError(`Length ${K} exceeds limit ${M}`);return J=J.toLowerCase(),`${J}1${le.encode(Q)}${Mc(J,Q,e)}`}function l(J,Q=90){if(typeof J!=="string")throw Error(`bech32.decode input should be string, not ${typeof J}`);if(J.length<8||Q!==!1&&J.length>Q)throw TypeError(`Wrong string length: ${J.length} (${J}). Expected (8..${Q})`);let M=J.toLowerCase();if(J!==M&&J!==J.toUpperCase())throw Error("String must be lowercase or uppercase");J=M;let K=J.lastIndexOf("1");if(K===0||K===-1)throw Error('Letter "1" must be present between prefix and data only');let z=J.slice(0,K),q=J.slice(K+1);if(q.length<6)throw Error("Data must be at least 6 characters long");let G=le.decode(q).slice(0,-6),E=Mc(z,G,e);if(!q.endsWith(E))throw Error(`Invalid checksum in ${J}: expected "${E}"`);return{prefix:z,words:G}}let $=Qc(l);function I(J){let{prefix:Q,words:M}=l(J,!1);return{prefix:Q,words:M,bytes:c(M)}}return{encode:i,decode:l,decodeToBytes:I,decodeUnsafe:$,fromWords:c,fromWordsUnsafe:w,toWords:f}}var kr=Kc("bech32"),Oi=Kc("bech32m"),N0={encode:(r)=>new TextDecoder().decode(r),decode:(r)=>new TextEncoder().encode(r)},S0=Qr(Sr(4),qr("0123456789abcdef"),Tr(""),Gc((r)=>{if(typeof r!=="string"||r.length%2)throw TypeError(`hex.decode: expected string, got ${typeof r} with length ${r.length}`);return r.toLowerCase()})),X0={utf8:N0,hex:S0,base16:G0,base32:E0,base64:Wn,base64url:K0,base58:$e,base58xmr:W0},Zi=`Invalid encoding type. Available types: ${Object.keys(X0).join(", ")}`;var Y0=new TextDecoder("utf-8"),A0=new TextEncoder,Wc=1,Nc=65535;function Sc(r,e){let n=jr.getSharedSecret(r,"02"+e).subarray(1,33);return Jc(ir,n,"nip44-v2")}function Xc(r,e){let n=Ic(ir,r,e,76);return{chacha_key:n.subarray(0,32),chacha_nonce:n.subarray(32,44),hmac_key:n.subarray(44,76)}}function Ie(r){if(!Number.isSafeInteger(r)||r<1)throw Error("expected positive integer");if(r<=32)return 32;let e=1<Nc)throw Error("invalid plaintext size: must be between 1 and 65535 bytes");let e=new Uint8Array(2);return new DataView(e.buffer).setUint16(0,r,!1),e}function U0(r){let e=A0.encode(r),n=e.length,c=d0(n),f=new Uint8Array(Ie(n)-n);return Hr(c,e,f)}function g0(r){let e=new DataView(r.buffer).getUint16(0),n=r.subarray(2,2+e);if(eNc||n.length!==e||r.length!==2+Ie(e))throw Error("invalid padding");return Y0.decode(n)}function Yc(r,e,n){if(n.length!==32)throw Error("AAD associated data must be 32 bytes");let c=Hr(n,e);return Rr(ir,r,c)}function O0(r){if(typeof r!=="string")throw Error("payload must be a valid string");let e=r.length;if(e<132||e>87472)throw Error("invalid payload length: "+e);if(r[0]==="#")throw Error("unknown encryption version");let n;try{n=Wn.decode(r)}catch(w){throw Error("invalid base64: "+w.message)}let c=n.length;if(c<99||c>65603)throw Error("invalid data length: "+c);let f=n[0];if(f!==2)throw Error("unknown encryption version "+f);return{nonce:n.subarray(1,33),ciphertext:n.subarray(33,-32),mac:n.subarray(-32)}}function Ac(r,e,n=ke(32)){let{chacha_key:c,chacha_nonce:f,hmac_key:w}=Xc(e,n),i=U0(r),l=Gn(c,f,i),$=Yc(w,l,n);return Wn.encode(Hr(new Uint8Array([2]),n,l,$))}function dc(r,e){let{nonce:n,ciphertext:c,mac:f}=O0(r),{chacha_key:w,chacha_nonce:i,hmac_key:l}=Xc(e,n),$=Yc(l,c,n);if(!Tn($,f))throw Error("invalid MAC");let I=Gn(w,i,c);return g0(I)}var Z0={utils:{getConversationKey:Sc,calcPaddedLen:Ie},encrypt:Ac,decrypt:dc};var Zc={};mr(Zc,{minePow:()=>V0,getPow:()=>gc,fastEventHash:()=>Oc});var Li=new TextDecoder("utf-8"),D0=new TextEncoder;function gc(r){let e=0;for(let n=0;n<64;n+=8){let c=parseInt(r.substring(n,n+8),16);if(c===0)e+=32;else{e+=Math.clz32(c);break}}return e}function V0(r,e){let n=0,c=r,f=["nonce",n.toString(),e.toString()];c.tags.push(f);while(!0){let w=Math.floor(new Date().getTime()/1000);if(w!==c.created_at)n=0,c.created_at=w;if(f[1]=(++n).toString(),c.id=Oc(c),gc(c.id)>=e)break}return c}function Oc(r){return L(ir(D0.encode(JSON.stringify([0,r.pubkey,r.created_at,r.kind,r.tags,r.content]))))}var Dc={};mr(Dc,{nsecEncode:()=>P0,npubEncode:()=>B0,nprofileEncode:()=>o0,noteEncode:()=>v0,neventEncode:()=>C0,naddrEncode:()=>L0,encodeBytes:()=>Yn,decode:()=>R0,NostrTypeGuard:()=>j0,Bech32MaxSize:()=>Qe,BECH32_REGEX:()=>H0});var Nn=new TextDecoder("utf-8"),Sn=new TextEncoder,j0={isNProfile:(r)=>/^nprofile1[a-z\d]+$/.test(r||""),isNEvent:(r)=>/^nevent1[a-z\d]+$/.test(r||""),isNAddr:(r)=>/^naddr1[a-z\d]+$/.test(r||""),isNSec:(r)=>/^nsec1[a-z\d]{58}$/.test(r||""),isNPub:(r)=>/^npub1[a-z\d]{58}$/.test(r||""),isNote:(r)=>/^note1[a-z\d]+$/.test(r||""),isNcryptsec:(r)=>/^ncryptsec1[a-z\d]+$/.test(r||"")},Qe=5000,H0=/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/;function u0(r){let e=new Uint8Array(4);return e[0]=r>>24&255,e[1]=r>>16&255,e[2]=r>>8&255,e[3]=r&255,e}function R0(r){let{prefix:e,words:n}=kr.decode(r,Qe),c=new Uint8Array(kr.fromWords(n));switch(e){case"nprofile":{let f=ze(c);if(!f[0]?.[0])throw Error("missing TLV 0 for nprofile");if(f[0][0].length!==32)throw Error("TLV 0 should be 32 bytes");return{type:"nprofile",data:{pubkey:L(f[0][0]),relays:f[1]?f[1].map((w)=>Nn.decode(w)):[]}}}case"nevent":{let f=ze(c);if(!f[0]?.[0])throw Error("missing TLV 0 for nevent");if(f[0][0].length!==32)throw Error("TLV 0 should be 32 bytes");if(f[2]&&f[2][0].length!==32)throw Error("TLV 2 should be 32 bytes");if(f[3]&&f[3][0].length!==4)throw Error("TLV 3 should be 4 bytes");return{type:"nevent",data:{id:L(f[0][0]),relays:f[1]?f[1].map((w)=>Nn.decode(w)):[],author:f[2]?.[0]?L(f[2][0]):void 0,kind:f[3]?.[0]?parseInt(L(f[3][0]),16):void 0}}}case"naddr":{let f=ze(c);if(!f[0]?.[0])throw Error("missing TLV 0 for naddr");if(!f[2]?.[0])throw Error("missing TLV 2 for naddr");if(f[2][0].length!==32)throw Error("TLV 2 should be 32 bytes");if(!f[3]?.[0])throw Error("missing TLV 3 for naddr");if(f[3][0].length!==4)throw Error("TLV 3 should be 4 bytes");return{type:"naddr",data:{identifier:Nn.decode(f[0][0]),pubkey:L(f[2][0]),kind:parseInt(L(f[3][0]),16),relays:f[1]?f[1].map((w)=>Nn.decode(w)):[]}}}case"nsec":return{type:e,data:c};case"npub":case"note":return{type:e,data:L(c)};default:throw Error(`unknown prefix ${e}`)}}function ze(r){let e={},n=r;while(n.length>0){let c=n[0],f=n[1],w=n.slice(2,2+f);if(n=n.slice(2+f),w.lengthSn.encode(n))});return Xn("nprofile",e)}function C0(r){let e;if(r.kind!==void 0)e=u0(r.kind);let n=qe({0:[gr(r.id)],1:(r.relays||[]).map((c)=>Sn.encode(c)),2:r.author?[gr(r.author)]:[],3:e?[new Uint8Array(e)]:[]});return Xn("nevent",n)}function L0(r){let e=new ArrayBuffer(4);new DataView(e).setUint32(0,r.kind,!1);let n=qe({0:[Sn.encode(r.identifier)],1:(r.relays||[]).map((c)=>Sn.encode(c)),2:[gr(r.pubkey)],3:[new Uint8Array(e)]});return Xn("naddr",n)}function qe(r){let e=[];return Object.entries(r).reverse().forEach(([n,c])=>{c.forEach((f)=>{let w=new Uint8Array(f.length+2);w.set([parseInt(n)],0),w.set([f.length],1),w.set(f,2),e.push(w)})}),Hr(...e)}export{e0 as verifyEvent,ir as sha256,jr as secp256k1,Uc as nip44,Dc as nip19,Zc as nip13,r0 as getPublicKey,pf as generateSecretKey,n0 as finalizeEvent,tf as SimplePool}; diff --git a/assets/nostr-relays.json b/assets/nostr-relays.json new file mode 100644 index 0000000..a262a02 --- /dev/null +++ b/assets/nostr-relays.json @@ -0,0 +1,6 @@ +[ + "wss://nos.lol", + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://nostr.mom" +] diff --git a/assets/nostr-tools-entry.js b/assets/nostr-tools-entry.js new file mode 100644 index 0000000..a76bd3d --- /dev/null +++ b/assets/nostr-tools-entry.js @@ -0,0 +1,15 @@ +// Entry point for the vendored nostr-tools bundle. +// +// We re-export only the small surface lnproxy needs so the committed bundle in +// assets/nostr-bundle.js stays small and auditable. Rebuild it with: +// bun install && bun run build:nostr +export { SimplePool } from "nostr-tools/pool"; +export { verifyEvent, finalizeEvent, getPublicKey, generateSecretKey } from "nostr-tools/pure"; +export * as nip44 from "nostr-tools/nip44"; +export * as nip13 from "nostr-tools/nip13"; +export * as nip19 from "nostr-tools/nip19"; +// secp256k1 (with recovery) and sha256 are needed to verify LND-style node +// attestation signatures in the browser. They are already pulled in by +// nostr-tools, so re-exporting them adds negligible bundle size. +export { secp256k1 } from "@noble/curves/secp256k1"; +export { sha256 } from "@noble/hashes/sha256"; diff --git a/assets/nostr.js b/assets/nostr.js new file mode 100644 index 0000000..7319d58 --- /dev/null +++ b/assets/nostr.js @@ -0,0 +1,653 @@ +// lnproxy nostr provider discovery and wrap transport. +// +// Implements the client side of spec/nostr.md: it subscribes to provider offers +// (kind 38421), validates and ranks them cheapest-first, and sends encrypted +// wrap requests (kind 21821) carrying a NIP-13 proof of work, reading the reply +// (kind 21822). Pure, side-effect-free helpers are exported for unit testing. +// +// Crypto comes from the vendored, audited nostr-tools bundle. +import { + SimplePool, + verifyEvent, + finalizeEvent, + getPublicKey, + generateSecretKey, + nip44, + nip13, + nip19, + secp256k1, + sha256, +} from "./nostr-bundle.js"; +import { diagnostics, shortID } from "./diagnostics.js"; + +export const KIND_OFFER = 38421; +export const KIND_REQUEST = 21821; +export const KIND_RESPONSE = 21822; +export const PROTOCOL_VERSION = "lnproxy-v1"; +export const MAX_REQUEST_POW = 24; + +// Default nostr relays. Overridable via assets/nostr-relays.json. +export const DEFAULT_RELAYS = [ + "wss://nos.lol", + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://nostr.mom", +]; + +// --------------------------------------------------------------------------- +// Pure helpers (unit tested) +// --------------------------------------------------------------------------- + +// attestationMessage returns the ASCII message an LN node signs to attest a +// nostr identity, matching the spec and the relay. +export function attestationMessage(nostrPubkeyHex) { + return "lnproxy:v1:announce:" + nostrPubkeyHex; +} + +// normalizePubkey accepts a hex x-only pubkey or an npub and returns the hex +// form, or null if the input is not a valid provider pubkey. Used for the +// manual "pin a provider" field. +export function normalizePubkey(input) { + if (!input) return null; + const s = input.trim(); + if (/^[0-9a-fA-F]{64}$/.test(s)) return s.toLowerCase(); + if (s.startsWith("npub1")) { + try { + const { type, data } = nip19.decode(s); + if (type === "npub" && typeof data === "string") return data; + } catch (_e) { + return null; + } + } + return null; +} + +// countLeadingZeroBits returns the number of leading zero bits of a byte array. +function countLeadingZeroBits(bytes) { + let count = 0; + for (const b of bytes) { + if (b === 0) { + count += 8; + continue; + } + let x = b; + let n = 0; + while ((x & 0x80) === 0) { + n++; + x <<= 1; + } + count += n; + break; + } + return count; +} + +// nonceTo32BE parses a pow_nonce (hex, optionally 0x-prefixed) into a 32-byte +// big-endian array, matching the relay's encoding. Returns null on overflow or +// invalid input. +function nonceTo32BE(nonce) { + if (typeof nonce !== "string") return null; + let hex = nonce.trim().toLowerCase(); + if (hex.startsWith("0x")) hex = hex.slice(2); + if (hex === "" || !/^[0-9a-f]+$/.test(hex)) return null; + if (hex.length % 2 === 1) hex = "0" + hex; + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + if (bytes.length > 32) return null; + const out = new Uint8Array(32); + out.set(bytes, 32 - bytes.length); // right-align (big-endian) + return out; +} + +// announcePoWBits recomputes the anonymous identity proof of work for an offer: +// the number of leading zero bits of SHA256("lnproxy" || pubkey || nonce), +// where pubkey is the 32-byte x-only nostr key and nonce is a 32-byte +// big-endian integer. Returns 0 for a missing/zero/invalid nonce. This lets the +// client rank anonymous providers on real work, not merely the presence of a +// pow_nonce field. +export function announcePoWBits(nostrPubkeyHex, nonce) { + try { + if (!/^[0-9a-fA-F]{64}$/.test(nostrPubkeyHex)) return 0; + const nonceBytes = nonceTo32BE(nonce); + if (!nonceBytes) return 0; + // All-zero nonce yields 0 bits by convention. + if (nonceBytes.every((b) => b === 0)) return 0; + const pub = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + pub[i] = parseInt(nostrPubkeyHex.slice(i * 2, i * 2 + 2), 16); + } + const prefix = utf8("lnproxy"); + const buf = new Uint8Array(prefix.length + 32 + 32); + buf.set(prefix, 0); + buf.set(pub, prefix.length); + buf.set(nonceBytes, prefix.length + 32); + return countLeadingZeroBits(sha256(buf)); + } catch (_e) { + return 0; + } +} + +const ZBASE32_ALPHABET = "ybndrfg8ejkmcpqxot1uwisza345h769"; +const SIGNED_MSG_PREFIX = "Lightning Signed Message:"; + +function zbase32Decode(s) { + const out = []; + let buffer = 0; + let bitsLeft = 0; + for (let i = 0; i < s.length; i++) { + const v = ZBASE32_ALPHABET.indexOf(s[i]); + if (v < 0) throw new Error("invalid zbase32 character"); + buffer = (buffer << 5) | v; + bitsLeft += 5; + if (bitsLeft >= 8) { + bitsLeft -= 8; + out.push((buffer >> bitsLeft) & 0xff); + } + } + return new Uint8Array(out); +} + +function utf8(s) { + return new TextEncoder().encode(s); +} + +function concatBytes(a, b) { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} + +function toHex(bytes) { + let s = ""; + for (const b of bytes) s += b.toString(16).padStart(2, "0"); + return s; +} + +// verifyAttestation checks that an LND-style zbase32 recoverable signature over +// attestationMessage(nostrPubkeyHex) recovers to nodePubkeyHex (hex compressed +// secp256k1 key). Returns true on success, false otherwise. This is the same +// trustless check the relay performs server-side, run in the browser so the UI +// can show a verified badge. +export function verifyAttestation(nostrPubkeyHex, nodePubkeyHex, sig) { + try { + if (!nodePubkeyHex || !sig) return false; + const raw = zbase32Decode(sig); + if (raw.length !== 65) return false; + // LND header byte: 27 + recid (+4 if compressed). Mask out the + // compressed flag to recover the 2-bit recovery id. + const header = raw[0]; + const recid = (header - 27) & 0x03; + const compact = raw.slice(1); // 64 bytes r||s + const msg = concatBytes(utf8(SIGNED_MSG_PREFIX), utf8(attestationMessage(nostrPubkeyHex))); + const digest = sha256(sha256(msg)); + const signature = secp256k1.Signature.fromCompact(toHex(compact)).addRecoveryBit(recid); + const point = signature.recoverPublicKey(toHex(digest)); + const recovered = point.toRawBytes(true); // compressed + return toHex(recovered) === nodePubkeyHex.toLowerCase(); + } catch (_e) { + return false; + } +} + +// parseOffer parses and validates an offer event's content. Returns a +// normalized offer object or null if the content is unusable. +export function parseOffer(event, { network = "mainnet", now = Date.now() } = {}) { + if (!event || event.kind !== KIND_OFFER) return null; + const dTag = tagValue(event, "d"); + const nTag = tagValue(event, "n"); + if (dTag !== PROTOCOL_VERSION) return null; + if (nTag !== network) return null; + // Freshness: created_at within ~1 hour of local time. + if (!Number.isSafeInteger(event.created_at)) return null; + const ageSec = Math.abs(now / 1000 - event.created_at); + if (ageSec > 3600) return null; + const expiration = tagValue(event, "expiration"); + if (expiration !== undefined) { + const expiresAt = toInt(expiration); + if (expiresAt === null || expiresAt <= now / 1000) return null; + } + + const actual_pow = nip13.getPow(event.id); + const nonceTag = event.tags?.find((tag) => tag[0] === "nonce"); + const committed_pow = nonceTag === undefined ? 0 : toInt(nonceTag[2]); + if (committed_pow === null || committed_pow > 256 || actual_pow < committed_pow) return null; + + let content; + try { + content = JSON.parse(event.content); + } catch (_e) { + return null; + } + const base_fee_msat = toInt(content.base_fee_msat); + const fee_ppm = toInt(content.fee_ppm); + const min_amount_msat = toInt(content.min_amount_msat); + const max_amount_msat = toInt(content.max_amount_msat); + if ( + base_fee_msat === null || + fee_ppm === null || + min_amount_msat === null || + max_amount_msat === null + ) { + return null; + } + if (max_amount_msat < min_amount_msat) return null; + const min_request_pow = content.min_request_pow === undefined ? 0 : toInt(content.min_request_pow); + if (min_request_pow === null) return null; + if (min_request_pow > MAX_REQUEST_POW) return null; + const max_expiry_seconds = content.max_expiry_seconds === undefined ? 0 : toInt(content.max_expiry_seconds); + if (max_expiry_seconds === null) return null; + + const node_pubkey = typeof content.node_pubkey === "string" ? content.node_pubkey : ""; + const node_sig = typeof content.node_sig === "string" ? content.node_sig : ""; + const attested = node_pubkey && node_sig + ? verifyAttestation(event.pubkey, node_pubkey, node_sig) + : false; + const pow_nonce = typeof content.pow_nonce === "string" ? content.pow_nonce : ""; + const identity_pow_bits = pow_nonce ? announcePoWBits(event.pubkey, pow_nonce) : 0; + + return { + pubkey: event.pubkey, + base_fee_msat, + fee_ppm, + min_amount_msat, + max_amount_msat, + max_expiry_seconds, + min_request_pow, + features: stringArray(content.features), + relays: relayURLs(content.relays), + urls: stringArray(content.urls), + node_pubkey, + node_sig, + attested, + pow_nonce, + identity_pow_bits, + created_at: event.created_at, + committed_pow, + actual_pow, + }; +} + +// effectiveFeeMsat returns the advertised fee an offer charges for an invoice of +// amountMsat, used for cheapest-first ranking. +export function effectiveFeeMsat(offer, amountMsat) { + return offer.base_fee_msat + Math.floor((amountMsat * offer.fee_ppm) / 1_000_000); +} + +// wrapFeature maps a requested output format to the feature flag a provider +// must advertise. +export function wrapFeature(wrap) { + switch (wrap) { + case undefined: + case "": + case "bolt11": + return "wrap_bolt11"; + case "bolt11_blinded": + return "wrap_bolt11_blinded"; + case "bolt12": + return "wrap_bolt12"; + default: + return wrap; + } +} + +// canServe reports whether an offer can wrap amountMsat into wrap format. +export function canServe(offer, amountMsat, wrap = "bolt11") { + if (amountMsat < offer.min_amount_msat) return false; + if (amountMsat > offer.max_amount_msat) return false; + return offer.features.includes(wrapFeature(wrap)); +} + +// selectOffers filters offers to those that can serve the payment and meet the +// client's minimum committed proof of work, then sorts them cheapest-first. +// Ties are broken by credential strength (attested node, then higher committed +// proof of work). +export function selectOffers(offers, { amountMsat, wrap = "bolt11", minPow = 0 } = {}) { + return offers + .filter((o) => o.committed_pow >= minPow) + .filter((o) => canServe(o, amountMsat, wrap)) + .sort((a, b) => { + const fa = effectiveFeeMsat(a, amountMsat); + const fb = effectiveFeeMsat(b, amountMsat); + if (fa !== fb) return fa - fb; + const ca = credentialRank(a); + const cb = credentialRank(b); + if (ca !== cb) return cb - ca; + if (b.identity_pow_bits !== a.identity_pow_bits) { + return b.identity_pow_bits - a.identity_pow_bits; + } + return b.committed_pow - a.committed_pow; + }); +} + +// randomOffer selects uniformly from an already-filtered offer list. Keeping +// this separate from ranking prevents always sending traffic to the cheapest +// provider and makes the selection policy directly testable. +export function randomOffer(offers, random = Math.random) { + if (!Array.isArray(offers) || offers.length === 0) return null; + const sample = Number(random()); + const index = Math.min(offers.length - 1, Math.max(0, Math.floor(sample * offers.length))); + return offers[index]; +} + +// credentialRank gives attested providers priority over anonymous ones for tie +// breaking only. It is intentionally coarse; richer policies (node centrality, +// reputation) are future work. Only a verified attestation, or a verified +// non-zero identity proof of work, counts. +export function credentialRank(offer) { + if (offer.attested) return 2; + if (offer.identity_pow_bits > 0) return 1; + return 0; +} + +// feeWithinAdvertised checks that a returned proxy invoice does not charge more +// than the offer advertised. When routingMsat is supplied the surplus must equal +// it exactly (base protocol rule). Amounts in msat. +export function feeWithinAdvertised(offer, originalMsat, proxyMsat, routingMsat) { + const surplus = proxyMsat - originalMsat; + if (surplus < 0) return false; + if (routingMsat !== undefined && routingMsat !== null) { + return surplus === routingMsat; + } + // Allow the advertised provider fee plus a routing budget headroom equal to + // that fee again (mirrors the relay's automatic budget, which is bounded). + const advertised = effectiveFeeMsat(offer, originalMsat); + return surplus <= advertised * 2 + 1000; +} + +function tagValue(event, name) { + if (!event.tags) return undefined; + const tag = event.tags.find((t) => t[0] === name); + return tag ? tag[1] : undefined; +} + +function toInt(v) { + if (v === undefined || v === null) return null; + if (typeof v === "string" && !/^(0|[1-9][0-9]*)$/.test(v)) return null; + const n = typeof v === "string" ? Number(v) : v; + return Number.isSafeInteger(n) && n >= 0 ? n : null; +} + +function stringArray(value) { + return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; +} + +function relayURLs(value) { + return stringArray(value).filter((relay) => { + try { + const url = new URL(relay); + return (url.protocol === "ws:" || url.protocol === "wss:") && url.username === "" && url.password === ""; + } catch (_error) { + return false; + } + }); +} + +// --------------------------------------------------------------------------- +// Network layer +// --------------------------------------------------------------------------- + +const encoder = new TextEncoder(); + +// discoverOffers subscribes to relays and collects valid offers until the +// timeout elapses, keeping the newest offer per provider. Returns an array of +// parsed offers (unsorted). +export async function discoverOffers(relays, { network = "mainnet", timeoutMs = 4000 } = {}) { + relays = relayURLs(relays); + if (relays.length === 0) throw new Error("no valid nostr relay URLs configured"); + const startedAt = performance.now(); + diagnostics.info("discovery.subscription.started", { + network, + relays, + timeoutMs, + }); + const pool = new SimplePool(); + const byPubkey = new Map(); + const since = Math.floor(Date.now() / 1000) - 3600; + return new Promise((resolve) => { + const filter = { + kinds: [KIND_OFFER], + "#d": [PROTOCOL_VERSION], + "#n": [network], + since, + }; + const sub = pool.subscribeMany(relays, [filter], { + receivedEvent(relay, eventID) { + diagnostics.debug("discovery.event.received", { + relay: relay.url, + eventID: shortID(eventID), + }); + }, + oneose() { + diagnostics.debug("discovery.relay.eose", { network }); + }, + onclose(reasons) { + diagnostics.info("discovery.subscription.closed", { network, reasons }); + }, + onevent(event) { + if (!verifyEvent(event)) { + diagnostics.warn("discovery.event.invalid-signature", { + eventID: shortID(event.id), + pubkey: shortID(event.pubkey), + }); + return; + } + const offer = parseOffer(event, { network }); + if (!offer) { + diagnostics.debug("discovery.offer.rejected", { + eventID: shortID(event.id), + pubkey: shortID(event.pubkey), + }); + return; + } + const prev = byPubkey.get(offer.pubkey); + if (!prev || offer.created_at > prev.created_at) { + byPubkey.set(offer.pubkey, offer); + diagnostics.info("discovery.offer.accepted", { + provider: offer.pubkey, + eventID: shortID(event.id), + createdAt: offer.created_at, + baseFeeMsat: offer.base_fee_msat, + feePpm: offer.fee_ppm, + minAmountMsat: offer.min_amount_msat, + maxAmountMsat: offer.max_amount_msat, + committedPow: offer.committed_pow, + actualPow: offer.actual_pow, + requestPow: offer.min_request_pow, + attested: offer.attested, + features: offer.features, + relays: offer.relays, + }); + } + }, + }); + setTimeout(() => { + const connectionStatus = Object.fromEntries(pool.listConnectionStatus()); + try { + sub.close(); + } catch (_e) { + /* ignore */ + } + try { + pool.close(relays); + } catch (_e) { + /* ignore */ + } + const offers = Array.from(byPubkey.values()); + diagnostics.info("discovery.subscription.completed", { + network, + durationMs: Math.round(performance.now() - startedAt), + offerCount: offers.length, + connectionStatus, + }); + resolve(offers); + }, timeoutMs); + }); +} + +// wrapViaNostr sends an encrypted wrap request to a provider and resolves with +// the decrypted response. powDifficulty is mined into the request event. +export async function wrapViaNostr(offer, request, { relays, timeoutMs = 30000 } = {}) { + const targetRelays = intersectRelays(relays, offer.relays); + if (targetRelays.length === 0) throw new Error("provider has no valid nostr relay URLs"); + diagnostics.info("wrap.transport.started", { + provider: offer.pubkey, + targetRelays, + timeoutMs, + requestPow: offer.min_request_pow || 0, + hasCustomDescription: Object.hasOwn(request, "description"), + hasCustomRoutingBudget: Object.hasOwn(request, "routing_msat"), + }); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const convKey = nip44.getConversationKey(sk, offer.pubkey); + const plaintext = JSON.stringify({ method: "wrap", ...request }); + const ciphertext = nip44.encrypt(plaintext, convKey); + + const target = Math.max(offer.min_request_pow || 0, 0); + if (!Number.isSafeInteger(target) || target > MAX_REQUEST_POW) { + throw new Error(`provider request proof of work exceeds the ${MAX_REQUEST_POW}-bit client limit`); + } + let unsigned = { + kind: KIND_REQUEST, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", offer.pubkey]], + content: ciphertext, + pubkey: pk, + }; + if (target > 0) { + const miningStartedAt = performance.now(); + diagnostics.info("wrap.pow.started", { difficulty: target }); + unsigned = await minePowAsync(unsigned, target); + diagnostics.info("wrap.pow.completed", { + difficulty: target, + actualPow: nip13.getPow(unsigned.id), + durationMs: Math.round(performance.now() - miningStartedAt), + }); + } + const event = finalizeEvent(unsigned, sk); + diagnostics.info("wrap.request.ready", { + eventID: event.id, + provider: offer.pubkey, + createdAt: event.created_at, + }); + + const pool = new SimplePool(); + return new Promise((resolve, reject) => { + let settled = false; + let timer; + const finish = (fn, arg) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + pool.close(targetRelays); + } catch (_e) { + /* ignore */ + } + fn(arg); + }; + timer = setTimeout(() => finish(reject, new Error("nostr wrap request timed out")), timeoutMs); + pool.subscribeMany( + targetRelays, + [{ kinds: [KIND_RESPONSE], authors: [offer.pubkey], "#p": [pk], "#e": [event.id] }], + { + receivedEvent(relay, eventID) { + diagnostics.debug("wrap.response.received", { + relay: relay.url, + eventID: shortID(eventID), + }); + }, + onclose(reasons) { + diagnostics.info("wrap.subscription.closed", { + closedRelayCount: reasons.length, + }); + }, + onevent(resp) { + if (!verifyEvent(resp)) { + diagnostics.warn("wrap.response.invalid-signature", { + eventID: shortID(resp.id), + pubkey: shortID(resp.pubkey), + }); + return; + } + let text; + try { + text = nip44.decrypt(resp.content, convKey); + } catch (error) { + diagnostics.error("wrap.response.decrypt-failed", { + errorName: error?.name || "Error", + }); + finish(reject, new Error("provider response could not be decrypted")); + return; + } + try { + const response = JSON.parse(text); + diagnostics.info("wrap.response.decrypted", { + eventID: shortID(resp.id), + provider: resp.pubkey, + status: response.status === "ERROR" ? "ERROR" : "OK", + }); + finish(resolve, response); + } catch (error) { + diagnostics.error("wrap.response.parse-failed", { + errorName: error?.name || "Error", + }); + finish(reject, new Error("provider response was not valid JSON")); + } + }, + }, + ); + const publishes = pool.publish(targetRelays, event); + publishes.forEach((publication, index) => { + publication.then(() => { + diagnostics.info("wrap.request.published", { + relay: targetRelays[index], + eventID: shortID(event.id), + acknowledged: true, + }); + }).catch((error) => { + diagnostics.warn("wrap.request.publish-failed", { + relay: targetRelays[index], + eventID: shortID(event.id), + errorType: error?.name || typeof error, + }); + }); + }); + }); +} + +export async function minePowAsync(unsigned, difficulty, { chunkSize = 4096 } = {}) { + const event = { ...unsigned, tags: unsigned.tags.map((tag) => [...tag]) }; + const nonceTag = ["nonce", "0", String(difficulty)]; + event.tags.push(nonceTag); + let nonce = 0; + while (true) { + const now = Math.floor(Date.now() / 1000); + if (event.created_at !== now) { + event.created_at = now; + nonce = 0; + } + for (let i = 0; i < chunkSize; i++) { + nonceTag[1] = String(++nonce); + event.id = nip13.fastEventHash(event); + if (nip13.getPow(event.id) >= difficulty) return event; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +function intersectRelays(clientRelays, offerRelays) { + clientRelays = relayURLs(clientRelays); + offerRelays = relayURLs(offerRelays); + if (!offerRelays || offerRelays.length === 0) return clientRelays; + const set = new Set(clientRelays); + const common = offerRelays.filter((r) => set.has(r)); + // Fall back to the union so a request still reaches the provider. + return common.length > 0 ? common : Array.from(new Set([...clientRelays, ...offerRelays])); +} + +// avoid an unused-import warning for encoder in environments that tree-shake. +void encoder; diff --git a/assets/relays.json b/assets/relays.json deleted file mode 100644 index 226d1a2..0000000 --- a/assets/relays.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - "http://w3sqmns2ct7ai2wiwzq5uplp2pqglpm6qpeey4blvn6agj3jr5abthqd.onion/spec", - "http://rdq6tvulanl7aqtupmoboyk2z3suzkdwurejwyjyjf4itr3zhxrm2lad.onion/spec", - "https://lnproxy.org/spec", - "https://lnproxy.lnemail.net/spec" -] diff --git a/assets/style.css b/assets/style.css index 8d5ccbe..361203f 100644 --- a/assets/style.css +++ b/assets/style.css @@ -16,14 +16,6 @@ header { justify-content: space-between; align-items: center; } -nav ul { - list-style: none; - margin: 0; - padding: 0; -} -nav li { - display: block; -} h1 { font-weight: normal; font-size: 1.80rem; @@ -80,13 +72,6 @@ textarea { background: #ffffea; resize: none; } -#relay { - width: 100%; - padding: 1ex; - margin: 0.5ex 0; - border: 1px solid; - background: #ffffea; -} input { padding: 1ex; margin: 0.5ex 0; @@ -96,7 +81,6 @@ input { } button { padding: 1ex; -// margin: 0.5ex auto; border: 1px solid; background: #ffffea; cursor: pointer; @@ -109,6 +93,59 @@ footer { color: #444444; text-align: center; } +.network_badge { + padding: 0.3rem 0.55rem; + border: 1px solid #222; + font-family: monospace; + font-size: 0.75rem; + font-weight: bold; + background: #ececec; +} +.network_badge[data-network="signet"] { + background: #fff0c2; + border-color: #8a5a00; + color: #613f00; +} +.network_badge[data-network="testnet"] { + background: #dff2ff; + border-color: #24658c; + color: #16415b; +} +.network_badge[data-network="regtest"] { + background: #eadfff; + border-color: #62458a; + color: #3d285d; +} +.provider_header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 2.5rem; + gap: 0.5rem; +} +.icon_button { + width: 2.25rem; + height: 2.25rem; + padding: 0; + font-size: 1.3rem; + line-height: 1; +} +.form_row { + display: flex; + align-items: center; + gap: 0.5rem; +} +.visually_hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} .demph { font-size: 80%; } @@ -163,3 +200,62 @@ footer { font-style: italic; color: #000; } +table.providers { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; + margin: 0.5em 0; +} +#provider_panel { + overflow-x: auto; +} +table.providers th, +table.providers td { + text-align: left; + padding: 0.2em 0.4em; + border-bottom: 1px solid #ddd; +} +table.providers .mono { + font-family: monospace; +} +.cred { + padding: 0 0.4ex; + border-radius: 0.4ex; + font-size: 0.85em; + white-space: nowrap; +} +.cred.verified { + background-color: #d6f5d6; + border: 1px solid #2e7d32; + color: #2e7d32; +} +.cred.unverified { + background-color: #ffeaea; + border: 1px solid #c62828; + color: #c62828; +} +.cred.anon { + background-color: #eee; + border: 1px solid #999; + color: #555; +} +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} +@media (max-width: 40rem) { + body { + font-size: 0.92rem; + } + .form_row { + align-items: flex-start; + flex-wrap: wrap; + } + table.providers { + font-size: 0.7rem; + } + table.providers th, + table.providers td { + padding: 0.2rem; + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..016a1b7 --- /dev/null +++ b/bun.lock @@ -0,0 +1,35 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "lnproxy-webui2", + "devDependencies": { + "nostr-tools": "2.10.4", + }, + }, + }, + "packages": { + "@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], + + "@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], + + "@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + + "@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], + + "@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="], + + "@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="], + + "nostr-tools": ["nostr-tools@2.10.4", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1" }, "optionalDependencies": { "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg=="], + + "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], + + "@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], + + "@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..96eeed8 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,6 @@ +[test] +# Scope test discovery to the unit-test directory so a bare `bun test` does not +# walk into e2e/playwright/ (those *.spec.ts files require the Playwright +# runner, `bunx playwright test`, not bun's built-in test runner). CI already +# uses `bun test test/`; this makes the bare command safe too. +root = "test" diff --git a/index.html b/index.html index 0d2e196..95405dc 100644 --- a/index.html +++ b/index.html @@ -4,8 +4,7 @@ - - lnproxy.org + lnproxy @@ -21,28 +20,19 @@
-

lnproxy.org

- +

lnproxy

+ +
- - - - +
+ Connecting to nostr relays... + +
+
+
+ + +
+
+ + +
@@ -71,19 +73,16 @@

lnproxy.org

type="number" name="routing" id="routing" + min="0" + step="0.001" > - - + Connecting to relay...
- - -
- dx7pn6ehykq6cadce4bjbxn5tf64z7e3fufpxgxce7n4f5eja476cpyd.onion -
+ diff --git a/package.json b/package.json new file mode 100644 index 0000000..b6f6330 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "lnproxy-webui2", + "version": "0.0.0", + "description": "Static web UI for lnproxy with nostr provider discovery", + "private": true, + "license": "GPL-3.0", + "type": "module", + "scripts": { + "build:nostr": "bun build ./assets/nostr-tools-entry.js --outfile=./assets/nostr-bundle.js --format=esm --minify --target=browser", + "test": "bun test test/" + }, + "devDependencies": { + "nostr-tools": "2.10.4" + } +} diff --git a/signet.html b/signet.html new file mode 100644 index 0000000..709f120 --- /dev/null +++ b/signet.html @@ -0,0 +1,13 @@ + + + + + + + lnproxy signet + + + Open lnproxy signet + + + diff --git a/test/diagnostics.test.js b/test/diagnostics.test.js new file mode 100644 index 0000000..c34af3d --- /dev/null +++ b/test/diagnostics.test.js @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { diagnostics } from "../assets/diagnostics.js"; + +const originalInfo = console.info; + +afterEach(() => { + console.info = originalInfo; +}); + +describe("browser diagnostics", () => { + test("does not interrupt the application when a console method is unavailable", () => { + console.info = undefined; + expect(() => diagnostics.info("test.unavailable")).not.toThrow(); + }); + + test("does not interrupt the application when a console method throws", () => { + console.info = () => { + throw new Error("console unavailable"); + }; + expect(() => diagnostics.info("test.failed")).not.toThrow(); + }); +}); diff --git a/test/invoice.test.js b/test/invoice.test.js new file mode 100644 index 0000000..a506095 --- /dev/null +++ b/test/invoice.test.js @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test"; +import { parseInvoice, validInvoice } from "../assets/invoice.js"; + +const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + +function polymod(values) { + let checksum = 1; + for (const value of values) { + const top = checksum >>> 25; + checksum = ((checksum & 0x1ffffff) << 5) ^ value; + for (let i = 0; i < GENERATORS.length; i++) { + if ((top >>> i) & 1) checksum ^= GENERATORS[i]; + } + } + return checksum; +} + +function checksum(hrp, data) { + const values = [ + ...Array.from(hrp, (char) => char.charCodeAt(0) >>> 5), + 0, + ...Array.from(hrp, (char) => char.charCodeAt(0) & 31), + ...Array.from(data, (char) => CHARSET.indexOf(char)), + 0, 0, 0, 0, 0, 0, + ]; + const mod = polymod(values) ^ 1; + return Array.from({ length: 6 }, (_, i) => CHARSET[(mod >>> (5 * (5 - i))) & 31]).join(""); +} + +function syntheticInvoice(prefix, amount = "10u") { + const hrp = `${prefix}${amount}`; + const data = `${"q".repeat(7)}pp5${"q".repeat(52)}dqq${"q".repeat(104)}`; + return `${hrp}1${data}${checksum(hrp, data)}`; +} + +test("validInvoice enforces the configured network", () => { + const signet = syntheticInvoice("lntbs"); + expect(validInvoice(signet, "signet")).toBe(signet); + expect(validInvoice(signet, "mainnet")).toBe(""); + expect(validInvoice(syntheticInvoice("lnbc"), "signet")).toBe(""); +}); + +test("parseInvoice reads a signet amount", () => { + const parsed = parseInvoice(syntheticInvoice("lntbs", "50u")); + expect(parsed.msat_amount).toBe(5_000_000); +}); + +test("parseInvoice supports whole-bitcoin amounts without a multiplier", () => { + const parsed = parseInvoice(syntheticInvoice("lntbs", "1")); + expect(parsed.msat_amount).toBe(100_000_000_000); +}); + +test("validInvoice accepts lightning URI prefixes", () => { + const signet = syntheticInvoice("lntbs"); + expect(validInvoice(`LIGHTNING:${signet.toUpperCase()}`, "signet")).toBe(signet); +}); + +test("validInvoice rejects bad checksums and mixed casing", () => { + const signet = syntheticInvoice("lntbs"); + const replacement = signet.endsWith("q") ? "p" : "q"; + expect(validInvoice(signet.slice(0, -1) + replacement, "signet")).toBe(""); + expect(validInvoice(signet.slice(0, 10).toUpperCase() + signet.slice(10), "signet")).toBe(""); +}); + +test("parseInvoice rejects a structurally valid invoice without a payment hash", () => { + const hrp = "lntbs10u"; + const data = `${"q".repeat(7)}dqq${"q".repeat(104)}`; + const invoice = `${hrp}1${data}${checksum(hrp, data)}`; + expect(() => parseInvoice(invoice)).toThrow("payment hash"); +}); + +test("parseInvoice recovers the payee from an official BOLT11 vector", () => { + const invoice = "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql"; + const parsed = parseInvoice(validInvoice(invoice, "mainnet")); + expect(parsed.destination).toBe("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad"); +}); diff --git a/test/network.test.js b/test/network.test.js new file mode 100644 index 0000000..66b792f --- /dev/null +++ b/test/network.test.js @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { loadDiscoveryConfig } from "../assets/discovery.js"; +import { invoiceNetwork, invoicePrefix, resolveNetwork } from "../assets/network.js"; + +test("invoiceNetwork distinguishes all BOLT11 networks", () => { + expect(invoiceNetwork("lnbc10u1abc")).toBe("mainnet"); + expect(invoiceNetwork("lntb10u1abc")).toBe("testnet"); + expect(invoiceNetwork("lntbs10u1abc")).toBe("signet"); + expect(invoiceNetwork("lnbcrt10u1abc")).toBe("regtest"); + expect(invoiceNetwork("lightning:LNBC1ABC")).toBe("mainnet"); + expect(invoiceNetwork("not-an-invoice")).toBeNull(); +}); + +test("invoiceNetwork waits for a complete HRP before resolving ambiguous typed prefixes", () => { + expect(invoiceNetwork("lnbc")).toBeNull(); + expect(invoiceNetwork("lnbcr")).toBeNull(); + expect(invoiceNetwork("lntb")).toBeNull(); + expect(invoiceNetwork("lnbcrt1")).toBe("regtest"); + expect(invoiceNetwork("lntbs1")).toBe("signet"); +}); + +test("resolveNetwork uses deployment config and allows an explicit query override", () => { + expect(resolveNetwork("", "signet")).toBe("signet"); + expect(resolveNetwork("?network=regtest", "signet")).toBe("regtest"); + expect(resolveNetwork("?network=invalid", "signet")).toBe("signet"); + expect(resolveNetwork("", "invalid")).toBe("mainnet"); +}); + +test("invoicePrefix returns the visible prefix for each network", () => { + expect(invoicePrefix("mainnet")).toBe("lnbc"); + expect(invoicePrefix("testnet")).toBe("lntb"); + expect(invoicePrefix("signet")).toBe("lntbs"); + expect(invoicePrefix("regtest")).toBe("lnbcrt"); +}); + +test("loadDiscoveryConfig makes a signet deployment the root-page default", async () => { + const fetcher = async (path) => ({ + ok: true, + json: async () => path.endsWith("deployment.json") + ? { network: "signet", nostr_relays: ["wss://signet.example"] } + : ["wss://bundled.example"], + }); + const config = await loadDiscoveryConfig({ + search: "", + storage: { getItem: () => null }, + fetcher, + }); + expect(config.network).toBe("signet"); + expect(config.relays).toEqual(["wss://signet.example"]); +}); + +test("loadDiscoveryConfig gives query relay overrides highest precedence", async () => { + const fetcher = async () => ({ ok: true, json: async () => ({ network: "mainnet" }) }); + const config = await loadDiscoveryConfig({ + search: "?network=signet&nostr_relays=wss%3A%2F%2Fone.example%2Cwss%3A%2F%2Ftwo.example", + storage: { getItem: () => "wss://stored.example" }, + fetcher, + }); + expect(config.network).toBe("signet"); + expect(config.relays).toEqual(["wss://one.example", "wss://two.example"]); +}); diff --git a/test/nostr.test.js b/test/nostr.test.js new file mode 100644 index 0000000..ee13814 --- /dev/null +++ b/test/nostr.test.js @@ -0,0 +1,369 @@ +import { test, expect } from "bun:test"; +import { + parseOffer, + effectiveFeeMsat, + wrapFeature, + canServe, + selectOffers, + randomOffer, + credentialRank, + feeWithinAdvertised, + verifyAttestation, + attestationMessage, + normalizePubkey, + announcePoWBits, + KIND_OFFER, + PROTOCOL_VERSION, + MAX_REQUEST_POW, + minePowAsync, +} from "../assets/nostr.js"; +import { secp256k1, sha256, nip19 } from "../assets/nostr-bundle.js"; + +const ZBASE32_ALPHABET = "ybndrfg8ejkmcpqxot1uwisza345h769"; + +function zbase32Encode(bytes) { + let out = ""; + let buffer = 0; + let bitsLeft = 0; + for (const x of bytes) { + buffer = (buffer << 8) | x; + bitsLeft += 8; + while (bitsLeft >= 5) { + bitsLeft -= 5; + out += ZBASE32_ALPHABET[(buffer >> bitsLeft) & 0x1f]; + } + } + if (bitsLeft > 0) { + out += ZBASE32_ALPHABET[(buffer << (5 - bitsLeft)) & 0x1f]; + } + return out; +} + +function utf8(s) { + return new TextEncoder().encode(s); +} +function concat(a, b) { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} +function toHex(bytes) { + let s = ""; + for (const b of bytes) s += b.toString(16).padStart(2, "0"); + return s; +} + +// signLNDStyle produces a zbase32 recoverable signature the way LND would, for +// testing verifyAttestation. +function signLNDStyle(privHex, nostrPub) { + const msg = concat(utf8("Lightning Signed Message:"), utf8(attestationMessage(nostrPub))); + const digest = sha256(sha256(msg)); + const sig = secp256k1.sign(digest, privHex); + const header = 27 + sig.recovery + 4; // compressed + const raw = new Uint8Array(65); + raw[0] = header; + raw.set(sig.toCompactRawBytes(), 1); + return zbase32Encode(raw); +} + +function makeOfferEvent(overrides = {}, contentOverrides = {}) { + const content = { + base_fee_msat: 1000, + fee_ppm: 1000, + min_amount_msat: 10000, + max_amount_msat: 1000000000, + max_expiry_seconds: 604800, + min_request_pow: 20, + features: ["pay_bolt11", "wrap_bolt11"], + relays: ["wss://relay.example"], + ...contentOverrides, + }; + return { + id: "f".repeat(64), // 0 leading zero bits -> pow 0, fine for parsing + pubkey: "a".repeat(64), + kind: KIND_OFFER, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["d", PROTOCOL_VERSION], + ["n", "mainnet"], + ], + content: JSON.stringify(content), + ...overrides, + }; +} + +test("parseOffer accepts a valid offer", () => { + const offer = parseOffer(makeOfferEvent()); + expect(offer).not.toBeNull(); + expect(offer.base_fee_msat).toBe(1000); + expect(offer.fee_ppm).toBe(1000); + expect(offer.features).toContain("wrap_bolt11"); +}); + +test("parseOffer rejects wrong protocol version", () => { + const evt = makeOfferEvent({ tags: [["d", "lnproxy-v2"], ["n", "mainnet"]] }); + expect(parseOffer(evt)).toBeNull(); +}); + +test("parseOffer rejects wrong network", () => { + const evt = makeOfferEvent(); + expect(parseOffer(evt, { network: "testnet" })).toBeNull(); +}); + +test("parseOffer rejects stale offers", () => { + const evt = makeOfferEvent({ created_at: Math.floor(Date.now() / 1000) - 7200 }); + expect(parseOffer(evt)).toBeNull(); +}); + +test("parseOffer rejects invalid content", () => { + const evt = makeOfferEvent({}, {}); + evt.content = "not json"; + expect(parseOffer(evt)).toBeNull(); +}); + +test("parseOffer rejects max < min", () => { + const evt = makeOfferEvent({}, { min_amount_msat: 100, max_amount_msat: 50 }); + expect(parseOffer(evt)).toBeNull(); +}); + +test("parseOffer rejects dishonest or malformed NIP-13 commitments", () => { + const dishonest = makeOfferEvent({ + id: "0" + "f".repeat(63), + tags: [["d", PROTOCOL_VERSION], ["n", "mainnet"], ["nonce", "1", "8"]], + }); + expect(parseOffer(dishonest)).toBeNull(); + + const honest = makeOfferEvent({ + id: "00" + "f".repeat(62), + tags: [["d", PROTOCOL_VERSION], ["n", "mainnet"], ["nonce", "1", "8"]], + }); + const parsed = parseOffer(honest); + expect(parsed.committed_pow).toBe(8); + expect(parsed.actual_pow).toBe(8); +}); + +test("parseOffer rejects unsafe numeric fields and excessive request work", () => { + expect(parseOffer(makeOfferEvent({}, { base_fee_msat: "1000junk" }))).toBeNull(); + expect(parseOffer(makeOfferEvent({}, { fee_ppm: -1 }))).toBeNull(); + expect(parseOffer(makeOfferEvent({}, { min_request_pow: MAX_REQUEST_POW + 1 }))).toBeNull(); + expect(parseOffer(makeOfferEvent({}, { min_request_pow: "invalid" }))).toBeNull(); +}); + +test("parseOffer keeps only websocket relay URLs", () => { + const offer = parseOffer(makeOfferEvent({}, { + relays: ["wss://relay.example", "ws://127.0.0.1:7777", "https://not-websocket.example", 7], + })); + expect(offer.relays).toEqual(["wss://relay.example", "ws://127.0.0.1:7777"]); +}); + +test("effectiveFeeMsat computes base + proportional", () => { + const offer = parseOffer(makeOfferEvent()); + // 1000 + 0.1% of 1_000_000 = 2000 + expect(effectiveFeeMsat(offer, 1_000_000)).toBe(2000); + expect(effectiveFeeMsat(offer, 0)).toBe(1000); +}); + +test("wrapFeature maps formats", () => { + expect(wrapFeature("")).toBe("wrap_bolt11"); + expect(wrapFeature("bolt11")).toBe("wrap_bolt11"); + expect(wrapFeature("bolt11_blinded")).toBe("wrap_bolt11_blinded"); + expect(wrapFeature("bolt12")).toBe("wrap_bolt12"); +}); + +test("canServe respects amount bounds and features", () => { + const offer = parseOffer(makeOfferEvent()); + expect(canServe(offer, 500_000, "bolt11")).toBe(true); + expect(canServe(offer, 5_000, "bolt11")).toBe(false); + expect(canServe(offer, 2_000_000_000, "bolt11")).toBe(false); + expect(canServe(offer, 500_000, "bolt12")).toBe(false); +}); + +test("selectOffers sorts cheapest-first and filters", () => { + const cheap = parseOffer(makeOfferEvent({ pubkey: "1".repeat(64) }, { base_fee_msat: 500, fee_ppm: 100 })); + const pricey = parseOffer(makeOfferEvent({ pubkey: "2".repeat(64) }, { base_fee_msat: 5000, fee_ppm: 5000 })); + const tooSmall = parseOffer(makeOfferEvent({ pubkey: "3".repeat(64) }, { min_amount_msat: 999_999_999 })); + + const selected = selectOffers([pricey, cheap, tooSmall], { amountMsat: 1_000_000, wrap: "bolt11", minPow: 0 }); + expect(selected.length).toBe(2); + expect(selected[0].pubkey).toBe("1".repeat(64)); // cheap first + expect(selected[1].pubkey).toBe("2".repeat(64)); +}); + +test("selectOffers filters by minimum committed pow", () => { + const offer = parseOffer(makeOfferEvent()); + // The fake event has no nonce commitment, regardless of accidental hash work. + const selected = selectOffers([offer], { amountMsat: 1_000_000, minPow: 1 }); + expect(selected.length).toBe(0); +}); + +test("randomOffer selects uniformly-addressable positions", () => { + const offers = [{ pubkey: "first" }, { pubkey: "middle" }, { pubkey: "last" }]; + expect(randomOffer([], () => 0)).toBeNull(); + expect(randomOffer(offers, () => 0)).toBe(offers[0]); + expect(randomOffer(offers, () => 0.34)).toBe(offers[1]); + expect(randomOffer(offers, () => 0.999999)).toBe(offers[2]); +}); + +test("minePowAsync commits to and reaches the requested difficulty", async () => { + const mined = await minePowAsync({ + kind: 21821, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", "a".repeat(64)]], + content: "test", + pubkey: "b".repeat(64), + }, 8, { chunkSize: 16 }); + expect(mined.tags.find((tag) => tag[0] === "nonce")[2]).toBe("8"); + expect(Number.parseInt(mined.id.slice(0, 2), 16)).toBeLessThan(1); +}); + +test("credentialRank prefers attested then pow then anon", () => { + expect(credentialRank({ attested: true })).toBe(2); + expect(credentialRank({ identity_pow_bits: 12 })).toBe(1); + expect(credentialRank({ identity_pow_bits: 0 })).toBe(0); + expect(credentialRank({})).toBe(0); +}); + +test("selectOffers breaks fee ties by credential strength", () => { + const base = { base_fee_msat: 1000, fee_ppm: 1000 }; + // Build a real attestation so the offer is genuinely verified. + const priv = secp256k1.utils.randomPrivateKey(); + const privHex = toHex(priv); + const nodePub = toHex(secp256k1.getPublicKey(priv, true)); + const attestedPubkey = "a".repeat(64); + const sig = signLNDStyle(privHex, attestedPubkey); + const attested = parseOffer( + makeOfferEvent({ pubkey: attestedPubkey }, { ...base, node_pubkey: nodePub, node_sig: sig }), + ); + const anon = parseOffer(makeOfferEvent({ pubkey: "b".repeat(64) }, base)); + expect(attested.attested).toBe(true); + const selected = selectOffers([anon, attested], { amountMsat: 1_000_000, minPow: 0 }); + expect(selected[0].pubkey).toBe(attestedPubkey); // attested wins the tie +}); + +test("verifyAttestation accepts a valid LND-style signature", () => { + const priv = secp256k1.utils.randomPrivateKey(); + const privHex = toHex(priv); + const nodePub = toHex(secp256k1.getPublicKey(priv, true)); + const nostrPub = "c".repeat(64); + const sig = signLNDStyle(privHex, nostrPub); + expect(verifyAttestation(nostrPub, nodePub, sig)).toBe(true); +}); + +test("verifyAttestation rejects wrong nostr pubkey", () => { + const priv = secp256k1.utils.randomPrivateKey(); + const privHex = toHex(priv); + const nodePub = toHex(secp256k1.getPublicKey(priv, true)); + const sig = signLNDStyle(privHex, "c".repeat(64)); + // Signature is over a different message -> recovers a different key. + expect(verifyAttestation("d".repeat(64), nodePub, sig)).toBe(false); +}); + +test("verifyAttestation rejects mismatched node pubkey", () => { + const priv = secp256k1.utils.randomPrivateKey(); + const privHex = toHex(priv); + const nostrPub = "c".repeat(64); + const sig = signLNDStyle(privHex, nostrPub); + const otherPub = toHex(secp256k1.getPublicKey(secp256k1.utils.randomPrivateKey(), true)); + expect(verifyAttestation(nostrPub, otherPub, sig)).toBe(false); +}); + +test("verifyAttestation rejects garbage", () => { + expect(verifyAttestation("c".repeat(64), "02abc", "not-zbase32!")).toBe(false); + expect(verifyAttestation("c".repeat(64), "", "")).toBe(false); +}); + +test("normalizePubkey accepts hex and npub", () => { + const hex = "a".repeat(64); + expect(normalizePubkey(hex)).toBe(hex); + expect(normalizePubkey(hex.toUpperCase())).toBe(hex); + const npub = nip19.npubEncode(hex); + expect(normalizePubkey(npub)).toBe(hex); + expect(normalizePubkey(" " + npub + " ")).toBe(hex); +}); + +test("normalizePubkey rejects invalid input", () => { + expect(normalizePubkey("")).toBeNull(); + expect(normalizePubkey("nope")).toBeNull(); + expect(normalizePubkey("a".repeat(63))).toBeNull(); + expect(normalizePubkey("npub1invalid")).toBeNull(); +}); + +// referencePoW recomputes the spec formula independently so the test does not +// just mirror the implementation. +function referencePoW(pubHex, nonce) { + const prefix = new TextEncoder().encode("lnproxy"); + const pub = new Uint8Array(32); + for (let i = 0; i < 32; i++) pub[i] = parseInt(pubHex.slice(i * 2, i * 2 + 2), 16); + const nb = new Uint8Array(32); + let n = BigInt(nonce); + for (let i = 31; i >= 0; i--) { + nb[i] = Number(n & 0xffn); + n >>= 8n; + } + const buf = new Uint8Array(prefix.length + 64); + buf.set(prefix, 0); + buf.set(pub, prefix.length); + buf.set(nb, prefix.length + 32); + const digest = sha256(buf); + let bits = 0; + for (const b of digest) { + if (b === 0) { + bits += 8; + continue; + } + let x = b, + c = 0; + while ((x & 0x80) === 0) { + c++; + x <<= 1; + } + bits += c; + break; + } + return bits; +} + +test("announcePoWBits matches the reference formula and finds work", () => { + const pub = "1".repeat(64); + // Brute force a nonce yielding at least 8 leading zero bits. + let nonce = 1; + while (referencePoW(pub, nonce) < 8) nonce++; + const nonceHex = "0x" + nonce.toString(16); + const bits = announcePoWBits(pub, nonceHex); + expect(bits).toBe(referencePoW(pub, nonce)); + expect(bits).toBeGreaterThanOrEqual(8); +}); + +test("announcePoWBits returns 0 for missing/zero/invalid nonce", () => { + const pub = "1".repeat(64); + expect(announcePoWBits(pub, "")).toBe(0); + expect(announcePoWBits(pub, "0x0")).toBe(0); + expect(announcePoWBits(pub, "0x00")).toBe(0); + expect(announcePoWBits(pub, "nothex")).toBe(0); + expect(announcePoWBits("bad", "0x1")).toBe(0); +}); + +test("announcePoWBits accepts nonce without 0x prefix", () => { + const pub = "2".repeat(64); + let nonce = 1; + while (referencePoW(pub, nonce) < 6) nonce++; + expect(announcePoWBits(pub, nonce.toString(16))).toBe(referencePoW(pub, nonce)); +}); + +test("feeWithinAdvertised enforces advertised schedule", () => { + const offer = parseOffer(makeOfferEvent()); // base 1000, ppm 1000 + const original = 1_000_000; + // advertised fee = 2000 msat; allowance = 2*2000 + 1000 = 5000 + expect(feeWithinAdvertised(offer, original, original + 5000)).toBe(true); + expect(feeWithinAdvertised(offer, original, original + 6000)).toBe(false); + // negative surplus is invalid + expect(feeWithinAdvertised(offer, original, original - 1)).toBe(false); +}); + +test("feeWithinAdvertised requires exact surplus with routing_msat", () => { + const offer = parseOffer(makeOfferEvent()); + const original = 1_000_000; + expect(feeWithinAdvertised(offer, original, original + 3000, 3000)).toBe(true); + expect(feeWithinAdvertised(offer, original, original + 2999, 3000)).toBe(false); +}); From 48de81a35a8d31bd53ef9dc6d08a574e50de1dc9 Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 15 Jul 2026 21:08:51 +0200 Subject: [PATCH 02/11] build: add production deployment and end-to-end validation Add the curated Nginx image, full bitcoind/LND/nostr Playwright settlement stack, pre-commit and CI checks, and GitHub Pages publishing gated on all test jobs. Document signet configuration, diagnostics, privacy, and operations. --- .dockerignore | 7 + .github/workflows/webui.yml | 122 ++++++++++++ .nojekyll | 0 .pre-commit-config.yaml | 21 ++ Dockerfile | 4 + README.md | 113 ++++++++++- e2e/docker-compose.e2e.yml | 166 ++++++++++++++++ e2e/playwright/package.json | 11 ++ e2e/playwright/playwright.config.ts | 38 ++++ e2e/playwright/specs/wrap.spec.ts | 290 ++++++++++++++++++++++++++++ e2e/scripts/setup.sh | 106 ++++++++++ 11 files changed, 869 insertions(+), 9 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/webui.yml create mode 100644 .nojekyll create mode 100644 .pre-commit-config.yaml create mode 100644 Dockerfile create mode 100644 e2e/docker-compose.e2e.yml create mode 100644 e2e/playwright/package.json create mode 100644 e2e/playwright/playwright.config.ts create mode 100644 e2e/playwright/specs/wrap.spec.ts create mode 100644 e2e/scripts/setup.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c2f8016 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +* +!Dockerfile +!index.html +!signet.html +!.nojekyll +!assets +!assets/** diff --git a/.github/workflows/webui.yml b/.github/workflows/webui.yml new file mode 100644 index 0000000..140e5cd --- /dev/null +++ b/.github/workflows/webui.yml @@ -0,0 +1,122 @@ +name: webui + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.11" + - name: install + run: bun install --frozen-lockfile + - name: verify vendored nostr bundle is up to date + run: | + bun run build:nostr + git diff --exit-code assets/nostr-bundle.js + - name: test + run: bun test test/ + + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.11" + - run: bun install --frozen-lockfile + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: pip install pre-commit + - run: pre-commit run --all-files + + e2e: + runs-on: ubuntu-latest + steps: + # The e2e compose builds the lnproxy image from a sibling relay checkout. + - uses: actions/checkout@v4 + with: + path: lnproxy-webui2 + - uses: actions/checkout@v4 + with: + repository: lnproxy/lnproxy-relay + path: lnproxy-relay + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.11" + - name: bring up the e2e stack + working-directory: lnproxy-webui2/e2e + run: | + mkdir -p tmp + docker compose -f docker-compose.e2e.yml up -d --build + # Wait for the channel setup to finish and lnproxy to publish. + for i in $(seq 1 60); do + if docker logs lnproxy-e2e-lnproxy 2>&1 | grep -q "published offer"; then + echo "lnproxy is advertising"; break + fi + sleep 5 + done + - name: install playwright + working-directory: lnproxy-webui2/e2e/playwright + run: | + bun install --frozen-lockfile + bunx playwright install --with-deps chromium + - name: run e2e + working-directory: lnproxy-webui2/e2e/playwright + run: bunx playwright test + - name: dump logs on failure + if: failure() + working-directory: lnproxy-webui2/e2e + run: docker compose -f docker-compose.e2e.yml logs --no-color | tail -300 + - name: tear down + if: always() + working-directory: lnproxy-webui2/e2e + run: docker compose -f docker-compose.e2e.yml down -v + + pages: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: + - test + - pre-commit + - e2e + runs-on: ubuntu-latest + permissions: + contents: read + pages: read + steps: + - uses: actions/checkout@v4 + - name: stage public files + run: | + mkdir _site + cp index.html signet.html .nojekyll _site/ + cp -R assets _site/assets + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v5 + with: + path: _site + include-hidden-files: true + + deploy-pages: + needs: pages + runs-on: ubuntu-latest + concurrency: + group: github-pages + cancel-in-progress: false + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..dd186ee --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + exclude: ^assets/(nostr-bundle\.js|qrcode\.js|modern-normalize\.css)$ + - id: end-of-file-fixer + exclude: ^assets/(nostr-bundle\.js|qrcode\.js|modern-normalize\.css)$ + - id: check-merge-conflict + - id: check-json + exclude: ^bun\.lock$ + - id: mixed-line-ending + args: [--fix=lf] + - repo: local + hooks: + - id: bun-test + name: bun test + entry: bun test test/ + language: system + pass_filenames: false + files: ^(assets/.*\.js|test/.*\.js)$ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..711079b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.29-alpine + +COPY index.html signet.html .nojekyll /usr/share/nginx/html/ +COPY assets/ /usr/share/nginx/html/assets/ diff --git a/README.md b/README.md index 8ae6c24..cf1af7f 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,108 @@ # lnproxy-webui2 -New webui for lnproxy +Static, nostr-only web UI for lnproxy. -The new ui is just static files with some minimalistic and easily verifiable js code -that interacts with https://github.com/lnproxy/lnproxy through the REST API. +The UI is static files with minimal, auditable JavaScript. It discovers lnproxy +providers through nostr and sends encrypted wrap requests using the protocol in +[the spec](https://github.com/lnproxy/spec/blob/main/nostr.md). There is no +bundled HTTP provider list or automatic HTTP fallback. -This change of architecture makes it much more secure for users since they -can run it locally and not have to trust the owner of lnproxy.org. -Thus, I took the opportunity to allow the code to request proxy invoices -from arbitrary relays and added some code that verifies that proxy invoices -returned have matching payment hashes and honor the requested -description and routing_msat fields. +## Decentralized provider discovery over nostr + +On page load the UI subscribes to configured nostr relays, collects provider +offers, and verifies their signatures, freshness, network tags, and proof of +work. Once an amount-bearing invoice is entered, it filters offers by amount, +feature, minimum proof of work (20 bits by default), and optional pinned +provider, then randomly selects one eligible provider. The user can select +another eligible provider before wrapping. Returned proxy invoices are checked +for payment hash, destination, description, routing budget, and the provider's +advertised fee. +Offers requiring more than 24 bits of request proof of work are ignored, and +accepted request work is mined in yielding chunks so the page stays responsive. + +The selected network is visible and editable beside the lnproxy name. Mainnet, +signet, testnet, and regtest can be selected directly. Entering an invoice +switches the network automatically from its BOLT11 prefix and refreshes offers +for that network. The repository defaults to mainnet through +`assets/deployment.json`. For a signet deployment, serve an override at the same +path: + +```json +{ + "network": "signet", + "nostr_relays": ["wss://nos.lol", "wss://relay.primal.net"] +} +``` + +The static `signet.html` entry point redirects to `?network=signet` for servers +that cannot override deployment assets. Explicit `?network=` and +`?nostr_relays=` query parameters remain available for testing; a +`nostrRelays` localStorage CSV can also override the nostr relay set. Network is +not read from localStorage so a stale hidden setting cannot select the wrong +Bitcoin network. + +Build the static Nginx image directly from this repository: + + docker build -t lnproxy-webui . + +The image copies only the public HTML and `assets/` tree; repository metadata, +tests, and local e2e credentials are not included. + +Privacy note: a provider sees the invoice it is asked to pay (destination, +amount, and description) but not who is paying or where the payment originates. +Changing the proxy invoice description cannot hide the original invoice from +the provider. This is the destination-side exposure described by Kappos et +al., *An Empirical Analysis of Privacy in the Lightning Network* (USENIX +Security 2021). Because nostr relays see your IP, run over Tor for best privacy. + +## Browser diagnostics + +The UI writes structured diagnostics to the browser console with an +`[lnproxy +123ms]`-style prefix. Logs cover deployment configuration, relay +source and connection state, network changes, offer acceptance and filtering, +provider selection, request proof of work, per-relay publication, response +transport, invoice validation, and total timings. Public nostr provider keys, +event IDs, fees, limits, features, relay URLs, and PoW values are included where +useful. + +Diagnostics deliberately exclude invoice text, memo/description contents, +payment hashes, ciphertext, decrypted response bodies, and ephemeral nostr +keys. Chrome hides `console.debug` entries unless the **Verbose** level is +enabled; key lifecycle events use `console.info` and remain visible normally. + +## GitHub Pages + +`.github/workflows/webui.yml` stages only public files and deploys them with the +official GitHub Pages actions on pushes to `main`, after unit, pre-commit, and +end-to-end jobs pass. Set the repository's Pages source to **GitHub Actions** +once under **Settings > Pages**; subsequent successful pushes publish +automatically. + +## Static files and the vendored nostr bundle + +The site itself needs no build step; serve the files as-is. The only generated +artifact is the vendored, committed nostr-tools bundle +`assets/nostr-bundle.js`. To rebuild it (and run the tests) you need +[Bun](https://bun.sh): + + bun install + bun run build:nostr # regenerates assets/nostr-bundle.js + bun test # runs the discovery/validation unit tests + +The bundle re-exports only the small nostr-tools surface lnproxy uses (see +`assets/nostr-tools-entry.js`), keeping it auditable. + +## End-to-end tests + +`e2e/` contains a full browser end-to-end test: a Docker stack with a regtest +Lightning network (bitcoind + two LND nodes with a channel), a nostr relay, and +the lnproxy nostr-relay backend, driven by Playwright. The test issues a real +invoice from one LND node, discovers the provider over nostr in a real browser, +wraps the invoice through the backend (which talks to the other LND node), and +checks the client-side verification. + + cd e2e + docker compose -f docker-compose.e2e.yml up -d --build + # wait for the `setup` service to finish and lnproxy to publish its offer + cd playwright && bun install && bunx playwright install chromium && bunx playwright test + cd .. && docker compose -f docker-compose.e2e.yml down -v diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml new file mode 100644 index 0000000..69a4203 --- /dev/null +++ b/e2e/docker-compose.e2e.yml @@ -0,0 +1,166 @@ +# End-to-end stack for the lnproxy nostr flow. +# +# Brings up a regtest Lightning network (bitcoind + two LND nodes with a +# channel), a nostr relay, the lnproxy nostr-relay backend wired to one of the +# LND nodes, and the static web UI. The Playwright test then issues an invoice +# from the payee node, discovers the provider over nostr in a real browser, and +# wraps it. +# +# The lnproxy image builds directly from the sibling relay repository. Run from +# this directory: +# +# docker compose -f docker-compose.e2e.yml up -d --build +# # wait for the `setup` service to exit 0 +# docker compose -f docker-compose.e2e.yml down -v + +services: + bitcoind: + image: polarlightning/bitcoind:27.0 + container_name: lnproxy-e2e-bitcoind + command: + - bitcoind + - -regtest + - -server + - -txindex + - -rpcuser=test + - -rpcpassword=test + - -rpcbind=0.0.0.0 + - -rpcallowip=0.0.0.0/0 + - -zmqpubrawblock=tcp://0.0.0.0:28334 + - -zmqpubrawtx=tcp://0.0.0.0:28335 + - -fallbackfee=0.0002 + ports: + - "18443:18443" + healthcheck: + test: ["CMD-SHELL", "bitcoin-cli -regtest -rpcuser=test -rpcpassword=test getblockchaininfo"] + interval: 3s + timeout: 5s + retries: 30 + + lnd-provider: + image: polarlightning/lnd:0.18.3-beta + container_name: lnproxy-e2e-lnd-provider + depends_on: + bitcoind: + condition: service_healthy + command: + - lnd + - --noseedbackup + - --tlsextradomain=lnd-provider + - --bitcoin.active + - --bitcoin.regtest + - --bitcoin.node=bitcoind + - --bitcoind.rpchost=bitcoind + - --bitcoind.rpcuser=test + - --bitcoind.rpcpass=test + - --bitcoind.zmqpubrawblock=tcp://bitcoind:28334 + - --bitcoind.zmqpubrawtx=tcp://bitcoind:28335 + - --rpclisten=0.0.0.0:10009 + - --restlisten=0.0.0.0:8080 + - --listen=0.0.0.0:9735 + - --alias=provider + - --maxpendingchannels=10 + - --trickledelay=50 + ports: + - "18081:8080" + volumes: + - lnd-provider-home:/home/lnd/.lnd + healthcheck: + test: ["CMD-SHELL", "lncli --lnddir=/home/lnd/.lnd --network=regtest getinfo >/dev/null 2>&1 || exit 1"] + interval: 3s + timeout: 5s + retries: 40 + + lnd-payee: + image: polarlightning/lnd:0.18.3-beta + container_name: lnproxy-e2e-lnd-payee + depends_on: + bitcoind: + condition: service_healthy + command: + - lnd + - --noseedbackup + - --tlsextradomain=lnd-payee + - --bitcoin.active + - --bitcoin.regtest + - --bitcoin.node=bitcoind + - --bitcoind.rpchost=bitcoind + - --bitcoind.rpcuser=test + - --bitcoind.rpcpass=test + - --bitcoind.zmqpubrawblock=tcp://bitcoind:28334 + - --bitcoind.zmqpubrawtx=tcp://bitcoind:28335 + - --rpclisten=0.0.0.0:10009 + - --restlisten=0.0.0.0:8080 + - --listen=0.0.0.0:9735 + - --alias=payee + - --trickledelay=50 + ports: + - "18082:8080" + volumes: + - lnd-payee-home:/home/lnd/.lnd + healthcheck: + test: ["CMD-SHELL", "lncli --lnddir=/home/lnd/.lnd --network=regtest getinfo >/dev/null 2>&1 || exit 1"] + interval: 3s + timeout: 5s + retries: 40 + + nostr-relay: + image: scsibug/nostr-rs-relay:latest + container_name: lnproxy-e2e-nostr-relay + volumes: + - ../../lnproxy-relay/nostr/testdata/relay-config.toml:/usr/src/app/config.toml:ro + ports: + - "7777:8080" + + # Opens a channel provider -> payee and waits for it to be active, then exits. + setup: + image: polarlightning/lnd:0.18.3-beta + container_name: lnproxy-e2e-setup + depends_on: + lnd-provider: + condition: service_healthy + lnd-payee: + condition: service_healthy + entrypoint: ["/bin/bash", "/setup.sh"] + volumes: + - ./scripts/setup.sh:/setup.sh:ro + - lnd-provider-home:/provider:ro + - lnd-payee-home:/payee:ro + - shared:/shared + - ./tmp:/out + + lnproxy: + build: + context: ../../lnproxy-relay + image: lnproxy-nostr-relay:e2e + container_name: lnproxy-e2e-lnproxy + depends_on: + setup: + condition: service_completed_successfully + command: + - --lnd=https://lnd-provider:8080 + - --lnd-cert=/shared/provider-tls.cert + - --nostr-relays=ws://nostr-relay:8080 + - --network=regtest + - --features=pay_bolt11,pay_bolt11_blinded,wrap_bolt11 + - --min-request-pow=0 + - --announce-pow=0 + - --min-msat=1000 + - --max-msat=1000000000 + - --nostr-key=/shared/nostr.key + - /shared/admin.macaroon + volumes: + - shared:/shared + + webui: + build: + context: .. + image: lnproxy-webui:e2e + container_name: lnproxy-e2e-webui + ports: + - "8088:80" + +volumes: + lnd-provider-home: + lnd-payee-home: + shared: diff --git a/e2e/playwright/package.json b/e2e/playwright/package.json new file mode 100644 index 0000000..25b5172 --- /dev/null +++ b/e2e/playwright/package.json @@ -0,0 +1,11 @@ +{ + "name": "lnproxy-webui2-e2e", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.49.1" + } +} diff --git a/e2e/playwright/playwright.config.ts b/e2e/playwright/playwright.config.ts new file mode 100644 index 0000000..d05d87e --- /dev/null +++ b/e2e/playwright/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Playwright config for the lnproxy nostr end-to-end test. +// +// Prerequisites (from e2e/): +// docker compose -f docker-compose.e2e.yml up -d --build +// # wait for the `setup` service to complete +// +// Environment variables: +// WEBUI_URL - static site URL (default: http://127.0.0.1:8088) +// PAYEE_REST - payee LND REST base URL (default: http://127.0.0.1:18082) +// NOSTR_RELAY_WS - relay websocket URL (default: ws://127.0.0.1:7777) + +const isCI = !!process.env.CI; + +export default defineConfig({ + testDir: "./specs", + timeout: 180_000, + expect: { timeout: 60_000 }, + fullyParallel: false, + forbidOnly: isCI, + retries: 0, + workers: 1, + reporter: isCI ? [["github"], ["list"]] : [["list"]], + use: { + baseURL: process.env.WEBUI_URL || "http://127.0.0.1:8088", + trace: "on", + screenshot: "only-on-failure", + actionTimeout: 30_000, + navigationTimeout: 30_000, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/e2e/playwright/specs/wrap.spec.ts b/e2e/playwright/specs/wrap.spec.ts new file mode 100644 index 0000000..1a5b916 --- /dev/null +++ b/e2e/playwright/specs/wrap.spec.ts @@ -0,0 +1,290 @@ +import { test, expect } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +// The payee LND uses a self-signed cert in regtest; accept it for the test-only +// REST call that creates an invoice. +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TMP = path.resolve(__dirname, "../../tmp"); + +const PAYEE_REST = process.env.PAYEE_REST || "https://127.0.0.1:18082"; +const NOSTR_RELAY_WS = process.env.NOSTR_RELAY_WS || "ws://127.0.0.1:7777"; +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + +function paymentHashWords(hashHex: string): string { + let accumulator = 0; + let bits = 0; + let words = ""; + for (const byte of Buffer.from(hashHex, "hex")) { + accumulator = (accumulator << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + words += BECH32_CHARSET[(accumulator >>> bits) & 31]; + } + accumulator &= (1 << bits) - 1; + } + if (bits > 0) words += BECH32_CHARSET[(accumulator << (5 - bits)) & 31]; + return words; +} + +function payeeMacaroon(): string { + return readFileSync(path.join(TMP, "payee.macaroon.hex"), "utf8").trim(); +} + +// Create a real invoice on the payee LND node via its REST API. Returns the +// bolt11 payment request and the r_hash (hex) so settlement can be checked. +async function createInvoice(amountSat: number, memo: string): Promise<{ invoice: string; rHashHex: string }> { + const res = await fetch(`${PAYEE_REST}/v1/invoices`, { + method: "POST", + headers: { + "Grpc-Metadata-macaroon": payeeMacaroon(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ value: String(amountSat), memo }), + }); + if (!res.ok) { + throw new Error(`payee addinvoice failed: ${res.status} ${await res.text()}`); + } + const body = (await res.json()) as { payment_request?: string; r_hash?: string }; + if (!body.payment_request) throw new Error("no payment_request in response"); + // r_hash comes back base64; convert to hex for lookup. + const rHashHex = Buffer.from(body.r_hash ?? "", "base64").toString("hex"); + return { invoice: body.payment_request, rHashHex }; +} + +// Pay a bolt11 invoice from the payee node using the router v2 endpoint (the +// modern, reliable path; the legacy SendPaymentSync mishandles hold-invoice +// latency). This is the self-loop: the payee pays the provider's proxy invoice, +// and the provider then pays the payee's original invoice back, settling both +// via the shared payment hash. Returns the final payment status. +async function payFromPayee(bolt11: string): Promise<{ ok: boolean; status: string }> { + const res = await fetch(`${PAYEE_REST}/v2/router/send`, { + method: "POST", + headers: { + "Grpc-Metadata-macaroon": payeeMacaroon(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + payment_request: bolt11, + timeout_seconds: 90, + no_inflight_updates: true, + fee_limit_sat: "1000", + }), + }); + // The endpoint streams newline-delimited JSON; with no_inflight_updates the + // last non-empty line carries the terminal payment state. + const text = await res.text(); + let status = "UNKNOWN"; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const obj = JSON.parse(trimmed); + const s = obj.result?.status ?? obj.payment?.status ?? obj.status; + if (s) status = s; + } catch (_e) { + /* ignore non-JSON keepalive lines */ + } + } + return { ok: status === "SUCCEEDED", status }; +} + +// Poll the payee's original invoice until it is SETTLED (proves the relay paid +// it back and the preimage propagated). +async function waitForSettled(rHashHex: string, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const res = await fetch(`${PAYEE_REST}/v1/invoice/${rHashHex}`, { + headers: { "Grpc-Metadata-macaroon": payeeMacaroon() }, + }); + if (res.ok) { + const body = (await res.json()) as { settled?: boolean; state?: string }; + if (body.settled === true || body.state === "SETTLED") return true; + } + await new Promise((r) => setTimeout(r, 1000)); + } + return false; +} + +// Extract the wrapped (proxy) invoice from the rendered QR link. +async function proxyInvoiceFromPage(page: import("@playwright/test").Page): Promise { + const href = await page.locator('#result a[href^="lightning:"]').first().getAttribute("href"); + if (!href) throw new Error("no lightning: link rendered"); + return href.replace(/^lightning:/i, "").toLowerCase(); +} + +test("discovers a provider over nostr and wraps a real LND invoice", async ({ page }) => { + const originalMemo = "lnproxy e2e"; + const customDescription = "lnproxy e2e custom description"; + const { invoice, rHashHex } = await createInvoice(50_000, originalMemo); + const encodedPaymentHash = paymentHashWords(rHashHex); + expect(invoice.startsWith("lnbcrt")).toBeTruthy(); + + const errors: string[] = []; + const diagnosticRecords: Promise<{ text: string; args: unknown[] }>[] = []; + page.on("console", (msg) => { + const text = msg.text(); + if (text.includes("[lnproxy +")) { + diagnosticRecords.push(Promise.all(msg.args().map(async (arg) => { + try { + return await arg.jsonValue(); + } catch (_error) { + return "[unserializable console argument]"; + } + })).then((args) => ({ text, args }))); + } + if (msg.type() !== "error") return; + // nostr-tools logs benign websocket-close noise when it tears down relay + // connections after the exchange; ignore those. + if (/WebSocket is already in CLOSING or CLOSED|WebSocket connection|ws error|failed to connect/i.test(text)) { + return; + } + errors.push(text); + }); + const cdp = await page.context().newCDPSession(page); + await cdp.send("Network.enable"); + const requestCiphertexts: string[] = []; + const ephemeralPublicKeys: string[] = []; + cdp.on("Network.webSocketFrameSent", ({ response }) => { + try { + const message = JSON.parse(response.payloadData); + const event = message?.[0] === "EVENT" ? message[1] : null; + if (event?.kind !== 21821) return; + requestCiphertexts.push(event.content); + ephemeralPublicKeys.push(event.pubkey); + } catch (_error) { + // Ignore non-JSON websocket frames. + } + }); + + // Point the UI at the local relay. Entering the invoice should switch the + // default mainnet deployment to regtest automatically, even if deployment + // configuration has not finished loading yet. + let releaseConfig = () => {}; + const configGate = new Promise((resolve) => { + releaseConfig = resolve; + }); + await page.route("**/assets/deployment.json", async (route) => { + await configGate; + await route.continue(); + }); + const url = `/?nostr_relays=${encodeURIComponent(NOSTR_RELAY_WS)}`; + await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.fill("#min_pow", "0"); + await page.fill("#invoice", invoice); + releaseConfig(); + await expect(page.locator("#network_select")).toHaveValue("regtest"); + await expect(page).toHaveURL(/network=regtest/); + + // Page-load discovery should filter and select once the invoice is entered. + const providerRadios = page.locator('#provider_panel input[name="provider"]'); + await expect(providerRadios).toBeVisible({ timeout: 60_000 }); + await expect(providerRadios).toBeChecked(); + await page.click("#atoggle"); + await page.fill("#description", customDescription); + + // An eligible provider is preselected. Change the next invoice's network + // immediately after submitting to prove response validation keeps the + // original request's network context. + await page.evaluate(() => { + const wrap = document.querySelector("#wrap"); + const input = document.querySelector("#invoice"); + if (!wrap || !input) throw new Error("wrap controls are missing"); + wrap.click(); + input.value = "lntbs1"; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await expect(page.locator("#network_select")).toHaveValue("signet"); + + // Verification badges from the existing client-side checks must appear. + await expect(page.locator("#result")).toContainText("Payment hashes", { timeout: 60_000 }); + await expect(page.locator("#result")).toContainText("Destination", { timeout: 60_000 }); + await expect(page.locator("#result")).toContainText(`Description: ${customDescription}`); + + // And no "evil relay" / mismatch errors. + const resultText = await page.locator("#result").innerText(); + expect(resultText).not.toContain("might be evil"); + expect(resultText).not.toContain("charged more than it advertised"); + + // A QR code (wrapped invoice) should have been rendered. + await expect(page.locator("#qrcode")).toBeVisible({ timeout: 30_000 }); + const proxyInvoice = await proxyInvoiceFromPage(page); + + expect(errors, `browser console errors: ${errors.join("\n")}`).toEqual([]); + const diagnostics = await Promise.all(diagnosticRecords); + for (const event of [ + "config.load.completed", + "network.changed", + "discovery.offer.accepted", + "provider.selected", + "wrap.transport.started", + "wrap.request.published", + "wrap.response.decrypted", + "wrap.response.validated", + "wrap.completed", + ]) { + expect(diagnostics.some(({ text }) => text.includes(event)), `missing diagnostic event: ${event}`).toBeTruthy(); + } + const diagnosticText = JSON.stringify(diagnostics); + for (const privateValue of [ + invoice, + originalMemo, + customDescription, + rHashHex, + encodedPaymentHash, + proxyInvoice, + ]) { + expect(diagnosticText).not.toContain(privateValue); + } + expect(requestCiphertexts.length).toBeGreaterThan(0); + expect(ephemeralPublicKeys.length).toBeGreaterThan(0); + for (const privateValue of [...requestCiphertexts, ...ephemeralPublicKeys]) { + expect(diagnosticText).not.toContain(privateValue); + } +}); + +// Full settlement, exercising the self-loop topology: the payee node both +// creates the original invoice and pays the wrapped invoice, so the same +// payment hash transits the payee in both directions (A pays B's proxy, B pays +// A's original). Asserts the payment succeeds and the original invoice settles, +// proving the relay safely completes the circuit with no fund loss. +test("pays the wrapped invoice end-to-end through a self-loop", async ({ page }) => { + const { invoice, rHashHex } = await createInvoice(20_000, "lnproxy e2e settle"); + + await page.goto(`/?network=regtest&nostr_relays=${encodeURIComponent(NOSTR_RELAY_WS)}`); + await page.fill("#min_pow", "0"); + await expect(page.locator("#provider_status")).toContainText("provider offer(s) available", { + timeout: 60_000, + }); + await page.fill("#invoice", invoice); + await expect(page.locator('#provider_panel input[name="provider"]')).toBeVisible({ + timeout: 60_000, + }); + await page.click("#wrap"); + await expect(page.locator("#qrcode")).toBeVisible({ timeout: 60_000 }); + + const proxyInvoice = await proxyInvoiceFromPage(page); + expect(proxyInvoice.startsWith("lnbcrt")).toBeTruthy(); + expect(proxyInvoice).not.toEqual(invoice.toLowerCase()); + + // Payee pays the proxy invoice; the provider then pays the original back. + const result = await payFromPayee(proxyInvoice); + expect(result.ok, `payment did not succeed: status=${result.status}`).toBeTruthy(); + + // The original invoice (same payment hash) must end up settled. + const settled = await waitForSettled(rHashHex); + expect(settled, "original invoice was not settled after paying the proxy").toBeTruthy(); +}); + +test("signet entry point visibly selects signet", async ({ page }) => { + await page.goto("/signet.html"); + await expect(page).toHaveURL(/network=signet/); + await expect(page.locator("#network_select")).toHaveValue("signet"); + await expect(page.locator("#min_pow")).toHaveValue("20"); + await expect(page.locator("#min_pow")).toHaveAttribute("max", "40"); + await expect(page.locator("#invoice")).toHaveAttribute("placeholder", "lntbs..."); +}); diff --git a/e2e/scripts/setup.sh b/e2e/scripts/setup.sh new file mode 100644 index 0000000..46ab5f5 --- /dev/null +++ b/e2e/scripts/setup.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Sets up a regtest channel provider -> payee so lnproxy can wrap and route, and +# exports the provider's admin macaroon for the lnproxy nostr-relay. +set -euo pipefail + +BTC_HOST="bitcoind:18443" +BTC_AUTH="test:test" + +PROVIDER_RPC="lnd-provider:10009" +PROVIDER_TLS="/provider/tls.cert" +PROVIDER_MAC="/provider/data/chain/bitcoin/regtest/admin.macaroon" +PAYEE_RPC="lnd-payee:10009" +PAYEE_TLS="/payee/tls.cert" +PAYEE_MAC="/payee/data/chain/bitcoin/regtest/admin.macaroon" + +btc() { + # bitcoind JSON-RPC via curl. Usage: btc method [json-params] + local method="$1" + local params="${2:-[]}" + curl -s --user "$BTC_AUTH" --data-binary \ + "{\"jsonrpc\":\"1.0\",\"id\":\"setup\",\"method\":\"$method\",\"params\":$params}" \ + -H 'content-type:text/plain;' "http://$BTC_HOST/" | + sed 's/.*"result":\(.*\),"error".*/\1/' +} + +provider() { lncli --network=regtest --rpcserver="$PROVIDER_RPC" --tlscertpath="$PROVIDER_TLS" --macaroonpath="$PROVIDER_MAC" "$@"; } +payee() { lncli --network=regtest --rpcserver="$PAYEE_RPC" --tlscertpath="$PAYEE_TLS" --macaroonpath="$PAYEE_MAC" "$@"; } + +echo "setup: waiting for both LND nodes to be synced..." +for i in $(seq 1 60); do + if provider getinfo >/dev/null 2>&1 && payee getinfo >/dev/null 2>&1; then + break + fi + sleep 2 +done + +# Extract pubkeys/addresses with grep/sed (no jq in image). +extract() { grep -o "\"$1\": *\"[^\"]*\"" | head -1 | sed "s/.*: *\"\([^\"]*\)\".*/\1/"; } + +PROVIDER_PUBKEY=$(provider getinfo | extract identity_pubkey) +echo "setup: provider pubkey = $PROVIDER_PUBKEY" + +PROVIDER_ADDR=$(provider newaddress p2wkh | extract address) +PAYEE_ADDR=$(payee newaddress p2wkh | extract address) +echo "setup: funding addresses $PROVIDER_ADDR / $PAYEE_ADDR" + +# Mine a wallet for fees and fund both nodes. +btc createwallet '["miner"]' >/dev/null 2>&1 || true +MINER_ADDR=$(btc getnewaddress | tr -d '"') +btc generatetoaddress "[101, \"$MINER_ADDR\"]" >/dev/null +btc sendtoaddress "[\"$PROVIDER_ADDR\", 5]" >/dev/null +btc sendtoaddress "[\"$PAYEE_ADDR\", 5]" >/dev/null +btc generatetoaddress "[6, \"$MINER_ADDR\"]" >/dev/null + +echo "setup: waiting for both nodes to sync to chain..." +for i in $(seq 1 60); do + PSYNC=$(provider getinfo | grep -o '"synced_to_chain": *true' || true) + YSYNC=$(payee getinfo | grep -o '"synced_to_chain": *true' || true) + if [ -n "$PSYNC" ] && [ -n "$YSYNC" ]; then + echo "setup: both nodes synced" + break + fi + btc generatetoaddress "[1, \"$MINER_ADDR\"]" >/dev/null + sleep 2 +done + +echo "setup: waiting for provider on-chain funds to confirm..." +for i in $(seq 1 30); do + CONF=$(provider walletbalance | extract confirmed_balance) + if [ "${CONF:-0}" -gt 0 ] 2>/dev/null; then break; fi + sleep 2 +done + +# Connect provider -> payee and open a channel so the provider can route to payee. +PAYEE_PUBKEY=$(payee getinfo | extract identity_pubkey) +provider connect "$PAYEE_PUBKEY@lnd-payee:9735" 2>/dev/null || true +echo "setup: opening channel provider -> payee..." +provider openchannel --node_key="$PAYEE_PUBKEY" --local_amt=3000000 --push_amt=1500000 >/dev/null +btc generatetoaddress "[6, \"$MINER_ADDR\"]" >/dev/null + +echo "setup: waiting for an active channel..." +for i in $(seq 1 40); do + if provider listchannels | grep -q '"active": true'; then + echo "setup: channel active" + break + fi + btc generatetoaddress "[1, \"$MINER_ADDR\"]" >/dev/null + sleep 2 +done + +# Export the provider admin macaroon and TLS cert for the lnproxy nostr-relay. +cp "$PROVIDER_MAC" /shared/admin.macaroon +cp /provider/tls.cert /shared/provider-tls.cert +chmod 644 /shared/admin.macaroon /shared/provider-tls.cert +# The lnproxy container runs as a non-root user and needs to write its nostr +# key into the shared volume. +chmod 0777 /shared + +# Export the payee admin macaroon (hex) so the host Playwright test can issue +# invoices from the payee node over REST. +if [ -d /out ]; then + od -An -v -tx1 "$PAYEE_MAC" | tr -d ' \n' > /out/payee.macaroon.hex + echo "$PAYEE_PUBKEY" > /out/payee.pubkey + chmod 644 /out/payee.macaroon.hex /out/payee.pubkey +fi +echo "setup: done; exported provider macaroon and payee credentials" From ff6c57a08ad566b1b9be6cf4ceb45f5ce0a1f47a Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 15 Jul 2026 21:28:08 +0200 Subject: [PATCH 03/11] ci: DELETE ME use forked relay for e2e --- .github/workflows/webui.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/webui.yml b/.github/workflows/webui.yml index 140e5cd..0355511 100644 --- a/.github/workflows/webui.yml +++ b/.github/workflows/webui.yml @@ -45,9 +45,10 @@ jobs: - uses: actions/checkout@v4 with: path: lnproxy-webui2 + # DELETE ME after lnproxy/lnproxy-relay includes its root Dockerfile. - uses: actions/checkout@v4 with: - repository: lnproxy/lnproxy-relay + repository: m0wer/lnproxy-relay path: lnproxy-relay - uses: oven-sh/setup-bun@v2 with: From 79fb36d7fc31baa79cea11836338ade6eea6a382 Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 15 Jul 2026 22:40:22 +0200 Subject: [PATCH 04/11] feat: prefer direct provider transport --- README.md | 25 +-- assets/main.js | 25 ++- assets/nostr.js | 106 +++++++++++- assets/transport.js | 198 ++++++++++++++++++++++ e2e/docker-compose.e2e.yml | 6 +- e2e/playwright/specs/wrap.spec.ts | 54 +++++- index.html | 2 +- test/nostr.test.js | 32 +++- test/transport.test.js | 266 ++++++++++++++++++++++++++++++ 9 files changed, 689 insertions(+), 25 deletions(-) create mode 100644 assets/transport.js create mode 100644 test/transport.test.js diff --git a/README.md b/README.md index cf1af7f..779059f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # lnproxy-webui2 -Static, nostr-only web UI for lnproxy. +Static web UI for decentralized lnproxy provider discovery. The UI is static files with minimal, auditable JavaScript. It discovers lnproxy -providers through nostr and sends encrypted wrap requests using the protocol in -[the spec](https://github.com/lnproxy/spec/blob/main/nostr.md). There is no -bundled HTTP provider list or automatic HTTP fallback. +providers through nostr using the protocol in +[the spec](https://github.com/lnproxy/spec/blob/main/nostr.md). After selecting a +provider it prefers a validated direct HTTP or onion endpoint, when offered, and +uses encrypted nostr requests as the fallback transport. ## Decentralized provider discovery over nostr @@ -49,11 +50,11 @@ The image copies only the public HTML and `assets/` tree; repository metadata, tests, and local e2e credentials are not included. Privacy note: a provider sees the invoice it is asked to pay (destination, -amount, and description) but not who is paying or where the payment originates. -Changing the proxy invoice description cannot hide the original invoice from -the provider. This is the destination-side exposure described by Kappos et -al., *An Empirical Analysis of Privacy in the Lightning Network* (USENIX -Security 2021). Because nostr relays see your IP, run over Tor for best privacy. +amount, and description), and a direct clearnet endpoint also sees the browser's +IP unless a proxy is used. Changing the proxy invoice description cannot hide +the original invoice from the provider. Nostr transport hides the browser IP +from the provider but exposes connection metadata to the nostr relay. A direct +onion endpoint over Tor avoids both kinds of exposure. ## Browser diagnostics @@ -98,8 +99,10 @@ The bundle re-exports only the small nostr-tools surface lnproxy uses (see Lightning network (bitcoind + two LND nodes with a channel), a nostr relay, and the lnproxy nostr-relay backend, driven by Playwright. The test issues a real invoice from one LND node, discovers the provider over nostr in a real browser, -wraps the invoice through the backend (which talks to the other LND node), and -checks the client-side verification. +wraps directly through the backend, and checks client-side verification. It also +drops a completed direct response and verifies that the browser recovers through +Nostr with the same request ID, then pays a wrapped invoice through the complete +hold-invoice circuit. cd e2e docker compose -f docker-compose.e2e.yml up -d --build diff --git a/assets/main.js b/assets/main.js index 627865a..3c5da6e 100644 --- a/assets/main.js +++ b/assets/main.js @@ -9,8 +9,8 @@ import { normalizePubkey, randomOffer, selectOffers, - wrapViaNostr, } from "./nostr.js"; +import { FEATURE_REQUEST_ID_V1, wrapProvider } from "./transport.js"; const form = document.getElementById("mainform"); const result = document.getElementById("result"); @@ -24,6 +24,7 @@ const advancedOptions = document.getElementById("advanced"); const wrapButton = document.getElementById("wrap"); const refreshButton = document.getElementById("refresh_offers"); const loading = document.getElementById("loading_message"); +const wrapStatus = document.getElementById("wrap_status"); const providerStatus = document.getElementById("provider_status"); const providerPanel = document.getElementById("provider_panel"); const networkSelect = document.getElementById("network_select"); @@ -114,7 +115,7 @@ function renderProviderTable(offers, amountMsat) { const table = document.createElement("table"); table.className = "providers"; const header = table.createTHead().insertRow(); - for (const label of ["", "Fee", "Limits (sat)", "Credential", "PoW", "Provider"]) { + for (const label of ["", "Fee", "Limits (sat)", "Credential", "PoW", "Transport", "Provider"]) { const th = document.createElement("th"); th.textContent = label; header.appendChild(th); @@ -144,6 +145,8 @@ function renderProviderTable(offers, amountMsat) { addCell(row, `${satStr(offer.min_amount_msat)}-${satStr(offer.max_amount_msat)}`); row.insertCell().appendChild(credentialCell(offer)); addCell(row, String(offer.committed_pow)); + const direct = offer.urls.length > 0 && offer.features.includes(FEATURE_REQUEST_ID_V1); + addCell(row, direct ? "Direct / Nostr" : "Nostr"); addCell(row, `${offer.pubkey.slice(0, 12)}...`, "mono"); } providerPanel.replaceChildren(table); @@ -444,12 +447,27 @@ async function wrapInvoice() { state.wrapping = true; syncWrapButton(); loading.style.display = "inline-block"; + wrapStatus.textContent = " Contacting provider..."; const providerName = `nostr:${offer.pubkey.slice(0, 12)}...`; result.replaceChildren(); appendHeading(`Proxying through ${providerName}`); try { - const response = await wrapViaNostr(offer, data, { relays: requestRelays }); + const response = await wrapProvider(offer, data, { + relays: requestRelays, + onStatus(status) { + switch (status) { + case "direct": + wrapStatus.textContent = " Connecting directly..."; + break; + case "fallback": + wrapStatus.textContent = " Direct connection unavailable, trying Nostr..."; + break; + default: + wrapStatus.textContent = " Connecting through Nostr..."; + } + }, + }); diagnostics.info("wrap.response.received", { provider: offer.pubkey, status: response.status === "ERROR" ? "ERROR" : "OK", @@ -572,6 +590,7 @@ async function wrapInvoice() { } state.wrapping = false; loading.style.display = "none"; + wrapStatus.textContent = " Contacting provider..."; syncWrapButton(); } } diff --git a/assets/nostr.js b/assets/nostr.js index 7319d58..862b091 100644 --- a/assets/nostr.js +++ b/assets/nostr.js @@ -25,6 +25,8 @@ export const KIND_REQUEST = 21821; export const KIND_RESPONSE = 21822; export const PROTOCOL_VERSION = "lnproxy-v1"; export const MAX_REQUEST_POW = 24; +export const MAX_DIRECT_URLS = 8; +export const MIN_RESPONSE_POW = 20; // Default nostr relays. Overridable via assets/nostr-relays.json. export const DEFAULT_RELAYS = [ @@ -258,7 +260,7 @@ export function parseOffer(event, { network = "mainnet", now = Date.now() } = {} min_request_pow, features: stringArray(content.features), relays: relayURLs(content.relays), - urls: stringArray(content.urls), + urls: directURLs(content.urls), node_pubkey, node_sig, attested, @@ -385,6 +387,100 @@ function relayURLs(value) { }); } +function isLoopbackHostname(hostname) { + if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "[::1]") return true; + const octets = ipv4Octets(hostname); + return octets !== null && octets[0] === 127; +} + +function isV3OnionHostname(hostname) { + return /^[a-z2-7]{56}\.onion$/.test(hostname); +} + +function ipv4Octets(hostname) { + const parts = hostname.split("."); + if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) return null; + const octets = parts.map(Number); + return octets.every((octet) => octet >= 0 && octet <= 255) ? octets : null; +} + +function isNonPublicIPAddress(hostname) { + const octets = ipv4Octets(hostname); + if (octets !== null) { + const [a, b, c] = octets; + return a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0 && c === 0) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + a >= 224; + } + + if (!hostname.startsWith("[") || !hostname.endsWith("]")) return false; + const address = hostname.slice(1, -1); + if (address === "::" || address === "::1" || address.startsWith("::ffff:")) return true; + const first = Number.parseInt(address.split(":", 1)[0], 16); + return (first & 0xfe00) === 0xfc00 || + (first & 0xffc0) === 0xfe80 || + (first & 0xffc0) === 0xfec0 || + (first & 0xff00) === 0xff00; +} + +function isLocalDevelopmentContext() { + const location = globalThis.location; + return location !== undefined && isLoopbackHostname(location.hostname.toLowerCase().replace(/\.$/, "")); +} + +// normalizeDirectURL validates a provider-advertised HTTP endpoint. Cleartext +// clearnet is rejected; HTTP is only accepted for onion services and local +// development. Redirect targets are independently blocked by the fetch policy. +export function normalizeDirectURL(value, { allowLoopback = isLocalDevelopmentContext() } = {}) { + if (typeof value !== "string") return null; + try { + const url = new URL(value); + const hostname = url.hostname.toLowerCase().replace(/\.$/, ""); + if (url.username !== "" || url.password !== "" || url.hash !== "") return null; + const loopback = isLoopbackHostname(hostname); + if ((loopback && !allowLoopback) || (!loopback && isNonPublicIPAddress(hostname))) return null; + if (url.protocol === "https:") return url.href; + if (url.protocol !== "http:") return null; + if (!(loopback && allowLoopback) && !isV3OnionHostname(hostname)) return null; + return url.href; + } catch (_error) { + return null; + } +} + +export function directURLs(value, options) { + const result = []; + for (const candidate of stringArray(value)) { + const normalized = normalizeDirectURL(candidate, options); + if (normalized && !result.includes(normalized)) result.push(normalized); + if (result.length === MAX_DIRECT_URLS) break; + } + return result; +} + +export function validResponseEvent( + event, + { provider, recipient, requestEventID, minPow = MIN_RESPONSE_POW }, +) { + if (!event || event.kind !== KIND_RESPONSE || event.pubkey !== provider) return false; + if (!hasTagValue(event.tags, "p", recipient) || !hasTagValue(event.tags, "e", requestEventID)) return false; + if (!verifyEvent(event)) return false; + const nonceTag = event.tags?.find((tag) => tag[0] === "nonce"); + const committedPow = nonceTag === undefined ? 0 : toInt(nonceTag[2]); + return committedPow !== null && committedPow >= minPow && nip13.getPow(event.id) >= committedPow; +} + +function hasTagValue(tags, name, value) { + return Array.isArray(tags) && tags.some((tag) => tag.length >= 2 && tag[0] === name && tag[1] === value); +} + // --------------------------------------------------------------------------- // Network layer // --------------------------------------------------------------------------- @@ -566,8 +662,12 @@ export async function wrapViaNostr(offer, request, { relays, timeoutMs = 30000 } }); }, onevent(resp) { - if (!verifyEvent(resp)) { - diagnostics.warn("wrap.response.invalid-signature", { + if (!validResponseEvent(resp, { + provider: offer.pubkey, + recipient: pk, + requestEventID: event.id, + })) { + diagnostics.warn("wrap.response.invalid-event", { eventID: shortID(resp.id), pubkey: shortID(resp.pubkey), }); diff --git a/assets/transport.js b/assets/transport.js new file mode 100644 index 0000000..8f4c15b --- /dev/null +++ b/assets/transport.js @@ -0,0 +1,198 @@ +import { diagnostics } from "./diagnostics.js"; +import { MAX_DIRECT_URLS, wrapViaNostr } from "./nostr.js"; + +export const FEATURE_REQUEST_ID_V1 = "request_id_v1"; +const DEFAULT_DIRECT_TIMEOUT_MS = 10000; +const MAX_RESPONSE_BYTES = 64 << 10; + +export class DirectTransportError extends Error { + constructor(message, cause) { + super(message, { cause }); + this.name = "DirectTransportError"; + } +} + +export function generateRequestID(randomValues = (bytes) => crypto.getRandomValues(bytes)) { + const bytes = randomValues(new Uint8Array(32)); + return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); +} + +function endpointDetails(endpoint) { + const url = new URL(endpoint); + return { + protocol: url.protocol, + onion: url.hostname.endsWith(".onion"), + }; +} + +async function boundedResponseText(response, maxBytes = MAX_RESPONSE_BYTES) { + if (!response.body?.getReader) { + const text = await response.text(); + if (new TextEncoder().encode(text).byteLength > maxBytes) { + throw new DirectTransportError("direct response was too large"); + } + return text; + } + + const reader = response.body.getReader(); + const chunks = []; + let length = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > maxBytes) { + await reader.cancel(); + throw new DirectTransportError("direct response was too large"); + } + chunks.push(value); + } + const body = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(body); +} + +function matchingResponse(response, requestID) { + if (!response || typeof response !== "object" || Array.isArray(response)) { + throw new DirectTransportError("provider response was not a JSON object"); + } + if (response.request_id !== requestID) { + throw new DirectTransportError("provider response had a mismatched request ID"); + } + return response; +} + +export async function wrapViaDirect( + endpoint, + request, + { fetchImpl = fetch, timeoutMs = DEFAULT_DIRECT_TIMEOUT_MS } = {}, +) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "wrap", ...request }), + credentials: "omit", + cache: "no-store", + redirect: "error", + referrerPolicy: "no-referrer", + signal: controller.signal, + }); + if (!response.ok) { + throw new DirectTransportError(`direct endpoint returned HTTP ${response.status}`); + } + const text = await boundedResponseText(response); + let parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new DirectTransportError("direct response was not valid JSON", error); + } + return matchingResponse(parsed, request.request_id); + } catch (error) { + if (error instanceof DirectTransportError) throw error; + if (controller.signal.aborted) { + throw new DirectTransportError("direct request timed out", error); + } + throw new DirectTransportError("direct request failed", error); + } finally { + clearTimeout(timer); + } +} + +// wrapProvider prefers an advertised direct endpoint and falls back to nostr +// with the same request ID. Legacy providers without request_id_v1 remain +// nostr-only because an ambiguous cross-transport retry would not be safe. +export async function wrapProvider( + offer, + request, + { + relays, + fetchImpl = fetch, + directTimeoutMs = DEFAULT_DIRECT_TIMEOUT_MS, + nostrWrap = wrapViaNostr, + onStatus = () => {}, + } = {}, +) { + const idempotent = offer.features?.includes(FEATURE_REQUEST_ID_V1) === true; + const requestID = idempotent ? generateRequestID() : ""; + const transportRequest = requestID ? { ...request, request_id: requestID } : request; + let directError = null; + const directDeadline = performance.now() + Math.max(0, directTimeoutMs); + + if (idempotent) { + for (const endpoint of (offer.urls || []).slice(0, MAX_DIRECT_URLS)) { + const remainingMs = Math.ceil(directDeadline - performance.now()); + if (remainingMs <= 0) { + directError ||= new DirectTransportError("direct transport timed out"); + break; + } + const details = endpointDetails(endpoint); + onStatus("direct"); + diagnostics.info("wrap.transport.started", { + transport: "direct", + provider: offer.pubkey, + ...details, + }); + const startedAt = performance.now(); + try { + const response = await wrapViaDirect(endpoint, transportRequest, { + fetchImpl, + timeoutMs: remainingMs, + }); + diagnostics.info("wrap.transport.completed", { + transport: "direct", + provider: offer.pubkey, + durationMs: Math.round(performance.now() - startedAt), + ...details, + }); + return response; + } catch (error) { + directError = error; + diagnostics.warn("wrap.transport.failed", { + transport: "direct", + provider: offer.pubkey, + durationMs: Math.round(performance.now() - startedAt), + errorName: error?.name || "Error", + ...details, + }); + } + } + } + + if (directError) { + diagnostics.info("wrap.transport.fallback", { + from: "direct", + to: "nostr", + provider: offer.pubkey, + }); + onStatus("fallback"); + } else { + onStatus("nostr"); + } + const startedAt = performance.now(); + try { + const response = await nostrWrap(offer, transportRequest, { relays }); + const matched = requestID ? matchingResponse(response, requestID) : response; + diagnostics.info("wrap.transport.completed", { + transport: "nostr", + provider: offer.pubkey, + durationMs: Math.round(performance.now() - startedAt), + }); + return matched; + } catch (error) { + diagnostics.warn("wrap.transport.failed", { + transport: "nostr", + provider: offer.pubkey, + durationMs: Math.round(performance.now() - startedAt), + errorName: error?.name || "Error", + }); + throw error; + } +} diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 69a4203..9c5df3c 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -143,6 +143,8 @@ services: - --nostr-relays=ws://nostr-relay:8080 - --network=regtest - --features=pay_bolt11,pay_bolt11_blinded,wrap_bolt11 + - --http-listen=0.0.0.0:4747 + - --urls=http://127.0.0.1:4747/spec - --min-request-pow=0 - --announce-pow=0 - --min-msat=1000 @@ -151,6 +153,8 @@ services: - /shared/admin.macaroon volumes: - shared:/shared + ports: + - "127.0.0.1:4747:4747" webui: build: @@ -158,7 +162,7 @@ services: image: lnproxy-webui:e2e container_name: lnproxy-e2e-webui ports: - - "8088:80" + - "${WEBUI_PORT:-8088}:80" volumes: lnd-provider-home: diff --git a/e2e/playwright/specs/wrap.spec.ts b/e2e/playwright/specs/wrap.spec.ts index 1a5b916..5bc49b3 100644 --- a/e2e/playwright/specs/wrap.spec.ts +++ b/e2e/playwright/specs/wrap.spec.ts @@ -117,7 +117,7 @@ async function proxyInvoiceFromPage(page: import("@playwright/test").Page): Prom return href.replace(/^lightning:/i, "").toLowerCase(); } -test("discovers a provider over nostr and wraps a real LND invoice", async ({ page }) => { +test("discovers over nostr and wraps directly with a real LND invoice", async ({ page }) => { const originalMemo = "lnproxy e2e"; const customDescription = "lnproxy e2e custom description"; const { invoice, rHashHex } = await createInvoice(50_000, originalMemo); @@ -222,8 +222,7 @@ test("discovers a provider over nostr and wraps a real LND invoice", async ({ pa "discovery.offer.accepted", "provider.selected", "wrap.transport.started", - "wrap.request.published", - "wrap.response.decrypted", + "wrap.transport.completed", "wrap.response.validated", "wrap.completed", ]) { @@ -240,13 +239,58 @@ test("discovers a provider over nostr and wraps a real LND invoice", async ({ pa ]) { expect(diagnosticText).not.toContain(privateValue); } - expect(requestCiphertexts.length).toBeGreaterThan(0); - expect(ephemeralPublicKeys.length).toBeGreaterThan(0); + expect(requestCiphertexts).toEqual([]); + expect(ephemeralPublicKeys).toEqual([]); for (const privateValue of [...requestCiphertexts, ...ephemeralPublicKeys]) { expect(diagnosticText).not.toContain(privateValue); } }); +test("falls back to nostr after a direct response is lost", async ({ page }) => { + const { invoice } = await createInvoice(30_000, "lnproxy e2e fallback"); + let directRequestID = ""; + let directCompleted = false; + await page.route("http://127.0.0.1:4747/spec", async (route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + const body = route.request().postDataJSON() as { request_id?: string }; + directRequestID = body.request_id ?? ""; + const response = await route.fetch(); + expect(response.ok()).toBeTruthy(); + directCompleted = true; + await route.abort("failed"); + }); + + let nostrRequests = 0; + const cdp = await page.context().newCDPSession(page); + await cdp.send("Network.enable"); + cdp.on("Network.webSocketFrameSent", ({ response }) => { + try { + const message = JSON.parse(response.payloadData); + if (message?.[0] === "EVENT" && message[1]?.kind === 21821) nostrRequests++; + } catch (_error) { + /* ignore non-JSON websocket frames */ + } + }); + + await page.goto(`/?network=regtest&nostr_relays=${encodeURIComponent(NOSTR_RELAY_WS)}`); + await page.fill("#min_pow", "0"); + await expect(page.locator("#provider_status")).toContainText("provider offer(s) available", { + timeout: 60_000, + }); + await page.fill("#invoice", invoice); + await expect(page.locator('#provider_panel input[name="provider"]')).toBeVisible({ timeout: 60_000 }); + await page.click("#wrap"); + await expect(page.locator("#qrcode")).toBeVisible({ timeout: 60_000 }); + + expect(directCompleted).toBeTruthy(); + expect(directRequestID).toMatch(/^[0-9a-f]{64}$/); + expect(nostrRequests).toBeGreaterThan(0); + await expect(page.locator("#result")).not.toContainText("request_id reused"); +}); + // Full settlement, exercising the self-loop topology: the payee node both // creates the original invoice and pays the wrapped invoice, so the same // payment hash transits the payee in both directions (A pays B's proxy, B pays diff --git a/index.html b/index.html index 95405dc..0292f48 100644 --- a/index.html +++ b/index.html @@ -79,7 +79,7 @@

lnproxy

- Connecting to relay... + Contacting provider... diff --git a/test/nostr.test.js b/test/nostr.test.js index ee13814..7d73ab6 100644 --- a/test/nostr.test.js +++ b/test/nostr.test.js @@ -15,9 +15,17 @@ import { KIND_OFFER, PROTOCOL_VERSION, MAX_REQUEST_POW, + validResponseEvent, minePowAsync, } from "../assets/nostr.js"; -import { secp256k1, sha256, nip19 } from "../assets/nostr-bundle.js"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + nip19, + secp256k1, + sha256, +} from "../assets/nostr-bundle.js"; const ZBASE32_ALPHABET = "ybndrfg8ejkmcpqxot1uwisza345h769"; @@ -217,6 +225,28 @@ test("minePowAsync commits to and reaches the requested difficulty", async () => expect(Number.parseInt(mined.id.slice(0, 2), 16)).toBeLessThan(1); }); +test("validResponseEvent verifies routing, signature, and committed proof of work", async () => { + const providerSK = generateSecretKey(); + const provider = getPublicKey(providerSK); + const recipient = "b".repeat(64); + const requestEventID = "c".repeat(64); + const unsigned = await minePowAsync({ + kind: 21822, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", recipient], ["e", requestEventID]], + content: "encrypted", + pubkey: provider, + }, 8, { chunkSize: 16 }); + const event = finalizeEvent(unsigned, providerSK); + const options = { provider, recipient, requestEventID, minPow: 8 }; + expect(validResponseEvent(event, options)).toBe(true); + expect(validResponseEvent(event, { ...options, recipient: "d".repeat(64) })).toBe(false); + expect(validResponseEvent(event, { ...options, minPow: 9 })).toBe(false); + const tampered = JSON.parse(JSON.stringify(event)); + tampered.content = "changed"; + expect(validResponseEvent(tampered, options)).toBe(false); +}); + test("credentialRank prefers attested then pow then anon", () => { expect(credentialRank({ attested: true })).toBe(2); expect(credentialRank({ identity_pow_bits: 12 })).toBe(1); diff --git a/test/transport.test.js b/test/transport.test.js new file mode 100644 index 0000000..51f4b87 --- /dev/null +++ b/test/transport.test.js @@ -0,0 +1,266 @@ +import { expect, test } from "bun:test"; + +import { directURLs, MAX_DIRECT_URLS, normalizeDirectURL } from "../assets/nostr.js"; +import { + FEATURE_REQUEST_ID_V1, + generateRequestID, + wrapProvider, + wrapViaDirect, +} from "../assets/transport.js"; + +const provider = "a".repeat(64); + +function offer(overrides = {}) { + return { + pubkey: provider, + features: ["wrap_bolt11", FEATURE_REQUEST_ID_V1], + urls: ["https://provider.example/spec"], + relays: ["wss://relay.example"], + ...overrides, + }; +} + +test("normalizeDirectURL accepts secure and onion endpoints", () => { + expect(normalizeDirectURL("https://provider.example/spec")).toBe("https://provider.example/spec"); + const onion = `${"a".repeat(56)}.onion`; + expect(normalizeDirectURL(`http://${onion}/spec`)).toBe(`http://${onion}/spec`); +}); + +test("normalizeDirectURL rejects unsafe endpoint forms", () => { + for (const value of [ + "http://provider.example/spec", + "http://127.0.0.1:4747/spec", + "https://127.0.0.2/spec", + "https://10.0.0.1/spec", + "https://169.254.169.254/latest/meta-data", + "https://192.168.1.1/spec", + "https://[::1]/spec", + "https://[fd00::1]/spec", + "https://[fe80::1]/spec", + "https://[fec0::1]/spec", + "https://[::ffff:7f00:1]/spec", + "https://user:pass@provider.example/spec", + "https://provider.example/spec#fragment", + "ftp://provider.example/spec", + "http://short.onion/spec", + "not a url", + ]) { + expect(normalizeDirectURL(value)).toBeNull(); + } +}); + +test("normalizeDirectURL allows loopback only for explicit local development", () => { + expect(normalizeDirectURL("http://127.1:4747/spec", { allowLoopback: true })) + .toBe("http://127.0.0.1:4747/spec"); + expect(normalizeDirectURL("https://[::1]/spec", { allowLoopback: true })).toBe("https://[::1]/spec"); +}); + +test("directURLs filters and deduplicates offer URLs", () => { + expect(directURLs([ + "https://provider.example/spec", + "https://provider.example/spec", + "http://unsafe.example/spec", + 7, + ])).toEqual(["https://provider.example/spec"]); +}); + +test("directURLs caps the number of endpoints retained from an offer", () => { + const urls = Array.from({ length: MAX_DIRECT_URLS + 3 }, (_, index) => `https://provider${index}.example/spec`); + expect(directURLs(urls)).toEqual(urls.slice(0, MAX_DIRECT_URLS)); +}); + +test("generateRequestID returns 32 random bytes as lowercase hex", () => { + const id = generateRequestID((bytes) => { + bytes.fill(0xab); + return bytes; + }); + expect(id).toBe("ab".repeat(32)); +}); + +test("wrapViaDirect sends privacy-preserving request options", async () => { + const requestID = "1".repeat(64); + let captured; + const response = await wrapViaDirect("https://provider.example/spec", { + request_id: requestID, + invoice: "lnbc1...", + }, { + fetchImpl: async (url, options) => { + captured = { url, options }; + return new Response(JSON.stringify({ request_id: requestID, proxy_invoice: "lnbc-proxy" })); + }, + }); + expect(response.proxy_invoice).toBe("lnbc-proxy"); + expect(captured.url).toBe("https://provider.example/spec"); + expect(captured.options.credentials).toBe("omit"); + expect(captured.options.cache).toBe("no-store"); + expect(captured.options.redirect).toBe("error"); + expect(captured.options.referrerPolicy).toBe("no-referrer"); + expect(JSON.parse(captured.options.body)).toEqual({ + method: "wrap", + request_id: requestID, + invoice: "lnbc1...", + }); +}); + +test("wrapViaDirect rejects oversized response bodies", async () => { + const requestID = "1".repeat(64); + await expect(wrapViaDirect("https://provider.example/spec", { request_id: requestID }, { + fetchImpl: async () => new Response("x".repeat((64 << 10) + 1)), + })).rejects.toThrow("direct response was too large"); +}); + +test("wrapProvider uses direct transport first", async () => { + let nostrCalls = 0; + const response = await wrapProvider(offer(), { invoice: "lnbc1..." }, { + fetchImpl: async (_url, options) => { + const request = JSON.parse(options.body); + return new Response(JSON.stringify({ request_id: request.request_id, proxy_invoice: "direct" })); + }, + nostrWrap: async () => { + nostrCalls++; + return {}; + }, + }); + expect(response.proxy_invoice).toBe("direct"); + expect(nostrCalls).toBe(0); +}); + +test("wrapProvider tries direct endpoints in order with the same request ID", async () => { + const calls = []; + let nostrCalls = 0; + const response = await wrapProvider(offer({ + urls: ["https://one.example/spec", "https://two.example/spec"], + }), { invoice: "lnbc1..." }, { + fetchImpl: async (url, options) => { + const request = JSON.parse(options.body); + calls.push({ url, requestID: request.request_id }); + if (calls.length === 1) throw new TypeError("connection lost"); + return new Response(JSON.stringify({ request_id: request.request_id, proxy_invoice: "direct" })); + }, + nostrWrap: async () => { + nostrCalls++; + return {}; + }, + }); + expect(response.proxy_invoice).toBe("direct"); + expect(calls.map(({ url }) => url)).toEqual([ + "https://one.example/spec", + "https://two.example/spec", + ]); + expect(calls[0].requestID).toBe(calls[1].requestID); + expect(nostrCalls).toBe(0); +}); + +test("wrapProvider falls back to nostr with the same request ID", async () => { + let directID; + let nostrID; + const statuses = []; + const response = await wrapProvider(offer(), { invoice: "lnbc1..." }, { + fetchImpl: async (_url, options) => { + directID = JSON.parse(options.body).request_id; + throw new TypeError("connection lost"); + }, + nostrWrap: async (_offer, request) => { + nostrID = request.request_id; + return { request_id: request.request_id, proxy_invoice: "nostr" }; + }, + onStatus: (status) => statuses.push(status), + }); + expect(response.proxy_invoice).toBe("nostr"); + expect(directID).toBe(nostrID); + expect(statuses).toEqual(["direct", "fallback"]); +}); + +test("wrapProvider applies one timeout budget across direct endpoints", async () => { + let directCalls = 0; + let nostrID; + const response = await wrapProvider(offer({ + urls: ["https://one.example/spec", "https://two.example/spec"], + }), { invoice: "lnbc1..." }, { + directTimeoutMs: 5, + fetchImpl: async (_url, options) => { + directCalls++; + return new Promise((_resolve, reject) => { + options.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + }); + }, + nostrWrap: async (_offer, request) => { + nostrID = request.request_id; + return { request_id: request.request_id, proxy_invoice: "nostr" }; + }, + }); + expect(response.proxy_invoice).toBe("nostr"); + expect(directCalls).toBe(1); + expect(nostrID).toMatch(/^[0-9a-f]{64}$/); +}); + +test("transport diagnostics exclude request IDs and request contents", async () => { + const originalInfo = console.info; + const originalWarn = console.warn; + const records = []; + let requestID; + console.info = (...args) => records.push(args); + console.warn = (...args) => records.push(args); + try { + await wrapProvider(offer(), { invoice: "private-invoice" }, { + fetchImpl: async (_url, options) => { + requestID = JSON.parse(options.body).request_id; + throw new TypeError("connection lost"); + }, + nostrWrap: async (_offer, request) => ({ + request_id: request.request_id, + proxy_invoice: "nostr", + }), + }); + } finally { + console.info = originalInfo; + console.warn = originalWarn; + } + const diagnosticText = JSON.stringify(records); + expect(diagnosticText).not.toContain(requestID); + expect(diagnosticText).not.toContain("private-invoice"); +}); + +test("wrapProvider does not use direct endpoints without request_id_v1", async () => { + let directCalls = 0; + let nostrRequest; + await wrapProvider(offer({ features: ["wrap_bolt11"] }), { invoice: "lnbc1..." }, { + fetchImpl: async () => { + directCalls++; + return new Response("{}"); + }, + nostrWrap: async (_offer, request) => { + nostrRequest = request; + return { proxy_invoice: "nostr" }; + }, + }); + expect(directCalls).toBe(0); + expect(nostrRequest.request_id).toBeUndefined(); +}); + +test("a valid provider error does not trigger nostr fallback", async () => { + let nostrCalls = 0; + const response = await wrapProvider(offer(), { invoice: "lnbc1..." }, { + fetchImpl: async (_url, options) => { + const request = JSON.parse(options.body); + return new Response(JSON.stringify({ + request_id: request.request_id, + status: "ERROR", + reason: "amount too low", + })); + }, + nostrWrap: async () => { + nostrCalls++; + return {}; + }, + }); + expect(response.status).toBe("ERROR"); + expect(nostrCalls).toBe(0); +}); + +test("mismatched direct response ID falls back and mismatched nostr ID fails", async () => { + await expect(wrapProvider(offer(), { invoice: "lnbc1..." }, { + fetchImpl: async () => new Response(JSON.stringify({ request_id: "2".repeat(64) })), + nostrWrap: async () => ({ request_id: "3".repeat(64), proxy_invoice: "bad" }), + })).rejects.toThrow("mismatched request ID"); +}); From b48844375b17628d9d8868664eeada505d6766bc Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 15 Jul 2026 23:31:49 +0200 Subject: [PATCH 05/11] fix: bound direct transport attempts --- README.md | 8 +++++++- assets/discovery.js | 11 +++++++++++ assets/main.js | 1 + assets/nostr.js | 2 +- test/network.test.js | 21 +++++++++++++++++++++ test/transport.test.js | 1 + 6 files changed, 42 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 779059f..74dc968 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,16 @@ path: ```json { "network": "signet", - "nostr_relays": ["wss://nos.lol", "wss://relay.primal.net"] + "nostr_relays": ["wss://nos.lol", "wss://relay.primal.net"], + "direct_timeout_ms": 15000 } ``` +The direct timeout is one aggregate budget across at most three advertised +endpoints. It defaults to 10 seconds and accepts integer millisecond values from +`1000` through `30000`; Tor-oriented deployments may want a longer value such as +`15000` (15 seconds). + The static `signet.html` entry point redirects to `?network=signet` for servers that cannot override deployment assets. Explicit `?network=` and `?nostr_relays=` query parameters remain available for testing; a diff --git a/assets/discovery.js b/assets/discovery.js index 5adf021..ba2c7b3 100644 --- a/assets/discovery.js +++ b/assets/discovery.js @@ -2,6 +2,14 @@ import { DEFAULT_RELAYS } from "./nostr.js"; import { resolveNetwork } from "./network.js"; import { diagnostics, errorDetails } from "./diagnostics.js"; +const DEFAULT_DIRECT_TIMEOUT_MS = 10000; + +function directTimeout(value) { + return Number.isSafeInteger(value) && value >= 1000 && value <= 30000 + ? value + : DEFAULT_DIRECT_TIMEOUT_MS; +} + function csv(value) { return (value || "") .split(",") @@ -60,15 +68,18 @@ export async function loadDiscoveryConfig({ const [relaySource, relays] = relayCandidates .find(([, list]) => Array.isArray(list) && list.length > 0); const network = resolveNetwork(search, deployment.network); + const directTimeoutMs = directTimeout(deployment.direct_timeout_ms); diagnostics.info("config.load.completed", { network, relaySource, relays, + directTimeoutMs, }); return { network, relays, relaySource, + directTimeoutMs, }; } diff --git a/assets/main.js b/assets/main.js index 3c5da6e..244dfc2 100644 --- a/assets/main.js +++ b/assets/main.js @@ -455,6 +455,7 @@ async function wrapInvoice() { try { const response = await wrapProvider(offer, data, { relays: requestRelays, + directTimeoutMs: state.config.directTimeoutMs, onStatus(status) { switch (status) { case "direct": diff --git a/assets/nostr.js b/assets/nostr.js index 862b091..6eb2b6d 100644 --- a/assets/nostr.js +++ b/assets/nostr.js @@ -25,7 +25,7 @@ export const KIND_REQUEST = 21821; export const KIND_RESPONSE = 21822; export const PROTOCOL_VERSION = "lnproxy-v1"; export const MAX_REQUEST_POW = 24; -export const MAX_DIRECT_URLS = 8; +export const MAX_DIRECT_URLS = 3; export const MIN_RESPONSE_POW = 20; // Default nostr relays. Overridable via assets/nostr-relays.json. diff --git a/test/network.test.js b/test/network.test.js index 66b792f..c165918 100644 --- a/test/network.test.js +++ b/test/network.test.js @@ -47,6 +47,7 @@ test("loadDiscoveryConfig makes a signet deployment the root-page default", asyn }); expect(config.network).toBe("signet"); expect(config.relays).toEqual(["wss://signet.example"]); + expect(config.directTimeoutMs).toBe(10000); }); test("loadDiscoveryConfig gives query relay overrides highest precedence", async () => { @@ -59,3 +60,23 @@ test("loadDiscoveryConfig gives query relay overrides highest precedence", async expect(config.network).toBe("signet"); expect(config.relays).toEqual(["wss://one.example", "wss://two.example"]); }); + +test("loadDiscoveryConfig accepts a bounded integer direct timeout", async () => { + const load = (direct_timeout_ms) => loadDiscoveryConfig({ + search: "", + storage: { getItem: () => null }, + fetcher: async (path) => ({ + ok: true, + json: async () => path.endsWith("deployment.json") + ? { direct_timeout_ms } + : ["wss://relay.example"], + }), + }); + expect((await load(1000)).directTimeoutMs).toBe(1000); + expect((await load(15000)).directTimeoutMs).toBe(15000); + expect((await load(30000)).directTimeoutMs).toBe(30000); + expect((await load(999)).directTimeoutMs).toBe(10000); + expect((await load(30001)).directTimeoutMs).toBe(10000); + expect((await load(15000.5)).directTimeoutMs).toBe(10000); + expect((await load("15000")).directTimeoutMs).toBe(10000); +}); diff --git a/test/transport.test.js b/test/transport.test.js index 51f4b87..f977cab 100644 --- a/test/transport.test.js +++ b/test/transport.test.js @@ -65,6 +65,7 @@ test("directURLs filters and deduplicates offer URLs", () => { }); test("directURLs caps the number of endpoints retained from an offer", () => { + expect(MAX_DIRECT_URLS).toBe(3); const urls = Array.from({ length: MAX_DIRECT_URLS + 3 }, (_, index) => `https://provider${index}.example/spec`); expect(directURLs(urls)).toEqual(urls.slice(0, MAX_DIRECT_URLS)); }); From 6511035233e91050231b570aa9453991df27474b Mon Sep 17 00:00:00 2001 From: m0wer Date: Thu, 16 Jul 2026 08:24:47 +0200 Subject: [PATCH 06/11] fix: harden nostr provider selection --- README.md | 28 ++++-- assets/invoice.js | 8 ++ assets/main.js | 23 +++-- assets/nostr.js | 191 +++++++++++++++++++++++++++++------------ assets/transport.js | 26 ++++-- test/invoice.test.js | 12 ++- test/nostr.test.js | 48 +++++++++-- test/transport.test.js | 36 ++++++++ 8 files changed, 283 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 74dc968..044842a 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,22 @@ uses encrypted nostr requests as the fallback transport. ## Decentralized provider discovery over nostr -On page load the UI subscribes to configured nostr relays, collects provider -offers, and verifies their signatures, freshness, network tags, and proof of -work. Once an amount-bearing invoice is entered, it filters offers by amount, -feature, minimum proof of work (20 bits by default), and optional pinned -provider, then randomly selects one eligible provider. The user can select -another eligible provider before wrapping. Returned proxy invoices are checked -for payment hash, destination, description, routing budget, and the provider's -advertised fee. +On page load the UI subscribes to configured nostr relays, collects a bounded +set of provider offers, and verifies their signatures, freshness, network tags, +and proof of work. Once an amount-bearing invoice is entered, it filters offers +by amount, feature, minimum proof of work (20 bits by default), and optional +pinned provider. It keeps the cheapest offer for each attested Lightning node, +sorts the remaining identities by provider fee, and selects the cheapest. The +user can select another eligible provider before wrapping. Node attestation +binds keys, but does not prove liquidity, channel capacity, or honest service. +Returned proxy invoices are checked for payment hash, destination, description, +routing budget, and the provider's advertised fee. +Without an explicit routing budget, the reference client permits a fixed 3 sat +routing allowance above the displayed provider fee. An explicit routing budget +is enforced exactly. Offers requiring more than 24 bits of request proof of work are ignored, and -accepted request work is mined in yielding chunks so the page stays responsive. +accepted request work is mined in yielding chunks within the overall request +deadline so the page stays responsive. The selected network is visible and editable beside the lnproxy name. Mainnet, signet, testnet, and regtest can be selected directly. Entering an invoice @@ -40,6 +46,10 @@ The direct timeout is one aggregate budget across at most three advertised endpoints. It defaults to 10 seconds and accepts integer millisecond values from `1000` through `30000`; Tor-oriented deployments may want a longer value such as `15000` (15 seconds). +Every direct request names the selected offer pubkey, so an endpoint rejects +work reflected from an offer signed by another provider. Nostr requests use +only the bounded intersection of client-configured and provider-advertised +relays; an offer cannot force the browser to connect to arbitrary relays. The static `signet.html` entry point redirects to `?network=signet` for servers that cannot override deployment assets. Explicit `?network=` and diff --git a/assets/invoice.js b/assets/invoice.js index f838b4c..a148c62 100644 --- a/assets/invoice.js +++ b/assets/invoice.js @@ -201,3 +201,11 @@ export function decodeBech32(bech32String) { } return new TextDecoder("utf-8").decode(new Uint8Array(eightBitArray)); } + +export function invoiceDescriptionMatches(original, proxy, requestedDescription) { + if (!original || !proxy) return false; + if (requestedDescription !== undefined) { + return proxy.description_hash === false && decodeBech32(proxy.description) === requestedDescription; + } + return original.description_hash === proxy.description_hash && original.description === proxy.description; +} diff --git a/assets/main.js b/assets/main.js index 244dfc2..28918c3 100644 --- a/assets/main.js +++ b/assets/main.js @@ -1,13 +1,13 @@ import { loadDiscoveryConfig } from "./discovery.js"; import { diagnostics, errorDetails } from "./diagnostics.js"; -import { decodeBech32, parseInvoice, validInvoice } from "./invoice.js"; +import { decodeBech32, invoiceDescriptionMatches, parseInvoice, validInvoice } from "./invoice.js"; import { invoiceNetwork, invoicePrefix, normalizeNetwork } from "./network.js"; import { discoverOffers, effectiveFeeMsat, feeWithinAdvertised, normalizePubkey, - randomOffer, + offerIsFresh, selectOffers, } from "./nostr.js"; import { FEATURE_REQUEST_ID_V1, wrapProvider } from "./transport.js"; @@ -88,8 +88,8 @@ function credentialCell(offer) { badge.className = "cred"; if (offer.attested) { badge.classList.add("verified"); - badge.textContent = "node-verified"; - badge.title = `Signature recovers to ${offer.node_pubkey}`; + badge.textContent = "node-bound"; + badge.title = `Offer key is signed by ${offer.node_pubkey}`; } else if (offer.node_pubkey) { badge.classList.add("unverified"); badge.textContent = "UNVERIFIED"; @@ -115,7 +115,7 @@ function renderProviderTable(offers, amountMsat) { const table = document.createElement("table"); table.className = "providers"; const header = table.createTHead().insertRow(); - for (const label of ["", "Fee", "Limits (sat)", "Credential", "PoW", "Transport", "Provider"]) { + for (const label of ["", "Provider fee", "Limits (sat)", "Credential", "PoW", "Transport", "Provider"]) { const th = document.createElement("th"); th.textContent = label; header.appendChild(th); @@ -240,7 +240,7 @@ function updateProviders() { const offerVersion = offers.map((offer) => `${offer.pubkey}:${offer.created_at}`).join(","); const selectionKey = `${invoice}|${minPow}|${pinValue}|${offerVersion}`; if (selectionKey !== state.selectionKey || !offers.some((offer) => offer.pubkey === state.selectedOffer?.pubkey)) { - state.selectedOffer = randomOffer(offers); + state.selectedOffer = offers[0]; state.selectionKey = selectionKey; diagnostics.info("provider.filter.completed", { network: state.config.network, @@ -251,7 +251,7 @@ function updateProviders() { eligibleOfferCount: offers.length, }); diagnostics.info("provider.selected", { - reason: "random", + reason: "cheapest-identity", provider: state.selectedOffer.pubkey, effectiveFeeMsat: effectiveFeeMsat(state.selectedOffer, amountMsat), committedPow: state.selectedOffer.committed_pow, @@ -279,7 +279,8 @@ async function refreshOffers(reason = "refresh") { state.selectionKey = ""; updateProviders(); try { - const offers = await discoverOffers(state.config.relays, { network }); + const minPow = Math.max(0, Number.parseInt(minPowInput.value, 10) || 0); + const offers = await discoverOffers(state.config.relays, { network, minPow }); if (generation !== state.discoveryGeneration) { diagnostics.info("discovery.stale-result.ignored", { generation, network }); return; @@ -404,6 +405,10 @@ async function wrapInvoice() { appendError("A valid invoice and eligible nostr provider are required."); return; } + if (!offerIsFresh(offer)) { + appendError("The selected provider offer expired. Refresh provider discovery before retrying."); + return; + } const requestRelays = state.config.relays; const wrapStartedAt = performance.now(); let outcome = "failed"; @@ -502,7 +507,7 @@ async function wrapInvoice() { appendError("Destination was not proxied."); return; } - if ("description" in data && decodeBech32(parsedProxyInvoice.description) !== data.description) { + if (!invoiceDescriptionMatches(parsedInvoice, parsedProxyInvoice, data.description)) { outcome = "description-mismatch"; appendError("Description does not match the request."); return; diff --git a/assets/nostr.js b/assets/nostr.js index 6eb2b6d..5968a46 100644 --- a/assets/nostr.js +++ b/assets/nostr.js @@ -26,7 +26,17 @@ export const KIND_RESPONSE = 21822; export const PROTOCOL_VERSION = "lnproxy-v1"; export const MAX_REQUEST_POW = 24; export const MAX_DIRECT_URLS = 3; +export const MAX_RELAYS = 8; export const MIN_RESPONSE_POW = 20; +export const DEFAULT_ROUTING_ALLOWANCE_MSAT = 3000; + +const MAX_OFFER_BYTES = 16 << 10; +const MAX_OFFER_TAGS = 16; +const MAX_OFFER_FEATURES = 32; +const MAX_DISCOVERY_EVENTS = 512; +const MAX_NOSTR_RESPONSE_BYTES = 64 << 10; +const OFFER_MAX_AGE_SECONDS = 3600; +const OFFER_FUTURE_SKEW_SECONDS = 60; // Default nostr relays. Overridable via assets/nostr-relays.json. export const DEFAULT_RELAYS = [ @@ -196,26 +206,32 @@ export function verifyAttestation(nostrPubkeyHex, nodePubkeyHex, sig) { // parseOffer parses and validates an offer event's content. Returns a // normalized offer object or null if the content is unusable. -export function parseOffer(event, { network = "mainnet", now = Date.now() } = {}) { +export function parseOffer(event, { network = "mainnet", now = Date.now(), minPow = 0 } = {}) { if (!event || event.kind !== KIND_OFFER) return null; - const dTag = tagValue(event, "d"); - const nTag = tagValue(event, "n"); + if (!/^[0-9a-f]{64}$/.test(event.id || "") || !/^[0-9a-f]{64}$/.test(event.pubkey || "")) return null; + if (typeof event.content !== "string" || event.content.length > MAX_OFFER_BYTES) return null; + if (!Array.isArray(event.tags) || event.tags.length > MAX_OFFER_TAGS) return null; + const dTag = exactTagValue(event, "d"); + const nTag = exactTagValue(event, "n"); if (dTag !== PROTOCOL_VERSION) return null; if (nTag !== network) return null; - // Freshness: created_at within ~1 hour of local time. if (!Number.isSafeInteger(event.created_at)) return null; - const ageSec = Math.abs(now / 1000 - event.created_at); - if (ageSec > 3600) return null; - const expiration = tagValue(event, "expiration"); + const nowSeconds = now / 1000; + if (event.created_at < nowSeconds - OFFER_MAX_AGE_SECONDS) return null; + if (event.created_at > nowSeconds + OFFER_FUTURE_SKEW_SECONDS) return null; + const expiration = exactTagValue(event, "expiration"); + let expires_at = null; if (expiration !== undefined) { - const expiresAt = toInt(expiration); - if (expiresAt === null || expiresAt <= now / 1000) return null; + expires_at = toInt(expiration); + if (expires_at === null || expires_at <= nowSeconds) return null; } const actual_pow = nip13.getPow(event.id); - const nonceTag = event.tags?.find((tag) => tag[0] === "nonce"); + const nonceTags = event.tags.filter((tag) => tag[0] === "nonce"); + if (nonceTags.length !== 1 || nonceTags[0].length !== 3) return null; + const nonceTag = nonceTags[0]; const committed_pow = nonceTag === undefined ? 0 : toInt(nonceTag[2]); - if (committed_pow === null || committed_pow > 256 || actual_pow < committed_pow) return null; + if (committed_pow === null || committed_pow > 256 || committed_pow < minPow || actual_pow < committed_pow) return null; let content; try { @@ -223,6 +239,7 @@ export function parseOffer(event, { network = "mainnet", now = Date.now() } = {} } catch (_e) { return null; } + if (content === null || typeof content !== "object" || Array.isArray(content)) return null; const base_fee_msat = toInt(content.base_fee_msat); const fee_ppm = toInt(content.fee_ppm); const min_amount_msat = toInt(content.min_amount_msat); @@ -258,7 +275,7 @@ export function parseOffer(event, { network = "mainnet", now = Date.now() } = {} max_amount_msat, max_expiry_seconds, min_request_pow, - features: stringArray(content.features), + features: stringArray(content.features, MAX_OFFER_FEATURES, 64), relays: relayURLs(content.relays), urls: directURLs(content.urls), node_pubkey, @@ -267,6 +284,8 @@ export function parseOffer(event, { network = "mainnet", now = Date.now() } = {} pow_nonce, identity_pow_bits, created_at: event.created_at, + expires_at, + event_id: event.id, committed_pow, actual_pow, }; @@ -275,7 +294,12 @@ export function parseOffer(event, { network = "mainnet", now = Date.now() } = {} // effectiveFeeMsat returns the advertised fee an offer charges for an invoice of // amountMsat, used for cheapest-first ranking. export function effectiveFeeMsat(offer, amountMsat) { - return offer.base_fee_msat + Math.floor((amountMsat * offer.fee_ppm) / 1_000_000); + try { + const fee = BigInt(offer.base_fee_msat) + (BigInt(amountMsat) * BigInt(offer.fee_ppm)) / 1_000_000n; + return fee <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(fee) : Number.MAX_SAFE_INTEGER; + } catch (_error) { + return Number.MAX_SAFE_INTEGER; + } } // wrapFeature maps a requested output format to the feature flag a provider @@ -296,7 +320,16 @@ export function wrapFeature(wrap) { } // canServe reports whether an offer can wrap amountMsat into wrap format. -export function canServe(offer, amountMsat, wrap = "bolt11") { +export function offerIsFresh(offer, now = Date.now()) { + if (!offer || !Number.isSafeInteger(offer.created_at)) return false; + const nowSeconds = now / 1000; + if (offer.created_at < nowSeconds - OFFER_MAX_AGE_SECONDS) return false; + if (offer.created_at > nowSeconds + OFFER_FUTURE_SKEW_SECONDS) return false; + return offer.expires_at === null || offer.expires_at === undefined || offer.expires_at > nowSeconds; +} + +export function canServe(offer, amountMsat, wrap = "bolt11", now = Date.now()) { + if (!offerIsFresh(offer, now)) return false; if (amountMsat < offer.min_amount_msat) return false; if (amountMsat > offer.max_amount_msat) return false; return offer.features.includes(wrapFeature(wrap)); @@ -306,10 +339,10 @@ export function canServe(offer, amountMsat, wrap = "bolt11") { // client's minimum committed proof of work, then sorts them cheapest-first. // Ties are broken by credential strength (attested node, then higher committed // proof of work). -export function selectOffers(offers, { amountMsat, wrap = "bolt11", minPow = 0 } = {}) { - return offers +export function selectOffers(offers, { amountMsat, wrap = "bolt11", minPow = 0, now = Date.now() } = {}) { + const ranked = offers .filter((o) => o.committed_pow >= minPow) - .filter((o) => canServe(o, amountMsat, wrap)) + .filter((o) => canServe(o, amountMsat, wrap, now)) .sort((a, b) => { const fa = effectiveFeeMsat(a, amountMsat); const fb = effectiveFeeMsat(b, amountMsat); @@ -320,18 +353,22 @@ export function selectOffers(offers, { amountMsat, wrap = "bolt11", minPow = 0 } if (b.identity_pow_bits !== a.identity_pow_bits) { return b.identity_pow_bits - a.identity_pow_bits; } - return b.committed_pow - a.committed_pow; + if (b.committed_pow !== a.committed_pow) return b.committed_pow - a.committed_pow; + return a.pubkey.localeCompare(b.pubkey); }); + const identities = new Set(); + return ranked.filter((offer) => { + const identity = providerIdentity(offer); + if (identities.has(identity)) return false; + identities.add(identity); + return true; + }); } -// randomOffer selects uniformly from an already-filtered offer list. Keeping -// this separate from ranking prevents always sending traffic to the cheapest -// provider and makes the selection policy directly testable. -export function randomOffer(offers, random = Math.random) { - if (!Array.isArray(offers) || offers.length === 0) return null; - const sample = Number(random()); - const index = Math.min(offers.length - 1, Math.max(0, Math.floor(sample * offers.length))); - return offers[index]; +export function providerIdentity(offer) { + return offer.attested && /^[0-9a-f]{66}$/i.test(offer.node_pubkey || "") + ? `node:${offer.node_pubkey.toLowerCase()}` + : `nostr:${offer.pubkey}`; } // credentialRank gives attested providers priority over anonymous ones for tie @@ -347,22 +384,40 @@ export function credentialRank(offer) { // feeWithinAdvertised checks that a returned proxy invoice does not charge more // than the offer advertised. When routingMsat is supplied the surplus must equal // it exactly (base protocol rule). Amounts in msat. -export function feeWithinAdvertised(offer, originalMsat, proxyMsat, routingMsat) { +export function feeWithinAdvertised( + offer, + originalMsat, + proxyMsat, + routingMsat, + routingAllowanceMsat = DEFAULT_ROUTING_ALLOWANCE_MSAT, +) { const surplus = proxyMsat - originalMsat; if (surplus < 0) return false; if (routingMsat !== undefined && routingMsat !== null) { return surplus === routingMsat; } - // Allow the advertised provider fee plus a routing budget headroom equal to - // that fee again (mirrors the relay's automatic budget, which is bounded). const advertised = effectiveFeeMsat(offer, originalMsat); - return surplus <= advertised * 2 + 1000; + return Number.isSafeInteger(routingAllowanceMsat) && routingAllowanceMsat >= 0 && + surplus <= advertised + routingAllowanceMsat; +} + +function exactTagValue(event, name) { + if (!Array.isArray(event.tags)) return undefined; + const tags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === name); + return tags.length === 1 && tags[0].length === 2 ? tags[0][1] : undefined; } -function tagValue(event, name) { - if (!event.tags) return undefined; - const tag = event.tags.find((t) => t[0] === name); - return tag ? tag[1] : undefined; +function offerEnvelopeAcceptable(event, network, minPow) { + if (!event || event.kind !== KIND_OFFER) return false; + if (!/^[0-9a-f]{64}$/.test(event.id || "") || !/^[0-9a-f]{64}$/.test(event.pubkey || "")) return false; + if (typeof event.content !== "string" || event.content.length > MAX_OFFER_BYTES) return false; + if (!Array.isArray(event.tags) || event.tags.length > MAX_OFFER_TAGS) return false; + if (exactTagValue(event, "d") !== PROTOCOL_VERSION || exactTagValue(event, "n") !== network) return false; + const nonceTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "nonce"); + if (nonceTags.length !== 1 || nonceTags[0].length !== 3) return false; + const committedPow = toInt(nonceTags[0][2]); + return committedPow !== null && committedPow >= minPow && committedPow <= 256 && + nip13.getPow(event.id) >= committedPow; } function toInt(v) { @@ -372,19 +427,31 @@ function toInt(v) { return Number.isSafeInteger(n) && n >= 0 ? n : null; } -function stringArray(value) { - return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; +function stringArray(value, limit = Number.MAX_SAFE_INTEGER, maxLength = Number.MAX_SAFE_INTEGER) { + return Array.isArray(value) + ? value.filter((item) => typeof item === "string" && item.length <= maxLength).slice(0, limit) + : []; } function relayURLs(value) { - return stringArray(value).filter((relay) => { + const result = []; + for (const relay of stringArray(value, MAX_RELAYS * 2, 2048)) { try { const url = new URL(relay); - return (url.protocol === "ws:" || url.protocol === "wss:") && url.username === "" && url.password === ""; + const hostname = url.hostname.toLowerCase().replace(/\.$/, ""); + const loopback = isLoopbackHostname(hostname); + if (url.username !== "" || url.password !== "" || url.hash !== "") continue; + if (loopback && !isLocalDevelopmentContext()) continue; + if (!loopback && isNonPublicIPAddress(hostname)) continue; + if (url.protocol === "ws:" && !loopback && !isV3OnionHostname(hostname)) continue; + if (url.protocol !== "ws:" && url.protocol !== "wss:") continue; + if (!result.includes(url.href)) result.push(url.href); + if (result.length === MAX_RELAYS) break; } catch (_error) { - return false; + continue; } - }); + } + return result; } function isLoopbackHostname(hostname) { @@ -490,7 +557,7 @@ const encoder = new TextEncoder(); // discoverOffers subscribes to relays and collects valid offers until the // timeout elapses, keeping the newest offer per provider. Returns an array of // parsed offers (unsorted). -export async function discoverOffers(relays, { network = "mainnet", timeoutMs = 4000 } = {}) { +export async function discoverOffers(relays, { network = "mainnet", timeoutMs = 4000, minPow = 0 } = {}) { relays = relayURLs(relays); if (relays.length === 0) throw new Error("no valid nostr relay URLs configured"); const startedAt = performance.now(); @@ -501,6 +568,7 @@ export async function discoverOffers(relays, { network = "mainnet", timeoutMs = }); const pool = new SimplePool(); const byPubkey = new Map(); + let processedEvents = 0; const since = Math.floor(Date.now() / 1000) - 3600; return new Promise((resolve) => { const filter = { @@ -523,23 +591,26 @@ export async function discoverOffers(relays, { network = "mainnet", timeoutMs = diagnostics.info("discovery.subscription.closed", { network, reasons }); }, onevent(event) { - if (!verifyEvent(event)) { - diagnostics.warn("discovery.event.invalid-signature", { - eventID: shortID(event.id), - pubkey: shortID(event.pubkey), + if (processedEvents++ >= MAX_DISCOVERY_EVENTS) return; + if (!offerEnvelopeAcceptable(event, network, minPow)) { + diagnostics.debug("discovery.offer.rejected", { + eventID: shortID(event?.id), + pubkey: shortID(event?.pubkey), }); return; } - const offer = parseOffer(event, { network }); - if (!offer) { - diagnostics.debug("discovery.offer.rejected", { + if (!verifyEvent(event)) { + diagnostics.warn("discovery.event.invalid-signature", { eventID: shortID(event.id), pubkey: shortID(event.pubkey), }); return; } + const offer = parseOffer(event, { network, minPow }); + if (!offer) return; const prev = byPubkey.get(offer.pubkey); - if (!prev || offer.created_at > prev.created_at) { + if (!prev || offer.created_at > prev.created_at || + (offer.created_at === prev.created_at && offer.event_id < prev.event_id)) { byPubkey.set(offer.pubkey, offer); diagnostics.info("discovery.offer.accepted", { provider: offer.pubkey, @@ -586,6 +657,7 @@ export async function discoverOffers(relays, { network = "mainnet", timeoutMs = // wrapViaNostr sends an encrypted wrap request to a provider and resolves with // the decrypted response. powDifficulty is mined into the request event. export async function wrapViaNostr(offer, request, { relays, timeoutMs = 30000 } = {}) { + const deadline = performance.now() + timeoutMs; const targetRelays = intersectRelays(relays, offer.relays); if (targetRelays.length === 0) throw new Error("provider has no valid nostr relay URLs"); diagnostics.info("wrap.transport.started", { @@ -616,7 +688,7 @@ export async function wrapViaNostr(offer, request, { relays, timeoutMs = 30000 } if (target > 0) { const miningStartedAt = performance.now(); diagnostics.info("wrap.pow.started", { difficulty: target }); - unsigned = await minePowAsync(unsigned, target); + unsigned = await minePowAsync(unsigned, target, { deadline }); diagnostics.info("wrap.pow.completed", { difficulty: target, actualPow: nip13.getPow(unsigned.id), @@ -624,6 +696,8 @@ export async function wrapViaNostr(offer, request, { relays, timeoutMs = 30000 } }); } const event = finalizeEvent(unsigned, sk); + const remainingMs = Math.ceil(deadline - performance.now()); + if (remainingMs <= 0) throw new Error("nostr wrap request timed out"); diagnostics.info("wrap.request.ready", { eventID: event.id, provider: offer.pubkey, @@ -645,7 +719,7 @@ export async function wrapViaNostr(offer, request, { relays, timeoutMs = 30000 } } fn(arg); }; - timer = setTimeout(() => finish(reject, new Error("nostr wrap request timed out")), timeoutMs); + timer = setTimeout(() => finish(reject, new Error("nostr wrap request timed out")), remainingMs); pool.subscribeMany( targetRelays, [{ kinds: [KIND_RESPONSE], authors: [offer.pubkey], "#p": [pk], "#e": [event.id] }], @@ -662,6 +736,10 @@ export async function wrapViaNostr(offer, request, { relays, timeoutMs = 30000 } }); }, onevent(resp) { + if (typeof resp?.content !== "string" || resp.content.length > MAX_NOSTR_RESPONSE_BYTES) { + finish(reject, new Error("provider response was too large")); + return; + } if (!validResponseEvent(resp, { provider: offer.pubkey, recipient: pk, @@ -676,6 +754,9 @@ export async function wrapViaNostr(offer, request, { relays, timeoutMs = 30000 } let text; try { text = nip44.decrypt(resp.content, convKey); + if (new TextEncoder().encode(text).byteLength > MAX_NOSTR_RESPONSE_BYTES) { + throw new Error("provider response was too large"); + } } catch (error) { diagnostics.error("wrap.response.decrypt-failed", { errorName: error?.name || "Error", @@ -719,12 +800,13 @@ export async function wrapViaNostr(offer, request, { relays, timeoutMs = 30000 } }); } -export async function minePowAsync(unsigned, difficulty, { chunkSize = 4096 } = {}) { +export async function minePowAsync(unsigned, difficulty, { chunkSize = 4096, deadline = Infinity } = {}) { const event = { ...unsigned, tags: unsigned.tags.map((tag) => [...tag]) }; const nonceTag = ["nonce", "0", String(difficulty)]; event.tags.push(nonceTag); let nonce = 0; while (true) { + if (performance.now() >= deadline) throw new Error("request proof of work timed out"); const now = Math.floor(Date.now() / 1000); if (event.created_at !== now) { event.created_at = now; @@ -742,11 +824,10 @@ export async function minePowAsync(unsigned, difficulty, { chunkSize = 4096 } = function intersectRelays(clientRelays, offerRelays) { clientRelays = relayURLs(clientRelays); offerRelays = relayURLs(offerRelays); - if (!offerRelays || offerRelays.length === 0) return clientRelays; + if (!offerRelays || offerRelays.length === 0) return []; const set = new Set(clientRelays); const common = offerRelays.filter((r) => set.has(r)); - // Fall back to the union so a request still reaches the provider. - return common.length > 0 ? common : Array.from(new Set([...clientRelays, ...offerRelays])); + return common.slice(0, MAX_RELAYS); } // avoid an unused-import warning for encoder in environments that tree-shake. diff --git a/assets/transport.js b/assets/transport.js index 8f4c15b..397d31d 100644 --- a/assets/transport.js +++ b/assets/transport.js @@ -4,6 +4,7 @@ import { MAX_DIRECT_URLS, wrapViaNostr } from "./nostr.js"; export const FEATURE_REQUEST_ID_V1 = "request_id_v1"; const DEFAULT_DIRECT_TIMEOUT_MS = 10000; const MAX_RESPONSE_BYTES = 64 << 10; +const MIN_DIRECT_RETRY_MS = 100; export class DirectTransportError extends Error { constructor(message, cause) { @@ -56,13 +57,20 @@ async function boundedResponseText(response, maxBytes = MAX_RESPONSE_BYTES) { return new TextDecoder().decode(body); } -function matchingResponse(response, requestID) { +export function validateProtocolResponse(response, requestID = "") { if (!response || typeof response !== "object" || Array.isArray(response)) { throw new DirectTransportError("provider response was not a JSON object"); } - if (response.request_id !== requestID) { + if (requestID && response.request_id !== requestID) { throw new DirectTransportError("provider response had a mismatched request ID"); } + const success = typeof response.proxy_invoice === "string" && response.proxy_invoice.length > 0 && + response.status === undefined && response.reason === undefined; + const failure = response.status === "ERROR" && typeof response.reason === "string" && + response.reason.length > 0 && response.proxy_invoice === undefined; + if (!success && !failure) { + throw new DirectTransportError("provider response had an invalid schema"); + } return response; } @@ -94,7 +102,7 @@ export async function wrapViaDirect( } catch (error) { throw new DirectTransportError("direct response was not valid JSON", error); } - return matchingResponse(parsed, request.request_id); + return validateProtocolResponse(parsed, request.request_id); } catch (error) { if (error instanceof DirectTransportError) throw error; if (controller.signal.aborted) { @@ -122,14 +130,18 @@ export async function wrapProvider( ) { const idempotent = offer.features?.includes(FEATURE_REQUEST_ID_V1) === true; const requestID = idempotent ? generateRequestID() : ""; - const transportRequest = requestID ? { ...request, request_id: requestID } : request; + const transportRequest = { + ...request, + provider_pubkey: offer.pubkey, + ...(requestID ? { request_id: requestID } : {}), + }; let directError = null; const directDeadline = performance.now() + Math.max(0, directTimeoutMs); if (idempotent) { - for (const endpoint of (offer.urls || []).slice(0, MAX_DIRECT_URLS)) { + for (const [index, endpoint] of (offer.urls || []).slice(0, MAX_DIRECT_URLS).entries()) { const remainingMs = Math.ceil(directDeadline - performance.now()); - if (remainingMs <= 0) { + if (remainingMs <= 0 || (index > 0 && remainingMs < MIN_DIRECT_RETRY_MS)) { directError ||= new DirectTransportError("direct transport timed out"); break; } @@ -179,7 +191,7 @@ export async function wrapProvider( const startedAt = performance.now(); try { const response = await nostrWrap(offer, transportRequest, { relays }); - const matched = requestID ? matchingResponse(response, requestID) : response; + const matched = validateProtocolResponse(response, requestID); diagnostics.info("wrap.transport.completed", { transport: "nostr", provider: offer.pubkey, diff --git a/test/invoice.test.js b/test/invoice.test.js index a506095..9fc652f 100644 --- a/test/invoice.test.js +++ b/test/invoice.test.js @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { parseInvoice, validInvoice } from "../assets/invoice.js"; +import { invoiceDescriptionMatches, parseInvoice, validInvoice } from "../assets/invoice.js"; const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; @@ -75,3 +75,13 @@ test("parseInvoice recovers the payee from an official BOLT11 vector", () => { const parsed = parseInvoice(validInvoice(invoice, "mainnet")); expect(parsed.destination).toBe("03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad"); }); + +test("invoiceDescriptionMatches preserves the original description type and value", () => { + const plain = { description_hash: false, description: "words" }; + const hashed = { description_hash: true, description: "hash-words" }; + expect(invoiceDescriptionMatches(plain, { ...plain })).toBe(true); + expect(invoiceDescriptionMatches(hashed, { ...hashed })).toBe(true); + expect(invoiceDescriptionMatches(plain, hashed)).toBe(false); + expect(invoiceDescriptionMatches(plain, { ...plain, description: "different" })).toBe(false); + expect(invoiceDescriptionMatches(plain, { description_hash: false, description: "" }, "")).toBe(true); +}); diff --git a/test/nostr.test.js b/test/nostr.test.js index 7d73ab6..e9defc0 100644 --- a/test/nostr.test.js +++ b/test/nostr.test.js @@ -5,7 +5,7 @@ import { wrapFeature, canServe, selectOffers, - randomOffer, + providerIdentity, credentialRank, feeWithinAdvertised, verifyAttestation, @@ -95,6 +95,7 @@ function makeOfferEvent(overrides = {}, contentOverrides = {}) { tags: [ ["d", PROTOCOL_VERSION], ["n", "mainnet"], + ["nonce", "0", "0"], ], content: JSON.stringify(content), ...overrides, @@ -128,6 +129,8 @@ test("parseOffer rejects invalid content", () => { const evt = makeOfferEvent({}, {}); evt.content = "not json"; expect(parseOffer(evt)).toBeNull(); + evt.content = "null"; + expect(parseOffer(evt)).toBeNull(); }); test("parseOffer rejects max < min", () => { @@ -162,7 +165,7 @@ test("parseOffer keeps only websocket relay URLs", () => { const offer = parseOffer(makeOfferEvent({}, { relays: ["wss://relay.example", "ws://127.0.0.1:7777", "https://not-websocket.example", 7], })); - expect(offer.relays).toEqual(["wss://relay.example", "ws://127.0.0.1:7777"]); + expect(offer.relays).toEqual(["wss://relay.example/"]); }); test("effectiveFeeMsat computes base + proportional", () => { @@ -205,12 +208,24 @@ test("selectOffers filters by minimum committed pow", () => { expect(selected.length).toBe(0); }); -test("randomOffer selects uniformly-addressable positions", () => { - const offers = [{ pubkey: "first" }, { pubkey: "middle" }, { pubkey: "last" }]; - expect(randomOffer([], () => 0)).toBeNull(); - expect(randomOffer(offers, () => 0)).toBe(offers[0]); - expect(randomOffer(offers, () => 0.34)).toBe(offers[1]); - expect(randomOffer(offers, () => 0.999999)).toBe(offers[2]); +test("selectOffers keeps one cheapest offer per attested node", () => { + const sharedNode = "02" + "a".repeat(64); + const first = parseOffer(makeOfferEvent({ pubkey: "1".repeat(64) }, { base_fee_msat: 500 })); + const duplicate = parseOffer(makeOfferEvent({ pubkey: "2".repeat(64) }, { base_fee_msat: 1000 })); + const other = parseOffer(makeOfferEvent({ pubkey: "3".repeat(64) }, { base_fee_msat: 1500 })); + Object.assign(first, { attested: true, node_pubkey: sharedNode }); + Object.assign(duplicate, { attested: true, node_pubkey: sharedNode }); + Object.assign(other, { attested: true, node_pubkey: "03" + "b".repeat(64) }); + + const selected = selectOffers([duplicate, other, first], { amountMsat: 1_000_000 }); + expect(selected.map((offer) => offer.pubkey)).toEqual([first.pubkey, other.pubkey]); + expect(providerIdentity(first)).toBe(providerIdentity(duplicate)); +}); + +test("selectOffers rejects offers that expired after discovery", () => { + const offer = parseOffer(makeOfferEvent()); + const later = (offer.created_at + 3601) * 1000; + expect(selectOffers([offer], { amountMsat: 1_000_000, now: later })).toEqual([]); }); test("minePowAsync commits to and reaches the requested difficulty", async () => { @@ -225,6 +240,16 @@ test("minePowAsync commits to and reaches the requested difficulty", async () => expect(Number.parseInt(mined.id.slice(0, 2), 16)).toBeLessThan(1); }); +test("minePowAsync respects its deadline", async () => { + await expect(minePowAsync({ + kind: 21821, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", "a".repeat(64)]], + content: "test", + pubkey: "b".repeat(64), + }, 256, { deadline: performance.now() })).rejects.toThrow("timed out"); +}); + test("validResponseEvent verifies routing, signature, and committed proof of work", async () => { const providerSK = generateSecretKey(); const provider = getPublicKey(providerSK); @@ -391,6 +416,13 @@ test("feeWithinAdvertised enforces advertised schedule", () => { expect(feeWithinAdvertised(offer, original, original - 1)).toBe(false); }); +test("feeWithinAdvertised uses a fixed routing allowance", () => { + const offer = parseOffer(makeOfferEvent({}, { base_fee_msat: 100_000_000, fee_ppm: 0 })); + const original = 1_000_000; + expect(feeWithinAdvertised(offer, original, original + 100_003_000)).toBe(true); + expect(feeWithinAdvertised(offer, original, original + 100_003_001)).toBe(false); +}); + test("feeWithinAdvertised requires exact surplus with routing_msat", () => { const offer = parseOffer(makeOfferEvent()); const original = 1_000_000; diff --git a/test/transport.test.js b/test/transport.test.js index f977cab..b127be6 100644 --- a/test/transport.test.js +++ b/test/transport.test.js @@ -4,6 +4,7 @@ import { directURLs, MAX_DIRECT_URLS, normalizeDirectURL } from "../assets/nostr import { FEATURE_REQUEST_ID_V1, generateRequestID, + validateProtocolResponse, wrapProvider, wrapViaDirect, } from "../assets/transport.js"; @@ -112,9 +113,11 @@ test("wrapViaDirect rejects oversized response bodies", async () => { test("wrapProvider uses direct transport first", async () => { let nostrCalls = 0; + let directRequest; const response = await wrapProvider(offer(), { invoice: "lnbc1..." }, { fetchImpl: async (_url, options) => { const request = JSON.parse(options.body); + directRequest = request; return new Response(JSON.stringify({ request_id: request.request_id, proxy_invoice: "direct" })); }, nostrWrap: async () => { @@ -123,6 +126,7 @@ test("wrapProvider uses direct transport first", async () => { }, }); expect(response.proxy_invoice).toBe("direct"); + expect(directRequest.provider_pubkey).toBe(provider); expect(nostrCalls).toBe(0); }); @@ -237,6 +241,7 @@ test("wrapProvider does not use direct endpoints without request_id_v1", async ( }); expect(directCalls).toBe(0); expect(nostrRequest.request_id).toBeUndefined(); + expect(nostrRequest.provider_pubkey).toBe(provider); }); test("a valid provider error does not trigger nostr fallback", async () => { @@ -265,3 +270,34 @@ test("mismatched direct response ID falls back and mismatched nostr ID fails", a nostrWrap: async () => ({ request_id: "3".repeat(64), proxy_invoice: "bad" }), })).rejects.toThrow("mismatched request ID"); }); + +test("protocol responses require exactly one success or error shape", () => { + const requestID = "1".repeat(64); + expect(validateProtocolResponse({ request_id: requestID, proxy_invoice: "lnbc1..." }, requestID).proxy_invoice) + .toBe("lnbc1..."); + expect(validateProtocolResponse({ request_id: requestID, status: "ERROR", reason: "no route" }, requestID).status) + .toBe("ERROR"); + for (const response of [ + { request_id: requestID }, + { request_id: requestID, status: "ERROR" }, + { request_id: requestID, proxy_invoice: "lnbc1...", status: "ERROR", reason: "ambiguous" }, + ]) { + expect(() => validateProtocolResponse(response, requestID)).toThrow("invalid schema"); + } +}); + +test("an invalid direct response schema falls back to nostr", async () => { + let nostrCalls = 0; + const response = await wrapProvider(offer(), { invoice: "lnbc1..." }, { + fetchImpl: async (_url, options) => { + const request = JSON.parse(options.body); + return new Response(JSON.stringify({ request_id: request.request_id })); + }, + nostrWrap: async (_offer, request) => { + nostrCalls++; + return { request_id: request.request_id, proxy_invoice: "nostr" }; + }, + }); + expect(response.proxy_invoice).toBe("nostr"); + expect(nostrCalls).toBe(1); +}); From d6d9cce3389421d3c4f9419951902a0f45723168 Mon Sep 17 00:00:00 2001 From: m0wer Date: Thu, 16 Jul 2026 08:28:50 +0200 Subject: [PATCH 07/11] fix: isolate nostr relay discovery --- assets/nostr.js | 135 ++++++++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 62 deletions(-) diff --git a/assets/nostr.js b/assets/nostr.js index 5968a46..f5fad93 100644 --- a/assets/nostr.js +++ b/assets/nostr.js @@ -33,7 +33,7 @@ export const DEFAULT_ROUTING_ALLOWANCE_MSAT = 3000; const MAX_OFFER_BYTES = 16 << 10; const MAX_OFFER_TAGS = 16; const MAX_OFFER_FEATURES = 32; -const MAX_DISCOVERY_EVENTS = 512; +const MAX_DISCOVERY_EVENTS_PER_RELAY = 128; const MAX_NOSTR_RESPONSE_BYTES = 64 << 10; const OFFER_MAX_AGE_SECONDS = 3600; const OFFER_FUTURE_SKEW_SECONDS = 60; @@ -566,9 +566,7 @@ export async function discoverOffers(relays, { network = "mainnet", timeoutMs = relays, timeoutMs, }); - const pool = new SimplePool(); const byPubkey = new Map(); - let processedEvents = 0; const since = Math.floor(Date.now() / 1000) - 3600; return new Promise((resolve) => { const filter = { @@ -577,70 +575,83 @@ export async function discoverOffers(relays, { network = "mainnet", timeoutMs = "#n": [network], since, }; - const sub = pool.subscribeMany(relays, [filter], { - receivedEvent(relay, eventID) { - diagnostics.debug("discovery.event.received", { - relay: relay.url, - eventID: shortID(eventID), - }); - }, - oneose() { - diagnostics.debug("discovery.relay.eose", { network }); - }, - onclose(reasons) { - diagnostics.info("discovery.subscription.closed", { network, reasons }); - }, - onevent(event) { - if (processedEvents++ >= MAX_DISCOVERY_EVENTS) return; - if (!offerEnvelopeAcceptable(event, network, minPow)) { - diagnostics.debug("discovery.offer.rejected", { - eventID: shortID(event?.id), - pubkey: shortID(event?.pubkey), - }); - return; + const connections = relays.map((relayURL) => { + const pool = new SimplePool(); + let processedEvents = 0; + let sub; + const close = () => { + try { + sub?.close(); + } catch (_e) { + /* ignore */ } - if (!verifyEvent(event)) { - diagnostics.warn("discovery.event.invalid-signature", { - eventID: shortID(event.id), - pubkey: shortID(event.pubkey), - }); - return; + try { + pool.close([relayURL]); + } catch (_e) { + /* ignore */ } - const offer = parseOffer(event, { network, minPow }); - if (!offer) return; - const prev = byPubkey.get(offer.pubkey); - if (!prev || offer.created_at > prev.created_at || - (offer.created_at === prev.created_at && offer.event_id < prev.event_id)) { - byPubkey.set(offer.pubkey, offer); - diagnostics.info("discovery.offer.accepted", { - provider: offer.pubkey, - eventID: shortID(event.id), - createdAt: offer.created_at, - baseFeeMsat: offer.base_fee_msat, - feePpm: offer.fee_ppm, - minAmountMsat: offer.min_amount_msat, - maxAmountMsat: offer.max_amount_msat, - committedPow: offer.committed_pow, - actualPow: offer.actual_pow, - requestPow: offer.min_request_pow, - attested: offer.attested, - features: offer.features, - relays: offer.relays, + }; + sub = pool.subscribeMany([relayURL], [filter], { + receivedEvent(relay, eventID) { + diagnostics.debug("discovery.event.received", { + relay: relay.url, + eventID: shortID(eventID), }); - } - }, + }, + oneose() { + diagnostics.debug("discovery.relay.eose", { network, relay: relayURL }); + }, + onclose(reasons) { + diagnostics.info("discovery.subscription.closed", { network, relay: relayURL, reasons }); + }, + onevent(event) { + if (processedEvents++ >= MAX_DISCOVERY_EVENTS_PER_RELAY) { + close(); + return; + } + if (!offerEnvelopeAcceptable(event, network, minPow)) return; + if (!verifyEvent(event)) { + diagnostics.warn("discovery.event.invalid-signature", { + relay: relayURL, + eventID: shortID(event.id), + pubkey: shortID(event.pubkey), + }); + return; + } + const offer = parseOffer(event, { network, minPow }); + if (!offer) return; + const prev = byPubkey.get(offer.pubkey); + if (!prev || offer.created_at > prev.created_at || + (offer.created_at === prev.created_at && offer.event_id < prev.event_id)) { + byPubkey.set(offer.pubkey, offer); + diagnostics.info("discovery.offer.accepted", { + relay: relayURL, + provider: offer.pubkey, + eventID: shortID(event.id), + createdAt: offer.created_at, + baseFeeMsat: offer.base_fee_msat, + feePpm: offer.fee_ppm, + minAmountMsat: offer.min_amount_msat, + maxAmountMsat: offer.max_amount_msat, + committedPow: offer.committed_pow, + actualPow: offer.actual_pow, + requestPow: offer.min_request_pow, + attested: offer.attested, + features: offer.features, + relays: offer.relays, + }); + } + }, + }); + return { pool, close }; }); setTimeout(() => { - const connectionStatus = Object.fromEntries(pool.listConnectionStatus()); - try { - sub.close(); - } catch (_e) { - /* ignore */ - } - try { - pool.close(relays); - } catch (_e) { - /* ignore */ + const connectionStatus = {}; + for (const connection of connections) { + for (const [url, status] of connection.pool.listConnectionStatus()) { + connectionStatus[url] = status; + } + connection.close(); } const offers = Array.from(byPubkey.values()); diagnostics.info("discovery.subscription.completed", { From 8f3d42eaacaad533ebd0c3c40289ff3884414c58 Mon Sep 17 00:00:00 2001 From: m0wer Date: Thu, 16 Jul 2026 08:42:43 +0200 Subject: [PATCH 08/11] fix: validate invoice expiration --- assets/invoice.js | 32 ++++++++++++++++++++++++++++++++ assets/main.js | 21 ++++++++++++++++++++- test/invoice.test.js | 12 +++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/assets/invoice.js b/assets/invoice.js index a148c62..a7215bd 100644 --- a/assets/invoice.js +++ b/assets/invoice.js @@ -76,6 +76,16 @@ function toHex(bytes) { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } +function wordsToInteger(words) { + let value = 0; + for (const word of words) { + if (!Number.isInteger(word) || word < 0 || word > 31) throw new Error("invalid bech32 integer"); + value = value * 32 + word; + if (!Number.isSafeInteger(value)) throw new Error("bech32 integer exceeds safe range"); + } + return value; +} + function invoiceDestination(invoice, separator, explicitPayeeWords) { try { const signatureWords = Array.from(invoice.slice(-110, -6), (char) => CHARSET.indexOf(char)); @@ -137,12 +147,17 @@ export function parseInvoice(invoice) { if (!Number.isSafeInteger(amount) || amount <= 0) throw new Error("invalid BOLT11 amount"); } spanned += invoice.slice(pos, pos + 8); + const timestamp = wordsToInteger( + Array.from(invoice.slice(pos + 1, pos + 8), (char) => CHARSET.indexOf(char)), + ); const data = invoice.slice(pos + 8, -110); let hash = ""; let description = ""; let descriptionHash = false; let explicitPayeeWords = null; + let expirySeconds = 3600; + let hasExpiry = false; let i = 0; while (i < data.length) { if (i + 3 > data.length) throw new Error("truncated BOLT11 tagged field"); @@ -163,12 +178,21 @@ export function parseInvoice(invoice) { } else if (data[i] === "n" && dataLength === 53 && explicitPayeeWords === null) { explicitPayeeWords = Array.from(data.slice(i + 3, i + 3 + dataLength), (char) => CHARSET.indexOf(char)); spanned += data.slice(i + 3, i + 3 + dataLength); + } else if (data[i] === "x") { + if (hasExpiry) throw new Error("duplicate BOLT11 expiry"); + hasExpiry = true; + expirySeconds = wordsToInteger( + Array.from(data.slice(i + 3, i + 3 + dataLength), (char) => CHARSET.indexOf(char)), + ); + spanned += data.slice(i + 3, i + 3 + dataLength); } else { spanned += data.slice(i + 3, i + 3 + dataLength); } i += 3 + dataLength; } if (hash.length !== 52) throw new Error("missing BOLT11 payment hash"); + const expiresAt = timestamp + expirySeconds; + if (!Number.isSafeInteger(expiresAt)) throw new Error("invalid BOLT11 expiration"); const signature = invoice.slice(-110, -6); const destination = invoiceDestination(invoice, pos, explicitPayeeWords); @@ -176,6 +200,9 @@ export function parseInvoice(invoice) { spanned += invoice.slice(-6); return { msat_amount: amount, + timestamp, + expiry_seconds: expirySeconds, + expires_at: expiresAt, hash, description, description_hash: descriptionHash, @@ -185,6 +212,11 @@ export function parseInvoice(invoice) { }; } +export function invoiceIsPayable(parsed, { now = Date.now(), minRemainingSeconds = 60 } = {}) { + if (!parsed || !Number.isSafeInteger(parsed.expires_at)) return false; + return parsed.expires_at >= Math.floor(now / 1000) + minRemainingSeconds; +} + export function decodeBech32(bech32String) { const fiveBitArray = Array.from(bech32String).map((char) => CHARSET.indexOf(char)); const eightBitArray = []; diff --git a/assets/main.js b/assets/main.js index 28918c3..f308716 100644 --- a/assets/main.js +++ b/assets/main.js @@ -1,6 +1,12 @@ import { loadDiscoveryConfig } from "./discovery.js"; import { diagnostics, errorDetails } from "./diagnostics.js"; -import { decodeBech32, invoiceDescriptionMatches, parseInvoice, validInvoice } from "./invoice.js"; +import { + decodeBech32, + invoiceDescriptionMatches, + invoiceIsPayable, + parseInvoice, + validInvoice, +} from "./invoice.js"; import { invoiceNetwork, invoicePrefix, normalizeNetwork } from "./network.js"; import { discoverOffers, @@ -195,6 +201,10 @@ function updateProviders() { clearSelection("Invalid BOLT11 invoice signature"); return; } + if (!invoiceIsPayable(parsedInvoice)) { + clearSelection("Invoice is expired or expires too soon"); + return; + } const amountMsat = parsedInvoice.msat_amount; if (amountMsat === 0) { clearSelection("Invoice amount is required"); @@ -423,6 +433,10 @@ async function wrapInvoice() { appendError("The original invoice signature is invalid."); return; } + if (!invoiceIsPayable(parsedInvoice)) { + appendError("The original invoice is expired or expires too soon."); + return; + } const data = { invoice }; if (advancedOptions.style.display === "block" && descriptionInput.value !== "") { @@ -496,6 +510,11 @@ async function wrapInvoice() { appendError(`${providerName} returned an invoice with an invalid signature.`); return; } + if (!invoiceIsPayable(parsedProxyInvoice) || parsedProxyInvoice.expires_at > parsedInvoice.expires_at) { + outcome = "invalid-proxy-expiry"; + appendError(`${providerName} returned an invoice with an unsafe expiration.`); + return; + } appendInvoiceDetails(parsedInvoice, parsedProxyInvoice); if (parsedInvoice.hash !== parsedProxyInvoice.hash) { outcome = "payment-hash-mismatch"; diff --git a/test/invoice.test.js b/test/invoice.test.js index 9fc652f..0afc81f 100644 --- a/test/invoice.test.js +++ b/test/invoice.test.js @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { invoiceDescriptionMatches, parseInvoice, validInvoice } from "../assets/invoice.js"; +import { invoiceDescriptionMatches, invoiceIsPayable, parseInvoice, validInvoice } from "../assets/invoice.js"; const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; @@ -44,6 +44,9 @@ test("validInvoice enforces the configured network", () => { test("parseInvoice reads a signet amount", () => { const parsed = parseInvoice(syntheticInvoice("lntbs", "50u")); expect(parsed.msat_amount).toBe(5_000_000); + expect(parsed.timestamp).toBe(0); + expect(parsed.expiry_seconds).toBe(3600); + expect(parsed.expires_at).toBe(3600); }); test("parseInvoice supports whole-bitcoin amounts without a multiplier", () => { @@ -85,3 +88,10 @@ test("invoiceDescriptionMatches preserves the original description type and valu expect(invoiceDescriptionMatches(plain, { ...plain, description: "different" })).toBe(false); expect(invoiceDescriptionMatches(plain, { description_hash: false, description: "" }, "")).toBe(true); }); + +test("invoiceIsPayable enforces a minimum remaining lifetime", () => { + const now = 1_700_000_000_000; + expect(invoiceIsPayable({ expires_at: 1_700_000_060 }, { now })).toBe(true); + expect(invoiceIsPayable({ expires_at: 1_700_000_059 }, { now })).toBe(false); + expect(invoiceIsPayable({}, { now })).toBe(false); +}); From a4cab618fe43e478132dc809dbda1394d468793f Mon Sep 17 00:00:00 2001 From: m0wer Date: Thu, 16 Jul 2026 08:53:16 +0200 Subject: [PATCH 09/11] test: configure client relay alias --- e2e/docker-compose.e2e.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 9c5df3c..3e3b891 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -141,6 +141,7 @@ services: - --lnd=https://lnd-provider:8080 - --lnd-cert=/shared/provider-tls.cert - --nostr-relays=ws://nostr-relay:8080 + - --advertised-nostr-relays=ws://127.0.0.1:7777 - --network=regtest - --features=pay_bolt11,pay_bolt11_blinded,wrap_bolt11 - --http-listen=0.0.0.0:4747 From 8df631858f3789d51ae45caf1c8d6c7f89a02c28 Mon Sep 17 00:00:00 2001 From: m0wer Date: Thu, 16 Jul 2026 09:18:29 +0200 Subject: [PATCH 10/11] fix: bound nostr event verification --- assets/invoice.js | 6 +++++ assets/main.js | 5 ++-- assets/nostr-bundle.js | 2 +- assets/nostr-tools-entry.js | 2 +- assets/nostr.js | 53 ++++++++++++++++++++++++++++--------- test/invoice.test.js | 9 +++++-- test/nostr.test.js | 13 +++++++++ 7 files changed, 71 insertions(+), 19 deletions(-) diff --git a/assets/invoice.js b/assets/invoice.js index a7215bd..17528e6 100644 --- a/assets/invoice.js +++ b/assets/invoice.js @@ -155,6 +155,7 @@ export function parseInvoice(invoice) { let hash = ""; let description = ""; let descriptionHash = false; + let hasDescription = false; let explicitPayeeWords = null; let expirySeconds = 3600; let hasExpiry = false; @@ -169,9 +170,13 @@ export function parseInvoice(invoice) { hash = data.slice(i + 3, i + 55); spanned += `${hash}`; } else if (data[i] === "d") { + if (hasDescription) throw new Error("duplicate BOLT11 description"); + hasDescription = true; description = data.slice(i + 3, i + 3 + dataLength); spanned += `${description}`; } else if (data[i] === "h" && data.slice(i + 1, i + 3) === "p5") { + if (hasDescription) throw new Error("duplicate BOLT11 description"); + hasDescription = true; descriptionHash = true; description = data.slice(i + 3, i + 55); spanned += `${description}`; @@ -191,6 +196,7 @@ export function parseInvoice(invoice) { i += 3 + dataLength; } if (hash.length !== 52) throw new Error("missing BOLT11 payment hash"); + if (!hasDescription) throw new Error("missing BOLT11 description"); const expiresAt = timestamp + expirySeconds; if (!Number.isSafeInteger(expiresAt)) throw new Error("invalid BOLT11 expiration"); diff --git a/assets/main.js b/assets/main.js index f308716..2f2d3e9 100644 --- a/assets/main.js +++ b/assets/main.js @@ -220,7 +220,7 @@ function updateProviders() { } const minPow = Math.max(0, Number.parseInt(minPowInput.value, 10) || 0); - let offers = selectOffers(state.offers, { amountMsat, wrap: "bolt11", minPow }); + let candidateOffers = state.offers; const pinValue = pinProviderInput.value.trim(); if (pinValue !== "") { const pinned = normalizePubkey(pinValue); @@ -228,8 +228,9 @@ function updateProviders() { clearSelection("Pinned provider is not a valid npub or hex pubkey"); return; } - offers = offers.filter((offer) => offer.pubkey === pinned); + candidateOffers = candidateOffers.filter((offer) => offer.pubkey === pinned); } + const offers = selectOffers(candidateOffers, { amountMsat, wrap: "bolt11", minPow }); if (offers.length === 0) { const noOffersKey = `${state.config.network}|${amountMsat}|${minPow}|${pinValue}|0`; if (state.lastFilterLogKey !== noOffersKey) { diff --git a/assets/nostr-bundle.js b/assets/nostr-bundle.js index 5f557f6..eebc79d 100644 --- a/assets/nostr-bundle.js +++ b/assets/nostr-bundle.js @@ -1 +1 @@ -var Vc=Object.defineProperty;var jc=(r)=>r;function Hc(r,e){this[r]=jc.bind(null,e)}var mr=(r,e)=>{for(var n in e)Vc(r,n,{get:e[n],enumerable:!0,configurable:!0,set:Hc.bind(e,n)})};function Ee(r){if(!Number.isSafeInteger(r)||r<0)throw Error(`Wrong positive integer: ${r}`)}function An(r,...e){if(!(r instanceof Uint8Array))throw Error("Expected Uint8Array");if(e.length>0&&!e.includes(r.length))throw Error(`Expected Uint8Array of length ${e}, not of length=${r.length}`)}function Ke(r){if(typeof r!=="function"||typeof r.create!=="function")throw Error("Hash should be wrapped by utils.wrapConstructor");Ee(r.outputLen),Ee(r.blockLen)}function Vr(r,e=!0){if(r.destroyed)throw Error("Hash instance has been destroyed");if(e&&r.finished)throw Error("Hash#digest() has already been called")}function We(r,e){An(r);let n=e.outputLen;if(r.lengthr instanceof Uint8Array;var Fr=(r)=>new DataView(r.buffer,r.byteOffset,r.byteLength),cr=(r,e)=>r<<32-e|r>>>e,uc=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;if(!uc)throw Error("Non little-endian hardware is not supported");function Rc(r){if(typeof r!=="string")throw Error(`utf8ToBytes expected string, got ${typeof r}`);return new Uint8Array(new TextEncoder().encode(r))}function Br(r){if(typeof r==="string")r=Rc(r);if(!Ne(r))throw Error(`expected Uint8Array, got ${typeof r}`);return r}function Se(...r){let e=new Uint8Array(r.reduce((c,f)=>c+f.length,0)),n=0;return r.forEach((c)=>{if(!Ne(c))throw Error("Uint8Array expected");e.set(c,n),n+=c.length}),e}class vr{clone(){return this._cloneInto()}}var m0={}.toString;function Xe(r){let e=(c)=>r().update(Br(c)).digest(),n=r();return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=()=>r(),e}function hr(r=32){if(tr&&typeof tr.getRandomValues==="function")return tr.getRandomValues(new Uint8Array(r));throw Error("crypto.getRandomValues must be defined")}function Pc(r,e,n,c){if(typeof r.setBigUint64==="function")return r.setBigUint64(e,n,c);let f=BigInt(32),w=BigInt(4294967295),i=Number(n>>f&w),l=Number(n&w),$=c?4:0,I=c?0:4;r.setUint32(e+$,i,c),r.setUint32(e+I,l,c)}class dn extends vr{constructor(r,e,n,c){super();this.blockLen=r,this.outputLen=e,this.padOffset=n,this.isLE=c,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(r),this.view=Fr(this.buffer)}update(r){Vr(this);let{view:e,buffer:n,blockLen:c}=this;r=Br(r);let f=r.length;for(let w=0;wc-w)this.process(n,0),w=0;for(let J=w;JI.length)throw Error("_sha2: outputLen bigger than state");for(let J=0;J<$;J++)i.setUint32(4*J,I[J],f)}digest(){let{buffer:r,outputLen:e}=this;this.digestInto(r);let n=r.slice(0,e);return this.destroy(),n}_cloneInto(r){r||(r=new this.constructor),r.set(...this.get());let{blockLen:e,buffer:n,length:c,finished:f,destroyed:w,pos:i}=this;if(r.length=c,r.pos=i,r.finished=f,r.destroyed=w,c%e)r.buffer.set(n);return r}}var Bc=(r,e,n)=>r&e^~r&n,vc=(r,e,n)=>r&e^r&n^e&n,oc=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),Mr=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),Gr=new Uint32Array(64);class Ye extends dn{constructor(){super(64,32,8,!1);this.A=Mr[0]|0,this.B=Mr[1]|0,this.C=Mr[2]|0,this.D=Mr[3]|0,this.E=Mr[4]|0,this.F=Mr[5]|0,this.G=Mr[6]|0,this.H=Mr[7]|0}get(){let{A:r,B:e,C:n,D:c,E:f,F:w,G:i,H:l}=this;return[r,e,n,c,f,w,i,l]}set(r,e,n,c,f,w,i,l){this.A=r|0,this.B=e|0,this.C=n|0,this.D=c|0,this.E=f|0,this.F=w|0,this.G=i|0,this.H=l|0}process(r,e){for(let J=0;J<16;J++,e+=4)Gr[J]=r.getUint32(e,!1);for(let J=16;J<64;J++){let Q=Gr[J-15],M=Gr[J-2],K=cr(Q,7)^cr(Q,18)^Q>>>3,z=cr(M,17)^cr(M,19)^M>>>10;Gr[J]=z+Gr[J-7]+K+Gr[J-16]|0}let{A:n,B:c,C:f,D:w,E:i,F:l,G:$,H:I}=this;for(let J=0;J<64;J++){let Q=cr(i,6)^cr(i,11)^cr(i,25),M=I+Q+Bc(i,l,$)+oc[J]+Gr[J]|0,z=(cr(n,2)^cr(n,13)^cr(n,22))+vc(n,c,f)|0;I=$,$=l,l=i,i=w+M|0,w=f,f=c,c=n,n=M+z|0}n=n+this.A|0,c=c+this.B|0,f=f+this.C|0,w=w+this.D|0,i=i+this.E|0,l=l+this.F|0,$=$+this.G|0,I=I+this.H|0,this.set(n,c,f,w,i,l,$,I)}roundClean(){Gr.fill(0)}destroy(){this.set(0,0,0,0,0,0,0,0),this.buffer.fill(0)}}var sr=Xe(()=>new Ye);var Zn={};mr(Zn,{validateObject:()=>Er,utf8ToBytes:()=>kc,numberToVarBytesBE:()=>yc,numberToHexUnpadded:()=>Ue,numberToBytesLE:()=>rn,numberToBytesBE:()=>fr,hexToNumber:()=>gn,hexToBytes:()=>Ar,equalBytes:()=>bc,ensureBytes:()=>b,createHmacDrbg:()=>On,concatBytes:()=>Jr,bytesToNumberLE:()=>pr,bytesToNumberBE:()=>m,bytesToHex:()=>Yr,bitSet:()=>tc,bitMask:()=>or,bitLen:()=>_c,bitGet:()=>mc});/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var de=BigInt(0),xr=BigInt(1),Cc=BigInt(2),ar=(r)=>r instanceof Uint8Array,Lc=Array.from({length:256},(r,e)=>e.toString(16).padStart(2,"0"));function Yr(r){if(!ar(r))throw Error("Uint8Array expected");let e="";for(let n=0;nc+f.length,0)),n=0;return r.forEach((c)=>{if(!ar(c))throw Error("Uint8Array expected");e.set(c,n),n+=c.length}),e}function bc(r,e){if(r.length!==e.length)return!1;for(let n=0;nde;r>>=xr,e+=1);return e}function mc(r,e){return r>>BigInt(e)&xr}var tc=(r,e,n)=>{return r|(n?xr:de)<(Cc<new Uint8Array(r),Ae=(r)=>Uint8Array.from(r);function On(r,e,n){if(typeof r!=="number"||r<2)throw Error("hashLen must be a number");if(typeof e!=="number"||e<2)throw Error("qByteLen must be a number");if(typeof n!=="function")throw Error("hmacFn must be a function");let c=Un(r),f=Un(r),w=0,i=()=>{c.fill(1),f.fill(0),w=0},l=(...Q)=>n(f,c,...Q),$=(Q=Un())=>{if(f=l(Ae([0]),Q),c=l(),Q.length===0)return;f=l(Ae([1]),Q),c=l()},I=()=>{if(w++>=1000)throw Error("drbg: tried 1000 values");let Q=0,M=[];while(Q{i(),$(Q);let K=void 0;while(!(K=M(I())))$();return i(),K}}var Fc={bigint:(r)=>typeof r==="bigint",function:(r)=>typeof r==="function",boolean:(r)=>typeof r==="boolean",string:(r)=>typeof r==="string",stringOrUint8Array:(r)=>typeof r==="string"||r instanceof Uint8Array,isSafeInteger:(r)=>Number.isSafeInteger(r),array:(r)=>Array.isArray(r),field:(r,e)=>e.Fp.isValid(r),hash:(r)=>typeof r==="function"&&Number.isSafeInteger(r.outputLen)};function Er(r,e,n={}){let c=(f,w,i)=>{let l=Fc[w];if(typeof l!=="function")throw Error(`Invalid validator "${w}", expected function`);let $=r[f];if(i&&$===void 0)return;if(!l($,r))throw Error(`Invalid param ${String(f)}=${$} (${typeof $}), expected ${w}`)};for(let[f,w]of Object.entries(e))c(f,w,!1);for(let[f,w]of Object.entries(n))c(f,w,!0);return r}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var _=BigInt(0),C=BigInt(1),dr=BigInt(2),hc=BigInt(3),Dn=BigInt(4),ge=BigInt(5),Oe=BigInt(8),sc=BigInt(9),xc=BigInt(16);function k(r,e){let n=r%e;return n>=_?n:e+n}function ac(r,e,n){if(n<=_||e<_)throw Error("Expected power/modulo > 0");if(n===C)return _;let c=C;while(e>_){if(e&C)c=c*r%n;r=r*r%n,e>>=C}return c}function a(r,e,n){let c=r;while(e-- >_)c*=c,c%=n;return c}function nn(r,e){if(r===_||e<=_)throw Error(`invert: expected positive integers, got n=${r} mod=${e}`);let n=k(r,e),c=e,f=_,w=C,i=C,l=_;while(n!==_){let I=c/n,J=c%n,Q=f-i*I,M=w-l*I;c=n,n=J,f=i,w=l,i=Q,l=M}if(c!==C)throw Error("invert: does not exist");return k(f,e)}function pc(r){let e=(r-C)/dr,n,c,f;for(n=r-C,c=0;n%dr===_;n/=dr,c++);for(f=dr;f{return c[f]="function",c},e);return Er(r,n)}function ef(r,e,n){if(n<_)throw Error("Expected power > 0");if(n===_)return r.ONE;if(n===C)return e;let c=r.ONE,f=e;while(n>_){if(n&C)c=r.mul(c,f);f=r.sqr(f),n>>=C}return c}function cf(r,e){let n=Array(e.length),c=e.reduce((w,i,l)=>{if(r.is0(i))return w;return n[l]=w,r.mul(w,i)},r.ONE),f=r.inv(c);return e.reduceRight((w,i,l)=>{if(r.is0(i))return w;return n[l]=r.mul(w,n[l]),r.mul(w,i)},f),n}function jn(r,e){let n=e!==void 0?e:r.toString(2).length,c=Math.ceil(n/8);return{nBitLength:n,nByteLength:c}}function Ze(r,e,n=!1,c={}){if(r<=_)throw Error(`Expected Field ORDER > 0, got ${r}`);let{nBitLength:f,nByteLength:w}=jn(r,e);if(w>2048)throw Error("Field lengths over 2048 bytes are not supported");let i=rf(r),l=Object.freeze({ORDER:r,BITS:f,BYTES:w,MASK:or(f),ZERO:_,ONE:C,create:($)=>k($,r),isValid:($)=>{if(typeof $!=="bigint")throw Error(`Invalid field element: expected bigint, got ${typeof $}`);return _<=$&&$$===_,isOdd:($)=>($&C)===C,neg:($)=>k(-$,r),eql:($,I)=>$===I,sqr:($)=>k($*$,r),add:($,I)=>k($+I,r),sub:($,I)=>k($-I,r),mul:($,I)=>k($*I,r),pow:($,I)=>ef(l,$,I),div:($,I)=>k($*nn(I,r),r),sqrN:($)=>$*$,addN:($,I)=>$+I,subN:($,I)=>$-I,mulN:($,I)=>$*I,inv:($)=>nn($,r),sqrt:c.sqrt||(($)=>i(l,$)),invertBatch:($)=>cf(l,$),cmov:($,I,J)=>J?I:$,toBytes:($)=>n?rn($,w):fr($,w),fromBytes:($)=>{if($.length!==w)throw Error(`Fp.fromBytes: expected ${w}, got ${$.length}`);return n?pr($):m($)}});return Object.freeze(l)}function De(r){if(typeof r!=="bigint")throw Error("field order must be bigint");let e=r.toString(2).length;return Math.ceil(e/8)}function Hn(r){let e=De(r);return e+Math.ceil(e/2)}function Ve(r,e,n=!1){let c=r.length,f=De(e),w=Hn(e);if(c<16||c1024)throw Error(`expected ${w}-1024 bytes of input, got ${c}`);let i=n?m(r):pr(r),l=k(i,e-C)+C;return n?rn(l,f):fr(l,f)}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var wf=BigInt(0),un=BigInt(1);function je(r,e){let n=(f,w)=>{let i=w.negate();return f?i:w},c=(f)=>{let w=Math.ceil(e/f)+1,i=2**(f-1);return{windows:w,windowSize:i}};return{constTimeNegate:n,unsafeLadder(f,w){let i=r.ZERO,l=f;while(w>wf){if(w&un)i=i.add(l);l=l.double(),w>>=un}return i},precomputeWindow(f,w){let{windows:i,windowSize:l}=c(w),$=[],I=f,J=I;for(let Q=0;Q>=K,G>$)G-=M,i+=un;let E=q,S=q+Math.abs(G)-1,U=z%2!==0,A=G<0;if(G===0)J=J.add(n(U,w[E]));else I=I.add(n(A,w[S]))}return{p:I,f:J}},wNAFCached(f,w,i,l){let $=f._WINDOW_SIZE||1,I=w.get(f);if(!I){if(I=this.precomputeWindow(f,$),$!==1)w.set(f,l(I))}return this.wNAF($,I,i)}}}function Rn(r){return Vn(r.Fp),Er(r,{n:"bigint",h:"bigint",Gx:"field",Gy:"field"},{nBitLength:"isSafeInteger",nByteLength:"isSafeInteger"}),Object.freeze({...jn(r.n,r.nBitLength),...r,...{p:r.Fp.ORDER}})}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function $f(r){let e=Rn(r);Er(e,{a:"field",b:"field"},{allowedPrivateKeyLengths:"array",wrapPrivateKey:"boolean",isTorsionFree:"function",clearCofactor:"function",allowInfinityPoint:"boolean",fromBytes:"function",toBytes:"function"});let{endo:n,Fp:c,a:f}=e;if(n){if(!c.eql(f,c.ZERO))throw Error("Endomorphism can only be defined for Koblitz curves that have a=0");if(typeof n!=="object"||typeof n.beta!=="bigint"||typeof n.splitScalar!=="function")throw Error("Expected endomorphism with beta: bigint and splitScalar: function")}return Object.freeze({...e})}var{bytesToNumberBE:lf,hexToBytes:Jf}=Zn,Ur={Err:class extends Error{constructor(e=""){super(e)}},_parseInt(r){let{Err:e}=Ur;if(r.length<2||r[0]!==2)throw new e("Invalid signature integer tag");let n=r[1],c=r.subarray(2,n+2);if(!n||c.length!==n)throw new e("Invalid signature integer: wrong length");if(c[0]&128)throw new e("Invalid signature integer: negative");if(c[0]===0&&!(c[1]&128))throw new e("Invalid signature integer: unnecessary leading zero");return{d:lf(c),l:r.subarray(n+2)}},toSig(r){let{Err:e}=Ur,n=typeof r==="string"?Jf(r):r;if(!(n instanceof Uint8Array))throw Error("ui8a expected");let c=n.length;if(c<2||n[0]!=48)throw new e("Invalid signature tag");if(n[1]!==c-2)throw new e("Invalid signature: incorrect length");let{d:f,l:w}=Ur._parseInt(n.subarray(2)),{d:i,l}=Ur._parseInt(w);if(l.length)throw new e("Invalid signature: left bytes after parsing");return{r:f,s:i}},hexFromSig(r){let e=(I)=>Number.parseInt(I[0],16)&8?"00"+I:I,n=(I)=>{let J=I.toString(16);return J.length&1?`0${J}`:J},c=e(n(r.s)),f=e(n(r.r)),w=c.length/2,i=f.length/2,l=n(w),$=n(i);return`30${n(i+w+4)}02${$}${f}02${l}${c}`}},Ir=BigInt(0),rr=BigInt(1),iw=BigInt(2),He=BigInt(3),$w=BigInt(4);function If(r){let e=$f(r),{Fp:n}=e,c=e.toBytes||((z,q,G)=>{let E=q.toAffine();return Jr(Uint8Array.from([4]),n.toBytes(E.x),n.toBytes(E.y))}),f=e.fromBytes||((z)=>{let q=z.subarray(1),G=n.fromBytes(q.subarray(0,n.BYTES)),E=n.fromBytes(q.subarray(n.BYTES,2*n.BYTES));return{x:G,y:E}});function w(z){let{a:q,b:G}=e,E=n.sqr(z),S=n.mul(E,z);return n.add(n.add(S,n.mul(z,q)),G)}if(!n.eql(n.sqr(e.Gy),w(e.Gx)))throw Error("bad generator point: equation left != right");function i(z){return typeof z==="bigint"&&Irn.eql(S,n.ZERO);if(E(q)&&E(G))return Q.ZERO;return new Q(q,G,n.ONE)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}static normalizeZ(z){let q=n.invertBatch(z.map((G)=>G.pz));return z.map((G,E)=>G.toAffine(q[E])).map(Q.fromAffine)}static fromHex(z){let q=Q.fromAffine(f(b("pointHex",z)));return q.assertValidity(),q}static fromPrivateKey(z){return Q.BASE.multiply($(z))}_setWindowSize(z){this._WINDOW_SIZE=z,I.delete(this)}assertValidity(){if(this.is0()){if(e.allowInfinityPoint&&!n.is0(this.py))return;throw Error("bad point: ZERO")}let{x:z,y:q}=this.toAffine();if(!n.isValid(z)||!n.isValid(q))throw Error("bad point: x or y not FE");let G=n.sqr(q),E=w(z);if(!n.eql(G,E))throw Error("bad point: equation left != right");if(!this.isTorsionFree())throw Error("bad point: not in prime-order subgroup")}hasEvenY(){let{y:z}=this.toAffine();if(n.isOdd)return!n.isOdd(z);throw Error("Field doesn't support isOdd")}equals(z){J(z);let{px:q,py:G,pz:E}=this,{px:S,py:U,pz:A}=z,g=n.eql(n.mul(q,A),n.mul(S,E)),Y=n.eql(n.mul(G,A),n.mul(U,E));return g&&Y}negate(){return new Q(this.px,n.neg(this.py),this.pz)}double(){let{a:z,b:q}=e,G=n.mul(q,He),{px:E,py:S,pz:U}=this,A=n.ZERO,g=n.ZERO,Y=n.ZERO,O=n.mul(E,E),B=n.mul(S,S),D=n.mul(U,U),Z=n.mul(E,S);return Z=n.add(Z,Z),Y=n.mul(E,U),Y=n.add(Y,Y),A=n.mul(z,Y),g=n.mul(G,D),g=n.add(A,g),A=n.sub(B,g),g=n.add(B,g),g=n.mul(A,g),A=n.mul(Z,A),Y=n.mul(G,Y),D=n.mul(z,D),Z=n.sub(O,D),Z=n.mul(z,Z),Z=n.add(Z,Y),Y=n.add(O,O),O=n.add(Y,O),O=n.add(O,D),O=n.mul(O,Z),g=n.add(g,O),D=n.mul(S,U),D=n.add(D,D),O=n.mul(D,Z),A=n.sub(A,O),Y=n.mul(D,B),Y=n.add(Y,Y),Y=n.add(Y,Y),new Q(A,g,Y)}add(z){J(z);let{px:q,py:G,pz:E}=this,{px:S,py:U,pz:A}=z,g=n.ZERO,Y=n.ZERO,O=n.ZERO,B=e.a,D=n.mul(e.b,He),Z=n.mul(q,S),u=n.mul(G,U),R=n.mul(E,A),v=n.add(q,G),j=n.add(S,U);v=n.mul(v,j),j=n.add(Z,u),v=n.sub(v,j),j=n.add(q,E);let T=n.add(S,A);return j=n.mul(j,T),T=n.add(Z,R),j=n.sub(j,T),T=n.add(G,E),g=n.add(U,A),T=n.mul(T,g),g=n.add(u,R),T=n.sub(T,g),O=n.mul(B,j),g=n.mul(D,R),O=n.add(g,O),g=n.sub(u,O),O=n.add(u,O),Y=n.mul(g,O),u=n.add(Z,Z),u=n.add(u,Z),R=n.mul(B,R),j=n.mul(D,j),u=n.add(u,R),R=n.sub(Z,R),R=n.mul(B,R),j=n.add(j,R),Z=n.mul(u,j),Y=n.add(Y,Z),Z=n.mul(T,j),g=n.mul(v,g),g=n.sub(g,Z),Z=n.mul(v,u),O=n.mul(T,O),O=n.add(O,Z),new Q(g,Y,O)}subtract(z){return this.add(z.negate())}is0(){return this.equals(Q.ZERO)}wNAF(z){return K.wNAFCached(this,I,z,(q)=>{let G=n.invertBatch(q.map((E)=>E.pz));return q.map((E,S)=>E.toAffine(G[S])).map(Q.fromAffine)})}multiplyUnsafe(z){let q=Q.ZERO;if(z===Ir)return q;if(l(z),z===rr)return this;let{endo:G}=e;if(!G)return K.unsafeLadder(this,z);let{k1neg:E,k1:S,k2neg:U,k2:A}=G.splitScalar(z),g=q,Y=q,O=this;while(S>Ir||A>Ir){if(S&rr)g=g.add(O);if(A&rr)Y=Y.add(O);O=O.double(),S>>=rr,A>>=rr}if(E)g=g.negate();if(U)Y=Y.negate();return Y=new Q(n.mul(Y.px,G.beta),Y.py,Y.pz),g.add(Y)}multiply(z){l(z);let q=z,G,E,{endo:S}=e;if(S){let{k1neg:U,k1:A,k2neg:g,k2:Y}=S.splitScalar(q),{p:O,f:B}=this.wNAF(A),{p:D,f:Z}=this.wNAF(Y);O=K.constTimeNegate(U,O),D=K.constTimeNegate(g,D),D=new Q(n.mul(D.px,S.beta),D.py,D.pz),G=O.add(D),E=B.add(Z)}else{let{p:U,f:A}=this.wNAF(q);G=U,E=A}return Q.normalizeZ([G,E])[0]}multiplyAndAddUnsafe(z,q,G){let E=Q.BASE,S=(A,g)=>g===Ir||g===rr||!A.equals(E)?A.multiplyUnsafe(g):A.multiply(g),U=S(this,q).add(S(z,G));return U.is0()?void 0:U}toAffine(z){let{px:q,py:G,pz:E}=this,S=this.is0();if(z==null)z=S?n.ONE:n.inv(E);let U=n.mul(q,z),A=n.mul(G,z),g=n.mul(E,z);if(S)return{x:n.ZERO,y:n.ZERO};if(!n.eql(g,n.ONE))throw Error("invZ was invalid");return{x:U,y:A}}isTorsionFree(){let{h:z,isTorsionFree:q}=e;if(z===rr)return!0;if(q)return q(Q,this);throw Error("isTorsionFree() has not been declared for the elliptic curve")}clearCofactor(){let{h:z,clearCofactor:q}=e;if(z===rr)return this;if(q)return q(Q,this);return this.multiplyUnsafe(e.h)}toRawBytes(z=!0){return this.assertValidity(),c(Q,this,z)}toHex(z=!0){return Yr(this.toRawBytes(z))}}Q.BASE=new Q(e.Gx,e.Gy,n.ONE),Q.ZERO=new Q(n.ZERO,n.ONE,n.ZERO);let M=e.nBitLength,K=je(Q,e.endo?Math.ceil(M/2):M);return{CURVE:e,ProjectivePoint:Q,normPrivateKeyToScalar:$,weierstrassEquation:w,isWithinCurveOrder:i}}function zf(r){let e=Rn(r);return Er(e,{hash:"hash",hmac:"function",randomBytes:"function"},{bits2int:"function",bits2int_modN:"function",lowS:"boolean"}),Object.freeze({lowS:!0,...e})}function ue(r){let e=zf(r),{Fp:n,n:c}=e,f=n.BYTES+1,w=2*n.BYTES+1;function i(T){return IrYr(fr(T,e.nByteLength));function z(T){let N=c>>rr;return T>N}function q(T){return z(T)?l(-T):T}let G=(T,N,d)=>m(T.slice(N,d));class E{constructor(T,N,d){this.r=T,this.s=N,this.recovery=d,this.assertValidity()}static fromCompact(T){let N=e.nByteLength;return T=b("compactSignature",T,N*2),new E(G(T,0,N),G(T,N,2*N))}static fromDER(T){let{r:N,s:d}=Ur.toSig(b("DER",T));return new E(N,d)}assertValidity(){if(!M(this.r))throw Error("r must be 0 < r < CURVE.n");if(!M(this.s))throw Error("s must be 0 < s < CURVE.n")}addRecoveryBit(T){return new E(this.r,this.s,T)}recoverPublicKey(T){let{r:N,s:d,recovery:W}=this,V=O(b("msgHash",T));if(W==null||![0,1,2,3].includes(W))throw Error("recovery id invalid");let H=W===2||W===3?N+e.n:N;if(H>=n.ORDER)throw Error("recovery id 2 or 3 invalid");let o=(W&1)===0?"02":"03",P=I.fromHex(o+K(H)),y=$(H),F=l(-V*y),x=l(d*y),h=I.BASE.multiplyAndAddUnsafe(P,F,x);if(!h)throw Error("point at infinify");return h.assertValidity(),h}hasHighS(){return z(this.s)}normalizeS(){return this.hasHighS()?new E(this.r,l(-this.s),this.recovery):this}toDERRawBytes(){return Ar(this.toDERHex())}toDERHex(){return Ur.hexFromSig({r:this.r,s:this.s})}toCompactRawBytes(){return Ar(this.toCompactHex())}toCompactHex(){return K(this.r)+K(this.s)}}let S={isValidPrivateKey(T){try{return J(T),!0}catch(N){return!1}},normPrivateKeyToScalar:J,randomPrivateKey:()=>{let T=Hn(e.n);return Ve(e.randomBytes(T),e.n)},precompute(T=8,N=I.BASE){return N._setWindowSize(T),N.multiply(BigInt(3)),N}};function U(T,N=!0){return I.fromPrivateKey(T).toRawBytes(N)}function A(T){let N=T instanceof Uint8Array,d=typeof T==="string",W=(N||d)&&T.length;if(N)return W===f||W===w;if(d)return W===2*f||W===2*w;if(T instanceof I)return!0;return!1}function g(T,N,d=!0){if(A(T))throw Error("first arg must be private key");if(!A(N))throw Error("second arg must be public key");return I.fromHex(N).multiply(J(T)).toRawBytes(d)}let Y=e.bits2int||function(T){let N=m(T),d=T.length*8-e.nBitLength;return d>0?N>>BigInt(d):N},O=e.bits2int_modN||function(T){return l(Y(T))},B=or(e.nBitLength);function D(T){if(typeof T!=="bigint")throw Error("bigint expected");if(!(Ir<=T&&T(Xr in d)))throw Error("sign() legacy options not supported");let{hash:W,randomBytes:V}=e,{lowS:H,prehash:o,extraEntropy:P}=d;if(H==null)H=!0;if(T=b("msgHash",T),o)T=b("prehashed msgHash",W(T));let y=O(T),F=J(N),x=[D(F),D(y)];if(P!=null){let Xr=P===!0?V(n.BYTES):P;x.push(b("extraEntropy",Xr))}let h=Jr(...x),er=y;function $r(Xr){let Zr=Y(Xr);if(!M(Zr))return;let Te=$(Zr),lr=I.BASE.multiply(Zr).toAffine(),Dr=l(lr.x);if(Dr===Ir)return;let _r=l(Te*l(er+Dr*F));if(_r===Ir)return;let Me=(lr.x===Dr?0:2)|Number(lr.y&rr),Ge=_r;if(H&&z(_r))Ge=q(_r),Me^=1;return new E(Dr,Ge,Me)}return{seed:h,k2sig:$r}}let u={lowS:e.lowS,prehash:!1},R={lowS:e.lowS,prehash:!1};function v(T,N,d=u){let{seed:W,k2sig:V}=Z(T,N,d),H=e;return On(H.hash.outputLen,H.nByteLength,H.hmac)(W,V)}I.BASE._setWindowSize(8);function j(T,N,d,W=R){let V=T;if(N=b("msgHash",N),d=b("publicKey",d),"strict"in W)throw Error("options.strict was renamed to lowS");let{lowS:H,prehash:o}=W,P=void 0,y;try{if(typeof V==="string"||V instanceof Uint8Array)try{P=E.fromDER(V)}catch(lr){if(!(lr instanceof Ur.Err))throw lr;P=E.fromCompact(V)}else if(typeof V==="object"&&typeof V.r==="bigint"&&typeof V.s==="bigint"){let{r:lr,s:Dr}=V;P=new E(lr,Dr)}else throw Error("PARSE");y=I.fromHex(d)}catch(lr){if(lr.message==="PARSE")throw Error("signature must be Signature instance, Uint8Array or hex string");return!1}if(H&&P.hasHighS())return!1;if(o)N=e.hash(N);let{r:F,s:x}=P,h=O(N),er=$(x),$r=l(h*er),Xr=l(F*er),Zr=I.BASE.multiplyAndAddUnsafe(y,$r,Xr)?.toAffine();if(!Zr)return!1;return l(Zr.x)===F}return{CURVE:e,getPublicKey:U,getSharedSecret:g,sign:v,verify:j,ProjectivePoint:I,Signature:E,utils:S}}class Pn extends vr{constructor(r,e){super();this.finished=!1,this.destroyed=!1,Ke(r);let n=Br(e);if(this.iHash=r.create(),typeof this.iHash.update!=="function")throw Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen,this.outputLen=this.iHash.outputLen;let c=this.blockLen,f=new Uint8Array(c);f.set(n.length>c?r.create().update(n).digest():n);for(let w=0;wnew Pn(r,e).update(n).digest();Bn.create=(r,e)=>new Pn(r,e);/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function Qf(r){return{hash:r,hmac:(e,...n)=>Bn(r,e,Se(...n)),randomBytes:hr}}function Re(r,e){let n=(c)=>ue({...r,...Qf(c)});return Object.freeze({...n(e),create:n})}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var wn=BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),en=BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),ve=BigInt(1),cn=BigInt(2),Pe=(r,e)=>(r+e/cn)/e;function oe(r){let e=wn,n=BigInt(3),c=BigInt(6),f=BigInt(11),w=BigInt(22),i=BigInt(23),l=BigInt(44),$=BigInt(88),I=r*r*r%e,J=I*I*r%e,Q=a(J,n,e)*J%e,M=a(Q,n,e)*J%e,K=a(M,cn,e)*I%e,z=a(K,f,e)*K%e,q=a(z,w,e)*z%e,G=a(q,l,e)*q%e,E=a(G,$,e)*G%e,S=a(E,l,e)*q%e,U=a(S,n,e)*J%e,A=a(U,i,e)*z%e,g=a(A,c,e)*I%e,Y=a(g,cn,e);if(!on.eql(on.sqr(Y),r))throw Error("Cannot find square root");return Y}var on=Ze(wn,void 0,void 0,{sqrt:oe}),jr=Re({a:BigInt(0),b:BigInt(7),Fp:on,n:en,Gx:BigInt("55066263022277343669578718895168534326250603453777594175500187360389116729240"),Gy:BigInt("32670510020758816978083085130507043184471273380659243275938904335757337482424"),h:BigInt(1),lowS:!0,endo:{beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),splitScalar:(r)=>{let e=en,n=BigInt("0x3086d221a7d46bcde86c90e49284eb15"),c=-ve*BigInt("0xe4437ed6010e88286f547fa90abfe4c3"),f=BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),w=n,i=BigInt("0x100000000000000000000000000000000"),l=Pe(w*r,e),$=Pe(-c*r,e),I=k(r-l*n-$*f,e),J=k(-l*c-$*w,e),Q=I>i,M=J>i;if(Q)I=e-I;if(M)J=e-J;if(I>i||J>i)throw Error("splitScalar: Endomorphism failed, k="+r);return{k1neg:Q,k1:I,k2neg:M,k2:J}}}},sr),$n=BigInt(0),Ce=(r)=>typeof r==="bigint"&&$ntypeof r==="bigint"&&$nf.charCodeAt(0)));n=Jr(c,c),Be[r]=n}return sr(Jr(n,...e))}var yn=(r)=>r.toRawBytes(!0).slice(1),Cn=(r)=>fr(r,32),vn=(r)=>k(r,wn),Cr=(r)=>k(r,en),bn=jr.ProjectivePoint,Tf=(r,e,n)=>bn.BASE.multiplyAndAddUnsafe(r,e,n);function Ln(r){let e=jr.utils.normPrivateKeyToScalar(r),n=bn.fromPrivateKey(e);return{scalar:n.hasEvenY()?e:Cr(-e),bytes:yn(n)}}function Le(r){if(!Ce(r))throw Error("bad x: need 0 < x < p");let e=vn(r*r),n=vn(e*r+BigInt(7)),c=oe(n);if(c%cn!==$n)c=vn(-c);let f=new bn(r,c,ve);return f.assertValidity(),f}function ye(...r){return Cr(m(fn("BIP0340/challenge",...r)))}function Mf(r){return Ln(r).bytes}function Gf(r,e,n=hr(32)){let c=b("message",r),{bytes:f,scalar:w}=Ln(e),i=b("auxRand",n,32),l=Cn(w^m(fn("BIP0340/aux",i))),$=fn("BIP0340/nonce",l,f,c),I=Cr(m($));if(I===$n)throw Error("sign failed: k is zero");let{bytes:J,scalar:Q}=Ln(I),M=ye(J,f,c),K=new Uint8Array(64);if(K.set(J,0),K.set(Cn(Cr(Q+M*w)),32),!be(K,c,f))throw Error("sign: Invalid signature produced");return K}function be(r,e,n){let c=b("signature",r,64),f=b("message",e),w=b("publicKey",n,32);try{let i=Le(m(w)),l=m(c.subarray(0,32));if(!Ce(l))return!1;let $=m(c.subarray(32,64));if(!qf($))return!1;let I=ye(Cn(l),yn(i),f),J=Tf(i,$,Cr(-I));if(!J||!J.hasEvenY()||J.toAffine().x!==l)return!1;return!0}catch(i){return!1}}var nr=(()=>({getPublicKey:Mf,sign:Gf,verify:be,utils:{randomPrivateKey:jr.utils.randomPrivateKey,lift_x:Le,pointToBytes:yn,numberToBytesBE:fr,bytesToNumberBE:m,taggedHash:fn,mod:k}}))();var ln=typeof globalThis==="object"&&"crypto"in globalThis?globalThis.crypto:void 0;/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */var kn=(r)=>r instanceof Uint8Array;var Jn=(r)=>new DataView(r.buffer,r.byteOffset,r.byteLength),wr=(r,e)=>r<<32-e|r>>>e,Ef=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;if(!Ef)throw Error("Non little-endian hardware is not supported");var Kf=Array.from({length:256},(r,e)=>e.toString(16).padStart(2,"0"));function L(r){if(!kn(r))throw Error("Uint8Array expected");let e="";for(let n=0;nc+f.length,0)),n=0;return r.forEach((c)=>{if(!kn(c))throw Error("Uint8Array expected");e.set(c,n),n+=c.length}),e}class Lr{clone(){return this._cloneInto()}}function _n(r){let e=(c)=>r().update(Kr(c)).digest(),n=r();return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=()=>r(),e}function ke(r=32){if(ln&&typeof ln.getRandomValues==="function")return ln.getRandomValues(new Uint8Array(r));throw Error("crypto.getRandomValues must be defined")}function mn(r){if(!Number.isSafeInteger(r)||r<0)throw Error(`Wrong positive integer: ${r}`)}function Nf(r){if(typeof r!=="boolean")throw Error(`Expected boolean, not ${r}`)}function _e(r,...e){if(!(r instanceof Uint8Array))throw Error("Expected Uint8Array");if(e.length>0&&!e.includes(r.length))throw Error(`Expected Uint8Array of length ${e}, not of length=${r.length}`)}function Sf(r){if(typeof r!=="function"||typeof r.create!=="function")throw Error("Hash should be wrapped by utils.wrapConstructor");mn(r.outputLen),mn(r.blockLen)}function Xf(r,e=!0){if(r.destroyed)throw Error("Hash instance has been destroyed");if(e&&r.finished)throw Error("Hash#digest() has already been called")}function Yf(r,e){_e(r);let n=e.outputLen;if(r.length>f&w),l=Number(n&w),$=c?4:0,I=c?0:4;r.setUint32(e+$,i,c),r.setUint32(e+I,l,c)}class tn extends Lr{constructor(r,e,n,c){super();this.blockLen=r,this.outputLen=e,this.padOffset=n,this.isLE=c,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(r),this.view=Jn(this.buffer)}update(r){p.exists(this);let{view:e,buffer:n,blockLen:c}=this;r=Kr(r);let f=r.length;for(let w=0;wc-w)this.process(n,0),w=0;for(let J=w;JI.length)throw Error("_sha2: outputLen bigger than state");for(let J=0;J<$;J++)i.setUint32(4*J,I[J],f)}digest(){let{buffer:r,outputLen:e}=this;this.digestInto(r);let n=r.slice(0,e);return this.destroy(),n}_cloneInto(r){r||(r=new this.constructor),r.set(...this.get());let{blockLen:e,buffer:n,length:c,finished:f,destroyed:w,pos:i}=this;if(r.length=c,r.pos=i,r.finished=f,r.destroyed=w,c%e)r.buffer.set(n);return r}}var Uf=(r,e,n)=>r&e^~r&n,gf=(r,e,n)=>r&e^r&n^e&n,Of=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),Wr=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),Nr=new Uint32Array(64);class Fn extends tn{constructor(){super(64,32,8,!1);this.A=Wr[0]|0,this.B=Wr[1]|0,this.C=Wr[2]|0,this.D=Wr[3]|0,this.E=Wr[4]|0,this.F=Wr[5]|0,this.G=Wr[6]|0,this.H=Wr[7]|0}get(){let{A:r,B:e,C:n,D:c,E:f,F:w,G:i,H:l}=this;return[r,e,n,c,f,w,i,l]}set(r,e,n,c,f,w,i,l){this.A=r|0,this.B=e|0,this.C=n|0,this.D=c|0,this.E=f|0,this.F=w|0,this.G=i|0,this.H=l|0}process(r,e){for(let J=0;J<16;J++,e+=4)Nr[J]=r.getUint32(e,!1);for(let J=16;J<64;J++){let Q=Nr[J-15],M=Nr[J-2],K=wr(Q,7)^wr(Q,18)^Q>>>3,z=wr(M,17)^wr(M,19)^M>>>10;Nr[J]=z+Nr[J-7]+K+Nr[J-16]|0}let{A:n,B:c,C:f,D:w,E:i,F:l,G:$,H:I}=this;for(let J=0;J<64;J++){let Q=wr(i,6)^wr(i,11)^wr(i,25),M=I+Q+Uf(i,l,$)+Of[J]+Nr[J]|0,z=(wr(n,2)^wr(n,13)^wr(n,22))+gf(n,c,f)|0;I=$,$=l,l=i,i=w+M|0,w=f,f=c,c=n,n=M+z|0}n=n+this.A|0,c=c+this.B|0,f=f+this.C|0,w=w+this.D|0,i=i+this.E|0,l=l+this.F|0,$=$+this.G|0,I=I+this.H|0,this.set(n,c,f,w,i,l,$,I)}roundClean(){Nr.fill(0)}destroy(){this.set(0,0,0,0,0,0,0,0),this.buffer.fill(0)}}class me extends Fn{constructor(){super();this.A=-1056596264,this.B=914150663,this.C=812702999,this.D=-150054599,this.E=-4191439,this.F=1750603025,this.G=1694076839,this.H=-1090891868,this.outputLen=28}}var ir=_n(()=>new Fn),Vw=_n(()=>new me);var Or=Symbol("verified"),Zf=(r)=>r instanceof Object;function Df(r){if(!Zf(r))return!1;if(typeof r.kind!=="number")return!1;if(typeof r.content!=="string")return!1;if(typeof r.created_at!=="number")return!1;if(typeof r.pubkey!=="string")return!1;if(!r.pubkey.match(/^[a-f0-9]{64}$/))return!1;if(!Array.isArray(r.tags))return!1;for(let e=0;ew===n.slice(1)&&f.indexOf(i)!==-1))return!1}if(r.since&&e.created_atr.until)return!1;return!0}function of(r,e){for(let n=0;n{let e=new MessageChannel,n=()=>{e.port1.removeEventListener("message",n),r()};e.port1.addEventListener("message",n),e.port2.postMessage(0),e.port1.start()})}var kf=(r)=>{return r[Or]=!0,!0},te=class{url;_connected=!1;onclose=null;onnotice=(r)=>console.debug(`NOTICE from ${this.url}: ${r}`);_onauth=null;baseEoseTimeout=4400;connectionTimeout=4400;publishTimeout=4400;openSubs=new Map;connectionTimeoutHandle;connectionPromise;openCountRequests=new Map;openEventPublishes=new Map;ws;incomingMessageQueue=new Hf;queueRunning=!1;challenge;serial=0;verifyEvent;_WebSocket;constructor(r,e){this.url=yr(r),this.verifyEvent=e.verifyEvent,this._WebSocket=e.websocketImplementation||WebSocket}static async connect(r,e){let n=new te(r,e);return await n.connect(),n}closeAllSubscriptions(r){for(let[e,n]of this.openSubs)n.close(r);this.openSubs.clear();for(let[e,n]of this.openEventPublishes)n.reject(Error(r));this.openEventPublishes.clear();for(let[e,n]of this.openCountRequests)n.reject(Error(r));this.openCountRequests.clear()}get connected(){return this._connected}async connect(){if(this.connectionPromise)return this.connectionPromise;return this.challenge=void 0,this.connectionPromise=new Promise((r,e)=>{this.connectionTimeoutHandle=setTimeout(()=>{e("connection timed out"),this.connectionPromise=void 0,this.onclose?.(),this.closeAllSubscriptions("relay connection timed out")},this.connectionTimeout);try{this.ws=new this._WebSocket(this.url)}catch(n){e(n);return}this.ws.onopen=()=>{clearTimeout(this.connectionTimeoutHandle),this._connected=!0,r()},this.ws.onerror=(n)=>{if(e(n.message||"websocket error"),this._connected)this._connected=!1,this.connectionPromise=void 0,this.onclose?.(),this.closeAllSubscriptions("relay connection errored")},this.ws.onclose=async()=>{if(this._connected)this._connected=!1,this.connectionPromise=void 0,this.onclose?.(),this.closeAllSubscriptions("relay connection closed")},this.ws.onmessage=this._onmessage.bind(this)}),this.connectionPromise}async runQueue(){this.queueRunning=!0;while(!0){if(this.handleNext()===!1)break;await bf()}this.queueRunning=!1}handleNext(){let r=this.incomingMessageQueue.dequeue();if(!r)return!1;let e=Lf(r);if(e){let n=this.openSubs.get(e);if(!n)return;let c=Cf(r,"id"),f=n.alreadyHaveEvent?.(c);if(n.receivedEvent?.(this,c),f)return}try{let n=JSON.parse(r);switch(n[0]){case"EVENT":{let c=this.openSubs.get(n[1]),f=n[2];if(this.verifyEvent(f)&&of(c.filters,f))c.onevent(f);return}case"COUNT":{let c=n[1],f=n[2],w=this.openCountRequests.get(c);if(w)w.resolve(f.count),this.openCountRequests.delete(c);return}case"EOSE":{let c=this.openSubs.get(n[1]);if(!c)return;c.receivedEose();return}case"OK":{let c=n[1],f=n[2],w=n[3],i=this.openEventPublishes.get(c);if(i){if(f)i.resolve(w);else i.reject(Error(w));this.openEventPublishes.delete(c)}return}case"CLOSED":{let c=n[1],f=this.openSubs.get(c);if(!f)return;f.closed=!0,f.close(n[2]);return}case"NOTICE":this.onnotice(n[1]);return;case"AUTH":{this.challenge=n[1],this._onauth?.(n[1]);return}}}catch(n){return}}async send(r){if(!this.connectionPromise)throw Error("sending on closed connection");this.connectionPromise.then(()=>{this.ws?.send(r)})}async auth(r){if(!this.challenge)throw Error("can't perform auth, no challenge was received");let e=await r(yf(this.url,this.challenge)),n=new Promise((c,f)=>{this.openEventPublishes.set(e.id,{resolve:c,reject:f})});return this.send('["AUTH",'+JSON.stringify(e)+"]"),n}async publish(r){let e=new Promise((n,c)=>{this.openEventPublishes.set(r.id,{resolve:n,reject:c})});return this.send('["EVENT",'+JSON.stringify(r)+"]"),setTimeout(()=>{let n=this.openEventPublishes.get(r.id);if(n)n.reject(Error("publish timed out")),this.openEventPublishes.delete(r.id)},this.publishTimeout),e}async count(r,e){this.serial++;let n=e?.id||"count:"+this.serial,c=new Promise((f,w)=>{this.openCountRequests.set(n,{resolve:f,reject:w})});return this.send('["COUNT","'+n+'",'+JSON.stringify(r).substring(1)),c}subscribe(r,e){let n=this.prepareSubscription(r,e);return n.fire(),n}prepareSubscription(r,e){this.serial++;let n=e.id||"sub:"+this.serial,c=new _f(this,n,r,e);return this.openSubs.set(n,c),c}close(){this.closeAllSubscriptions("relay connection closed by us"),this._connected=!1,this.ws?.close()}_onmessage(r){if(this.incomingMessageQueue.enqueue(r.data),!this.queueRunning)this.runQueue()}},_f=class{relay;id;closed=!1;eosed=!1;filters;alreadyHaveEvent;receivedEvent;onevent;oneose;onclose;eoseTimeout;eoseTimeoutHandle;constructor(r,e,n,c){this.relay=r,this.filters=n,this.id=e,this.alreadyHaveEvent=c.alreadyHaveEvent,this.receivedEvent=c.receivedEvent,this.eoseTimeout=c.eoseTimeout||r.baseEoseTimeout,this.oneose=c.oneose,this.onclose=c.onclose,this.onevent=c.onevent||((f)=>{console.warn(`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,f)})}fire(){this.relay.send('["REQ","'+this.id+'",'+JSON.stringify(this.filters).substring(1)),this.eoseTimeoutHandle=setTimeout(this.receivedEose.bind(this),this.eoseTimeout)}receivedEose(){if(this.eosed)return;clearTimeout(this.eoseTimeoutHandle),this.eosed=!0,this.oneose?.()}close(r="closed by caller"){if(!this.closed&&this.relay.connected)this.relay.send('["CLOSE",'+JSON.stringify(this.id)+"]"),this.closed=!0;this.relay.openSubs.delete(this.id),this.onclose?.(r)}},mf=class{relays=new Map;seenOn=new Map;trackRelays=!1;verifyEvent;trustedRelayURLs=new Set;_WebSocket;constructor(r){this.verifyEvent=r.verifyEvent,this._WebSocket=r.websocketImplementation}async ensureRelay(r,e){r=yr(r);let n=this.relays.get(r);if(!n){if(n=new te(r,{verifyEvent:this.trustedRelayURLs.has(r)?kf:this.verifyEvent,websocketImplementation:this._WebSocket}),e?.connectionTimeout)n.connectionTimeout=e.connectionTimeout;this.relays.set(r,n)}return await n.connect(),n}close(r){r.map(yr).forEach((e)=>{this.relays.get(e)?.close()})}subscribeMany(r,e,n){return this.subscribeManyMap(Object.fromEntries(r.map((c)=>[c,e])),n)}subscribeManyMap(r,e){if(this.trackRelays)e.receivedEvent=(Q,M)=>{let K=this.seenOn.get(M);if(!K)K=new Set,this.seenOn.set(M,K);K.add(Q)};let n=new Set,c=[],f=Object.keys(r).length,w=[],i=(Q)=>{if(w[Q]=!0,w.filter((M)=>M).length===f)e.oneose?.(),i=()=>{}},l=[],$=(Q,M)=>{if(i(Q),l[Q]=M,l.filter((K)=>K).length===f)e.onclose?.(l),$=()=>{}},I=(Q)=>{if(e.alreadyHaveEvent?.(Q))return!0;let M=n.has(Q);return n.add(Q),M},J=Promise.all(Object.entries(r).map(async(Q,M,K)=>{if(K.indexOf(Q)!==M){$(M,"duplicate url");return}let[z,q]=Q;z=yr(z);let G;try{G=await this.ensureRelay(z,{connectionTimeout:e.maxWait?Math.max(e.maxWait*0.8,e.maxWait-1000):void 0})}catch(S){$(M,S?.message||String(S));return}let E=G.subscribe(q,{...e,oneose:()=>i(M),onclose:(S)=>$(M,S),alreadyHaveEvent:I,eoseTimeout:e.maxWait});c.push(E)}));return{async close(){await J,c.forEach((Q)=>{Q.close()})}}}subscribeManyEose(r,e,n){let c=this.subscribeMany(r,e,{...n,oneose(){c.close()}});return c}async querySync(r,e,n){return new Promise(async(c)=>{let f=[];this.subscribeManyEose(r,[e],{...n,onevent(w){f.push(w)},onclose(w){c(f)}})})}async get(r,e,n){e.limit=1;let c=await this.querySync(r,e,n);return c.sort((f,w)=>w.created_at-f.created_at),c[0]||null}publish(r,e){return r.map(yr).map(async(n,c,f)=>{if(f.indexOf(n)!==c)return Promise.reject("duplicate url");let w=await this.ensureRelay(n);return w.publish(e).then((i)=>{if(this.trackRelays){let l=this.seenOn.get(e.id);if(!l)l=new Set,this.seenOn.set(e.id,l);l.add(w)}return i})})}listConnectionStatus(){let r=new Map;return this.relays.forEach((e,n)=>r.set(n,e.connected)),r}destroy(){this.relays.forEach((r)=>r.close()),this.relays=new Map}},Fe;try{Fe=WebSocket}catch{}var tf=class extends mf{constructor(){super({verifyEvent:Pf,websocketImplementation:Fe})}};var ur=Symbol("verified"),Ff=(r)=>r instanceof Object;function hf(r){if(!Ff(r))return!1;if(typeof r.kind!=="number")return!1;if(typeof r.content!=="string")return!1;if(typeof r.created_at!=="number")return!1;if(typeof r.pubkey!=="string")return!1;if(!r.pubkey.match(/^[a-f0-9]{64}$/))return!1;if(!Array.isArray(r.tags))return!1;for(let e=0;eZ0,getConversationKey:()=>Sc,encrypt:()=>Ac,decrypt:()=>dc});function Qn(r){if(!Number.isSafeInteger(r)||r<0)throw Error(`positive integer expected, not ${r}`)}function xn(r){if(typeof r!=="boolean")throw Error(`boolean expected, not ${r}`)}function an(r){return r instanceof Uint8Array||r!=null&&typeof r==="object"&&r.constructor.name==="Uint8Array"}function s(r,...e){if(!an(r))throw Error("Uint8Array expected");if(e.length>0&&!e.includes(r.length))throw Error(`Uint8Array expected of length ${e}, not of length=${r.length}`)}function pn(r,e=!0){if(r.destroyed)throw Error("Hash instance has been destroyed");if(e&&r.finished)throw Error("Hash#digest() has already been called")}function he(r,e){s(r);let n=e.outputLen;if(r.lengthnew Uint32Array(r.buffer,r.byteOffset,Math.floor(r.byteLength/4)),se=(r)=>new DataView(r.buffer,r.byteOffset,r.byteLength),c0=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;if(!c0)throw Error("Non little-endian hardware is not supported");function f0(r){if(typeof r!=="string")throw Error(`string expected, got ${typeof r}`);return new Uint8Array(new TextEncoder().encode(r))}function qn(r){if(typeof r==="string")r=f0(r);else if(an(r))r=r.slice();else throw Error(`Uint8Array expected, got ${typeof r}`);return r}function xe(r,e){if(e==null||typeof e!=="object")throw Error("options must be defined");return Object.assign(r,e)}function Tn(r,e){if(r.length!==e.length)return!1;let n=0;for(let c=0;c{return Object.assign(e,r),e};function ne(r,e,n,c){if(typeof r.setBigUint64==="function")return r.setBigUint64(e,n,c);let f=BigInt(32),w=BigInt(4294967295),i=Number(n>>f&w),l=Number(n&w),$=c?4:0,I=c?0:4;r.setUint32(e+$,i,c),r.setUint32(e+I,l,c)}var t=(r,e)=>r[e++]&255|(r[e++]&255)<<8;class ae{constructor(r){this.blockLen=16,this.outputLen=16,this.buffer=new Uint8Array(16),this.r=new Uint16Array(10),this.h=new Uint16Array(10),this.pad=new Uint16Array(8),this.pos=0,this.finished=!1,r=qn(r),s(r,32);let e=t(r,0),n=t(r,2),c=t(r,4),f=t(r,6),w=t(r,8),i=t(r,10),l=t(r,12),$=t(r,14);this.r[0]=e&8191,this.r[1]=(e>>>13|n<<3)&8191,this.r[2]=(n>>>10|c<<6)&7939,this.r[3]=(c>>>7|f<<9)&8191,this.r[4]=(f>>>4|w<<12)&255,this.r[5]=w>>>1&8190,this.r[6]=(w>>>14|i<<2)&8191,this.r[7]=(i>>>11|l<<5)&8065,this.r[8]=(l>>>8|$<<8)&8191,this.r[9]=$>>>5&127;for(let I=0;I<8;I++)this.pad[I]=t(r,16+2*I)}process(r,e,n=!1){let c=n?0:2048,{h:f,r:w}=this,i=w[0],l=w[1],$=w[2],I=w[3],J=w[4],Q=w[5],M=w[6],K=w[7],z=w[8],q=w[9],G=t(r,e+0),E=t(r,e+2),S=t(r,e+4),U=t(r,e+6),A=t(r,e+8),g=t(r,e+10),Y=t(r,e+12),O=t(r,e+14),B=f[0]+(G&8191),D=f[1]+((G>>>13|E<<3)&8191),Z=f[2]+((E>>>10|S<<6)&8191),u=f[3]+((S>>>7|U<<9)&8191),R=f[4]+((U>>>4|A<<12)&8191),v=f[5]+(A>>>1&8191),j=f[6]+((A>>>14|g<<2)&8191),T=f[7]+((g>>>11|Y<<5)&8191),N=f[8]+((Y>>>8|O<<8)&8191),d=f[9]+(O>>>5|c),W=0,V=W+B*i+D*(5*q)+Z*(5*z)+u*(5*K)+R*(5*M);W=V>>>13,V&=8191,V+=v*(5*Q)+j*(5*J)+T*(5*I)+N*(5*$)+d*(5*l),W+=V>>>13,V&=8191;let H=W+B*l+D*i+Z*(5*q)+u*(5*z)+R*(5*K);W=H>>>13,H&=8191,H+=v*(5*M)+j*(5*Q)+T*(5*J)+N*(5*I)+d*(5*$),W+=H>>>13,H&=8191;let o=W+B*$+D*l+Z*i+u*(5*q)+R*(5*z);W=o>>>13,o&=8191,o+=v*(5*K)+j*(5*M)+T*(5*Q)+N*(5*J)+d*(5*I),W+=o>>>13,o&=8191;let P=W+B*I+D*$+Z*l+u*i+R*(5*q);W=P>>>13,P&=8191,P+=v*(5*z)+j*(5*K)+T*(5*M)+N*(5*Q)+d*(5*J),W+=P>>>13,P&=8191;let y=W+B*J+D*I+Z*$+u*l+R*i;W=y>>>13,y&=8191,y+=v*(5*q)+j*(5*z)+T*(5*K)+N*(5*M)+d*(5*Q),W+=y>>>13,y&=8191;let F=W+B*Q+D*J+Z*I+u*$+R*l;W=F>>>13,F&=8191,F+=v*i+j*(5*q)+T*(5*z)+N*(5*K)+d*(5*M),W+=F>>>13,F&=8191;let x=W+B*M+D*Q+Z*J+u*I+R*$;W=x>>>13,x&=8191,x+=v*l+j*i+T*(5*q)+N*(5*z)+d*(5*K),W+=x>>>13,x&=8191;let h=W+B*K+D*M+Z*Q+u*J+R*I;W=h>>>13,h&=8191,h+=v*$+j*l+T*i+N*(5*q)+d*(5*z),W+=h>>>13,h&=8191;let er=W+B*z+D*K+Z*M+u*Q+R*J;W=er>>>13,er&=8191,er+=v*I+j*$+T*l+N*i+d*(5*q),W+=er>>>13,er&=8191;let $r=W+B*q+D*z+Z*K+u*M+R*Q;W=$r>>>13,$r&=8191,$r+=v*J+j*I+T*$+N*l+d*i,W+=$r>>>13,$r&=8191,W=(W<<2)+W|0,W=W+V|0,V=W&8191,W=W>>>13,H+=W,f[0]=V,f[1]=H,f[2]=o,f[3]=P,f[4]=y,f[5]=F,f[6]=x,f[7]=h,f[8]=er,f[9]=$r}finalize(){let{h:r,pad:e}=this,n=new Uint16Array(10),c=r[1]>>>13;r[1]&=8191;for(let i=2;i<10;i++)r[i]+=c,c=r[i]>>>13,r[i]&=8191;r[0]+=c*5,c=r[0]>>>13,r[0]&=8191,r[1]+=c,c=r[1]>>>13,r[1]&=8191,r[2]+=c,n[0]=r[0]+5,c=n[0]>>>13,n[0]&=8191;for(let i=1;i<10;i++)n[i]=r[i]+c,c=n[i]>>>13,n[i]&=8191;n[9]-=8192;let f=(c^1)-1;for(let i=0;i<10;i++)n[i]&=f;f=~f;for(let i=0;i<10;i++)r[i]=r[i]&f|n[i];r[0]=(r[0]|r[1]<<13)&65535,r[1]=(r[1]>>>3|r[2]<<10)&65535,r[2]=(r[2]>>>6|r[3]<<7)&65535,r[3]=(r[3]>>>9|r[4]<<4)&65535,r[4]=(r[4]>>>12|r[5]<<1|r[6]<<14)&65535,r[5]=(r[6]>>>2|r[7]<<11)&65535,r[6]=(r[7]>>>5|r[8]<<8)&65535,r[7]=(r[8]>>>8|r[9]<<5)&65535;let w=r[0]+e[0];r[0]=w&65535;for(let i=1;i<8;i++)w=(r[i]+e[i]|0)+(w>>>16)|0,r[i]=w&65535}update(r){pn(this);let{buffer:e,blockLen:n}=this;r=qn(r);let c=r.length;for(let f=0;f>>0,r[f++]=n[w]>>>8;return r}digest(){let{buffer:r,outputLen:e}=this;this.digestInto(r);let n=r.slice(0,e);return this.destroy(),n}}function w0(r){let e=(c,f)=>r(f).update(qn(c)).digest(),n=r(new Uint8Array(32));return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=(c)=>r(c),e}var pe=w0((r)=>new ae(r));var nc=(r)=>Uint8Array.from(r.split("").map((e)=>e.charCodeAt(0))),i0=nc("expand 16-byte k"),$0=nc("expand 32-byte k"),l0=zr(i0),ec=zr($0),li=ec.slice();function X(r,e){return r<>>32-e}function ee(r){return r.byteOffset%4===0}var Mn=64,J0=16,cc=4294967295,rc=new Uint32Array;function I0(r,e,n,c,f,w,i,l){let $=f.length,I=new Uint8Array(Mn),J=zr(I),Q=ee(f)&&ee(w),M=Q?zr(f):rc,K=Q?zr(w):rc;for(let z=0;z<$;i++){if(r(e,n,c,J,i,l),i>=cc)throw Error("arx: counter overflow");let q=Math.min(Mn,$-z);if(Q&&q===Mn){let G=z/4;if(z%4!==0)throw Error("arx: invalid block position");for(let E=0,S;E{s(l),s($),s(I);let M=I.length;if(!J)J=new Uint8Array(M);if(s(J),Qn(Q),Q<0||Q>=cc)throw Error("arx: counter overflow");if(J.length0)K.pop().fill(0);return J}}function ic(r,e,n,c,f,w=20){let i=r[0],l=r[1],$=r[2],I=r[3],J=e[0],Q=e[1],M=e[2],K=e[3],z=e[4],q=e[5],G=e[6],E=e[7],S=f,U=n[0],A=n[1],g=n[2],Y=i,O=l,B=$,D=I,Z=J,u=Q,R=M,v=K,j=z,T=q,N=G,d=E,W=S,V=U,H=A,o=g;for(let y=0;y{r.update(e);let n=e.length%16;if(n)r.update(q0.subarray(n))},T0=new Uint8Array(32);function wc(r,e,n,c,f){let w=r(e,n,T0),i=pe.create(w);if(f)fc(i,f);fc(i,c);let l=new Uint8Array(16),$=se(l);ne($,0,BigInt(f?f.length:0),!0),ne($,8,BigInt(c.length),!0),i.update(l);let I=i.digest();return w.fill(0),I}var $c=(r)=>(e,n,c)=>{return s(e,32),s(n),{encrypt:(w,i)=>{let l=w.length,$=l+16;if(i)s(i,$);else i=new Uint8Array($);r(e,n,w,i,1);let I=wc(r,e,n,i.subarray(0,-16),c);return i.set(I,l),i},decrypt:(w,i)=>{let l=w.length,$=l-16;if(l<16)throw Error("encrypted data must be at least 16 bytes");if(i)s(i,$);else i=new Uint8Array($);let I=w.subarray(0,-16),J=w.subarray(-16),Q=wc(r,e,n,I,c);if(!Tn(J,Q))throw Error("invalid tag");return r(e,n,I,i,1),i}}},Ti=re({blockSize:64,nonceLength:12,tagLength:16},$c(Gn)),Mi=re({blockSize:64,nonceLength:24,tagLength:16},$c(Q0));class fe extends Lr{constructor(r,e){super();this.finished=!1,this.destroyed=!1,p.hash(r);let n=Kr(e);if(this.iHash=r.create(),typeof this.iHash.update!=="function")throw Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen,this.outputLen=this.iHash.outputLen;let c=this.blockLen,f=new Uint8Array(c);f.set(n.length>c?r.create().update(n).digest():n);for(let w=0;wnew fe(r,e).update(n).digest();Rr.create=(r,e)=>new fe(r,e);function Jc(r,e,n){if(p.hash(r),n===void 0)n=new Uint8Array(r.outputLen);return Rr(r,Kr(n),Kr(e))}var we=new Uint8Array([0]),lc=new Uint8Array;function Ic(r,e,n,c=32){if(p.hash(r),p.number(c),c>255*r.outputLen)throw Error("Length should be <= 255*HashLen");let f=Math.ceil(c/r.outputLen);if(n===void 0)n=lc;let w=new Uint8Array(f*r.outputLen),i=Rr.create(r,e),l=i._cloneInto(),$=new Uint8Array(i.outputLen);for(let I=0;I(i)=>f(w(i)),n=Array.from(r).reverse().reduce((f,w)=>f?e(f,w.encode):w.encode,void 0),c=r.reduce((f,w)=>f?e(f,w.decode):w.decode,void 0);return{encode:n,decode:c}}function qr(r){return{encode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="number")throw Error("alphabet.encode input should be an array of numbers");return e.map((n)=>{if(Pr(n),n<0||n>=r.length)throw Error(`Digit index outside alphabet: ${n} (alphabet: ${r.length})`);return r[n]})},decode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="string")throw Error("alphabet.decode input should be array of strings");return e.map((n)=>{if(typeof n!=="string")throw Error(`alphabet.decode: not string element=${n}`);let c=r.indexOf(n);if(c===-1)throw Error(`Unknown letter: "${n}". Allowed: ${r}`);return c})}}}function Tr(r=""){if(typeof r!=="string")throw Error("join separator should be string");return{encode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="string")throw Error("join.encode input should be array of strings");for(let n of e)if(typeof n!=="string")throw Error(`join.encode: non-string input=${n}`);return e.join(r)},decode:(e)=>{if(typeof e!=="string")throw Error("join.decode input should be string");return e.split(r)}}}function Kn(r,e="="){if(Pr(r),typeof e!=="string")throw Error("padding chr should be string");return{encode(n){if(!Array.isArray(n)||n.length&&typeof n[0]!=="string")throw Error("padding.encode input should be array of strings");for(let c of n)if(typeof c!=="string")throw Error(`padding.encode: non-string input=${c}`);while(n.length*r%8)n.push(e);return n},decode(n){if(!Array.isArray(n)||n.length&&typeof n[0]!=="string")throw Error("padding.encode input should be array of strings");for(let f of n)if(typeof f!=="string")throw Error(`padding.decode: non-string input=${f}`);let c=n.length;if(c*r%8)throw Error("Invalid padding: string should have whole number of bytes");for(;c>0&&n[c-1]===e;c--)if(!((c-1)*r%8))throw Error("Invalid padding: string has too much padding");return n.slice(0,c)}}}function Gc(r){if(typeof r!=="function")throw Error("normalize fn should be function");return{encode:(e)=>e,decode:(e)=>r(e)}}function zc(r,e,n){if(e<2)throw Error(`convertRadix: wrong from=${e}, base cannot be less than 2`);if(n<2)throw Error(`convertRadix: wrong to=${n}, base cannot be less than 2`);if(!Array.isArray(r))throw Error("convertRadix: data should be array");if(!r.length)return[];let c=0,f=[],w=Array.from(r);w.forEach((i)=>{if(Pr(i),i<0||i>=e)throw Error(`Wrong integer: ${i}`)});while(!0){let i=0,l=!0;for(let $=c;$!e?r:Ec(e,r%e),En=(r,e)=>r+(e-Ec(r,e));function ie(r,e,n,c){if(!Array.isArray(r))throw Error("convertRadix2: data should be array");if(e<=0||e>32)throw Error(`convertRadix2: wrong from=${e}`);if(n<=0||n>32)throw Error(`convertRadix2: wrong to=${n}`);if(En(e,n)>32)throw Error(`convertRadix2: carry overflow from=${e} to=${n} carryBits=${En(e,n)}`);let f=0,w=0,i=2**n-1,l=[];for(let $ of r){if(Pr($),$>=2**e)throw Error(`convertRadix2: invalid data word=${$} from=${e}`);if(f=f<32)throw Error(`convertRadix2: carry overflow pos=${w} from=${e}`);w+=e;for(;w>=n;w-=n)l.push((f>>w-n&i)>>>0);f&=2**w-1}if(f=f<=e)throw Error("Excess padding");if(!c&&f)throw Error(`Non-zero padding: ${f}`);if(c&&w>0)l.push(f>>>0);return l}function M0(r){return Pr(r),{encode:(e)=>{if(!(e instanceof Uint8Array))throw Error("radix.encode input should be Uint8Array");return zc(Array.from(e),256,r)},decode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="number")throw Error("radix.decode input should be array of strings");return Uint8Array.from(zc(e,r,256))}}}function Sr(r,e=!1){if(Pr(r),r<=0||r>32)throw Error("radix2: bits should be in (0..32]");if(En(8,r)>32||En(r,8)>32)throw Error("radix2: carry overflow");return{encode:(n)=>{if(!(n instanceof Uint8Array))throw Error("radix2.encode input should be Uint8Array");return ie(Array.from(n),8,r,!e)},decode:(n)=>{if(!Array.isArray(n)||n.length&&typeof n[0]!=="number")throw Error("radix2.decode input should be array of strings");return Uint8Array.from(ie(n,r,8,e))}}}function Qc(r){if(typeof r!=="function")throw Error("unsafeWrapper fn should be function");return function(...e){try{return r.apply(null,e)}catch(n){}}}var G0=Qr(Sr(4),qr("0123456789ABCDEF"),Tr("")),E0=Qr(Sr(5),qr("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"),Kn(5),Tr("")),Ai=Qr(Sr(5),qr("0123456789ABCDEFGHIJKLMNOPQRSTUV"),Kn(5),Tr("")),di=Qr(Sr(5),qr("0123456789ABCDEFGHJKMNPQRSTVWXYZ"),Tr(""),Gc((r)=>r.toUpperCase().replace(/O/g,"0").replace(/[IL]/g,"1"))),Wn=Qr(Sr(6),qr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"),Kn(6),Tr("")),K0=Qr(Sr(6),qr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"),Kn(6),Tr("")),Je=(r)=>Qr(M0(58),qr(r),Tr("")),$e=Je("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"),Ui=Je("123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"),gi=Je("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"),qc=[0,2,3,5,6,7,9,10,11],W0={encode(r){let e="";for(let n=0;n>25,n=(r&33554431)<<5;for(let c=0;c>c&1)===1)n^=Tc[c];return n}function Mc(r,e,n=1){let c=r.length,f=1;for(let w=0;w126)throw Error(`Invalid prefix (${r})`);f=br(f)^i>>5}f=br(f);for(let w=0;wM)throw TypeError(`Length ${K} exceeds limit ${M}`);return J=J.toLowerCase(),`${J}1${le.encode(Q)}${Mc(J,Q,e)}`}function l(J,Q=90){if(typeof J!=="string")throw Error(`bech32.decode input should be string, not ${typeof J}`);if(J.length<8||Q!==!1&&J.length>Q)throw TypeError(`Wrong string length: ${J.length} (${J}). Expected (8..${Q})`);let M=J.toLowerCase();if(J!==M&&J!==J.toUpperCase())throw Error("String must be lowercase or uppercase");J=M;let K=J.lastIndexOf("1");if(K===0||K===-1)throw Error('Letter "1" must be present between prefix and data only');let z=J.slice(0,K),q=J.slice(K+1);if(q.length<6)throw Error("Data must be at least 6 characters long");let G=le.decode(q).slice(0,-6),E=Mc(z,G,e);if(!q.endsWith(E))throw Error(`Invalid checksum in ${J}: expected "${E}"`);return{prefix:z,words:G}}let $=Qc(l);function I(J){let{prefix:Q,words:M}=l(J,!1);return{prefix:Q,words:M,bytes:c(M)}}return{encode:i,decode:l,decodeToBytes:I,decodeUnsafe:$,fromWords:c,fromWordsUnsafe:w,toWords:f}}var kr=Kc("bech32"),Oi=Kc("bech32m"),N0={encode:(r)=>new TextDecoder().decode(r),decode:(r)=>new TextEncoder().encode(r)},S0=Qr(Sr(4),qr("0123456789abcdef"),Tr(""),Gc((r)=>{if(typeof r!=="string"||r.length%2)throw TypeError(`hex.decode: expected string, got ${typeof r} with length ${r.length}`);return r.toLowerCase()})),X0={utf8:N0,hex:S0,base16:G0,base32:E0,base64:Wn,base64url:K0,base58:$e,base58xmr:W0},Zi=`Invalid encoding type. Available types: ${Object.keys(X0).join(", ")}`;var Y0=new TextDecoder("utf-8"),A0=new TextEncoder,Wc=1,Nc=65535;function Sc(r,e){let n=jr.getSharedSecret(r,"02"+e).subarray(1,33);return Jc(ir,n,"nip44-v2")}function Xc(r,e){let n=Ic(ir,r,e,76);return{chacha_key:n.subarray(0,32),chacha_nonce:n.subarray(32,44),hmac_key:n.subarray(44,76)}}function Ie(r){if(!Number.isSafeInteger(r)||r<1)throw Error("expected positive integer");if(r<=32)return 32;let e=1<Nc)throw Error("invalid plaintext size: must be between 1 and 65535 bytes");let e=new Uint8Array(2);return new DataView(e.buffer).setUint16(0,r,!1),e}function U0(r){let e=A0.encode(r),n=e.length,c=d0(n),f=new Uint8Array(Ie(n)-n);return Hr(c,e,f)}function g0(r){let e=new DataView(r.buffer).getUint16(0),n=r.subarray(2,2+e);if(eNc||n.length!==e||r.length!==2+Ie(e))throw Error("invalid padding");return Y0.decode(n)}function Yc(r,e,n){if(n.length!==32)throw Error("AAD associated data must be 32 bytes");let c=Hr(n,e);return Rr(ir,r,c)}function O0(r){if(typeof r!=="string")throw Error("payload must be a valid string");let e=r.length;if(e<132||e>87472)throw Error("invalid payload length: "+e);if(r[0]==="#")throw Error("unknown encryption version");let n;try{n=Wn.decode(r)}catch(w){throw Error("invalid base64: "+w.message)}let c=n.length;if(c<99||c>65603)throw Error("invalid data length: "+c);let f=n[0];if(f!==2)throw Error("unknown encryption version "+f);return{nonce:n.subarray(1,33),ciphertext:n.subarray(33,-32),mac:n.subarray(-32)}}function Ac(r,e,n=ke(32)){let{chacha_key:c,chacha_nonce:f,hmac_key:w}=Xc(e,n),i=U0(r),l=Gn(c,f,i),$=Yc(w,l,n);return Wn.encode(Hr(new Uint8Array([2]),n,l,$))}function dc(r,e){let{nonce:n,ciphertext:c,mac:f}=O0(r),{chacha_key:w,chacha_nonce:i,hmac_key:l}=Xc(e,n),$=Yc(l,c,n);if(!Tn($,f))throw Error("invalid MAC");let I=Gn(w,i,c);return g0(I)}var Z0={utils:{getConversationKey:Sc,calcPaddedLen:Ie},encrypt:Ac,decrypt:dc};var Zc={};mr(Zc,{minePow:()=>V0,getPow:()=>gc,fastEventHash:()=>Oc});var Li=new TextDecoder("utf-8"),D0=new TextEncoder;function gc(r){let e=0;for(let n=0;n<64;n+=8){let c=parseInt(r.substring(n,n+8),16);if(c===0)e+=32;else{e+=Math.clz32(c);break}}return e}function V0(r,e){let n=0,c=r,f=["nonce",n.toString(),e.toString()];c.tags.push(f);while(!0){let w=Math.floor(new Date().getTime()/1000);if(w!==c.created_at)n=0,c.created_at=w;if(f[1]=(++n).toString(),c.id=Oc(c),gc(c.id)>=e)break}return c}function Oc(r){return L(ir(D0.encode(JSON.stringify([0,r.pubkey,r.created_at,r.kind,r.tags,r.content]))))}var Dc={};mr(Dc,{nsecEncode:()=>P0,npubEncode:()=>B0,nprofileEncode:()=>o0,noteEncode:()=>v0,neventEncode:()=>C0,naddrEncode:()=>L0,encodeBytes:()=>Yn,decode:()=>R0,NostrTypeGuard:()=>j0,Bech32MaxSize:()=>Qe,BECH32_REGEX:()=>H0});var Nn=new TextDecoder("utf-8"),Sn=new TextEncoder,j0={isNProfile:(r)=>/^nprofile1[a-z\d]+$/.test(r||""),isNEvent:(r)=>/^nevent1[a-z\d]+$/.test(r||""),isNAddr:(r)=>/^naddr1[a-z\d]+$/.test(r||""),isNSec:(r)=>/^nsec1[a-z\d]{58}$/.test(r||""),isNPub:(r)=>/^npub1[a-z\d]{58}$/.test(r||""),isNote:(r)=>/^note1[a-z\d]+$/.test(r||""),isNcryptsec:(r)=>/^ncryptsec1[a-z\d]+$/.test(r||"")},Qe=5000,H0=/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/;function u0(r){let e=new Uint8Array(4);return e[0]=r>>24&255,e[1]=r>>16&255,e[2]=r>>8&255,e[3]=r&255,e}function R0(r){let{prefix:e,words:n}=kr.decode(r,Qe),c=new Uint8Array(kr.fromWords(n));switch(e){case"nprofile":{let f=ze(c);if(!f[0]?.[0])throw Error("missing TLV 0 for nprofile");if(f[0][0].length!==32)throw Error("TLV 0 should be 32 bytes");return{type:"nprofile",data:{pubkey:L(f[0][0]),relays:f[1]?f[1].map((w)=>Nn.decode(w)):[]}}}case"nevent":{let f=ze(c);if(!f[0]?.[0])throw Error("missing TLV 0 for nevent");if(f[0][0].length!==32)throw Error("TLV 0 should be 32 bytes");if(f[2]&&f[2][0].length!==32)throw Error("TLV 2 should be 32 bytes");if(f[3]&&f[3][0].length!==4)throw Error("TLV 3 should be 4 bytes");return{type:"nevent",data:{id:L(f[0][0]),relays:f[1]?f[1].map((w)=>Nn.decode(w)):[],author:f[2]?.[0]?L(f[2][0]):void 0,kind:f[3]?.[0]?parseInt(L(f[3][0]),16):void 0}}}case"naddr":{let f=ze(c);if(!f[0]?.[0])throw Error("missing TLV 0 for naddr");if(!f[2]?.[0])throw Error("missing TLV 2 for naddr");if(f[2][0].length!==32)throw Error("TLV 2 should be 32 bytes");if(!f[3]?.[0])throw Error("missing TLV 3 for naddr");if(f[3][0].length!==4)throw Error("TLV 3 should be 4 bytes");return{type:"naddr",data:{identifier:Nn.decode(f[0][0]),pubkey:L(f[2][0]),kind:parseInt(L(f[3][0]),16),relays:f[1]?f[1].map((w)=>Nn.decode(w)):[]}}}case"nsec":return{type:e,data:c};case"npub":case"note":return{type:e,data:L(c)};default:throw Error(`unknown prefix ${e}`)}}function ze(r){let e={},n=r;while(n.length>0){let c=n[0],f=n[1],w=n.slice(2,2+f);if(n=n.slice(2+f),w.lengthSn.encode(n))});return Xn("nprofile",e)}function C0(r){let e;if(r.kind!==void 0)e=u0(r.kind);let n=qe({0:[gr(r.id)],1:(r.relays||[]).map((c)=>Sn.encode(c)),2:r.author?[gr(r.author)]:[],3:e?[new Uint8Array(e)]:[]});return Xn("nevent",n)}function L0(r){let e=new ArrayBuffer(4);new DataView(e).setUint32(0,r.kind,!1);let n=qe({0:[Sn.encode(r.identifier)],1:(r.relays||[]).map((c)=>Sn.encode(c)),2:[gr(r.pubkey)],3:[new Uint8Array(e)]});return Xn("naddr",n)}function qe(r){let e=[];return Object.entries(r).reverse().forEach(([n,c])=>{c.forEach((f)=>{let w=new Uint8Array(f.length+2);w.set([parseInt(n)],0),w.set([f.length],1),w.set(f,2),e.push(w)})}),Hr(...e)}export{e0 as verifyEvent,ir as sha256,jr as secp256k1,Uc as nip44,Dc as nip19,Zc as nip13,r0 as getPublicKey,pf as generateSecretKey,n0 as finalizeEvent,tf as SimplePool}; +var jc=Object.defineProperty;var Hc=(r)=>r;function uc(r,e){this[r]=Hc.bind(null,e)}var mr=(r,e)=>{for(var n in e)jc(r,n,{get:e[n],enumerable:!0,configurable:!0,set:uc.bind(e,n)})};function Ee(r){if(!Number.isSafeInteger(r)||r<0)throw Error(`Wrong positive integer: ${r}`)}function An(r,...e){if(!(r instanceof Uint8Array))throw Error("Expected Uint8Array");if(e.length>0&&!e.includes(r.length))throw Error(`Expected Uint8Array of length ${e}, not of length=${r.length}`)}function Ke(r){if(typeof r!=="function"||typeof r.create!=="function")throw Error("Hash should be wrapped by utils.wrapConstructor");Ee(r.outputLen),Ee(r.blockLen)}function Vr(r,e=!0){if(r.destroyed)throw Error("Hash instance has been destroyed");if(e&&r.finished)throw Error("Hash#digest() has already been called")}function We(r,e){An(r);let n=e.outputLen;if(r.lengthr instanceof Uint8Array;var Fr=(r)=>new DataView(r.buffer,r.byteOffset,r.byteLength),cr=(r,e)=>r<<32-e|r>>>e,Rc=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;if(!Rc)throw Error("Non little-endian hardware is not supported");function Pc(r){if(typeof r!=="string")throw Error(`utf8ToBytes expected string, got ${typeof r}`);return new Uint8Array(new TextEncoder().encode(r))}function Br(r){if(typeof r==="string")r=Pc(r);if(!Ne(r))throw Error(`expected Uint8Array, got ${typeof r}`);return r}function Se(...r){let e=new Uint8Array(r.reduce((c,f)=>c+f.length,0)),n=0;return r.forEach((c)=>{if(!Ne(c))throw Error("Uint8Array expected");e.set(c,n),n+=c.length}),e}class vr{clone(){return this._cloneInto()}}var m0={}.toString;function Xe(r){let e=(c)=>r().update(Br(c)).digest(),n=r();return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=()=>r(),e}function hr(r=32){if(tr&&typeof tr.getRandomValues==="function")return tr.getRandomValues(new Uint8Array(r));throw Error("crypto.getRandomValues must be defined")}function Bc(r,e,n,c){if(typeof r.setBigUint64==="function")return r.setBigUint64(e,n,c);let f=BigInt(32),w=BigInt(4294967295),i=Number(n>>f&w),l=Number(n&w),$=c?4:0,I=c?0:4;r.setUint32(e+$,i,c),r.setUint32(e+I,l,c)}class dn extends vr{constructor(r,e,n,c){super();this.blockLen=r,this.outputLen=e,this.padOffset=n,this.isLE=c,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(r),this.view=Fr(this.buffer)}update(r){Vr(this);let{view:e,buffer:n,blockLen:c}=this;r=Br(r);let f=r.length;for(let w=0;wc-w)this.process(n,0),w=0;for(let J=w;JI.length)throw Error("_sha2: outputLen bigger than state");for(let J=0;J<$;J++)i.setUint32(4*J,I[J],f)}digest(){let{buffer:r,outputLen:e}=this;this.digestInto(r);let n=r.slice(0,e);return this.destroy(),n}_cloneInto(r){r||(r=new this.constructor),r.set(...this.get());let{blockLen:e,buffer:n,length:c,finished:f,destroyed:w,pos:i}=this;if(r.length=c,r.pos=i,r.finished=f,r.destroyed=w,c%e)r.buffer.set(n);return r}}var vc=(r,e,n)=>r&e^~r&n,oc=(r,e,n)=>r&e^r&n^e&n,Cc=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),Mr=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),Gr=new Uint32Array(64);class Ye extends dn{constructor(){super(64,32,8,!1);this.A=Mr[0]|0,this.B=Mr[1]|0,this.C=Mr[2]|0,this.D=Mr[3]|0,this.E=Mr[4]|0,this.F=Mr[5]|0,this.G=Mr[6]|0,this.H=Mr[7]|0}get(){let{A:r,B:e,C:n,D:c,E:f,F:w,G:i,H:l}=this;return[r,e,n,c,f,w,i,l]}set(r,e,n,c,f,w,i,l){this.A=r|0,this.B=e|0,this.C=n|0,this.D=c|0,this.E=f|0,this.F=w|0,this.G=i|0,this.H=l|0}process(r,e){for(let J=0;J<16;J++,e+=4)Gr[J]=r.getUint32(e,!1);for(let J=16;J<64;J++){let Q=Gr[J-15],M=Gr[J-2],K=cr(Q,7)^cr(Q,18)^Q>>>3,z=cr(M,17)^cr(M,19)^M>>>10;Gr[J]=z+Gr[J-7]+K+Gr[J-16]|0}let{A:n,B:c,C:f,D:w,E:i,F:l,G:$,H:I}=this;for(let J=0;J<64;J++){let Q=cr(i,6)^cr(i,11)^cr(i,25),M=I+Q+vc(i,l,$)+Cc[J]+Gr[J]|0,z=(cr(n,2)^cr(n,13)^cr(n,22))+oc(n,c,f)|0;I=$,$=l,l=i,i=w+M|0,w=f,f=c,c=n,n=M+z|0}n=n+this.A|0,c=c+this.B|0,f=f+this.C|0,w=w+this.D|0,i=i+this.E|0,l=l+this.F|0,$=$+this.G|0,I=I+this.H|0,this.set(n,c,f,w,i,l,$,I)}roundClean(){Gr.fill(0)}destroy(){this.set(0,0,0,0,0,0,0,0),this.buffer.fill(0)}}var sr=Xe(()=>new Ye);var Zn={};mr(Zn,{validateObject:()=>Er,utf8ToBytes:()=>_c,numberToVarBytesBE:()=>bc,numberToHexUnpadded:()=>Ue,numberToBytesLE:()=>rn,numberToBytesBE:()=>fr,hexToNumber:()=>gn,hexToBytes:()=>Ar,equalBytes:()=>kc,ensureBytes:()=>b,createHmacDrbg:()=>On,concatBytes:()=>Jr,bytesToNumberLE:()=>pr,bytesToNumberBE:()=>m,bytesToHex:()=>Yr,bitSet:()=>Fc,bitMask:()=>or,bitLen:()=>mc,bitGet:()=>tc});/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var de=BigInt(0),xr=BigInt(1),Lc=BigInt(2),ar=(r)=>r instanceof Uint8Array,yc=Array.from({length:256},(r,e)=>e.toString(16).padStart(2,"0"));function Yr(r){if(!ar(r))throw Error("Uint8Array expected");let e="";for(let n=0;nc+f.length,0)),n=0;return r.forEach((c)=>{if(!ar(c))throw Error("Uint8Array expected");e.set(c,n),n+=c.length}),e}function kc(r,e){if(r.length!==e.length)return!1;for(let n=0;nde;r>>=xr,e+=1);return e}function tc(r,e){return r>>BigInt(e)&xr}var Fc=(r,e,n)=>{return r|(n?xr:de)<(Lc<new Uint8Array(r),Ae=(r)=>Uint8Array.from(r);function On(r,e,n){if(typeof r!=="number"||r<2)throw Error("hashLen must be a number");if(typeof e!=="number"||e<2)throw Error("qByteLen must be a number");if(typeof n!=="function")throw Error("hmacFn must be a function");let c=Un(r),f=Un(r),w=0,i=()=>{c.fill(1),f.fill(0),w=0},l=(...Q)=>n(f,c,...Q),$=(Q=Un())=>{if(f=l(Ae([0]),Q),c=l(),Q.length===0)return;f=l(Ae([1]),Q),c=l()},I=()=>{if(w++>=1000)throw Error("drbg: tried 1000 values");let Q=0,M=[];while(Q{i(),$(Q);let K=void 0;while(!(K=M(I())))$();return i(),K}}var hc={bigint:(r)=>typeof r==="bigint",function:(r)=>typeof r==="function",boolean:(r)=>typeof r==="boolean",string:(r)=>typeof r==="string",stringOrUint8Array:(r)=>typeof r==="string"||r instanceof Uint8Array,isSafeInteger:(r)=>Number.isSafeInteger(r),array:(r)=>Array.isArray(r),field:(r,e)=>e.Fp.isValid(r),hash:(r)=>typeof r==="function"&&Number.isSafeInteger(r.outputLen)};function Er(r,e,n={}){let c=(f,w,i)=>{let l=hc[w];if(typeof l!=="function")throw Error(`Invalid validator "${w}", expected function`);let $=r[f];if(i&&$===void 0)return;if(!l($,r))throw Error(`Invalid param ${String(f)}=${$} (${typeof $}), expected ${w}`)};for(let[f,w]of Object.entries(e))c(f,w,!1);for(let[f,w]of Object.entries(n))c(f,w,!0);return r}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var _=BigInt(0),C=BigInt(1),dr=BigInt(2),sc=BigInt(3),Dn=BigInt(4),ge=BigInt(5),Oe=BigInt(8),xc=BigInt(9),ac=BigInt(16);function k(r,e){let n=r%e;return n>=_?n:e+n}function pc(r,e,n){if(n<=_||e<_)throw Error("Expected power/modulo > 0");if(n===C)return _;let c=C;while(e>_){if(e&C)c=c*r%n;r=r*r%n,e>>=C}return c}function a(r,e,n){let c=r;while(e-- >_)c*=c,c%=n;return c}function nn(r,e){if(r===_||e<=_)throw Error(`invert: expected positive integers, got n=${r} mod=${e}`);let n=k(r,e),c=e,f=_,w=C,i=C,l=_;while(n!==_){let I=c/n,J=c%n,Q=f-i*I,M=w-l*I;c=n,n=J,f=i,w=l,i=Q,l=M}if(c!==C)throw Error("invert: does not exist");return k(f,e)}function rf(r){let e=(r-C)/dr,n,c,f;for(n=r-C,c=0;n%dr===_;n/=dr,c++);for(f=dr;f{return c[f]="function",c},e);return Er(r,n)}function cf(r,e,n){if(n<_)throw Error("Expected power > 0");if(n===_)return r.ONE;if(n===C)return e;let c=r.ONE,f=e;while(n>_){if(n&C)c=r.mul(c,f);f=r.sqr(f),n>>=C}return c}function ff(r,e){let n=Array(e.length),c=e.reduce((w,i,l)=>{if(r.is0(i))return w;return n[l]=w,r.mul(w,i)},r.ONE),f=r.inv(c);return e.reduceRight((w,i,l)=>{if(r.is0(i))return w;return n[l]=r.mul(w,n[l]),r.mul(w,i)},f),n}function jn(r,e){let n=e!==void 0?e:r.toString(2).length,c=Math.ceil(n/8);return{nBitLength:n,nByteLength:c}}function Ze(r,e,n=!1,c={}){if(r<=_)throw Error(`Expected Field ORDER > 0, got ${r}`);let{nBitLength:f,nByteLength:w}=jn(r,e);if(w>2048)throw Error("Field lengths over 2048 bytes are not supported");let i=nf(r),l=Object.freeze({ORDER:r,BITS:f,BYTES:w,MASK:or(f),ZERO:_,ONE:C,create:($)=>k($,r),isValid:($)=>{if(typeof $!=="bigint")throw Error(`Invalid field element: expected bigint, got ${typeof $}`);return _<=$&&$$===_,isOdd:($)=>($&C)===C,neg:($)=>k(-$,r),eql:($,I)=>$===I,sqr:($)=>k($*$,r),add:($,I)=>k($+I,r),sub:($,I)=>k($-I,r),mul:($,I)=>k($*I,r),pow:($,I)=>cf(l,$,I),div:($,I)=>k($*nn(I,r),r),sqrN:($)=>$*$,addN:($,I)=>$+I,subN:($,I)=>$-I,mulN:($,I)=>$*I,inv:($)=>nn($,r),sqrt:c.sqrt||(($)=>i(l,$)),invertBatch:($)=>ff(l,$),cmov:($,I,J)=>J?I:$,toBytes:($)=>n?rn($,w):fr($,w),fromBytes:($)=>{if($.length!==w)throw Error(`Fp.fromBytes: expected ${w}, got ${$.length}`);return n?pr($):m($)}});return Object.freeze(l)}function De(r){if(typeof r!=="bigint")throw Error("field order must be bigint");let e=r.toString(2).length;return Math.ceil(e/8)}function Hn(r){let e=De(r);return e+Math.ceil(e/2)}function Ve(r,e,n=!1){let c=r.length,f=De(e),w=Hn(e);if(c<16||c1024)throw Error(`expected ${w}-1024 bytes of input, got ${c}`);let i=n?m(r):pr(r),l=k(i,e-C)+C;return n?rn(l,f):fr(l,f)}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var $f=BigInt(0),un=BigInt(1);function je(r,e){let n=(f,w)=>{let i=w.negate();return f?i:w},c=(f)=>{let w=Math.ceil(e/f)+1,i=2**(f-1);return{windows:w,windowSize:i}};return{constTimeNegate:n,unsafeLadder(f,w){let i=r.ZERO,l=f;while(w>$f){if(w&un)i=i.add(l);l=l.double(),w>>=un}return i},precomputeWindow(f,w){let{windows:i,windowSize:l}=c(w),$=[],I=f,J=I;for(let Q=0;Q>=K,G>$)G-=M,i+=un;let E=q,S=q+Math.abs(G)-1,U=z%2!==0,A=G<0;if(G===0)J=J.add(n(U,w[E]));else I=I.add(n(A,w[S]))}return{p:I,f:J}},wNAFCached(f,w,i,l){let $=f._WINDOW_SIZE||1,I=w.get(f);if(!I){if(I=this.precomputeWindow(f,$),$!==1)w.set(f,l(I))}return this.wNAF($,I,i)}}}function Rn(r){return Vn(r.Fp),Er(r,{n:"bigint",h:"bigint",Gx:"field",Gy:"field"},{nBitLength:"isSafeInteger",nByteLength:"isSafeInteger"}),Object.freeze({...jn(r.n,r.nBitLength),...r,...{p:r.Fp.ORDER}})}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function lf(r){let e=Rn(r);Er(e,{a:"field",b:"field"},{allowedPrivateKeyLengths:"array",wrapPrivateKey:"boolean",isTorsionFree:"function",clearCofactor:"function",allowInfinityPoint:"boolean",fromBytes:"function",toBytes:"function"});let{endo:n,Fp:c,a:f}=e;if(n){if(!c.eql(f,c.ZERO))throw Error("Endomorphism can only be defined for Koblitz curves that have a=0");if(typeof n!=="object"||typeof n.beta!=="bigint"||typeof n.splitScalar!=="function")throw Error("Expected endomorphism with beta: bigint and splitScalar: function")}return Object.freeze({...e})}var{bytesToNumberBE:Jf,hexToBytes:If}=Zn,Ur={Err:class extends Error{constructor(e=""){super(e)}},_parseInt(r){let{Err:e}=Ur;if(r.length<2||r[0]!==2)throw new e("Invalid signature integer tag");let n=r[1],c=r.subarray(2,n+2);if(!n||c.length!==n)throw new e("Invalid signature integer: wrong length");if(c[0]&128)throw new e("Invalid signature integer: negative");if(c[0]===0&&!(c[1]&128))throw new e("Invalid signature integer: unnecessary leading zero");return{d:Jf(c),l:r.subarray(n+2)}},toSig(r){let{Err:e}=Ur,n=typeof r==="string"?If(r):r;if(!(n instanceof Uint8Array))throw Error("ui8a expected");let c=n.length;if(c<2||n[0]!=48)throw new e("Invalid signature tag");if(n[1]!==c-2)throw new e("Invalid signature: incorrect length");let{d:f,l:w}=Ur._parseInt(n.subarray(2)),{d:i,l}=Ur._parseInt(w);if(l.length)throw new e("Invalid signature: left bytes after parsing");return{r:f,s:i}},hexFromSig(r){let e=(I)=>Number.parseInt(I[0],16)&8?"00"+I:I,n=(I)=>{let J=I.toString(16);return J.length&1?`0${J}`:J},c=e(n(r.s)),f=e(n(r.r)),w=c.length/2,i=f.length/2,l=n(w),$=n(i);return`30${n(i+w+4)}02${$}${f}02${l}${c}`}},Ir=BigInt(0),rr=BigInt(1),iw=BigInt(2),He=BigInt(3),$w=BigInt(4);function zf(r){let e=lf(r),{Fp:n}=e,c=e.toBytes||((z,q,G)=>{let E=q.toAffine();return Jr(Uint8Array.from([4]),n.toBytes(E.x),n.toBytes(E.y))}),f=e.fromBytes||((z)=>{let q=z.subarray(1),G=n.fromBytes(q.subarray(0,n.BYTES)),E=n.fromBytes(q.subarray(n.BYTES,2*n.BYTES));return{x:G,y:E}});function w(z){let{a:q,b:G}=e,E=n.sqr(z),S=n.mul(E,z);return n.add(n.add(S,n.mul(z,q)),G)}if(!n.eql(n.sqr(e.Gy),w(e.Gx)))throw Error("bad generator point: equation left != right");function i(z){return typeof z==="bigint"&&Irn.eql(S,n.ZERO);if(E(q)&&E(G))return Q.ZERO;return new Q(q,G,n.ONE)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}static normalizeZ(z){let q=n.invertBatch(z.map((G)=>G.pz));return z.map((G,E)=>G.toAffine(q[E])).map(Q.fromAffine)}static fromHex(z){let q=Q.fromAffine(f(b("pointHex",z)));return q.assertValidity(),q}static fromPrivateKey(z){return Q.BASE.multiply($(z))}_setWindowSize(z){this._WINDOW_SIZE=z,I.delete(this)}assertValidity(){if(this.is0()){if(e.allowInfinityPoint&&!n.is0(this.py))return;throw Error("bad point: ZERO")}let{x:z,y:q}=this.toAffine();if(!n.isValid(z)||!n.isValid(q))throw Error("bad point: x or y not FE");let G=n.sqr(q),E=w(z);if(!n.eql(G,E))throw Error("bad point: equation left != right");if(!this.isTorsionFree())throw Error("bad point: not in prime-order subgroup")}hasEvenY(){let{y:z}=this.toAffine();if(n.isOdd)return!n.isOdd(z);throw Error("Field doesn't support isOdd")}equals(z){J(z);let{px:q,py:G,pz:E}=this,{px:S,py:U,pz:A}=z,g=n.eql(n.mul(q,A),n.mul(S,E)),Y=n.eql(n.mul(G,A),n.mul(U,E));return g&&Y}negate(){return new Q(this.px,n.neg(this.py),this.pz)}double(){let{a:z,b:q}=e,G=n.mul(q,He),{px:E,py:S,pz:U}=this,A=n.ZERO,g=n.ZERO,Y=n.ZERO,O=n.mul(E,E),B=n.mul(S,S),D=n.mul(U,U),Z=n.mul(E,S);return Z=n.add(Z,Z),Y=n.mul(E,U),Y=n.add(Y,Y),A=n.mul(z,Y),g=n.mul(G,D),g=n.add(A,g),A=n.sub(B,g),g=n.add(B,g),g=n.mul(A,g),A=n.mul(Z,A),Y=n.mul(G,Y),D=n.mul(z,D),Z=n.sub(O,D),Z=n.mul(z,Z),Z=n.add(Z,Y),Y=n.add(O,O),O=n.add(Y,O),O=n.add(O,D),O=n.mul(O,Z),g=n.add(g,O),D=n.mul(S,U),D=n.add(D,D),O=n.mul(D,Z),A=n.sub(A,O),Y=n.mul(D,B),Y=n.add(Y,Y),Y=n.add(Y,Y),new Q(A,g,Y)}add(z){J(z);let{px:q,py:G,pz:E}=this,{px:S,py:U,pz:A}=z,g=n.ZERO,Y=n.ZERO,O=n.ZERO,B=e.a,D=n.mul(e.b,He),Z=n.mul(q,S),u=n.mul(G,U),R=n.mul(E,A),v=n.add(q,G),j=n.add(S,U);v=n.mul(v,j),j=n.add(Z,u),v=n.sub(v,j),j=n.add(q,E);let T=n.add(S,A);return j=n.mul(j,T),T=n.add(Z,R),j=n.sub(j,T),T=n.add(G,E),g=n.add(U,A),T=n.mul(T,g),g=n.add(u,R),T=n.sub(T,g),O=n.mul(B,j),g=n.mul(D,R),O=n.add(g,O),g=n.sub(u,O),O=n.add(u,O),Y=n.mul(g,O),u=n.add(Z,Z),u=n.add(u,Z),R=n.mul(B,R),j=n.mul(D,j),u=n.add(u,R),R=n.sub(Z,R),R=n.mul(B,R),j=n.add(j,R),Z=n.mul(u,j),Y=n.add(Y,Z),Z=n.mul(T,j),g=n.mul(v,g),g=n.sub(g,Z),Z=n.mul(v,u),O=n.mul(T,O),O=n.add(O,Z),new Q(g,Y,O)}subtract(z){return this.add(z.negate())}is0(){return this.equals(Q.ZERO)}wNAF(z){return K.wNAFCached(this,I,z,(q)=>{let G=n.invertBatch(q.map((E)=>E.pz));return q.map((E,S)=>E.toAffine(G[S])).map(Q.fromAffine)})}multiplyUnsafe(z){let q=Q.ZERO;if(z===Ir)return q;if(l(z),z===rr)return this;let{endo:G}=e;if(!G)return K.unsafeLadder(this,z);let{k1neg:E,k1:S,k2neg:U,k2:A}=G.splitScalar(z),g=q,Y=q,O=this;while(S>Ir||A>Ir){if(S&rr)g=g.add(O);if(A&rr)Y=Y.add(O);O=O.double(),S>>=rr,A>>=rr}if(E)g=g.negate();if(U)Y=Y.negate();return Y=new Q(n.mul(Y.px,G.beta),Y.py,Y.pz),g.add(Y)}multiply(z){l(z);let q=z,G,E,{endo:S}=e;if(S){let{k1neg:U,k1:A,k2neg:g,k2:Y}=S.splitScalar(q),{p:O,f:B}=this.wNAF(A),{p:D,f:Z}=this.wNAF(Y);O=K.constTimeNegate(U,O),D=K.constTimeNegate(g,D),D=new Q(n.mul(D.px,S.beta),D.py,D.pz),G=O.add(D),E=B.add(Z)}else{let{p:U,f:A}=this.wNAF(q);G=U,E=A}return Q.normalizeZ([G,E])[0]}multiplyAndAddUnsafe(z,q,G){let E=Q.BASE,S=(A,g)=>g===Ir||g===rr||!A.equals(E)?A.multiplyUnsafe(g):A.multiply(g),U=S(this,q).add(S(z,G));return U.is0()?void 0:U}toAffine(z){let{px:q,py:G,pz:E}=this,S=this.is0();if(z==null)z=S?n.ONE:n.inv(E);let U=n.mul(q,z),A=n.mul(G,z),g=n.mul(E,z);if(S)return{x:n.ZERO,y:n.ZERO};if(!n.eql(g,n.ONE))throw Error("invZ was invalid");return{x:U,y:A}}isTorsionFree(){let{h:z,isTorsionFree:q}=e;if(z===rr)return!0;if(q)return q(Q,this);throw Error("isTorsionFree() has not been declared for the elliptic curve")}clearCofactor(){let{h:z,clearCofactor:q}=e;if(z===rr)return this;if(q)return q(Q,this);return this.multiplyUnsafe(e.h)}toRawBytes(z=!0){return this.assertValidity(),c(Q,this,z)}toHex(z=!0){return Yr(this.toRawBytes(z))}}Q.BASE=new Q(e.Gx,e.Gy,n.ONE),Q.ZERO=new Q(n.ZERO,n.ONE,n.ZERO);let M=e.nBitLength,K=je(Q,e.endo?Math.ceil(M/2):M);return{CURVE:e,ProjectivePoint:Q,normPrivateKeyToScalar:$,weierstrassEquation:w,isWithinCurveOrder:i}}function Qf(r){let e=Rn(r);return Er(e,{hash:"hash",hmac:"function",randomBytes:"function"},{bits2int:"function",bits2int_modN:"function",lowS:"boolean"}),Object.freeze({lowS:!0,...e})}function ue(r){let e=Qf(r),{Fp:n,n:c}=e,f=n.BYTES+1,w=2*n.BYTES+1;function i(T){return IrYr(fr(T,e.nByteLength));function z(T){let N=c>>rr;return T>N}function q(T){return z(T)?l(-T):T}let G=(T,N,d)=>m(T.slice(N,d));class E{constructor(T,N,d){this.r=T,this.s=N,this.recovery=d,this.assertValidity()}static fromCompact(T){let N=e.nByteLength;return T=b("compactSignature",T,N*2),new E(G(T,0,N),G(T,N,2*N))}static fromDER(T){let{r:N,s:d}=Ur.toSig(b("DER",T));return new E(N,d)}assertValidity(){if(!M(this.r))throw Error("r must be 0 < r < CURVE.n");if(!M(this.s))throw Error("s must be 0 < s < CURVE.n")}addRecoveryBit(T){return new E(this.r,this.s,T)}recoverPublicKey(T){let{r:N,s:d,recovery:W}=this,V=O(b("msgHash",T));if(W==null||![0,1,2,3].includes(W))throw Error("recovery id invalid");let H=W===2||W===3?N+e.n:N;if(H>=n.ORDER)throw Error("recovery id 2 or 3 invalid");let o=(W&1)===0?"02":"03",P=I.fromHex(o+K(H)),y=$(H),F=l(-V*y),x=l(d*y),h=I.BASE.multiplyAndAddUnsafe(P,F,x);if(!h)throw Error("point at infinify");return h.assertValidity(),h}hasHighS(){return z(this.s)}normalizeS(){return this.hasHighS()?new E(this.r,l(-this.s),this.recovery):this}toDERRawBytes(){return Ar(this.toDERHex())}toDERHex(){return Ur.hexFromSig({r:this.r,s:this.s})}toCompactRawBytes(){return Ar(this.toCompactHex())}toCompactHex(){return K(this.r)+K(this.s)}}let S={isValidPrivateKey(T){try{return J(T),!0}catch(N){return!1}},normPrivateKeyToScalar:J,randomPrivateKey:()=>{let T=Hn(e.n);return Ve(e.randomBytes(T),e.n)},precompute(T=8,N=I.BASE){return N._setWindowSize(T),N.multiply(BigInt(3)),N}};function U(T,N=!0){return I.fromPrivateKey(T).toRawBytes(N)}function A(T){let N=T instanceof Uint8Array,d=typeof T==="string",W=(N||d)&&T.length;if(N)return W===f||W===w;if(d)return W===2*f||W===2*w;if(T instanceof I)return!0;return!1}function g(T,N,d=!0){if(A(T))throw Error("first arg must be private key");if(!A(N))throw Error("second arg must be public key");return I.fromHex(N).multiply(J(T)).toRawBytes(d)}let Y=e.bits2int||function(T){let N=m(T),d=T.length*8-e.nBitLength;return d>0?N>>BigInt(d):N},O=e.bits2int_modN||function(T){return l(Y(T))},B=or(e.nBitLength);function D(T){if(typeof T!=="bigint")throw Error("bigint expected");if(!(Ir<=T&&T(Xr in d)))throw Error("sign() legacy options not supported");let{hash:W,randomBytes:V}=e,{lowS:H,prehash:o,extraEntropy:P}=d;if(H==null)H=!0;if(T=b("msgHash",T),o)T=b("prehashed msgHash",W(T));let y=O(T),F=J(N),x=[D(F),D(y)];if(P!=null){let Xr=P===!0?V(n.BYTES):P;x.push(b("extraEntropy",Xr))}let h=Jr(...x),er=y;function $r(Xr){let Zr=Y(Xr);if(!M(Zr))return;let Te=$(Zr),lr=I.BASE.multiply(Zr).toAffine(),Dr=l(lr.x);if(Dr===Ir)return;let _r=l(Te*l(er+Dr*F));if(_r===Ir)return;let Me=(lr.x===Dr?0:2)|Number(lr.y&rr),Ge=_r;if(H&&z(_r))Ge=q(_r),Me^=1;return new E(Dr,Ge,Me)}return{seed:h,k2sig:$r}}let u={lowS:e.lowS,prehash:!1},R={lowS:e.lowS,prehash:!1};function v(T,N,d=u){let{seed:W,k2sig:V}=Z(T,N,d),H=e;return On(H.hash.outputLen,H.nByteLength,H.hmac)(W,V)}I.BASE._setWindowSize(8);function j(T,N,d,W=R){let V=T;if(N=b("msgHash",N),d=b("publicKey",d),"strict"in W)throw Error("options.strict was renamed to lowS");let{lowS:H,prehash:o}=W,P=void 0,y;try{if(typeof V==="string"||V instanceof Uint8Array)try{P=E.fromDER(V)}catch(lr){if(!(lr instanceof Ur.Err))throw lr;P=E.fromCompact(V)}else if(typeof V==="object"&&typeof V.r==="bigint"&&typeof V.s==="bigint"){let{r:lr,s:Dr}=V;P=new E(lr,Dr)}else throw Error("PARSE");y=I.fromHex(d)}catch(lr){if(lr.message==="PARSE")throw Error("signature must be Signature instance, Uint8Array or hex string");return!1}if(H&&P.hasHighS())return!1;if(o)N=e.hash(N);let{r:F,s:x}=P,h=O(N),er=$(x),$r=l(h*er),Xr=l(F*er),Zr=I.BASE.multiplyAndAddUnsafe(y,$r,Xr)?.toAffine();if(!Zr)return!1;return l(Zr.x)===F}return{CURVE:e,getPublicKey:U,getSharedSecret:g,sign:v,verify:j,ProjectivePoint:I,Signature:E,utils:S}}class Pn extends vr{constructor(r,e){super();this.finished=!1,this.destroyed=!1,Ke(r);let n=Br(e);if(this.iHash=r.create(),typeof this.iHash.update!=="function")throw Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen,this.outputLen=this.iHash.outputLen;let c=this.blockLen,f=new Uint8Array(c);f.set(n.length>c?r.create().update(n).digest():n);for(let w=0;wnew Pn(r,e).update(n).digest();Bn.create=(r,e)=>new Pn(r,e);/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function qf(r){return{hash:r,hmac:(e,...n)=>Bn(r,e,Se(...n)),randomBytes:hr}}function Re(r,e){let n=(c)=>ue({...r,...qf(c)});return Object.freeze({...n(e),create:n})}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var wn=BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),en=BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),ve=BigInt(1),cn=BigInt(2),Pe=(r,e)=>(r+e/cn)/e;function oe(r){let e=wn,n=BigInt(3),c=BigInt(6),f=BigInt(11),w=BigInt(22),i=BigInt(23),l=BigInt(44),$=BigInt(88),I=r*r*r%e,J=I*I*r%e,Q=a(J,n,e)*J%e,M=a(Q,n,e)*J%e,K=a(M,cn,e)*I%e,z=a(K,f,e)*K%e,q=a(z,w,e)*z%e,G=a(q,l,e)*q%e,E=a(G,$,e)*G%e,S=a(E,l,e)*q%e,U=a(S,n,e)*J%e,A=a(U,i,e)*z%e,g=a(A,c,e)*I%e,Y=a(g,cn,e);if(!on.eql(on.sqr(Y),r))throw Error("Cannot find square root");return Y}var on=Ze(wn,void 0,void 0,{sqrt:oe}),jr=Re({a:BigInt(0),b:BigInt(7),Fp:on,n:en,Gx:BigInt("55066263022277343669578718895168534326250603453777594175500187360389116729240"),Gy:BigInt("32670510020758816978083085130507043184471273380659243275938904335757337482424"),h:BigInt(1),lowS:!0,endo:{beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),splitScalar:(r)=>{let e=en,n=BigInt("0x3086d221a7d46bcde86c90e49284eb15"),c=-ve*BigInt("0xe4437ed6010e88286f547fa90abfe4c3"),f=BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),w=n,i=BigInt("0x100000000000000000000000000000000"),l=Pe(w*r,e),$=Pe(-c*r,e),I=k(r-l*n-$*f,e),J=k(-l*c-$*w,e),Q=I>i,M=J>i;if(Q)I=e-I;if(M)J=e-J;if(I>i||J>i)throw Error("splitScalar: Endomorphism failed, k="+r);return{k1neg:Q,k1:I,k2neg:M,k2:J}}}},sr),$n=BigInt(0),Ce=(r)=>typeof r==="bigint"&&$ntypeof r==="bigint"&&$nf.charCodeAt(0)));n=Jr(c,c),Be[r]=n}return sr(Jr(n,...e))}var yn=(r)=>r.toRawBytes(!0).slice(1),Cn=(r)=>fr(r,32),vn=(r)=>k(r,wn),Cr=(r)=>k(r,en),bn=jr.ProjectivePoint,Mf=(r,e,n)=>bn.BASE.multiplyAndAddUnsafe(r,e,n);function Ln(r){let e=jr.utils.normPrivateKeyToScalar(r),n=bn.fromPrivateKey(e);return{scalar:n.hasEvenY()?e:Cr(-e),bytes:yn(n)}}function Le(r){if(!Ce(r))throw Error("bad x: need 0 < x < p");let e=vn(r*r),n=vn(e*r+BigInt(7)),c=oe(n);if(c%cn!==$n)c=vn(-c);let f=new bn(r,c,ve);return f.assertValidity(),f}function ye(...r){return Cr(m(fn("BIP0340/challenge",...r)))}function Gf(r){return Ln(r).bytes}function Ef(r,e,n=hr(32)){let c=b("message",r),{bytes:f,scalar:w}=Ln(e),i=b("auxRand",n,32),l=Cn(w^m(fn("BIP0340/aux",i))),$=fn("BIP0340/nonce",l,f,c),I=Cr(m($));if(I===$n)throw Error("sign failed: k is zero");let{bytes:J,scalar:Q}=Ln(I),M=ye(J,f,c),K=new Uint8Array(64);if(K.set(J,0),K.set(Cn(Cr(Q+M*w)),32),!be(K,c,f))throw Error("sign: Invalid signature produced");return K}function be(r,e,n){let c=b("signature",r,64),f=b("message",e),w=b("publicKey",n,32);try{let i=Le(m(w)),l=m(c.subarray(0,32));if(!Ce(l))return!1;let $=m(c.subarray(32,64));if(!Tf($))return!1;let I=ye(Cn(l),yn(i),f),J=Mf(i,$,Cr(-I));if(!J||!J.hasEvenY()||J.toAffine().x!==l)return!1;return!0}catch(i){return!1}}var nr=(()=>({getPublicKey:Gf,sign:Ef,verify:be,utils:{randomPrivateKey:jr.utils.randomPrivateKey,lift_x:Le,pointToBytes:yn,numberToBytesBE:fr,bytesToNumberBE:m,taggedHash:fn,mod:k}}))();var ln=typeof globalThis==="object"&&"crypto"in globalThis?globalThis.crypto:void 0;/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */var kn=(r)=>r instanceof Uint8Array;var Jn=(r)=>new DataView(r.buffer,r.byteOffset,r.byteLength),wr=(r,e)=>r<<32-e|r>>>e,Kf=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;if(!Kf)throw Error("Non little-endian hardware is not supported");var Wf=Array.from({length:256},(r,e)=>e.toString(16).padStart(2,"0"));function L(r){if(!kn(r))throw Error("Uint8Array expected");let e="";for(let n=0;nc+f.length,0)),n=0;return r.forEach((c)=>{if(!kn(c))throw Error("Uint8Array expected");e.set(c,n),n+=c.length}),e}class Lr{clone(){return this._cloneInto()}}function _n(r){let e=(c)=>r().update(Kr(c)).digest(),n=r();return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=()=>r(),e}function ke(r=32){if(ln&&typeof ln.getRandomValues==="function")return ln.getRandomValues(new Uint8Array(r));throw Error("crypto.getRandomValues must be defined")}function mn(r){if(!Number.isSafeInteger(r)||r<0)throw Error(`Wrong positive integer: ${r}`)}function Sf(r){if(typeof r!=="boolean")throw Error(`Expected boolean, not ${r}`)}function _e(r,...e){if(!(r instanceof Uint8Array))throw Error("Expected Uint8Array");if(e.length>0&&!e.includes(r.length))throw Error(`Expected Uint8Array of length ${e}, not of length=${r.length}`)}function Xf(r){if(typeof r!=="function"||typeof r.create!=="function")throw Error("Hash should be wrapped by utils.wrapConstructor");mn(r.outputLen),mn(r.blockLen)}function Yf(r,e=!0){if(r.destroyed)throw Error("Hash instance has been destroyed");if(e&&r.finished)throw Error("Hash#digest() has already been called")}function Af(r,e){_e(r);let n=e.outputLen;if(r.length>f&w),l=Number(n&w),$=c?4:0,I=c?0:4;r.setUint32(e+$,i,c),r.setUint32(e+I,l,c)}class tn extends Lr{constructor(r,e,n,c){super();this.blockLen=r,this.outputLen=e,this.padOffset=n,this.isLE=c,this.finished=!1,this.length=0,this.pos=0,this.destroyed=!1,this.buffer=new Uint8Array(r),this.view=Jn(this.buffer)}update(r){p.exists(this);let{view:e,buffer:n,blockLen:c}=this;r=Kr(r);let f=r.length;for(let w=0;wc-w)this.process(n,0),w=0;for(let J=w;JI.length)throw Error("_sha2: outputLen bigger than state");for(let J=0;J<$;J++)i.setUint32(4*J,I[J],f)}digest(){let{buffer:r,outputLen:e}=this;this.digestInto(r);let n=r.slice(0,e);return this.destroy(),n}_cloneInto(r){r||(r=new this.constructor),r.set(...this.get());let{blockLen:e,buffer:n,length:c,finished:f,destroyed:w,pos:i}=this;if(r.length=c,r.pos=i,r.finished=f,r.destroyed=w,c%e)r.buffer.set(n);return r}}var gf=(r,e,n)=>r&e^~r&n,Of=(r,e,n)=>r&e^r&n^e&n,Zf=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),Wr=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),Nr=new Uint32Array(64);class Fn extends tn{constructor(){super(64,32,8,!1);this.A=Wr[0]|0,this.B=Wr[1]|0,this.C=Wr[2]|0,this.D=Wr[3]|0,this.E=Wr[4]|0,this.F=Wr[5]|0,this.G=Wr[6]|0,this.H=Wr[7]|0}get(){let{A:r,B:e,C:n,D:c,E:f,F:w,G:i,H:l}=this;return[r,e,n,c,f,w,i,l]}set(r,e,n,c,f,w,i,l){this.A=r|0,this.B=e|0,this.C=n|0,this.D=c|0,this.E=f|0,this.F=w|0,this.G=i|0,this.H=l|0}process(r,e){for(let J=0;J<16;J++,e+=4)Nr[J]=r.getUint32(e,!1);for(let J=16;J<64;J++){let Q=Nr[J-15],M=Nr[J-2],K=wr(Q,7)^wr(Q,18)^Q>>>3,z=wr(M,17)^wr(M,19)^M>>>10;Nr[J]=z+Nr[J-7]+K+Nr[J-16]|0}let{A:n,B:c,C:f,D:w,E:i,F:l,G:$,H:I}=this;for(let J=0;J<64;J++){let Q=wr(i,6)^wr(i,11)^wr(i,25),M=I+Q+gf(i,l,$)+Zf[J]+Nr[J]|0,z=(wr(n,2)^wr(n,13)^wr(n,22))+Of(n,c,f)|0;I=$,$=l,l=i,i=w+M|0,w=f,f=c,c=n,n=M+z|0}n=n+this.A|0,c=c+this.B|0,f=f+this.C|0,w=w+this.D|0,i=i+this.E|0,l=l+this.F|0,$=$+this.G|0,I=I+this.H|0,this.set(n,c,f,w,i,l,$,I)}roundClean(){Nr.fill(0)}destroy(){this.set(0,0,0,0,0,0,0,0),this.buffer.fill(0)}}class me extends Fn{constructor(){super();this.A=-1056596264,this.B=914150663,this.C=812702999,this.D=-150054599,this.E=-4191439,this.F=1750603025,this.G=1694076839,this.H=-1090891868,this.outputLen=28}}var ir=_n(()=>new Fn),Vw=_n(()=>new me);var Or=Symbol("verified"),Df=(r)=>r instanceof Object;function Vf(r){if(!Df(r))return!1;if(typeof r.kind!=="number")return!1;if(typeof r.content!=="string")return!1;if(typeof r.created_at!=="number")return!1;if(typeof r.pubkey!=="string")return!1;if(!r.pubkey.match(/^[a-f0-9]{64}$/))return!1;if(!Array.isArray(r.tags))return!1;for(let e=0;ew===n.slice(1)&&f.indexOf(i)!==-1))return!1}if(r.since&&e.created_atr.until)return!1;return!0}function Cf(r,e){for(let n=0;n{let e=new MessageChannel,n=()=>{e.port1.removeEventListener("message",n),r()};e.port1.addEventListener("message",n),e.port2.postMessage(0),e.port1.start()})}var _f=(r)=>{return r[Or]=!0,!0},te=class{url;_connected=!1;onclose=null;onnotice=(r)=>console.debug(`NOTICE from ${this.url}: ${r}`);_onauth=null;baseEoseTimeout=4400;connectionTimeout=4400;publishTimeout=4400;openSubs=new Map;connectionTimeoutHandle;connectionPromise;openCountRequests=new Map;openEventPublishes=new Map;ws;incomingMessageQueue=new uf;queueRunning=!1;challenge;serial=0;verifyEvent;_WebSocket;constructor(r,e){this.url=yr(r),this.verifyEvent=e.verifyEvent,this._WebSocket=e.websocketImplementation||WebSocket}static async connect(r,e){let n=new te(r,e);return await n.connect(),n}closeAllSubscriptions(r){for(let[e,n]of this.openSubs)n.close(r);this.openSubs.clear();for(let[e,n]of this.openEventPublishes)n.reject(Error(r));this.openEventPublishes.clear();for(let[e,n]of this.openCountRequests)n.reject(Error(r));this.openCountRequests.clear()}get connected(){return this._connected}async connect(){if(this.connectionPromise)return this.connectionPromise;return this.challenge=void 0,this.connectionPromise=new Promise((r,e)=>{this.connectionTimeoutHandle=setTimeout(()=>{e("connection timed out"),this.connectionPromise=void 0,this.onclose?.(),this.closeAllSubscriptions("relay connection timed out")},this.connectionTimeout);try{this.ws=new this._WebSocket(this.url)}catch(n){e(n);return}this.ws.onopen=()=>{clearTimeout(this.connectionTimeoutHandle),this._connected=!0,r()},this.ws.onerror=(n)=>{if(e(n.message||"websocket error"),this._connected)this._connected=!1,this.connectionPromise=void 0,this.onclose?.(),this.closeAllSubscriptions("relay connection errored")},this.ws.onclose=async()=>{if(this._connected)this._connected=!1,this.connectionPromise=void 0,this.onclose?.(),this.closeAllSubscriptions("relay connection closed")},this.ws.onmessage=this._onmessage.bind(this)}),this.connectionPromise}async runQueue(){this.queueRunning=!0;while(!0){if(this.handleNext()===!1)break;await kf()}this.queueRunning=!1}handleNext(){let r=this.incomingMessageQueue.dequeue();if(!r)return!1;let e=yf(r);if(e){let n=this.openSubs.get(e);if(!n)return;let c=Lf(r,"id"),f=n.alreadyHaveEvent?.(c);if(n.receivedEvent?.(this,c),f)return}try{let n=JSON.parse(r);switch(n[0]){case"EVENT":{let c=this.openSubs.get(n[1]),f=n[2];if(this.verifyEvent(f)&&Cf(c.filters,f))c.onevent(f);return}case"COUNT":{let c=n[1],f=n[2],w=this.openCountRequests.get(c);if(w)w.resolve(f.count),this.openCountRequests.delete(c);return}case"EOSE":{let c=this.openSubs.get(n[1]);if(!c)return;c.receivedEose();return}case"OK":{let c=n[1],f=n[2],w=n[3],i=this.openEventPublishes.get(c);if(i){if(f)i.resolve(w);else i.reject(Error(w));this.openEventPublishes.delete(c)}return}case"CLOSED":{let c=n[1],f=this.openSubs.get(c);if(!f)return;f.closed=!0,f.close(n[2]);return}case"NOTICE":this.onnotice(n[1]);return;case"AUTH":{this.challenge=n[1],this._onauth?.(n[1]);return}}}catch(n){return}}async send(r){if(!this.connectionPromise)throw Error("sending on closed connection");this.connectionPromise.then(()=>{this.ws?.send(r)})}async auth(r){if(!this.challenge)throw Error("can't perform auth, no challenge was received");let e=await r(bf(this.url,this.challenge)),n=new Promise((c,f)=>{this.openEventPublishes.set(e.id,{resolve:c,reject:f})});return this.send('["AUTH",'+JSON.stringify(e)+"]"),n}async publish(r){let e=new Promise((n,c)=>{this.openEventPublishes.set(r.id,{resolve:n,reject:c})});return this.send('["EVENT",'+JSON.stringify(r)+"]"),setTimeout(()=>{let n=this.openEventPublishes.get(r.id);if(n)n.reject(Error("publish timed out")),this.openEventPublishes.delete(r.id)},this.publishTimeout),e}async count(r,e){this.serial++;let n=e?.id||"count:"+this.serial,c=new Promise((f,w)=>{this.openCountRequests.set(n,{resolve:f,reject:w})});return this.send('["COUNT","'+n+'",'+JSON.stringify(r).substring(1)),c}subscribe(r,e){let n=this.prepareSubscription(r,e);return n.fire(),n}prepareSubscription(r,e){this.serial++;let n=e.id||"sub:"+this.serial,c=new mf(this,n,r,e);return this.openSubs.set(n,c),c}close(){this.closeAllSubscriptions("relay connection closed by us"),this._connected=!1,this.ws?.close()}_onmessage(r){if(this.incomingMessageQueue.enqueue(r.data),!this.queueRunning)this.runQueue()}},mf=class{relay;id;closed=!1;eosed=!1;filters;alreadyHaveEvent;receivedEvent;onevent;oneose;onclose;eoseTimeout;eoseTimeoutHandle;constructor(r,e,n,c){this.relay=r,this.filters=n,this.id=e,this.alreadyHaveEvent=c.alreadyHaveEvent,this.receivedEvent=c.receivedEvent,this.eoseTimeout=c.eoseTimeout||r.baseEoseTimeout,this.oneose=c.oneose,this.onclose=c.onclose,this.onevent=c.onevent||((f)=>{console.warn(`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,f)})}fire(){this.relay.send('["REQ","'+this.id+'",'+JSON.stringify(this.filters).substring(1)),this.eoseTimeoutHandle=setTimeout(this.receivedEose.bind(this),this.eoseTimeout)}receivedEose(){if(this.eosed)return;clearTimeout(this.eoseTimeoutHandle),this.eosed=!0,this.oneose?.()}close(r="closed by caller"){if(!this.closed&&this.relay.connected)this.relay.send('["CLOSE",'+JSON.stringify(this.id)+"]"),this.closed=!0;this.relay.openSubs.delete(this.id),this.onclose?.(r)}},Fe=class{relays=new Map;seenOn=new Map;trackRelays=!1;verifyEvent;trustedRelayURLs=new Set;_WebSocket;constructor(r){this.verifyEvent=r.verifyEvent,this._WebSocket=r.websocketImplementation}async ensureRelay(r,e){r=yr(r);let n=this.relays.get(r);if(!n){if(n=new te(r,{verifyEvent:this.trustedRelayURLs.has(r)?_f:this.verifyEvent,websocketImplementation:this._WebSocket}),e?.connectionTimeout)n.connectionTimeout=e.connectionTimeout;this.relays.set(r,n)}return await n.connect(),n}close(r){r.map(yr).forEach((e)=>{this.relays.get(e)?.close()})}subscribeMany(r,e,n){return this.subscribeManyMap(Object.fromEntries(r.map((c)=>[c,e])),n)}subscribeManyMap(r,e){if(this.trackRelays)e.receivedEvent=(Q,M)=>{let K=this.seenOn.get(M);if(!K)K=new Set,this.seenOn.set(M,K);K.add(Q)};let n=new Set,c=[],f=Object.keys(r).length,w=[],i=(Q)=>{if(w[Q]=!0,w.filter((M)=>M).length===f)e.oneose?.(),i=()=>{}},l=[],$=(Q,M)=>{if(i(Q),l[Q]=M,l.filter((K)=>K).length===f)e.onclose?.(l),$=()=>{}},I=(Q)=>{if(e.alreadyHaveEvent?.(Q))return!0;let M=n.has(Q);return n.add(Q),M},J=Promise.all(Object.entries(r).map(async(Q,M,K)=>{if(K.indexOf(Q)!==M){$(M,"duplicate url");return}let[z,q]=Q;z=yr(z);let G;try{G=await this.ensureRelay(z,{connectionTimeout:e.maxWait?Math.max(e.maxWait*0.8,e.maxWait-1000):void 0})}catch(S){$(M,S?.message||String(S));return}let E=G.subscribe(q,{...e,oneose:()=>i(M),onclose:(S)=>$(M,S),alreadyHaveEvent:I,eoseTimeout:e.maxWait});c.push(E)}));return{async close(){await J,c.forEach((Q)=>{Q.close()})}}}subscribeManyEose(r,e,n){let c=this.subscribeMany(r,e,{...n,oneose(){c.close()}});return c}async querySync(r,e,n){return new Promise(async(c)=>{let f=[];this.subscribeManyEose(r,[e],{...n,onevent(w){f.push(w)},onclose(w){c(f)}})})}async get(r,e,n){e.limit=1;let c=await this.querySync(r,e,n);return c.sort((f,w)=>w.created_at-f.created_at),c[0]||null}publish(r,e){return r.map(yr).map(async(n,c,f)=>{if(f.indexOf(n)!==c)return Promise.reject("duplicate url");let w=await this.ensureRelay(n);return w.publish(e).then((i)=>{if(this.trackRelays){let l=this.seenOn.get(e.id);if(!l)l=new Set,this.seenOn.set(e.id,l);l.add(w)}return i})})}listConnectionStatus(){let r=new Map;return this.relays.forEach((e,n)=>r.set(n,e.connected)),r}destroy(){this.relays.forEach((r)=>r.close()),this.relays=new Map}},he;try{he=WebSocket}catch{}var tf=class extends Fe{constructor(){super({verifyEvent:Bf,websocketImplementation:he})}};var ur=Symbol("verified"),Ff=(r)=>r instanceof Object;function hf(r){if(!Ff(r))return!1;if(typeof r.kind!=="number")return!1;if(typeof r.content!=="string")return!1;if(typeof r.created_at!=="number")return!1;if(typeof r.pubkey!=="string")return!1;if(!r.pubkey.match(/^[a-f0-9]{64}$/))return!1;if(!Array.isArray(r.tags))return!1;for(let e=0;eZ0,getConversationKey:()=>Xc,encrypt:()=>dc,decrypt:()=>Uc});function Qn(r){if(!Number.isSafeInteger(r)||r<0)throw Error(`positive integer expected, not ${r}`)}function xn(r){if(typeof r!=="boolean")throw Error(`boolean expected, not ${r}`)}function an(r){return r instanceof Uint8Array||r!=null&&typeof r==="object"&&r.constructor.name==="Uint8Array"}function s(r,...e){if(!an(r))throw Error("Uint8Array expected");if(e.length>0&&!e.includes(r.length))throw Error(`Uint8Array expected of length ${e}, not of length=${r.length}`)}function pn(r,e=!0){if(r.destroyed)throw Error("Hash instance has been destroyed");if(e&&r.finished)throw Error("Hash#digest() has already been called")}function se(r,e){s(r);let n=e.outputLen;if(r.lengthnew Uint32Array(r.buffer,r.byteOffset,Math.floor(r.byteLength/4)),xe=(r)=>new DataView(r.buffer,r.byteOffset,r.byteLength),c0=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;if(!c0)throw Error("Non little-endian hardware is not supported");function f0(r){if(typeof r!=="string")throw Error(`string expected, got ${typeof r}`);return new Uint8Array(new TextEncoder().encode(r))}function qn(r){if(typeof r==="string")r=f0(r);else if(an(r))r=r.slice();else throw Error(`Uint8Array expected, got ${typeof r}`);return r}function ae(r,e){if(e==null||typeof e!=="object")throw Error("options must be defined");return Object.assign(r,e)}function Tn(r,e){if(r.length!==e.length)return!1;let n=0;for(let c=0;c{return Object.assign(e,r),e};function ne(r,e,n,c){if(typeof r.setBigUint64==="function")return r.setBigUint64(e,n,c);let f=BigInt(32),w=BigInt(4294967295),i=Number(n>>f&w),l=Number(n&w),$=c?4:0,I=c?0:4;r.setUint32(e+$,i,c),r.setUint32(e+I,l,c)}var t=(r,e)=>r[e++]&255|(r[e++]&255)<<8;class pe{constructor(r){this.blockLen=16,this.outputLen=16,this.buffer=new Uint8Array(16),this.r=new Uint16Array(10),this.h=new Uint16Array(10),this.pad=new Uint16Array(8),this.pos=0,this.finished=!1,r=qn(r),s(r,32);let e=t(r,0),n=t(r,2),c=t(r,4),f=t(r,6),w=t(r,8),i=t(r,10),l=t(r,12),$=t(r,14);this.r[0]=e&8191,this.r[1]=(e>>>13|n<<3)&8191,this.r[2]=(n>>>10|c<<6)&7939,this.r[3]=(c>>>7|f<<9)&8191,this.r[4]=(f>>>4|w<<12)&255,this.r[5]=w>>>1&8190,this.r[6]=(w>>>14|i<<2)&8191,this.r[7]=(i>>>11|l<<5)&8065,this.r[8]=(l>>>8|$<<8)&8191,this.r[9]=$>>>5&127;for(let I=0;I<8;I++)this.pad[I]=t(r,16+2*I)}process(r,e,n=!1){let c=n?0:2048,{h:f,r:w}=this,i=w[0],l=w[1],$=w[2],I=w[3],J=w[4],Q=w[5],M=w[6],K=w[7],z=w[8],q=w[9],G=t(r,e+0),E=t(r,e+2),S=t(r,e+4),U=t(r,e+6),A=t(r,e+8),g=t(r,e+10),Y=t(r,e+12),O=t(r,e+14),B=f[0]+(G&8191),D=f[1]+((G>>>13|E<<3)&8191),Z=f[2]+((E>>>10|S<<6)&8191),u=f[3]+((S>>>7|U<<9)&8191),R=f[4]+((U>>>4|A<<12)&8191),v=f[5]+(A>>>1&8191),j=f[6]+((A>>>14|g<<2)&8191),T=f[7]+((g>>>11|Y<<5)&8191),N=f[8]+((Y>>>8|O<<8)&8191),d=f[9]+(O>>>5|c),W=0,V=W+B*i+D*(5*q)+Z*(5*z)+u*(5*K)+R*(5*M);W=V>>>13,V&=8191,V+=v*(5*Q)+j*(5*J)+T*(5*I)+N*(5*$)+d*(5*l),W+=V>>>13,V&=8191;let H=W+B*l+D*i+Z*(5*q)+u*(5*z)+R*(5*K);W=H>>>13,H&=8191,H+=v*(5*M)+j*(5*Q)+T*(5*J)+N*(5*I)+d*(5*$),W+=H>>>13,H&=8191;let o=W+B*$+D*l+Z*i+u*(5*q)+R*(5*z);W=o>>>13,o&=8191,o+=v*(5*K)+j*(5*M)+T*(5*Q)+N*(5*J)+d*(5*I),W+=o>>>13,o&=8191;let P=W+B*I+D*$+Z*l+u*i+R*(5*q);W=P>>>13,P&=8191,P+=v*(5*z)+j*(5*K)+T*(5*M)+N*(5*Q)+d*(5*J),W+=P>>>13,P&=8191;let y=W+B*J+D*I+Z*$+u*l+R*i;W=y>>>13,y&=8191,y+=v*(5*q)+j*(5*z)+T*(5*K)+N*(5*M)+d*(5*Q),W+=y>>>13,y&=8191;let F=W+B*Q+D*J+Z*I+u*$+R*l;W=F>>>13,F&=8191,F+=v*i+j*(5*q)+T*(5*z)+N*(5*K)+d*(5*M),W+=F>>>13,F&=8191;let x=W+B*M+D*Q+Z*J+u*I+R*$;W=x>>>13,x&=8191,x+=v*l+j*i+T*(5*q)+N*(5*z)+d*(5*K),W+=x>>>13,x&=8191;let h=W+B*K+D*M+Z*Q+u*J+R*I;W=h>>>13,h&=8191,h+=v*$+j*l+T*i+N*(5*q)+d*(5*z),W+=h>>>13,h&=8191;let er=W+B*z+D*K+Z*M+u*Q+R*J;W=er>>>13,er&=8191,er+=v*I+j*$+T*l+N*i+d*(5*q),W+=er>>>13,er&=8191;let $r=W+B*q+D*z+Z*K+u*M+R*Q;W=$r>>>13,$r&=8191,$r+=v*J+j*I+T*$+N*l+d*i,W+=$r>>>13,$r&=8191,W=(W<<2)+W|0,W=W+V|0,V=W&8191,W=W>>>13,H+=W,f[0]=V,f[1]=H,f[2]=o,f[3]=P,f[4]=y,f[5]=F,f[6]=x,f[7]=h,f[8]=er,f[9]=$r}finalize(){let{h:r,pad:e}=this,n=new Uint16Array(10),c=r[1]>>>13;r[1]&=8191;for(let i=2;i<10;i++)r[i]+=c,c=r[i]>>>13,r[i]&=8191;r[0]+=c*5,c=r[0]>>>13,r[0]&=8191,r[1]+=c,c=r[1]>>>13,r[1]&=8191,r[2]+=c,n[0]=r[0]+5,c=n[0]>>>13,n[0]&=8191;for(let i=1;i<10;i++)n[i]=r[i]+c,c=n[i]>>>13,n[i]&=8191;n[9]-=8192;let f=(c^1)-1;for(let i=0;i<10;i++)n[i]&=f;f=~f;for(let i=0;i<10;i++)r[i]=r[i]&f|n[i];r[0]=(r[0]|r[1]<<13)&65535,r[1]=(r[1]>>>3|r[2]<<10)&65535,r[2]=(r[2]>>>6|r[3]<<7)&65535,r[3]=(r[3]>>>9|r[4]<<4)&65535,r[4]=(r[4]>>>12|r[5]<<1|r[6]<<14)&65535,r[5]=(r[6]>>>2|r[7]<<11)&65535,r[6]=(r[7]>>>5|r[8]<<8)&65535,r[7]=(r[8]>>>8|r[9]<<5)&65535;let w=r[0]+e[0];r[0]=w&65535;for(let i=1;i<8;i++)w=(r[i]+e[i]|0)+(w>>>16)|0,r[i]=w&65535}update(r){pn(this);let{buffer:e,blockLen:n}=this;r=qn(r);let c=r.length;for(let f=0;f>>0,r[f++]=n[w]>>>8;return r}digest(){let{buffer:r,outputLen:e}=this;this.digestInto(r);let n=r.slice(0,e);return this.destroy(),n}}function w0(r){let e=(c,f)=>r(f).update(qn(c)).digest(),n=r(new Uint8Array(32));return e.outputLen=n.outputLen,e.blockLen=n.blockLen,e.create=(c)=>r(c),e}var rc=w0((r)=>new pe(r));var ec=(r)=>Uint8Array.from(r.split("").map((e)=>e.charCodeAt(0))),i0=ec("expand 16-byte k"),$0=ec("expand 32-byte k"),l0=zr(i0),cc=zr($0),li=cc.slice();function X(r,e){return r<>>32-e}function ee(r){return r.byteOffset%4===0}var Mn=64,J0=16,fc=4294967295,nc=new Uint32Array;function I0(r,e,n,c,f,w,i,l){let $=f.length,I=new Uint8Array(Mn),J=zr(I),Q=ee(f)&&ee(w),M=Q?zr(f):nc,K=Q?zr(w):nc;for(let z=0;z<$;i++){if(r(e,n,c,J,i,l),i>=fc)throw Error("arx: counter overflow");let q=Math.min(Mn,$-z);if(Q&&q===Mn){let G=z/4;if(z%4!==0)throw Error("arx: invalid block position");for(let E=0,S;E{s(l),s($),s(I);let M=I.length;if(!J)J=new Uint8Array(M);if(s(J),Qn(Q),Q<0||Q>=fc)throw Error("arx: counter overflow");if(J.length0)K.pop().fill(0);return J}}function $c(r,e,n,c,f,w=20){let i=r[0],l=r[1],$=r[2],I=r[3],J=e[0],Q=e[1],M=e[2],K=e[3],z=e[4],q=e[5],G=e[6],E=e[7],S=f,U=n[0],A=n[1],g=n[2],Y=i,O=l,B=$,D=I,Z=J,u=Q,R=M,v=K,j=z,T=q,N=G,d=E,W=S,V=U,H=A,o=g;for(let y=0;y{r.update(e);let n=e.length%16;if(n)r.update(q0.subarray(n))},T0=new Uint8Array(32);function ic(r,e,n,c,f){let w=r(e,n,T0),i=rc.create(w);if(f)wc(i,f);wc(i,c);let l=new Uint8Array(16),$=xe(l);ne($,0,BigInt(f?f.length:0),!0),ne($,8,BigInt(c.length),!0),i.update(l);let I=i.digest();return w.fill(0),I}var lc=(r)=>(e,n,c)=>{return s(e,32),s(n),{encrypt:(w,i)=>{let l=w.length,$=l+16;if(i)s(i,$);else i=new Uint8Array($);r(e,n,w,i,1);let I=ic(r,e,n,i.subarray(0,-16),c);return i.set(I,l),i},decrypt:(w,i)=>{let l=w.length,$=l-16;if(l<16)throw Error("encrypted data must be at least 16 bytes");if(i)s(i,$);else i=new Uint8Array($);let I=w.subarray(0,-16),J=w.subarray(-16),Q=ic(r,e,n,I,c);if(!Tn(J,Q))throw Error("invalid tag");return r(e,n,I,i,1),i}}},Ti=re({blockSize:64,nonceLength:12,tagLength:16},lc(Gn)),Mi=re({blockSize:64,nonceLength:24,tagLength:16},lc(Q0));class fe extends Lr{constructor(r,e){super();this.finished=!1,this.destroyed=!1,p.hash(r);let n=Kr(e);if(this.iHash=r.create(),typeof this.iHash.update!=="function")throw Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen,this.outputLen=this.iHash.outputLen;let c=this.blockLen,f=new Uint8Array(c);f.set(n.length>c?r.create().update(n).digest():n);for(let w=0;wnew fe(r,e).update(n).digest();Rr.create=(r,e)=>new fe(r,e);function Ic(r,e,n){if(p.hash(r),n===void 0)n=new Uint8Array(r.outputLen);return Rr(r,Kr(n),Kr(e))}var we=new Uint8Array([0]),Jc=new Uint8Array;function zc(r,e,n,c=32){if(p.hash(r),p.number(c),c>255*r.outputLen)throw Error("Length should be <= 255*HashLen");let f=Math.ceil(c/r.outputLen);if(n===void 0)n=Jc;let w=new Uint8Array(f*r.outputLen),i=Rr.create(r,e),l=i._cloneInto(),$=new Uint8Array(i.outputLen);for(let I=0;I(i)=>f(w(i)),n=Array.from(r).reverse().reduce((f,w)=>f?e(f,w.encode):w.encode,void 0),c=r.reduce((f,w)=>f?e(f,w.decode):w.decode,void 0);return{encode:n,decode:c}}function qr(r){return{encode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="number")throw Error("alphabet.encode input should be an array of numbers");return e.map((n)=>{if(Pr(n),n<0||n>=r.length)throw Error(`Digit index outside alphabet: ${n} (alphabet: ${r.length})`);return r[n]})},decode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="string")throw Error("alphabet.decode input should be array of strings");return e.map((n)=>{if(typeof n!=="string")throw Error(`alphabet.decode: not string element=${n}`);let c=r.indexOf(n);if(c===-1)throw Error(`Unknown letter: "${n}". Allowed: ${r}`);return c})}}}function Tr(r=""){if(typeof r!=="string")throw Error("join separator should be string");return{encode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="string")throw Error("join.encode input should be array of strings");for(let n of e)if(typeof n!=="string")throw Error(`join.encode: non-string input=${n}`);return e.join(r)},decode:(e)=>{if(typeof e!=="string")throw Error("join.decode input should be string");return e.split(r)}}}function Kn(r,e="="){if(Pr(r),typeof e!=="string")throw Error("padding chr should be string");return{encode(n){if(!Array.isArray(n)||n.length&&typeof n[0]!=="string")throw Error("padding.encode input should be array of strings");for(let c of n)if(typeof c!=="string")throw Error(`padding.encode: non-string input=${c}`);while(n.length*r%8)n.push(e);return n},decode(n){if(!Array.isArray(n)||n.length&&typeof n[0]!=="string")throw Error("padding.encode input should be array of strings");for(let f of n)if(typeof f!=="string")throw Error(`padding.decode: non-string input=${f}`);let c=n.length;if(c*r%8)throw Error("Invalid padding: string should have whole number of bytes");for(;c>0&&n[c-1]===e;c--)if(!((c-1)*r%8))throw Error("Invalid padding: string has too much padding");return n.slice(0,c)}}}function Ec(r){if(typeof r!=="function")throw Error("normalize fn should be function");return{encode:(e)=>e,decode:(e)=>r(e)}}function Qc(r,e,n){if(e<2)throw Error(`convertRadix: wrong from=${e}, base cannot be less than 2`);if(n<2)throw Error(`convertRadix: wrong to=${n}, base cannot be less than 2`);if(!Array.isArray(r))throw Error("convertRadix: data should be array");if(!r.length)return[];let c=0,f=[],w=Array.from(r);w.forEach((i)=>{if(Pr(i),i<0||i>=e)throw Error(`Wrong integer: ${i}`)});while(!0){let i=0,l=!0;for(let $=c;$!e?r:Kc(e,r%e),En=(r,e)=>r+(e-Kc(r,e));function ie(r,e,n,c){if(!Array.isArray(r))throw Error("convertRadix2: data should be array");if(e<=0||e>32)throw Error(`convertRadix2: wrong from=${e}`);if(n<=0||n>32)throw Error(`convertRadix2: wrong to=${n}`);if(En(e,n)>32)throw Error(`convertRadix2: carry overflow from=${e} to=${n} carryBits=${En(e,n)}`);let f=0,w=0,i=2**n-1,l=[];for(let $ of r){if(Pr($),$>=2**e)throw Error(`convertRadix2: invalid data word=${$} from=${e}`);if(f=f<32)throw Error(`convertRadix2: carry overflow pos=${w} from=${e}`);w+=e;for(;w>=n;w-=n)l.push((f>>w-n&i)>>>0);f&=2**w-1}if(f=f<=e)throw Error("Excess padding");if(!c&&f)throw Error(`Non-zero padding: ${f}`);if(c&&w>0)l.push(f>>>0);return l}function M0(r){return Pr(r),{encode:(e)=>{if(!(e instanceof Uint8Array))throw Error("radix.encode input should be Uint8Array");return Qc(Array.from(e),256,r)},decode:(e)=>{if(!Array.isArray(e)||e.length&&typeof e[0]!=="number")throw Error("radix.decode input should be array of strings");return Uint8Array.from(Qc(e,r,256))}}}function Sr(r,e=!1){if(Pr(r),r<=0||r>32)throw Error("radix2: bits should be in (0..32]");if(En(8,r)>32||En(r,8)>32)throw Error("radix2: carry overflow");return{encode:(n)=>{if(!(n instanceof Uint8Array))throw Error("radix2.encode input should be Uint8Array");return ie(Array.from(n),8,r,!e)},decode:(n)=>{if(!Array.isArray(n)||n.length&&typeof n[0]!=="number")throw Error("radix2.decode input should be array of strings");return Uint8Array.from(ie(n,r,8,e))}}}function qc(r){if(typeof r!=="function")throw Error("unsafeWrapper fn should be function");return function(...e){try{return r.apply(null,e)}catch(n){}}}var G0=Qr(Sr(4),qr("0123456789ABCDEF"),Tr("")),E0=Qr(Sr(5),qr("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"),Kn(5),Tr("")),Ai=Qr(Sr(5),qr("0123456789ABCDEFGHIJKLMNOPQRSTUV"),Kn(5),Tr("")),di=Qr(Sr(5),qr("0123456789ABCDEFGHJKMNPQRSTVWXYZ"),Tr(""),Ec((r)=>r.toUpperCase().replace(/O/g,"0").replace(/[IL]/g,"1"))),Wn=Qr(Sr(6),qr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"),Kn(6),Tr("")),K0=Qr(Sr(6),qr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"),Kn(6),Tr("")),Je=(r)=>Qr(M0(58),qr(r),Tr("")),$e=Je("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"),Ui=Je("123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"),gi=Je("rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"),Tc=[0,2,3,5,6,7,9,10,11],W0={encode(r){let e="";for(let n=0;n>25,n=(r&33554431)<<5;for(let c=0;c>c&1)===1)n^=Mc[c];return n}function Gc(r,e,n=1){let c=r.length,f=1;for(let w=0;w126)throw Error(`Invalid prefix (${r})`);f=br(f)^i>>5}f=br(f);for(let w=0;wM)throw TypeError(`Length ${K} exceeds limit ${M}`);return J=J.toLowerCase(),`${J}1${le.encode(Q)}${Gc(J,Q,e)}`}function l(J,Q=90){if(typeof J!=="string")throw Error(`bech32.decode input should be string, not ${typeof J}`);if(J.length<8||Q!==!1&&J.length>Q)throw TypeError(`Wrong string length: ${J.length} (${J}). Expected (8..${Q})`);let M=J.toLowerCase();if(J!==M&&J!==J.toUpperCase())throw Error("String must be lowercase or uppercase");J=M;let K=J.lastIndexOf("1");if(K===0||K===-1)throw Error('Letter "1" must be present between prefix and data only');let z=J.slice(0,K),q=J.slice(K+1);if(q.length<6)throw Error("Data must be at least 6 characters long");let G=le.decode(q).slice(0,-6),E=Gc(z,G,e);if(!q.endsWith(E))throw Error(`Invalid checksum in ${J}: expected "${E}"`);return{prefix:z,words:G}}let $=qc(l);function I(J){let{prefix:Q,words:M}=l(J,!1);return{prefix:Q,words:M,bytes:c(M)}}return{encode:i,decode:l,decodeToBytes:I,decodeUnsafe:$,fromWords:c,fromWordsUnsafe:w,toWords:f}}var kr=Wc("bech32"),Oi=Wc("bech32m"),N0={encode:(r)=>new TextDecoder().decode(r),decode:(r)=>new TextEncoder().encode(r)},S0=Qr(Sr(4),qr("0123456789abcdef"),Tr(""),Ec((r)=>{if(typeof r!=="string"||r.length%2)throw TypeError(`hex.decode: expected string, got ${typeof r} with length ${r.length}`);return r.toLowerCase()})),X0={utf8:N0,hex:S0,base16:G0,base32:E0,base64:Wn,base64url:K0,base58:$e,base58xmr:W0},Zi=`Invalid encoding type. Available types: ${Object.keys(X0).join(", ")}`;var Y0=new TextDecoder("utf-8"),A0=new TextEncoder,Nc=1,Sc=65535;function Xc(r,e){let n=jr.getSharedSecret(r,"02"+e).subarray(1,33);return Ic(ir,n,"nip44-v2")}function Yc(r,e){let n=zc(ir,r,e,76);return{chacha_key:n.subarray(0,32),chacha_nonce:n.subarray(32,44),hmac_key:n.subarray(44,76)}}function Ie(r){if(!Number.isSafeInteger(r)||r<1)throw Error("expected positive integer");if(r<=32)return 32;let e=1<Sc)throw Error("invalid plaintext size: must be between 1 and 65535 bytes");let e=new Uint8Array(2);return new DataView(e.buffer).setUint16(0,r,!1),e}function U0(r){let e=A0.encode(r),n=e.length,c=d0(n),f=new Uint8Array(Ie(n)-n);return Hr(c,e,f)}function g0(r){let e=new DataView(r.buffer).getUint16(0),n=r.subarray(2,2+e);if(eSc||n.length!==e||r.length!==2+Ie(e))throw Error("invalid padding");return Y0.decode(n)}function Ac(r,e,n){if(n.length!==32)throw Error("AAD associated data must be 32 bytes");let c=Hr(n,e);return Rr(ir,r,c)}function O0(r){if(typeof r!=="string")throw Error("payload must be a valid string");let e=r.length;if(e<132||e>87472)throw Error("invalid payload length: "+e);if(r[0]==="#")throw Error("unknown encryption version");let n;try{n=Wn.decode(r)}catch(w){throw Error("invalid base64: "+w.message)}let c=n.length;if(c<99||c>65603)throw Error("invalid data length: "+c);let f=n[0];if(f!==2)throw Error("unknown encryption version "+f);return{nonce:n.subarray(1,33),ciphertext:n.subarray(33,-32),mac:n.subarray(-32)}}function dc(r,e,n=ke(32)){let{chacha_key:c,chacha_nonce:f,hmac_key:w}=Yc(e,n),i=U0(r),l=Gn(c,f,i),$=Ac(w,l,n);return Wn.encode(Hr(new Uint8Array([2]),n,l,$))}function Uc(r,e){let{nonce:n,ciphertext:c,mac:f}=O0(r),{chacha_key:w,chacha_nonce:i,hmac_key:l}=Yc(e,n),$=Ac(l,c,n);if(!Tn($,f))throw Error("invalid MAC");let I=Gn(w,i,c);return g0(I)}var Z0={utils:{getConversationKey:Xc,calcPaddedLen:Ie},encrypt:dc,decrypt:Uc};var Dc={};mr(Dc,{minePow:()=>V0,getPow:()=>Oc,fastEventHash:()=>Zc});var Li=new TextDecoder("utf-8"),D0=new TextEncoder;function Oc(r){let e=0;for(let n=0;n<64;n+=8){let c=parseInt(r.substring(n,n+8),16);if(c===0)e+=32;else{e+=Math.clz32(c);break}}return e}function V0(r,e){let n=0,c=r,f=["nonce",n.toString(),e.toString()];c.tags.push(f);while(!0){let w=Math.floor(new Date().getTime()/1000);if(w!==c.created_at)n=0,c.created_at=w;if(f[1]=(++n).toString(),c.id=Zc(c),Oc(c.id)>=e)break}return c}function Zc(r){return L(ir(D0.encode(JSON.stringify([0,r.pubkey,r.created_at,r.kind,r.tags,r.content]))))}var Vc={};mr(Vc,{nsecEncode:()=>P0,npubEncode:()=>B0,nprofileEncode:()=>o0,noteEncode:()=>v0,neventEncode:()=>C0,naddrEncode:()=>L0,encodeBytes:()=>Yn,decode:()=>R0,NostrTypeGuard:()=>j0,Bech32MaxSize:()=>Qe,BECH32_REGEX:()=>H0});var Nn=new TextDecoder("utf-8"),Sn=new TextEncoder,j0={isNProfile:(r)=>/^nprofile1[a-z\d]+$/.test(r||""),isNEvent:(r)=>/^nevent1[a-z\d]+$/.test(r||""),isNAddr:(r)=>/^naddr1[a-z\d]+$/.test(r||""),isNSec:(r)=>/^nsec1[a-z\d]{58}$/.test(r||""),isNPub:(r)=>/^npub1[a-z\d]{58}$/.test(r||""),isNote:(r)=>/^note1[a-z\d]+$/.test(r||""),isNcryptsec:(r)=>/^ncryptsec1[a-z\d]+$/.test(r||"")},Qe=5000,H0=/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/;function u0(r){let e=new Uint8Array(4);return e[0]=r>>24&255,e[1]=r>>16&255,e[2]=r>>8&255,e[3]=r&255,e}function R0(r){let{prefix:e,words:n}=kr.decode(r,Qe),c=new Uint8Array(kr.fromWords(n));switch(e){case"nprofile":{let f=ze(c);if(!f[0]?.[0])throw Error("missing TLV 0 for nprofile");if(f[0][0].length!==32)throw Error("TLV 0 should be 32 bytes");return{type:"nprofile",data:{pubkey:L(f[0][0]),relays:f[1]?f[1].map((w)=>Nn.decode(w)):[]}}}case"nevent":{let f=ze(c);if(!f[0]?.[0])throw Error("missing TLV 0 for nevent");if(f[0][0].length!==32)throw Error("TLV 0 should be 32 bytes");if(f[2]&&f[2][0].length!==32)throw Error("TLV 2 should be 32 bytes");if(f[3]&&f[3][0].length!==4)throw Error("TLV 3 should be 4 bytes");return{type:"nevent",data:{id:L(f[0][0]),relays:f[1]?f[1].map((w)=>Nn.decode(w)):[],author:f[2]?.[0]?L(f[2][0]):void 0,kind:f[3]?.[0]?parseInt(L(f[3][0]),16):void 0}}}case"naddr":{let f=ze(c);if(!f[0]?.[0])throw Error("missing TLV 0 for naddr");if(!f[2]?.[0])throw Error("missing TLV 2 for naddr");if(f[2][0].length!==32)throw Error("TLV 2 should be 32 bytes");if(!f[3]?.[0])throw Error("missing TLV 3 for naddr");if(f[3][0].length!==4)throw Error("TLV 3 should be 4 bytes");return{type:"naddr",data:{identifier:Nn.decode(f[0][0]),pubkey:L(f[2][0]),kind:parseInt(L(f[3][0]),16),relays:f[1]?f[1].map((w)=>Nn.decode(w)):[]}}}case"nsec":return{type:e,data:c};case"npub":case"note":return{type:e,data:L(c)};default:throw Error(`unknown prefix ${e}`)}}function ze(r){let e={},n=r;while(n.length>0){let c=n[0],f=n[1],w=n.slice(2,2+f);if(n=n.slice(2+f),w.lengthSn.encode(n))});return Xn("nprofile",e)}function C0(r){let e;if(r.kind!==void 0)e=u0(r.kind);let n=qe({0:[gr(r.id)],1:(r.relays||[]).map((c)=>Sn.encode(c)),2:r.author?[gr(r.author)]:[],3:e?[new Uint8Array(e)]:[]});return Xn("nevent",n)}function L0(r){let e=new ArrayBuffer(4);new DataView(e).setUint32(0,r.kind,!1);let n=qe({0:[Sn.encode(r.identifier)],1:(r.relays||[]).map((c)=>Sn.encode(c)),2:[gr(r.pubkey)],3:[new Uint8Array(e)]});return Xn("naddr",n)}function qe(r){let e=[];return Object.entries(r).reverse().forEach(([n,c])=>{c.forEach((f)=>{let w=new Uint8Array(f.length+2);w.set([parseInt(n)],0),w.set([f.length],1),w.set(f,2),e.push(w)})}),Hr(...e)}export{e0 as verifyEvent,ir as sha256,jr as secp256k1,gc as nip44,Vc as nip19,Dc as nip13,r0 as getPublicKey,pf as generateSecretKey,n0 as finalizeEvent,tf as SimplePool,Fe as AbstractSimplePool}; diff --git a/assets/nostr-tools-entry.js b/assets/nostr-tools-entry.js index a76bd3d..65006ce 100644 --- a/assets/nostr-tools-entry.js +++ b/assets/nostr-tools-entry.js @@ -3,7 +3,7 @@ // We re-export only the small surface lnproxy needs so the committed bundle in // assets/nostr-bundle.js stays small and auditable. Rebuild it with: // bun install && bun run build:nostr -export { SimplePool } from "nostr-tools/pool"; +export { AbstractSimplePool, SimplePool } from "nostr-tools/pool"; export { verifyEvent, finalizeEvent, getPublicKey, generateSecretKey } from "nostr-tools/pure"; export * as nip44 from "nostr-tools/nip44"; export * as nip13 from "nostr-tools/nip13"; diff --git a/assets/nostr.js b/assets/nostr.js index f5fad93..1aa3d23 100644 --- a/assets/nostr.js +++ b/assets/nostr.js @@ -7,7 +7,7 @@ // // Crypto comes from the vendored, audited nostr-tools bundle. import { - SimplePool, + AbstractSimplePool, verifyEvent, finalizeEvent, getPublicKey, @@ -220,6 +220,8 @@ export function parseOffer(event, { network = "mainnet", now = Date.now(), minPo if (event.created_at < nowSeconds - OFFER_MAX_AGE_SECONDS) return null; if (event.created_at > nowSeconds + OFFER_FUTURE_SKEW_SECONDS) return null; const expiration = exactTagValue(event, "expiration"); + const expirationTags = event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "expiration"); + if (expirationTags.length > 1 || (expirationTags.length === 1 && expirationTags[0].length !== 2)) return null; let expires_at = null; if (expiration !== undefined) { expires_at = toInt(expiration); @@ -409,7 +411,8 @@ function exactTagValue(event, name) { function offerEnvelopeAcceptable(event, network, minPow) { if (!event || event.kind !== KIND_OFFER) return false; - if (!/^[0-9a-f]{64}$/.test(event.id || "") || !/^[0-9a-f]{64}$/.test(event.pubkey || "")) return false; + if (!/^[0-9a-f]{64}$/.test(event.id || "") || !/^[0-9a-f]{64}$/.test(event.pubkey || "") || + !/^[0-9a-f]{128}$/.test(event.sig || "")) return false; if (typeof event.content !== "string" || event.content.length > MAX_OFFER_BYTES) return false; if (!Array.isArray(event.tags) || event.tags.length > MAX_OFFER_TAGS) return false; if (exactTagValue(event, "d") !== PROTOCOL_VERSION || exactTagValue(event, "n") !== network) return false; @@ -420,6 +423,34 @@ function offerEnvelopeAcceptable(event, network, minPow) { nip13.getPow(event.id) >= committedPow; } +function eventEnvelopeWithinBounds(event) { + if (!event || !/^[0-9a-f]{64}$/.test(event.id || "") || + !/^[0-9a-f]{64}$/.test(event.pubkey || "") || !/^[0-9a-f]{128}$/.test(event.sig || "")) return false; + if (typeof event.content !== "string" || event.content.length > MAX_NOSTR_RESPONSE_BYTES) return false; + if (!Array.isArray(event.tags) || event.tags.length > MAX_OFFER_TAGS) return false; + let tagBytes = 0; + for (const tag of event.tags) { + if (!Array.isArray(tag) || tag.length === 0 || tag.length > 8) return false; + for (const item of tag) { + if (typeof item !== "string") return false; + tagBytes += item.length; + if (tagBytes > 4096) return false; + } + } + return true; +} + +function boundedPool(envelopeVerifier, maxEvents) { + let events = 0; + return new AbstractSimplePool({ + websocketImplementation: globalThis.WebSocket, + verifyEvent(event) { + if (events++ >= maxEvents) return false; + return envelopeVerifier(event) && verifyEvent(event); + }, + }); +} + function toInt(v) { if (v === undefined || v === null) return null; if (typeof v === "string" && !/^(0|[1-9][0-9]*)$/.test(v)) return null; @@ -576,7 +607,10 @@ export async function discoverOffers(relays, { network = "mainnet", timeoutMs = since, }; const connections = relays.map((relayURL) => { - const pool = new SimplePool(); + const pool = boundedPool( + (event) => offerEnvelopeAcceptable(event, network, minPow), + MAX_DISCOVERY_EVENTS_PER_RELAY, + ); let processedEvents = 0; let sub; const close = () => { @@ -609,15 +643,6 @@ export async function discoverOffers(relays, { network = "mainnet", timeoutMs = close(); return; } - if (!offerEnvelopeAcceptable(event, network, minPow)) return; - if (!verifyEvent(event)) { - diagnostics.warn("discovery.event.invalid-signature", { - relay: relayURL, - eventID: shortID(event.id), - pubkey: shortID(event.pubkey), - }); - return; - } const offer = parseOffer(event, { network, minPow }); if (!offer) return; const prev = byPubkey.get(offer.pubkey); @@ -705,6 +730,8 @@ export async function wrapViaNostr(offer, request, { relays, timeoutMs = 30000 } actualPow: nip13.getPow(unsigned.id), durationMs: Math.round(performance.now() - miningStartedAt), }); + } else { + unsigned.tags.push(["nonce", "0", "0"]); } const event = finalizeEvent(unsigned, sk); const remainingMs = Math.ceil(deadline - performance.now()); @@ -715,7 +742,7 @@ export async function wrapViaNostr(offer, request, { relays, timeoutMs = 30000 } createdAt: event.created_at, }); - const pool = new SimplePool(); + const pool = boundedPool(eventEnvelopeWithinBounds, 128); return new Promise((resolve, reject) => { let settled = false; let timer; diff --git a/test/invoice.test.js b/test/invoice.test.js index 0afc81f..f7aed17 100644 --- a/test/invoice.test.js +++ b/test/invoice.test.js @@ -28,9 +28,9 @@ function checksum(hrp, data) { return Array.from({ length: 6 }, (_, i) => CHARSET[(mod >>> (5 * (5 - i))) & 31]).join(""); } -function syntheticInvoice(prefix, amount = "10u") { +function syntheticInvoice(prefix, amount = "10u", extraTags = "") { const hrp = `${prefix}${amount}`; - const data = `${"q".repeat(7)}pp5${"q".repeat(52)}dqq${"q".repeat(104)}`; + const data = `${"q".repeat(7)}pp5${"q".repeat(52)}dqq${extraTags}${"q".repeat(104)}`; return `${hrp}1${data}${checksum(hrp, data)}`; } @@ -73,6 +73,11 @@ test("parseInvoice rejects a structurally valid invoice without a payment hash", expect(() => parseInvoice(invoice)).toThrow("payment hash"); }); +test("parseInvoice rejects duplicate description fields", () => { + const invoice = syntheticInvoice("lntbs", "10u", "dqq"); + expect(() => parseInvoice(invoice)).toThrow("duplicate BOLT11 description"); +}); + test("parseInvoice recovers the payee from an official BOLT11 vector", () => { const invoice = "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql"; const parsed = parseInvoice(validInvoice(invoice, "mainnet")); diff --git a/test/nostr.test.js b/test/nostr.test.js index e9defc0..9073044 100644 --- a/test/nostr.test.js +++ b/test/nostr.test.js @@ -125,6 +125,19 @@ test("parseOffer rejects stale offers", () => { expect(parseOffer(evt)).toBeNull(); }); +test("parseOffer rejects duplicate expiration tags", () => { + const event = makeOfferEvent({ + tags: [ + ["d", PROTOCOL_VERSION], + ["n", "mainnet"], + ["expiration", String(Math.floor(Date.now() / 1000) + 600)], + ["expiration", String(Math.floor(Date.now() / 1000) + 1200)], + ["nonce", "0", "0"], + ], + }); + expect(parseOffer(event)).toBeNull(); +}); + test("parseOffer rejects invalid content", () => { const evt = makeOfferEvent({}, {}); evt.content = "not json"; From 937f597f18ebd12c2a0385e4f5bd554db6e59b82 Mon Sep 17 00:00:00 2001 From: m0wer Date: Thu, 16 Jul 2026 10:12:53 +0200 Subject: [PATCH 11/11] fix: pin e2e relay revision --- .github/workflows/webui.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/webui.yml b/.github/workflows/webui.yml index 0355511..f2bf159 100644 --- a/.github/workflows/webui.yml +++ b/.github/workflows/webui.yml @@ -49,6 +49,7 @@ jobs: - uses: actions/checkout@v4 with: repository: m0wer/lnproxy-relay + ref: nostr path: lnproxy-relay - uses: oven-sh/setup-bun@v2 with: @@ -63,8 +64,13 @@ jobs: if docker logs lnproxy-e2e-lnproxy 2>&1 | grep -q "published offer"; then echo "lnproxy is advertising"; break fi + if [ "$(docker inspect -f '{{.State.Status}}' lnproxy-e2e-lnproxy)" = "exited" ]; then + docker logs lnproxy-e2e-lnproxy + exit 1 + fi sleep 5 done + docker logs lnproxy-e2e-lnproxy 2>&1 | grep -q "published offer" - name: install playwright working-directory: lnproxy-webui2/e2e/playwright run: |