docs(bolt12): scope NWC BOLT12 pay/receive (nwc#2) integration

Captures the design for paying BOLT12 offers over NIP-47 and — via the pay
result's payer_proof — sending real kind:9736 zaps. Maps the reusable NIP-47
plumbing, breaks the work into phases, and flags the linchpin risk: nwc#2 does
not guarantee payer_note lands in the BOLT12 invreq_payer_note that NIP-2421's
zap binding depends on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
Claude
2026-07-24 21:01:20 +00:00
parent 6d111626ee
commit 802652dd4f
+124
View File
@@ -0,0 +1,124 @@
# NWC BOLT12 payments (nostr-wallet-connect/nwc#2)
Status: **scoping** — no code yet. Depends on NIP-2421 (this branch) and the
unmerged `nostr-wallet-connect/nwc#2` (adds `pay`/`receive` to NIP-47).
## What nwc#2 adds
Two generalized methods replace the bolt11-only `pay_invoice`:
- **`pay`** — params `{ payment: "bitcoin:?lno=lno1…", amount?, payer_note?, metadata? }`.
`payment` is a BIP321 URI (so `bitcoin:?lno=` — exactly what
`payViaBolt12Intent` already builds — or `lightning=`/on-chain). `amount`
(msats) is required only when the instruction has no amount. Result:
`{ transaction_id, state, instruction_type, amount, fees_paid, payment_hash,
preimage, payer_proof: "lnp1…", txid, failure_reason, created_at, settled_at }`.
- **`receive`** — params `{ amount?, description?, metadata? }` → result
`{ bip321: "bitcoin:?lightning=lnbc…&lno=lno1…", transaction_id }`. Lets a
wallet mint our own unified offer; ties to the kind-10058 editor (auto-fill
offers) — later phase.
New errors: `UNSUPPORTED_PAYMENT_INSTRUCTION`, `UNSUPPORTED_NETWORK`. Wallets
advertise support in their kind-13194 info event + `get_info.methods`.
## Two capabilities this unlocks
**A. In-app payment of an offer** — the "extra payment instruction" half. Today
`Bolt12PayButton` fires a `bitcoin:?lno=` intent to an external wallet. With `pay`
we can settle the offer over the user's already-configured NWC connection, no app
switch. No Nostr receipt.
**B. Sending real BOLT12 zaps** — the half deferred since the start of the branch.
The `pay` result carries **`payer_proof: "lnp1…"`**, which is precisely the input
`Bolt12ZapEvent.build(signedIntent, payerProof, payerPubKey)` needs. So NWC `pay`
is the payment rail that produces the proof for a kind-9736 zap. This is the
strategic reason to do this now.
## Existing NIP-47 infra we reuse (from the code map)
The send/await/correlate plumbing is **method-agnostic** — it keys on request id
and dispatches the decrypted `Response`, so a new method needs no changes there:
- `NwcSignerState.sendNwcRequestToWallet(uri, request, onResponse)`
(`amethyst/…/model/nip47WalletConnect/NwcSignerState.kt`) — builds the 23194,
synchronous REQ-before-EVENT via `NWCPaymentFilterAssembler`, 60 s timeout,
decrypt-on-arrival.
- `NwcPaymentTracker` (`commons/…/service/nwc/`) — request↔response match with the
author-spoof gate.
- `LocalCache.consume(LnZapPaymentResponseEvent)` — routes 23195 back to the callback.
- Wallet storage: `AccountSettings.nwcWallets` + `defaultPaymentSourceId`;
`PaymentSourceResolver`.
- Pay rail entry: `ZapPaymentHandler.zap()``payViaNWC()` (the `PaymentSource.Nwc`
branch).
Capability discovery exists (`NwcInfoEvent.supportsMethod`, `GetInfoResult.methods`)
but is **not** wired into the pay path — we'd add the gate ourselves.
## Work breakdown
### Phase 0 — quartz protocol (`nip47WalletConnect`)
- `NwcMethod.PAY = "pay"`, `NwcMethod.RECEIVE = "receive"`.
- `rpc/Request.kt`: `PayMethod` + `PayParams(payment, amount, payerNote, metadata)`
with `create(…)`; `ReceiveMethod`/`ReceiveParams`.
- `rpc/Response.kt`: `PaySuccessResponse` (all result fields above, `payerProof`
nullable) and `ReceiveSuccessResponse(bip321, transactionId)`.
- `rpc/NwcErrorCode.kt`: add the two new codes.
- Serializer branches in **both** `Nip47RequestKSerializer` / `Nip47ResponseKSerializer`
**and** the jvmAndroid Jackson variants.
- Optional `Nip47Client.pay(...)` builder.
- Tests: request/response round-trip; a real captured `pay` result fixture.
- No new third-party deps (pure protocol) → licensing clean.
### Phase 1 — in-app offer payment (capability A)
- `Account.sendNwcPayRequest(payment, amount, payerNote, onResponse)` wrapper
(mirrors `sendZapPaymentRequestFor`, reuses `NwcSignerState`).
- In `Bolt12PayButton`'s dialog: when an NWC wallet is configured, add a "Pay with
connected wallet" action (amount-entry sheet, since offers are often amountless)
`pay` with `payment=bitcoin:?lno=<offer>`. Keep the external-intent path as
fallback.
- Surface `UNSUPPORTED_PAYMENT_INSTRUCTION`/`PAYMENT_FAILED`.
### Phase 2 — send BOLT12 zaps (capability B)
- New `Bolt12ZapSender` (or extend `ZapPaymentHandler`): when the recipient has a
kind-10058 offer and the wallet supports `pay`, offer a BOLT12 zap.
1. Build + sign kind-9737 intent (amount, offer, p, e/a/k, zap_id, content).
2. `pay` with `payment=bitcoin:?lno=<offer>`, `amount`,
`payer_note="nostr:nipXX:<intent-id>"`.
3. On success with a `payer_proof`, `Bolt12ZapEvent.build(intent, payerProof,
payerPubKey = own | null for anon)`, sign, publish to the recipient's inbox
relays.
4. Our own `LocalCache` consumes the 9736 and counts it.
- Anonymous vs attributed (`P` tag) toggle, mirroring lightning-zap anonymity.
### Phase 3 — gating + receive (later)
- Read `NwcInfoEvent.supportsMethod("pay")` / `get_info.methods` to show the NWC
BOLT12 options only when supported; else fall back to the intent.
- `receive` to mint the user's own offer and pre-fill the kind-10058 editor.
## Risks / open questions (decide before Phase 2)
1. **`payer_note` → `invreq_payer_note` is NOT guaranteed by nwc#2.** The spec only
says "if `payer_note` is not empty, the selected instruction MUST support
payer-provided messages" — it never states the note lands in the BOLT12
`invreq_payer_note`. NIP-2421 binds the zap to the intent through exactly that
field (`invreq_payer_note == nostr:nipXX:<intent-id>`). If a wallet routes
`payer_note` elsewhere, the returned `payer_proof` fails our validator and the
9736 is worthless. **Phase 2 feasibility hinges on this** — needs confirmation
in the nwc thread / a reference wallet, or a follow-up to nwc#2 to nail it down.
2. **`payer_proof` is best-effort** (`"optional if unavailable"`, no wallet mandate).
A wallet may settle the offer and return no proof → payment succeeds but we
can't publish a zap. Phase 1 is unaffected; Phase 2 must degrade gracefully
("paid, but no zap receipt available").
3. **Our verifier can't check compressed proofs yet** (see
`quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md`). Real wallet proofs are
compressed, so a zap we send may show locally as unverified. Publishing is fine;
local counting waits on the merkle-reconstruction work.
4. **Maturity.** Both nwc#2 and NIP-2421 are unmerged; few/no wallets implement
`pay` today. Gate hard on capability (Phase 3) and keep the intent fallback.
## Recommendation
Phase 0 is safe, self-contained, and unblocks everything — do it regardless.
Phase 1 (in-app pay) is low-risk and immediately useful. **Hold Phase 2 until
risk #1 is resolved** — it's the high-value piece (real zap sending) but its
correctness depends on a binding the current nwc#2 text doesn't guarantee.