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 @@
-
-
+
+
+
-
-
-
+