diff --git a/docs/guide/amountless-invoice-implementation.md b/docs/guide/amountless-invoice-implementation.md deleted file mode 100644 index bec96c50..00000000 --- a/docs/guide/amountless-invoice-implementation.md +++ /dev/null @@ -1,459 +0,0 @@ -# Amountless BOLT11 Invoice Support in LNbits - -This document provides a comprehensive analysis supporting the implementation of amountless (zero-amount) BOLT11 invoice payments in LNbits, with a focus on the LND REST backend. - -## Table of Contents - -- [Overview](#overview) -- [What Are Amountless Invoices?](#what-are-amountless-invoices) -- [Use Cases](#use-cases) -- [Industry Analysis: Mobile Wallet Implementations](#industry-analysis-mobile-wallet-implementations) - - [Blixt Wallet (React Native)](#blixt-wallet-react-native) - - [Breez SDK (Flutter)](#breez-sdk-flutter) - - [Zeus Wallet (React Native)](#zeus-wallet-react-native) - - [Common Patterns](#common-patterns) -- [LND API Specification](#lnd-api-specification) - - [SendPaymentRequest Fields](#sendpaymentrequest-fields) - - [Amountless Invoice Handling](#amountless-invoice-handling) - - [Validation Logic](#validation-logic) -- [LNbits Implementation](#lnbits-implementation) - - [Architecture Overview](#architecture-overview) - - [Layer-by-Layer Changes](#layer-by-layer-changes) - - [API Usage](#api-usage) -- [Verification Matrix](#verification-matrix) -- [Security Considerations](#security-considerations) -- [Testing](#testing) - ---- - -## Overview - -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 -- Updated payment service layer to validate and pass through `amount_msat` -- Added `amount_msat` field to CreateInvoice API model - ---- - -## What Are Amountless Invoices? - -A BOLT11 invoice typically encodes a specific amount to be paid. However, the BOLT11 specification allows for invoices without an amount field, leaving the payment amount to be determined by the payer. These are commonly called: - -- **Amountless invoices** -- **Zero-amount invoices** -- **Open invoices** - -When decoded, these invoices have `amount_msat = null` or `amount_msat = 0`. - ---- - -## Use Cases - -1. **Donations**: Accept any amount the donor wishes to give -2. **Tips**: Allow customers to decide the tip amount -3. **Variable services**: Pay-what-you-want pricing models -4. **LNURL-withdraw**: Some LNURL flows use amountless invoices -5. **NFC payments**: Tap-to-pay scenarios where amount is determined at payment time - ---- - -## Industry Analysis: Mobile Wallet Implementations - -To ensure our implementation follows established patterns, we analyzed three major Lightning mobile wallets. - -### Blixt Wallet (React Native) - -**Detection** (`src/state/Send.ts:185`): - -```typescript -if (!paymentRequest.numSatoshis) { - // Invoice is amountless - require user input -} -``` - -**Amount Injection** (`src/state/Send.ts:203-204`): - -```typescript -// Mutate the payment request with user-provided amount -paymentRequest.numSatoshis = payload.amount -``` - -**UI Handling** (`src/windows/Send/SendConfirmation.tsx:67-70`): - -```typescript -const amountEditable = !paymentRequest.numSatoshis -// Shows editable amount field when invoice has no amount -``` - -**Key Pattern**: Blixt mutates the payment request object directly before sending to the LND backend. - -### Breez SDK (Flutter) - -**Detection** (`lib/widgets/payment_request_info_dialog.dart:162`): - -```dart -if (widget.invoice.amount == 0) { - // Show amount input field -} -``` - -**Validation** (`lib/widgets/payment_request_info_dialog.dart:251`): - -```dart -var validationResult = acc.validateOutgoingPayment(amountToPay); -if (validationResult.isNotEmpty) { - // Show validation error -} -``` - -**Key Pattern**: Breez validates the user-entered amount against account balance before payment. - -### Zeus Wallet (React Native) - -**Detection** (`views/PaymentRequest.tsx:602-603`): - -```typescript -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 -} -``` - -**Key Pattern**: Zeus uses explicit boolean flags (`isNoAmountInvoice`) for clear code intent. - -### Common Patterns - -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 | - ---- - -## LND API Specification - -The implementation must comply with LND's REST and gRPC API specifications. - -### SendPaymentRequest Fields - -From `lnrpc/routerrpc/router.proto`: - -```protobuf -message SendPaymentRequest { - // Number of satoshis to send. - // The fields amt and amt_msat are mutually exclusive. - int64 amt = 2; - - // A bare-bones invoice for a payment within the Lightning Network. - // The amount in the payment request may be zero. In that case it is - // required to set the amt field as well. - string payment_request = 5; - - // Number of millisatoshis to send. - // The fields amt and amt_msat are mutually exclusive. - int64 amt_msat = 12; -} -``` - -**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 - -### Amountless Invoice Handling - -From `lnrpc/routerrpc/router_backend.go:1028-1045`: - -```go -// If the amount was not included in the invoice, then we let -// the payer specify the amount of satoshis they wish to send. -if payReq.MilliSat == nil { - if reqAmt == 0 { - return nil, errors.New("amount must be specified when paying a zero amount invoice") - } - payIntent.Amount = reqAmt -} else { - if reqAmt != 0 { - return nil, errors.New("amount must not be specified when paying a non-zero amount invoice") - } - payIntent.Amount = *payReq.MilliSat -} -``` - -### Validation Logic - -LND's `UnmarshallAmt` function (`lnrpc/marshall_utils.go:57-72`): - -```go -func UnmarshallAmt(amtSat, amtMsat int64) (lnwire.MilliSatoshi, error) { - if amtSat != 0 && amtMsat != 0 { - return 0, ErrSatMsatMutualExclusive - } - if amtSat < 0 || amtMsat < 0 { - return 0, ErrNegativeAmt - } - if amtSat != 0 { - return lnwire.NewMSatFromSatoshis(btcutil.Amount(amtSat)), nil - } - return lnwire.MilliSatoshi(amtMsat), nil -} -``` - ---- - -## LNbits Implementation - -### Architecture Overview - -LNbits uses a layered architecture where changes flow from API → Service → Wallet: - -``` -┌─────────────────────────────────────────────────────────┐ -│ API Layer (payment_api.py) │ -│ - Receives amount_msat from client │ -└─────────────────────┬───────────────────────────────────┘ - │ -┌─────────────────────▼───────────────────────────────────┐ -│ Service Layer (payments.py) │ -│ - Validates amountless invoice + amount_msat │ -│ - Checks funding source capability │ -│ - Passes amountless_amount_msat through chain │ -└─────────────────────┬───────────────────────────────────┘ - │ -┌─────────────────────▼───────────────────────────────────┐ -│ Wallet Layer (lndrest.py, lndgrpc.py, etc.) │ -│ - Adds amt_msat to LND request if provided │ -│ - Declares Feature.amountless_invoice capability │ -└─────────────────────────────────────────────────────────┘ -``` - -### Layer-by-Layer Changes - -#### 1. Base Wallet Class (`lnbits/wallets/base.py`) - -**Feature Declaration:** - -```python -class Feature(Enum): - nodemanager = "nodemanager" - holdinvoice = "holdinvoice" - amountless_invoice = "amountless_invoice" # NEW -``` - -**Method Signature:** - -```python -@abstractmethod -def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None -) -> Coroutine[None, None, PaymentResponse]: - """ - Pay a BOLT11 invoice. - - Args: - bolt11: The BOLT11 invoice string - fee_limit_msat: Maximum fee in millisatoshis - amount_msat: Amount to pay in millisatoshis. Required for amountless - invoices on wallets that support Feature.amountless_invoice. - Ignored for invoices that already contain an amount. - """ - pass -``` - -#### 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 -) -> PaymentResponse: - req: dict = { - "payment_request": bolt11, - "fee_limit_msat": fee_limit_msat, - "timeout_seconds": 30, - "no_inflight_updates": True, - } - # For amountless invoices, specify the amount to pay - if amount_msat is not None: - req["amt_msat"] = amount_msat - # ... rest of implementation -``` - -#### 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 -) -> Bolt11: - invoice = bolt11_decode(payment_request) - - if not invoice.amount_msat or invoice.amount_msat <= 0: - # Amountless invoice - check capability and require amount - funding_source = get_funding_source() - if not funding_source.has_feature(Feature.amountless_invoice): - raise PaymentError( - "Amountless invoices not supported by the funding source.", - status="failed", - ) - if not amount_msat or amount_msat <= 0: - raise PaymentError( - "Amount required for amountless invoices.", - status="failed", - ) - check_amount_msat = amount_msat - else: - check_amount_msat = invoice.amount_msat - - # Validate against max payment limit - # ... -``` - -**Amount Handling:** - -```python -# Determine the actual amount to pay -pay_amount_msat = invoice.amount_msat or amount_msat - -# Only pass amount to funding source if invoice is amountless -amountless_amount_msat = amount_msat if not invoice.amount_msat else None -``` - -#### 4. API Layer (`lnbits/core/views/payment_api.py`) - -```python -payment = await pay_invoice( - wallet_id=wallet_id, - payment_request=invoice_data.bolt11, - extra=invoice_data.extra, - labels=invoice_data.labels, - amount_msat=invoice_data.amount_msat, # NEW -) -``` - -#### 5. API Model (`lnbits/core/models/payments.py`) - -```python -class CreateInvoice(BaseModel): - # ... existing fields - amount_msat: int | None = Query( - None, - ge=1, - description=( - "Amount to pay in millisatoshis. Required for amountless invoices " - "when the funding source supports them." - ), - ) -``` - -### API Usage - -**Paying an amountless invoice:** - -```bash -curl -X POST "https://lnbits.example.com/api/v1/payments" \ - -H "X-Api-Key: " \ - -H "Content-Type: application/json" \ - -d '{ - "out": true, - "bolt11": "lnbc1p...", - "amount_msat": 100000 - }' -``` - -**Response:** - -```json -{ - "payment_hash": "abc123...", - "checking_id": "abc123...", - "status": "success" -} -``` - ---- - -## 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` | - ---- - -## Security Considerations - -1. **Balance Validation**: The service layer validates that the wallet has sufficient balance for the specified amount before attempting payment. - -2. **Maximum Amount**: Amountless payments are still subject to `lnbits_max_outgoing_payment_amount_sats` limits. - -3. **Feature Gating**: Only wallets that explicitly declare `Feature.amountless_invoice` support can process amountless payments. This prevents accidental payment failures on unsupported backends. - -4. **Input Validation**: The API model enforces `amount_msat >= 1` when provided, preventing zero or negative amounts. - ---- - -## Testing - -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): - """Test paying an amountless invoice by specifying amount_msat.""" - # Create amountless invoice via FakeWallet - # Pay with amount_msat specified - # Verify payment succeeds -``` - -### Error Case - -```python -@pytest.mark.anyio -async def test_pay_amountless_invoice_without_amount_fails(client, adminkey_headers_from): - """Test that paying an amountless invoice without amount_msat fails.""" - # Create amountless invoice - # Attempt payment WITHOUT amount_msat - # Verify proper error: "Amount required for amountless invoices" -``` - ---- - -## References - -- [BOLT11 Specification](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md) -- [LND Router RPC Documentation](https://lightning.engineering/api-docs/api/lnd/router/) -- [LND SendPaymentV2 API](https://lightning.engineering/api-docs/api/lnd/router/send-payment-v2/) diff --git a/lnbits/core/models/payments.py b/lnbits/core/models/payments.py index e6ba60f1..d7743cdc 100644 --- a/lnbits/core/models/payments.py +++ b/lnbits/core/models/payments.py @@ -292,15 +292,6 @@ class CreateInvoice(BaseModel): lnurl_withdraw: LnurlWithdrawResponse | None = None fiat_provider: str | None = None labels: list[str] = [] - # For paying amountless invoices (out=true only) - amount_msat: int | None = Query( - None, - ge=1, - description=( - "Amount to pay in millisatoshis. Required for amountless invoices " - "when the funding source supports them." - ), - ) @validator("payment_hash") def check_hex(cls, v): diff --git a/lnbits/core/services/payments.py b/lnbits/core/services/payments.py index 186a8308..80be5620 100644 --- a/lnbits/core/services/payments.py +++ b/lnbits/core/services/payments.py @@ -24,7 +24,6 @@ from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_pre from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets.base import ( - Feature, InvoiceResponse, PaymentPendingStatus, PaymentResponse, @@ -64,44 +63,17 @@ async def pay_invoice( tag: str = "", labels: list[str] | None = None, conn: Connection | None = None, - amount_msat: int | None = None, ) -> Payment: - """ - Pay a BOLT11 invoice. - - Args: - wallet_id: The wallet to pay from - payment_request: The BOLT11 invoice string - max_sat: Maximum amount allowed in satoshis - extra: Extra metadata to store with the payment - description: Payment description/memo - tag: Payment tag (usually extension name) - labels: Payment labels - conn: Optional database connection to reuse - amount_msat: Amount to pay in millisatoshis. Required for amountless - invoices when the funding source supports Feature.amountless_invoice. - - Returns: - The created Payment object - """ if settings.lnbits_only_allow_incoming_payments: raise PaymentError("Only incoming payments allowed.", status="failed") - invoice = _validate_payment_request(payment_request, max_sat, amount_msat) + invoice = _validate_payment_request(payment_request, max_sat) - # Determine the actual amount to pay - # For amountless invoices, use the provided amount_msat - pay_amount_msat = invoice.amount_msat or amount_msat - if not pay_amount_msat: - raise ValueError("Missing invoice amount.") - - # For amountless invoices, we need to pass the amount to the funding source - # Only pass amount if the invoice is amountless - amountless_amount_msat = amount_msat if not invoice.amount_msat else None + if not invoice.amount_msat: + raise ValueError("Missig invoice amount.") async with db.reuse_conn(conn) if conn else db.connect() as new_conn: - wallet = await _check_wallet_for_payment( - wallet_id, tag, pay_amount_msat, new_conn - ) + amount_msat = invoice.amount_msat + wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn) if not wallet.can_send_payments: raise PaymentError( @@ -112,15 +84,13 @@ async def pay_invoice( if await is_internal_status_success(invoice.payment_hash, new_conn): raise PaymentError("Internal invoice already paid.", status="failed") - _, extra = await calculate_fiat_amounts( - pay_amount_msat / 1000, wallet, extra=extra - ) + _, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra) create_payment_model = CreatePayment( wallet_id=wallet.source_wallet_id, bolt11=payment_request, payment_hash=invoice.payment_hash, - amount_msat=-pay_amount_msat, + amount_msat=-amount_msat, expiry=invoice.expiry_date, memo=description or invoice.description or "", extra=extra, @@ -129,10 +99,7 @@ async def pay_invoice( async with db.reuse_conn(conn) if conn else db.connect() as new_conn: payment = await _pay_invoice( - wallet.source_wallet_id, - create_payment_model, - amountless_amount_msat=amountless_amount_msat, - conn=new_conn, + wallet.source_wallet_id, create_payment_model, conn=new_conn ) await _credit_service_fee_wallet(wallet, payment, conn=new_conn) @@ -705,7 +672,6 @@ async def get_payments_daily_stats( async def _pay_invoice( wallet_id: str, create_payment_model: CreatePayment, - amountless_amount_msat: int | None = None, conn: Connection | None = None, ): async with payment_lock: @@ -722,9 +688,7 @@ async def _pay_invoice( payment = await _pay_internal_invoice(wallet, create_payment_model, conn) if not payment: - payment = await _pay_external_invoice( - wallet, create_payment_model, amountless_amount_msat, conn - ) + payment = await _pay_external_invoice(wallet, create_payment_model, conn) return payment @@ -802,7 +766,6 @@ async def _pay_internal_invoice( async def _pay_external_invoice( wallet: Wallet, create_payment_model: CreatePayment, - amountless_amount_msat: int | None = None, conn: Connection | None = None, ) -> Payment: checking_id = create_payment_model.payment_hash @@ -833,9 +796,7 @@ async def _pay_external_invoice( fee_reserve_msat = fee_reserve(amount_msat, internal=False) task = create_task( - _fundingsource_pay_invoice( - checking_id, payment.bolt11, fee_reserve_msat, amountless_amount_msat - ) + _fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat) ) # make sure a hold invoice or deferred payment is not blocking the server @@ -886,15 +847,12 @@ async def update_payment_success_status( async def _fundingsource_pay_invoice( - checking_id: str, - bolt11: str, - fee_reserve_msat: int, - amountless_amount_msat: int | None = None, + checking_id: str, bolt11: str, fee_reserve_msat: int ) -> PaymentResponse: logger.debug(f"fundingsource: sending payment {checking_id}") funding_source = get_funding_source() payment_response: PaymentResponse = await funding_source.pay_invoice( - bolt11, fee_reserve_msat, amountless_amount_msat + bolt11, fee_reserve_msat ) logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}") return payment_response @@ -948,48 +906,21 @@ async def _check_wallet_for_payment( def _validate_payment_request( - payment_request: str, max_sat: int | None = None, amount_msat: int | None = None + payment_request: str, max_sat: int | None = None ) -> Bolt11: - """ - Validate a BOLT11 payment request. - - Args: - payment_request: The BOLT11 invoice string - max_sat: Maximum amount allowed in satoshis - amount_msat: Amount to pay for amountless invoices (in millisatoshis) - - Returns: - Decoded Bolt11 invoice object - """ try: invoice = bolt11_decode(payment_request) except Exception as exc: raise PaymentError("Bolt11 decoding failed.", status="failed") from exc - # Check if this is an amountless invoice - if not invoice.amount_msat or invoice.amount_msat <= 0: - # Amountless invoice - check if funding source supports it and amount provided - funding_source = get_funding_source() - if not funding_source.has_feature(Feature.amountless_invoice): - raise PaymentError( - "Amountless invoices not supported by the funding source.", - status="failed", - ) - if not amount_msat or amount_msat <= 0: - raise PaymentError( - "Amount required for amountless invoices.", - status="failed", - ) - # Use provided amount for max_sat check - check_amount_msat = amount_msat - else: - check_amount_msat = invoice.amount_msat + if not invoice.amount_msat or not invoice.amount_msat > 0: + raise PaymentError("Amountless invoices not supported.", status="failed") max_sat = max_sat or settings.lnbits_max_outgoing_payment_amount_sats max_sat = min(max_sat, settings.lnbits_max_outgoing_payment_amount_sats) - if check_amount_msat > max_sat * 1000: + if invoice.amount_msat > max_sat * 1000: raise PaymentError( - f"Invoice amount {check_amount_msat // 1000} sats is too high. " + f"Invoice amount {invoice.amount_msat // 1000} sats is too high. " f"Max allowed: {max_sat} sats.", status="failed", ) diff --git a/lnbits/core/views/payment_api.py b/lnbits/core/views/payment_api.py index c21a729f..38d6e10d 100644 --- a/lnbits/core/views/payment_api.py +++ b/lnbits/core/views/payment_api.py @@ -263,7 +263,6 @@ async def api_payments_create( payment_request=invoice_data.bolt11, extra=invoice_data.extra, labels=invoice_data.labels, - amount_msat=invoice_data.amount_msat, ) return payment diff --git a/lnbits/static/i18n/en.js b/lnbits/static/i18n/en.js index 7a5ce917..792bebec 100644 --- a/lnbits/static/i18n/en.js +++ b/lnbits/static/i18n/en.js @@ -217,8 +217,6 @@ 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 8310cfd1..aef5b720 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, amountMsat = null) { + payInvoice(wallet, bolt11, internalMemo = null) { const data = { out: true, bolt11: bolt11 @@ -49,9 +49,6 @@ 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 11ccf441..c2a35f9c 100644 --- a/lnbits/static/js/pages/wallet.js +++ b/lnbits/static/js/pages/wallet.js @@ -307,25 +307,11 @@ 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: 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' + 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 } _.each(invoice.data.tags, tag => { @@ -361,7 +347,7 @@ window.PageWallet = { } }) - if (this.g.wallet.currency && !isAmountless) { + if (this.g.wallet.currency) { cleanInvoice.fiatAmount = LNbits.utils.formatCurrency( ((cleanInvoice.sat / 1e8) * this.g.exchangeRate).toFixed(2), this.g.wallet.currency @@ -371,35 +357,16 @@ 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, - amountMsat + this.parse.data.internalMemo ) .then(response => { dismissPaymentMsg() diff --git a/lnbits/templates/pages/wallet.vue b/lnbits/templates/pages/wallet.vue index 7fc8386d..7b285bf6 100644 --- a/lnbits/templates/pages/wallet.vue +++ b/lnbits/templates/pages/wallet.vue @@ -458,26 +458,7 @@
- -
-
- -
- -
+

