From e062743e5a23bff66ea90b37817bbe19e9d4cd46 Mon Sep 17 00:00:00 2001 From: padreug Date: Wed, 15 Oct 2025 00:56:14 +0200 Subject: [PATCH 1/6] feat: implement automatic crediting of new accounts with 1 million satoshis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modified the user account creation process to automatically credit new accounts with 1,000,000 satoshis upon creation. - Updated the `create_user_account_no_ckeck` function to include error handling and logging for the credit operation. - Enhanced tests to verify the balance for newly registered users, ensuring the correct credit amount is applied. - Documented affected account creation paths and testing instructions in the new `AUTO_CREDIT_CHANGES.md` file. fix: pass db connection to update_wallet_balance to prevent SQLite deadlock The auto-credit feature was causing a deadlock on SQLite because update_wallet_balance was opening a new database connection while already inside a transaction. By passing conn=conn, we reuse the existing connection and avoid the deadlock. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- AUTO_CREDIT_CHANGES.md | 88 +++++++++++++++++++++++++++++++++++ lnbits/core/services/users.py | 9 ++++ tests/api/test_auth.py | 8 ++++ 3 files changed, 105 insertions(+) create mode 100644 AUTO_CREDIT_CHANGES.md diff --git a/AUTO_CREDIT_CHANGES.md b/AUTO_CREDIT_CHANGES.md new file mode 100644 index 00000000..408f6987 --- /dev/null +++ b/AUTO_CREDIT_CHANGES.md @@ -0,0 +1,88 @@ +# LNBits Auto-Credit Changes + +## Overview +Modified LNBits server to automatically credit new accounts with 1 million satoshis (1,000,000 sats) when they are created. + +## Changes Made + +### 1. Modified `lnbits/core/services/users.py` + +**Added imports:** +- `get_wallet` from `..crud` +- `update_wallet_balance` from `.payments` + +**Modified `create_user_account_no_ckeck` function:** +- Changed `create_wallet` call to capture the returned wallet object +- Added automatic credit of 1,000,000 sats after wallet creation +- Added error handling and logging for the credit operation + +**Code changes:** +```python +# Before: +await create_wallet( + user_id=account.id, + wallet_name=wallet_name or settings.lnbits_default_wallet_name, +) + +# After: +wallet = await create_wallet( + user_id=account.id, + wallet_name=wallet_name or settings.lnbits_default_wallet_name, +) + +# Credit new account with 1 million satoshis +try: + await update_wallet_balance(wallet, 1_000_000) + logger.info(f"Credited new account {account.id} with 1,000,000 sats") +except Exception as e: + logger.error(f"Failed to credit new account {account.id} with 1,000,000 sats: {e}") +``` + +### 2. Updated Tests in `tests/api/test_auth.py` + +**Modified test functions:** +- `test_register_ok`: Added balance verification for regular user registration +- `test_register_nostr_ok`: Added balance verification for Nostr authentication + +**Added assertions:** +```python +# Check that the wallet has 1 million satoshis +wallet = user.wallets[0] +assert wallet.balance == 1_000_000, f"Expected 1,000,000 sats balance, got {wallet.balance} sats" +``` + +## Affected Account Creation Paths + +The automatic credit will be applied to all new accounts created through: + +1. **Regular user registration** (`/api/v1/auth/register`) +2. **Nostr authentication** (`/api/v1/auth/nostr`) +3. **SSO login** (when new account is created) +4. **API account creation** (`/api/v1/account`) +5. **Admin user creation** (via admin interface) + +## Excluded Paths + +- **Superuser/Admin account creation** (`init_admin_settings`): This function creates the admin account directly and bypasses the user creation flow, so it won't receive the automatic credit. + +## Testing + +To test the changes: + +1. Install dependencies: `poetry install` +2. Run the modified tests: `poetry run pytest tests/api/test_auth.py::test_register_ok -v` +3. Run Nostr test: `poetry run pytest tests/api/test_auth.py::test_register_nostr_ok -v` + +## Logging + +The system will log: +- Success: `"Credited new account {account.id} with 1,000,000 sats"` +- Failure: `"Failed to credit new account {account.id} with 1,000,000 sats: {error}"` + +## Notes + +- The credit uses the existing `update_wallet_balance` function which creates an internal payment record +- The credit is applied after wallet creation but before user extensions are set up +- Error handling ensures that account creation continues even if the credit fails +- The credit amount is hardcoded to 1,000,000 sats (1MM sats) + diff --git a/lnbits/core/services/users.py b/lnbits/core/services/users.py index d21a8aff..16d6884c 100644 --- a/lnbits/core/services/users.py +++ b/lnbits/core/services/users.py @@ -26,6 +26,7 @@ from ..crud import ( get_super_settings, get_user_extensions, get_user_from_account, + get_wallet, update_account, update_super_user, update_user_extension, @@ -37,6 +38,7 @@ from ..models import ( UserExtra, ) from .settings import update_cached_settings +from .payments import update_wallet_balance async def create_user_account( @@ -80,6 +82,13 @@ async def create_user_account_no_ckeck( conn=conn, ) + # Credit new account with 1 million satoshis + try: + await update_wallet_balance(wallet, 1_000_000, conn=conn) + logger.info(f"Credited new account {account.id} with 1,000,000 sats") + except Exception as e: + logger.error(f"Failed to credit new account {account.id} with 1,000,000 sats: {e}") + user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions for ext_id in user_extensions: try: diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index 57cacd03..8b1e7ba2 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -295,6 +295,10 @@ async def test_register_ok(http_client: AsyncClient): assert ( len(user.wallets) == 1 ), f"Expected 1 default wallet, not {len(user.wallets)}." + + # Check that the wallet has 1 million satoshis + wallet = user.wallets[0] + assert wallet.balance == 1_000_000, f"Expected 1,000,000 sats balance, got {wallet.balance} sats" @pytest.mark.anyio @@ -588,6 +592,10 @@ async def test_register_nostr_ok(http_client: AsyncClient, settings: Settings): assert ( len(user.wallets) == 1 ), f"Expected 1 default wallet, not {len(user.wallets)}." + + # Check that the wallet has 1 million satoshis + wallet = user.wallets[0] + assert wallet.balance == 1_000_000, f"Expected 1,000,000 sats balance, got {wallet.balance} sats" @pytest.mark.anyio From 0513074bd6601cc1ca9e9357c76a591d8975387f Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 9 Jan 2026 19:12:42 +0100 Subject: [PATCH 2/6] feat: add support for paying amountless BOLT11 invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amountless-invoice-implementation.md | 441 ++++++++++++++++++ lnbits/core/models/payments.py | 9 + lnbits/core/services/payments.py | 103 +++- lnbits/core/views/payment_api.py | 1 + lnbits/wallets/alby.py | 4 +- lnbits/wallets/base.py | 16 +- lnbits/wallets/blink.py | 2 +- lnbits/wallets/boltz.py | 4 +- lnbits/wallets/breez.py | 2 +- lnbits/wallets/breez_liquid.py | 2 +- lnbits/wallets/cliche.py | 4 +- lnbits/wallets/clnrest.py | 2 +- lnbits/wallets/corelightning.py | 4 +- lnbits/wallets/corelightningrest.py | 4 +- lnbits/wallets/eclair.py | 4 +- lnbits/wallets/fake.py | 14 +- lnbits/wallets/lnbits.py | 4 +- lnbits/wallets/lndgrpc.py | 9 +- lnbits/wallets/lndrest.py | 11 +- lnbits/wallets/lnpay.py | 4 +- lnbits/wallets/lntips.py | 4 +- lnbits/wallets/nwc.py | 4 +- lnbits/wallets/opennode.py | 4 +- lnbits/wallets/phoenixd.py | 4 +- lnbits/wallets/spark.py | 4 +- lnbits/wallets/strike.py | 4 +- lnbits/wallets/zbd.py | 4 +- tests/api/test_api.py | 68 +++ tests/unit/test_pay_invoice.py | 2 +- 29 files changed, 697 insertions(+), 45 deletions(-) create mode 100644 docs/guide/amountless-invoice-implementation.md 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, From 8657e221c6db49cf75d5d00e4317fcbe50247848 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 9 Jan 2026 19:12:42 +0100 Subject: [PATCH 3/6] feat: add support for paying amountless BOLT11 invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amountless-invoice-implementation.md | 441 ++++++++++++++++++ lnbits/core/models/payments.py | 9 + lnbits/core/services/payments.py | 103 +++- lnbits/core/views/payment_api.py | 1 + lnbits/wallets/alby.py | 4 +- lnbits/wallets/base.py | 16 +- lnbits/wallets/blink.py | 2 +- lnbits/wallets/boltz.py | 4 +- lnbits/wallets/breez.py | 2 +- lnbits/wallets/breez_liquid.py | 2 +- lnbits/wallets/cliche.py | 4 +- lnbits/wallets/clnrest.py | 2 +- lnbits/wallets/corelightning.py | 4 +- lnbits/wallets/corelightningrest.py | 4 +- lnbits/wallets/eclair.py | 4 +- lnbits/wallets/fake.py | 14 +- lnbits/wallets/lnbits.py | 4 +- lnbits/wallets/lndgrpc.py | 9 +- lnbits/wallets/lndrest.py | 11 +- lnbits/wallets/lnpay.py | 4 +- lnbits/wallets/lntips.py | 4 +- lnbits/wallets/nwc.py | 4 +- lnbits/wallets/opennode.py | 4 +- lnbits/wallets/phoenixd.py | 4 +- lnbits/wallets/spark.py | 4 +- lnbits/wallets/strike.py | 4 +- lnbits/wallets/zbd.py | 4 +- tests/api/test_api.py | 68 +++ tests/unit/test_pay_invoice.py | 2 +- 29 files changed, 697 insertions(+), 45 deletions(-) create mode 100644 docs/guide/amountless-invoice-implementation.md 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, From 71a94766b108d2a07e10b869ec4519f044079e0f Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 9 Jan 2026 19:58:42 +0100 Subject: [PATCH 4/6] feat(ui): add frontend support for paying amountless invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the wallet UI to properly handle amountless BOLT11 invoices: - Detect amountless invoices when decoding (amount is null/0) - Display "Any Amount" header and amount input field for amountless invoices - Validate that amount is provided before payment - Pass amount_msat to API when paying amountless invoices - Add translations for new UI strings - Hide fiat toggle for amountless invoices (amount not yet known) This complements the backend changes in the previous commit, providing a complete user experience for paying amountless invoices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../amountless-invoice-implementation.md | 58 ++++++++++++------- lnbits/static/i18n/en.js | 2 + lnbits/static/js/api.js | 5 +- lnbits/static/js/pages/wallet.js | 45 ++++++++++++-- lnbits/templates/pages/wallet.vue | 25 +++++++- 5 files changed, 105 insertions(+), 30 deletions(-) diff --git a/docs/guide/amountless-invoice-implementation.md b/docs/guide/amountless-invoice-implementation.md index dd0a3579..bec96c50 100644 --- a/docs/guide/amountless-invoice-implementation.md +++ b/docs/guide/amountless-invoice-implementation.md @@ -31,6 +31,7 @@ This document provides a comprehensive analysis supporting the implementation of This implementation adds the ability to pay BOLT11 invoices that don't have an embedded amount by specifying the amount at payment time via the `amount_msat` parameter. **Key Changes:** + - Added `Feature.amountless_invoice` to wallet base class for capability detection - Updated `Wallet.pay_invoice()` signature with optional `amount_msat` parameter - Implemented amountless invoice support in LND REST and LND gRPC wallets @@ -68,6 +69,7 @@ To ensure our implementation follows established patterns, we analyzed three maj ### Blixt Wallet (React Native) **Detection** (`src/state/Send.ts:185`): + ```typescript if (!paymentRequest.numSatoshis) { // Invoice is amountless - require user input @@ -75,14 +77,16 @@ if (!paymentRequest.numSatoshis) { ``` **Amount Injection** (`src/state/Send.ts:203-204`): + ```typescript // Mutate the payment request with user-provided amount -paymentRequest.numSatoshis = payload.amount; +paymentRequest.numSatoshis = payload.amount ``` **UI Handling** (`src/windows/Send/SendConfirmation.tsx:67-70`): + ```typescript -const amountEditable = !paymentRequest.numSatoshis; +const amountEditable = !paymentRequest.numSatoshis // Shows editable amount field when invoice has no amount ``` @@ -91,6 +95,7 @@ const amountEditable = !paymentRequest.numSatoshis; ### Breez SDK (Flutter) **Detection** (`lib/widgets/payment_request_info_dialog.dart:162`): + ```dart if (widget.invoice.amount == 0) { // Show amount input field @@ -98,6 +103,7 @@ if (widget.invoice.amount == 0) { ``` **Validation** (`lib/widgets/payment_request_info_dialog.dart:251`): + ```dart var validationResult = acc.validateOutgoingPayment(amountToPay); if (validationResult.isNotEmpty) { @@ -110,15 +116,17 @@ if (validationResult.isNotEmpty) { ### Zeus Wallet (React Native) **Detection** (`views/PaymentRequest.tsx:602-603`): + ```typescript -const isNoAmountInvoice = !requestAmount || requestAmount === 0; +const isNoAmountInvoice = !requestAmount || requestAmount === 0 ``` **Amount Handling** (`views/Send.tsx:339-341`): + ```typescript if (isNoAmountInvoice) { // Use amount from user input instead of invoice - amountToSend = userEnteredAmount; + amountToSend = userEnteredAmount } ``` @@ -128,14 +136,14 @@ if (isNoAmountInvoice) { All three wallets follow the same logical flow: -| Step | Pattern | -|------|---------| -| 1. Decode | Parse BOLT11 invoice | -| 2. Detect | Check if `amount == 0` or `null` | -| 3. Prompt | Show UI for amount input if amountless | -| 4. Validate | Verify amount > 0 and within balance | -| 5. Inject | Pass amount to payment backend | -| 6. Pay | Execute payment with specified amount | +| Step | Pattern | +| ----------- | -------------------------------------- | +| 1. Decode | Parse BOLT11 invoice | +| 2. Detect | Check if `amount == 0` or `null` | +| 3. Prompt | Show UI for amount input if amountless | +| 4. Validate | Verify amount > 0 and within balance | +| 5. Inject | Pass amount to payment backend | +| 6. Pay | Execute payment with specified amount | --- @@ -165,6 +173,7 @@ message SendPaymentRequest { ``` **Key Points:** + - `amt` and `amt_msat` are mutually exclusive - When `payment_request` has zero amount, `amt` or `amt_msat` is **required** - When `payment_request` has an amount, `amt`/`amt_msat` must **not** be specified @@ -241,6 +250,7 @@ LNbits uses a layered architecture where changes flow from API → Service → W #### 1. Base Wallet Class (`lnbits/wallets/base.py`) **Feature Declaration:** + ```python class Feature(Enum): nodemanager = "nodemanager" @@ -249,6 +259,7 @@ class Feature(Enum): ``` **Method Signature:** + ```python @abstractmethod def pay_invoice( @@ -270,11 +281,13 @@ def pay_invoice( #### 2. LND REST Wallet (`lnbits/wallets/lndrest.py`) **Feature Declaration:** + ```python features = [Feature.nodemanager, Feature.holdinvoice, Feature.amountless_invoice] ``` **Payment Implementation:** + ```python async def pay_invoice( self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None @@ -294,6 +307,7 @@ async def pay_invoice( #### 3. Payment Service (`lnbits/core/services/payments.py`) **Validation:** + ```python def _validate_payment_request( payment_request: str, max_sat: int | None = None, amount_msat: int | None = None @@ -322,6 +336,7 @@ def _validate_payment_request( ``` **Amount Handling:** + ```python # Determine the actual amount to pay pay_amount_msat = invoice.amount_msat or amount_msat @@ -373,6 +388,7 @@ curl -X POST "https://lnbits.example.com/api/v1/payments" \ ``` **Response:** + ```json { "payment_hash": "abc123...", @@ -385,14 +401,14 @@ curl -X POST "https://lnbits.example.com/api/v1/payments" \ ## Verification Matrix -| Requirement | LND Spec | Mobile Wallets | LNbits Implementation | -|-------------|----------|----------------|----------------------| -| Detect amountless | `payReq.MilliSat == nil` | Check `amount == 0/null` | `not invoice.amount_msat` | -| Require amount for amountless | Error if `reqAmt == 0` | Show input field | `PaymentError` if not provided | -| Block amount for regular invoices | Error if `reqAmt != 0` | N/A (UI doesn't allow) | `amountless_amount_msat = None` | -| Field name | `amt_msat` | N/A (native SDK) | `amt_msat` | -| Type | int64 (msat) | varies | int (msat) | -| Feature detection | N/A | Hardcoded | `Feature.amountless_invoice` | +| Requirement | LND Spec | Mobile Wallets | LNbits Implementation | +| --------------------------------- | ------------------------ | ------------------------ | ------------------------------- | +| Detect amountless | `payReq.MilliSat == nil` | Check `amount == 0/null` | `not invoice.amount_msat` | +| Require amount for amountless | Error if `reqAmt == 0` | Show input field | `PaymentError` if not provided | +| Block amount for regular invoices | Error if `reqAmt != 0` | N/A (UI doesn't allow) | `amountless_amount_msat = None` | +| Field name | `amt_msat` | N/A (native SDK) | `amt_msat` | +| Type | int64 (msat) | varies | int (msat) | +| Feature detection | N/A | Hardcoded | `Feature.amountless_invoice` | --- @@ -413,6 +429,7 @@ curl -X POST "https://lnbits.example.com/api/v1/payments" \ Two test cases cover the amountless invoice functionality: ### Happy Path + ```python @pytest.mark.anyio async def test_pay_amountless_invoice_with_amount(client, adminkey_headers_from): @@ -423,6 +440,7 @@ async def test_pay_amountless_invoice_with_amount(client, adminkey_headers_from) ``` ### Error Case + ```python @pytest.mark.anyio async def test_pay_amountless_invoice_without_amount_fails(client, adminkey_headers_from): diff --git a/lnbits/static/i18n/en.js b/lnbits/static/i18n/en.js index 792bebec..7a5ce917 100644 --- a/lnbits/static/i18n/en.js +++ b/lnbits/static/i18n/en.js @@ -217,6 +217,8 @@ window.localisation.en = { amount: 'Amount', amount_limits: 'Amount Limits', amount_sats: 'Amount (sats)', + amount_must_be_positive: 'Amount must be greater than 0', + any_amount: 'Any Amount', faucest_wallet: 'Faucet Wallet', faucest_wallet_desc_1: 'Each time a payment is confirmed by the {provider} provider funds will be subtracted from this wallet.', diff --git a/lnbits/static/js/api.js b/lnbits/static/js/api.js index aef5b720..8310cfd1 100644 --- a/lnbits/static/js/api.js +++ b/lnbits/static/js/api.js @@ -39,7 +39,7 @@ window._lnbitsApi = { } return this.request('post', '/api/v1/payments', wallet.inkey, data) }, - payInvoice(wallet, bolt11, internalMemo = null) { + payInvoice(wallet, bolt11, internalMemo = null, amountMsat = null) { const data = { out: true, bolt11: bolt11 @@ -49,6 +49,9 @@ window._lnbitsApi = { internal_memo: String(internalMemo) } } + if (amountMsat) { + data.amount_msat = amountMsat + } return this.request('post', '/api/v1/payments', wallet.adminkey, data) }, cancelInvoice(wallet, paymentHash) { diff --git a/lnbits/static/js/pages/wallet.js b/lnbits/static/js/pages/wallet.js index c2a35f9c..11ccf441 100644 --- a/lnbits/static/js/pages/wallet.js +++ b/lnbits/static/js/pages/wallet.js @@ -307,11 +307,25 @@ window.PageWallet = { return } + // Check if invoice is amountless (no amount specified) + const isAmountless = + !invoice.human_readable_part.amount || + invoice.human_readable_part.amount === 0 + let cleanInvoice = { - msat: invoice.human_readable_part.amount, - sat: invoice.human_readable_part.amount / 1000, - fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000), - bolt11: this.parse.data.request + msat: isAmountless ? null : invoice.human_readable_part.amount, + sat: isAmountless ? null : invoice.human_readable_part.amount / 1000, + fsat: isAmountless + ? this.$t('any_amount') + : LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000), + bolt11: this.parse.data.request, + isAmountless: isAmountless + } + + // Initialize amount for amountless invoices + if (isAmountless) { + this.parse.data.amount = null + this.parse.data.unit = 'sat' } _.each(invoice.data.tags, tag => { @@ -347,7 +361,7 @@ window.PageWallet = { } }) - if (this.g.wallet.currency) { + if (this.g.wallet.currency && !isAmountless) { cleanInvoice.fiatAmount = LNbits.utils.formatCurrency( ((cleanInvoice.sat / 1e8) * this.g.exchangeRate).toFixed(2), this.g.wallet.currency @@ -357,16 +371,35 @@ window.PageWallet = { this.parse.invoice = Object.freeze(cleanInvoice) }, payInvoice() { + // Validate amount for amountless invoices + if (this.parse.invoice.isAmountless) { + if (!this.parse.data.amount || this.parse.data.amount <= 0) { + Quasar.Notify.create({ + timeout: 3000, + type: 'warning', + message: this.$t('amount_must_be_positive') + }) + return + } + } + const dismissPaymentMsg = Quasar.Notify.create({ timeout: 0, message: this.$t('payment_processing') }) + // Calculate amount_msat for amountless invoices + let amountMsat = null + if (this.parse.invoice.isAmountless && this.parse.data.amount) { + amountMsat = this.parse.data.amount * 1000 // Convert sats to msats + } + LNbits.api .payInvoice( this.g.wallet, this.parse.data.request, - this.parse.data.internalMemo + this.parse.data.internalMemo, + amountMsat ) .then(response => { dismissPaymentMsg() diff --git a/lnbits/templates/pages/wallet.vue b/lnbits/templates/pages/wallet.vue index 7b285bf6..7fc8386d 100644 --- a/lnbits/templates/pages/wallet.vue +++ b/lnbits/templates/pages/wallet.vue @@ -458,7 +458,26 @@
-
+ +
+
+ +
+ +

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

-
+
Date: Wed, 15 Oct 2025 00:56:14 +0200 Subject: [PATCH 6/6] feat: implement automatic crediting of new accounts with 1 million satoshis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modified the user account creation process to automatically credit new accounts with 1,000,000 satoshis upon creation. - Updated the `create_user_account_no_ckeck` function to include error handling and logging for the credit operation. - Enhanced tests to verify the balance for newly registered users, ensuring the correct credit amount is applied. - Documented affected account creation paths and testing instructions in the new `AUTO_CREDIT_CHANGES.md` file. fix: pass db connection to update_wallet_balance to prevent SQLite deadlock The auto-credit feature was causing a deadlock on SQLite because update_wallet_balance was opening a new database connection while already inside a transaction. By passing conn=conn, we reuse the existing connection and avoid the deadlock. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- AUTO_CREDIT_CHANGES.md | 88 +++++++++++++++++++++++++++++++++++ lnbits/core/services/users.py | 9 ++++ tests/api/test_auth.py | 8 ++++ 3 files changed, 105 insertions(+) create mode 100644 AUTO_CREDIT_CHANGES.md diff --git a/AUTO_CREDIT_CHANGES.md b/AUTO_CREDIT_CHANGES.md new file mode 100644 index 00000000..408f6987 --- /dev/null +++ b/AUTO_CREDIT_CHANGES.md @@ -0,0 +1,88 @@ +# LNBits Auto-Credit Changes + +## Overview +Modified LNBits server to automatically credit new accounts with 1 million satoshis (1,000,000 sats) when they are created. + +## Changes Made + +### 1. Modified `lnbits/core/services/users.py` + +**Added imports:** +- `get_wallet` from `..crud` +- `update_wallet_balance` from `.payments` + +**Modified `create_user_account_no_ckeck` function:** +- Changed `create_wallet` call to capture the returned wallet object +- Added automatic credit of 1,000,000 sats after wallet creation +- Added error handling and logging for the credit operation + +**Code changes:** +```python +# Before: +await create_wallet( + user_id=account.id, + wallet_name=wallet_name or settings.lnbits_default_wallet_name, +) + +# After: +wallet = await create_wallet( + user_id=account.id, + wallet_name=wallet_name or settings.lnbits_default_wallet_name, +) + +# Credit new account with 1 million satoshis +try: + await update_wallet_balance(wallet, 1_000_000) + logger.info(f"Credited new account {account.id} with 1,000,000 sats") +except Exception as e: + logger.error(f"Failed to credit new account {account.id} with 1,000,000 sats: {e}") +``` + +### 2. Updated Tests in `tests/api/test_auth.py` + +**Modified test functions:** +- `test_register_ok`: Added balance verification for regular user registration +- `test_register_nostr_ok`: Added balance verification for Nostr authentication + +**Added assertions:** +```python +# Check that the wallet has 1 million satoshis +wallet = user.wallets[0] +assert wallet.balance == 1_000_000, f"Expected 1,000,000 sats balance, got {wallet.balance} sats" +``` + +## Affected Account Creation Paths + +The automatic credit will be applied to all new accounts created through: + +1. **Regular user registration** (`/api/v1/auth/register`) +2. **Nostr authentication** (`/api/v1/auth/nostr`) +3. **SSO login** (when new account is created) +4. **API account creation** (`/api/v1/account`) +5. **Admin user creation** (via admin interface) + +## Excluded Paths + +- **Superuser/Admin account creation** (`init_admin_settings`): This function creates the admin account directly and bypasses the user creation flow, so it won't receive the automatic credit. + +## Testing + +To test the changes: + +1. Install dependencies: `poetry install` +2. Run the modified tests: `poetry run pytest tests/api/test_auth.py::test_register_ok -v` +3. Run Nostr test: `poetry run pytest tests/api/test_auth.py::test_register_nostr_ok -v` + +## Logging + +The system will log: +- Success: `"Credited new account {account.id} with 1,000,000 sats"` +- Failure: `"Failed to credit new account {account.id} with 1,000,000 sats: {error}"` + +## Notes + +- The credit uses the existing `update_wallet_balance` function which creates an internal payment record +- The credit is applied after wallet creation but before user extensions are set up +- Error handling ensures that account creation continues even if the credit fails +- The credit amount is hardcoded to 1,000,000 sats (1MM sats) + diff --git a/lnbits/core/services/users.py b/lnbits/core/services/users.py index d21a8aff..16d6884c 100644 --- a/lnbits/core/services/users.py +++ b/lnbits/core/services/users.py @@ -26,6 +26,7 @@ from ..crud import ( get_super_settings, get_user_extensions, get_user_from_account, + get_wallet, update_account, update_super_user, update_user_extension, @@ -37,6 +38,7 @@ from ..models import ( UserExtra, ) from .settings import update_cached_settings +from .payments import update_wallet_balance async def create_user_account( @@ -80,6 +82,13 @@ async def create_user_account_no_ckeck( conn=conn, ) + # Credit new account with 1 million satoshis + try: + await update_wallet_balance(wallet, 1_000_000, conn=conn) + logger.info(f"Credited new account {account.id} with 1,000,000 sats") + except Exception as e: + logger.error(f"Failed to credit new account {account.id} with 1,000,000 sats: {e}") + user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions for ext_id in user_extensions: try: diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index 57cacd03..8b1e7ba2 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -295,6 +295,10 @@ async def test_register_ok(http_client: AsyncClient): assert ( len(user.wallets) == 1 ), f"Expected 1 default wallet, not {len(user.wallets)}." + + # Check that the wallet has 1 million satoshis + wallet = user.wallets[0] + assert wallet.balance == 1_000_000, f"Expected 1,000,000 sats balance, got {wallet.balance} sats" @pytest.mark.anyio @@ -588,6 +592,10 @@ async def test_register_nostr_ok(http_client: AsyncClient, settings: Settings): assert ( len(user.wallets) == 1 ), f"Expected 1 default wallet, not {len(user.wallets)}." + + # Check that the wallet has 1 million satoshis + wallet = user.wallets[0] + assert wallet.balance == 1_000_000, f"Expected 1,000,000 sats balance, got {wallet.balance} sats" @pytest.mark.anyio