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
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? }.paymentis a BIP321 URI (sobitcoin:?lno=— exactly whatpayViaBolt12Intentalready builds — orlightning=/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 viaNWCPaymentFilterAssembler, 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()(thePaymentSource.Nwcbranch).
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)withcreate(…);ReceiveMethod/ReceiveParams.rpc/Response.kt:PaySuccessResponse(all result fields above,payerProofnullable) andReceiveSuccessResponse(bip321, transactionId).rpc/NwcErrorCode.kt: add the two new codes.- Serializer branches in both
Nip47RequestKSerializer/Nip47ResponseKSerializerand the jvmAndroid Jackson variants. - Optional
Nip47Client.pay(...)builder. - Tests: request/response round-trip; a real captured
payresult 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 (mirrorssendZapPaymentRequestFor, reusesNwcSignerState).- 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) →paywithpayment=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 extendZapPaymentHandler): when the recipient has a kind-10058 offer and the wallet supportspay, offer a BOLT12 zap.- Build + sign kind-9737 intent (amount, offer, p, e/a/k, zap_id, content).
paywithpayment=bitcoin:?lno=<offer>,amount,payer_note="nostr:nipB1:<intent-id>".- On success with a
payer_proof,Bolt12ZapEvent.build(intent, payerProof, payerPubKey = own | null for anon), sign, publish to the recipient's inbox relays. - Our own
LocalCacheconsumes the 9736 and counts it.
- Anonymous vs attributed (
Ptag) toggle, mirroring lightning-zap anonymity.
Phase 3 — gating + receive (later)
- Read
NwcInfoEvent.supportsMethod("pay")/get_info.methodsto show the NWC BOLT12 options only when supported; else fall back to the intent. receiveto mint the user's own offer and pre-fill the kind-10058 editor.
Risks / open questions (decide before Phase 2)
payer_note→invreq_payer_noteis NOT guaranteed by nwc#2. The spec only says "ifpayer_noteis not empty, the selected instruction MUST support payer-provided messages" — it never states the note lands in the BOLT12invreq_payer_note. NIP-B1 binds the zap to the intent through exactly that field (invreq_payer_note == nostr:nipB1:<intent-id>). If a wallet routespayer_noteelsewhere, the returnedpayer_prooffails 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.payer_proofis 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").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 (seequartz/plans/2026-07-23-bolt12-zap-interop-vectors.md). Real wallet (selective-disclosure) proofs now reconstruct and verify, so a bound zap counts locally ascryptoVerified = true.- Maturity. Both nwc#2 and NIP-B1 are unmerged; few/no wallets implement
paytoday. 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 withpayer_note = nostr:nipB1:<intent-id>, and only on a proof that passesBolt12ZapValidatorself-consumes + publishes the 9736 viacomputeRelayListToBroadcast. 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 (totalWeightthreaded intosignAllZapRequests/assembleAllInvoices) so mixed splits stay proportional. Anonymous/public follows the account zap type.Bolt12ZapBuilderTestproves 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 viaNwcSignerState.infoCache(added onmainfor encryption negotiation; it already refreshes on wallet change) and checkssupportsMethod("pay"). A missing/unfetched info event reads as false. (An earlier cut fetchedget_info.methodsinto its own state; unified onto the 13194 cache on themainmerge to avoid a redundant fetch — the info event is the canonical capability advertisement.)- The zap path's
canBolt12now requires it, so a recipient with an offer but a wallet that can'tpayfalls 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, soupdateZapTotalcounts a bound zap locally. Validated against the lightning/bolts#1346 conformance vectors — seequartz/plans/2026-07-23-bolt12-zap-interop-vectors.md.