Skip to content

Add BOLT12 zaps - #2421

Open
benthecarman wants to merge 1 commit into
nostr-protocol:masterfrom
benthecarman:bolt12-zaps
Open

Add BOLT12 zaps#2421
benthecarman wants to merge 1 commit into
nostr-protocol:masterfrom
benthecarman:bolt12-zaps

Conversation

@benthecarman

@benthecarman benthecarman commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Add BOLT12 offers and payer proofs as another zap option alongside the NIP-57 LNURL callback flow. Bind each Lightning payment proof to a signed Nostr zap intent so clients can verify settled payments and compute cumulative zap amounts without a recipient-operated receipt publisher.

@shocknet-justin

Copy link
Copy Markdown
Contributor

CLINK offers already solved the LNURL/HTTP problem and has gotten uptake, so interesting framing/timing.

The delay tolerance aspect sounds nice on the surface, until clients have to implement it. Waiting for settlement, because someone was convinced running a payment server on a phone was a good idea, shifts A LOT to clients.

Beyond this clients will also now need additional remote wallet connection surface to background settle.

That doesn't even make attribution less dubious, attribution is still the signed intent as lnp doesn’t bind Nostr↔payer.

Comment thread XX.md Outdated
- `description`: serialized zap intent event.
- `p`: recipient pubkey.
- `amount`: amount in millisatoshis.
- `offer`: canonical raw BOLT12 offer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth including this? Any validating client will have to examine the profile to check the offer matches anyway, and a non-validating client (which hopefully isn't common?) doesn't care about the offer anyway.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, when people switch offers in their profiles, does it invalidate all past zaps? Or are there ways to know that two or more offers come from the same "account"?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would implicitly. We could, I suppose, include the signed profile info to prove the offer came from the recipient?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah we could embed the 10058 event instead so you can verify that its actually for the user without fetching it yourself and makes historical ones possible to validate

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like it'd be relatively cheap to do it? Or would we be worried about the storage cost of it?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggested below that it might be enough to embed a hash of the bolt12: #2421 (comment)

without fetching it yourself

I don't think that's too much to ask. And verifying historical zaps seems like a niche concern.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And verifying historical zaps seems like a niche concern.

I dunno, I wouldn't generally expect that all of my zaps would dissapear just because I switched wallets and updated my offer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They don't disappear. Others just can't verify them. You could also make bolt12_offer not replaceable and have clients consider the highest timestamp one canonical. Presumably it won't get rotated that often anyway.

@vitorpamplona vitorpamplona Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use a variation of a p tag to prove the receiver P has authorized the signer in each zap.

Setup (recipient, once per service)

  1. The service generates its own keypair K.
  2. The recipient computes m = tagged_hash("nostr-zap-signer", K), with K as 32 raw bytes (public key).
  3. The recipient signs m with his regular identity key R, producing sig.
  4. The recipient hands the service the tag ["p", R, relay, sig].

Signing (service, every zap)

  1. The service builds the zap event.
  2. It adds the p tag unchanged.
  3. It signs the event with K.

Verifying (anyone)

  1. Read K from the event pubkey.
  2. Read R, relay, and sig from the p tag.
  3. Recompute m = tagged_hash("nostr-zap-signer", K).
  4. BIP340-verify sig against R. Fail → reject.
  5. Verify the event signature against K. Fail → reject.
  6. Pass → the zap is provably authorized by R.

The proof lives in each event. Rotating services never invalidates old zaps.

No need to check the offer every time.

And the receiver can have multiple zap services running at the same time

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They don't disappear. Others just can't verify them.

Sure, but its weird that a new client won't see them.

Signing (service, every zap)

Eh, it'd be nice to not require yet another service? It seems fairly straightforward to just include the signed offer, or

You could also make bolt12_offer not replaceable and have clients consider the highest timestamp one canonical. Presumably it won't get rotated that often anyway.

seems fine to me too.

Comment thread XX.md Outdated

## Zap Intent

Before paying, the payer creates and signs a zap intent event. The zap intent binds the payer's Nostr key to the recipient, target, amount, and BOLT12 offer before the Lightning payment is attempted.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, seems like a lot of overhead to include intents here and we already require the payer_note match the zap info, why not just put whatever we need in there and drop the second event?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This event isn't broadcast, its just embedded in the payer_note / zap event. This is similar to 9734 events in bolt 11 zaps, we want a signed message from the payer so we can commit the bolt12 invoice to them and vice versa. We use an event rather than a simple signed message because this is easier for clients because they already sign lots of events, it also works better for setups with remote keys where they can just whitelist this event type for signing.

Comment thread XX.md Outdated
Comment thread XX.md Outdated
Comment thread XX.md Outdated
@vitorpamplona

Copy link
Copy Markdown
Collaborator

Need to define where we get the offer for each user: kind: 0, another new kind: 10xxx for offers only, or maybe a PayTo event.

@staab

staab commented Jul 23, 2026

Copy link
Copy Markdown
Member

Please not kind 0

@benthecarman

benthecarman commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

another new kind: 10xxx for offers only

can you explain this?

I tried modeling this off of NIP 57 and it didn't list any of the things like this

@vitorpamplona

Copy link
Copy Markdown
Collaborator

Basically this: #2217

@benthecarman

Copy link
Copy Markdown
Contributor Author

@vitorpamplona added

@vitorpamplona

Copy link
Copy Markdown
Collaborator

Nice! How do we test payments to these offers today? Do we have a NWC channel also developed for bolt 12? Or do we need to call some lightning: uri with a bolt 12 payment?

@benthecarman

Copy link
Copy Markdown
Contributor Author

For nwc, we want people to transition to the bip321 based methods defined here: nostr-wallet-connect/nwc#2

@vitorpamplona

Copy link
Copy Markdown
Collaborator

Also, pick a NIP hex, please. How about B1?

@vitorpamplona

Copy link
Copy Markdown
Collaborator

I couldn't test end-to-end at all, but added: vitorpamplona/amethyst#3685

davotoula pushed a commit to davotoula/amethyst that referenced this pull request Jul 25, 2026
Implements the quartz-side of the proposed "BOLT12 Zaps" NIP
(nostr-protocol/nips#2421): public, self-verifying zap events that prove a
BOLT12 payment without an LNURL server or recipient-operated receipt publisher.

Events (nip-88 style templates/tags/builders):
- Bolt12ZapEvent (kind 9736) and Bolt12ZapIntentEvent (kind 9737)
- shared tags: amount (msats), offer, proof, P (payer), zap_id, description
- registered both kinds in EventFactory

BOLT12 decoding (new, no existing KMP library):
- Bolt12Bech32: canonicalization (+ continuation / whitespace) + no-checksum,
  no-length-limit bech32 for lno1 offers and lnp1 payer proofs
- Tlv: BigSize codec, TLV stream reader/writer, tu64 helpers
- Bolt12Offer / Bolt12PayerProof parsers (proof TLV types per lightning/bolts#1346)
- Bolt12Merkle: BOLT12 tagged-hash + signature merkle root + signature digest

Validation:
- Bolt12ZapValidator runs the NIP's steps (structure, embedded-intent match,
  payer-proof binding: invreq_payer_note == nostr:nipXX:<intent-id>,
  invoice_amount == amount) and returns a typed result with the payment-hash
  dedup key
- Bolt12ProofVerifier checks preimage->payment_hash and the invoice/proof
  BIP-340 signatures for fully-disclosed proofs; compressed proofs are reported
  as unverified pending the (still-draft) lightning/bolts#1346 test vectors
- Bolt12ZapBuilder assembles+signs the intent and the final zap

Tests: 24 commonTest cases covering bech32/TLV/merkle round-trips, event tag
structure + factory typing, and validator accept/reject paths (self-signed
BOLT12 fixtures exercise the full merkle + schnorr path).

Note: this is the receive/verify + assembly layer only. Origination is blocked
on the upstream BOLT12 payer-proof spec merging and a wallet/NWC rail exposing
lnp proofs; no Amethyst payment rail returns one today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
@fiatjaf

fiatjaf commented Jul 25, 2026

Copy link
Copy Markdown
Member

Basically everybody on Nostr uses custodial wallets. Is BOLT12 supported by any of those?

@benthecarman

Copy link
Copy Markdown
Contributor Author

Basically everybody on Nostr uses custodial wallets. Is this supported by any of those?

Some cashu mints support it, strike supports paying bolt12s

@fiatjaf

fiatjaf commented Jul 25, 2026

Copy link
Copy Markdown
Member

What is the point of the "zap intent"? Can't you just publish the "zap" directly with the proof, tagging the recipient and the target event directly and be done with it?

@benthecarman

Copy link
Copy Markdown
Contributor Author

What is the point of the "zap intent"? Can't you just publish the "zap" directly with the proof, tagging the recipient and the target event directly and be done with it?

As stated above, to tie the bolt12 invoice to the zap and user. Prevents others from replaying your zap as their own

Comment thread B1.md
@fiatjaf

fiatjaf commented Jul 25, 2026

Copy link
Copy Markdown
Member

What is the point of the "zap intent"? Can't you just publish the "zap" directly with the proof, tagging the recipient and the target event directly and be done with it?

As stated above, to tie the bolt12 invoice to the zap and user. Prevents others from replaying your zap as their own

I see, but this is too ugly. It makes sense on NIP-57 but here it is just unnecessary to have these fields embedded inside a JSON blob inside a tag.

You could instead just define a "virtual event" that is produced with the intent of merely having its signature checked and then discarded, for example.

Something like:

  1. Prepare the zap event E to be published;
  2. Compute the signature S of that event;
  3. When making the payment, include the id of E in the bolt12 payment;
  4. As the payment is made and the payment proof built, modify that event E into a new event P by adding the tag "proof", the tag "proof" should include the signature S as the third item.
  5. When verifying, the reader sees P and from it rebuilds event E (by removing the tag "proof" and assigning to it the signature S) then checks that.

@benthecarman

Copy link
Copy Markdown
Contributor Author

@fiatjaf I guess that works but feels a lot more complex and error prone. This flow is how everything else does it in nostr afaik

@fiatjaf

fiatjaf commented Jul 26, 2026

Copy link
Copy Markdown
Member

Nothing else uses this weird maneuver of embedding events as strings inside other events except for NIP-57 and the reposts stuff, which is also ugly and a bad idea I always argued against.

But it was just a suggestion to save space and make things more elegant. I have no horse in this race anyway.

@benthecarman

Copy link
Copy Markdown
Contributor Author

Also, pick a NIP hex, please. How about B1?

Done

@vitorpamplona

Copy link
Copy Markdown
Collaborator

Did you pick BI? BI is not a hex :)

@benthecarman

Copy link
Copy Markdown
Contributor Author

Did you pick BI? BI is not a hex :)

