Files
amethyst/amethyst/plans/2026-07-24-nwc-bolt12-pay.md
T
Claude f72388a559 refactor: rename packages, helpers and app code after the NIP event renames
Follow-up to the event class renames: the names around those classes now
match too.

Quartz (published API, every old name kept as a @Deprecated alias/forwarder):
- nip51Lists packages moved: followList -> starterPack, hashtagList -> interestList,
  peopleList -> followSet, labeledBookmarkList -> bookmarkSet. The old packages
  keep a Deprecated.kt with typealiases and forwarding extension functions
  (ReplaceWith points at the new package).
- LnZapPrivateEvent -> PrivateZapEvent, LnZapReceiptValidator -> ZapReceiptValidator,
  ChannelListDiff -> PublicChatListDiff, HashtagListDiff -> InterestListDiff.
- Kind 30063 now indexes NIP-82 release notes for search (never NIP-51 content,
  which can hold encrypted private items). searchable-events docs updated.

App code (amethyst, commons, desktopApp, cli; no aliases needed):
- model packages nip51Lists.{hashtagLists,peopleList,labeledBookmarkLists,relayFeeds}
  -> {interestLists,followSets,bookmarkSets,favoriteRelays}.
- State/cache/UI names derived from the old event names: HashtagListState ->
  InterestListState, PeopleListsState -> FollowSetsState, FollowListsState ->
  StarterPacksState, LabeledBookmarkList -> BookmarkSet, RelayFeedListState ->
  FavoriteRelayListState, ContactCardsState -> UserAssertionsState, NIP90* view
  models/renderers -> Dvm*, LnZap* handlers -> Zap*, SealedRumor* -> Seal*, and
  Account.peopleLists/followLists/hashtagList -> followSets/starterPacks/interestList.
- String literals were left untouched, so preference keys and @SerialName values
  stored on disk are unchanged.
- The generic PeopleList UI model (shared by follow sets and starter packs) and the
  EmojiPackSelection route (which shows a kind 30030 pack) keep their names.

Docs: plans, brainstorms, changelogs, skills and READMEs use the new names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VvJ8XpeYqBe8bYT3bJ7UD
2026-09-26 20:56:07 +00:00

11 KiB

NWC BOLT12 payments (nostr-wallet-connect/nwc#2)

Status: scoping — no code yet. Depends on NIP-B1 (this branch) and the unmerged nostr-wallet-connect/nwc#2 (adds pay/receive to NIP-47).

Ecosystem status (2026-07-25) — the wallet gap for real testing

A real NWC-321 (pay/receive) service exists — benthecarman/nostr-wallet-connect-lnd — which confirms the protocol we built (Phase 0) is real and adopted. But it is LND-backed: its pay "selects and pays a BOLT11 lightning instruction from a BIP-321 URI" and "BOLT12 lno instructions are not supported by this LND backend." So it returns no BOLT12 invoice and no payer_proof, and can't drive the NIP-B1 zap loop. The blocker is the node backend, not the protocol: a CLN- or LDK-backed NWC-321 service (CLN has full BOLT12 and leads the payer-proof draft bolts#1346) would support lno + return payer_proof. Until one exists, the self-consistent interop harness (cli/tests/bolt12/, TODO) is the only way to exercise the full send → verify → count loop.

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(NwcResponseEvent) — 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:nipB1:<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-B1 binds the zap to the intent through exactly that field (invreq_payer_note == nostr:nipB1:<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. Resolved. The compressed-proof merkle reconstruction shipped and is validated byte-for-byte against the lightning/bolts#1346 conformance vectors (see quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md). Real wallet (selective-disclosure) proofs now reconstruct and verify, so a bound zap counts locally as cryptoVerified = true.
  4. Maturity. Both nwc#2 and NIP-B1 are unmerged; few/no wallets implement pay today. Gate hard on capability (Phase 3) and keep the intent fallback.

Resolution of risks #1/#2 (nwc#2 maintainer, 2026-07-24)

Asked on the nwc#2 thread. Maintainer confirmed:

  • payer_note → invreq_payer_note: "that is the intention" for BOLT12 (the field doubles as a general memo). So our zap binding (invreq_payer_note == nostr:nipB1:<intent-id>) is the intended target.
  • payer_proof: "should be returned for successful bolt12 payments"; the "optional if unavailable" wording only covers non-BOLT12 instruction types.

Both are informal maintainer intent, not yet spec text, so a non-conforming wallet is still possible. That's fine: after a pay returns, we run the returned payer_proof through Bolt12ZapValidator before publishing a kind:9736. A wallet that misroutes the note fails the binding check and we publish nothing — Phase 2 fails safe, never emitting an invalid receipt.

Recommendation

Phase 0 (done) and Phase 1 (done) shipped. Phase 2 is now unblocked. Build it with the validate-before-publish gate above; degrade to "paid, no zap receipt" when the proof is absent or fails validation.

Phase 2 as shipped (full zap-button integration, BOLT12-first)

Chosen: full integration into the zap pipeline, preferring BOLT12 when the recipient offers it.

  • Account.sendBolt12Zap(...) — signs a 9737 intent (ephemeral key for anonymous), pays over NWC with payer_note = nostr:nipB1:<intent-id>, and only on a proof that passes Bolt12ZapValidator self-consumes + publishes the 9736 via computeRelayListToBroadcast. No proof / invalid proof → "paid, no receipt".
  • ZapPaymentHandler.zap() resolves each recipient's kind:10058 offer and partitions recipients into a BOLT12 lane (offer present AND an NWC wallet is configured) and the existing lightning lane. Split weights are summed across both lanes (totalWeight threaded into signAllZapRequests/assembleAllInvoices) so mixed splits stay proportional. Anonymous/public follows the account zap type.
  • Bolt12ZapBuilderTest proves the send-side assembly round-trips to a validator-accepted, crypto-verified zap (attributed and anonymous).

Phase 3 as shipped (capability gate + lightning fallback)

  • Account.defaultWalletSupportsBolt12Pay() reads the default wallet's cached kind:13194 info event via NwcSignerState.infoCache (added on main for encryption negotiation; it already refreshes on wallet change) and checks supportsMethod("pay"). A missing/unfetched info event reads as false. (An earlier cut fetched get_info.methods into its own state; unified onto the 13194 cache on the main merge to avoid a redundant fetch — the info event is the canonical capability advertisement.)
  • The zap path's canBolt12 now requires it, so a recipient with an offer but a wallet that can't pay falls back to lightning via the existing partition instead of erroring.
  • AccountViewModel.canPayBolt12ViaNwc() gates the profile "pay with wallet" action on the same signal.

Residual (acceptable): capabilities are empty for the first moment after launch until get_info returns, so a very early zap can miss the BOLT12 rail and use lightning; and a wallet that doesn't populate get_info.methods never gets the BOLT12 rail even if it supports pay. Both fail safe toward lightning.

Known limitations (follow-ons, not blockers):

  • Compressed proofs aren't locally counted. Resolved. Compressed (selective-disclosure) proofs now reconstruct and verify, so updateZapTotal counts a bound zap locally. Validated against the lightning/bolts#1346 conformance vectors — see quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md.