From 802652dd4f519d0cc8619fceddde773d16fe8067 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:01:20 +0000 Subject: [PATCH] docs(bolt12): scope NWC BOLT12 pay/receive (nwc#2) integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- amethyst/plans/2026-07-24-nwc-bolt12-pay.md | 124 ++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 amethyst/plans/2026-07-24-nwc-bolt12-pay.md diff --git a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md new file mode 100644 index 0000000000..e689f0424e --- /dev/null +++ b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md @@ -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=`. 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=`, `amount`, + `payer_note="nostr:nipXX:"`. + 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:`). 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.