Pay Podcasting 2.0 boosts (window.webln.keysend) - #291
Merged
Conversation
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
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.
Owner
|
Thank you for your well-scoped and detailed contribution, @ChadFarrow. This will be in the next release! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
window.webln.keysendwas a single line that rejected in the page: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:173lists Boost Me Bitch in Sidecar's own curated app catalog: a recommendation to visit a site the extension structurally could not pay.typeof webln.keysendwas"function", so feature detection by presence passed and then failed.What this does
webln.keysend→handleWeblnRpc→ NWCpay_keysend, with WebLNcustomRecords(UTF-8) translated totlv_records(hex), sats → msat, anddestination→pubkey. 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.claimmatches 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@alicecould immediately keysend 21 sats to a node of its choosing, silently, spending an approval meant for someone else.ZAPREQ.recipientForis 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 existinglookup_invoicewatcher applies to a payment that has no invoice — preserving the rejection contract innwc-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, TLV5482373484(the keysend preimage record) is denylisted fromcustomRecords: a page able to set it would choose the payment hash of a spend somebody else pays for.getInfounions, it does not translate.keysendis added to the advertised methods only when the wallet haspay_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:
covers()ran before a payment andconsume()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 onwalletDenied— the one outcome proven to have spent nothing.covers()failed open. It answered yes to0(covered even on an exhausted budget) and to negatives, whereconsume(-5)computedremaining - Math.floor(-5)and grew the allowance.Testing
24 new tests across
test/keysend-tlv.test.jsandtest/keysend-budget.test.js, following the existinglift()-into-a-vmidiom. Full suite: 1046 pass, 2 pre-existing failures (search-entity,web-comment— both need an uninstallednostr-toolsdep and fail identically onmain).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;
methodsread 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
reserve()now exists to close it, but moving that path means reworking howbudgetOkfeeds 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.requeststays rejected — a raw NIP-47 passthrough hands any page arbitrary wallet methods.multi_pay_keysend— thin support outside Alby Hub.all_frames: falsemeans an embedded podcast player in an iframe never seeswindow.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