From 3de653928d5a0967899bd455b2fe0c0141b78836 Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 10 Jun 2026 11:00:09 +0200 Subject: [PATCH 01/10] docs: specify decentralized provider discovery over nostr Add nostr.md describing addressable offer events (kind 38421), encrypted wrap request/response (kinds 21821/21822), NIP-13 request proof of work, optional LN node attestation and anonymous identity proof of work, per-provider fees and limits, cheapest-first client selection, and configurable nostr relays with working defaults. Document the privacy model (provider sees the invoice it pays but not the payer; cf. Kappos et al., USENIX Security 2021) and reuse the base protocol request/response bodies so HTTP and nostr share an implementation. --- README.md | 11 ++ nostr.md | 369 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 nostr.md diff --git a/README.md b/README.md index 84c7688..5a8e286 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,17 @@ A user may want to use a proxy destination for an invoice, either because the us Then, once the user verifies the amount and payment hash, the user can use the proxy invoice wherever the original invoice would have been used and know that the only way for the payment to succeed is for the lnproxy relay to pay the original invoice. +## Decentralized provider discovery over nostr + +Historically a user had to already know a relay's URL. [nostr.md](nostr.md) +specifies an optional discovery and transport layer over +[nostr](https://github.com/nostr-protocol/nips): providers advertise themselves +(with their own fees and limits) as replaceable events, clients filter and rank +them cheapest-first, and the wrap request/response is carried over encrypted +nostr messages with proof-of-work spam protection. The request and response +bodies are identical to the HTTP API below, so the two transports share an +implementation. + ## Requesting and verifying a proxy invoice 1. User makes a POST request to a relay like: diff --git a/nostr.md b/nostr.md new file mode 100644 index 0000000..8dbe0ce --- /dev/null +++ b/nostr.md @@ -0,0 +1,369 @@ +draft: lnproxy provider discovery over nostr. +============================================= + +`author: lnproxy` `status: draft` `kinds: 38421, 21821, 21822` + +--- + +This document specifies how lnproxy providers (makers) advertise themselves on +[nostr](https://github.com/nostr-protocol/nips) and how clients discover them, +select one, and request a proxy invoice without a central directory. It builds +on the base lnproxy protocol described in [README.md](README.md): the +request and response bodies are identical, only the transport and the discovery +layer are new. + +The design follows the same shape as Electrum's submarine-swap provider +discovery: providers publish a replaceable "offer" event, clients filter and +rank the offers, and the actual request/response happens over encrypted nostr +messages. lnproxy differs in that it lets each provider set its own fees and +limits and lets clients enforce them trustlessly against the returned invoice. + +## Why decentralized discovery is safe + +In the base protocol the client always verifies the proxy invoice it receives +(same payment hash, matching description, bounded extra amount), so a malicious +provider can only refuse to serve or fail to pay, never steal funds. That +property is unchanged here. Discovery therefore does not need to be trusted: a +client can pick any advertised provider, attempt a wrap, and reject the result +if it does not verify. The remaining problems are (a) preventing advertisement +and request spam, and (b) being explicit about what a provider learns. + +### What a provider can and cannot see + +A provider necessarily learns the original invoice it is asked to pay: its +**destination node, amount, and description/memo** (unless the client strips or +replaces the description). A provider does **not** learn who is paying or where +the payment originates: the client pays the proxy invoice from somewhere else +on the Lightning Network, so from the provider's point of view the payment +arrives like any other inbound HTLC. + +This asymmetry is the whole point of lnproxy and it matches the empirical +findings of Kappos et al., *"An Empirical Analysis of Privacy in the Lightning +Network"* (USENIX Security 2021): the information that meaningfully +deanonymizes Lightning payments is on the **destination/path** side, while the +origin of a payment is comparatively well hidden. lnproxy moves the +destination-side exposure from the payer's wallet/custodian to a provider that +does not know the payer. + +Two consequences are spelled out as requirements below: + +- Clients SHOULD connect to nostr relays over Tor or another anonymizing + transport, because the nostr relay (not the provider) sees the client's IP. +- Providers MAY hide their own node by advertising only blinded-path or BOLT12 + capabilities (see [Features](#features-and-negotiation)); in that case the + proxy invoice does not reveal the provider's node id. Note that a curious + client can always forge an invoice to a known destination and ask a provider + to wrap it, so node-pubkey privacy is only meaningful for providers that + never reveal their node id in any proxy invoice. + +## Event kinds + +| kind | type | purpose | +|-------|-------------|-------------------------------------------| +| 38421 | addressable | provider offer / advertisement | +| 21821 | ephemeral | encrypted wrap request (client -> provider)| +| 21822 | ephemeral | encrypted wrap response (provider -> client)| + +Kind 38421 is in the addressable range (30000-39999, NIP-01): relays keep only +the latest event per `(pubkey, d-tag)`, so each provider has exactly one live +offer per network. Kinds 21821/21822 are in the ephemeral range +(20000-29999): relays are not expected to store them, which suits short-lived +RPC traffic. + +## Provider offer (kind 38421) + +A provider publishes a kind 38421 event and re-publishes it periodically +(RECOMMENDED every 10 minutes, and whenever its terms change). Stale offers are +ignored by clients, so a provider that goes offline disappears from discovery +within roughly an hour. + +### Tags + +```jsonc +[ + ["d", "lnproxy-v1"], // protocol version, filterable via #d + ["n", "mainnet"], // network: mainnet|testnet|signet|regtest, filterable via #n + ["expiration", "1718000000"] // NIP-40 unix timestamp; RECOMMENDED now + ~15 min +] +``` + +The `d` tag carries the protocol version so that incompatible future versions +can coexist on the same relays. Clients MUST filter on both `#d` and `#n`. + +### Content + +The `content` field is a JSON object. All amounts are in millisatoshis +(strings or numbers both accepted; strings RECOMMENDED to avoid float issues). + +```jsonc +{ + "base_fee_msat": 1000, // provider flat fee + "fee_ppm": 1000, // provider proportional fee (parts per million) + "min_amount_msat": 10000, // smallest original-invoice amount accepted + "max_amount_msat": 1000000000, // largest original-invoice amount accepted + "max_expiry_seconds": 604800, // longest proxy-invoice expiry offered + "min_request_pow": 20, // minimum NIP-13 difficulty required on requests + "features": [ // see "Features and negotiation" + "pay_bolt11", "pay_bolt11_blinded", "wrap_bolt11" + ], + "relays": [ // nostr relays the provider listens on for requests + "wss://relay.example.com" + ], + "urls": [ // OPTIONAL legacy HTTP/onion endpoints (base protocol) + "https://lnproxy.example.com/spec" + ], + "node_pubkey": "02...", // OPTIONAL, hex compressed LN identity pubkey + "node_sig": "", // OPTIONAL, see "Node attestation" + "pow_nonce": "0x..." // OPTIONAL, see "Anonymous identity proof of work" +} +``` + +`base_fee_msat`, `fee_ppm`, `min_amount_msat` and `max_amount_msat` are the +per-provider knobs requested by operators: each provider sets its own fees and +amount limits. They are advertised so clients can compare and pre-filter, and +they are enforced trustlessly by clients against the returned proxy invoice +(see [Client algorithm](#client-algorithm)). + +Clients MUST ignore unknown content fields and unknown `features` entries so +that the format can be extended. + +## Anti-spam: proof of work and identity + +Three independent mechanisms are combined. Only the first is mandatory. + +### 1. NIP-13 proof of work on every event (rate limiting, REQUIRED) + +Every 38421, 21821 and 21822 event MUST carry a +[NIP-13](https://github.com/nostr-protocol/nips/blob/master/13.md) `nonce` tag +committing to a target difficulty: + +```jsonc +["nonce", "", ""] +``` + +- Offers (38421): the difficulty is cheap to remine on each republish; it + mainly lets relays enforce a floor via NIP-11 `min_pow_difficulty`. A target + of 20 is RECOMMENDED. +- Requests (21821): the provider advertises `min_request_pow`; a client MUST + mine at least that difficulty, committed in the nonce tag, before the + provider will process the request. Because the proof of work is over the + event id (which includes the encrypted request body, the recipient and the + timestamp), each request requires fresh work. This is what makes flooding a + provider with hodl-invoice-opening requests expensive. + +Verifiers MUST reject an event whose actual difficulty is below the committed +target, and clients MUST reject an offer/response whose committed target is +below their configured minimum, per NIP-13. + +### 2. Node attestation (OPTIONAL, stronger Sybil resistance) + +To bind a nostr identity to a real Lightning node, an offer MAY include +`node_pubkey` and `node_sig`. `node_sig` is a BOLT-style zbase32 recoverable +signature (as produced by `lncli signmessage` / `lnrpc.Lightning/SignMessage`) +over the exact ASCII message: + +``` +lnproxy:v1:announce: +``` + +A client verifies the attestation by recovering the signing pubkey from +`node_sig` and the message above and checking that it equals `node_pubkey`. The +client MAY then weight the provider by external information about that node +(channel count, capacity, centrality); such weighting is out of scope for this +version and left to client policy. + +Because a standard (non-blinded) proxy invoice already reveals the provider's +node id to the client, attestation leaks nothing new for such providers; it +merely makes the existing link verifiable while raising the cost of running +many fake providers to that of running many funded nodes. + +### 3. Anonymous identity proof of work (OPTIONAL) + +A provider that does not want to reveal a node id (because it only ever issues +blinded or BOLT12 proxy invoices) cannot attest with a node key. Such a +provider MAY instead include a `pow_nonce` and commit work to its nostr +identity itself. Define: + +``` +pow_bits = number of leading zero bits of SHA256("lnproxy" || pubkey || nonce) +``` + +where `pubkey` is the 32-byte x-only nostr public key and `nonce` is the +`pow_nonce` value as a 32-byte big-endian integer. This work is mined once per +identity and reused across republished offers, so it acts as a one-time stake. +A target of 30 bits is RECOMMENDED, matching Electrum's swap-provider default. + +Clients MAY require either a valid node attestation or a minimum anonymous +`pow_bits`, and MAY use either as a ranking input. + +## Wrap request (kind 21821) + +A client generates a fresh, **ephemeral** nostr keypair per session (it MUST +NOT reuse a long-lived identity, to avoid being profiled across requests) and +sends: + +```jsonc +{ + "kind": 21821, + "tags": [ + ["p", ""], + ["nonce", "", "= min_request_pow>"] + ], + "content": "", + ... // pubkey = ephemeral key, signed normally +} +``` + +The plaintext inside the NIP-44 ciphertext is the base lnproxy request object +with an added `method` field: + +```jsonc +{ + "method": "wrap", + "invoice": "", + "routing_msat": "", // OPTIONAL + "description": "", // OPTIONAL, mutually exclusive with description_hash + "description_hash": "<32-byte hex>", // OPTIONAL + "wrap": "bolt11" // OPTIONAL output format, default "bolt11" +} +``` + +Encryption uses +[NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md) between the +client's ephemeral key and the provider's advertisement pubkey. + +## Wrap response (kind 21822) + +The provider replies with an ephemeral event addressed to the client's +ephemeral key and referencing the request: + +```jsonc +{ + "kind": 21822, + "tags": [ + ["p", ""], + ["e", ""], + ["nonce", "", "20"] + ], + "content": "", + ... +} +``` + +The plaintext is exactly the base protocol response: + +```jsonc +{ "proxy_invoice": "" } +``` + +or + +```jsonc +{ "status": "ERROR", "reason": "error details..." } +``` + +`proxy_invoice` is the universal field for all output formats (BOLT11, BOLT11 +with blinded paths, and BOLT12 are all bech32 strings). Clients MUST treat +error `reason` strings as untrusted text and MUST NOT act on them beyond +display and trying another provider. + +## Features and negotiation + +The `features` array advertises capabilities. It is split by direction so that +input and output capabilities are unambiguous: + +| feature | meaning | status | +|----------------------|---------------------------------------------------------------|----------| +| `pay_bolt11` | provider can pay a plain BOLT11 original invoice | core | +| `pay_bolt11_blinded` | provider can pay a BOLT11 invoice that uses blinded paths | core | +| `wrap_bolt11` | provider can issue a plain BOLT11 proxy invoice | core | +| `wrap_bolt11_blinded`| provider can issue a BOLT11 proxy invoice with blinded paths | reserved | +| `pay_bolt12` | provider can pay a BOLT12 invoice | reserved | +| `wrap_bolt12` | provider can issue a BOLT12 proxy invoice | reserved | + +Negotiation is by single selection: the client sets the request `wrap` field to +the output format it wants (default `bolt11`) and the provider returns a proxy +invoice in that format or an error. A client MUST NOT expect more than one proxy +invoice per request, because a Lightning node cannot hold two invoices with the +same payment hash, so multiple parallel proxy invoices for one payment are not +implementable. A BOLT11 invoice that includes blinded paths is still a single +invoice. + +`wrap_bolt11_blinded`, `pay_bolt12` and `wrap_bolt12` are reserved here for +forward compatibility. BOLT12 wrapping additionally requires an offer -> +invoice_request -> invoice flow that is out of scope for this version and is +left as future work; it is expected to be implemented first by CLN-based or +LND+lndk providers. Clients MUST ignore features they do not understand. + +## Client algorithm + +1. Subscribe to the configured nostr relays with the filter + `{"kinds":[38421], "#d":["lnproxy-v1"], "#n":[""], "since": now-3600}`. +2. For each offer event: + 1. verify the event signature (NIP-01); + 2. verify the NIP-13 committed target meets the client's minimum and the + event actually meets its committed target; + 3. drop the event if `created_at` is more than ~1 hour from local time; + 4. keep only the newest event per provider pubkey (addressable semantics); + 5. parse the content; drop offers whose fees/limits are missing or + nonsensical; + 6. if present, verify `node_sig` against `node_pubkey` and + `lnproxy:v1:announce:`; if absent, optionally require a minimum + anonymous `pow_bits`. +3. Filter the surviving offers to those that can serve the payment: the + original-invoice amount must lie within `[min_amount_msat, max_amount_msat]` + and the requested `wrap` format must be in `features`. +4. Sort the filtered offers by **effective fee for this payment, cheapest + first**: `base_fee_msat + amount_msat * fee_ppm / 1_000_000`. Ties MAY be + broken by credential strength (attested node, then higher anonymous + `pow_bits`). Future versions MAY mix in node centrality or reputation. +5. Pick the top provider (or let the user choose), mine the request proof of + work, send the kind 21821 request and await the kind 21822 response. +6. Verify the returned proxy invoice exactly as in the base protocol, and + additionally verify that the extra amount it charges does not exceed the + advertised fee: with no explicit `routing_msat`, + `proxy_amount_msat - original_amount_msat <= base_fee_msat + original_amount_msat * fee_ppm / 1_000_000 + provider_routing_budget`. + If verification fails, discard and try the next provider. + +The advertised-fee check makes a provider's published fee schedule +trustlessly binding: a provider that tries to charge more than it advertised is +rejected client-side, exactly like a provider returning a wrong payment hash. + +## Configurable nostr relays + +Both providers and clients MUST allow the nostr relay set to be configured, and +SHOULD ship a working default set so that the system functions out of the box. +A reasonable default set at the time of writing is: + +``` +wss://nos.lol +wss://relay.damus.io +wss://relay.primal.net +wss://nostr.mom +``` + +Providers announce, in each offer's `relays` field, the relays on which they +actually listen for requests; clients SHOULD send requests to the intersection +of their own relays and the provider's advertised relays, and MAY add the +provider's relays to their set for the duration of the exchange. + +## Denial-of-service considerations + +- Request proof of work (`min_request_pow`) makes opening hodl invoices costly + for attackers while remaining sub-second for honest clients. +- Providers SHOULD additionally bound the number of concurrently queued + requests and rate-limit how fast they dequeue them, dropping excess requests. +- Offer proof of work and (optionally) NIP-13 enforcement at the relay + (NIP-11 `min_pow_difficulty`) limit advertisement spam; ranking by credential + strength means flooding the visible provider list is progressively expensive. +- Because client identities are ephemeral and request/response events are + ephemeral kinds, there is little long-lived metadata for an attacker to mine. + +## Relationship to the base protocol + +This specification reuses the base protocol's request and response JSON +verbatim, so a provider can offer both the HTTP transport (advertised via +`urls`) and the nostr transport from the same core implementation, and a client +can fall back to HTTP when an offer advertises a URL. The trust model, +hodl-invoice mechanics, and `min_final_cltv_expiry` considerations from +[README.md](README.md) all continue to apply unchanged. From 3ae7b8e465f798af91fada78627def0c5a0e340d Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 10 Jun 2026 11:49:13 +0200 Subject: [PATCH 02/10] docs: clarify LND cannot issue blinded/bolt12 proxy invoices LND's AddHoldInvoice API exposes no blinded-path generation, and lnproxy must use hold invoices to learn the preimage, so an LND-only relay cannot fulfill wrap_bolt11_blinded or wrap_bolt12. State that relays must only advertise wrap_* features they can fulfill and must error otherwise. --- nostr.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/nostr.md b/nostr.md index 8dbe0ce..840ea5c 100644 --- a/nostr.md +++ b/nostr.md @@ -290,10 +290,16 @@ implementable. A BOLT11 invoice that includes blinded paths is still a single invoice. `wrap_bolt11_blinded`, `pay_bolt12` and `wrap_bolt12` are reserved here for -forward compatibility. BOLT12 wrapping additionally requires an offer -> -invoice_request -> invoice flow that is out of scope for this version and is -left as future work; it is expected to be implemented first by CLN-based or -LND+lndk providers. Clients MUST ignore features they do not understand. +forward compatibility. They are deliberately not implementable on every +backend: because a relay must issue a hold invoice to learn the preimage, and +LND's hold-invoice API (`invoicesrpc.Invoices/AddHoldInvoice`) does not expose +blinded-path generation, an LND-only relay cannot currently issue +`wrap_bolt11_blinded` or `wrap_bolt12` proxy invoices. These features are +therefore expected to appear first on CLN-based (or LND+lndk) relays. BOLT12 +wrapping additionally requires an offer -> invoice_request -> invoice flow that +is out of scope for this version. A relay MUST only advertise a `wrap_*` feature +it can actually fulfill, and MUST return an error for a `wrap` format it does not +support. Clients MUST ignore features they do not understand. ## Client algorithm From 2888da857eca1660b22f51be3132865f9a1c1ec6 Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 10 Jun 2026 11:54:35 +0200 Subject: [PATCH 03/10] ci: add pre-commit config and lint workflow --- .github/workflows/lint.yml | 16 ++++++++++++++++ .pre-commit-config.yaml | 12 ++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .github/workflows/lint.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..3adf798 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,16 @@ +name: lint + +on: + push: + pull_request: + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: pip install pre-commit + - run: pre-commit run --all-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ef7fa85 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + exclude: ^vendor/ + - id: end-of-file-fixer + exclude: ^vendor/ + - id: check-merge-conflict + - id: mixed-line-ending + args: [--fix=lf] + exclude: ^vendor/ From 86117f4f31fcd7dbed5eb17f57284003b7249401 Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 10 Jun 2026 11:59:04 +0200 Subject: [PATCH 04/10] docs: refine nostr spec (expiration, notation, reference impl) Align the offer expiration recommendation with the reference relay, clarify that clients use a ~1h created_at freshness window independent of NIP-40, note that || is byte concatenation in the identity proof of work, and point to the Go and JavaScript reference implementations. --- nostr.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/nostr.md b/nostr.md index 840ea5c..43d4953 100644 --- a/nostr.md +++ b/nostr.md @@ -83,13 +83,20 @@ within roughly an hour. [ ["d", "lnproxy-v1"], // protocol version, filterable via #d ["n", "mainnet"], // network: mainnet|testnet|signet|regtest, filterable via #n - ["expiration", "1718000000"] // NIP-40 unix timestamp; RECOMMENDED now + ~15 min + ["expiration", "1718000000"] // NIP-40 unix timestamp; RECOMMENDED republish interval + small margin ] ``` The `d` tag carries the protocol version so that incompatible future versions can coexist on the same relays. Clients MUST filter on both `#d` and `#n`. +The NIP-40 `expiration` tag lets relays garbage-collect offers from providers +that stop republishing; set it slightly beyond the republish interval (the +reference relay uses interval + 1 minute). Independently of NIP-40, clients +treat an offer as stale and ignore it once its `created_at` is more than about +an hour old, so a provider that disappears falls out of discovery within that +window even on relays that ignore `expiration`. + ### Content The `content` field is a JSON object. All amounts are in millisatoshis @@ -189,7 +196,8 @@ pow_bits = number of leading zero bits of SHA256("lnproxy" || pubkey || nonce) ``` where `pubkey` is the 32-byte x-only nostr public key and `nonce` is the -`pow_nonce` value as a 32-byte big-endian integer. This work is mined once per +`pow_nonce` value as a 32-byte big-endian integer (`||` denotes byte +concatenation and `"lnproxy"` its 7 ASCII bytes). This work is mined once per identity and reused across republished offers, so it acts as a one-time stake. A target of 30 bits is RECOMMENDED, matching Electrum's swap-provider default. @@ -373,3 +381,15 @@ verbatim, so a provider can offer both the HTTP transport (advertised via can fall back to HTTP when an offer advertises a URL. The trust model, hodl-invoice mechanics, and `min_final_cltv_expiry` considerations from [README.md](README.md) all continue to apply unchanged. + +## Reference implementation + +- Provider (Go): the `nostr` package and `cmd/nostr-relay` binary in + [lnproxy-relay](https://github.com/lnproxy/lnproxy-relay) publish offers, + serve encrypted requests, mine/verify proof of work, and optionally attest + with the node key (via [lnc](https://github.com/lnproxy/lnc)'s `SignMessage`). +- Client (JavaScript): the discovery and wrap logic in + [lnproxy-webui2](https://github.com/lnproxy/lnproxy-webui2) + (`assets/nostr.js`) validates offers, verifies attestations in the browser, + ranks providers cheapest-first, and enforces the advertised fee against the + returned invoice. From 6bf5e5356bec081be0daa374d8357a338dd61752 Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 10 Jun 2026 15:05:53 +0200 Subject: [PATCH 05/10] docs: note providers only verify incoming client requests --- nostr.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nostr.md b/nostr.md index 43d4953..c11cfe8 100644 --- a/nostr.md +++ b/nostr.md @@ -363,6 +363,11 @@ provider's relays to their set for the duration of the exchange. ## Denial-of-service considerations +- A provider only validates the requests that reach it: it MUST check the + client signature and that the request meets the advertised `min_request_pow`. + A provider does NOT need to verify its own announcement proof of work, nor any + other provider's offer or proof of work; offer/identity proof of work and + attestation exist solely so that **clients** can rank and filter providers. - Request proof of work (`min_request_pow`) makes opening hodl invoices costly for attackers while remaining sub-second for honest clients. - Providers SHOULD additionally bound the number of concurrently queued From ce928d62a1a68cd526f2dc9351ba762c52e2c80a Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 10 Jun 2026 15:51:08 +0200 Subject: [PATCH 06/10] docs: analyze shared-payment-hash self-loop safety Document that the same payment hash legitimately appears on both the proxy and original payments (including when the original destination is the payer), why this is safe for the relay (CLTV timeline, not destination distinctness), and the requirement to keep a non-zero CLTV margin and refuse to pay out below it. --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 5a8e286..3a48284 100644 --- a/README.md +++ b/README.md @@ -98,3 +98,37 @@ An lnproxy relay needs to ensure that payments to the original invoice expire be ### Atomic multi-path payments Relays cannot create proxy invoices for AMP invoices since there is not payment_hash reveal mechanism. + +### Shared payment hash and self-loops + +Because the proxy invoice and the original invoice share a payment hash `H`, the +same `H` can legitimately appear on two payments at once: the payer's payment to +the relay (proxy invoice) and the relay's payment to the original destination. +This is fine even when both payments traverse the same node, and even when the +**original destination is the payer itself** (or a node behind it), for example +a payer that wraps an invoice addressed to its own node in order to rebalance or +to hide behind the relay. BOLT 2/3 do not require payment-hash uniqueness across +HTLCs, so a node can hold an incoming and an outgoing HTLC with the same `H` in +opposite directions. + +This is safe for the relay regardless of topology, and the safety comes entirely +from the CLTV timeline, not from the destination being different from the payer: + +- The proxy invoice's `min_final_cltv_expiry` is set larger than the route the + relay needs to pay the original invoice (see above), so the relay's outgoing + HTLC expires before its accepted incoming HTLC. +- When paying the original invoice the relay sets its outgoing CLTV limit to the + accepted HTLC's remaining delta minus a safety margin (`CltvDeltaAlpha`), so + the relay always learns the preimage in time to settle its incoming HTLC. + +In the self-loop case the payer simply learns the preimage (it created the +original invoice) and reveals it when the relay's payment reaches it; that is +exactly the intended preimage flow and lets the relay settle. The payer cannot +claim the relay's outgoing payment without revealing the preimage, and revealing +it lets the relay claim the incoming payment, so there is no way for the payer to +make the relay lose funds. + +Implementations MUST keep a non-zero CLTV safety margin and MUST NOT pay out +when the accepted HTLC's remaining CLTV delta is not strictly greater than that +margin (otherwise a maliciously short incoming HTLC, or an arithmetic underflow, +could erase the margin). The relay should cancel such an invoice instead. From 2d7dd9b658b2200581a22f519390911731bd7e32 Mon Sep 17 00:00:00 2001 From: m0wer Date: Wed, 15 Jul 2026 22:40:21 +0200 Subject: [PATCH 07/10] docs: prefer direct transport after nostr discovery --- README.md | 12 ++-- nostr.md | 178 +++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 141 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 3a48284..fd059eb 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ Then, once the user verifies the amount and payment hash, the user can use the p ## Decentralized provider discovery over nostr Historically a user had to already know a relay's URL. [nostr.md](nostr.md) -specifies an optional discovery and transport layer over +specifies decentralized discovery over [nostr](https://github.com/nostr-protocol/nips): providers advertise themselves -(with their own fees and limits) as replaceable events, clients filter and rank -them cheapest-first, and the wrap request/response is carried over encrypted -nostr messages with proof-of-work spam protection. The request and response -bodies are identical to the HTTP API below, so the two transports share an -implementation. +with their own fees, limits and direct endpoints, and clients filter and rank +them cheapest-first. Clients prefer an idempotent direct request when available +and can fall back to an encrypted Nostr exchange with proof-of-work spam +protection. The extended request and response objects are shared by both +transports. ## Requesting and verifying a proxy invoice diff --git a/nostr.md b/nostr.md index c11cfe8..a3d71e1 100644 --- a/nostr.md +++ b/nostr.md @@ -8,15 +8,16 @@ draft: lnproxy provider discovery over nostr. This document specifies how lnproxy providers (makers) advertise themselves on [nostr](https://github.com/nostr-protocol/nips) and how clients discover them, select one, and request a proxy invoice without a central directory. It builds -on the base lnproxy protocol described in [README.md](README.md): the -request and response bodies are identical, only the transport and the discovery -layer are new. +on the base lnproxy protocol described in [README.md](README.md). Discovery uses +nostr; a provider advertising `request_id_v1` and a usable URL can then be +contacted directly over HTTP, with encrypted nostr messages as the fallback. The design follows the same shape as Electrum's submarine-swap provider -discovery: providers publish a replaceable "offer" event, clients filter and -rank the offers, and the actual request/response happens over encrypted nostr -messages. lnproxy differs in that it lets each provider set its own fees and -limits and lets clients enforce them trustlessly against the returned invoice. +discovery: providers publish a replaceable "offer" event and clients filter and +rank the offers. lnproxy differs in that it lets each provider set its own fees +and limits, lets clients enforce them trustlessly against the returned invoice, +and avoids using nostr relays for the request when the provider advertises a +usable idempotent direct endpoint. ## Why decentralized discovery is safe @@ -30,12 +31,20 @@ and request spam, and (b) being explicit about what a provider learns. ### What a provider can and cannot see -A provider necessarily learns the original invoice it is asked to pay: its -**destination node, amount, and description/memo** (unless the client strips or -replaces the description). A provider does **not** learn who is paying or where -the payment originates: the client pays the proxy invoice from somewhere else -on the Lightning Network, so from the provider's point of view the payment -arrives like any other inbound HTLC. +A provider necessarily learns the complete original invoice it is asked to pay, +including its **destination node, amount, and description/memo**. Replacing the +description changes the proxy invoice but does not hide the original invoice's +memo from the provider. The later inbound HTLC does not identify the payer's +originating Lightning node, but the provider sees its immediate channel peer, +which can be the payer, and may be able to correlate amount and timing. + +Network-layer privacy depends on the request transport. A direct clearnet HTTP +connection reveals the client's IP address to the provider unless the client +uses an anonymizing proxy. A nostr request hides the client IP from the provider +only if the relay neither discloses that metadata nor is operated or observed by +the provider; the relay itself sees connection and timing metadata. A direct +onion endpoint reached over Tor avoids third-party relay metadata and disclosure +of the client IP to the provider, and is the preferred privacy-preserving path. This asymmetry is the whole point of lnproxy and it matches the empirical findings of Kappos et al., *"An Empirical Analysis of Privacy in the Lightning @@ -43,12 +52,13 @@ Network"* (USENIX Security 2021): the information that meaningfully deanonymizes Lightning payments is on the **destination/path** side, while the origin of a payment is comparatively well hidden. lnproxy moves the destination-side exposure from the payer's wallet/custodian to a provider that -does not know the payer. +is not inherently told the payer's identity by the Lightning protocol. Two consequences are spelled out as requirements below: -- Clients SHOULD connect to nostr relays over Tor or another anonymizing - transport, because the nostr relay (not the provider) sees the client's IP. +- Clients SHOULD connect to nostr relays and direct endpoints over Tor or + another anonymizing transport where available, and MUST NOT assume that nostr + alone hides their IP address from the provider. - Providers MAY hide their own node by advertising only blinded-path or BOLT12 capabilities (see [Features](#features-and-negotiation)); in that case the proxy invoice does not reveal the provider's node id. Note that a curious @@ -65,10 +75,11 @@ Two consequences are spelled out as requirements below: | 21822 | ephemeral | encrypted wrap response (provider -> client)| Kind 38421 is in the addressable range (30000-39999, NIP-01): relays keep only -the latest event per `(pubkey, d-tag)`, so each provider has exactly one live -offer per network. Kinds 21821/21822 are in the ephemeral range -(20000-29999): relays are not expected to store them, which suits short-lived -RPC traffic. +the latest event per `(kind, pubkey, d-tag)`, so each provider pubkey has exactly +one live offer. Because the `n` tag is not part of that address, a provider that +serves multiple networks MUST use a distinct nostr keypair for each network. +Kinds 21821/21822 are in the ephemeral range (20000-29999): relays are not +expected to store them, which suits short-lived RPC traffic. ## Provider offer (kind 38421) @@ -116,7 +127,7 @@ The `content` field is a JSON object. All amounts are in millisatoshis "relays": [ // nostr relays the provider listens on for requests "wss://relay.example.com" ], - "urls": [ // OPTIONAL legacy HTTP/onion endpoints (base protocol) + "urls": [ // OPTIONAL direct HTTP/onion wrap endpoints, in preference order "https://lnproxy.example.com/spec" ], "node_pubkey": "02...", // OPTIONAL, hex compressed LN identity pubkey @@ -134,6 +145,18 @@ they are enforced trustlessly by clients against the returned proxy invoice Clients MUST ignore unknown content fields and unknown `features` entries so that the format can be extended. +`urls` contains full wrap endpoint URLs in provider preference order. These +URLs are untrusted network input even though the offer is signed. Clients MUST +require HTTPS for clearnet endpoints, MUST reject credentials and fragments, +and, if they follow redirects, MUST validate every new target under the same +rules. Plain HTTP MAY be used for a v3 onion service or for explicit local +development. Native clients MUST reject loopback, private, link-local, +unspecified, multicast and metadata-service targets unless the user explicitly +configured them. This check applies to IP literals and every address returned +by DNS, and MUST be repeated when reconnecting or following a redirect. Browser +clients are additionally subject to mixed-content, CORS and local-network +access policy. + ## Anti-spam: proof of work and identity Three independent mechanisms are combined. Only the first is mandatory. @@ -206,9 +229,9 @@ Clients MAY require either a valid node attestation or a minimum anonymous ## Wrap request (kind 21821) -A client generates a fresh, **ephemeral** nostr keypair per session (it MUST -NOT reuse a long-lived identity, to avoid being profiled across requests) and -sends: +A client generates a fresh, **ephemeral** nostr keypair per logical wrap +operation (it MUST NOT reuse a long-lived identity or the same ephemeral key for +unrelated operations, to avoid being profiled across requests) and sends: ```jsonc { @@ -222,12 +245,12 @@ sends: } ``` -The plaintext inside the NIP-44 ciphertext is the base lnproxy request object -with an added `method` field: +The plaintext inside the NIP-44 ciphertext is the lnproxy wrap request object: ```jsonc { "method": "wrap", + "request_id": "<32 random bytes as lowercase hex>", // REQUIRED with request_id_v1 "invoice": "", "routing_msat": "", // OPTIONAL "description": "", // OPTIONAL, mutually exclusive with description_hash @@ -240,6 +263,28 @@ Encryption uses [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md) between the client's ephemeral key and the provider's advertisement pubkey. +Providers MUST validate the event signature, kind, recipient `p` tag, proof of +work and a bounded `created_at` freshness window before decrypting or processing +a request. Because the same event can arrive from several relays, providers +MUST atomically deduplicate valid request event IDs for at least that freshness +window, even when `request_id_v1` is not advertised. Re-delivery MAY cause the +original response to be republished but MUST NOT open another hold invoice. + +The same logical request object is sent as the body of a direct HTTP request. +For this transport an omitted `method` defaults to `wrap` and any present value +MUST be `wrap`; `request_id` is REQUIRED when `request_id_v1` is advertised; and +`wrap` MAY be omitted to select its `bolt11` default. Direct endpoints MUST +accept `POST` with `Content-Type: +application/json`, MUST enforce a bounded request-body size, and SHOULD support +browser CORS preflight. Protocol success and error responses MUST use HTTP 200 +and `Content-Type: application/json`; other HTTP status codes indicate a +transport-level failure. Clients MUST enforce a bounded response-body size. + +For fallback purposes under `request_id_v1`, a timeout, network error, non-200 +status, oversized or malformed response, or missing or mismatched request ID is +an ambiguous transport failure. A well-formed success or `ERROR` response with +the matching request ID is a protocol response, not a transport failure. + ## Wrap response (kind 21822) The provider replies with an ephemeral event addressed to the client's @@ -258,16 +303,19 @@ ephemeral key and referencing the request: } ``` -The plaintext is exactly the base protocol response: +Before decrypting, clients MUST verify the event signature, kind, selected +provider pubkey, exact request `e` tag, recipient `p` tag, and required proof of +work. The plaintext extends the base protocol response with `request_id` when +`request_id_v1` is advertised: ```jsonc -{ "proxy_invoice": "" } +{ "request_id": "", "proxy_invoice": "" } ``` or ```jsonc -{ "status": "ERROR", "reason": "error details..." } +{ "request_id": "", "status": "ERROR", "reason": "error details..." } ``` `proxy_invoice` is the universal field for all output formats (BOLT11, BOLT11 @@ -275,6 +323,32 @@ with blinded paths, and BOLT12 are all bech32 strings). Clients MUST treat error `reason` strings as untrusted text and MUST NOT act on them beyond display and trying another provider. +When `request_id_v1` is negotiated, every response, including an error response, +MUST echo the exact request ID. Clients MUST reject a missing or mismatched ID. + +### Idempotent transport fallback + +`request_id_v1` permits safe retry of one logical operation when a direct +response is lost. The request ID MUST contain 32 cryptographically random bytes, +encoded as 64 lowercase hexadecimal characters, and MUST be reused only for +retries of the same request to the same provider. + +A provider advertising `request_id_v1` MUST atomically deduplicate a request ID +across all of its advertised direct endpoints and its nostr transport. While a +result is retained, an identical retry MUST receive the original response +without opening another hold invoice. Reuse of an ID with different `invoice`, +`routing_msat`, `description`, `description_hash`, `method` or `wrap` fields MUST +return an error without changing the stored result. Successful responses and +their request bindings MUST remain available until the returned proxy invoice +expires. Providers MAY retain error responses for a shorter bounded period only +after ensuring that the failed operation left no open hold invoice, and MUST NOT +evict idempotency state while effects of the original request can remain active. + +Clients MUST NOT automatically retry an ambiguous request across transports +unless the offer advertises `request_id_v1`. Requests to different providers +always require new request IDs and explicit client action because idempotency +state is provider-local. + ## Features and negotiation The `features` array advertises capabilities. It is split by direction so that @@ -288,6 +362,7 @@ input and output capabilities are unambiguous: | `wrap_bolt11_blinded`| provider can issue a BOLT11 proxy invoice with blinded paths | reserved | | `pay_bolt12` | provider can pay a BOLT12 invoice | reserved | | `wrap_bolt12` | provider can issue a BOLT12 proxy invoice | reserved | +| `request_id_v1` | provider deduplicates and echoes request IDs across transports | optional | Negotiation is by single selection: the client sets the request `wrap` field to the output format it wants (default `bolt11`) and the provider returns a proxy @@ -331,17 +406,27 @@ support. Clients MUST ignore features they do not understand. first**: `base_fee_msat + amount_msat * fee_ppm / 1_000_000`. Ties MAY be broken by credential strength (attested node, then higher anonymous `pow_bits`). Future versions MAY mix in node centrality or reputation. -5. Pick the top provider (or let the user choose), mine the request proof of - work, send the kind 21821 request and await the kind 21822 response. +5. Pick the top provider (or let the user choose) and choose a transport: + 1. if the offer advertises `request_id_v1` and has a usable `urls` entry, + generate a request ID and try the direct endpoints in advertised order; + 2. after a transport-level direct failure, reuse the same request ID, mine + the request proof of work, send kind 21821 and await kind 21822; + 3. if direct transport is unavailable or `request_id_v1` is absent, use the + nostr request directly. A valid provider `ERROR` response is not a + transport failure and MUST NOT trigger automatic fallback. 6. Verify the returned proxy invoice exactly as in the base protocol, and additionally verify that the extra amount it charges does not exceed the advertised fee: with no explicit `routing_msat`, `proxy_amount_msat - original_amount_msat <= base_fee_msat + original_amount_msat * fee_ppm / 1_000_000 + provider_routing_budget`. - If verification fails, discard and try the next provider. + Here `provider_routing_budget` is a non-negative, client-configured allowance + for the provider's cost of routing the original payment; it is not an offer + field. + If verification fails, discard the response and require explicit client + action before trying another provider. -The advertised-fee check makes a provider's published fee schedule -trustlessly binding: a provider that tries to charge more than it advertised is -rejected client-side, exactly like a provider returning a wrong payment hash. +This check trustlessly binds the total surcharge to the advertised fee plus the +client's routing allowance. The client cannot distinguish routing cost from +provider revenue, so it MUST choose that allowance as part of its own fee policy. ## Configurable nostr relays @@ -369,23 +454,30 @@ provider's relays to their set for the duration of the exchange. other provider's offer or proof of work; offer/identity proof of work and attestation exist solely so that **clients** can rank and filter providers. - Request proof of work (`min_request_pow`) makes opening hodl invoices costly - for attackers while remaining sub-second for honest clients. + over nostr while remaining sub-second for honest clients. Direct HTTP requests + do not carry NIP-13 proof of work, so direct endpoints MUST independently + enforce admission or request rate limits and MUST bound their request-ID cache + without evicting active idempotency entries. - Providers SHOULD additionally bound the number of concurrently queued requests and rate-limit how fast they dequeue them, dropping excess requests. - Offer proof of work and (optionally) NIP-13 enforcement at the relay (NIP-11 `min_pow_difficulty`) limit advertisement spam; ranking by credential strength means flooding the visible provider list is progressively expensive. -- Because client identities are ephemeral and request/response events are - ephemeral kinds, there is little long-lived metadata for an attacker to mine. +- Ephemeral event kinds do not guarantee deletion. Relays can retain ciphertext + and metadata, and NIP-44 has no forward secrecy: compromise of a provider's + persistent nostr key can expose previously captured requests. Clients and + providers SHOULD minimize sensitive logs and delete request plaintext and + ephemeral client keys when they are no longer needed for the operation. ## Relationship to the base protocol -This specification reuses the base protocol's request and response JSON -verbatim, so a provider can offer both the HTTP transport (advertised via -`urls`) and the nostr transport from the same core implementation, and a client -can fall back to HTTP when an offer advertises a URL. The trust model, +This specification extends the base protocol request with optional `method`, +`wrap` and `request_id` fields. Providers SHOULD use one core implementation and +one idempotency store for direct HTTP and nostr. Nostr remains the decentralized +discovery and fallback layer, while direct HTTP is preferred for the selected +provider when it advertises `request_id_v1` and a usable URL. The trust model, hodl-invoice mechanics, and `min_final_cltv_expiry` considerations from -[README.md](README.md) all continue to apply unchanged. +[README.md](README.md) continue to apply unchanged. ## Reference implementation From 1cd71f226a1b123b875d69ce228cee3804c9e8c3 Mon Sep 17 00:00:00 2001 From: m0wer Date: Thu, 16 Jul 2026 08:29:20 +0200 Subject: [PATCH 08/10] docs: define nostr discovery threat model --- nostr.md | 175 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 133 insertions(+), 42 deletions(-) diff --git a/nostr.md b/nostr.md index a3d71e1..4a15829 100644 --- a/nostr.md +++ b/nostr.md @@ -23,11 +23,13 @@ usable idempotent direct endpoint. In the base protocol the client always verifies the proxy invoice it receives (same payment hash, matching description, bounded extra amount), so a malicious -provider can only refuse to serve or fail to pay, never steal funds. That -property is unchanged here. Discovery therefore does not need to be trusted: a -client can pick any advertised provider, attempt a wrap, and reject the result -if it does not verify. The remaining problems are (a) preventing advertisement -and request spam, and (b) being explicit about what a provider learns. +provider cannot change the payment intent or charge beyond the client's limit. +That property is unchanged here. Discovery does affect availability and privacy: +a selected provider can learn the original invoice and then refuse service, and +a Sybil can try to keep honest providers from being selected. Clients therefore +MUST treat invoice integrity, provider selection, and availability as separate +security properties. The anti-spam mechanisms below bound work but do not turn +an unknown provider into a trusted service. ### What a provider can and cannot see @@ -76,7 +78,8 @@ Two consequences are spelled out as requirements below: Kind 38421 is in the addressable range (30000-39999, NIP-01): relays keep only the latest event per `(kind, pubkey, d-tag)`, so each provider pubkey has exactly -one live offer. Because the `n` tag is not part of that address, a provider that +one live offer. This does not stop one operator from creating many pubkeys. +Because the `n` tag is not part of that address, a provider that serves multiple networks MUST use a distinct nostr keypair for each network. Kinds 21821/21822 are in the ephemeral range (20000-29999): relays are not expected to store them, which suits short-lived RPC traffic. @@ -106,12 +109,18 @@ that stop republishing; set it slightly beyond the republish interval (the reference relay uses interval + 1 minute). Independently of NIP-40, clients treat an offer as stale and ignore it once its `created_at` is more than about an hour old, so a provider that disappears falls out of discovery within that -window even on relays that ignore `expiration`. +window even on relays that ignore `expiration`. Clients MUST also reject an +offer whose expiration is in the past, recheck freshness immediately before use, +and reject duplicate or malformed `d`, `n`, `expiration`, and `nonce` tags. ### Content -The `content` field is a JSON object. All amounts are in millisatoshis -(strings or numbers both accepted; strings RECOMMENDED to avoid float issues). +The `content` field is a JSON object. All amounts are non-negative integer +millisatoshis. Implementations MAY accept canonical unsigned decimal strings or +JSON integers, but MUST reject fractions, exponents in strings, negative values, +values outside their exact integer range, and arithmetic overflow. JavaScript +implementations MUST use checked `BigInt` arithmetic for fee multiplication or +reject values above their safe integer range. ```jsonc { @@ -145,6 +154,11 @@ they are enforced trustlessly by clients against the returned proxy invoice Clients MUST ignore unknown content fields and unknown `features` entries so that the format can be extended. +Clients and relays MUST enforce local envelope and content limits before costly +processing. The reference client accepts at most 16 KiB of offer content, 16 +tags, 32 feature strings, eight relay URLs, and three direct URLs. Other limits +MAY differ, but MUST be finite and documented. + `urls` contains full wrap endpoint URLs in provider preference order. These URLs are untrusted network input even though the offer is signed. Clients MUST require HTTPS for clearnet endpoints, MUST reject credentials and fragments, @@ -157,6 +171,13 @@ by DNS, and MUST be repeated when reconnecting or following a redirect. Browser clients are additionally subject to mixed-content, CORS and local-network access policy. +A URL is not authenticated as belonging to the offer key merely because it is +inside a signed offer. Every direct request MUST include the selected offer's +`provider_pubkey`, and an endpoint MUST compare it with its configured Nostr +identity before rate-limit admission, idempotency lookup, invoice decoding, or +other side effects. This prevents a malicious offer from naming an honest +competitor's URL and reflecting client requests into it. + ## Anti-spam: proof of work and identity Three independent mechanisms are combined. Only the first is mandatory. @@ -200,12 +221,16 @@ A client verifies the attestation by recovering the signing pubkey from `node_sig` and the message above and checking that it equals `node_pubkey`. The client MAY then weight the provider by external information about that node (channel count, capacity, centrality); such weighting is out of scope for this -version and left to client policy. +version and left to client policy. Clients MUST collapse all valid offers with +the same `node_pubkey` into one provider identity for automatic selection. -Because a standard (non-blinded) proxy invoice already reveals the provider's -node id to the client, attestation leaks nothing new for such providers; it -merely makes the existing link verifiable while raising the cost of running -many fake providers to that of running many funded nodes. +Attestation publicly and persistently links the provider offer, terms, URLs, and +Nostr key to the Lightning node. A standard proxy invoice reveals the node only +to a requesting client, so operators MUST understand that attestation creates a +broader disclosure. Attestation proves control of a node identity key. It does +not prove that the node is funded, has channels, can route, or will provide +service, and one node can attest arbitrarily many Nostr keys. Identity collapsing +prevents those keys from gaining extra automatic selection weight. ### 3. Anonymous identity proof of work (OPTIONAL) @@ -225,7 +250,9 @@ identity and reused across republished offers, so it acts as a one-time stake. A target of 30 bits is RECOMMENDED, matching Electrum's swap-provider default. Clients MAY require either a valid node attestation or a minimum anonymous -`pow_bits`, and MAY use either as a ranking input. +`pow_bits`, and MAY use either as a ranking input. Each anonymous Nostr pubkey is +a separate identity, so clients that automatically accept anonymous providers +SHOULD require reusable identity work in addition to per-offer work. ## Wrap request (kind 21821) @@ -251,6 +278,7 @@ The plaintext inside the NIP-44 ciphertext is the lnproxy wrap request object: { "method": "wrap", "request_id": "<32 random bytes as lowercase hex>", // REQUIRED with request_id_v1 + "provider_pubkey": "", // REQUIRED "invoice": "", "routing_msat": "", // OPTIONAL "description": "", // OPTIONAL, mutually exclusive with description_hash @@ -263,9 +291,11 @@ Encryption uses [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md) between the client's ephemeral key and the provider's advertisement pubkey. -Providers MUST validate the event signature, kind, recipient `p` tag, proof of -work and a bounded `created_at` freshness window before decrypting or processing -a request. Because the same event can arrive from several relays, providers +Providers MUST validate envelope size, kind, recipient `p` tag, bounded +`created_at` freshness, committed and actual proof of work, signature, and replay +status before placing a request in a paced processing queue or decrypting it. +Rejected traffic MUST NOT consume the same pacing slots as admitted requests. +Because the same event can arrive from several relays, providers MUST atomically deduplicate valid request event IDs for at least that freshness window, even when `request_id_v1` is not advertised. Re-delivery MAY cause the original response to be republished but MUST NOT open another hold invoice. @@ -276,9 +306,11 @@ MUST be `wrap`; `request_id` is REQUIRED when `request_id_v1` is advertised; and `wrap` MAY be omitted to select its `bolt11` default. Direct endpoints MUST accept `POST` with `Content-Type: application/json`, MUST enforce a bounded request-body size, and SHOULD support -browser CORS preflight. Protocol success and error responses MUST use HTTP 200 -and `Content-Type: application/json`; other HTTP status codes indicate a -transport-level failure. Clients MUST enforce a bounded response-body size. +browser CORS preflight. The endpoint MUST reject a missing or mismatched +`provider_pubkey` before side effects. Protocol success and error responses MUST +use HTTP 200 and `Content-Type: application/json`; other HTTP status codes +indicate a transport-level failure. Clients MUST enforce a bounded response-body +size. For fallback purposes under `request_id_v1`, a timeout, network error, non-200 status, oversized or malformed response, or missing or mismatched request ID is @@ -305,8 +337,9 @@ ephemeral key and referencing the request: Before decrypting, clients MUST verify the event signature, kind, selected provider pubkey, exact request `e` tag, recipient `p` tag, and required proof of -work. The plaintext extends the base protocol response with `request_id` when -`request_id_v1` is advertised: +work. Clients MUST bound the ciphertext and decrypted plaintext before parsing; +the reference client uses 64 KiB. The plaintext extends the base protocol +response with `request_id` when `request_id_v1` is advertised: ```jsonc { "request_id": "", "proxy_invoice": "" } @@ -337,7 +370,7 @@ A provider advertising `request_id_v1` MUST atomically deduplicate a request ID across all of its advertised direct endpoints and its nostr transport. While a result is retained, an identical retry MUST receive the original response without opening another hold invoice. Reuse of an ID with different `invoice`, -`routing_msat`, `description`, `description_hash`, `method` or `wrap` fields MUST +`provider_pubkey`, `routing_msat`, `description`, `description_hash`, `method` or `wrap` fields MUST return an error without changing the stored result. Successful responses and their request bindings MUST remain available until the returned proxy invoice expires. Providers MAY retain error responses for a shorter bounded period only @@ -389,24 +422,38 @@ support. Clients MUST ignore features they do not understand. 1. Subscribe to the configured nostr relays with the filter `{"kinds":[38421], "#d":["lnproxy-v1"], "#n":[""], "since": now-3600}`. 2. For each offer event: - 1. verify the event signature (NIP-01); - 2. verify the NIP-13 committed target meets the client's minimum and the + 1. reject oversized envelopes and use the NIP-13 committed target and + declared event-id difficulty as a cheap prefilter before expensive + signature or content work (a malicious relay can forge these fields, so + this prefilter is not authentication); + 2. verify the event signature (NIP-01); + 3. verify the NIP-13 committed target meets the client's minimum and the event actually meets its committed target; - 3. drop the event if `created_at` is more than ~1 hour from local time; - 4. keep only the newest event per provider pubkey (addressable semantics); - 5. parse the content; drop offers whose fees/limits are missing or + 4. drop the event if `created_at` is more than ~1 hour old, unreasonably in + the future, or its NIP-40 expiration is past; + 5. keep only the newest event per provider pubkey (addressable semantics, + including the NIP-01 event-id tie break for equal timestamps); + 6. parse the content; drop offers whose fees/limits are missing or nonsensical; - 6. if present, verify `node_sig` against `node_pubkey` and + 7. if present, verify `node_sig` against `node_pubkey` and `lnproxy:v1:announce:`; if absent, optionally require a minimum anonymous `pow_bits`. 3. Filter the surviving offers to those that can serve the payment: the original-invoice amount must lie within `[min_amount_msat, max_amount_msat]` - and the requested `wrap` format must be in `features`. -4. Sort the filtered offers by **effective fee for this payment, cheapest - first**: `base_fee_msat + amount_msat * fee_ppm / 1_000_000`. Ties MAY be + and the requested `wrap` format must be in `features`. Recheck freshness and + expiration here and again immediately before sending a request. +4. Group validly attested offers by `node_pubkey` and keep only the cheapest + offer in each group. Each anonymous Nostr pubkey remains a separate identity. + Applying a minimum anonymous identity-PoW policy before this step is strongly + recommended. +5. Sort the remaining provider identities by **effective fee for this payment, + cheapest first**: + `base_fee_msat + amount_msat * fee_ppm / 1_000_000`. Ties MAY be broken by credential strength (attested node, then higher anonymous - `pow_bits`). Future versions MAY mix in node centrality or reputation. -5. Pick the top provider (or let the user choose) and choose a transport: + `pow_bits`), then MUST use a deterministic final tie break. Future versions + MAY mix in independently verified node capacity, availability history, or + reputation. +6. Pick the top provider (or let the user choose) and choose a transport: 1. if the offer advertises `request_id_v1` and has a usable `urls` entry, generate a request ID and try the direct endpoints in advertised order; 2. after a transport-level direct failure, reuse the same request ID, mine @@ -414,19 +461,45 @@ support. Clients MUST ignore features they do not understand. 3. if direct transport is unavailable or `request_id_v1` is absent, use the nostr request directly. A valid provider `ERROR` response is not a transport failure and MUST NOT trigger automatic fallback. -6. Verify the returned proxy invoice exactly as in the base protocol, and - additionally verify that the extra amount it charges does not exceed the +7. Verify the returned proxy invoice exactly as in the base protocol, including + signature, network, remaining lifetime, payment hash, amount, and exact + preservation of the original description or description hash unless the + client explicitly requested a replacement. Additionally verify that the + extra amount it charges does not exceed the advertised fee: with no explicit `routing_msat`, `proxy_amount_msat - original_amount_msat <= base_fee_msat + original_amount_msat * fee_ppm / 1_000_000 + provider_routing_budget`. Here `provider_routing_budget` is a non-negative, client-configured allowance for the provider's cost of routing the original payment; it is not an offer field. - If verification fails, discard the response and require explicit client - action before trying another provider. + If verification fails, discard the response. A client MAY offer another + distinct identity, but automatic retries MUST be bounded because each new + provider learns the invoice and may open another hold invoice. The reference + client requires explicit user action before changing providers. This check trustlessly binds the total surcharge to the advertised fee plus the client's routing allowance. The client cannot distinguish routing cost from provider revenue, so it MUST choose that allowance as part of its own fee policy. +The reference Web UI uses a fixed 3000 msat allowance when the user does not set +an explicit `routing_msat`; an explicit value is enforced exactly. + +### Sybil and availability limits + +Cheapest-first selection prevents duplicate offers from increasing an +operator's selection probability when all duplicates advertise the same price: +only one identity is selected, and an attacker must undercut the honest price to +win. This is useful price competition, but it is not complete Sybil resistance. +A malicious zero-fee provider can win, learn the invoice, and refuse service. +Many low-fee identities can also occupy a bounded failover list without ever +serving. Per-offer proof of work limits relay spam, identity collapsing removes +free duplication behind one attested node, and anonymous identity proof of work +raises the cost of fresh identities, but none proves availability. + +Clients SHOULD keep bounded local failure history and de-prioritize identities +that repeatedly time out or return invalid responses. Such history is local and +advisory because identities can rotate and false reports must not become a way +to censor competitors. Stronger admission based on independently verifiable +channel funding, bonds, or privacy-preserving reputation is left for a future +version. ## Configurable nostr relays @@ -443,8 +516,12 @@ wss://nostr.mom Providers announce, in each offer's `relays` field, the relays on which they actually listen for requests; clients SHOULD send requests to the intersection -of their own relays and the provider's advertised relays, and MAY add the -provider's relays to their set for the duration of the exchange. +of their own relays and the provider's advertised relays. Automatic clients +MUST bound and canonicalize both sets. They MUST NOT connect to arbitrary +provider-supplied relay URLs unless those URLs pass the same address-class and +transport policy as direct endpoints and the user or deployment explicitly +allows extending the configured set. The reference Web UI uses only the bounded +intersection. ## Denial-of-service considerations @@ -459,10 +536,24 @@ provider's relays to their set for the duration of the exchange. enforce admission or request rate limits and MUST bound their request-ID cache without evicting active idempotency entries. - Providers SHOULD additionally bound the number of concurrently queued - requests and rate-limit how fast they dequeue them, dropping excess requests. + admitted requests, active hold invoices, aggregate pending amount, idempotency + entries, and request lifetime. They SHOULD rate-limit sources independently + where source information is available, with a global backstop, rather than + relying only on one global bucket that a single sender can monopolize. - Offer proof of work and (optionally) NIP-13 enforcement at the relay (NIP-11 `min_pow_difficulty`) limit advertisement spam; ranking by credential strength means flooding the visible provider list is progressively expensive. +- Clients MUST bound events processed per discovery round and per relay, content + and tag sizes, accepted identities, concurrent relay connections, and request + proof-of-work time. These client bounds protect the browser but can let a + malicious relay exhaust its own quota before honest events arrive. Clients + SHOULD isolate per-relay quotas and merge only verified events so one relay + cannot poison another relay's deduplication state. +- A fixed request-PoW target does not prevent unpaid requests from occupying all + hold-invoice slots. Providers SHOULD keep proxy invoice lifetimes no longer + than operationally necessary, apply occupancy-aware admission, and monitor + both circuit count and aggregate amount. This is an inherent availability + risk of issuing hold invoices before receiving payment. - Ephemeral event kinds do not guarantee deletion. Relays can retain ciphertext and metadata, and NIP-44 has no forward secrecy: compromise of a provider's persistent nostr key can expose previously captured requests. Clients and From 758d83846eecc11afd2e5fc1226ad679491d6f2f Mon Sep 17 00:00:00 2001 From: m0wer Date: Thu, 16 Jul 2026 08:43:01 +0200 Subject: [PATCH 09/10] docs: bound nostr response fanout --- nostr.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nostr.md b/nostr.md index 4a15829..b0334fd 100644 --- a/nostr.md +++ b/nostr.md @@ -549,6 +549,12 @@ intersection. malicious relay exhaust its own quota before honest events arrive. Clients SHOULD isolate per-relay quotas and merge only verified events so one relay cannot poison another relay's deduplication state. +- Providers SHOULD publish a response to the relay that delivered the admitted + request, with only a small bounded fallback set when source information is not + available. Publishing every attacker-triggered response to every configured + relay creates avoidable fanout. Relay publication failures SHOULD be tracked + per relay, and loss of Nostr connectivity MUST NOT take down an otherwise + healthy direct endpoint. - A fixed request-PoW target does not prevent unpaid requests from occupying all hold-invoice slots. Providers SHOULD keep proxy invoice lifetimes no longer than operationally necessary, apply occupancy-aware admission, and monitor From 8cb047dbdfdcadd0f2668358f1d7f4f423f3562f Mon Sep 17 00:00:00 2001 From: m0wer Date: Thu, 16 Jul 2026 08:53:25 +0200 Subject: [PATCH 10/10] docs: allow client reachable relay aliases --- nostr.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nostr.md b/nostr.md index b0334fd..91c4084 100644 --- a/nostr.md +++ b/nostr.md @@ -515,7 +515,9 @@ wss://nostr.mom ``` Providers announce, in each offer's `relays` field, the relays on which they -actually listen for requests; clients SHOULD send requests to the intersection +actually listen for requests, using client-reachable public URLs. A provider +that connects through an internal hostname or reverse proxy MAY advertise a +different URL for the same logical relay. Clients SHOULD send requests to the intersection of their own relays and the provider's advertised relays. Automatic clients MUST bound and canonicalize both sets. They MUST NOT connect to arbitrary provider-supplied relay URLs unless those URLs pass the same address-class and