Woops lol, changed to b1

@barrydeen

Copy link
Copy Markdown

ACK

@Sjors

Sjors commented Jul 27, 2026

Copy link
Copy Markdown

Nice!

So if I understand correctly, as a user I just copy a bolt12 from my lightning node, paste it into a nostr client, and it just works(tm)?

Render therefore unto Nostr the things which are Nostr's; and unto Lightning the things that are Lightning's.

without a recipient-operated receipt publisher

This is great, for at least two reasons:

  1. avoids nostr-related code with access to the lightning node (even if in theory it could be read-only).
  2. not exposing nsec to lightning software

alongside the NIP-57 LNURL callback flow

I used to do this with BTCPay, and it required a plugin and custom nginx rules. It was also very hard to debug when zaps failed.

And this removes the dependency on DNS (other than nip-05 to find a user).

@Sjors

Sjors commented Jul 27, 2026

Copy link
Copy Markdown

The payer embeds the announcement in the zap intent and zap event so that clients can verify that the recipient authorized the selected offer.

Why not just include a reference? If for some reason a nostr client can't find the announcement, then it can't verify that zap, but that doesn't sound like a big deal.

Maybe instead of a reference to a bolt12_offer, zaps can reference the hash of a bolt12. That way clients can count zaps to any bolt12 they remember, even if relays only publish the most recent bolt12_offer. That separates two concerns: letting recipients change where new zaps go, vs letting the general public count valid zaps.