-
+
PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: try: # https://api.getalby.com/payments/bolt11 r = await self.client.post( diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py index 23ddcbeb..90517948 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -18,7 +18,6 @@ if TYPE_CHECKING: class Feature(Enum): nodemanager = "nodemanager" holdinvoice = "holdinvoice" - amountless_invoice = "amountless_invoice" # bolt12 = "bolt12" @@ -138,21 +137,8 @@ class Wallet(ABC): @abstractmethod def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + self, bolt11: str, fee_limit_msat: int ) -> Coroutine[None, None, PaymentResponse]: - """ - Pay a BOLT11 invoice. - - Args: - bolt11: The BOLT11 invoice string - fee_limit_msat: Maximum fee in millisatoshis - amount_msat: Amount to pay in millisatoshis. Required for amountless - invoices on wallets that support Feature.amountless_invoice. - Ignored for invoices that already contain an amount. - - Returns: - PaymentResponse indicating success, failure, or pending status - """ pass @abstractmethod diff --git a/lnbits/wallets/blink.py b/lnbits/wallets/blink.py index 94331a12..be736086 100644 --- a/lnbits/wallets/blink.py +++ b/lnbits/wallets/blink.py @@ -165,7 +165,7 @@ class BlinkWallet(Wallet): ) async def pay_invoice( - self, bolt11_invoice: str, fee_limit_msat: int, amount_msat: int | None = None + self, bolt11_invoice: str, fee_limit_msat: int ) -> PaymentResponse: # https://dev.blink.sv/api/btc-ln-send # Future: add check fee estimate is < fee_limit_msat before paying invoice diff --git a/lnbits/wallets/boltz.py b/lnbits/wallets/boltz.py index c5b336e0..c6473f18 100644 --- a/lnbits/wallets/boltz.py +++ b/lnbits/wallets/boltz.py @@ -123,9 +123,7 @@ class BoltzWallet(Wallet): fee_msat=fee_msat, ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC}) try: diff --git a/lnbits/wallets/breez.py b/lnbits/wallets/breez.py index 5ba4d43b..bf6d59c9 100644 --- a/lnbits/wallets/breez.py +++ b/lnbits/wallets/breez.py @@ -208,7 +208,7 @@ else: return InvoiceResponse(ok=False, error_message=str(e)) async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + self, bolt11: str, fee_limit_msat: int ) -> PaymentResponse: logger.debug(f"fee_limit_msat {fee_limit_msat} is ignored by Breez SDK") try: diff --git a/lnbits/wallets/breez_liquid.py b/lnbits/wallets/breez_liquid.py index 2b0ffa97..6f1f6fb5 100644 --- a/lnbits/wallets/breez_liquid.py +++ b/lnbits/wallets/breez_liquid.py @@ -171,7 +171,7 @@ else: return InvoiceResponse(ok=False, error_message=str(e)) async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + self, bolt11: str, fee_limit_msat: int ) -> PaymentResponse: invoice_data = bolt11_decode(bolt11) diff --git a/lnbits/wallets/cliche.py b/lnbits/wallets/cliche.py index af9c6cc1..a0ef0e3c 100644 --- a/lnbits/wallets/cliche.py +++ b/lnbits/wallets/cliche.py @@ -103,9 +103,7 @@ class ClicheWallet(Wallet): preimage=data["result"].get("preimage"), ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: ws = create_connection(self.endpoint) ws.send(f"pay-invoice --invoice {bolt11}") checking_id, fee_msat, preimage, payment_ok = ( diff --git a/lnbits/wallets/clnrest.py b/lnbits/wallets/clnrest.py index 5396d518..8d34cef3 100644 --- a/lnbits/wallets/clnrest.py +++ b/lnbits/wallets/clnrest.py @@ -248,7 +248,7 @@ class CLNRestWallet(Wallet): self, bolt11: str, fee_limit_msat: int, - amount_msat: int | None = None, + **_, ) -> PaymentResponse: try: diff --git a/lnbits/wallets/corelightning.py b/lnbits/wallets/corelightning.py index 4075742e..e21cb4f1 100644 --- a/lnbits/wallets/corelightning.py +++ b/lnbits/wallets/corelightning.py @@ -147,9 +147,7 @@ class CoreLightningWallet(Wallet): logger.warning(e) return InvoiceResponse(ok=False, error_message=str(e)) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: try: invoice = bolt11_decode(bolt11) except Bolt11Exception as exc: diff --git a/lnbits/wallets/corelightningrest.py b/lnbits/wallets/corelightningrest.py index 74cc9602..79fd1cf0 100644 --- a/lnbits/wallets/corelightningrest.py +++ b/lnbits/wallets/corelightningrest.py @@ -176,9 +176,7 @@ class CoreLightningRestWallet(Wallet): ok=False, error_message=f"Unable to connect to {self.url}." ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: try: invoice = decode(bolt11) except Bolt11Exception as exc: diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 843fbed7..dc5ed590 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -144,9 +144,7 @@ class EclairWallet(Wallet): ok=False, error_message=f"Unable to connect to {self.url}." ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: try: r = await self.client.post( "/payinvoice", diff --git a/lnbits/wallets/fake.py b/lnbits/wallets/fake.py index bf30b0bf..be70d572 100644 --- a/lnbits/wallets/fake.py +++ b/lnbits/wallets/fake.py @@ -19,7 +19,6 @@ from lnbits.settings import settings from lnbits.utils.crypto import fake_privkey from .base import ( - Feature, InvoiceResponse, PaymentFailedStatus, PaymentPendingStatus, @@ -32,7 +31,6 @@ from .base import ( class FakeWallet(Wallet): - features = [Feature.amountless_invoice] def __init__(self) -> None: self.queue: asyncio.Queue = asyncio.Queue(0) @@ -91,7 +89,7 @@ class FakeWallet(Wallet): bolt11 = Bolt11( currency="bc", - amount_msat=MilliSatoshi(amount * 1000) if amount > 0 else None, + amount_msat=MilliSatoshi(amount * 1000), date=int(datetime.now().timestamp()), tags=tags, ) @@ -105,20 +103,12 @@ class FakeWallet(Wallet): preimage=preimage.hex(), ) - async def pay_invoice( - self, bolt11: str, _: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, _: int) -> PaymentResponse: try: invoice = decode(bolt11) except Bolt11Exception as exc: return PaymentResponse(ok=False, error_message=str(exc)) - # For amountless invoices, amount_msat must be provided - if not invoice.amount_msat and not amount_msat: - return PaymentResponse( - ok=False, error_message="Amount required for amountless invoice" - ) - if invoice.payment_hash in self.payment_secrets: await self.queue.put(invoice) self.paid_invoices.add(invoice.payment_hash) diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py index caf0d619..c14a4170 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -117,9 +117,7 @@ class LNbitsWallet(Wallet): ok=False, error_message=f"Unable to connect to {self.endpoint}." ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: try: r = await self.client.post( url="/api/v1/payments", diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py index 47509aad..878433e9 100644 --- a/lnbits/wallets/lndgrpc.py +++ b/lnbits/wallets/lndgrpc.py @@ -88,7 +88,7 @@ class LndWallet(Wallet): router_rpc: RouterStub invoices_rpc: InvoicesStub - features = [Feature.holdinvoice, Feature.amountless_invoice] + features = [Feature.holdinvoice] def __init__(self): if not settings.lnd_grpc_endpoint: @@ -185,9 +185,7 @@ class LndWallet(Wallet): preimage=preimage, ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: # fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000) req = SendPaymentRequest( payment_request=bolt11, @@ -195,9 +193,6 @@ class LndWallet(Wallet): timeout_seconds=30, no_inflight_updates=True, ) - # For amountless invoices, specify the amount to pay - if amount_msat is not None: - req.amt_msat = amount_msat try: res: Payment = await self.router_rpc.SendPaymentV2(req).read() except Exception as exc: diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index 305a7dc2..da72a61f 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -30,7 +30,7 @@ class LndRestWallet(Wallet): """https://api.lightning.community/#lnd-rest-api-reference""" __node_cls__ = LndRestNode - features = [Feature.nodemanager, Feature.holdinvoice, Feature.amountless_invoice] + features = [Feature.nodemanager, Feature.holdinvoice] def __init__(self): if not settings.lnd_rest_endpoint: @@ -170,10 +170,8 @@ class LndRestWallet(Wallet): ok=False, error_message=f"Unable to connect to {self.endpoint}." ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: - req: dict = { + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + req = { "payment_request": bolt11, "fee_limit_msat": fee_limit_msat, "timeout_seconds": 30, @@ -181,9 +179,6 @@ class LndRestWallet(Wallet): } if settings.lnd_rest_allow_self_payment: req["allow_self_payment"] = True - # For amountless invoices, specify the amount to pay - if amount_msat is not None: - req["amt_msat"] = amount_msat try: r = await self.client.post( diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index 4266309e..738faf9e 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -104,9 +104,7 @@ class LNPayWallet(Wallet): error_message=r.text, ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: r = await self.client.post( f"/wallet/{self.wallet_key}/withdraw", json={"payment_request": bolt11}, diff --git a/lnbits/wallets/lntips.py b/lnbits/wallets/lntips.py index d344dc58..5a15be1a 100644 --- a/lnbits/wallets/lntips.py +++ b/lnbits/wallets/lntips.py @@ -102,9 +102,7 @@ class LnTipsWallet(Wallet): preimage=data.get("preimage"), ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: r = await self.client.post( "/api/v1/payinvoice", json={"pay_req": bolt11}, diff --git a/lnbits/wallets/nwc.py b/lnbits/wallets/nwc.py index 80163916..c73ba668 100644 --- a/lnbits/wallets/nwc.py +++ b/lnbits/wallets/nwc.py @@ -192,9 +192,7 @@ class NWCWallet(Wallet): except Exception as e: return StatusResponse(str(e), 0) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: try: resp = await self.conn.call("pay_invoice", {"invoice": bolt11}) preimage = resp.get("preimage", None) diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py index ac747189..f0534974 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -99,9 +99,7 @@ class OpenNodeWallet(Wallet): ok=True, checking_id=checking_id, payment_request=payment_request ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: r = await self.client.post( "/v2/withdrawals", json={"type": "ln", "address": bolt11}, diff --git a/lnbits/wallets/phoenixd.py b/lnbits/wallets/phoenixd.py index 4a2f29ad..8de95e27 100644 --- a/lnbits/wallets/phoenixd.py +++ b/lnbits/wallets/phoenixd.py @@ -164,9 +164,7 @@ class PhoenixdWallet(Wallet): ok=False, error_message=f"Unable to connect to {self.endpoint}." ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: try: r = await self.client.post( "/payinvoice", diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index 6fa94480..90dcc05e 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -146,9 +146,7 @@ class SparkWallet(Wallet): except (SparkError, UnknownError) as e: return InvoiceResponse(ok=False, error_message=str(e)) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: try: r = await self.pay( bolt11=bolt11, diff --git a/lnbits/wallets/strike.py b/lnbits/wallets/strike.py index 2afe9e98..8fbbb7e1 100644 --- a/lnbits/wallets/strike.py +++ b/lnbits/wallets/strike.py @@ -225,9 +225,7 @@ class StrikeWallet(Wallet): logger.warning(e) return InvoiceResponse(ok=False, error_message="Connection error") - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: # Extract payment hash from invoice for checking_id try: diff --git a/lnbits/wallets/zbd.py b/lnbits/wallets/zbd.py index d10b53ad..1377e0db 100644 --- a/lnbits/wallets/zbd.py +++ b/lnbits/wallets/zbd.py @@ -103,9 +103,7 @@ class ZBDWallet(Wallet): preimage=preimage, ) - async def pay_invoice( - self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None - ) -> PaymentResponse: + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: # https://api.zebedee.io/v0/payments r = await self.client.post( "payments", diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 607adfd2..16c53def 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -997,71 +997,3 @@ async def _create_some_payments(payment_count: int, client, payments_headers): data = response.json() assert data["labels"] == labels return payment_count - - -################################ Amountless Invoices ################################ - - -@pytest.mark.anyio -async def test_pay_amountless_invoice_with_amount(client, adminkey_headers_from): - """Test paying an amountless invoice by specifying amount_msat. - - This tests the primary use case: receiving an amountless invoice from an - external source and paying it with a specified amount. - """ - from lnbits.wallets import get_funding_source - - # Create an amountless invoice directly using FakeWallet - # (bypassing the service layer which blocks amountless invoices for creation) - funding_source = get_funding_source() - invoice_response = await funding_source.create_invoice( - amount=0, memo="test_amountless_external" - ) - assert invoice_response.ok - assert invoice_response.payment_request - - # Verify it's amountless - decoded = bolt11.decode(invoice_response.payment_request) - assert decoded.amount_msat is None - - # Pay the amountless invoice with an explicit amount via API - pay_data = { - "out": True, - "bolt11": invoice_response.payment_request, - "amount_msat": 5000, # 5 sats in msat - } - response = await client.post( - "/api/v1/payments", json=pay_data, headers=adminkey_headers_from - ) - assert response.status_code < 300 - payment = response.json() - assert "payment_hash" in payment - assert payment["payment_hash"] == invoice_response.checking_id - - -@pytest.mark.anyio -async def test_pay_amountless_invoice_without_amount_fails( - client, adminkey_headers_from -): - """Test that paying an amountless invoice without amount_msat fails.""" - from lnbits.wallets import get_funding_source - - # Create an amountless invoice directly using FakeWallet - funding_source = get_funding_source() - invoice_response = await funding_source.create_invoice( - amount=0, memo="test_fail_amountless" - ) - assert invoice_response.ok - assert invoice_response.payment_request - - # Try to pay without specifying amount - should fail - pay_data = { - "out": True, - "bolt11": invoice_response.payment_request, - } - response = await client.post( - "/api/v1/payments", json=pay_data, headers=adminkey_headers_from - ) - # Should fail because amount is required for amountless invoices - assert response.status_code >= 400 - assert "Amount required" in response.json().get("detail", "") diff --git a/tests/unit/test_pay_invoice.py b/tests/unit/test_pay_invoice.py index f235aa8d..961c4ddf 100644 --- a/tests/unit/test_pay_invoice.py +++ b/tests/unit/test_pay_invoice.py @@ -42,7 +42,7 @@ async def test_amountless_invoice(to_wallet: Wallet): "73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv" "6hjxepwp93cq398l3s" ) - with pytest.raises(PaymentError, match="Amount required for amountless invoices."): + with pytest.raises(PaymentError, match="Amountless invoices not supported."): await pay_invoice( wallet_id=to_wallet.id, payment_request=zero_amount_invoice,