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 + ); + }); +});