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/ diff --git a/README.md b/README.md index 84c7688..fd059eb 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 decentralized discovery over +[nostr](https://github.com/nostr-protocol/nips): providers advertise themselves +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 1. User makes a POST request to a relay like: @@ -87,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. diff --git a/nostr.md b/nostr.md new file mode 100644 index 0000000..91c4084 --- /dev/null +++ b/nostr.md @@ -0,0 +1,591 @@ +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). 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 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 + +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 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 + +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 +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 +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 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 + 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 `(kind, pubkey, d-tag)`, so each provider pubkey has exactly +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. + +## 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 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`. 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 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 +{ + "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 direct HTTP/onion wrap endpoints, in preference order + "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. + +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, +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. + +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. + +### 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. Clients MUST collapse all valid offers with +the same `node_pubkey` into one provider identity for automatic selection. + +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) + +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 (`||` 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. + +Clients MAY require either a valid node attestation or a minimum anonymous +`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) + +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 +{ + "kind": 21821, + "tags": [ + ["p", ""], + ["nonce", "", "= min_request_pow>"] + ], + "content": "", + ... // pubkey = ephemeral key, signed normally +} +``` + +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 + "provider_pubkey": "", // REQUIRED + "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. + +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. + +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. 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 +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 +ephemeral key and referencing the request: + +```jsonc +{ + "kind": 21822, + "tags": [ + ["p", ""], + ["e", ""], + ["nonce", "", "20"] + ], + "content": "", + ... +} +``` + +Before decrypting, clients MUST verify the event signature, kind, selected +provider pubkey, exact request `e` tag, recipient `p` tag, and required proof of +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": "" } +``` + +or + +```jsonc +{ "request_id": "", "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. + +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`, +`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 +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 +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 | +| `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 +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. 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 + +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. 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; + 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; + 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`. 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`), 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 + 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. +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. 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 + +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, 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 +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 + +- 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 + 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 + 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. +- 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 + 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 + 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 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) 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.