feat: add support for paying amountless BOLT11 invoices

Add ability to pay BOLT11 invoices that don't have an embedded amount
by specifying the amount at payment time via the `amount_msat` parameter.

Changes:
- Add `Feature.amountless_invoice` to wallet base class for capability detection
- Update `Wallet.pay_invoice()` signature with optional `amount_msat` parameter
- Implement amountless invoice support in LND REST and LND gRPC wallets
- Update payment service layer to validate and pass through amount_msat
- Add `amount_msat` field to CreateInvoice API model
- Update all wallet implementations with new method signature
- Add tests for amountless invoice payment flow

Usage: POST /api/v1/payments with `amount_msat` when paying amountless invoices

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-09 19:12:42 +01:00 committed by padreug
parent e062743e5a
commit 0513074bd6
29 changed files with 697 additions and 45 deletions

View file

@ -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: <admin_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/)

View file

@ -292,6 +292,15 @@ class CreateInvoice(BaseModel):
lnurl_withdraw: LnurlWithdrawResponse | None = None lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
labels: list[str] = [] 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") @validator("payment_hash")
def check_hex(cls, v): def check_hex(cls, v):

View file

@ -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.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
Feature,
InvoiceResponse, InvoiceResponse,
PaymentPendingStatus, PaymentPendingStatus,
PaymentResponse, PaymentResponse,
@ -63,17 +64,44 @@ async def pay_invoice(
tag: str = "", tag: str = "",
labels: list[str] | None = None, labels: list[str] | None = None,
conn: Connection | None = None, conn: Connection | None = None,
amount_msat: int | None = None,
) -> Payment: ) -> 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: if settings.lnbits_only_allow_incoming_payments:
raise PaymentError("Only incoming payments allowed.", status="failed") 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: # Determine the actual amount to pay
raise ValueError("Missig invoice amount.") # 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: 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 = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn) wallet_id, tag, pay_amount_msat, new_conn
)
if not wallet.can_send_payments: if not wallet.can_send_payments:
raise PaymentError( raise PaymentError(
@ -84,13 +112,15 @@ async def pay_invoice(
if await is_internal_status_success(invoice.payment_hash, new_conn): if await is_internal_status_success(invoice.payment_hash, new_conn):
raise PaymentError("Internal invoice already paid.", status="failed") 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( create_payment_model = CreatePayment(
wallet_id=wallet.source_wallet_id, wallet_id=wallet.source_wallet_id,
bolt11=payment_request, bolt11=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount_msat=-amount_msat, amount_msat=-pay_amount_msat,
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=description or invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
@ -99,7 +129,10 @@ async def pay_invoice(
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
payment = await _pay_invoice( 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) await _credit_service_fee_wallet(wallet, payment, conn=new_conn)
@ -672,6 +705,7 @@ async def get_payments_daily_stats(
async def _pay_invoice( async def _pay_invoice(
wallet_id: str, wallet_id: str,
create_payment_model: CreatePayment, create_payment_model: CreatePayment,
amountless_amount_msat: int | None = None,
conn: Connection | None = None, conn: Connection | None = None,
): ):
async with payment_lock: async with payment_lock:
@ -688,7 +722,9 @@ async def _pay_invoice(
payment = await _pay_internal_invoice(wallet, create_payment_model, conn) payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment: 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 return payment
@ -766,6 +802,7 @@ async def _pay_internal_invoice(
async def _pay_external_invoice( async def _pay_external_invoice(
wallet: Wallet, wallet: Wallet,
create_payment_model: CreatePayment, create_payment_model: CreatePayment,
amountless_amount_msat: int | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> Payment:
checking_id = create_payment_model.payment_hash 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) fee_reserve_msat = fee_reserve(amount_msat, internal=False)
task = create_task( 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 # 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( 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: ) -> PaymentResponse:
logger.debug(f"fundingsource: sending payment {checking_id}") logger.debug(f"fundingsource: sending payment {checking_id}")
funding_source = get_funding_source() funding_source = get_funding_source()
payment_response: PaymentResponse = await funding_source.pay_invoice( 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}") logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}")
return payment_response return payment_response
@ -906,21 +948,48 @@ async def _check_wallet_for_payment(
def _validate_payment_request( 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: ) -> 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: try:
invoice = bolt11_decode(payment_request) invoice = bolt11_decode(payment_request)
except Exception as exc: except Exception as exc:
raise PaymentError("Bolt11 decoding failed.", status="failed") from exc raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
if not invoice.amount_msat or not invoice.amount_msat > 0: # Check if this is an amountless invoice
raise PaymentError("Amountless invoices not supported.", status="failed") 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 = max_sat or settings.lnbits_max_outgoing_payment_amount_sats
max_sat = min(max_sat, 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( 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.", f"Max allowed: {max_sat} sats.",
status="failed", status="failed",
) )

View file

@ -263,6 +263,7 @@ async def api_payments_create(
payment_request=invoice_data.bolt11, payment_request=invoice_data.bolt11,
extra=invoice_data.extra, extra=invoice_data.extra,
labels=invoice_data.labels, labels=invoice_data.labels,
amount_msat=invoice_data.amount_msat,
) )
return payment return payment

View file

@ -123,7 +123,9 @@ class AlbyWallet(Wallet):
ok=False, error_message=f"Unable to connect to {self.endpoint}." 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: try:
# https://api.getalby.com/payments/bolt11 # https://api.getalby.com/payments/bolt11
r = await self.client.post( r = await self.client.post(

View file

@ -18,6 +18,7 @@ if TYPE_CHECKING:
class Feature(Enum): class Feature(Enum):
nodemanager = "nodemanager" nodemanager = "nodemanager"
holdinvoice = "holdinvoice" holdinvoice = "holdinvoice"
amountless_invoice = "amountless_invoice"
# bolt12 = "bolt12" # bolt12 = "bolt12"
@ -137,8 +138,21 @@ class Wallet(ABC):
@abstractmethod @abstractmethod
def pay_invoice( 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]: ) -> 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 pass
@abstractmethod @abstractmethod

View file

@ -165,7 +165,7 @@ class BlinkWallet(Wallet):
) )
async def pay_invoice( 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: ) -> PaymentResponse:
# https://dev.blink.sv/api/btc-ln-send # https://dev.blink.sv/api/btc-ln-send
# Future: add check fee estimate is < fee_limit_msat before paying invoice # Future: add check fee estimate is < fee_limit_msat before paying invoice

View file

@ -123,7 +123,9 @@ class BoltzWallet(Wallet):
fee_msat=fee_msat, 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}) pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC})
try: try:

View file

@ -208,7 +208,7 @@ else:
return InvoiceResponse(ok=False, error_message=str(e)) return InvoiceResponse(ok=False, error_message=str(e))
async def pay_invoice( async def pay_invoice(
self, bolt11: str, fee_limit_msat: int self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None
) -> PaymentResponse: ) -> PaymentResponse:
logger.debug(f"fee_limit_msat {fee_limit_msat} is ignored by Breez SDK") logger.debug(f"fee_limit_msat {fee_limit_msat} is ignored by Breez SDK")
try: try:

View file

@ -171,7 +171,7 @@ else:
return InvoiceResponse(ok=False, error_message=str(e)) return InvoiceResponse(ok=False, error_message=str(e))
async def pay_invoice( async def pay_invoice(
self, bolt11: str, fee_limit_msat: int self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None
) -> PaymentResponse: ) -> PaymentResponse:
invoice_data = bolt11_decode(bolt11) invoice_data = bolt11_decode(bolt11)

View file

@ -103,7 +103,9 @@ class ClicheWallet(Wallet):
preimage=data["result"].get("preimage"), 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 = create_connection(self.endpoint)
ws.send(f"pay-invoice --invoice {bolt11}") ws.send(f"pay-invoice --invoice {bolt11}")
checking_id, fee_msat, preimage, payment_ok = ( checking_id, fee_msat, preimage, payment_ok = (

View file

@ -248,7 +248,7 @@ class CLNRestWallet(Wallet):
self, self,
bolt11: str, bolt11: str,
fee_limit_msat: int, fee_limit_msat: int,
**_, amount_msat: int | None = None,
) -> PaymentResponse: ) -> PaymentResponse:
try: try:

View file

@ -147,7 +147,9 @@ class CoreLightningWallet(Wallet):
logger.warning(e) logger.warning(e)
return InvoiceResponse(ok=False, error_message=str(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: try:
invoice = bolt11_decode(bolt11) invoice = bolt11_decode(bolt11)
except Bolt11Exception as exc: except Bolt11Exception as exc:

View file

@ -176,7 +176,9 @@ class CoreLightningRestWallet(Wallet):
ok=False, error_message=f"Unable to connect to {self.url}." 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: try:
invoice = decode(bolt11) invoice = decode(bolt11)
except Bolt11Exception as exc: except Bolt11Exception as exc:

View file

@ -144,7 +144,9 @@ class EclairWallet(Wallet):
ok=False, error_message=f"Unable to connect to {self.url}." 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: try:
r = await self.client.post( r = await self.client.post(
"/payinvoice", "/payinvoice",

View file

@ -19,6 +19,7 @@ from lnbits.settings import settings
from lnbits.utils.crypto import fake_privkey from lnbits.utils.crypto import fake_privkey
from .base import ( from .base import (
Feature,
InvoiceResponse, InvoiceResponse,
PaymentFailedStatus, PaymentFailedStatus,
PaymentPendingStatus, PaymentPendingStatus,
@ -31,6 +32,7 @@ from .base import (
class FakeWallet(Wallet): class FakeWallet(Wallet):
features = [Feature.amountless_invoice]
def __init__(self) -> None: def __init__(self) -> None:
self.queue: asyncio.Queue = asyncio.Queue(0) self.queue: asyncio.Queue = asyncio.Queue(0)
@ -89,7 +91,7 @@ class FakeWallet(Wallet):
bolt11 = Bolt11( bolt11 = Bolt11(
currency="bc", currency="bc",
amount_msat=MilliSatoshi(amount * 1000), amount_msat=MilliSatoshi(amount * 1000) if amount > 0 else None,
date=int(datetime.now().timestamp()), date=int(datetime.now().timestamp()),
tags=tags, tags=tags,
) )
@ -103,12 +105,20 @@ class FakeWallet(Wallet):
preimage=preimage.hex(), 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: try:
invoice = decode(bolt11) invoice = decode(bolt11)
except Bolt11Exception as exc: except Bolt11Exception as exc:
return PaymentResponse(ok=False, error_message=str(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: if invoice.payment_hash in self.payment_secrets:
await self.queue.put(invoice) await self.queue.put(invoice)
self.paid_invoices.add(invoice.payment_hash) self.paid_invoices.add(invoice.payment_hash)

View file

@ -117,7 +117,9 @@ class LNbitsWallet(Wallet):
ok=False, error_message=f"Unable to connect to {self.endpoint}." 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: try:
r = await self.client.post( r = await self.client.post(
url="/api/v1/payments", url="/api/v1/payments",

View file

@ -88,7 +88,7 @@ class LndWallet(Wallet):
router_rpc: RouterStub router_rpc: RouterStub
invoices_rpc: InvoicesStub invoices_rpc: InvoicesStub
features = [Feature.holdinvoice] features = [Feature.holdinvoice, Feature.amountless_invoice]
def __init__(self): def __init__(self):
if not settings.lnd_grpc_endpoint: if not settings.lnd_grpc_endpoint:
@ -185,7 +185,9 @@ class LndWallet(Wallet):
preimage=preimage, 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) # fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
req = SendPaymentRequest( req = SendPaymentRequest(
payment_request=bolt11, payment_request=bolt11,
@ -193,6 +195,9 @@ class LndWallet(Wallet):
timeout_seconds=30, timeout_seconds=30,
no_inflight_updates=True, no_inflight_updates=True,
) )
# For amountless invoices, specify the amount to pay
if amount_msat is not None:
req.amt_msat = amount_msat
try: try:
res: Payment = await self.router_rpc.SendPaymentV2(req).read() res: Payment = await self.router_rpc.SendPaymentV2(req).read()
except Exception as exc: except Exception as exc:

View file

@ -30,7 +30,7 @@ class LndRestWallet(Wallet):
"""https://api.lightning.community/#lnd-rest-api-reference""" """https://api.lightning.community/#lnd-rest-api-reference"""
__node_cls__ = LndRestNode __node_cls__ = LndRestNode
features = [Feature.nodemanager, Feature.holdinvoice] features = [Feature.nodemanager, Feature.holdinvoice, Feature.amountless_invoice]
def __init__(self): def __init__(self):
if not settings.lnd_rest_endpoint: if not settings.lnd_rest_endpoint:
@ -170,8 +170,10 @@ class LndRestWallet(Wallet):
ok=False, error_message=f"Unable to connect to {self.endpoint}." 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(
req = { self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None
) -> PaymentResponse:
req: dict = {
"payment_request": bolt11, "payment_request": bolt11,
"fee_limit_msat": fee_limit_msat, "fee_limit_msat": fee_limit_msat,
"timeout_seconds": 30, "timeout_seconds": 30,
@ -179,6 +181,9 @@ class LndRestWallet(Wallet):
} }
if settings.lnd_rest_allow_self_payment: if settings.lnd_rest_allow_self_payment:
req["allow_self_payment"] = True 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: try:
r = await self.client.post( r = await self.client.post(

View file

@ -104,7 +104,9 @@ class LNPayWallet(Wallet):
error_message=r.text, 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( r = await self.client.post(
f"/wallet/{self.wallet_key}/withdraw", f"/wallet/{self.wallet_key}/withdraw",
json={"payment_request": bolt11}, json={"payment_request": bolt11},

View file

@ -102,7 +102,9 @@ class LnTipsWallet(Wallet):
preimage=data.get("preimage"), 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( r = await self.client.post(
"/api/v1/payinvoice", "/api/v1/payinvoice",
json={"pay_req": bolt11}, json={"pay_req": bolt11},

View file

@ -192,7 +192,9 @@ class NWCWallet(Wallet):
except Exception as e: except Exception as e:
return StatusResponse(str(e), 0) 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: try:
resp = await self.conn.call("pay_invoice", {"invoice": bolt11}) resp = await self.conn.call("pay_invoice", {"invoice": bolt11})
preimage = resp.get("preimage", None) preimage = resp.get("preimage", None)

View file

@ -99,7 +99,9 @@ class OpenNodeWallet(Wallet):
ok=True, checking_id=checking_id, payment_request=payment_request 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( r = await self.client.post(
"/v2/withdrawals", "/v2/withdrawals",
json={"type": "ln", "address": bolt11}, json={"type": "ln", "address": bolt11},

View file

@ -164,7 +164,9 @@ class PhoenixdWallet(Wallet):
ok=False, error_message=f"Unable to connect to {self.endpoint}." 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: try:
r = await self.client.post( r = await self.client.post(
"/payinvoice", "/payinvoice",

View file

@ -146,7 +146,9 @@ class SparkWallet(Wallet):
except (SparkError, UnknownError) as e: except (SparkError, UnknownError) as e:
return InvoiceResponse(ok=False, error_message=str(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: try:
r = await self.pay( r = await self.pay(
bolt11=bolt11, bolt11=bolt11,

View file

@ -225,7 +225,9 @@ class StrikeWallet(Wallet):
logger.warning(e) logger.warning(e)
return InvoiceResponse(ok=False, error_message="Connection error") 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 # Extract payment hash from invoice for checking_id
try: try:

View file

@ -103,7 +103,9 @@ class ZBDWallet(Wallet):
preimage=preimage, 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 # https://api.zebedee.io/v0/payments
r = await self.client.post( r = await self.client.post(
"payments", "payments",

View file

@ -997,3 +997,71 @@ async def _create_some_payments(payment_count: int, client, payments_headers):
data = response.json() data = response.json()
assert data["labels"] == labels assert data["labels"] == labels
return payment_count 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", "")

View file

@ -42,7 +42,7 @@ async def test_amountless_invoice(to_wallet: Wallet):
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv" "73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s" "6hjxepwp93cq398l3s"
) )
with pytest.raises(PaymentError, match="Amountless invoices not supported."): with pytest.raises(PaymentError, match="Amount required for amountless invoices."):
await pay_invoice( await pay_invoice(
wallet_id=to_wallet.id, wallet_id=to_wallet.id,
payment_request=zero_amount_invoice, payment_request=zero_amount_invoice,