Skip to content

Pay Podcasting 2.0 boosts (window.webln.keysend) - #291

Merged
dmnyc merged 2 commits into
dmnyc:mainfrom
ChadFarrow:feat/webln-keysend
Sep 11, 2026
Merged

Pay Podcasting 2.0 boosts (window.webln.keysend)#291
dmnyc merged 2 commits into
dmnyc:mainfrom
ChadFarrow:feat/webln-keysend

Conversation

@ChadFarrow

Copy link
Copy Markdown
Contributor

The gap

window.webln.keysend was a single line that rejected in the page:

// nostr-provider.js:196
keysend: () => Promise.reject(new Error('keysend is not supported by Sidecar')),

That was the only occurrence of "keysend" in the repo. The rejection happened in the page context, so a boost never reached the service worker and the connected wallet was never the reason it failed.

Boosts are keysend-only — a value split names node pubkeys and carries its boostagram in TLV record 7629169 rather than issuing an invoice — so there was no BOLT11 for the "Pay with Sidecar" card to fall back on either. Meanwhile welcome.js:173 lists Boost Me Bitch in Sidecar's own curated app catalog: a recommendation to visit a site the extension structurally could not pay.

typeof webln.keysend was "function", so feature detection by presence passed and then failed.

What this does

webln.keysendhandleWeblnRpc → NWC pay_keysend, with WebLN customRecords (UTF-8) translated to tlv_records (hex), sats → msat, and destinationpubkey. The approval card names the show and the boost message instead of a bare node key, and a boost's splits are covered together by one per-site budget.

Three things worth a reviewer's attention

Keysend has its own spend path, not a mode of payInvoiceLocked. ZAPREQ.claim matches a signed zap request on host + account + amount and on nothing about the destination, because a zap's recipient is fixed by their lnurl server. A keysend goes wherever the page says. If keysend could claim a zap record, a site that got someone to approve signing a 21-sat zap request for @alice could immediately keysend 21 sats to a node of its choosing, silently, spending an approval meant for someone else. ZAPREQ.recipientFor is out for the same reason even though it only writes a label — a boost of 21/100/1000 sats near a same-sized zap request would be recorded as "Zap to alice" for money that went elsewhere. Separate functions make that structural rather than a boolean a later edit can flip.

Sidecar generates the preimage. Keysend needs a sender-generated one regardless and NIP-47 accepts it, so payment_hash = sha256(preimage) is known before the request goes out and the existing lookup_invoice watcher applies to a payment that has no invoice — preserving the rejection contract in nwc-client.js:133-140. Where a backend ignores a supplied preimage the watcher never matches, which is the pre-#138 single point of failure and never a false report. Relatedly, TLV 5482373484 (the keysend preimage record) is denylisted from customRecords: a page able to set it would choose the payment hash of a spend somebody else pays for.

getInfo unions, it does not translate. keysend is added to the advertised methods only when the wallet has pay_keysend. The other four stay unconditional — Sidecar provides them whatever an NWC connection is scoped to, and clients branch on this array, so deriving it wholesale would hide receive flows that work.

Two pre-existing budget bugs fixed

Both unreachable from a BOLT11 invoice, both wide open to an amount taken straight from a page:

  • Check-then-consume race. covers() ran before a payment and consume() in the bookkeeping tail that deliberately lands after the pay lock releases (the webln.sendPayment: page never learns a payment succeeded (valid preimage returned, page promise never settles) #138 fix). Four splits of one boost therefore cleared a budget with room for two, and overlapping unsynchronized debits could lose one outright. BUDGETS.reserve() now checks and debits in one step before the money moves, refunded only on walletDenied — the one outcome proven to have spent nothing.
  • covers() failed open. It answered yes to 0 (covered even on an exhausted budget) and to negatives, where consume(-5) computed remaining - Math.floor(-5) and grew the allowance.

Testing

24 new tests across test/keysend-tlv.test.js and test/keysend-budget.test.js, following the existing lift()-into-a-vm idiom. Full suite: 1046 pass, 2 pre-existing failures (search-entity, web-comment — both need an uninstalled nostr-tools dep and fail identically on main).

Verified live: the extension loaded unpacked, two real boosts on boostmebitch.com settled over keysend, and the boostagram was readable on the receiving end. Wallet history renders "Boost to Chad and Reeds Podcast" rather than a bare "Sent".

Live testing found four bugs in this branch before it landed, each an invented limit rather than a logic error, all fixed here: a 512-byte cap on TLV values against a real boostagram of 857; methods read only as an array when Alby sends a comma-separated string; an unknown capability treated as a refusal, which together with that would have blocked keysend on Alby Hub; and routing fees left unbooked, which is a rounding error for a zap and was a 1 sat fee on a 1 sat leg here.

Deliberately out of scope

  • The BOLT11 path keeps its old check-then-debit order. reserve() now exists to close it, but moving that path means reworking how budgetOk feeds the auto-zap accounting — a change to make deliberately, not in passing. The exposure there is unchanged, not newly introduced; there is a comment at the call site.
  • webln.request stays rejected — a raw NIP-47 passthrough hands any page arbitrary wallet methods.
  • multi_pay_keysend — thin support outside Alby Hub.
  • all_frames: false means an embedded podcast player in an iframe never sees window.webln. A real gap for this use case, but widening frame injection is its own change with its own security argument.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JmcDS3h6LHo7La2cuU92aa

window.webln.keysend was one line that rejected in the page, so a boost never reached
the wallet and the wallet was never the reason it failed. Boosts are keysend only — a
value split names node pubkeys and carries its boostagram in TLV record 7629169 rather
than issuing an invoice — so there was no BOLT11 for the pay card to fall back on
either. welcome.js meanwhile lists Boost Me Bitch in Sidecar's own app catalog, which
is a recommendation to visit a site the extension could not pay.

Keysend gets its own spend path rather than a mode of payInvoiceLocked. Half of that
function is BOLT11-shaped, and the other half must never run here: ZAPREQ.claim matches
a signed zap request on host, account and amount and on nothing about the destination,
because a zap's recipient is fixed by their lnurl server. A keysend goes wherever the
page says, so a site could otherwise turn an approval to zap alice into 21 sats sent
silently to a node of its choosing. Sharing the function would put that rule behind a
boolean; separate functions put it out of scope entirely.

Confirmation works because Sidecar supplies the preimage. Keysend needs a
sender-generated one regardless and NIP-47 accepts it, so payment_hash is known before
the request goes out and the existing lookup_invoice watcher still applies to a payment
that has no invoice. Where a backend ignores a supplied preimage the watcher never
matches, which is the pre-dmnyc#138 single point of failure and never a false report.

getInfo adds keysend to the advertised methods when the wallet has pay_keysend, and
only then. A union rather than a translation of the wallet's list: Sidecar provides the
other four whatever an NWC connection is scoped to, and clients branch on this array,
so deriving it wholesale would hide receive flows that work.

Two budget bugs fall out, both unreachable from a BOLT11 invoice and both wide open to
an amount taken straight from a page. covers() ran before a payment and consume() in
the bookkeeping tail that deliberately lands after the pay lock releases, so four
splits of one boost cleared a budget with room for two, and overlapping debits could
lose one outright; budgets are now reserved in a single step before the money moves and
handed back only on the one outcome that proves nothing was spent. And covers()
answered yes to zero and to negatives, where the debit that followed added sats back.

Live boosts found four more, each an invented limit rather than a logic error: a 512
byte cap on TLV values against a real boostagram of 857; methods read only as an array
when Alby sends a string; an unknown capability treated as a refusal, which together
with that would have blocked keysend on Alby Hub; and routing fees left unbooked, which
is a rounding error for a zap and was a 1 sat fee on a 1 sat leg here.

The BOLT11 path keeps its old check-then-debit order. reserve() now exists to close it,
but moving that path means reworking how budgetOk feeds the auto-zap accounting, and
that is a change to make deliberately rather than in passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmcDS3h6LHo7La2cuU92aa
@ChadFarrow

Copy link
Copy Markdown
Contributor Author

This is to add support for AlbyHub NWC connections added to sidecar so keysend payments can be made in podcasting 2.0 apps.

Only CHANGELOG.md conflicted, and only structurally: this branch renamed the
Unreleased heading while main kept "— 1.13.0" and added entries of its own.
Resolved on main's structure, with the three keysend Added bullets appended to
the existing list and the three Fixed bullets as a new section after Changed,
in Keep-a-Changelog order. No wording changed on either side.
@dmnyc
dmnyc merged commit 9e581b4 into dmnyc:main Sep 11, 2026
1 check passed
@dmnyc

dmnyc commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Thank you for your well-scoped and detailed contribution, @ChadFarrow. This will be in the next release!

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.

2 participants