Compare commits

...

2 commits

Author SHA1 Message Date
cfc4e80e24 Merge pull request 'fix(wallet): send amount_msat when paying an amountless invoice' (#162) from fix/wallet-amountless-invoice into dev
Reviewed-on: #162
2026-09-22 21:06:00 +00:00
d636be6bb5 fix(wallet): send amount_msat when paying an amountless invoice
Paying a zero-amount BOLT11 always failed. The send dialog correctly
showed an amount field for such an invoice, but the amount the user
typed was then dropped: the request body carried only `bolt11`.

LNbits rejects that outright — `_validate_payment_request` raises
"Amount required for amountless invoices." when the invoice has no
amount and no `amount_msat` accompanies it. So the user filled in an
amount and got an error.

The invoice is now decoded in the service rather than trusted from the
caller, so every caller benefits and no call site has to remember. When
the decoded amount is zero we forward the caller's amount as
`amount_msat`; when the invoice carries its own amount we deliberately
send nothing, since the invoice amount is authoritative and must not be
overridable by the caller.

A decode failure returns null and sends nothing, leaving the server as
the authority on whether the invoice is payable.

Verified with light-bolt11-decoder:
  BOLT11 spec donation vector (amountless) -> 0      (amount_msat sent)
  live 777-sat LNbits invoice              -> 777000 (amount_msat omitted)
  malformed input                          -> null   (defer to server)
2026-09-22 22:42:28 +02:00

View file

@ -2,6 +2,7 @@ import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { config } from '@/lib/config' import { config } from '@/lib/config'
import { decode as decodeBolt11 } from 'light-bolt11-decoder'
export interface PayLink { export interface PayLink {
id: string id: string
@ -255,6 +256,19 @@ export default class WalletService extends BaseService {
out: true, out: true,
bolt11: lower bolt11: lower
} }
// An amountless invoice carries no amount of its own, so the payer
// chooses one. LNbits rejects the payment outright ("Amount required
// for amountless invoices") unless `amount_msat` is supplied, so the
// caller's amount has to be forwarded explicitly. For an invoice that
// does carry an amount we deliberately send nothing: the invoice's own
// amount is authoritative and must not be overridden by the caller.
if (this.getInvoiceAmountMsat(lower) === 0) {
if (!request.amount || request.amount <= 0) {
throw new Error('This invoice has no amount. Enter an amount to send.')
}
body.amount_msat = Math.round(request.amount * 1000)
}
} else if (dest.includes('@') || lower.startsWith('lnurl')) { } else if (dest.includes('@') || lower.startsWith('lnurl')) {
// Lightning address or LNURL // Lightning address or LNURL
endpoint = `${config.api.baseUrl}/api/v1/payments/lnurl` endpoint = `${config.api.baseUrl}/api/v1/payments/lnurl`
@ -300,6 +314,26 @@ export default class WalletService extends BaseService {
} }
} }
/**
* Amount of a BOLT11 invoice in millisats, or `null` if it cannot be decoded.
*
* Returns `0` for an amountless invoice, which is a meaningful value here
* and not an error — hence `null` rather than `0` for the failure case.
*/
private getInvoiceAmountMsat(bolt11: string): number | null {
try {
const decoded = decodeBolt11(bolt11)
const section = decoded.sections.find((s: any) => s.name === 'amount')
if (!section || section.value === undefined || section.value === null) return 0
return Number(section.value)
} catch (error) {
// Let the server be the authority on whether the invoice is payable;
// a local decode failure should not block the attempt.
console.warn('WalletService: could not decode invoice amount', error)
return null
}
}
/** /**
* Load existing pay links * Load existing pay links
*/ */