diff --git a/CHANGELOG.md b/CHANGELOG.md index ba65033..bca73a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,18 @@ Update that section alongside this file as part of every release. ### Added - **Set a status (NIP-38).** A short line about what you are doing, published as kind 30315 and shown beside your name by clients that read it, shown as a small balloon over your banner on the Profile tab, which is also how you edit it. With no status there is no balloon: just a Set status button beside Edit profile. An optional link and an optional expiry ride along; the expiry is a request relays are asked to honor rather than required to, so Sidecar treats an expired status as gone regardless of what a relay still serves. Clearing publishes an empty status rather than a deletion request, because a status is a replaceable event and an empty one overwrites the old text everywhere the old text reached, while a deletion is a request any relay may ignore and anyone already holding it would keep showing. Only the general status is written: the spec's music status is meant to be published by whatever is playing the track, with an expiry matching when it stops, and a signer cannot see that. - **Four new sections in the guide.** Key backup, notifications, relays, and themes each had a What's new bullet and no explanation anywhere. Backup opens by saying plainly that a lost profile with no backup means the accounts are gone, since that is the section people go looking for at the worst possible moment. +- **Keysend, so Podcasting 2.0 boosts work.** `window.webln.keysend` was a one-line stub that rejected every call before it left the page, which meant a boost never reached the wallet and the wallet was never the reason it failed. Boosts are keysend-only — a value split names node public keys and carries its boostagram in a TLV record rather than issuing an invoice — so there was no BOLT11 for the "Pay with Sidecar" card to fall back on either. Sidecar now pays them through your own NWC wallet, and the approval card names the show and the message rather than a bare node key, because 66 characters of hex is not something anyone can make a decision about. A boost is several payments, one per recipient in the split, and the card says so: set a spending limit and the rest of the split is covered by the same approval. +- **`webln.getInfo` tells the truth about keysend.** The method list reported to pages was a hardcoded four, identical whatever wallet was behind it. Keysend is the one thing Sidecar cannot provide on its own, so it is now advertised only when the connected wallet actually has `pay_keysend` — a site that checks first gets an honest answer and can fall back to an invoice, instead of taking its keysend branch and failing at payment time. The other four are still reported unconditionally; deriving the whole list from the wallet would hide receive flows that work. +- **Boosts are named in your history.** A keysend carries no invoice, no description and no zap request, so it would otherwise appear as a bare "Sent". Where a boostagram named the show, the row says so. Recorded only for boosts that carry a message: streaming sats emit a payment a minute, and filling the store with those would evict the zap labels it exists for. ### Changed - **The Recent activity screenshot in the guide** predated the verify tap-through, so it is retaken; its alt text now says which rows carry the button and why the row that signed nothing does not. +### Fixed +- **A site with a spending limit could spend past it in a burst.** The budget was checked before a payment and debited afterwards, in the bookkeeping that deliberately runs after the page has been answered — so the debit landed after the payment lock had already released, and anything queued behind it read a balance nobody had reduced yet. Four splits of one boost therefore cleared a budget with room for two, and two overlapping debits could lose one entirely. Budgets are now reserved in a single step before the money moves, and handed back only when the wallet explicitly refused, which is the one outcome that proves nothing was spent. At the pace a person clicks a zap this was invisible; a boost fires its splits back to back, which is exactly the timing that finds it. +- **Keysend routing fees are counted against a site's spending limit.** A budget is reserved before the payment, so it can only book the amount — the fee is not known until the wallet answers. That is a rounding error for a zap, which routes to a well-connected LSP, and not for a keysend, which goes to whatever node a podcast's value split names: the first live boost paid a 1 sat fee on a 1 sat leg. Unbooked, a limit drifted by the whole routing cost of every boost, in the direction of letting more through. +- **Zero and negative payment amounts are no longer covered by a budget.** `covers()` answered the question "is `remaining >= amount`", which says yes to zero even on an exhausted allowance, and yes to a negative — where the debit that followed *added* sats back. Neither could be reached through a BOLT11 invoice, which cannot express either, so this sat unreachable until keysend began taking its amount straight from the page. + ## [1.12.0] — 2026-09-04 ### Added diff --git a/FEATURES.md b/FEATURES.md index f4418f7..e0c311a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -38,6 +38,10 @@ clients without ever pasting your nsec into a website. counterparty, fee, payment hash, preimage. - **WebLN** — web apps can pay and make invoices through your wallet, gated by approval with an optional per-site daily budget you can edit or revoke any time. +- **Podcasting 2.0 boosts** — keysend payments with their boostagram, so value-split + shows can be boosted straight from the page. The approval card names the show and + the message rather than a bare node key, and a boost's splits are covered together + by one spending limit. Offered only when your wallet supports keysend. - **Pay from any page** — when a site shows a Lightning invoice, a "Pay with Sidecar" card appears (only on apps you're signed into). You can also right-click a `lightning:` link, a selected invoice, or a QR. diff --git a/README.md b/README.md index ff9dd8e..e0f6c8e 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Connect) and a composer for posting notes directly from the panel. - **Client tag, your call** — posts carry a `client` tag attributing them to Sidecar; turn it off in Settings to post untagged. - **Notifications** — a bell in the header shows replies, mentions, reposts, reactions, and zaps for the active account — each with the sender's name, a content preview, and a tap-through that opens the note in your preferred client. Replies and comments can be answered without leaving the panel: the composer opens with what you're answering held above the editor, through the preview and the review countdown both, and follows NIP-10 for notes and NIP-22 for page comments. A web-of-trust sort puts your follows (and anyone ten of them follow) first, gathering everyone else into a counted group at the bottom rather than hiding them — a denylist can't outrun key rotation, but an allowlist that hides is how you miss the one reply that mattered. Muted users (public and private mute lists) are filtered out. - **Lightning wallet (NWC)** — connect any self-custody Lightning wallet over Nostr Wallet Connect. Send (BOLT11 or lightning address via LNURL-pay), receive (invoice or your lightning address, with a QR — also surfaced as a card on the wallet page), view live balance (auto-updating via NIP-47 notifications) and paginated history, and back up the connection to your relays — or export it (PIN-gated, with a QR) to move it to another app. New to Lightning? Built-in **wallet suggestions** point you to NWC-capable options. Sidecar never holds your funds. -- **WebLN provider** — web apps can pay and make invoices through your connected wallet (`window.webln`), gated by an approval prompt with an optional per-site daily budget you can edit or revoke any time. +- **WebLN provider** — web apps can pay and make invoices through your connected wallet (`window.webln`), gated by an approval prompt with an optional per-site daily budget you can edit or revoke any time. Includes **keysend**, so Podcasting 2.0 boosts and their boostagrams work on shows that split their value. - **Pay invoices from any page** — when a Nostr client you're signed into shows a Lightning invoice, a **Pay with Sidecar** card appears so you can pay in a tap. You can also right-click a `lightning:` link, a selected BOLT11 invoice, or a QR image. - **Auto-approve zaps** (optional, off by default) — pay zaps without a prompt up to a per-zap limit *and* a daily total you set. Verified zaps only; larger zaps, anything over the daily total, non-zaps, and a locked wallet still ask. - **Help & guides** — a built-in guide page, one click from the top bar, walks through accounts, how sites remember who you are, switching accounts, the client families, and the wallet. diff --git a/background.js b/background.js index 2d59def..e28162b 100644 --- a/background.js +++ b/background.js @@ -1595,7 +1595,7 @@ let qrSecret = null; // { value, at } | null let swNwc = null; // { client, pubkey } async function getSwNwc(pubkey) { if (swNwc && swNwc.pubkey === pubkey) return swNwc.client; - if (swNwc) { try { swNwc.client.close(); } catch (_) {} swNwc = null; } + if (swNwc) { try { swNwc.client.close(); } catch (_) {} swNwc = null; swNwcMethods = null; } const connection = await KS.getNwc(pubkey); // requires unlocked if (!connection) return null; // Controls for the "is it this relay, or is it the browser?" check when a wallet @@ -1605,10 +1605,62 @@ async function getSwNwc(pubkey) { if (NWC.setControlRelays) NWC.setControlRelays(Object.keys(await getConfiguredRelays())); } catch (_) {} swNwc = { client: NWC.makeClient(connection), pubkey }; + swNwcMethods = null; // a new client is a new connection; re-ask what it supports return swNwc.client; } function closeSwNwc() { if (swNwc) { try { swNwc.client.close(); } catch (_) {} swNwc = null; } + swNwcMethods = null; +} + +// What the connected wallet says it can do — the NIP-47 `methods` list from get_info. +// +// Nothing used to read this: getInfo reported a hardcoded four to every page regardless +// of the wallet behind it. That is fine while Sidecar implements everything it advertises, +// and wrong for pay_keysend, which plenty of wallets do not have. A page that is told +// keysend works takes its keysend branch and fails at payment time instead of falling back +// to an invoice — which is the shape of the bug this whole feature came from. +// +// Cached per client because it is a relay round trip and the answer does not change for a +// connection; dropped whenever the client is (closeSwNwc), so a reconnect re-asks. A +// wallet that answers nothing usable caches [] and is treated as "unknown", never as "no". +let swNwcMethods = null; // string[] | null +async function getWalletMethods(pubkey) { + if (swNwcMethods) return swNwcMethods; + const c = await getSwNwc(pubkey); + if (!c) return []; + let list = []; + try { + const info = (await c.getInfo()) || {}; + list = parseNwcMethods(info.methods); + } catch (_) { /* leave it unknown rather than caching a failure as a denial */ } + swNwcMethods = list; + return list; +} + +// NIP-47 says get_info returns `methods` as an array, and it usually is — but Alby's own +// info endpoint hands back one comma-separated string, and a wallet is free to do the +// same over the wire. Reading only the array shape would treat every such wallet as having +// no methods at all, which (given walletHasKeysend below) is the difference between +// offering keysend and refusing it on a wallet that supports it perfectly well. +function parseNwcMethods(methods) { + if (Array.isArray(methods)) return methods.map((m) => String(m).trim().toLowerCase()).filter(Boolean); + if (typeof methods === 'string') return methods.split(/[\s,]+/).map((m) => m.trim().toLowerCase()).filter(Boolean); + return []; +} + +// Does the wallet advertise pay_keysend? +// +// UNKNOWN IS NOT NO. An empty list means the wallet told us nothing usable — it answered +// in a shape we do not read, or get_info failed — and refusing on that basis would block +// keysend on a wallet that supports it. This gate exists to save someone from approving a +// payment that cannot succeed, not to be the authority on what a wallet can do; when there +// is no evidence either way the wallet answers for itself, which it does cleanly with a +// walletDenied NOT_IMPLEMENTED. +async function walletHasKeysend(pubkey) { + const m = await getWalletMethods(pubkey); + if (!m.length) return true; // no evidence — let the wallet speak + return m.includes('pay_keysend'); } const msatToSat = (m) => Math.floor((m || 0) / 1000); @@ -1700,9 +1752,22 @@ async function handleWeblnRpc(method, params, host, sendResponse, originWindowId await weblnReadGate(host, method, pubkey, originWindowId); const c = await getSwNwc(pubkey); const info = (await c.getInfo()) || {}; + // Prime the capability cache off the round trip we just paid for. + swNwcMethods = parseNwcMethods(info.methods); + // Report the four Sidecar implements unconditionally, and ADD keysend only when the + // wallet actually has pay_keysend. + // + // Deliberately a union rather than a translation of the wallet's list. Sidecar + // provides these four whatever an NWC connection happens to be scoped to, and clients + // (Bitcoin Connect, the Alby SDK) branch on this array to decide which UI to show — + // so deriving it wholesale would hide working receive flows on a connection scoped to + // pay_invoice. Only keysend needs the honesty, because it is the one method Sidecar + // cannot provide on its own. + const methods = ['getInfo', 'makeInvoice', 'sendPayment', 'getBalance']; + if ((swNwcMethods || []).includes('pay_keysend')) methods.push('keysend'); result = { node: { alias: info.alias || 'Sidecar wallet', pubkey: info.pubkey || '', color: info.color || '' }, - methods: ['getInfo', 'makeInvoice', 'sendPayment', 'getBalance'], + methods, supports: ['lightning'], }; } else if (method === 'getBalance') { @@ -1721,6 +1786,8 @@ async function handleWeblnRpc(method, params, host, sendResponse, originWindowId result = { paymentRequest: invoice }; } else if (method === 'sendPayment') { result = await weblnSendPayment(params, host, pubkey, originWindowId); + } else if (method === 'keysend') { + result = await weblnKeysend(params, host, pubkey, originWindowId); } else { throw new Error('Sidecar does not support webln.' + method); } @@ -1740,6 +1807,165 @@ async function handleWeblnRpc(method, params, host, sendResponse, originWindowId } } +// ---- keysend (window.webln.keysend) ---- +// +// A spontaneous payment: no invoice, just a node pubkey, an amount, and optional TLV +// records riding along in the onion. Podcasting 2.0 boosts are built on it — the value +// split names node pubkeys and the boostagram travels in record 7629169 (blip-0010). + +// The boostagram record: a JSON blob with the podcast, episode and the listener's message. +const TLV_BOOSTAGRAM = 7629169; +// The keysend preimage record. A PAGE MUST NEVER SET THIS. +// +// Keysend works by the sender putting the preimage in the onion, so whoever writes this +// record chooses the payment hash. Sidecar generates its own (see keysendPreimage) and +// depends on knowing sha256(preimage) to confirm the payment afterwards — a page that +// could set it would both break that confirmation and choose the hash of a spend it asked +// someone else to pay for. Rejected outright rather than overwritten, so a page that tries +// gets an error instead of a silently different payment. +const TLV_KEYSEND_PREIMAGE = 5482373484; +// Custom TLV types live above 65535; LND rejects anything lower outright. +const TLV_MIN_TYPE = 65536; +const TLV_MAX_RECORDS = 16; +// Bounds on absurdity, NOT on what the network will carry. +// +// These started at 512 and 900 bytes, reasoning from the ~1300-byte onion payload — and +// refused real boosts on the first live test. A boostagram is not a message; it is a +// blip-0010 record carrying the show, the episode, three GUIDs, a boost link, the app +// name, the sender and a signature, and a realistic one runs past 900 bytes with a +// fourteen-character message in it. Sidecar refusing that did not protect anyone: the site +// caught the error and fell back to LNURL, so the boost went out with no keysend and no +// boostagram, which is the outcome this feature exists to prevent. +// +// The wallet is the authority on what fits in the onion, the same way it is the authority +// on whether a payment settled. These numbers only stop a page pushing megabytes into a +// spend path; anything a real client sends is far below them, and anything above is the +// wallet's to refuse. +const TLV_MAX_VALUE_BYTES = 8192; +const TLV_MAX_TOTAL_BYTES = 16384; +// A sanity bound, not a policy one — the approval card and the site budget decide what is +// allowed to be spent. This only keeps absurd input out of the arithmetic. +const KEYSEND_MAX_SATS = 100000000; + +const toHex = (bytes) => Array.from(bytes, (x) => x.toString(16).padStart(2, '0')).join(''); + +// Validate what the page sent and translate it to NIP-47 shape. Throws with a message the +// page will see. Runs BEFORE the payment lock and before any prompt: bad input should +// never take the lock, wake the heartbeat, or put a card in front of the user. +// +// Nothing upstream of here checks any of this. content.js only routes by scope and the +// message router only re-derives the host, so `params` arrives exactly as the page wrote +// it — including the amount that the budget check is about to trust. +function normalizeKeysend(params) { + const p = params || {}; + + const destination = String(p.destination == null ? '' : p.destination).trim(); + // 33-byte compressed secp256k1 point. A site that confuses a nostr key for a node key + // sends 64 characters; catch that here rather than letting the wallet puzzle over it. + if (!/^0[23][0-9a-f]{64}$/i.test(destination)) { + throw new Error('keysend needs a 66-character node public key as `destination`'); + } + + // Number(), not parseInt(): WebLN allows a stringified integer, and parseInt('0.5') is 0 + // while parseInt('1e6') is 1 — both of which would spend a different amount than the page + // asked for, in the direction of "silently wrong" rather than "rejected". + const sats = Number(p.amount); + if (!Number.isFinite(sats) || !Number.isInteger(sats) || sats < 1) { + throw new Error('keysend needs a whole number of sats, at least 1'); + } + if (sats > KEYSEND_MAX_SATS) throw new Error('That keysend amount is out of range'); + + const records = []; + let total = 0; + const custom = p.customRecords; + if (custom != null) { + if (typeof custom !== 'object' || Array.isArray(custom)) { + throw new Error('customRecords must be an object of TLV records'); + } + for (const key of Object.keys(custom)) { + if (!Object.prototype.hasOwnProperty.call(custom, key)) continue; + const type = Number(key); + if (!Number.isSafeInteger(type) || type < TLV_MIN_TYPE) { + throw new Error('customRecords keys must be TLV types of ' + TLV_MIN_TYPE + ' or above'); + } + if (type === TLV_KEYSEND_PREIMAGE) { + throw new Error('customRecords may not set the keysend preimage record'); + } + const value = custom[key]; + if (typeof value !== 'string') throw new Error('customRecords values must be strings'); + const bytes = new TextEncoder().encode(value); + if (bytes.length > TLV_MAX_VALUE_BYTES) { + throw new Error( + 'customRecords value for ' + type + ' is ' + bytes.length + + ' bytes; the limit is ' + TLV_MAX_VALUE_BYTES + ); + } + total += bytes.length; + // WebLN hands these over as the text they are; NIP-47 wants the bytes as hex. Getting + // this wrong does not fail the payment — it delivers a boostagram nobody can read. + records.push({ type, value: toHex(bytes) }); + if (records.length > TLV_MAX_RECORDS) throw new Error('Too many customRecords'); + } + } + if (total > TLV_MAX_TOTAL_BYTES) { + throw new Error( + 'customRecords total ' + total + ' bytes; the limit is ' + TLV_MAX_TOTAL_BYTES + ); + } + + return { destination, sats, records }; +} + +// Pull the human-readable bits out of the boostagram so the approval card can say what is +// being paid for instead of showing 66 characters of hex. +// +// Everything here is written by the page and verified by nobody: it is a caption, never a +// claim Sidecar is making. Parsed defensively — it may not be JSON, may be an array, may +// have any field missing or of the wrong type — because a malformed boostagram must cost +// a card its detail, not its ability to render. +function boostagramFrom(records) { + try { + const rec = (records || []).find((r) => r.type === TLV_BOOSTAGRAM); + if (!rec) return null; + const bytes = new Uint8Array((rec.value.match(/../g) || []).map((h) => parseInt(h, 16))); + const obj = JSON.parse(new TextDecoder().decode(bytes)); + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null; + const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : ''); + const out = { + podcast: str(obj.podcast), + episode: str(obj.episode), + message: str(obj.message), + senderName: str(obj.sender_name), + // blip-0010's action: 'boost', 'stream', 'lsat', 'auto'. Worth keeping because a + // stream tick and a boost look identical otherwise, and they should not be recorded + // the same way — see the pay-meta note in payKeysendLocked. + action: str(obj.action).toLowerCase(), + }; + return out.podcast || out.episode || out.message || out.senderName ? out : null; + } catch (_) { + return null; + } +} + +// A preimage of our own, so the payment can be confirmed afterwards. +// +// Keysend needs a sender-generated preimage regardless, and NIP-47 lets the caller supply +// it. Supplying ours is what buys the safety net: payment_hash is sha256(preimage), so we +// know it BEFORE the request goes out and the existing lookup_invoice watcher works on a +// payment that has no invoice at all. Let the wallet generate one instead and there is +// nothing to poll — silence would be indistinguishable from failure, which is exactly the +// mistake the rejection contract in nwc-client.js exists to prevent. +// +// Not every backend honours a supplied preimage (LDK- and Phoenix-based ones may not). +// Where it is ignored the lookup simply never matches and the payment falls back to being +// a single point of failure — the same place pay_invoice was before #138, and still never +// a false "failed". +async function keysendPreimage() { + const bytes = CRYPTO.randomBytes(32); + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); + return { preimage: toHex(bytes), paymentHash: toHex(hash) }; +} + async function weblnSendPayment(params, host, pubkey, originWindowId) { const invoice = (params && (params.paymentRequest || params.invoice)) || ''; return payInvoiceCore(invoice, host, pubkey, params && params.memo, originWindowId); @@ -1975,13 +2201,16 @@ const CONFIRM_GRACE_MS = 12000; // failure afterwards. Running a second poller for the latter would just double the // traffic to the wallet relay. lookup_invoice is read-only, so watching a payment // alongside it can never cause a second one. -async function payAndConfirm(c, invoice) { - const hash = bolt11PaymentHash(invoice); - const arg = hash ? { payment_hash: hash } : { invoice }; +// Parameterized over the payment rather than taking a BOLT11, so a keysend — which has no +// invoice and no recipient-issued payment hash — gets the same watcher. `send` is the +// wallet call, `lookupArg` is what identifies the payment to lookup_invoice afterwards +// (for keysend, sha256 of the preimage Sidecar supplied; see keysendPreimage). +async function payAndConfirm(c, { send, lookupArg }) { + const arg = lookupArg; let stop = false; - let deadline = Infinity; // tightened once pay_invoice gives up + let deadline = Infinity; // tightened once the payment call gives up - const paying = c.payInvoice(invoice).then( + const paying = send().then( (res) => ({ kind: 'paid', res }), (err) => ({ kind: 'error', err }) ); @@ -2012,7 +2241,7 @@ async function payAndConfirm(c, invoice) { // The wallet gave a definitive no. Nothing moved, so don't go looking. if (first.kind === 'error' && first.err && first.err.walletDenied) throw first.err; // Indeterminate. Let the watcher already in flight run on a little longer. - dlog('info', 'pay', 'no usable answer from pay_invoice; confirming', { + dlog('info', 'pay', 'no usable answer from the payment call; confirming', { error: (first.err && first.err.message) || first.kind, graceMs: CONFIRM_GRACE_MS, }); @@ -2050,18 +2279,56 @@ async function payAndConfirm(c, invoice) { // budget, log, and notify the panel. Used by window.webln.sendPayment AND the // "Pay with Sidecar" context menu. Assumes the caller resolved `pubkey` and // checked the account/site is usable. Serialized per account (see withPayLock). -function payInvoiceCore(invoiceRaw, host, pubkey, memo, originWindowId, offerAutoZap) { - // Hold the worker up for the whole payment, approval prompt included. +// Hold the worker up for the whole payment, approval prompt included. Shared by every +// spend path so the keepalive/heartbeat pairing exists once rather than once per caller. +function withPayInFlight(fn) { payInFlight++; ensureKeepalive(); startPayHeartbeat(); - return withPayLock(pubkey, () => payInvoiceLocked(invoiceRaw, host, pubkey, memo, originWindowId, offerAutoZap)) + return Promise.resolve() + .then(fn) .finally(() => { payInFlight--; if (payInFlight === 0) stopPayHeartbeat(); stopKeepaliveIfIdle(); }); } + +// Run post-payment bookkeeping OFF the caller's path, but not where the worker can sleep +// through it. These writes used to be awaited between a settled payment and the page +// hearing about it, so a recycled worker left the sats gone and the page's promise hanging +// (#138). They still must not be droppable — a lost budget debit lets the next payment +// through on a stale count — hence the payInFlight bracket rather than a bare call. +function settleTail(work) { + payInFlight++; + work() + .catch(() => {}) + .then(() => { + payInFlight--; + if (payInFlight === 0) stopPayHeartbeat(); + stopKeepaliveIfIdle(); + }); +} + +// One flourish per host per burst. +// +// A boost is several keysends in a row and streaming sats fire one a minute, while +// notifyTabsPaidByHost does a settings read plus a full chrome.tabs.query on every call. +// Four identical lightning strikes in two seconds is not four times the delight. Leading +// edge: the first one still lands immediately. +const FLASH_DEBOUNCE_MS = 1500; +const flashPending = new Map(); // host -> timer +function flashPaidByHost(host) { + if (!host || flashPending.has(host)) return; + flashPending.set(host, setTimeout(() => flashPending.delete(host), FLASH_DEBOUNCE_MS)); + notifyTabsPaidByHost(host).catch(() => {}); +} + +function payInvoiceCore(invoiceRaw, host, pubkey, memo, originWindowId, offerAutoZap) { + return withPayInFlight(() => + withPayLock(pubkey, () => payInvoiceLocked(invoiceRaw, host, pubkey, memo, originWindowId, offerAutoZap)) + ); +} async function payInvoiceLocked(invoiceRaw, host, pubkey, memo, originWindowId, offerAutoZap) { const invoice = String(invoiceRaw || '').replace(/^lightning:/i, '').trim(); if (!invoice) throw new Error('No invoice provided'); @@ -2152,7 +2419,11 @@ async function payInvoiceLocked(invoiceRaw, host, pubkey, memo, originWindowId, // those used to surface to the page as an error while the sats were gone, or hang // it for the full 180s — issue #138. The wallet is the only authority on what // actually happened, so payAndConfirm asks it rather than inferring from silence. - const res = await payAndConfirm(c, invoice); + const hash = bolt11PaymentHash(invoice); + const res = await payAndConfirm(c, { + send: () => c.payInvoice(invoice), + lookupArg: hash ? { payment_hash: hash } : { invoice }, + }); const preimage = res && (res.preimage || res.payment_preimage); // ---- the money has moved; from here nothing may delay the caller ---- @@ -2172,9 +2443,15 @@ async function payInvoiceLocked(invoiceRaw, host, pubkey, memo, originWindowId, // and the auto-zap daily total. Both are spending limits: dropping them lets the // next payment through on a stale count. The reply is not delayed by this; only // the worker's eligibility to sleep is. - payInFlight++; - (async () => { + settleTail(async () => { // Decrement the budget by the paid amount (known amount only). + // + // Still the check-then-pay-then-debit order, which has a known gap: this lands after + // withPayLock has released, so concurrent sendPayment calls each read a balance the + // previous one has not reduced yet. BUDGETS.reserve exists now and closes it (the + // keysend path uses it), but moving this one means reworking how budgetOk feeds the + // auto-zap accounting below, and that is a change to make deliberately rather than in + // passing. Left as it was; the exposure here is unchanged, not newly introduced. if (sats != null) await BUDGETS.consume(pubkey, host, sats); // Count an auto-zap (one authorized solely by the zap allowance) against the // rolling daily total, so the aggregate cap is enforced across the window. @@ -2184,13 +2461,7 @@ async function payInvoiceLocked(invoiceRaw, host, pubkey, memo, originWindowId, // rest of it — after the money moved and off the caller's path. if (zapRecipient) await savePayMetaEntry(invoice, { zapPubkey: zapRecipient }); await setSiteAccount(host, pubkey); - })() - .catch(() => {}) - .then(() => { - payInFlight--; - if (payInFlight === 0) stopPayHeartbeat(); - stopKeepaliveIfIdle(); - }); + }); logActivity({ ts: Date.now(), host, method: 'webln.sendPayment', amountSats: sats, pubkey }); // Tell an open side panel to refresh its balance/history. chrome.runtime.sendMessage({ type: 'SIDECAR_EVENT', event: 'walletChanged' }).catch(() => {}); @@ -2202,6 +2473,162 @@ async function payInvoiceLocked(invoiceRaw, host, pubkey, memo, originWindowId, return { preimage: preimage || '', sats }; } +// ---- keysend spend path ---- +// +// A SEPARATE function rather than a mode of payInvoiceLocked, and deliberately so. Half of +// that function's body is BOLT11-shaped (invoiceSats drives the amount, the auto-zap +// allowance, the prompt copy and the return value), and the other half must never run for +// a keysend at all — see the ZAPREQ note below. A shared function with a `kind` flag would +// put that rule behind a boolean; keeping the paths apart makes it structural, because the +// identifiers simply are not in scope here. +function payKeysendCore(ks, host, pubkey, originWindowId) { + return withPayInFlight(() => withPayLock(pubkey, () => payKeysendLocked(ks, host, pubkey, originWindowId))); +} + +// WHY THERE IS NO AUTO-ZAP PATH HERE. +// +// ZAPREQ.claim matches a signed zap request on host + account + exact amount within a +// three-minute window, and on NOTHING about where the money goes — it does not need to, +// because a zap's destination is fixed by the recipient's lnurl server, not by the caller. +// A keysend's destination is whatever pubkey the page names. Let a keysend claim a zap +// record and 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 that was for somebody else. +// +// ZAPREQ.recipientFor is out for the same reason even though it only writes a label: it is +// amount-only too, so a boost of 21 or 100 or 1000 sats near a same-sized zap request would +// be recorded as "Zap to alice" for money that went elsewhere — a false entry in someone's +// financial record. Keysend is budget-or-prompt, and that is all. +async function payKeysendLocked(ks, host, pubkey, originWindowId) { + const boost = boostagramFrom(ks.records); + + // Reserve inside the lock and BEFORE the spend. + // + // The invoice path checks the budget, pays, then debits in its bookkeeping tail — which + // lands after the lock has already released. That ordering survives payments at human + // pace and fails on a boost, which fires one keysend per split back to back: each + // check reads a balance the previous split has not debited yet, so four of them clear a + // budget with room for two. Reserving spends the allowance before the request goes out, + // so the next split sees what is actually left. + let reserved = !KS.isLocked() && (await BUDGETS.reserve(pubkey, host, ks.sats)); + + if (!reserved) { + const st = await KS.getState(); + const acct = st.accounts.find((a) => a.pubkey === pubkey); + const decision = await openPrompt({ + scope: 'webln', + host, + method: 'keysend', + npub: self.NostrTools.nip19.npubEncode(pubkey), + accountName: (acct && acct.name) || '', + accountPicture: (acct && acct.picture) || '', + amountSats: ks.sats, + destination: ks.destination, + // Page-supplied and verified by nobody — a caption, never a claim Sidecar is making. + // The card labels it as coming from the site for exactly that reason. + boost: boost || null, + needUnlock: KS.isLocked(), + needApproval: true, + }, originWindowId); + if (decision.action === 'reject') throw new Error('You rejected this payment'); + if (KS.isLocked()) throw new Error('Keystore is locked'); + if (decision.action === 'budget' && decision.budgetSats) { + await BUDGETS.setBudget(pubkey, host, { + budgetSats: decision.budgetSats, + perPaymentSats: decision.perPaymentSats || 0, + }); + // Debit this payment from the allowance just set, so the splits queued behind it see + // what is left rather than the full amount. This is the path that turns one approval + // into one boost instead of one approval per split. + reserved = await BUDGETS.reserve(pubkey, host, ks.sats); + } + } + + bumpAutoLock(); + const c = await getSwNwc(pubkey); + if (!c) throw new Error('No wallet connected in Sidecar'); + + // Our own preimage, so there is something to confirm against afterwards — a keysend has + // no invoice and no recipient-issued hash. See keysendPreimage. + const { preimage: sent, paymentHash } = await keysendPreimage(); + const req = { amount: ks.sats * 1000, pubkey: ks.destination, preimage: sent }; + if (ks.records.length) req.tlv_records = ks.records; + + let res; + try { + res = await payAndConfirm(c, { + send: () => c.payKeysend(req), + lookupArg: { payment_hash: paymentHash }, + }); + } catch (e) { + // Hand the reservation back ONLY when the wallet explicitly refused. That is the one + // outcome the rejection contract proves left the money where it was; a timeout or a + // dropped reply is indeterminate, and crediting a budget for a payment that may well + // have settled is how a site spends past its limit. Leaving it debited over-counts, + // which is the direction that costs nobody anything. + if (reserved && e && e.walletDenied) await BUDGETS.refund(pubkey, host, ks.sats); + throw e; + } + // The wallet echoes the preimage; ours is the fallback, and they should agree. + const preimage = (res && (res.preimage || res.payment_preimage)) || sent; + // What the route actually cost, on top of what was reserved. + // + // Reserving happens before the payment, so it can only book the amount — the fee is not + // known until the wallet answers. Ignoring it is defensible for a zap, which routes to a + // well-connected LSP for a rounding error. It is not defensible here: a keysend goes to + // whatever node a podcast's value split names, and the first live boost paid a 1 sat fee + // on a 1 sat leg. Left unbooked, a spending limit drifts by the whole routing cost of + // every boost, in the direction of letting more through. + // + // Rounded up, and only against a budget that was actually reserved from — a payment the + // user approved by hand has no allowance to debit. + const feeSats = Math.ceil((Number(res && res.fees_paid) || 0) / 1000); + + // ---- the money has moved; from here nothing may delay the caller ---- + settleTail(async () => { + if (reserved && feeSats > 0) await BUDGETS.consume(pubkey, host, feeSats); + // Caption the payment, but ONLY when there is a boostagram worth keeping. + // + // Streaming sats emit a keysend a minute and carry the show's name on every one, so + // going by "is there a boostagram" would write a row a minute and roll the 300-entry + // store over in five hours — evicting the zap labels it exists for (#253). A boost is + // something a person did once and may want to find again; a stream tick is not. + // + // Keyed by payment hash because a keysend has no invoice. savePayMetaEntry's first + // argument is a key whatever its parameter happens to be called. + if (boost && boost.action !== 'stream') { + await savePayMetaEntry(paymentHash, { + keysend: true, + dest: ks.destination, + podcast: boost.podcast, + episode: boost.episode, + message: boost.message, + }); + } + await setSiteAccount(host, pubkey); + }); + logActivity({ ts: Date.now(), host, method: 'webln.keysend', amountSats: ks.sats, pubkey }); + chrome.runtime.sendMessage({ type: 'SIDECAR_EVENT', event: 'walletChanged' }).catch(() => {}); + // Debounced: a boost is several of these in a row (see flashPaidByHost). + flashPaidByHost(host); + return { preimage, sats: ks.sats }; +} + +async function weblnKeysend(params, host, pubkey, originWindowId) { + // Validate before the lock, the heartbeat and any prompt. Nothing upstream checks this — + // content.js routes by scope and the router only re-derives the host — so bad input from + // a page should cost nothing and put no card in front of anyone. + const ks = normalizeKeysend(params); + // And ask the wallet whether it can do this at all before spending someone's attention on + // an approval. Skipped while locked, because reading the connection needs an unlock; in + // that case the wallet answers for itself after the prompt. An unknown method list is + // treated as capable, never as incapable — see walletHasKeysend. + if (!KS.isLocked() && !(await walletHasKeysend(pubkey))) { + throw new Error("Your wallet doesn't support keysend payments"); + } + return payKeysendCore(ks, host, pubkey, originWindowId); +} + // ============================================================================ // "Pay with Sidecar" — pay an invoice found on a page (context menu) // ============================================================================ diff --git a/nostr-provider.js b/nostr-provider.js index 0502a12..a6ff973 100644 --- a/nostr-provider.js +++ b/nostr-provider.js @@ -193,7 +193,12 @@ getBalance: () => ensure().then(() => call('webln', 'getBalance')), makeInvoice: (args) => ensure().then(() => call('webln', 'makeInvoice', normInvoice(args))), sendPayment: (paymentRequest) => ensure().then(() => call('webln', 'sendPayment', { paymentRequest })), - keysend: () => Promise.reject(new Error('keysend is not supported by Sidecar')), + // Spontaneous payment to a node pubkey — how Podcasting 2.0 boosts move, since a + // value split names nodes and carries its boostagram in a TLV record rather than + // issuing an invoice. Args are handed over as the page wrote them; the service + // worker validates before anything is spent, because this half runs in the page and + // is not a trust boundary. + keysend: (args) => ensure().then(() => call('webln', 'keysend', args)), signMessage: () => Promise.reject(new Error('signMessage is not supported by Sidecar')), verifyMessage: () => Promise.reject(new Error('verifyMessage is not supported by Sidecar')), // Raw NIP-47-style passthrough some clients probe for; unsupported for now. diff --git a/nwc-client.js b/nwc-client.js index 8271ce7..1782b18 100644 --- a/nwc-client.js +++ b/nwc-client.js @@ -267,6 +267,15 @@ getBalance: () => request('get_balance'), // → { balance } in msat payInvoice: (invoice, amountMsat) => request('pay_invoice', amountMsat ? { invoice, amount: amountMsat } : { invoice }), + // Spontaneous payment to a node pubkey, no invoice involved — what a Podcasting 2.0 + // boost is made of. An optional NIP-47 extension method, so a wallet may answer + // NOT_IMPLEMENTED; callers check get_info's method list first rather than finding + // out after they have shown someone a spend prompt. + // + // params: { amount (msat), pubkey, preimage?, tlv_records?: [{ type, value }] } + // The tlv value is HEX, not the text it encodes. Same rejection contract as + // pay_invoice above: only err.walletDenied proves the money stayed put. + payKeysend: (params) => request('pay_keysend', params), makeInvoice: (amountMsat, description) => request('make_invoice', { amount: amountMsat, description: description || '' }), listTransactions: (params) => request('list_transactions', params || { limit: 20, unpaid: false }), diff --git a/prompt.html b/prompt.html index 5b82be4..fd8b66c 100644 --- a/prompt.html +++ b/prompt.html @@ -161,6 +161,11 @@ .card .row { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; } .card .row span:first-child { color: var(--muted); } .card .row span:last-child { word-break: break-all; text-align: right; font-family: var(--font-mono); font-size: 12px; } + /* A row whose value is a sentence, not an identifier: break at words and set it in the + reading face. break-all is right for a hex key and wrong for someone's boost message, + which it would chop mid-word. The value is clamped in JS so it cannot grow the card; + nothing here is made smaller to fit. */ + .card .row.prose span:last-child { word-break: break-word; font-family: inherit; min-width: 0; } pre { background: rgba(var(--well-rgb), 0.6); border-radius: 8px; padding: 10px; margin: 8px 0 0; font-family: var(--font-mono); diff --git a/prompt.js b/prompt.js index b204e51..0eb9d76 100644 --- a/prompt.js +++ b/prompt.js @@ -297,6 +297,18 @@ if (isPayment) { const rows = []; rows.push(row('Amount', data.amountSats != null ? fmtSats(data.amountSats) + ' sats' : 'set by invoice')); + // Keysend goes to a bare node key, so say where — and, when the site sent a + // boostagram, say what it is for. A card reading "500 sats to 03a1…9f2c" tells + // nobody anything; one naming the show is a decision someone can actually make. + if (data.method === 'keysend') { + const b = data.boost || {}; + const who = b.podcast || b.episode || ''; + rows.push(row('To', who ? clampText(who, 60) : truncMid(data.destination, 10, 8))); + // Named as the site's words, not Sidecar's. Nothing here is verified — the page + // wrote it — and a spend card must not lend it authority it has not earned. + if (who && data.destination) rows.push(row('Node', truncMid(data.destination, 10, 8))); + if (b.message) rows.push(row('Message from site', clampText(b.message, 140), 'prose')); + } if (data.memo) rows.push(row('Memo', String(data.memo))); els.preview.innerHTML = rows.join(''); els.preview.classList.remove('hidden'); @@ -390,8 +402,26 @@ if (data.peerNpub) return shortNpub(data.peerNpub); return (data.params && data.params.pubkey) || '—'; } - function row(k, v) { - return `
${k}${escapeHtml(v)}
`; + function row(k, v, cls) { + return `
${k}${escapeHtml(v)}
`; + } + + // Middle-elide an identifier so it stays one line. + // + // A node pubkey is 66 characters of hex and the row value is styled break-all, so the + // raw thing wraps to three lines and pushes the Pay button down the card. Truncation + // belongs to prose, and an identifier read for recognition is best served by keeping + // both ends — the middle carries nothing a person checks. + function truncMid(s, head, tail) { + const str = String(s || ''); + return str.length > head + tail + 1 ? str.slice(0, head) + '…' + str.slice(-tail) : str; + } + + // Page-supplied text on a spend card: keep it short enough that it cannot push the + // buttons off the screen. + function clampText(s, max) { + const str = String(s || '').replace(/\s+/g, ' ').trim(); + return str.length > max ? str.slice(0, max - 1) + '…' : str; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => @@ -453,7 +483,10 @@ // Keyed off the VALIDATED theme rather than data.theme, so an unrecognised value // falls back with everything else instead of picking a cut of its own. avatarPh = isLight ? 'icons/avatar-default-dark.svg' : 'icons/avatar-default.svg'; - isPayment = data.scope === 'webln' && data.method === 'sendPayment'; + // keysend counts as a payment here, and everything downstream depends on it: the Pay + // button, hiding Trust on a spend card, and the "remember a budget" toggle that turns + // one approval into one boost instead of one approval per split. + isPayment = data.scope === 'webln' && (data.method === 'sendPayment' || data.method === 'keysend'); chosenPubkey = data.activePubkey; els.host.textContent = data.host; @@ -498,6 +531,18 @@ els.allow.textContent = 'Allow this session'; } + // Boosts are not one payment. A Podcasting 2.0 value split pays each recipient + // separately, so the site sends one keysend per share and Sidecar sees them as the + // independent payments they are — it has no way to know four calls were one boost. + // Approving with plain Pay therefore brings the next card up straight away. Say so, + // and point at the limit below, which is the thing that actually covers the rest. + // Identical sentence in the sidepanel's renderConsentNote — keep the two in step. + if (data.method === 'keysend') { + els.decryptNote.textContent = + 'A boost is several payments — one per recipient in the show’s split. Set a limit below to cover them all, or Sidecar asks for each one.'; + els.decryptNote.classList.remove('hidden'); + } + // Shared-identity confirm: this host is signed in with more than one of your // accounts, so make the "who's posting" choice explicit and relabel the // switcher for the signing (not login) context. This confirms on EVERY diff --git a/sidepanel.js b/sidepanel.js index 8199273..4273640 100644 --- a/sidepanel.js +++ b/sidepanel.js @@ -7990,6 +7990,10 @@ 'nip04.decrypt': { icon: 'unlock', label: () => 'Decrypted a message' }, 'nip44.encrypt': { icon: 'lock', label: () => 'Encrypted a message' }, 'nip44.decrypt': { icon: 'unlock', label: () => 'Decrypted a message' }, + // Without these the activity list falls through to the default and prints the raw + // method string under a quill — 'webln.keysend' four times over for one boost. + 'webln.sendPayment': { icon: 'zap', label: () => 'Paid a Lightning invoice' }, + 'webln.keysend': { icon: 'zap', label: () => 'Sent a keysend payment' }, }; function relTime(ts) { @@ -15154,6 +15158,13 @@ return s.length > head + tail + 1 ? s.slice(0, head) + '…' + s.slice(-tail) : s; } + // Page-supplied text shown on a spend card: keep it short enough that it cannot push the + // buttons off the screen. Mirrors clampText in prompt.js. + function clampApprovalText(s, max) { + const str = String(s || '').replace(/\s+/g, ' ').trim(); + return str.length > max ? str.slice(0, max - 1) + '…' : str; + } + async function loadTransactions(listEl, client) { const PAGE = 15; let offset = 0; @@ -15193,8 +15204,13 @@ // Prepend any transactions that aren't already in the list, without clearing it. // Keyed on payment_hash (unique per payment) so a re-fetch after a zap adds only // the new entry — the existing rows stay in place, no flash. + // A boost broadcasts walletChanged once per split, within a second or two. Dropping the + // overlapping calls outright meant a split that settled after the in-flight + // listTransactions never appeared until the next full render — so remember that + // something asked, and run once more when the current pass finishes. + let refreshAgain = false; async function refresh() { - if (loading) return; + if (loading) { refreshAgain = true; return; } loading = true; try { const res = await client.listTransactions({ limit: PAGE, offset: 0, unpaid: false }); @@ -15219,6 +15235,10 @@ } finally { loading = false; } + if (refreshAgain) { + refreshAgain = false; + await refresh(); + } } more.addEventListener('click', () => { more.textContent = 'Loading…'; loadPage(); }); @@ -15356,8 +15376,19 @@ function txRow(tx, metaMap) { const incoming = tx.type === 'incoming'; const sats = msatToSat(tx.amount); - const meta = (metaMap && tx.invoice && metaMap[tx.invoice]) || {}; - const counterparty = incoming ? '' : meta.address || ''; + // Keyed by invoice for anything with one, and by payment hash for what does not — a + // keysend has no invoice at all, so meta written for a boost would be stored and never + // read if this only ever looked at tx.invoice. + const meta = + (metaMap && ((tx.invoice && metaMap[tx.invoice]) || (tx.payment_hash && metaMap[tx.payment_hash]))) || {}; + // An outgoing keysend carries no invoice, no description and no zap request, so it + // would otherwise render as a bare "Sent". Where a boostagram was recorded, name what + // it paid for instead — never the raw node key, which is 64 characters of noise in a + // 328px row. + const boostLabel = meta.keysend + ? (meta.podcast ? 'Boost to ' + meta.podcast : meta.dest ? 'Keysend to ' + truncMid(meta.dest, 8, 6) : 'Boost') + : ''; + const counterparty = incoming ? '' : meta.address || boostLabel || ''; const row = h('div', { className: 'item tx-row' }); if (tx.payment_hash) row.dataset.ph = tx.payment_hash; @@ -16998,7 +17029,11 @@ 'webln.makeInvoice': 'create a Lightning invoice', }; - const isPaymentApproval = (data) => data.scope === 'webln' && data.method === 'sendPayment'; + // keysend is a payment too, and this predicate is what makes it one everywhere: the Pay + // button, hiding "Trust this site" on a spend card, and the budget capture that turns one + // approval into a whole boost. Mirrors isPayment in prompt.js — keep the two in step. + const isPaymentApproval = (data) => + data.scope === 'webln' && (data.method === 'sendPayment' || data.method === 'keysend'); // Human-readable labels for the event kinds sites most commonly ask Sidecar to // sign (not exhaustive — see https://nips.nostr.com for the full registry). @@ -17191,6 +17226,22 @@ }; if (isPaymentApproval(data)) { box.append(row('Amount', data.amountSats != null ? fmtSats(data.amountSats) + ' sats' : 'set by invoice')); + // Keysend pays a bare node key, so say where it goes and — when the site sent a + // boostagram — what it is for. Wording matches prompt.js; keep the two surfaces in + // step. Never the raw 66-character key, for the reason stated at txRow below. + if (data.method === 'keysend') { + const b = data.boost || {}; + const who = b.podcast || b.episode || ''; + box.append(row('To', who ? clampApprovalText(who, 60) : truncMid(data.destination, 10, 8))); + if (who && data.destination) box.append(row('Node', truncMid(data.destination, 10, 8))); + // Labelled as the site's words. Nothing in a boostagram is verified — the page + // wrote it — and a spend card must not lend it authority it has not earned. + if (b.message) { + const r = row('Message from site', clampApprovalText(b.message, 140)); + r.classList.add('prose'); + box.append(r); + } + } if (data.memo) box.append(row('Memo', String(data.memo))); } else if (data.method === 'signEvent') { const ev = (data.params && (data.params.event || data.params)) || {}; @@ -17331,6 +17382,13 @@ } else if (data.method === 'webln.getBalance' || data.method === 'webln.getInfo' || data.method === 'webln.makeInvoice') { note.textContent = 'Allowing lets ' + data.host + ' read wallet info from Sidecar for the rest of this session.'; + } else if (data.method === 'keysend') { + // Boosts are not one payment: a value split pays each recipient separately, so the + // site sends one keysend per share and Sidecar sees them as the independent payments + // they are — nothing tells it that four calls were one boost. Plain Pay therefore + // brings the next card straight up. Wording matches prompt.js exactly. + note.textContent = + 'A boost is several payments — one per recipient in the show’s split. Set a limit below to cover them all, or Sidecar asks for each one.'; } else { hide(note); return; diff --git a/styles.css b/styles.css index 2ea31f3..92eb11c 100644 --- a/styles.css +++ b/styles.css @@ -3367,6 +3367,11 @@ html.reduce-balance-motion .balances-hidden .pinned-balance-amt::after { animati .card .row { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; } .card .row span:first-child { color: var(--muted); } .card .row span:last-child { word-break: break-all; text-align: right; font-family: var(--font-mono); font-size: 12px; } +/* A row whose value is a sentence rather than an identifier: break at words and set it in + the reading face. break-all is right for a hex key and wrong for someone's boost message, + which it would chop mid-word. The value is clamped in JS so it cannot grow the card; + nothing here is made smaller to fit. Mirrors the same rule in prompt.html. */ +.card .row.prose span:last-child { word-break: break-word; font-family: inherit; min-width: 0; } /* Encrypt/decrypt counterparty: @name on top, npub beneath as a verifiable key. */ .card .row .peer-val { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; } .card .row .peer-val .peer-name { font-family: var(--font-ui); font-weight: 600; } diff --git a/test/keysend-budget.test.js b/test/keysend-budget.test.js new file mode 100644 index 0000000..10c9d40 --- /dev/null +++ b/test/keysend-budget.test.js @@ -0,0 +1,153 @@ +'use strict'; + +// Spending limits under a burst of payments. +// +// Budgets were written for zaps, which arrive at whatever pace a person clicks. One +// Podcasting 2.0 boost arrives as several keysends back to back — one per recipient in the +// show's value split — and that is a different problem: +// +// - covers() answers a question and consume() acts on it much later (the background does +// the debit in its un-awaited bookkeeping tail, after the payment lock has released). +// Between those two moments every other split reads a balance nobody has debited yet. +// reserve() collapses them into one step. +// - covers() used to answer "yes" to zero and to negative amounts, and consume(-5) grew +// the allowance by five. No BOLT11 invoice can express either, so it sat unreachable +// until keysend started taking its amount straight from the page. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const ROOT = path.join(__dirname, '..'); +const source = fs.readFileSync(path.join(ROOT, 'wallet-budgets.js'), 'utf8'); + +const PK = 'pubkey1'; +const HOST = 'boostmebitch.com'; + +// A storage mock that actually yields. Resolving synchronously would hide the very +// interleaving these tests exist to catch. +function load() { + const store = {}; + const ctx = { + chrome: { + storage: { + local: { + get: (keys, cb) => setTimeout(() => cb({ ...store }), 0), + set: (obj, cb) => setTimeout(() => { Object.assign(store, obj); cb(); }, 0), + }, + }, + }, + setTimeout, + Date, + Math, + Object, + Number, + Promise, + }; + ctx.self = ctx; + vm.createContext(ctx); + vm.runInContext(source, ctx); + return ctx.SidecarBudgets; +} + +test('four splits cannot all clear a budget with room for two', async () => { + const B = load(); + await B.setBudget(PK, HOST, { budgetSats: 100, perPaymentSats: 0 }); + + // A boost fires its splits without waiting for each other. + const results = await Promise.all([ + B.reserve(PK, HOST, 50), + B.reserve(PK, HOST, 50), + B.reserve(PK, HOST, 50), + B.reserve(PK, HOST, 50), + ]); + + assert.equal(results.filter(Boolean).length, 2, 'exactly two 50-sat splits fit in 100 sats'); + const rec = await B.getBudget(PK, HOST); + assert.equal(rec.remainingSats, 0, 'and the balance reflects both, with no lost decrement'); +}); + +test('covers() is not a substitute for reserving', async () => { + const B = load(); + await B.setBudget(PK, HOST, { budgetSats: 100, perPaymentSats: 0 }); + // The read-only question still answers honestly... + assert.equal(await B.covers(PK, HOST, 50), true); + await B.reserve(PK, HOST, 100); + // ...and stops saying yes once the allowance is actually spent. + assert.equal(await B.covers(PK, HOST, 50), false); +}); + +test('a zero-sat payment is not covered by an exhausted budget', async () => { + const B = load(); + await B.setBudget(PK, HOST, { budgetSats: 100, perPaymentSats: 0 }); + await B.reserve(PK, HOST, 100); // spend it all + assert.equal(await B.covers(PK, HOST, 0), false); + assert.equal(await B.reserve(PK, HOST, 0), false); +}); + +test('a negative amount cannot be reserved, consumed, or used to grow the budget', async () => { + const B = load(); + await B.setBudget(PK, HOST, { budgetSats: 100, perPaymentSats: 0 }); + await B.reserve(PK, HOST, 60); + + assert.equal(await B.covers(PK, HOST, -5), false); + assert.equal(await B.reserve(PK, HOST, -5), false); + await B.consume(PK, HOST, -5); + await B.refund(PK, HOST, -5); + + const rec = await B.getBudget(PK, HOST); + assert.equal(rec.remainingSats, 40, 'nothing above put sats back'); +}); + +test('a fractional amount is not spendable', async () => { + const B = load(); + await B.setBudget(PK, HOST, { budgetSats: 100, perPaymentSats: 0 }); + assert.equal(await B.reserve(PK, HOST, 10.5), false); + assert.equal((await B.getBudget(PK, HOST)).remainingSats, 100); +}); + +test('the per-payment cap still applies to a reservation', async () => { + const B = load(); + await B.setBudget(PK, HOST, { budgetSats: 1000, perPaymentSats: 100 }); + assert.equal(await B.reserve(PK, HOST, 101), false); + assert.equal(await B.reserve(PK, HOST, 100), true); +}); + +test('a refund puts back exactly what was reserved, and never more than the budget', async () => { + const B = load(); + await B.setBudget(PK, HOST, { budgetSats: 100, perPaymentSats: 0 }); + await B.reserve(PK, HOST, 40); + await B.refund(PK, HOST, 40); + assert.equal((await B.getBudget(PK, HOST)).remainingSats, 100); + + // A refund larger than what was taken cannot mint an allowance. + await B.refund(PK, HOST, 500); + assert.equal((await B.getBudget(PK, HOST)).remainingSats, 100); +}); + +test('a site with no budget reserves nothing', async () => { + const B = load(); + assert.equal(await B.reserve(PK, 'never-seen.example', 10), false); +}); + +test('a routing fee is debited on top of the reservation', async () => { + // Reserving happens before the payment, so it books the amount and cannot know the fee. + // On keysend that gap is not a rounding error: the first live boost paid a 1 sat fee on a + // 1 sat leg. The background books it afterwards with consume(); this is that arithmetic. + const B = load(); + await B.setBudget(PK, HOST, { budgetSats: 100, perPaymentSats: 0 }); + assert.equal(await B.reserve(PK, HOST, 33), true); + const feeMsat = 1000; // what pay_keysend reported + await B.consume(PK, HOST, Math.ceil(feeMsat / 1000)); + assert.equal((await B.getBudget(PK, HOST)).remainingSats, 66, '33 paid + 1 fee, not 33 alone'); +}); + +test('a sub-sat fee still costs a sat rather than nothing', async () => { + const B = load(); + await B.setBudget(PK, HOST, { budgetSats: 100, perPaymentSats: 0 }); + await B.reserve(PK, HOST, 10); + await B.consume(PK, HOST, Math.ceil(400 / 1000)); // 0.4 sat of routing + assert.equal((await B.getBudget(PK, HOST)).remainingSats, 89, 'rounded up, never floored to free'); +}); diff --git a/test/keysend-tlv.test.js b/test/keysend-tlv.test.js new file mode 100644 index 0000000..89dbfad --- /dev/null +++ b/test/keysend-tlv.test.js @@ -0,0 +1,244 @@ +'use strict'; + +// normalizeKeysend — the gate between window.webln.keysend and somebody's money. +// +// Nothing upstream of it validates anything: content.js routes by scope and the message +// router only re-derives the host, so `params` arrives exactly as the page wrote it, +// including the amount the budget check is about to trust. Two of these checks are +// load-bearing beyond ordinary input hygiene: +// +// - The UTF-8 → hex translation. Getting it wrong does not fail the payment. It settles +// and delivers a boostagram the recipient cannot read, which is the kind of bug that +// ships. +// - The TLV 5482373484 denylist. That record IS the keysend preimage. A page able to set +// it chooses the payment hash of a spend somebody else is paying for, and defeats the +// lookup_invoice confirmation that keeps a lost reply from being reported as a failure. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const ROOT = path.join(__dirname, '..'); +const source = fs.readFileSync(path.join(ROOT, 'background.js'), 'utf8'); + +function lift(pattern, label) { + const m = source.match(pattern); + if (!m) throw new Error('Could not find ' + label + ' in background.js'); + return m[0]; +} + +const lifted = [ + lift(/const TLV_BOOSTAGRAM = \d+;/, 'TLV_BOOSTAGRAM'), + lift(/const TLV_KEYSEND_PREIMAGE = \d+;/, 'TLV_KEYSEND_PREIMAGE'), + lift(/const TLV_MIN_TYPE = \d+;/, 'TLV_MIN_TYPE'), + lift(/const TLV_MAX_RECORDS = \d+;/, 'TLV_MAX_RECORDS'), + lift(/const TLV_MAX_VALUE_BYTES = \d+;/, 'TLV_MAX_VALUE_BYTES'), + lift(/const TLV_MAX_TOTAL_BYTES = \d+;/, 'TLV_MAX_TOTAL_BYTES'), + lift(/const KEYSEND_MAX_SATS = \d+;/, 'KEYSEND_MAX_SATS'), + lift(/const toHex = .*\n/, 'toHex'), + lift(/function normalizeKeysend\(params\) \{[\s\S]*?\n\}/, 'normalizeKeysend'), + lift(/function boostagramFrom\(records\) \{[\s\S]*?\n\}/, 'boostagramFrom'), + lift(/function parseNwcMethods\(methods\) \{[\s\S]*?\n\}/, 'parseNwcMethods'), +].join('\n'); + +const ctx = { TextEncoder, TextDecoder, Uint8Array, JSON, Math, Number, Object, Array, String, Error, parseInt }; +vm.createContext(ctx); +vm.runInContext( + lifted + '\n;this.normalizeKeysend = normalizeKeysend; this.boostagramFrom = boostagramFrom; this.parseNwcMethods = parseNwcMethods;', + ctx +); +const { normalizeKeysend, boostagramFrom, parseNwcMethods } = ctx; + +const DEST = '03' + 'a1b2c3d4'.repeat(8); // 66 chars, compressed-point prefix +const args = (over) => Object.assign({ destination: DEST, amount: 100 }, over); +const hexToStr = (hex) => + new TextDecoder().decode(new Uint8Array(hex.match(/../g).map((h) => parseInt(h, 16)))); + +test('accepts a well-formed keysend and converts nothing it should not', () => { + const ks = normalizeKeysend(args()); + assert.equal(ks.destination, DEST); + assert.equal(ks.sats, 100); // sats stay sats here; the msat conversion is at the call site + // length, not deepEqual: the array is built inside the vm context, so its prototype is + // that realm's Array and a strict structural compare fails on identity alone. + assert.equal(ks.records.length, 0); +}); + +test('custom record values are hex-encoded UTF-8, multi-byte included', () => { + const message = 'Great show! 🎙 — café'; + const ks = normalizeKeysend(args({ customRecords: { '7629169': message } })); + assert.equal(ks.records.length, 1); + assert.equal(ks.records[0].type, 7629169); // a number on the wire, not the string key + assert.match(ks.records[0].value, /^[0-9a-f]+$/); + // The whole point: what the recipient decodes is what the page wrote. + assert.equal(hexToStr(ks.records[0].value), message); + // And a multi-byte character really did become more than one byte. + assert.ok(ks.records[0].value.length / 2 > message.length); +}); + +test('a stringified amount is read as a number, not parsed as one', () => { + assert.equal(normalizeKeysend(args({ amount: '250' })).sats, 250); + // The reason this is not parseInt: parseInt('1e6') is 1, so a page asking to send a + // million sats would have sent one, silently and in the direction of "wrong amount". + assert.equal(normalizeKeysend(args({ amount: '1e6' })).sats, 1000000); +}); + +test('rejects a destination that is not a compressed node key', () => { + for (const bad of ['', 'a'.repeat(64), '04' + 'a'.repeat(64), '03' + 'z'.repeat(64), DEST.slice(0, 65)]) { + assert.throws(() => normalizeKeysend(args({ destination: bad })), /destination/); + } +}); + +test('rejects amounts that are not a positive whole number of sats', () => { + // 0 and negatives matter beyond tidiness: BUDGETS.covers used to answer "yes" to both, + // so a 0-sat boostagram cleared an exhausted budget and a negative one grew it. + for (const bad of [0, -5, '0', '-5', '1.9', 1.5, NaN, Infinity, 'abc', null, undefined]) { + assert.throws(() => normalizeKeysend(args({ amount: bad })), /whole number of sats/); + } +}); + +test('rejects the keysend preimage record outright', () => { + assert.throws( + () => normalizeKeysend(args({ customRecords: { '5482373484': 'deadbeef' } })), + /preimage/ + ); +}); + +test('rejects TLV types below the custom range', () => { + assert.throws(() => normalizeKeysend(args({ customRecords: { '7': 'x' } })), /TLV types/); + assert.throws(() => normalizeKeysend(args({ customRecords: { '65535': 'x' } })), /TLV types/); + // 65536 is the first legal one. + assert.equal(normalizeKeysend(args({ customRecords: { '65536': 'x' } })).records[0].type, 65536); +}); + +test('rejects customRecords that are not an object of strings', () => { + assert.throws(() => normalizeKeysend(args({ customRecords: 'nope' })), /must be an object/); + assert.throws(() => normalizeKeysend(args({ customRecords: ['nope'] })), /must be an object/); + assert.throws(() => normalizeKeysend(args({ customRecords: { '7629169': 42 } })), /must be strings/); +}); + +// The regression test for the first live boost, which Sidecar refused. +// +// The size caps started at 512 bytes per value and 900 in total, reasoned from the onion +// payload. A blip-0010 boostagram is not a message — it carries the show, the episode, +// three GUIDs, a boost link, the app, the sender and a signature — and a real one runs past +// 900 bytes with a fourteen-character message in it. The site caught the rejection and fell +// back to LNURL, so the boost went out with no keysend and no boostagram at all: exactly +// what this feature exists to prevent, caused by the check meant to protect it. +test('a real boostagram is not refused for its size', () => { + const boostagram = { + podcast: 'Chad and Reeds Podcast', + feedID: 7778147, + url: 'https://feeds.podhome.fm/chad-and-reeds-podcast', + episode: '003. Dimly LIT', + episode_guid: '39217942-8932-6008-2e72-b12f43295a09', + itemID: 41234567, + ts: 1423, + time: '00:23:43', + action: 'boost', + app_name: 'Boost Me Bitch', + app_version: '1.4.2', + value_msat: 100000, + value_msat_total: 100000, + name: 'Chad and Reeds Podcast', + sender_name: 'chadf', + sender_id: 'greyturkey26@primal.net', + message: 'great episode!', + boost_link: 'https://tardbox.com/boost/01M1STZFS8E0JR351YYQTKN1JQ', + guid: '7c6f7875-2b73-491e-b32c-e2c8d6e91d53', + remote_feed_guid: '11708875-2ff3-4c2e-a00b-08dee99f0b15', + remote_item_guid: '639217942893260082e72b12f4-3295-40b6-abaa-9945ff051a09', + uuid: '01M1STZFS8E0JR351YYQTKN1JQ', + signature: '3045022100e8f1a2b3c4d5e6f70819'.repeat(3), + }; + const payload = JSON.stringify(boostagram); + // The cap it actually hit was 512 bytes per value; this is comfortably past it. + assert.ok(payload.length > 512, 'the fixture must be big enough to have tripped the old cap'); + + const ks = normalizeKeysend(args({ customRecords: { '7629169': payload } })); + assert.equal(ks.records.length, 1); + assert.equal(JSON.parse(hexToStr(ks.records[0].value)).boost_link, boostagram.boost_link); + + // And a chatty listener does not push it over either. + const chatty = { ...boostagram, message: 'Great show — the bit about value splits finally made it click. '.repeat(8) }; + assert.equal( + normalizeKeysend(args({ customRecords: { '7629169': JSON.stringify(chatty) } })).records.length, + 1 + ); +}); + +test('bounds a single value, the record count, and the total payload', () => { + // These are bounds on absurdity, not on what the network carries — the wallet is the + // authority on what fits in the onion. Nothing a real client sends comes near them. + assert.throws( + () => normalizeKeysend(args({ customRecords: { '7629169': 'x'.repeat(8193) } })), + /the limit is 8192/ + ); + + const many = {}; + for (let i = 0; i < 17; i++) many[String(65536 + i)] = 'x'; + assert.throws(() => normalizeKeysend(args({ customRecords: many })), /Too many/); + + // Records that are each individually legal but together exceed the total. + assert.throws( + () => normalizeKeysend(args({ + customRecords: { '7629169': 'x'.repeat(8000), '65536': 'y'.repeat(8000), '65537': 'z'.repeat(8000) }, + })), + /the limit is 16384/ + ); +}); + +test('boostagramFrom reads the caption out of record 7629169', () => { + const payload = JSON.stringify({ + podcast: 'Podcasting 2.0', + episode: 'Episode 42', + message: 'great episode', + sender_name: 'someone', + action: 'boost', + }); + const { records } = normalizeKeysend(args({ customRecords: { '7629169': payload } })); + const boost = boostagramFrom(records); + assert.equal(boost.podcast, 'Podcasting 2.0'); + assert.equal(boost.episode, 'Episode 42'); + assert.equal(boost.message, 'great episode'); + assert.equal(boost.senderName, 'someone'); + assert.equal(boost.action, 'boost'); +}); + +test('a stream tick is distinguishable from a boost', () => { + // Both carry the show's name, so "is there a boostagram" cannot tell them apart — and + // streaming sats fire one a minute. Only the action does. + const tick = JSON.stringify({ podcast: 'Podcasting 2.0', action: 'STREAM', value_msat: 1000 }); + const { records } = normalizeKeysend(args({ customRecords: { '7629169': tick } })); + const boost = boostagramFrom(records); + assert.equal(boost.podcast, 'Podcasting 2.0'); + assert.equal(boost.action, 'stream', 'lower-cased, so the caller compares one thing'); + assert.equal(boost.message, ''); +}); + +test('a boostagram that is malformed costs the card its detail, not its render', () => { + const bad = (v) => boostagramFrom(normalizeKeysend(args({ customRecords: { '7629169': v } })).records); + assert.equal(bad('not json at all'), null); + assert.equal(bad('[1,2,3]'), null); // valid JSON, wrong shape + assert.equal(bad('{}'), null); // nothing worth showing + assert.equal(bad('{"podcast": 42}'), null); // right key, wrong type + assert.equal(boostagramFrom([]), null); + assert.equal(boostagramFrom(null), null); +}); + +// A wallet's advertised method list decides whether Sidecar offers keysend at all, so +// misreading it is the difference between a working boost and a flat refusal. +test('the wallet method list is read in both shapes wallets actually send', () => { + const wire = 'pay_invoice,pay_keysend,get_balance,get_info,make_invoice,lookup_invoice'; + // NIP-47 says array, and it usually is. + assert.ok(parseNwcMethods(['pay_invoice', 'PAY_KEYSEND']).includes('pay_keysend')); + // Alby's own info endpoint hands back one comma-separated string. Reading only the array + // shape would call that wallet method-less and refuse keysend on a wallet that has it. + assert.ok(parseNwcMethods(wire).includes('pay_keysend')); + assert.ok(parseNwcMethods('pay_invoice pay_keysend').includes('pay_keysend')); + // Nothing usable is an empty list, which the caller reads as "unknown", not "no". + for (const junk of [undefined, null, 42, {}, '']) { + assert.equal(parseNwcMethods(junk).length, 0); + } +}); diff --git a/wallet-budgets.js b/wallet-budgets.js index 1958053..b1dcb9b 100644 --- a/wallet-budgets.js +++ b/wallet-budgets.js @@ -65,22 +65,95 @@ return rootMap[pubkey][host]; } - // True if `sats` can be paid from the current budget without a prompt. + // A payable amount: a positive whole number of sats, and nothing else. + // + // covers() used to answer `remainingSats >= sats`, which says YES to zero and to + // negatives — so a 0-sat payment was covered by an exhausted budget, and consume(-5) + // computed `remaining - Math.floor(-5)` and GREW the allowance by five. Neither was + // reachable from a BOLT11 invoice (invoiceSats returns null or a positive), so this + // sat harmless until window.webln.keysend arrived and took its amount straight from + // the page. The entry point validates too; this is the floor under it, because a + // spending limit should not depend on every caller having checked first. + const payable = (sats) => Number.isFinite(sats) && Math.floor(sats) === sats && sats > 0; + + // Serialize every read-modify-write on the store. + // + // reserve/consume/refund are each a load, a mutate and a save with awaits in between, + // so two overlapping calls both read the pre-debit balance and the second save wins — + // a lost decrement. Payments used to be spaced far enough apart (a human clicking a + // zap) that this never showed, but one Podcasting 2.0 boost fires a keysend per split + // back to back, which is precisely the interleaving that loses one. A single chain + // makes each of these atomic on its own, rather than only while some caller happens to + // hold the payment lock. + let queue = Promise.resolve(); + function serialized(fn) { + const run = queue.then(fn, fn); + queue = run.then(() => {}, () => {}); + return run; + } + + // True if `sats` can be paid from the current budget without a prompt. Read-only — + // use reserve() when the answer is about to authorize a spend. async function covers(pubkey, host, sats) { + if (!payable(sats)) return false; const rec = await getBudget(pubkey, host); if (!rec || rec.budgetSats <= 0) return false; if (rec.perPaymentSats > 0 && sats > rec.perPaymentSats) return false; return rec.remainingSats >= sats; } - // Decrement the remaining balance after a successful payment. - async function consume(pubkey, host, sats) { - const rootMap = await loadRoot(); - const rec = rootMap[pubkey] && rootMap[pubkey][host]; - if (!rec) return; - applyReset(rec, Date.now()); - rec.remainingSats = Math.max(0, rec.remainingSats - Math.floor(sats || 0)); - await set({ [KEY]: rootMap }); + // Check and debit in ONE step, before the money moves. Returns true if the budget + // covered it (and has now been decremented), false if it did not (and nothing changed). + // + // covers()-then-pay-then-consume() is not safe for payments that arrive in a burst: + // consume runs in the background's un-awaited bookkeeping tail, which lands AFTER the + // payment lock has already released, so the next payment's covers() reads a balance + // that has not been debited yet. Four splits of a boost against a budget with room for + // two therefore all passed. Reserving up front closes that: the balance is spent before + // the request goes out, and the only thing that can hand it back is a wallet that + // explicitly refused (see refund). + function reserve(pubkey, host, sats) { + return serialized(async () => { + if (!payable(sats)) return false; + const rootMap = await loadRoot(); + const rec = rootMap[pubkey] && rootMap[pubkey][host]; + if (!rec || rec.budgetSats <= 0) return false; + applyReset(rec, Date.now()); + if (rec.perPaymentSats > 0 && sats > rec.perPaymentSats) return false; + if (rec.remainingSats < sats) return false; + rec.remainingSats -= sats; + await set({ [KEY]: rootMap }); + return true; + }); + } + + // Put a reservation back. ONLY for a payment the wallet explicitly refused — that is + // the single outcome proven to have left the money alone (see the rejection contract in + // nwc-client.js). A timeout or a lost reply is indeterminate, and crediting the budget + // back for a payment that may well have settled is how a site spends past its limit. + function refund(pubkey, host, sats) { + return serialized(async () => { + if (!payable(sats)) return; + const rootMap = await loadRoot(); + const rec = rootMap[pubkey] && rootMap[pubkey][host]; + if (!rec) return; + rec.remainingSats = Math.min(rec.budgetSats, rec.remainingSats + sats); + await set({ [KEY]: rootMap }); + }); + } + + // Decrement the remaining balance after a successful payment. Kept for the paths that + // still pay first and account afterwards; reserve() is the safer order. + function consume(pubkey, host, sats) { + return serialized(async () => { + if (!payable(sats)) return; + const rootMap = await loadRoot(); + const rec = rootMap[pubkey] && rootMap[pubkey][host]; + if (!rec) return; + applyReset(rec, Date.now()); + rec.remainingSats = Math.max(0, rec.remainingSats - sats); + await set({ [KEY]: rootMap }); + }); } async function revoke(pubkey, host) { @@ -107,5 +180,5 @@ await set({ [KEY]: rootMap }); } - root.SidecarBudgets = { getBudget, setBudget, covers, consume, revoke, getAll, clearAccount }; + root.SidecarBudgets = { getBudget, setBudget, covers, reserve, refund, consume, revoke, getAll, clearAccount }; })(typeof self !== 'undefined' ? self : this);