feat(ui): add frontend support for paying amountless invoices
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 <noreply@anthropic.com>
This commit is contained in:
parent
0513074bd6
commit
b093b0abb9
5 changed files with 105 additions and 30 deletions
|
|
@ -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.
|
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:**
|
**Key Changes:**
|
||||||
|
|
||||||
- Added `Feature.amountless_invoice` to wallet base class for capability detection
|
- Added `Feature.amountless_invoice` to wallet base class for capability detection
|
||||||
- Updated `Wallet.pay_invoice()` signature with optional `amount_msat` parameter
|
- Updated `Wallet.pay_invoice()` signature with optional `amount_msat` parameter
|
||||||
- Implemented amountless invoice support in LND REST and LND gRPC wallets
|
- 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)
|
### Blixt Wallet (React Native)
|
||||||
|
|
||||||
**Detection** (`src/state/Send.ts:185`):
|
**Detection** (`src/state/Send.ts:185`):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
if (!paymentRequest.numSatoshis) {
|
if (!paymentRequest.numSatoshis) {
|
||||||
// Invoice is amountless - require user input
|
// Invoice is amountless - require user input
|
||||||
|
|
@ -75,14 +77,16 @@ if (!paymentRequest.numSatoshis) {
|
||||||
```
|
```
|
||||||
|
|
||||||
**Amount Injection** (`src/state/Send.ts:203-204`):
|
**Amount Injection** (`src/state/Send.ts:203-204`):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Mutate the payment request with user-provided amount
|
// 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`):
|
**UI Handling** (`src/windows/Send/SendConfirmation.tsx:67-70`):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const amountEditable = !paymentRequest.numSatoshis;
|
const amountEditable = !paymentRequest.numSatoshis
|
||||||
// Shows editable amount field when invoice has no amount
|
// Shows editable amount field when invoice has no amount
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -91,6 +95,7 @@ const amountEditable = !paymentRequest.numSatoshis;
|
||||||
### Breez SDK (Flutter)
|
### Breez SDK (Flutter)
|
||||||
|
|
||||||
**Detection** (`lib/widgets/payment_request_info_dialog.dart:162`):
|
**Detection** (`lib/widgets/payment_request_info_dialog.dart:162`):
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
if (widget.invoice.amount == 0) {
|
if (widget.invoice.amount == 0) {
|
||||||
// Show amount input field
|
// Show amount input field
|
||||||
|
|
@ -98,6 +103,7 @@ if (widget.invoice.amount == 0) {
|
||||||
```
|
```
|
||||||
|
|
||||||
**Validation** (`lib/widgets/payment_request_info_dialog.dart:251`):
|
**Validation** (`lib/widgets/payment_request_info_dialog.dart:251`):
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
var validationResult = acc.validateOutgoingPayment(amountToPay);
|
var validationResult = acc.validateOutgoingPayment(amountToPay);
|
||||||
if (validationResult.isNotEmpty) {
|
if (validationResult.isNotEmpty) {
|
||||||
|
|
@ -110,15 +116,17 @@ if (validationResult.isNotEmpty) {
|
||||||
### Zeus Wallet (React Native)
|
### Zeus Wallet (React Native)
|
||||||
|
|
||||||
**Detection** (`views/PaymentRequest.tsx:602-603`):
|
**Detection** (`views/PaymentRequest.tsx:602-603`):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const isNoAmountInvoice = !requestAmount || requestAmount === 0;
|
const isNoAmountInvoice = !requestAmount || requestAmount === 0
|
||||||
```
|
```
|
||||||
|
|
||||||
**Amount Handling** (`views/Send.tsx:339-341`):
|
**Amount Handling** (`views/Send.tsx:339-341`):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
if (isNoAmountInvoice) {
|
if (isNoAmountInvoice) {
|
||||||
// Use amount from user input instead of invoice
|
// 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:
|
All three wallets follow the same logical flow:
|
||||||
|
|
||||||
| Step | Pattern |
|
| Step | Pattern |
|
||||||
|------|---------|
|
| ----------- | -------------------------------------- |
|
||||||
| 1. Decode | Parse BOLT11 invoice |
|
| 1. Decode | Parse BOLT11 invoice |
|
||||||
| 2. Detect | Check if `amount == 0` or `null` |
|
| 2. Detect | Check if `amount == 0` or `null` |
|
||||||
| 3. Prompt | Show UI for amount input if amountless |
|
| 3. Prompt | Show UI for amount input if amountless |
|
||||||
| 4. Validate | Verify amount > 0 and within balance |
|
| 4. Validate | Verify amount > 0 and within balance |
|
||||||
| 5. Inject | Pass amount to payment backend |
|
| 5. Inject | Pass amount to payment backend |
|
||||||
| 6. Pay | Execute payment with specified amount |
|
| 6. Pay | Execute payment with specified amount |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -165,6 +173,7 @@ message SendPaymentRequest {
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Points:**
|
**Key Points:**
|
||||||
|
|
||||||
- `amt` and `amt_msat` are mutually exclusive
|
- `amt` and `amt_msat` are mutually exclusive
|
||||||
- When `payment_request` has zero amount, `amt` or `amt_msat` is **required**
|
- 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
|
- 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`)
|
#### 1. Base Wallet Class (`lnbits/wallets/base.py`)
|
||||||
|
|
||||||
**Feature Declaration:**
|
**Feature Declaration:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class Feature(Enum):
|
class Feature(Enum):
|
||||||
nodemanager = "nodemanager"
|
nodemanager = "nodemanager"
|
||||||
|
|
@ -249,6 +259,7 @@ class Feature(Enum):
|
||||||
```
|
```
|
||||||
|
|
||||||
**Method Signature:**
|
**Method Signature:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def pay_invoice(
|
def pay_invoice(
|
||||||
|
|
@ -270,11 +281,13 @@ def pay_invoice(
|
||||||
#### 2. LND REST Wallet (`lnbits/wallets/lndrest.py`)
|
#### 2. LND REST Wallet (`lnbits/wallets/lndrest.py`)
|
||||||
|
|
||||||
**Feature Declaration:**
|
**Feature Declaration:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
features = [Feature.nodemanager, Feature.holdinvoice, Feature.amountless_invoice]
|
features = [Feature.nodemanager, Feature.holdinvoice, Feature.amountless_invoice]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Payment Implementation:**
|
**Payment Implementation:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async def pay_invoice(
|
async def pay_invoice(
|
||||||
self, bolt11: str, fee_limit_msat: int, amount_msat: int | None = None
|
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`)
|
#### 3. Payment Service (`lnbits/core/services/payments.py`)
|
||||||
|
|
||||||
**Validation:**
|
**Validation:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def _validate_payment_request(
|
def _validate_payment_request(
|
||||||
payment_request: str, max_sat: int | None = None, amount_msat: int | None = None
|
payment_request: str, max_sat: int | None = None, amount_msat: int | None = None
|
||||||
|
|
@ -322,6 +336,7 @@ def _validate_payment_request(
|
||||||
```
|
```
|
||||||
|
|
||||||
**Amount Handling:**
|
**Amount Handling:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Determine the actual amount to pay
|
# Determine the actual amount to pay
|
||||||
pay_amount_msat = invoice.amount_msat or amount_msat
|
pay_amount_msat = invoice.amount_msat or amount_msat
|
||||||
|
|
@ -373,6 +388,7 @@ curl -X POST "https://lnbits.example.com/api/v1/payments" \
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"payment_hash": "abc123...",
|
"payment_hash": "abc123...",
|
||||||
|
|
@ -385,14 +401,14 @@ curl -X POST "https://lnbits.example.com/api/v1/payments" \
|
||||||
|
|
||||||
## Verification Matrix
|
## Verification Matrix
|
||||||
|
|
||||||
| Requirement | LND Spec | Mobile Wallets | LNbits Implementation |
|
| Requirement | LND Spec | Mobile Wallets | LNbits Implementation |
|
||||||
|-------------|----------|----------------|----------------------|
|
| --------------------------------- | ------------------------ | ------------------------ | ------------------------------- |
|
||||||
| Detect amountless | `payReq.MilliSat == nil` | Check `amount == 0/null` | `not invoice.amount_msat` |
|
| 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 |
|
| 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` |
|
| 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` |
|
| Field name | `amt_msat` | N/A (native SDK) | `amt_msat` |
|
||||||
| Type | int64 (msat) | varies | int (msat) |
|
| Type | int64 (msat) | varies | int (msat) |
|
||||||
| Feature detection | N/A | Hardcoded | `Feature.amountless_invoice` |
|
| 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:
|
Two test cases cover the amountless invoice functionality:
|
||||||
|
|
||||||
### Happy Path
|
### Happy Path
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_pay_amountless_invoice_with_amount(client, adminkey_headers_from):
|
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
|
### Error Case
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_pay_amountless_invoice_without_amount_fails(client, adminkey_headers_from):
|
async def test_pay_amountless_invoice_without_amount_fails(client, adminkey_headers_from):
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,8 @@ window.localisation.en = {
|
||||||
amount: 'Amount',
|
amount: 'Amount',
|
||||||
amount_limits: 'Amount Limits',
|
amount_limits: 'Amount Limits',
|
||||||
amount_sats: 'Amount (sats)',
|
amount_sats: 'Amount (sats)',
|
||||||
|
amount_must_be_positive: 'Amount must be greater than 0',
|
||||||
|
any_amount: 'Any Amount',
|
||||||
faucest_wallet: 'Faucet Wallet',
|
faucest_wallet: 'Faucet Wallet',
|
||||||
faucest_wallet_desc_1:
|
faucest_wallet_desc_1:
|
||||||
'Each time a payment is confirmed by the {provider} provider funds will be subtracted from this wallet.',
|
'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)
|
return this.request('post', '/api/v1/payments', wallet.inkey, data)
|
||||||
},
|
},
|
||||||
payInvoice(wallet, bolt11, internalMemo = null) {
|
payInvoice(wallet, bolt11, internalMemo = null, amountMsat = null) {
|
||||||
const data = {
|
const data = {
|
||||||
out: true,
|
out: true,
|
||||||
bolt11: bolt11
|
bolt11: bolt11
|
||||||
|
|
@ -49,6 +49,9 @@ window._lnbitsApi = {
|
||||||
internal_memo: String(internalMemo)
|
internal_memo: String(internalMemo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (amountMsat) {
|
||||||
|
data.amount_msat = amountMsat
|
||||||
|
}
|
||||||
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
|
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
|
||||||
},
|
},
|
||||||
cancelInvoice(wallet, paymentHash) {
|
cancelInvoice(wallet, paymentHash) {
|
||||||
|
|
|
||||||
|
|
@ -307,11 +307,25 @@ window.PageWallet = {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if invoice is amountless (no amount specified)
|
||||||
|
const isAmountless =
|
||||||
|
!invoice.human_readable_part.amount ||
|
||||||
|
invoice.human_readable_part.amount === 0
|
||||||
|
|
||||||
let cleanInvoice = {
|
let cleanInvoice = {
|
||||||
msat: invoice.human_readable_part.amount,
|
msat: isAmountless ? null : invoice.human_readable_part.amount,
|
||||||
sat: invoice.human_readable_part.amount / 1000,
|
sat: isAmountless ? null : invoice.human_readable_part.amount / 1000,
|
||||||
fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000),
|
fsat: isAmountless
|
||||||
bolt11: this.parse.data.request
|
? 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 => {
|
_.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.fiatAmount = LNbits.utils.formatCurrency(
|
||||||
((cleanInvoice.sat / 1e8) * this.g.exchangeRate).toFixed(2),
|
((cleanInvoice.sat / 1e8) * this.g.exchangeRate).toFixed(2),
|
||||||
this.g.wallet.currency
|
this.g.wallet.currency
|
||||||
|
|
@ -357,16 +371,35 @@ window.PageWallet = {
|
||||||
this.parse.invoice = Object.freeze(cleanInvoice)
|
this.parse.invoice = Object.freeze(cleanInvoice)
|
||||||
},
|
},
|
||||||
payInvoice() {
|
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({
|
const dismissPaymentMsg = Quasar.Notify.create({
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
message: this.$t('payment_processing')
|
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
|
LNbits.api
|
||||||
.payInvoice(
|
.payInvoice(
|
||||||
this.g.wallet,
|
this.g.wallet,
|
||||||
this.parse.data.request,
|
this.parse.data.request,
|
||||||
this.parse.data.internalMemo
|
this.parse.data.internalMemo,
|
||||||
|
amountMsat
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
dismissPaymentMsg()
|
dismissPaymentMsg()
|
||||||
|
|
|
||||||
|
|
@ -458,7 +458,26 @@
|
||||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
<div v-if="parse.invoice">
|
<div v-if="parse.invoice">
|
||||||
<div class="column content-center text-center q-mb-md">
|
<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">
|
<h4 class="q-my-none text-bold">
|
||||||
<span
|
<span
|
||||||
v-text="utils.formatBalance(parse.invoice.sat, g.denomination)"
|
v-text="utils.formatBalance(parse.invoice.sat, g.denomination)"
|
||||||
|
|
@ -473,7 +492,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="q-my-md absolute">
|
<div class="q-my-md absolute">
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="g.fiatTracking"
|
v-if="g.fiatTracking && !parse.invoice.isAmountless"
|
||||||
@click="g.isFiatPriority = !g.isFiatPriority"
|
@click="g.isFiatPriority = !g.isFiatPriority"
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
|
|
@ -481,7 +500,7 @@
|
||||||
color="primary"
|
color="primary"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="g.fiatTracking">
|
<div v-if="g.fiatTracking && !parse.invoice.isAmountless">
|
||||||
<div v-if="g.isFiatPriority">
|
<div v-if="g.isFiatPriority">
|
||||||
<h5 class="q-my-none text-bold">
|
<h5 class="q-my-none text-bold">
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue