diff --git a/docs/guide/amountless-invoice-implementation.md b/docs/guide/amountless-invoice-implementation.md new file mode 100644 index 00000000..dd0a3579 --- /dev/null +++ b/docs/guide/amountless-invoice-implementation.md @@ -0,0 +1,441 @@ +# 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 d7743cdc..e6ba60f1 100644 --- a/lnbits/core/models/payments.py +++ b/lnbits/core/models/payments.py @@ -292,6 +292,15 @@ 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 80be5620..186a8308 100644 --- a/lnbits/core/services/payments.py +++ b/lnbits/core/services/payments.py @@ -24,6 +24,7 @@ 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, @@ -63,17 +64,44 @@ 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) + invoice = _validate_payment_request(payment_request, max_sat, amount_msat) - if not invoice.amount_msat: - raise ValueError("Missig invoice amount.") + # 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 async with db.reuse_conn(conn) if conn else db.connect() as new_conn: - amount_msat = invoice.amount_msat - wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn) + wallet = await _check_wallet_for_payment( + wallet_id, tag, pay_amount_msat, new_conn + ) if not wallet.can_send_payments: raise PaymentError( @@ -84,13 +112,15 @@ 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(amount_msat / 1000, wallet, extra=extra) + _, extra = await calculate_fiat_amounts( + pay_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=-amount_msat, + amount_msat=-pay_amount_msat, expiry=invoice.expiry_date, memo=description or invoice.description or "", extra=extra, @@ -99,7 +129,10 @@ 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, conn=new_conn + wallet.source_wallet_id, + create_payment_model, + amountless_amount_msat=amountless_amount_msat, + conn=new_conn, ) await _credit_service_fee_wallet(wallet, payment, conn=new_conn) @@ -672,6 +705,7 @@ 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: @@ -688,7 +722,9 @@ 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, conn) + payment = await _pay_external_invoice( + wallet, create_payment_model, amountless_amount_msat, conn + ) return payment @@ -766,6 +802,7 @@ 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 @@ -796,7 +833,9 @@ 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) + _fundingsource_pay_invoice( + checking_id, payment.bolt11, fee_reserve_msat, amountless_amount_msat + ) ) # make sure a hold invoice or deferred payment is not blocking the server @@ -847,12 +886,15 @@ async def update_payment_success_status( async def _fundingsource_pay_invoice( - checking_id: str, bolt11: str, fee_reserve_msat: int + checking_id: str, + bolt11: str, + fee_reserve_msat: int, + amountless_amount_msat: int | None = None, ) -> 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 + bolt11, fee_reserve_msat, amountless_amount_msat ) logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}") return payment_response @@ -906,21 +948,48 @@ async def _check_wallet_for_payment( def _validate_payment_request( - payment_request: str, max_sat: int | None = None + payment_request: str, max_sat: int | None = None, amount_msat: 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 - if not invoice.amount_msat or not invoice.amount_msat > 0: - raise PaymentError("Amountless invoices not supported.", status="failed") + # 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 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 invoice.amount_msat > max_sat * 1000: + if check_amount_msat > max_sat * 1000: raise PaymentError( - f"Invoice amount {invoice.amount_msat // 1000} sats is too high. " + f"Invoice amount {check_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 38d6e10d..c21a729f 100644 --- a/lnbits/core/views/payment_api.py +++ b/lnbits/core/views/payment_api.py @@ -263,6 +263,7 @@ 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/wallets/alby.py b/lnbits/wallets/alby.py index bc7822c0..a6c816cf 100644 --- a/lnbits/wallets/alby.py +++ b/lnbits/wallets/alby.py @@ -123,7 +123,9 @@ class AlbyWallet(Wallet): ok=False, error_message=f"Unable to connect to {self.endpoint}." ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> 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 90517948..23ddcbeb 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: class Feature(Enum): nodemanager = "nodemanager" holdinvoice = "holdinvoice" + amountless_invoice = "amountless_invoice" # bolt12 = "bolt12" @@ -137,8 +138,21 @@ class Wallet(ABC): @abstractmethod def pay_invoice( - self, bolt11: str, fee_limit_msat: int + 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. + + Returns: + PaymentResponse indicating success, failure, or pending status + """ pass @abstractmethod diff --git a/lnbits/wallets/blink.py b/lnbits/wallets/blink.py index be736086..94331a12 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 + self, bolt11_invoice: str, fee_limit_msat: int, amount_msat: int | None = None ) -> 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 c6473f18..c5b336e0 100644 --- a/lnbits/wallets/boltz.py +++ b/lnbits/wallets/boltz.py @@ -123,7 +123,9 @@ class BoltzWallet(Wallet): fee_msat=fee_msat, ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> PaymentResponse: pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC}) try: diff --git a/lnbits/wallets/breez.py b/lnbits/wallets/breez.py index bf6d59c9..5ba4d43b 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 + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None ) -> 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 6f1f6fb5..2b0ffa97 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 + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None ) -> PaymentResponse: invoice_data = bolt11_decode(bolt11) diff --git a/lnbits/wallets/cliche.py b/lnbits/wallets/cliche.py index a0ef0e3c..af9c6cc1 100644 --- a/lnbits/wallets/cliche.py +++ b/lnbits/wallets/cliche.py @@ -103,7 +103,9 @@ class ClicheWallet(Wallet): preimage=data["result"].get("preimage"), ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> 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 8d34cef3..5396d518 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 e21cb4f1..4075742e 100644 --- a/lnbits/wallets/corelightning.py +++ b/lnbits/wallets/corelightning.py @@ -147,7 +147,9 @@ 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) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> PaymentResponse: try: invoice = bolt11_decode(bolt11) except Bolt11Exception as exc: diff --git a/lnbits/wallets/corelightningrest.py b/lnbits/wallets/corelightningrest.py index 79fd1cf0..74cc9602 100644 --- a/lnbits/wallets/corelightningrest.py +++ b/lnbits/wallets/corelightningrest.py @@ -176,7 +176,9 @@ 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) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> PaymentResponse: try: invoice = decode(bolt11) except Bolt11Exception as exc: diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index dc5ed590..843fbed7 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -144,7 +144,9 @@ 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) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> PaymentResponse: try: r = await self.client.post( "/payinvoice", diff --git a/lnbits/wallets/fake.py b/lnbits/wallets/fake.py index be70d572..bf30b0bf 100644 --- a/lnbits/wallets/fake.py +++ b/lnbits/wallets/fake.py @@ -19,6 +19,7 @@ from lnbits.settings import settings from lnbits.utils.crypto import fake_privkey from .base import ( + Feature, InvoiceResponse, PaymentFailedStatus, PaymentPendingStatus, @@ -31,6 +32,7 @@ from .base import ( class FakeWallet(Wallet): + features = [Feature.amountless_invoice] def __init__(self) -> None: self.queue: asyncio.Queue = asyncio.Queue(0) @@ -89,7 +91,7 @@ class FakeWallet(Wallet): bolt11 = Bolt11( currency="bc", - amount_msat=MilliSatoshi(amount * 1000), + amount_msat=MilliSatoshi(amount * 1000) if amount > 0 else None, date=int(datetime.now().timestamp()), tags=tags, ) @@ -103,12 +105,20 @@ class FakeWallet(Wallet): preimage=preimage.hex(), ) - async def pay_invoice(self, bolt11: str, _: int) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, _: int, amount_msat: int | None = None + ) -> 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 c14a4170..caf0d619 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -117,7 +117,9 @@ 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) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> PaymentResponse: try: r = await self.client.post( url="/api/v1/payments", diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py index 878433e9..47509aad 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] + features = [Feature.holdinvoice, Feature.amountless_invoice] def __init__(self): if not settings.lnd_grpc_endpoint: @@ -185,7 +185,9 @@ class LndWallet(Wallet): preimage=preimage, ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> PaymentResponse: # fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000) req = SendPaymentRequest( payment_request=bolt11, @@ -193,6 +195,9 @@ 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 da72a61f..305a7dc2 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] + features = [Feature.nodemanager, Feature.holdinvoice, Feature.amountless_invoice] def __init__(self): if not settings.lnd_rest_endpoint: @@ -170,8 +170,10 @@ 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) -> PaymentResponse: - req = { + 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, @@ -179,6 +181,9 @@ 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 738faf9e..4266309e 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -104,7 +104,9 @@ class LNPayWallet(Wallet): error_message=r.text, ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> 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 5a15be1a..d344dc58 100644 --- a/lnbits/wallets/lntips.py +++ b/lnbits/wallets/lntips.py @@ -102,7 +102,9 @@ class LnTipsWallet(Wallet): preimage=data.get("preimage"), ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> 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 c73ba668..80163916 100644 --- a/lnbits/wallets/nwc.py +++ b/lnbits/wallets/nwc.py @@ -192,7 +192,9 @@ class NWCWallet(Wallet): except Exception as e: return StatusResponse(str(e), 0) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> 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 f0534974..ac747189 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -99,7 +99,9 @@ class OpenNodeWallet(Wallet): ok=True, checking_id=checking_id, payment_request=payment_request ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> 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 8de95e27..4a2f29ad 100644 --- a/lnbits/wallets/phoenixd.py +++ b/lnbits/wallets/phoenixd.py @@ -164,7 +164,9 @@ 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) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> PaymentResponse: try: r = await self.client.post( "/payinvoice", diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index 90dcc05e..6fa94480 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -146,7 +146,9 @@ 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) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> PaymentResponse: try: r = await self.pay( bolt11=bolt11, diff --git a/lnbits/wallets/strike.py b/lnbits/wallets/strike.py index 8fbbb7e1..2afe9e98 100644 --- a/lnbits/wallets/strike.py +++ b/lnbits/wallets/strike.py @@ -225,7 +225,9 @@ 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) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> PaymentResponse: # Extract payment hash from invoice for checking_id try: diff --git a/lnbits/wallets/zbd.py b/lnbits/wallets/zbd.py index 1377e0db..d10b53ad 100644 --- a/lnbits/wallets/zbd.py +++ b/lnbits/wallets/zbd.py @@ -103,7 +103,9 @@ class ZBDWallet(Wallet): preimage=preimage, ) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async def pay_invoice( + self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None + ) -> 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 16c53def..607adfd2 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -997,3 +997,71 @@ 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 961c4ddf..f235aa8d 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="Amountless invoices not supported."): + with pytest.raises(PaymentError, match="Amount required for amountless invoices."): await pay_invoice( wallet_id=to_wallet.id, payment_request=zero_amount_invoice,