Why is the zap intent signed? If you don't sign it, then bolt12_zap can just be a bolt12_zap_intent with extra fields and a signature.

Prevents others from replaying your zap as their own

I understand how that could happen. The NIP says:

The payer MUST include the zap intent event id as the BOLT12 invreq_payer_note:
nostr:nipXX:<zap-intent-event-id>

The zap intent commits to the zapper (ephemeral) public key.

Add BOLT12 offers and payer proofs as another zap option alongside the
NIP-57 LNURL callback flow. Bind each Lightning payment proof to a
signed Nostr zap intent so clients can verify settled payments and
compute cumulative zap amounts without a recipient-operated receipt
publisher.
@benthecarman

Copy link
Copy Markdown
Contributor Author

@Sjors

Why not just include a reference? If for some reason a nostr client can't find the announcement, then it can't verify that zap, but that doesn't sound like a big deal.

It's not much different to include it and then get the full benefit of just needing the single zap event to e2e verify

Maybe instead of a reference to a bolt12_offer, zaps can reference the hash of a bolt12. That way clients can count zaps to any bolt12 they remember, even if relays only publish the most recent bolt12_offer. That separates two concerns: letting recipients change where new zaps go, vs letting the general public count valid zaps.

Just including the bolt12 offer event solves all this more simply.

