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..f2bf159 --- /dev/null +++ b/.github/workflows/webui.yml @@ -0,0 +1,129 @@ +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 + # DELETE ME after lnproxy/lnproxy-relay includes its root Dockerfile. + - uses: actions/checkout@v4 + with: + repository: m0wer/lnproxy-relay + ref: nostr + 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 + 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: | + 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/.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/.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..044842a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,127 @@ # lnproxy-webui2 -New webui for lnproxy +Static web UI for decentralized lnproxy provider discovery. -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 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. -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 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 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 +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"], + "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). +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 +`?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), 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 + +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 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 + # 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/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..ba2c7b3 --- /dev/null +++ b/assets/discovery.js @@ -0,0 +1,85 @@ +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(",") + .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); + 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/invoice.js b/assets/invoice.js new file mode 100644 index 0000000..17528e6 --- /dev/null +++ b/assets/invoice.js @@ -0,0 +1,249 @@ +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 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)); + 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 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 hasDescription = 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"); + 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") { + 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}`; + } 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"); + if (!hasDescription) throw new Error("missing BOLT11 description"); + 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); + spanned += `${signature}`; + spanned += invoice.slice(-6); + return { + msat_amount: amount, + timestamp, + expiry_seconds: expirySeconds, + expires_at: expiresAt, + hash, + description, + description_hash: descriptionHash, + signature, + destination, + as_spans: spanned, + }; +} + +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 = []; + 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)); +} + +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 70d16f2..2f2d3e9 100644 --- a/assets/main.js +++ b/assets/main.js @@ -1,341 +1,644 @@ -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, + invoiceDescriptionMatches, + invoiceIsPayable, + parseInvoice, + validInvoice, +} from "./invoice.js"; +import { invoiceNetwork, invoicePrefix, normalizeNetwork } from "./network.js"; +import { + discoverOffers, + effectiveFeeMsat, + feeWithinAdvertised, + normalizePubkey, + offerIsFresh, + selectOffers, +} from "./nostr.js"; +import { FEATURE_REQUEST_ID_V1, wrapProvider } from "./transport.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 wrapStatus = document.getElementById("wrap_status"); +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-bound"; + badge.title = `Offer key is signed by ${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 ["", "Provider fee", "Limits (sat)", "Credential", "PoW", "Transport", "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)); + 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); } -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; + } + + 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; + } -function wrapInvoice() { - wrapButton.disabled = true; - loading.style.display = "inline-block"; - const invoice = validInvoice(formInvoice.value); + 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; + } + 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"); + 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 candidateOffers = state.offers; + 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; + } + 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) { + 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 = offers[0]; + 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: "cheapest-identity", + 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 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; } - - 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(); + } +} + +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; + } + 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"; + 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; + } + if (!invoiceIsPayable(parsedInvoice)) { + appendError("The original invoice is expired or expires too soon."); + return; + } - 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(); + 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, + }); + + 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}`); - 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(); + try { + const response = await wrapProvider(offer, data, { + relays: requestRelays, + directTimeoutMs: state.config.directTimeoutMs, + 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", + }); + 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; + } + 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"; + 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 (!invoiceDescriptionMatches(parsedInvoice, parsedProxyInvoice, 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 ""; + wrapStatus.textContent = " Contacting provider..."; + 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..eebc79d --- /dev/null +++ b/assets/nostr-bundle.js @@ -0,0 +1 @@ +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-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..65006ce --- /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 { 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"; +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..1aa3d23 --- /dev/null +++ b/assets/nostr.js @@ -0,0 +1,872 @@ +// 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 { + AbstractSimplePool, + 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; +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_PER_RELAY = 128; +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 = [ + "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(), minPow = 0 } = {}) { + if (!event || event.kind !== KIND_OFFER) return null; + 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; + if (!Number.isSafeInteger(event.created_at)) return null; + 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"); + 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); + if (expires_at === null || expires_at <= nowSeconds) return null; + } + + const actual_pow = nip13.getPow(event.id); + 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 || committed_pow < minPow || actual_pow < committed_pow) return null; + + let content; + try { + content = JSON.parse(event.content); + } 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); + 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, MAX_OFFER_FEATURES, 64), + relays: relayURLs(content.relays), + urls: directURLs(content.urls), + node_pubkey, + node_sig, + attested, + pow_nonce, + identity_pow_bits, + created_at: event.created_at, + expires_at, + event_id: event.id, + 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) { + 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 +// 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 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)); +} + +// 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, now = Date.now() } = {}) { + const ranked = offers + .filter((o) => o.committed_pow >= minPow) + .filter((o) => canServe(o, amountMsat, wrap, now)) + .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; + } + 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; + }); +} + +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 +// 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, + routingAllowanceMsat = DEFAULT_ROUTING_ALLOWANCE_MSAT, +) { + const surplus = proxyMsat - originalMsat; + if (surplus < 0) return false; + if (routingMsat !== undefined && routingMsat !== null) { + return surplus === routingMsat; + } + const advertised = effectiveFeeMsat(offer, originalMsat); + 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 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 || "") || + !/^[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; + 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 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; + const n = typeof v === "string" ? Number(v) : v; + return Number.isSafeInteger(n) && n >= 0 ? n : null; +} + +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) { + const result = []; + for (const relay of stringArray(value, MAX_RELAYS * 2, 2048)) { + try { + const url = new URL(relay); + 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) { + continue; + } + } + return result; +} + +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 +// --------------------------------------------------------------------------- + +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, minPow = 0 } = {}) { + 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 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 connections = relays.map((relayURL) => { + const pool = boundedPool( + (event) => offerEnvelopeAcceptable(event, network, minPow), + MAX_DISCOVERY_EVENTS_PER_RELAY, + ); + let processedEvents = 0; + let sub; + const close = () => { + try { + sub?.close(); + } catch (_e) { + /* ignore */ + } + try { + pool.close([relayURL]); + } catch (_e) { + /* ignore */ + } + }; + 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; + } + 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 = {}; + 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", { + 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 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", { + 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, { deadline }); + diagnostics.info("wrap.pow.completed", { + difficulty: target, + 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()); + if (remainingMs <= 0) throw new Error("nostr wrap request timed out"); + diagnostics.info("wrap.request.ready", { + eventID: event.id, + provider: offer.pubkey, + createdAt: event.created_at, + }); + + const pool = boundedPool(eventEnvelopeWithinBounds, 128); + 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")), remainingMs); + 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 (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, + requestEventID: event.id, + })) { + diagnostics.warn("wrap.response.invalid-event", { + eventID: shortID(resp.id), + pubkey: shortID(resp.pubkey), + }); + return; + } + 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", + }); + 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, 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; + 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 []; + const set = new Set(clientRelays); + const common = offerRelays.filter((r) => set.has(r)); + return common.slice(0, MAX_RELAYS); +} + +// 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/assets/transport.js b/assets/transport.js new file mode 100644 index 0000000..397d31d --- /dev/null +++ b/assets/transport.js @@ -0,0 +1,210 @@ +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; +const MIN_DIRECT_RETRY_MS = 100; + +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); +} + +export function validateProtocolResponse(response, requestID = "") { + if (!response || typeof response !== "object" || Array.isArray(response)) { + throw new DirectTransportError("provider response was not a JSON object"); + } + 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; +} + +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 validateProtocolResponse(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 = { + ...request, + provider_pubkey: offer.pubkey, + ...(requestID ? { request_id: requestID } : {}), + }; + let directError = null; + const directDeadline = performance.now() + Math.max(0, directTimeoutMs); + + if (idempotent) { + for (const [index, endpoint] of (offer.urls || []).slice(0, MAX_DIRECT_URLS).entries()) { + const remainingMs = Math.ceil(directDeadline - performance.now()); + if (remainingMs <= 0 || (index > 0 && remainingMs < MIN_DIRECT_RETRY_MS)) { + 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 = validateProtocolResponse(response, requestID); + 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/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/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml new file mode 100644 index 0000000..3e3b891 --- /dev/null +++ b/e2e/docker-compose.e2e.yml @@ -0,0 +1,171 @@ +# 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 + - --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 + - --urls=http://127.0.0.1:4747/spec + - --min-request-pow=0 + - --announce-pow=0 + - --min-msat=1000 + - --max-msat=1000000000 + - --nostr-key=/shared/nostr.key + - /shared/admin.macaroon + volumes: + - shared:/shared + ports: + - "127.0.0.1:4747:4747" + + webui: + build: + context: .. + image: lnproxy-webui:e2e + container_name: lnproxy-e2e-webui + ports: + - "${WEBUI_PORT:-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..5bc49b3 --- /dev/null +++ b/e2e/playwright/specs/wrap.spec.ts @@ -0,0 +1,334 @@ +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 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); + 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.transport.completed", + "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).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 +// 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" diff --git a/index.html b/index.html index 0d2e196..0292f48 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... + + Contacting provider...
- - -
- 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..f7aed17 --- /dev/null +++ b/test/invoice.test.js @@ -0,0 +1,102 @@ +import { expect, test } from "bun:test"; +import { invoiceDescriptionMatches, invoiceIsPayable, 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", extraTags = "") { + const hrp = `${prefix}${amount}`; + const data = `${"q".repeat(7)}pp5${"q".repeat(52)}dqq${extraTags}${"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); + 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", () => { + 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 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")); + 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); +}); + +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); +}); diff --git a/test/network.test.js b/test/network.test.js new file mode 100644 index 0000000..c165918 --- /dev/null +++ b/test/network.test.js @@ -0,0 +1,82 @@ +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"]); + expect(config.directTimeoutMs).toBe(10000); +}); + +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"]); +}); + +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/nostr.test.js b/test/nostr.test.js new file mode 100644 index 0000000..9073044 --- /dev/null +++ b/test/nostr.test.js @@ -0,0 +1,444 @@ +import { test, expect } from "bun:test"; +import { + parseOffer, + effectiveFeeMsat, + wrapFeature, + canServe, + selectOffers, + providerIdentity, + credentialRank, + feeWithinAdvertised, + verifyAttestation, + attestationMessage, + normalizePubkey, + announcePoWBits, + KIND_OFFER, + PROTOCOL_VERSION, + MAX_REQUEST_POW, + validResponseEvent, + minePowAsync, +} from "../assets/nostr.js"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + nip19, + secp256k1, + sha256, +} 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"], + ["nonce", "0", "0"], + ], + 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 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"; + expect(parseOffer(evt)).toBeNull(); + evt.content = "null"; + 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/"]); +}); + +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("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 () => { + 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("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); + 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); + 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 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; + expect(feeWithinAdvertised(offer, original, original + 3000, 3000)).toBe(true); + expect(feeWithinAdvertised(offer, original, original + 2999, 3000)).toBe(false); +}); diff --git a/test/transport.test.js b/test/transport.test.js new file mode 100644 index 0000000..b127be6 --- /dev/null +++ b/test/transport.test.js @@ -0,0 +1,303 @@ +import { expect, test } from "bun:test"; + +import { directURLs, MAX_DIRECT_URLS, normalizeDirectURL } from "../assets/nostr.js"; +import { + FEATURE_REQUEST_ID_V1, + generateRequestID, + validateProtocolResponse, + 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", () => { + 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)); +}); + +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; + 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 () => { + nostrCalls++; + return {}; + }, + }); + expect(response.proxy_invoice).toBe("direct"); + expect(directRequest.provider_pubkey).toBe(provider); + 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(); + expect(nostrRequest.provider_pubkey).toBe(provider); +}); + +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"); +}); + +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); +});