From b093b0abb983f312d447fb3c5239de298b3e9e59 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 9 Jan 2026 19:58:42 +0100 Subject: [PATCH] feat(ui): add frontend support for paying amountless invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the wallet UI to properly handle amountless BOLT11 invoices: - Detect amountless invoices when decoding (amount is null/0) - Display "Any Amount" header and amount input field for amountless invoices - Validate that amount is provided before payment - Pass amount_msat to API when paying amountless invoices - Add translations for new UI strings - Hide fiat toggle for amountless invoices (amount not yet known) This complements the backend changes in the previous commit, providing a complete user experience for paying amountless invoices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../amountless-invoice-implementation.md | 58 ++++++++++++------- lnbits/static/i18n/en.js | 2 + lnbits/static/js/api.js | 5 +- lnbits/static/js/pages/wallet.js | 45 ++++++++++++-- lnbits/templates/pages/wallet.vue | 25 +++++++- 5 files changed, 105 insertions(+), 30 deletions(-) diff --git a/docs/guide/amountless-invoice-implementation.md b/docs/guide/amountless-invoice-implementation.md index dd0a3579..bec96c50 100644 --- a/docs/guide/amountless-invoice-implementation.md +++ b/docs/guide/amountless-invoice-implementation.md @@ -31,6 +31,7 @@ This document provides a comprehensive analysis supporting the implementation of This implementation adds the ability to pay BOLT11 invoices that don't have an embedded amount by specifying the amount at payment time via the `amount_msat` parameter. **Key Changes:** + - Added `Feature.amountless_invoice` to wallet base class for capability detection - Updated `Wallet.pay_invoice()` signature with optional `amount_msat` parameter - Implemented amountless invoice support in LND REST and LND gRPC wallets @@ -68,6 +69,7 @@ To ensure our implementation follows established patterns, we analyzed three maj ### Blixt Wallet (React Native) **Detection** (`src/state/Send.ts:185`): + ```typescript if (!paymentRequest.numSatoshis) { // Invoice is amountless - require user input @@ -75,14 +77,16 @@ if (!paymentRequest.numSatoshis) { ``` **Amount Injection** (`src/state/Send.ts:203-204`): + ```typescript // Mutate the payment request with user-provided amount -paymentRequest.numSatoshis = payload.amount; +paymentRequest.numSatoshis = payload.amount ``` **UI Handling** (`src/windows/Send/SendConfirmation.tsx:67-70`): + ```typescript -const amountEditable = !paymentRequest.numSatoshis; +const amountEditable = !paymentRequest.numSatoshis // Shows editable amount field when invoice has no amount ``` @@ -91,6 +95,7 @@ const amountEditable = !paymentRequest.numSatoshis; ### Breez SDK (Flutter) **Detection** (`lib/widgets/payment_request_info_dialog.dart:162`): + ```dart if (widget.invoice.amount == 0) { // Show amount input field @@ -98,6 +103,7 @@ if (widget.invoice.amount == 0) { ``` **Validation** (`lib/widgets/payment_request_info_dialog.dart:251`): + ```dart var validationResult = acc.validateOutgoingPayment(amountToPay); if (validationResult.isNotEmpty) { @@ -110,15 +116,17 @@ if (validationResult.isNotEmpty) { ### Zeus Wallet (React Native) **Detection** (`views/PaymentRequest.tsx:602-603`): + ```typescript -const isNoAmountInvoice = !requestAmount || requestAmount === 0; +const isNoAmountInvoice = !requestAmount || requestAmount === 0 ``` **Amount Handling** (`views/Send.tsx:339-341`): + ```typescript if (isNoAmountInvoice) { // Use amount from user input instead of invoice - amountToSend = userEnteredAmount; + amountToSend = userEnteredAmount } ``` @@ -128,14 +136,14 @@ if (isNoAmountInvoice) { All three wallets follow the same logical flow: -| Step | Pattern | -|------|---------| -| 1. Decode | Parse BOLT11 invoice | -| 2. Detect | Check if `amount == 0` or `null` | -| 3. Prompt | Show UI for amount input if amountless | -| 4. Validate | Verify amount > 0 and within balance | -| 5. Inject | Pass amount to payment backend | -| 6. Pay | Execute payment with specified amount | +| Step | Pattern | +| ----------- | -------------------------------------- | +| 1. Decode | Parse BOLT11 invoice | +| 2. Detect | Check if `amount == 0` or `null` | +| 3. Prompt | Show UI for amount input if amountless | +| 4. Validate | Verify amount > 0 and within balance | +| 5. Inject | Pass amount to payment backend | +| 6. Pay | Execute payment with specified amount | --- @@ -165,6 +173,7 @@ message SendPaymentRequest { ``` **Key Points:** + - `amt` and `amt_msat` are mutually exclusive - When `payment_request` has zero amount, `amt` or `amt_msat` is **required** - When `payment_request` has an amount, `amt`/`amt_msat` must **not** be specified @@ -241,6 +250,7 @@ LNbits uses a layered architecture where changes flow from API → Service → W #### 1. Base Wallet Class (`lnbits/wallets/base.py`) **Feature Declaration:** + ```python class Feature(Enum): nodemanager = "nodemanager" @@ -249,6 +259,7 @@ class Feature(Enum): ``` **Method Signature:** + ```python @abstractmethod def pay_invoice( @@ -270,11 +281,13 @@ def pay_invoice( #### 2. LND REST Wallet (`lnbits/wallets/lndrest.py`) **Feature Declaration:** + ```python features = [Feature.nodemanager, Feature.holdinvoice, Feature.amountless_invoice] ``` **Payment Implementation:** + ```python async def pay_invoice( self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None @@ -294,6 +307,7 @@ async def pay_invoice( #### 3. Payment Service (`lnbits/core/services/payments.py`) **Validation:** + ```python def _validate_payment_request( payment_request: str, max_sat: int | None = None, amount_msat: int | None = None @@ -322,6 +336,7 @@ def _validate_payment_request( ``` **Amount Handling:** + ```python # Determine the actual amount to pay pay_amount_msat = invoice.amount_msat or amount_msat @@ -373,6 +388,7 @@ curl -X POST "https://lnbits.example.com/api/v1/payments" \ ``` **Response:** + ```json { "payment_hash": "abc123...", @@ -385,14 +401,14 @@ curl -X POST "https://lnbits.example.com/api/v1/payments" \ ## Verification Matrix -| Requirement | LND Spec | Mobile Wallets | LNbits Implementation | -|-------------|----------|----------------|----------------------| -| Detect amountless | `payReq.MilliSat == nil` | Check `amount == 0/null` | `not invoice.amount_msat` | -| Require amount for amountless | Error if `reqAmt == 0` | Show input field | `PaymentError` if not provided | -| Block amount for regular invoices | Error if `reqAmt != 0` | N/A (UI doesn't allow) | `amountless_amount_msat = None` | -| Field name | `amt_msat` | N/A (native SDK) | `amt_msat` | -| Type | int64 (msat) | varies | int (msat) | -| Feature detection | N/A | Hardcoded | `Feature.amountless_invoice` | +| Requirement | LND Spec | Mobile Wallets | LNbits Implementation | +| --------------------------------- | ------------------------ | ------------------------ | ------------------------------- | +| Detect amountless | `payReq.MilliSat == nil` | Check `amount == 0/null` | `not invoice.amount_msat` | +| Require amount for amountless | Error if `reqAmt == 0` | Show input field | `PaymentError` if not provided | +| Block amount for regular invoices | Error if `reqAmt != 0` | N/A (UI doesn't allow) | `amountless_amount_msat = None` | +| Field name | `amt_msat` | N/A (native SDK) | `amt_msat` | +| Type | int64 (msat) | varies | int (msat) | +| Feature detection | N/A | Hardcoded | `Feature.amountless_invoice` | --- @@ -413,6 +429,7 @@ curl -X POST "https://lnbits.example.com/api/v1/payments" \ Two test cases cover the amountless invoice functionality: ### Happy Path + ```python @pytest.mark.anyio async def test_pay_amountless_invoice_with_amount(client, adminkey_headers_from): @@ -423,6 +440,7 @@ async def test_pay_amountless_invoice_with_amount(client, adminkey_headers_from) ``` ### Error Case + ```python @pytest.mark.anyio async def test_pay_amountless_invoice_without_amount_fails(client, adminkey_headers_from): diff --git a/lnbits/static/i18n/en.js b/lnbits/static/i18n/en.js index 792bebec..7a5ce917 100644 --- a/lnbits/static/i18n/en.js +++ b/lnbits/static/i18n/en.js @@ -217,6 +217,8 @@ window.localisation.en = { amount: 'Amount', amount_limits: 'Amount Limits', amount_sats: 'Amount (sats)', + amount_must_be_positive: 'Amount must be greater than 0', + any_amount: 'Any Amount', faucest_wallet: 'Faucet Wallet', faucest_wallet_desc_1: 'Each time a payment is confirmed by the {provider} provider funds will be subtracted from this wallet.', diff --git a/lnbits/static/js/api.js b/lnbits/static/js/api.js index aef5b720..8310cfd1 100644 --- a/lnbits/static/js/api.js +++ b/lnbits/static/js/api.js @@ -39,7 +39,7 @@ window._lnbitsApi = { } return this.request('post', '/api/v1/payments', wallet.inkey, data) }, - payInvoice(wallet, bolt11, internalMemo = null) { + payInvoice(wallet, bolt11, internalMemo = null, amountMsat = null) { const data = { out: true, bolt11: bolt11 @@ -49,6 +49,9 @@ window._lnbitsApi = { internal_memo: String(internalMemo) } } + if (amountMsat) { + data.amount_msat = amountMsat + } return this.request('post', '/api/v1/payments', wallet.adminkey, data) }, cancelInvoice(wallet, paymentHash) { diff --git a/lnbits/static/js/pages/wallet.js b/lnbits/static/js/pages/wallet.js index c2a35f9c..11ccf441 100644 --- a/lnbits/static/js/pages/wallet.js +++ b/lnbits/static/js/pages/wallet.js @@ -307,11 +307,25 @@ window.PageWallet = { return } + // Check if invoice is amountless (no amount specified) + const isAmountless = + !invoice.human_readable_part.amount || + invoice.human_readable_part.amount === 0 + let cleanInvoice = { - msat: invoice.human_readable_part.amount, - sat: invoice.human_readable_part.amount / 1000, - fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000), - bolt11: this.parse.data.request + msat: isAmountless ? null : invoice.human_readable_part.amount, + sat: isAmountless ? null : invoice.human_readable_part.amount / 1000, + fsat: isAmountless + ? this.$t('any_amount') + : LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000), + bolt11: this.parse.data.request, + isAmountless: isAmountless + } + + // Initialize amount for amountless invoices + if (isAmountless) { + this.parse.data.amount = null + this.parse.data.unit = 'sat' } _.each(invoice.data.tags, tag => { @@ -347,7 +361,7 @@ window.PageWallet = { } }) - if (this.g.wallet.currency) { + if (this.g.wallet.currency && !isAmountless) { cleanInvoice.fiatAmount = LNbits.utils.formatCurrency( ((cleanInvoice.sat / 1e8) * this.g.exchangeRate).toFixed(2), this.g.wallet.currency @@ -357,16 +371,35 @@ window.PageWallet = { this.parse.invoice = Object.freeze(cleanInvoice) }, payInvoice() { + // Validate amount for amountless invoices + if (this.parse.invoice.isAmountless) { + if (!this.parse.data.amount || this.parse.data.amount <= 0) { + Quasar.Notify.create({ + timeout: 3000, + type: 'warning', + message: this.$t('amount_must_be_positive') + }) + return + } + } + const dismissPaymentMsg = Quasar.Notify.create({ timeout: 0, message: this.$t('payment_processing') }) + // Calculate amount_msat for amountless invoices + let amountMsat = null + if (this.parse.invoice.isAmountless && this.parse.data.amount) { + amountMsat = this.parse.data.amount * 1000 // Convert sats to msats + } + LNbits.api .payInvoice( this.g.wallet, this.parse.data.request, - this.parse.data.internalMemo + this.parse.data.internalMemo, + amountMsat ) .then(response => { dismissPaymentMsg() diff --git a/lnbits/templates/pages/wallet.vue b/lnbits/templates/pages/wallet.vue index 7b285bf6..7fc8386d 100644 --- a/lnbits/templates/pages/wallet.vue +++ b/lnbits/templates/pages/wallet.vue @@ -458,7 +458,26 @@
-
+ +
+
+ +
+ +

-
+