From f69b0a3adae495dc22ae56a9b1e600d98685f312 Mon Sep 17 00:00:00 2001 From: pretyflaco Date: Mon, 14 Sep 2026 13:32:45 +0300 Subject: [PATCH] fix: quote the exact sat amount to USD-wallet recipients (1.5.3) A sats donation to a recipient whose default wallet is USD was converted to USD cents before invoicing, so the donor was asked for MORE than they entered. Two errors stacked: 1. Rounding to a whole cent. 1000 sats x 0.077936 c/sat = 77.936c -> 78c. A cent is ~13 sats at current rates, so this alone moves the total. 2. The stablesats dealer spread. Blink prices those 78c back into sats for the payer at the immediate-buy price: 1007 sats, ~0.7% above mid. Donors default to the sats selector, so this applied to every sats donation to a USD-wallet account. Blink has a mutation for exactly this case: lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient takes an amount in satoshis against a USD wallet and credits the recipient its value at creation, so the donor pays the sats they entered and the recipient still receives USD. It is public like the other on-behalf-of mutations and carries the same 5-minute expiry (an exchange rate is attached to the amount). handleDonate now decides the invoice DENOMINATION as well as the amount, and createInvoice takes an optional 4th argument (amountUnit: 'sats' | 'cents') that selects the mutation. Omitting it keeps the previous behaviour, so the existing call shape still works. The sats path also no longer needs fetchExchangeRate('USD'), removing a network round-trip and its "USD exchange rate not available" failure mode. Unchanged: fiat donations still invoice in cents, BTC-wallet recipients are untouched, the self-custodial (Spark) LNURL path is untouched (Spark is BTC-only and always invoiced sats), and the memo, public API and DOM are the same. convertToUsdCents() is left intact and still characterized by widget-currency.spec.js; it is simply no longer on the sats invoice path, and a comment at the call site records why. Verified against the live API on all three paths: USD wallet + 1000 sats -> BtcDenominated mutation, amount "1000" -> lnbc10u1... (exactly 1000 sats, was ~1007) USD wallet + $1 -> cent mutation, amount "100" -> 1291 sats BTC wallet + 1234 sats -> sats mutation, expiresIn 15 -> 1234 sats --- js/blink-pay-button.js | 83 ++++++-- tests/widget-invoice-denomination.spec.js | 230 ++++++++++++++++++++++ 2 files changed, 300 insertions(+), 13 deletions(-) create mode 100644 tests/widget-invoice-denomination.spec.js diff --git a/js/blink-pay-button.js b/js/blink-pay-button.js index 9c47c10..5b7ee08 100644 --- a/js/blink-pay-button.js +++ b/js/blink-pay-button.js @@ -1,7 +1,22 @@ /** * Blink Pay Button Widget * A simple widget for accepting Bitcoin Lightning donations via Blink wallet - * Version: 1.5.2 - Copy: the widget is now the "Donate Button" (was "Donation Button") + * Version: 1.5.3 - Fix: a sats donation to a recipient whose default wallet is USD + * was converted to USD cents before invoicing, so the donor was + * asked for MORE than they entered: the cent amount rounds to a + * whole cent (~13 sats at current rates) and Blink then prices + * those cents back into sats at the stablesats dealer spread + * (1000 sats => 78 cents => ~1007 sats, ~0.7% high). Sat amounts + * now stay sat-denominated via + * lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient, which + * invoices the exact sats and credits the USD wallet with their + * value at creation. Fiat donations still invoice in cents, and + * BTC-wallet recipients are untouched. Also drops the + * fetchExchangeRate('USD') round-trip (and its failure mode) from + * the sats path. createInvoice() takes an optional 4th argument + * (amountUnit); omitting it keeps the previous behaviour. + * No public API / DOM changes. + * 1.5.2 - Copy: the widget is now the "Donate Button" (was "Donation Button") * across the generator UI, docs and manual test pages. The invoice memo * sent on both receive paths changes from " donation button" * to " donate button". On the custodial path that memo is the @@ -1522,20 +1537,30 @@ } this.log(`Retrieved wallet info:`, walletInfo); - // Step 2: Convert amount to the correct unit based on wallet currency - let convertedAmount; + // Step 2: Convert the amount to the unit the invoice is denominated in + let convertedAmount, amountUnit; if (walletInfo.currency === 'BTC') { // Convert to satoshis for BTC wallets convertedAmount = this.convertToSatoshis(amount, this.selectedCurrency); + amountUnit = 'sats'; this.log(`Converted to ${convertedAmount} satoshis for BTC wallet`); } else if (walletInfo.currency === 'USD') { - // Convert to USD cents for USD wallets - // First ensure we have USD rates if converting from sats - if (this.selectedCurrency === 'sats' && !this.exchangeRates['USD']) { - await this.fetchExchangeRate('USD'); + if (this.selectedCurrency === 'sats') { + // A sats donation stays sat-denominated even into a USD wallet. + // Routing it through convertToUsdCents() would round to a whole + // cent and then have Blink price those cents back into sats at + // the dealer spread, so the donor was asked for more than they + // typed (1000 sats => 78 cents => ~1007 sats). The + // BTC-denominated mutation invoices the exact sats and credits + // the USD wallet with their value at creation. + convertedAmount = Math.round(amount); + amountUnit = 'sats'; + this.log(`Keeping ${convertedAmount} satoshis for USD wallet (BTC-denominated invoice)`); + } else { + convertedAmount = this.convertToUsdCents(amount, this.selectedCurrency); + amountUnit = 'cents'; + this.log(`Converted to ${convertedAmount} USD cents for USD wallet`); } - convertedAmount = this.convertToUsdCents(amount, this.selectedCurrency); - this.log(`Converted to ${convertedAmount} USD cents for USD wallet`); } else { throw new Error(`Unsupported wallet currency: ${walletInfo.currency}`); } @@ -1545,7 +1570,7 @@ if (typeof this.createInvoice !== 'function') { throw new Error('createInvoice is not a function - this context may be lost'); } - const invoiceResult = await this.createInvoice(walletInfo.id, convertedAmount, walletInfo.currency); + const invoiceResult = await this.createInvoice(walletInfo.id, convertedAmount, walletInfo.currency, amountUnit); if (!invoiceResult || !invoiceResult.paymentRequest) { throw new Error('Could not create invoice'); } @@ -1863,9 +1888,15 @@ } }, - // Create a lightning invoice - createInvoice: async function(walletId, amount, currency) { + // Create a lightning invoice. + // `currency` is the RECIPIENT WALLET's currency; `amountUnit` is the unit + // `amount` is expressed in ('sats' or 'cents'). A USD wallet accepts both: + // cents for fiat donations, sats for sat donations (BTC-denominated, so the + // donor is quoted exactly the sats they entered). Omitting `amountUnit` + // keeps the historical behaviour (sats for BTC, cents for USD). + createInvoice: async function(walletId, amount, currency, amountUnit) { let mutation, mutationName, variables, expiryMinutes; + const unit = amountUnit || (currency === 'BTC' ? 'sats' : 'cents'); if (currency === 'BTC') { // Use BTC mutation - 15 minutes expiry @@ -1891,6 +1922,32 @@ }; mutationName = 'lnInvoiceCreateOnBehalfOfRecipient'; + } else if (currency === 'USD' && unit === 'sats') { + // Sat-denominated invoice into a USD wallet - 5 minutes expiry + // (an exchange rate is attached to the amount). The donor pays the + // exact sats entered; Blink credits the wallet their USD value. + expiryMinutes = 5; + mutation = ` + mutation LnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient($input: LnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipientInput!) { + lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient(input: $input) { + invoice { + paymentRequest + satoshis + } + } + } + `; + + variables = { + input: { + amount: amount.toString(), + recipientWalletId: walletId, + memo: `${this.username} donate button`, + expiresIn: "5" + } + }; + + mutationName = 'lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient'; } else if (currency === 'USD') { // Use USD mutation - 5 minutes expiry expiryMinutes = 5; @@ -2777,7 +2834,7 @@ } // Add widget version for tracking - params.append('widget_version', '1.5.2'); + params.append('widget_version', '1.5.3'); return `${baseUrl}?${params.toString()}`; } diff --git a/tests/widget-invoice-denomination.spec.js b/tests/widget-invoice-denomination.spec.js new file mode 100644 index 0000000..0a21d8d --- /dev/null +++ b/tests/widget-invoice-denomination.spec.js @@ -0,0 +1,230 @@ +/** + * Tests for the unit a donation invoice is denominated in (1.5.3). + * + * A recipient's default wallet is either BTC or USD, and the donor picks their + * own display currency. Those two choices decide which on-behalf-of mutation is + * used and what `amount` means: + * + * wallet BTC + any donor currency -> lnInvoiceCreateOnBehalfOfRecipient (sats) + * wallet USD + fiat donor currency -> lnUsdInvoiceCreateOnBehalfOfRecipient (cents) + * wallet USD + sats donor currency -> lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient (sats) + * + * The last row is the 1.5.3 fix. Sats used to be converted to USD cents, which + * rounds to a whole cent (~13 sats at current rates) and then has Blink price + * those cents back into sats at the stablesats dealer spread — a donor entering + * 1000 sats was asked for ~1007. Guarded here so it cannot silently come back. + * + * The widget is loaded by executing its IIFE source (the same approach the other + * specs use) so we exercise the real shipped file. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const widgetSrc = readFileSync(resolve(__dirname, '../js/blink-pay-button.js'), 'utf8'); + +function loadWidget() { + const run = new Function(widgetSrc); + run(); + return window.BlinkPayButton; +} + +let widget; + +beforeEach(() => { + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + window.ResizeObserver = global.ResizeObserver; + + document.body.innerHTML = ''; + widget = loadWidget(); + widget.init({ + username: 'alice', + containerId: 'blink-pay-button-container', + debug: false, + }); + widget.log = () => {}; + // 1 sat = 0.05 USD cents (=> ~50,000 USD/BTC); EUR slightly different. + widget.exchangeRates = { + USD: { satPriceInCurrency: 0.05, usdCentPriceInCurrency: 1 }, + EUR: { satPriceInCurrency: 0.045, usdCentPriceInCurrency: 0.9 }, + }; +}); + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + delete global.ResizeObserver; + delete window.ResizeObserver; +}); + +/** + * Drive handleDonate with a stubbed wallet lookup and capture the createInvoice + * call, without touching the network or the invoice/QR UI. + */ +async function donate({ walletCurrency, donorCurrency, amount }) { + widget.getAccountDefaultWallet = vi.fn(() => + Promise.resolve({ id: 'wallet-1', currency: walletCurrency }) + ); + widget.fetchExchangeRate = vi.fn(() => Promise.resolve()); + widget.createInvoice = vi.fn(() => + Promise.resolve({ paymentRequest: 'lnbc1fake', expiryMinutes: 5 }) + ); + widget.displayInvoice = vi.fn(); + widget.subscribeToPaymentStatus = vi.fn(); + + widget.selectedCurrency = donorCurrency; + document.getElementById('blink-pay-amount').value = String(amount); + + await widget.handleDonate(); + return widget.createInvoice.mock.calls[0]; +} + +describe('handleDonate: invoice denomination', () => { + it('keeps a sats donation in sats for a USD wallet (no cent conversion)', async () => { + const [walletId, invoiceAmount, walletCurrency, unit] = await donate({ + walletCurrency: 'USD', + donorCurrency: 'sats', + amount: 1000, + }); + + expect(walletId).toBe('wallet-1'); + expect(invoiceAmount).toBe(1000); // exact: NOT 78 cents -> ~1007 sats + expect(walletCurrency).toBe('USD'); + expect(unit).toBe('sats'); + }); + + it('does not look up an exchange rate for a sats donation to a USD wallet', async () => { + widget.exchangeRates = {}; // no cached rates at all + await donate({ walletCurrency: 'USD', donorCurrency: 'sats', amount: 1000 }); + + expect(widget.fetchExchangeRate).not.toHaveBeenCalled(); + }); + + it('still converts a fiat donation to USD cents for a USD wallet', async () => { + const [, invoiceAmount, walletCurrency, unit] = await donate({ + walletCurrency: 'USD', + donorCurrency: 'EUR', + amount: 10, + }); + + // 10 EUR = 1000 minor units; 1000 / 0.9 = 1111.1 -> 1111 USD cents + expect(invoiceAmount).toBe(1111); + expect(walletCurrency).toBe('USD'); + expect(unit).toBe('cents'); + }); + + it('passes sats straight through for a BTC wallet', async () => { + const [, invoiceAmount, walletCurrency, unit] = await donate({ + walletCurrency: 'BTC', + donorCurrency: 'sats', + amount: 2500, + }); + + expect(invoiceAmount).toBe(2500); + expect(walletCurrency).toBe('BTC'); + expect(unit).toBe('sats'); + }); + + it('converts a fiat donation to sats for a BTC wallet', async () => { + const [, invoiceAmount, walletCurrency, unit] = await donate({ + walletCurrency: 'BTC', + donorCurrency: 'USD', + amount: 1, + }); + + // 1 USD = 100 cents; 100 / 0.05 = 2000 sats + expect(invoiceAmount).toBe(2000); + expect(walletCurrency).toBe('BTC'); + expect(unit).toBe('sats'); + }); +}); + +describe('createInvoice: mutation selection', () => { + function stubInvoiceApi(mutationName) { + const fetchMock = vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + data: { + [mutationName]: { + invoice: { paymentRequest: 'lnbc1fake', satoshis: 1000 }, + }, + }, + }), + }) + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; + } + + function sentBody(fetchMock) { + return JSON.parse(fetchMock.mock.calls[0][1].body); + } + + it('USD wallet + sats uses the BTC-denominated mutation with the exact sats', async () => { + const fetchMock = stubInvoiceApi('lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient'); + + const result = await widget.createInvoice('wallet-1', 1000, 'USD', 'sats'); + + const body = sentBody(fetchMock); + expect(body.query).toContain('lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient'); + expect(body.query).toContain( + '$input: LnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipientInput!' + ); + expect(body.variables.input).toEqual({ + amount: '1000', + recipientWalletId: 'wallet-1', + memo: 'alice donate button', + expiresIn: '5', + }); + expect(result.expiryMinutes).toBe(5); + }); + + it('USD wallet + cents uses the USD cent mutation', async () => { + const fetchMock = stubInvoiceApi('lnUsdInvoiceCreateOnBehalfOfRecipient'); + + const result = await widget.createInvoice('wallet-1', 250, 'USD', 'cents'); + + const body = sentBody(fetchMock); + expect(body.query).toContain('lnUsdInvoiceCreateOnBehalfOfRecipient'); + expect(body.query).not.toContain('BtcDenominated'); + expect(body.variables.input.amount).toBe('250'); + expect(body.variables.input.expiresIn).toBe('5'); + expect(result.expiryMinutes).toBe(5); + }); + + it('BTC wallet uses the sats mutation with a 15 minute expiry', async () => { + const fetchMock = stubInvoiceApi('lnInvoiceCreateOnBehalfOfRecipient'); + + const result = await widget.createInvoice('wallet-1', 2500, 'BTC', 'sats'); + + const body = sentBody(fetchMock); + expect(body.query).toContain('lnInvoiceCreateOnBehalfOfRecipient'); + expect(body.variables.input.amount).toBe('2500'); + expect(body.variables.input.expiresIn).toBe('15'); + expect(result.expiryMinutes).toBe(15); + }); + + it('omitting amountUnit keeps the historical behaviour', async () => { + const usdFetch = stubInvoiceApi('lnUsdInvoiceCreateOnBehalfOfRecipient'); + await widget.createInvoice('wallet-1', 250, 'USD'); + expect(sentBody(usdFetch).query).toContain('lnUsdInvoiceCreateOnBehalfOfRecipient'); + expect(sentBody(usdFetch).query).not.toContain('BtcDenominated'); + + const btcFetch = stubInvoiceApi('lnInvoiceCreateOnBehalfOfRecipient'); + await widget.createInvoice('wallet-1', 2500, 'BTC'); + expect(sentBody(btcFetch).query).toContain('lnInvoiceCreateOnBehalfOfRecipient'); + }); + + it('rejects an unsupported wallet currency', async () => { + await expect(widget.createInvoice('wallet-1', 1, 'DOGE', 'sats')).rejects.toThrow( + /Unsupported currency/i + ); + }); +});