- name
- zeus-lightning-reference
- description
- Bitcoin/Lightning/ecash domain theory AS IMPLEMENTED IN ZEUS, for readers who may know React Native but zero Lightning (or vice versa). Load when working on or reading code that touches - BOLT11 invoice decoding (utils/Bolt11Utils.ts), payments (MPP/AMP/keysend, TLV records, routing fee limits), LNURL pay/withdraw/auth/channel or lightning addresses, ZEUS Pay / Zaplocker / nostr attestations, LSP flows (Olympus Flow 2.0, jit_bolt11 wrapping, LSPS0/1/7, 0-conf channel acceptance), Cashu/ecash (CDK FFI, multimint melts, token extraction), Nostr Wallet Connect (NIP-47, service and client roles), Boltz-protocol swaps, on-chain (address types, taproot, PSBT, coin control, CPFP bump retry, mempool.space fees), the universal input router utils/handleAnything.ts and its detector ordering, CLINK/noffer, or watchtowers. Also load when confused by terms like HTLC, preimage, hodl invoice, JIT channel, melt quote, submarine swap, or keysend while reading Zeus code.
# Zeus Lightning & Ecash Domain Reference
Protocol knowledge tied to the exact Zeus file that implements it, plus the Zeus-specific
twist on each protocol. Every claim below was verified against the code at the commit in
"Provenance and maintenance". Paths are repo-relative.
## When to use / When NOT to use
**Use this skill when** you need to understand WHAT a protocol does and HOW Zeus implements
it: decoding invoices, payment flows, LNURL, ZEUS Pay, LSPs, Cashu, NWC, swaps, on-chain,
or the input router.
**Do NOT use this skill for:**
| Need | Use instead |
| --- | --- |
| Which of the 7 backends supports feature X; per-backend RPC quirks; adding an RPC | `zeus-backends-and-capabilities` |
| Settings defaults, config axes, add-a-setting checklist | `zeus-config-and-flags` |
| Keychain keys, storage blob, migrations (e.g. Cashu per-node namespacing details) | `zeus-storage-and-migrations` |
| Boot sequence, stores DI, dispatch design rationale | `zeus-architecture-contract` |
| Symptom-driven debugging, past incident history | `zeus-debugging-playbook` / `zeus-failure-archaeology` |
| PR/commit/review rules, what changes are gated | `zeus-change-control` |
One rule from change control that this skill must echo because everything here is
funds-touching: **send/receive/payment-handling code takes minimal diffs only — no
drive-by refactoring.** See `zeus-change-control` for the full policy.
## 1. Lightning in three paragraphs (just enough to read this codebase)
**Channels and HTLCs.** The Lightning Network is a payment layer on top of Bitcoin. Two
nodes lock bitcoin into a shared 2-of-2 on-chain output called a **channel**; they then pay
each other instantly by exchanging signed balance updates off-chain. Multi-hop payments are
forwarded through chains of channels using **HTLCs** (Hashed Time-Locked Contracts): each
hop's payment is locked to the SHA-256 hash of a secret (the **preimage**); revealing the
preimage settles every hop atomically. A **hodl invoice** is one where the receiver
deliberately delays revealing the preimage — the payment hangs "in-flight" until released
(ZEUS Pay's Zaplocker scheme, section 5, is built on this). **0-conf** channels are usable
before their funding transaction confirms — safe only when you trust the channel opener,
which is why Zeus only auto-accepts 0-conf from its LSP (section 6).
**Invoices and routing.** A **BOLT11 invoice** (a `lnbc...` bech32 string) encodes amount,
payment hash, expiry, destination and a signature (section 2). **BOLT12 "offers"**
(`lno1...`) are reusable payment codes; Zeus supports them on the cln-rest and ldk-node
backends. Payment senders find a route themselves and pay **routing fees** to intermediate
hops; wallets set a fee cap per payment (section 3). **MPP** (multi-path payments) split
one payment across several routes; **AMP** (atomic multi-path) is LND's variant that also
works without an invoice. **Keysend** is a spontaneous payment (no invoice) where the sender
generates the preimage and ships it inside the payment's **TLV** (type-length-value) custom
records.
**LSPs and the rest.** An **LSP** (Lightning Service Provider) sells inbound channel
capacity, including **JIT** (just-in-time) channels opened at the moment a payment arrives
(fee deducted from the received amount). **LNURL** is a family of HTTP-based UX protocols
(pay/withdraw/auth/channel) layered on Lightning; a **lightning address**
(`user@domain.com`) is LNURL-pay behind an email-like name. **Ecash (Cashu)** is a
custodial-but-blinded token system: a **mint** holds sats and issues bearer tokens
(**proofs**) it cannot link to you; **melting** a token converts it back into a Lightning
payment. **NWC** (Nostr Wallet Connect, NIP-47) lets apps drive a wallet remotely over the
Nostr relay network. **Submarine swaps** move funds between on-chain and Lightning through
a swap provider (Boltz protocol): submarine = on-chain in, Lightning out; reverse = Lightning
in, on-chain out.
## 2. BOLT11 invoices — `utils/Bolt11Utils.ts`
Zeus does NOT use a stock bolt11 npm decoder; it has a hand-rolled one (based on
light-bolt11-decoder) with two load-bearing performance features:
1. **LRU cache** (`CACHE_LIMIT = 256`), keyed by the lowercased payment request. The
Activity list decodes the same invoices repeatedly; the cache collapses that. Cache hits
return **the same object instance** to every caller.
2. **Lazy self-replacing getters** for `destination`, `payeeNodeKey`, `signature`,
`recoveryFlag`. Recovering the payee node key from the invoice signature is an
ECDSA public-key recovery via `@noble/secp256k1` (~5–20 ms per call on a phone — the
comment in the file says so). `decode()` defers it: those four fields are installed as
`Object.defineProperty` getters that run recovery on first access, then replace
themselves with plain values. Callers that only read `payment_hash` / `description` /
`expiry` / `timestamp` pay zero crypto cost. If the invoice carries an explicit payee
tag (tag 19), `destination`/`payeeNodeKey` are set eagerly from it and the getters are
skipped.
**Consequences — the rules:**
- **Never naively serialize or spread a decoded invoice** (`JSON.stringify(decoded)`,
`{...decoded}`). The lazy getters are enumerable, so both operations force the expensive
secp256k1 recovery immediately — for every cached invoice you touch — defeating the
entire design. Read individual fields instead.
- **Never mutate the returned object.** It is shared cache state; your mutation poisons
every future `decode()` of that invoice.
- `decode()` throws on non-`ln` strings — callers gate with
`AddressUtils.isValidLightningPaymentRequest` first (see `utils/handleAnything.ts`).
- Amount fields: `satoshis` (number|null), `millisatoshis` (string|null), plus
Zeus-convention `num_satoshis` / `num_msat` (strings, `'0'` when absent).
The interface is `DecodedBolt11` in the same file. Tests: `utils/Bolt11Utils.test.ts`.
## 3. Paying: MPP, AMP, keysend, fee limits
Payment dispatch lives in `stores/TransactionsStore.ts` (`sendPayment` →
`sendPaymentInternal`); the send UI is `views/PaymentRequest.tsx`.
### MPP vs AMP and their version gates
- `supportsMPP()` and `supportsAMP()` are per-backend capability flags (see
`zeus-backends-and-capabilities` for the doctrine). On the LND family
(`backends/LND.ts`, `backends/EmbeddedLND.ts`, `backends/LightningNodeConnect.ts`)
they are node-version-gated: MPP requires LND ≥ v0.10.0, AMP requires LND ≥ v0.13.0.
`backends/LdkNode.ts`: MPP true, AMP false. CLNRest, LndHub, NostrWalletConnect: both false.
- AMP is also **forced on** when the invoice's feature bit 30 is `is_required`
(`views/PaymentRequest.tsx`, `lockAtomicMultiPathPayment`). Enabling AMP sets
`no_inflight_updates = true`. Tor also sets `no_inflight_updates = true` ("Tor can't
handle streaming updates").
### The `max_parts` quirk (verify before relying on defaults)
- `TransactionsStore.sendPaymentInternal`: `data.max_parts = max_parts ? max_parts : '1'`
— the **store default is `'1'`** (single path) for any caller that doesn't pass it.
- `views/PaymentRequest.tsx` `triggerPayment`:
`max_parts: enableMultiPathPayment ? maxParts : '16'` with state default
`maxParts: '16'` — the **main send screen always passes `'16'`**, whether or not the
MPP toggle is on. So payments from PaymentRequest allow 16 shards; payments from other
call sites (e.g. programmatic sends) allow 1.
- `sendPaymentSilently` has `if (max_parts) { data.max_parts = max_parts || '16'; }` —
the `|| '16'` branch is unreachable (dead fallback); if the caller omits `max_parts`
the field is simply not set.
### Keysend
Constants at the top of `stores/TransactionsStore.ts`:
| TLV record type | Content | Encoding in Zeus |
| --- | --- | --- |
| `5482373484` | payment preimage (sender-generated) | random bytes → base64 (`preimage.toString('base64')`) |
| `34349334` | optional text message | UTF-8 → hex → base64 (`Base64Utils.hexToBase64(Base64Utils.utf8ToHex(message))`) |
Plus: `data.dest = Base64Utils.hexToBase64(pubkey)` and
`data.last_hop_pubkey = Base64Utils.hexToBase64(...)` — **LND REST wants raw bytes
base64-encoded, not hex strings**; forgetting the conversion is a classic bug.
`payment_hash` is `hexToBase64(sha256(preimage))`.
Function selection is one of the few sanctioned `implementation` branches (see
`zeus-architecture-contract`): `cln-rest` / `embedded-lnd` / `ldk-node` with a pubkey go
through `BackendUtils.sendKeysend`; everything else — including remote LND REST, which has
no dedicated keysend endpoint — goes through `BackendUtils.payLightningInvoice` carrying
`dest_custom_records`.
### Routing fee conventions — `utils/FeeUtils.ts`
`calculateDefaultRoutingFee(amount)`:
- amount > 1000 sats → fee cap = 5% (`DEFAULT_ROUTING_FEE_PERCENT = 0.05`), rounded.
- amount ≤ 1000 sats → fee cap = the amount itself (i.e. 100%; tiny payments routinely
need proportionally huge fees).
`views/PaymentRequest.tsx` passes `max_fee_percent` (default `'5.0'`) for cln-rest, which
takes a percent instead of a sat limit; the store only forwards `max_fee_percent` when
`implementation === 'cln-rest'`.
## 4. LNURL family and lightning addresses
Zeus uses the `js-lnurl` package (`getParams`, `findlnurl`, `decodelnurl` — imported in
`utils/handleAnything.ts`; version pinned in `package.json`). LNURL flows are routed by
the input router (section 11) to one of four screens based on `params.tag`:
| tag | Meaning | Zeus screen |
| --- | --- | --- |
| `payRequest` (LNURL-pay / LUD-06) | server gives min/max, you request an invoice from its callback | `views/LnurlPay/` (or `ChoosePaymentMethod` when ecash is enabled) |
| `withdrawRequest` (LNURL-withdraw) | server offers sats; you submit YOUR invoice to its callback | `views/Receive.tsx` with `lnurlParams` |
| `channelRequest` (LNURL-channel) | server opens a channel to you | `views/LnurlChannel.tsx` |
| `login` (LNURL-auth) | key-based login challenge | `views/LnurlAuth.tsx`, gated by `supportsLnurlAuth()` |
**Lightning address (LUD-16)** resolution is inline in `handleAnything.ts`: split
`user@domain`, fetch `https://<domain>/.well-known/lnurlp/<user>` (plain `http://` for
`.onion`). Per LUD-16 the domain is always lowercased and the username normally is too —
**except for `cryptoqr.net` addresses**, whose usernames are URL-encoded merchant payloads
where hex-digit casing matters server-side (`isCryptoQR` check; covered by
`utils/handleAnything.test.ts`). Before the LNURL-pay lookup, Zeus also tries a **BOLT12
DNS lookup** (BIP-353 style: TXT record at `<user>.user._bitcoin-payment.<domain>` via
`cloudflare-dns.com/dns-query`) and offers a payment-method choice if both exist.
**Onion gap:** direct `.onion` lightning-address lookups go through `doTorRequest` when
Tor is enabled, but generic LNURL parameter fetches via `js-lnurl`'s `getParams` do NOT
route over Zeus's internal Tor — there is a literal
`// TODO handle fetching of params with internal Tor` in `handleAnything.ts`; `.onion`
LNURL endpoints currently error out of that path. Open gap, not a bug you introduced.
Nested URI schemes `lnurlp://` / `lnurlw://` / `lnurlc://` / `lnurlauth://` are rewritten
to `https://` (or `http://` for `.onion`) before processing.
## 5. ZEUS Pay / Zaplocker — `stores/LightningAddressStore.ts` + `stores/LnurlPayStore.ts`
ZEUS Pay gives a self-custodial wallet a lightning address (`user@zeuspay.com`) even
though the phone is usually offline. Server: `LNURL_HOST = 'https://zeuspay.com'`. Three
address types exist (`address_type` sent to the server): `'zaplocker'` (hodl-invoice
scheme, below), `'cashu'` (payments held as Cashu mint quotes, redeemed via
`/api/lnurl/nuts/redeem`), and `'nwc'`. Gate: `supportsLightningAddress()` in
`utils/BackendUtils.ts` is the composite `supportsCustomPreimages() || supportsCashuWallet()`.
**Zaplocker lifecycle (receiver side):**
1. **Pre-generate preimages**: `generatePreimages` creates **250 preimages per batch**
(32-byte entropy each), computes `hash = sha256(preimage)`, schnorr-signs each hash
with the user's nostr private key, and POSTs `{pubkey, hashes, nostrSignatures, ...}`
to `zeuspay.com/api/lnurl/submitHashes`. Preimages stay ONLY on the phone
(storage key `zeuspay-lightning-address-hashes`). Auto-replenishes when the server
reports fewer than 50 unused hashes.
2. **Someone pays**: zeuspay.com issues a hodl invoice against one of your hashes and
holds the HTLC. It cannot settle — it doesn't know the preimage.
3. **Redeem** (`lookupPreimageAndRedeemZaplocker`): the app looks up the preimage for the
hash, creates a local invoice with that **fixed preimage**
(`BackendUtils.createInvoice({ preimage, expiry: '86400', ... })`), and POSTs it to
`/api/lnurl/redeem`; the server pays it, which reveals the preimage and settles the
held HTLC. This is why the gate is `supportsCustomPreimages` — the backend must accept
caller-chosen preimages.
**Anti-fraud attestations (nostr kind 55869):**
- **Payer side** (`LnurlPayStore.broadcastAttestation`, triggered from
`views/PaymentRequest.tsx` when `isZaplocker`): publish a kind-`55869` event, signed by
an **ephemeral key**, whose `p` tag is `getPublicKey(paymentHash)` — the payment hash
itself is used as a nostr secret key so anyone holding the hash can find attestations —
and whose content is the bolt11 invoice being paid.
- **Receiver side** (`LightningAddressStore.lookupAttestations`): for each hash, query
relays for kind 55869 with `#p = getPublicKey(hash)`, then `analyzeAttestation` checks
the embedded invoice actually commits to that hash and amount. Exactly one valid
attestation → success status; **more than one attestation → status `'error'`** (a fraud
signal: two different "payers" claiming the same hash means someone is lying).
- The payer also verifies the receiver's zaplocker setup: `LnurlPayStore.load` checks
schnorr signatures over the payment hash (`isPmtHashSigValid`) and the relay list
(`isRelaysSigValid`) against the receiver's advertised nostr pubkey.
## 6. LSP integration — three generations, all in `stores/LSPStore.ts`
Default endpoints (Olympus, per network) are exported from `stores/SettingsStore.ts`
(`DEFAULT_LSP_MAINNET = 'https://0conf.lnolymp.us'`, LSPS1 REST
`https://lsps1.lnolymp.us`, etc.); resolution via `getLspConfig` — endpoint/default
details belong to `zeus-config-and-flags`.
### Generation 1: Olympus "Flow 2.0" REST (JIT channels on receive)
- `getZeroConfFee` POSTs the msat amount to `<flowHost>/api/v1/fee`;
`getZeroConfInvoice(bolt11)` POSTs to `/api/v1/proposal` and resolves `data.jit_bolt11`
— a **wrapped invoice** routing through the LSP, which opens a 0-conf channel just in
time and deducts its fee.
- **The wrapping MUTATES the requested amount** (`stores/InvoicesStore.ts`,
`createInvoice`): when Flow LSP is active and `value > zeroConfFee`, Zeus creates the
local invoice for `req.value = requested − zeroConfFee`, then swaps the displayed
payment request for `jit_bolt11` (which asks the payer for the full amount). The payer
sees the full amount; your node's invoice is smaller by the fee.
- **Silent degradation**: if `getZeroConfInvoice` rejects, Zeus clears the error state and
shows the UNWRAPPED invoice — receiving still works if a channel already exists, but no
JIT channel will be opened. Don't "fix" the swallowed error without understanding this.
- **LNURL-withdraw interaction**: the withdraw callback must receive the wrapped invoice —
`qs.pr = jit_bolt11 || invoice.getPaymentRequest` in `InvoicesStore`. Passing the
unwrapped one would make the withdrawing service pay an invoice the LSP never sees.
### Generation 2/3: LSPS0 transport + LSPS1 (buy channel) / LSPS7 (extend lease)
Auf GitHub ansehen