Why is the zap intent signed? If you don't sign it, then bolt12_zap can just be a bolt12_zap_intent with extra fields and a signature.

It makes it so you can't forge zaps for others.

@benthecarman

Copy link
Copy Markdown
Contributor Author

Fixed a few other references in the NIP that still said NIP-XX instead of NIP-B1

asoltys added a commit to coinos/coinos-server that referenced this pull request Aug 1, 2026
Tightens our implementation of the BIP-321-based NWC methods proposed
in nostr-wallet-connect/nwc#2 — the successor
to pay_invoice/make_invoice that clients are asked to transition to
for bolt12 zaps in nostr-protocol/nips#2421:

- reject cross-network payment instructions before paying
  (UNSUPPORTED_NETWORK) instead of failing after the debit
- reject unknown req- BIP-321 params, including req-pop since we
  can't open proof-of-payment callbacks
- reject a request amount that conflicts with the instruction amount
- payer_note is delivered or rejected, never dropped: bolt11+note is
  rejected, internal settlements hand the note over as the payment memo
- receive mints a per-request bolt12 offer carrying the requested
  description/amount instead of reusing the standing amountless offer
- get_info reports the node's actual network instead of "mainnet"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@benthecarman

benthecarman commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Found a potential hole in this and curious on people's thoughts on the solution.

Currently, you can replay a zap multiple times by just taking the same payment proof, zap intent, etc and creating a new zap event with just a different timestamp.

We could fix this by requiring the zap event's created_at is the same as the invoice's created_at, however this has the trade off of potentially making payments that take a long time invalid on some relays because the event is too far back in time. I think the main concern is doing this async over NWC. Given all this I still think this is worth doing because it otherwise has a potential fake zap vector.

@TheBlueMatt

Copy link
Copy Markdown

Should clients not be de-dup'ing by the zap intent?

@benthecarman

benthecarman commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Should clients not be de-dup'ing by the zap intent?

That is a solution to this but in general is kind of an anti pattern in nostr from my understanding. Having to save lots of events and making the client have to dedup across a "global state" isn't cheap.

It's also much nicer if it's just inherent to the spec

@TheBlueMatt

Copy link
Copy Markdown

I mean presumably if a client is displaying "zaped X total" or even just listing zaps it already scans through all such events so de-dup'ing is cheap? Unless they store a history of seen zap event ids and filter for new ones which would make zap intent filtering cost a bit more, but only like 2x-ish.

@benthecarman

Copy link
Copy Markdown
Contributor Author

I mean presumably if a client is displaying "zaped X total" or even just listing zaps it already scans through all such events so de-dup'ing is cheap? Unless they store a history of seen zap event ids and filter for new ones which would make zap intent filtering cost a bit more, but only like 2x-ish.

My understanding is that is kinda an anti-pattern in nostr with having to dedup everything like this.

Maybe @vitorpamplona or @jb55 can chime in?

@vitorpamplona

vitorpamplona commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Yes, we dedup by request event already. It's annoying, but that's the cost of Nostr purposefully not being a blockchain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants