Compare commits
3 commits
b093b0abb9
...
d8cdc1e680
| Author | SHA1 | Date | |
|---|---|---|---|
| d8cdc1e680 | |||
|
|
71a94766b1 | ||
|
|
8657e221c6 |
36 changed files with 887 additions and 55 deletions
88
AUTO_CREDIT_CHANGES.md
Normal file
88
AUTO_CREDIT_CHANGES.md
Normal file
|
|
@ -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)
|
||||
|
||||
459
docs/guide/amountless-invoice-implementation.md
Normal file
459
docs/guide/amountless-invoice-implementation.md
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
# Amountless BOLT11 Invoice Support in LNbits
|
||||
|
||||
This document provides a comprehensive analysis supporting the implementation of amountless (zero-amount) BOLT11 invoice payments in LNbits, with a focus on the LND REST backend.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [What Are Amountless Invoices?](#what-are-amountless-invoices)
|
||||
- [Use Cases](#use-cases)
|
||||
- [Industry Analysis: Mobile Wallet Implementations](#industry-analysis-mobile-wallet-implementations)
|
||||
- [Blixt Wallet (React Native)](#blixt-wallet-react-native)
|
||||
- [Breez SDK (Flutter)](#breez-sdk-flutter)
|
||||
- [Zeus Wallet (React Native)](#zeus-wallet-react-native)
|
||||
- [Common Patterns](#common-patterns)
|
||||
- [LND API Specification](#lnd-api-specification)
|
||||
- [SendPaymentRequest Fields](#sendpaymentrequest-fields)
|
||||
- [Amountless Invoice Handling](#amountless-invoice-handling)
|
||||
- [Validation Logic](#validation-logic)
|
||||
- [LNbits Implementation](#lnbits-implementation)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [Layer-by-Layer Changes](#layer-by-layer-changes)
|
||||
- [API Usage](#api-usage)
|
||||
- [Verification Matrix](#verification-matrix)
|
||||
- [Security Considerations](#security-considerations)
|
||||
- [Testing](#testing)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation adds the ability to pay BOLT11 invoices that don't have an embedded amount by specifying the amount at payment time via the `amount_msat` parameter.
|
||||
|
||||
**Key Changes:**
|
||||
|
||||
- Added `Feature.amountless_invoice` to wallet base class for capability detection
|
||||
- Updated `Wallet.pay_invoice()` signature with optional `amount_msat` parameter
|
||||
- Implemented amountless invoice support in LND REST and LND gRPC wallets
|
||||
- Updated payment service layer to validate and pass through `amount_msat`
|
||||
- Added `amount_msat` field to CreateInvoice API model
|
||||
|
||||
---
|
||||
|
||||
## What Are Amountless Invoices?
|
||||
|
||||
A BOLT11 invoice typically encodes a specific amount to be paid. However, the BOLT11 specification allows for invoices without an amount field, leaving the payment amount to be determined by the payer. These are commonly called:
|
||||
|
||||
- **Amountless invoices**
|
||||
- **Zero-amount invoices**
|
||||
- **Open invoices**
|
||||
|
||||
When decoded, these invoices have `amount_msat = null` or `amount_msat = 0`.
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
1. **Donations**: Accept any amount the donor wishes to give
|
||||
2. **Tips**: Allow customers to decide the tip amount
|
||||
3. **Variable services**: Pay-what-you-want pricing models
|
||||
4. **LNURL-withdraw**: Some LNURL flows use amountless invoices
|
||||
5. **NFC payments**: Tap-to-pay scenarios where amount is determined at payment time
|
||||
|
||||
---
|
||||
|
||||
## Industry Analysis: Mobile Wallet Implementations
|
||||
|
||||
To ensure our implementation follows established patterns, we analyzed three major Lightning mobile wallets.
|
||||
|
||||
### Blixt Wallet (React Native)
|
||||
|
||||
**Detection** (`src/state/Send.ts:185`):
|
||||
|
||||
```typescript
|
||||
if (!paymentRequest.numSatoshis) {
|
||||
// Invoice is amountless - require user input
|
||||
}
|
||||
```
|
||||
|
||||
**Amount Injection** (`src/state/Send.ts:203-204`):
|
||||
|
||||
```typescript
|
||||
// Mutate the payment request with user-provided amount
|
||||
paymentRequest.numSatoshis = payload.amount
|
||||
```
|
||||
|
||||
**UI Handling** (`src/windows/Send/SendConfirmation.tsx:67-70`):
|
||||
|
||||
```typescript
|
||||
const amountEditable = !paymentRequest.numSatoshis
|
||||
// Shows editable amount field when invoice has no amount
|
||||
```
|
||||
|
||||
**Key Pattern**: Blixt mutates the payment request object directly before sending to the LND backend.
|
||||
|
||||
### Breez SDK (Flutter)
|
||||
|
||||
**Detection** (`lib/widgets/payment_request_info_dialog.dart:162`):
|
||||
|
||||
```dart
|
||||
if (widget.invoice.amount == 0) {
|
||||
// Show amount input field
|
||||
}
|
||||
```
|
||||
|
||||
**Validation** (`lib/widgets/payment_request_info_dialog.dart:251`):
|
||||
|
||||
```dart
|
||||
var validationResult = acc.validateOutgoingPayment(amountToPay);
|
||||
if (validationResult.isNotEmpty) {
|
||||
// Show validation error
|
||||
}
|
||||
```
|
||||
|
||||
**Key Pattern**: Breez validates the user-entered amount against account balance before payment.
|
||||
|
||||
### Zeus Wallet (React Native)
|
||||
|
||||
**Detection** (`views/PaymentRequest.tsx:602-603`):
|
||||
|
||||
```typescript
|
||||
const isNoAmountInvoice = !requestAmount || requestAmount === 0
|
||||
```
|
||||
|
||||
**Amount Handling** (`views/Send.tsx:339-341`):
|
||||
|
||||
```typescript
|
||||
if (isNoAmountInvoice) {
|
||||
// Use amount from user input instead of invoice
|
||||
amountToSend = userEnteredAmount
|
||||
}
|
||||
```
|
||||
|
||||
**Key Pattern**: Zeus uses explicit boolean flags (`isNoAmountInvoice`) for clear code intent.
|
||||
|
||||
### Common Patterns
|
||||
|
||||
All three wallets follow the same logical flow:
|
||||
|
||||
| Step | Pattern |
|
||||
| ----------- | -------------------------------------- |
|
||||
| 1. Decode | Parse BOLT11 invoice |
|
||||
| 2. Detect | Check if `amount == 0` or `null` |
|
||||
| 3. Prompt | Show UI for amount input if amountless |
|
||||
| 4. Validate | Verify amount > 0 and within balance |
|
||||
| 5. Inject | Pass amount to payment backend |
|
||||
| 6. Pay | Execute payment with specified amount |
|
||||
|
||||
---
|
||||
|
||||
## LND API Specification
|
||||
|
||||
The implementation must comply with LND's REST and gRPC API specifications.
|
||||
|
||||
### SendPaymentRequest Fields
|
||||
|
||||
From `lnrpc/routerrpc/router.proto`:
|
||||
|
||||
```protobuf
|
||||
message SendPaymentRequest {
|
||||
// Number of satoshis to send.
|
||||
// The fields amt and amt_msat are mutually exclusive.
|
||||
int64 amt = 2;
|
||||
|
||||
// A bare-bones invoice for a payment within the Lightning Network.
|
||||
// The amount in the payment request may be zero. In that case it is
|
||||
// required to set the amt field as well.
|
||||
string payment_request = 5;
|
||||
|
||||
// Number of millisatoshis to send.
|
||||
// The fields amt and amt_msat are mutually exclusive.
|
||||
int64 amt_msat = 12;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- `amt` and `amt_msat` are mutually exclusive
|
||||
- When `payment_request` has zero amount, `amt` or `amt_msat` is **required**
|
||||
- When `payment_request` has an amount, `amt`/`amt_msat` must **not** be specified
|
||||
|
||||
### Amountless Invoice Handling
|
||||
|
||||
From `lnrpc/routerrpc/router_backend.go:1028-1045`:
|
||||
|
||||
```go
|
||||
// If the amount was not included in the invoice, then we let
|
||||
// the payer specify the amount of satoshis they wish to send.
|
||||
if payReq.MilliSat == nil {
|
||||
if reqAmt == 0 {
|
||||
return nil, errors.New("amount must be specified when paying a zero amount invoice")
|
||||
}
|
||||
payIntent.Amount = reqAmt
|
||||
} else {
|
||||
if reqAmt != 0 {
|
||||
return nil, errors.New("amount must not be specified when paying a non-zero amount invoice")
|
||||
}
|
||||
payIntent.Amount = *payReq.MilliSat
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Logic
|
||||
|
||||
LND's `UnmarshallAmt` function (`lnrpc/marshall_utils.go:57-72`):
|
||||
|
||||
```go
|
||||
func UnmarshallAmt(amtSat, amtMsat int64) (lnwire.MilliSatoshi, error) {
|
||||
if amtSat != 0 && amtMsat != 0 {
|
||||
return 0, ErrSatMsatMutualExclusive
|
||||
}
|
||||
if amtSat < 0 || amtMsat < 0 {
|
||||
return 0, ErrNegativeAmt
|
||||
}
|
||||
if amtSat != 0 {
|
||||
return lnwire.NewMSatFromSatoshis(btcutil.Amount(amtSat)), nil
|
||||
}
|
||||
return lnwire.MilliSatoshi(amtMsat), nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LNbits Implementation
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
LNbits uses a layered architecture where changes flow from API → Service → Wallet:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ API Layer (payment_api.py) │
|
||||
│ - Receives amount_msat from client │
|
||||
└─────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼───────────────────────────────────┐
|
||||
│ Service Layer (payments.py) │
|
||||
│ - Validates amountless invoice + amount_msat │
|
||||
│ - Checks funding source capability │
|
||||
│ - Passes amountless_amount_msat through chain │
|
||||
└─────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼───────────────────────────────────┐
|
||||
│ Wallet Layer (lndrest.py, lndgrpc.py, etc.) │
|
||||
│ - Adds amt_msat to LND request if provided │
|
||||
│ - Declares Feature.amountless_invoice capability │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layer-by-Layer Changes
|
||||
|
||||
#### 1. Base Wallet Class (`lnbits/wallets/base.py`)
|
||||
|
||||
**Feature Declaration:**
|
||||
|
||||
```python
|
||||
class Feature(Enum):
|
||||
nodemanager = "nodemanager"
|
||||
holdinvoice = "holdinvoice"
|
||||
amountless_invoice = "amountless_invoice" # NEW
|
||||
```
|
||||
|
||||
**Method Signature:**
|
||||
|
||||
```python
|
||||
@abstractmethod
|
||||
def pay_invoice(
|
||||
self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None
|
||||
) -> Coroutine[None, None, PaymentResponse]:
|
||||
"""
|
||||
Pay a BOLT11 invoice.
|
||||
|
||||
Args:
|
||||
bolt11: The BOLT11 invoice string
|
||||
fee_limit_msat: Maximum fee in millisatoshis
|
||||
amount_msat: Amount to pay in millisatoshis. Required for amountless
|
||||
invoices on wallets that support Feature.amountless_invoice.
|
||||
Ignored for invoices that already contain an amount.
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
#### 2. LND REST Wallet (`lnbits/wallets/lndrest.py`)
|
||||
|
||||
**Feature Declaration:**
|
||||
|
||||
```python
|
||||
features = [Feature.nodemanager, Feature.holdinvoice, Feature.amountless_invoice]
|
||||
```
|
||||
|
||||
**Payment Implementation:**
|
||||
|
||||
```python
|
||||
async def pay_invoice(
|
||||
self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None
|
||||
) -> PaymentResponse:
|
||||
req: dict = {
|
||||
"payment_request": bolt11,
|
||||
"fee_limit_msat": fee_limit_msat,
|
||||
"timeout_seconds": 30,
|
||||
"no_inflight_updates": True,
|
||||
}
|
||||
# For amountless invoices, specify the amount to pay
|
||||
if amount_msat is not None:
|
||||
req["amt_msat"] = amount_msat
|
||||
# ... rest of implementation
|
||||
```
|
||||
|
||||
#### 3. Payment Service (`lnbits/core/services/payments.py`)
|
||||
|
||||
**Validation:**
|
||||
|
||||
```python
|
||||
def _validate_payment_request(
|
||||
payment_request: str, max_sat: int | None = None, amount_msat: int | None = None
|
||||
) -> Bolt11:
|
||||
invoice = bolt11_decode(payment_request)
|
||||
|
||||
if not invoice.amount_msat or invoice.amount_msat <= 0:
|
||||
# Amountless invoice - check capability and require amount
|
||||
funding_source = get_funding_source()
|
||||
if not funding_source.has_feature(Feature.amountless_invoice):
|
||||
raise PaymentError(
|
||||
"Amountless invoices not supported by the funding source.",
|
||||
status="failed",
|
||||
)
|
||||
if not amount_msat or amount_msat <= 0:
|
||||
raise PaymentError(
|
||||
"Amount required for amountless invoices.",
|
||||
status="failed",
|
||||
)
|
||||
check_amount_msat = amount_msat
|
||||
else:
|
||||
check_amount_msat = invoice.amount_msat
|
||||
|
||||
# Validate against max payment limit
|
||||
# ...
|
||||
```
|
||||
|
||||
**Amount Handling:**
|
||||
|
||||
```python
|
||||
# Determine the actual amount to pay
|
||||
pay_amount_msat = invoice.amount_msat or amount_msat
|
||||
|
||||
# Only pass amount to funding source if invoice is amountless
|
||||
amountless_amount_msat = amount_msat if not invoice.amount_msat else None
|
||||
```
|
||||
|
||||
#### 4. API Layer (`lnbits/core/views/payment_api.py`)
|
||||
|
||||
```python
|
||||
payment = await pay_invoice(
|
||||
wallet_id=wallet_id,
|
||||
payment_request=invoice_data.bolt11,
|
||||
extra=invoice_data.extra,
|
||||
labels=invoice_data.labels,
|
||||
amount_msat=invoice_data.amount_msat, # NEW
|
||||
)
|
||||
```
|
||||
|
||||
#### 5. API Model (`lnbits/core/models/payments.py`)
|
||||
|
||||
```python
|
||||
class CreateInvoice(BaseModel):
|
||||
# ... existing fields
|
||||
amount_msat: int | None = Query(
|
||||
None,
|
||||
ge=1,
|
||||
description=(
|
||||
"Amount to pay in millisatoshis. Required for amountless invoices "
|
||||
"when the funding source supports them."
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### API Usage
|
||||
|
||||
**Paying an amountless invoice:**
|
||||
|
||||
```bash
|
||||
curl -X POST "https://lnbits.example.com/api/v1/payments" \
|
||||
-H "X-Api-Key: <admin_key>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"out": true,
|
||||
"bolt11": "lnbc1p...",
|
||||
"amount_msat": 100000
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_hash": "abc123...",
|
||||
"checking_id": "abc123...",
|
||||
"status": "success"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Matrix
|
||||
|
||||
| Requirement | LND Spec | Mobile Wallets | LNbits Implementation |
|
||||
| --------------------------------- | ------------------------ | ------------------------ | ------------------------------- |
|
||||
| Detect amountless | `payReq.MilliSat == nil` | Check `amount == 0/null` | `not invoice.amount_msat` |
|
||||
| Require amount for amountless | Error if `reqAmt == 0` | Show input field | `PaymentError` if not provided |
|
||||
| Block amount for regular invoices | Error if `reqAmt != 0` | N/A (UI doesn't allow) | `amountless_amount_msat = None` |
|
||||
| Field name | `amt_msat` | N/A (native SDK) | `amt_msat` |
|
||||
| Type | int64 (msat) | varies | int (msat) |
|
||||
| Feature detection | N/A | Hardcoded | `Feature.amountless_invoice` |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Balance Validation**: The service layer validates that the wallet has sufficient balance for the specified amount before attempting payment.
|
||||
|
||||
2. **Maximum Amount**: Amountless payments are still subject to `lnbits_max_outgoing_payment_amount_sats` limits.
|
||||
|
||||
3. **Feature Gating**: Only wallets that explicitly declare `Feature.amountless_invoice` support can process amountless payments. This prevents accidental payment failures on unsupported backends.
|
||||
|
||||
4. **Input Validation**: The API model enforces `amount_msat >= 1` when provided, preventing zero or negative amounts.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Two test cases cover the amountless invoice functionality:
|
||||
|
||||
### Happy Path
|
||||
|
||||
```python
|
||||
@pytest.mark.anyio
|
||||
async def test_pay_amountless_invoice_with_amount(client, adminkey_headers_from):
|
||||
"""Test paying an amountless invoice by specifying amount_msat."""
|
||||
# Create amountless invoice via FakeWallet
|
||||
# Pay with amount_msat specified
|
||||
# Verify payment succeeds
|
||||
```
|
||||
|
||||
### Error Case
|
||||
|
||||
```python
|
||||
@pytest.mark.anyio
|
||||
async def test_pay_amountless_invoice_without_amount_fails(client, adminkey_headers_from):
|
||||
"""Test that paying an amountless invoice without amount_msat fails."""
|
||||
# Create amountless invoice
|
||||
# Attempt payment WITHOUT amount_msat
|
||||
# Verify proper error: "Amount required for amountless invoices"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [BOLT11 Specification](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md)
|
||||
- [LND Router RPC Documentation](https://lightning.engineering/api-docs/api/lnd/router/)
|
||||
- [LND SendPaymentV2 API](https://lightning.engineering/api-docs/api/lnd/router/send-payment-v2/)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -458,7 +458,26 @@
|
|||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div v-if="parse.invoice">
|
||||
<div class="column content-center text-center q-mb-md">
|
||||
<div v-if="!g.isFiatPriority">
|
||||
<!-- Amountless invoice: show amount input -->
|
||||
<div v-if="parse.invoice.isAmountless">
|
||||
<h5
|
||||
class="q-my-none text-bold q-mb-sm"
|
||||
v-text="$t('any_amount')"
|
||||
></h5>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="parse.data.amount"
|
||||
type="number"
|
||||
:label="$t('amount') + ' (sat) *'"
|
||||
min="1"
|
||||
class="q-mx-auto"
|
||||
style="max-width: 200px"
|
||||
autofocus
|
||||
></q-input>
|
||||
</div>
|
||||
<!-- Regular invoice with amount -->
|
||||
<div v-else-if="!g.isFiatPriority">
|
||||
<h4 class="q-my-none text-bold">
|
||||
<span
|
||||
v-text="utils.formatBalance(parse.invoice.sat, g.denomination)"
|
||||
|
|
@ -473,7 +492,7 @@
|
|||
</div>
|
||||
<div class="q-my-md absolute">
|
||||
<q-btn
|
||||
v-if="g.fiatTracking"
|
||||
v-if="g.fiatTracking && !parse.invoice.isAmountless"
|
||||
@click="g.isFiatPriority = !g.isFiatPriority"
|
||||
flat
|
||||
dense
|
||||
|
|
@ -481,7 +500,7 @@
|
|||
color="primary"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-if="g.fiatTracking">
|
||||
<div v-if="g.fiatTracking && !parse.invoice.isAmountless">
|
||||
<div v-if="g.isFiatPriority">
|
||||
<h5 class="q-my-none text-bold">
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ class CLNRestWallet(Wallet):
|
|||
self,
|
||||
bolt11: str,
|
||||
fee_limit_msat: int,
|
||||
**_,
|
||||
amount_msat: int | None = None,
|
||||
) -> PaymentResponse:
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue