Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 70 additions & 13 deletions js/blink-pay-button.js
Original file line number Diff line number Diff line change
@@ -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 "<username> donation button"
* to "<username> donate button". On the custodial path that memo is the
Expand Down Expand Up @@ -768,7 +783,7 @@
};

// Choose colors based on theme
const mainColor = this.themeColor || colors.sunset;

Check warning on line 786 in js/blink-pay-button.js

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

'mainColor' is assigned a value but never used
const isDark = this.themeMode === 'dark';
const textColor = isDark ? colors.light : colors.dark;
const bgColor = isDark ? colors.darkBg : colors.lightBg;
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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');
}
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -2742,7 +2799,7 @@
const parentContainer = widgetContainer.parentElement;

const resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {

Check warning on line 2802 in js/blink-pay-button.js

View workflow job for this annotation

GitHub Actions / Lint (ESLint)

'entry' is assigned a value but never used
this.adjustButtonResponsiveness(button);
}
});
Expand Down Expand Up @@ -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()}`;
}
Expand Down
230 changes: 230 additions & 0 deletions tests/widget-invoice-denomination.spec.js
Original file line number Diff line number Diff line change
@@ -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 = '<div id="blink-pay-button-container"></div>';
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
);
});
});
Loading