feat: HodlInvoices (Rebase) (#2869)

Co-authored-by: pseudozach <git@pseudozach.com>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
ziggieXXX 2025-07-10 16:11:08 +02:00 committed by dni ⚡
parent 6e9f451419
commit f91c933919
No known key found for this signature in database
GPG key ID: D1F416F29AD26E87
23 changed files with 4192 additions and 6027 deletions

View file

@ -9,6 +9,7 @@ from .misc import (
SimpleStatus, SimpleStatus,
) )
from .payments import ( from .payments import (
CancelInvoice,
CreateInvoice, CreateInvoice,
CreatePayment, CreatePayment,
DecodePayment, DecodePayment,
@ -23,6 +24,7 @@ from .payments import (
PaymentsStatusCount, PaymentsStatusCount,
PaymentState, PaymentState,
PaymentWalletStats, PaymentWalletStats,
SettleInvoice,
) )
from .tinyurl import TinyURL from .tinyurl import TinyURL
from .users import ( from .users import (
@ -57,6 +59,7 @@ __all__ = [
"BalanceDelta", "BalanceDelta",
"BaseWallet", "BaseWallet",
"Callback", "Callback",
"CancelInvoice",
"ConversionData", "ConversionData",
"CoreAppExtra", "CoreAppExtra",
"CreateInvoice", "CreateInvoice",
@ -85,6 +88,7 @@ __all__ = [
"PaymentsStatusCount", "PaymentsStatusCount",
"RegisterUser", "RegisterUser",
"ResetUserPassword", "ResetUserPassword",
"SettleInvoice",
"SimpleStatus", "SimpleStatus",
"TinyURL", "TinyURL",
"UpdateBalance", "UpdateBalance",

View file

@ -252,6 +252,12 @@ class CreateInvoice(BaseModel):
memo: str | None = Query(None, max_length=640) memo: str | None = Query(None, max_length=640)
description_hash: str | None = None description_hash: str | None = None
unhashed_description: str | None = None unhashed_description: str | None = None
payment_hash: str | None = Query(
None,
description="The payment hash of the hold invoice.",
min_length=64,
max_length=64,
)
expiry: int | None = None expiry: int | None = None
extra: dict | None = None extra: dict | None = None
webhook: str | None = None webhook: str | None = None
@ -259,6 +265,12 @@ class CreateInvoice(BaseModel):
lnurl_callback: str | None = None lnurl_callback: str | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
@validator("payment_hash")
def check_hex(cls, v):
if v:
_ = bytes.fromhex(v)
return v
@validator("unit") @validator("unit")
@classmethod @classmethod
def unit_is_from_allowed_currencies(cls, v): def unit_is_from_allowed_currencies(cls, v):
@ -272,3 +284,31 @@ class PaymentsStatusCount(BaseModel):
outgoing: int = 0 outgoing: int = 0
failed: int = 0 failed: int = 0
pending: int = 0 pending: int = 0
class SettleInvoice(BaseModel):
preimage: str = Field(
...,
description="The preimage of the payment hash to settle the invoice.",
min_length=64,
max_length=64,
)
@validator("preimage")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class CancelInvoice(BaseModel):
payment_hash: str = Field(
...,
description="The payment hash of the invoice to cancel.",
min_length=64,
max_length=64,
)
@validator("payment_hash")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v

View file

@ -6,6 +6,7 @@ from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
from .notifications import enqueue_notification, send_payment_notification from .notifications import enqueue_notification, send_payment_notification
from .payments import ( from .payments import (
calculate_fiat_amounts, calculate_fiat_amounts,
cancel_hold_invoice,
check_transaction_status, check_transaction_status,
check_wallet_limits, check_wallet_limits,
create_fiat_invoice, create_fiat_invoice,
@ -17,6 +18,7 @@ from .payments import (
get_payments_daily_stats, get_payments_daily_stats,
pay_invoice, pay_invoice,
service_fee, service_fee,
settle_hold_invoice,
update_pending_payment, update_pending_payment,
update_pending_payments, update_pending_payments,
update_wallet_balance, update_wallet_balance,
@ -36,6 +38,7 @@ from .websockets import websocket_manager, websocket_updater
__all__ = [ __all__ = [
"calculate_fiat_amounts", "calculate_fiat_amounts",
"cancel_hold_invoice",
"check_admin_settings", "check_admin_settings",
"check_transaction_status", "check_transaction_status",
"check_wallet_limits", "check_wallet_limits",
@ -56,6 +59,7 @@ __all__ = [
"redeem_lnurl_withdraw", "redeem_lnurl_withdraw",
"send_payment_notification", "send_payment_notification",
"service_fee", "service_fee",
"settle_hold_invoice",
"switch_to_voidwallet", "switch_to_voidwallet",
"update_cached_settings", "update_cached_settings",
"update_pending_payment", "update_pending_payment",

View file

@ -16,15 +16,16 @@ from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import create_task, internal_invoice_queue_put from lnbits.tasks import create_task, internal_invoice_queue_put
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
InvoiceResponse,
PaymentPendingStatus, PaymentPendingStatus,
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
@ -200,6 +201,7 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
extra=data.extra, extra=data.extra,
webhook=data.webhook, webhook=data.webhook,
internal=data.internal, internal=data.internal,
payment_hash=data.payment_hash,
) )
# lnurl_response is not saved in the database # lnurl_response is not saved in the database
@ -240,6 +242,7 @@ async def create_invoice(
extra: Optional[dict] = None, extra: Optional[dict] = None,
webhook: Optional[str] = None, webhook: Optional[str] = None,
internal: Optional[bool] = False, internal: Optional[bool] = False,
payment_hash: str | None = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Payment: ) -> Payment:
if not amount > 0: if not amount > 0:
@ -273,39 +276,54 @@ async def create_invoice(
status="failed", status="failed",
) )
payment_response = await funding_source.create_invoice( if payment_hash:
amount=amount_sat, try:
memo=invoice_memo, invoice_response = await funding_source.create_hold_invoice(
description_hash=description_hash, amount=amount_sat,
unhashed_description=unhashed_description, memo=invoice_memo,
expiry=expiry or settings.lightning_invoice_expiry, payment_hash=payment_hash,
) description_hash=description_hash,
)
extra["hold_invoice"] = True
except UnsupportedError as exc:
raise InvoiceError(
"Hold invoices are not supported by the funding source.",
status="failed",
) from exc
else:
invoice_response = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if ( if (
not payment_response.ok not invoice_response.ok
or not payment_response.payment_request or not invoice_response.payment_request
or not payment_response.checking_id or not invoice_response.checking_id
): ):
raise InvoiceError( raise InvoiceError(
message=payment_response.error_message or "unexpected backend error.", message=invoice_response.error_message or "unexpected backend error.",
status="pending", status="pending",
) )
invoice = bolt11_decode(payment_response.payment_request) invoice = bolt11_decode(invoice_response.payment_request)
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=wallet_id, wallet_id=wallet_id,
bolt11=payment_response.payment_request, bolt11=invoice_response.payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
preimage=payment_response.preimage, preimage=invoice_response.preimage,
amount_msat=amount_sat * 1000, amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=memo, memo=memo,
extra=extra, extra=extra,
webhook=webhook, webhook=webhook,
fee=payment_response.fee_msat or 0, fee=invoice_response.fee_msat or 0,
) )
payment = await create_payment( payment = await create_payment(
checking_id=payment_response.checking_id, checking_id=invoice_response.checking_id,
data=create_payment_model, data=create_payment_model,
conn=conn, conn=conn,
) )
@ -949,3 +967,40 @@ async def _check_fiat_invoice_limits(
f"The amount exceeds the '{fiat_provider_name}'" f"The amount exceeds the '{fiat_provider_name}'"
"faucet wallet balance.", "faucet wallet balance.",
) )
async def settle_hold_invoice(payment: Payment, preimage: str) -> InvoiceResponse:
if verify_preimage(preimage, payment.payment_hash) is False:
raise InvoiceError("Invalid preimage.", status="failed")
funding_source = get_funding_source()
response = await funding_source.settle_hold_invoice(preimage=preimage)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.preimage = preimage
payment.extra["hold_invoice_settled"] = True
await update_payment(payment)
return response
async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
funding_source = get_funding_source()
response = await funding_source.cancel_hold_invoice(
payment_hash=payment.payment_hash
)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.status = PaymentState.FAILED
payment.extra["hold_invoice_cancelled"] = True
await update_payment(payment)
return response

View file

@ -661,7 +661,12 @@
:readonly="receive.lnurl && receive.lnurl.fixed" :readonly="receive.lnurl && receive.lnurl.fixed"
></q-input> ></q-input>
{% endif %} {% endif %}
<q-input
filled
dense
v-model="receive.data.payment_hash"
:label="$t('hold_invoice_payment_hash')"
></q-input>
<q-input <q-input
filled filled
dense dense

View file

@ -1,5 +1,6 @@
import json import json
import ssl import ssl
from hashlib import sha256
from http import HTTPStatus from http import HTTPStatus
from math import ceil from math import ceil
from typing import Optional from typing import Optional
@ -22,6 +23,7 @@ from lnbits.core.crud.payments import (
get_wallets_stats, get_wallets_stats,
) )
from lnbits.core.models import ( from lnbits.core.models import (
CancelInvoice,
CreateInvoice, CreateInvoice,
CreateLnurl, CreateLnurl,
DecodePayment, DecodePayment,
@ -34,6 +36,7 @@ from lnbits.core.models import (
PaymentFilters, PaymentFilters,
PaymentHistoryPoint, PaymentHistoryPoint,
PaymentWalletStats, PaymentWalletStats,
SettleInvoice,
) )
from lnbits.core.models.users import User from lnbits.core.models.users import User
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
@ -52,6 +55,7 @@ from lnbits.helpers import (
from lnbits.lnurl import decode as lnurl_decode from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from lnbits.wallets.base import InvoiceResponse
from ..crud import ( from ..crud import (
DateTrunc, DateTrunc,
@ -62,10 +66,12 @@ from ..crud import (
get_wallet_for_key, get_wallet_for_key,
) )
from ..services import ( from ..services import (
cancel_hold_invoice,
create_payment_request, create_payment_request,
fee_reserve_total, fee_reserve_total,
get_payments_daily_stats, get_payments_daily_stats,
pay_invoice, pay_invoice,
settle_hold_invoice,
update_pending_payment, update_pending_payment,
update_pending_payments, update_pending_payments,
) )
@ -476,3 +482,34 @@ async def api_payment_pay_with_nfc(
except Exception as e: except Exception as e:
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"}) return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
@payment_router.post("/settle")
async def api_payments_settle(
data: SettleInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> InvoiceResponse:
payment_hash = sha256(bytes.fromhex(data.preimage)).hexdigest()
payment = await get_standalone_payment(
payment_hash, incoming=True, wallet_id=key_type.wallet.id
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Payment does not exist or does not belong to this wallet.",
)
return await settle_hold_invoice(payment, data.preimage)
@payment_router.post("/cancel")
async def api_payments_cancel(
data: CancelInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> InvoiceResponse:
payment = await get_standalone_payment(
data.payment_hash, incoming=True, wallet_id=key_type.wallet.id
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Payment does not exist or does not belong to this wallet.",
)
return await cancel_hold_invoice(payment)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -176,7 +176,17 @@ window.localisation.en = {
extension_required_lnbits_version: 'This release requires LNbits version', extension_required_lnbits_version: 'This release requires LNbits version',
min_version: 'Minimum (included)', min_version: 'Minimum (included)',
max_version: 'Maximum (excluded)', max_version: 'Maximum (excluded)',
preimage: 'Preimage',
preimage_hint: 'Preimage to settle the hold invoice',
hold_invoice: 'Hold Invoice',
hold_invoice_description:
'This invoice is on hold and requires a preimage to settle.',
payment_hash: 'Payment Hash', payment_hash: 'Payment Hash',
invoice_cancelled: 'Invoice Cancelled',
invoice_settled: 'Invoice Settled',
hold_invoice_payment_hash: 'Payment hash for hold invoice (optional)',
settle_invoice: 'Settle Invoice',
cancel_invoice: 'Cancel Invoice',
fee: 'Fee', fee: 'Fee',
amount: 'Amount', amount: 'Amount',
amount_limits: 'Amount Limits', amount_limits: 'Amount Limits',

View file

@ -21,7 +21,8 @@ window.LNbits = {
unit = 'sat', unit = 'sat',
lnurlCallback = null, lnurlCallback = null,
fiatProvider = null, fiatProvider = null,
internalMemo = null internalMemo = null,
payment_hash = null
) { ) {
const data = { const data = {
out: false, out: false,
@ -29,7 +30,8 @@ window.LNbits = {
memo: memo, memo: memo,
unit: unit, unit: unit,
lnurl_callback: lnurlCallback, lnurl_callback: lnurlCallback,
fiat_provider: fiatProvider fiat_provider: fiatProvider,
payment_hash: payment_hash
} }
if (internalMemo) { if (internalMemo) {
data.extra = { data.extra = {
@ -80,6 +82,16 @@ window.LNbits = {
data data
) )
}, },
cancelInvoice(wallet, paymentHash) {
return this.request('post', '/api/v1/payments/cancel', wallet.adminkey, {
payment_hash: paymentHash
})
},
settleInvoice(wallet, preimage) {
return this.request('post', `/api/v1/payments/settle`, wallet.adminkey, {
preimage: preimage
})
},
authLnurl(wallet, callback) { authLnurl(wallet, callback) {
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, { return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
callback callback

View file

@ -118,7 +118,13 @@ window.app.component('payment-list', {
field: row => row.extra.wallet_fiat_amount field: row => row.extra.wallet_fiat_amount
} }
], ],
preimage: null,
loading: false loading: false
},
hodlInvoice: {
show: false,
payment: null,
preimage: null
} }
} }
}, },
@ -223,6 +229,35 @@ window.app.component('payment-list', {
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
showHoldInvoiceDialog(payment) {
this.hodlInvoice.show = true
this.hodlInvoice.preimage = ''
this.hodlInvoice.payment = payment
},
cancelHoldInvoice(payment_hash) {
LNbits.api
.cancelInvoice(this.g.wallet, payment_hash)
.then(() => {
this.update = !this.update
Quasar.Notify.create({
type: 'positive',
message: this.$t('invoice_cancelled')
})
})
.catch(LNbits.utils.notifyApiError)
},
settleHoldInvoice(preimage) {
LNbits.api
.settleInvoice(this.g.wallet, preimage)
.then(() => {
this.update = !this.update
Quasar.Notify.create({
type: 'positive',
message: this.$t('invoice_settled')
})
})
.catch(LNbits.utils.notifyApiError)
},
paymentTableRowKey(row) { paymentTableRowKey(row) {
return row.payment_hash + row.amount return row.payment_hash + row.amount
}, },

View file

@ -40,7 +40,8 @@ window.WalletPageLogic = {
data: { data: {
amount: null, amount: null,
memo: '', memo: '',
internalMemo: null internalMemo: null,
payment_hash: null
} }
}, },
invoiceQrCode: '', invoiceQrCode: '',
@ -200,6 +201,7 @@ window.WalletPageLogic = {
this.receive.data.amount = null this.receive.data.amount = null
this.receive.data.memo = null this.receive.data.memo = null
this.receive.data.internalMemo = null this.receive.data.internalMemo = null
this.receive.data.payment_hash = null
this.receive.unit = this.isFiatPriority this.receive.unit = this.isFiatPriority
? this.g.wallet.currency || 'sat' ? this.g.wallet.currency || 'sat'
: 'sat' : 'sat'
@ -258,7 +260,8 @@ window.WalletPageLogic = {
this.receive.unit, this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback, this.receive.lnurl && this.receive.lnurl.callback,
this.receive.fiatProvider, this.receive.fiatProvider,
this.receive.data.internalMemo this.receive.data.internalMemo,
this.receive.data.payment_hash
) )
.then(response => { .then(response => {
this.g.updatePayments = !this.g.updatePayments this.g.updatePayments = !this.g.updatePayments

View file

@ -880,6 +880,19 @@
:props="props" :props="props"
style="white-space: normal; word-break: break-all" style="white-space: normal; word-break: break-all"
> >
<q-icon
v-if="
props.row.isIn &&
props.row.isPending &&
props.row.extra.hold_invoice
"
name="pause_presentation"
color="grey"
class="cursor-pointer q-mr-sm"
@click="showHoldInvoiceDialog(props.row)"
>
<q-tooltip><span v-text="$t('hold_invoice')"></span></q-tooltip>
</q-icon>
<q-badge <q-badge
v-if="props.row.tag" v-if="props.row.tag"
color="yellow" color="yellow"
@ -941,7 +954,7 @@
</q-td> </q-td>
<q-dialog v-model="props.expand" :props="props" position="top"> <q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-sm q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-sm q-pt-xl lnbits__dialog-card">
<q-card-section class=""> <q-card-section>
<q-list bordered separator> <q-list bordered separator>
<q-expansion-item <q-expansion-item
expand-separator expand-separator
@ -1004,6 +1017,7 @@
></lnbits-payment-details> ></lnbits-payment-details>
</q-expansion-item> </q-expansion-item>
</q-list> </q-list>
<div <div
v-if="props.row.isIn && props.row.isPending && props.row.bolt11" v-if="props.row.isIn && props.row.isPending && props.row.bolt11"
class="text-center q-my-lg" class="text-center q-my-lg"
@ -1060,6 +1074,55 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="hodlInvoice.show" position="top">
<q-card class="q-pa-sm q-pt-xl lnbits__dialog-card">
<q-card-section>
<q-item-label class="text-h6">
<span v-text="$t('hold_invoice')"></span>
</q-item-label>
<q-item-label class="text-subtitle2">
<span v-text="$t('hold_invoice_description')"></span>
</q-item-label>
</q-card-section>
<q-card-section>
<q-input
filled
:label="$t('preimage')"
:hint="$t('preimage_hint')"
v-model="hodlInvoice.preimage"
dense
autofocus
@keyup.enter="settleHoldInvoice(hodlInvoice.preimage)"
>
</q-input>
</q-card-section>
<q-card-section class="row q-gutter-x-sm">
<q-btn
@click="settleHoldInvoice(hodlInvoice.preimage)"
outline
v-close-popup
color="grey"
:label="$t('settle_invoice')"
>
</q-btn>
<q-btn
v-close-popup
outline
color="grey"
class="q-ml-sm"
@click="cancelHoldInvoice(hodlInvoice.payment.payment_hash)"
:label="$t('cancel_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</q-card-section>
</q-card>
</q-dialog>
</q-tr> </q-tr>
</template> </template>
</q-table> </q-table>

View file

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, NamedTuple
from loguru import logger from loguru import logger
from lnbits.exceptions import InvoiceError
from lnbits.settings import settings from lnbits.settings import settings
if TYPE_CHECKING: if TYPE_CHECKING:
@ -152,6 +153,29 @@ class Wallet(ABC):
) -> Coroutine[None, None, PaymentStatus]: ) -> Coroutine[None, None, PaymentStatus]:
pass pass
async def create_hold_invoice(
self,
amount: int,
payment_hash: str,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
raise InvoiceError(
message="Hold invoices are not supported by this wallet.", status="failed"
)
async def settle_hold_invoice(self, preimage: str) -> InvoiceResponse:
raise InvoiceError(
message="Hold invoices are not supported by this wallet.", status="failed"
)
async def cancel_hold_invoice(self, payment_hash: str) -> InvoiceResponse:
raise InvoiceError(
message="Hold invoices are not supported by this wallet.", status="failed"
)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running: while settings.lnbits_running:
for invoice in self.pending_invoices: for invoice in self.pending_invoices:

View file

@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: invoices.proto
# Protobuf Python Version: 5.28.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
5,
28,
1,
'',
'invoices.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as lightning__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0einvoices.proto\x12\x0binvoicesrpc\x1a\x0flightning.proto\"(\n\x10\x43\x61ncelInvoiceMsg\x12\x14\n\x0cpayment_hash\x18\x01 \x01(\x0c\"\x13\n\x11\x43\x61ncelInvoiceResp\"\xe4\x01\n\x15\x41\x64\x64HoldInvoiceRequest\x12\x0c\n\x04memo\x18\x01 \x01(\t\x12\x0c\n\x04hash\x18\x02 \x01(\x0c\x12\r\n\x05value\x18\x03 \x01(\x03\x12\x12\n\nvalue_msat\x18\n \x01(\x03\x12\x18\n\x10\x64\x65scription_hash\x18\x04 \x01(\x0c\x12\x0e\n\x06\x65xpiry\x18\x05 \x01(\x03\x12\x15\n\rfallback_addr\x18\x06 \x01(\t\x12\x13\n\x0b\x63ltv_expiry\x18\x07 \x01(\x04\x12%\n\x0broute_hints\x18\x08 \x03(\x0b\x32\x10.lnrpc.RouteHint\x12\x0f\n\x07private\x18\t \x01(\x08\"V\n\x12\x41\x64\x64HoldInvoiceResp\x12\x17\n\x0fpayment_request\x18\x01 \x01(\t\x12\x11\n\tadd_index\x18\x02 \x01(\x04\x12\x14\n\x0cpayment_addr\x18\x03 \x01(\x0c\"$\n\x10SettleInvoiceMsg\x12\x10\n\x08preimage\x18\x01 \x01(\x0c\"\x13\n\x11SettleInvoiceResp\"5\n\x1dSubscribeSingleInvoiceRequest\x12\x0e\n\x06r_hash\x18\x02 \x01(\x0cJ\x04\x08\x01\x10\x02\"\x99\x01\n\x10LookupInvoiceMsg\x12\x16\n\x0cpayment_hash\x18\x01 \x01(\x0cH\x00\x12\x16\n\x0cpayment_addr\x18\x02 \x01(\x0cH\x00\x12\x10\n\x06set_id\x18\x03 \x01(\x0cH\x00\x12\x34\n\x0flookup_modifier\x18\x04 \x01(\x0e\x32\x1b.invoicesrpc.LookupModifierB\r\n\x0binvoice_ref\".\n\nCircuitKey\x12\x0f\n\x07\x63han_id\x18\x01 \x01(\x04\x12\x0f\n\x07htlc_id\x18\x02 \x01(\x04\"\xdd\x02\n\x11HtlcModifyRequest\x12\x1f\n\x07invoice\x18\x01 \x01(\x0b\x32\x0e.lnrpc.Invoice\x12\x36\n\x15\x65xit_htlc_circuit_key\x18\x02 \x01(\x0b\x32\x17.invoicesrpc.CircuitKey\x12\x15\n\rexit_htlc_amt\x18\x03 \x01(\x04\x12\x18\n\x10\x65xit_htlc_expiry\x18\x04 \x01(\r\x12\x16\n\x0e\x63urrent_height\x18\x05 \x01(\r\x12\x64\n\x1d\x65xit_htlc_wire_custom_records\x18\x06 \x03(\x0b\x32=.invoicesrpc.HtlcModifyRequest.ExitHtlcWireCustomRecordsEntry\x1a@\n\x1e\x45xitHtlcWireCustomRecordsEntry\x12\x0b\n\x03key\x18\x01 \x01(\x04\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"z\n\x12HtlcModifyResponse\x12,\n\x0b\x63ircuit_key\x18\x01 \x01(\x0b\x32\x17.invoicesrpc.CircuitKey\x12\x15\n\x08\x61mt_paid\x18\x02 \x01(\x04H\x00\x88\x01\x01\x12\x12\n\ncancel_set\x18\x03 \x01(\x08\x42\x0b\n\t_amt_paid*D\n\x0eLookupModifier\x12\x0b\n\x07\x44\x45\x46\x41ULT\x10\x00\x12\x11\n\rHTLC_SET_ONLY\x10\x01\x12\x12\n\x0eHTLC_SET_BLANK\x10\x02\x32\xf0\x03\n\x08Invoices\x12V\n\x16SubscribeSingleInvoice\x12*.invoicesrpc.SubscribeSingleInvoiceRequest\x1a\x0e.lnrpc.Invoice0\x01\x12N\n\rCancelInvoice\x12\x1d.invoicesrpc.CancelInvoiceMsg\x1a\x1e.invoicesrpc.CancelInvoiceResp\x12U\n\x0e\x41\x64\x64HoldInvoice\x12\".invoicesrpc.AddHoldInvoiceRequest\x1a\x1f.invoicesrpc.AddHoldInvoiceResp\x12N\n\rSettleInvoice\x12\x1d.invoicesrpc.SettleInvoiceMsg\x1a\x1e.invoicesrpc.SettleInvoiceResp\x12@\n\x0fLookupInvoiceV2\x12\x1d.invoicesrpc.LookupInvoiceMsg\x1a\x0e.lnrpc.Invoice\x12S\n\x0cHtlcModifier\x12\x1f.invoicesrpc.HtlcModifyResponse\x1a\x1e.invoicesrpc.HtlcModifyRequest(\x01\x30\x01\x42\x33Z1github.com/lightningnetwork/lnd/lnrpc/invoicesrpcb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'invoices_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._loaded_options = None
_globals['DESCRIPTOR']._serialized_options = b'Z1github.com/lightningnetwork/lnd/lnrpc/invoicesrpc'
_globals['_HTLCMODIFYREQUEST_EXITHTLCWIRECUSTOMRECORDSENTRY']._loaded_options = None
_globals['_HTLCMODIFYREQUEST_EXITHTLCWIRECUSTOMRECORDSENTRY']._serialized_options = b'8\001'
_globals['_LOOKUPMODIFIER']._serialized_start=1224
_globals['_LOOKUPMODIFIER']._serialized_end=1292
_globals['_CANCELINVOICEMSG']._serialized_start=48
_globals['_CANCELINVOICEMSG']._serialized_end=88
_globals['_CANCELINVOICERESP']._serialized_start=90
_globals['_CANCELINVOICERESP']._serialized_end=109
_globals['_ADDHOLDINVOICEREQUEST']._serialized_start=112
_globals['_ADDHOLDINVOICEREQUEST']._serialized_end=340
_globals['_ADDHOLDINVOICERESP']._serialized_start=342
_globals['_ADDHOLDINVOICERESP']._serialized_end=428
_globals['_SETTLEINVOICEMSG']._serialized_start=430
_globals['_SETTLEINVOICEMSG']._serialized_end=466
_globals['_SETTLEINVOICERESP']._serialized_start=468
_globals['_SETTLEINVOICERESP']._serialized_end=487
_globals['_SUBSCRIBESINGLEINVOICEREQUEST']._serialized_start=489
_globals['_SUBSCRIBESINGLEINVOICEREQUEST']._serialized_end=542
_globals['_LOOKUPINVOICEMSG']._serialized_start=545
_globals['_LOOKUPINVOICEMSG']._serialized_end=698
_globals['_CIRCUITKEY']._serialized_start=700
_globals['_CIRCUITKEY']._serialized_end=746
_globals['_HTLCMODIFYREQUEST']._serialized_start=749
_globals['_HTLCMODIFYREQUEST']._serialized_end=1098
_globals['_HTLCMODIFYREQUEST_EXITHTLCWIRECUSTOMRECORDSENTRY']._serialized_start=1034
_globals['_HTLCMODIFYREQUEST_EXITHTLCWIRECUSTOMRECORDSENTRY']._serialized_end=1098
_globals['_HTLCMODIFYRESPONSE']._serialized_start=1100
_globals['_HTLCMODIFYRESPONSE']._serialized_end=1222
_globals['_INVOICES']._serialized_start=1295
_globals['_INVOICES']._serialized_end=1791
# @@protoc_insertion_point(module_scope)

View file

@ -0,0 +1,392 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
import lnbits.wallets.lnd_grpc_files.invoices_pb2 as invoices__pb2
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as lightning__pb2
GRPC_GENERATED_VERSION = '1.68.1'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ f' but the generated code in invoices_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class InvoicesStub(object):
"""
Comments in this file will be directly parsed into the API
Documentation as descriptions of the associated method, message, or field.
These descriptions should go right above the definition of the object, and
can be in either block or // comment format.
An RPC method can be matched to an lncli command by placing a line in the
beginning of the description in exactly the following format:
lncli: `methodname`
Failure to specify the exact name of the command will cause documentation
generation to fail.
More information on how exactly the gRPC documentation is generated from
this proto file can be found here:
https://github.com/lightninglabs/lightning-api
Invoices is a service that can be used to create, accept, settle and cancel
invoices.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.SubscribeSingleInvoice = channel.unary_stream(
'/invoicesrpc.Invoices/SubscribeSingleInvoice',
request_serializer=invoices__pb2.SubscribeSingleInvoiceRequest.SerializeToString,
response_deserializer=lightning__pb2.Invoice.FromString,
_registered_method=True)
self.CancelInvoice = channel.unary_unary(
'/invoicesrpc.Invoices/CancelInvoice',
request_serializer=invoices__pb2.CancelInvoiceMsg.SerializeToString,
response_deserializer=invoices__pb2.CancelInvoiceResp.FromString,
_registered_method=True)
self.AddHoldInvoice = channel.unary_unary(
'/invoicesrpc.Invoices/AddHoldInvoice',
request_serializer=invoices__pb2.AddHoldInvoiceRequest.SerializeToString,
response_deserializer=invoices__pb2.AddHoldInvoiceResp.FromString,
_registered_method=True)
self.SettleInvoice = channel.unary_unary(
'/invoicesrpc.Invoices/SettleInvoice',
request_serializer=invoices__pb2.SettleInvoiceMsg.SerializeToString,
response_deserializer=invoices__pb2.SettleInvoiceResp.FromString,
_registered_method=True)
self.LookupInvoiceV2 = channel.unary_unary(
'/invoicesrpc.Invoices/LookupInvoiceV2',
request_serializer=invoices__pb2.LookupInvoiceMsg.SerializeToString,
response_deserializer=lightning__pb2.Invoice.FromString,
_registered_method=True)
self.HtlcModifier = channel.stream_stream(
'/invoicesrpc.Invoices/HtlcModifier',
request_serializer=invoices__pb2.HtlcModifyResponse.SerializeToString,
response_deserializer=invoices__pb2.HtlcModifyRequest.FromString,
_registered_method=True)
class InvoicesServicer(object):
"""
Comments in this file will be directly parsed into the API
Documentation as descriptions of the associated method, message, or field.
These descriptions should go right above the definition of the object, and
can be in either block or // comment format.
An RPC method can be matched to an lncli command by placing a line in the
beginning of the description in exactly the following format:
lncli: `methodname`
Failure to specify the exact name of the command will cause documentation
generation to fail.
More information on how exactly the gRPC documentation is generated from
this proto file can be found here:
https://github.com/lightninglabs/lightning-api
Invoices is a service that can be used to create, accept, settle and cancel
invoices.
"""
def SubscribeSingleInvoice(self, request, context):
"""
SubscribeSingleInvoice returns a uni-directional stream (server -> client)
to notify the client of state transitions of the specified invoice.
Initially the current invoice state is always sent out.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def CancelInvoice(self, request, context):
"""lncli: `cancelinvoice`
CancelInvoice cancels a currently open invoice. If the invoice is already
canceled, this call will succeed. If the invoice is already settled, it will
fail.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def AddHoldInvoice(self, request, context):
"""lncli: `addholdinvoice`
AddHoldInvoice creates a hold invoice. It ties the invoice to the hash
supplied in the request.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def SettleInvoice(self, request, context):
"""lncli: `settleinvoice`
SettleInvoice settles an accepted invoice. If the invoice is already
settled, this call will succeed.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def LookupInvoiceV2(self, request, context):
"""
LookupInvoiceV2 attempts to look up at invoice. An invoice can be referenced
using either its payment hash, payment address, or set ID.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def HtlcModifier(self, request_iterator, context):
"""
HtlcModifier is a bidirectional streaming RPC that allows a client to
intercept and modify the HTLCs that attempt to settle the given invoice. The
server will send HTLCs of invoices to the client and the client can modify
some aspects of the HTLC in order to pass the invoice acceptance tests.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_InvoicesServicer_to_server(servicer, server):
rpc_method_handlers = {
'SubscribeSingleInvoice': grpc.unary_stream_rpc_method_handler(
servicer.SubscribeSingleInvoice,
request_deserializer=invoices__pb2.SubscribeSingleInvoiceRequest.FromString,
response_serializer=lightning__pb2.Invoice.SerializeToString,
),
'CancelInvoice': grpc.unary_unary_rpc_method_handler(
servicer.CancelInvoice,
request_deserializer=invoices__pb2.CancelInvoiceMsg.FromString,
response_serializer=invoices__pb2.CancelInvoiceResp.SerializeToString,
),
'AddHoldInvoice': grpc.unary_unary_rpc_method_handler(
servicer.AddHoldInvoice,
request_deserializer=invoices__pb2.AddHoldInvoiceRequest.FromString,
response_serializer=invoices__pb2.AddHoldInvoiceResp.SerializeToString,
),
'SettleInvoice': grpc.unary_unary_rpc_method_handler(
servicer.SettleInvoice,
request_deserializer=invoices__pb2.SettleInvoiceMsg.FromString,
response_serializer=invoices__pb2.SettleInvoiceResp.SerializeToString,
),
'LookupInvoiceV2': grpc.unary_unary_rpc_method_handler(
servicer.LookupInvoiceV2,
request_deserializer=invoices__pb2.LookupInvoiceMsg.FromString,
response_serializer=lightning__pb2.Invoice.SerializeToString,
),
'HtlcModifier': grpc.stream_stream_rpc_method_handler(
servicer.HtlcModifier,
request_deserializer=invoices__pb2.HtlcModifyResponse.FromString,
response_serializer=invoices__pb2.HtlcModifyRequest.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'invoicesrpc.Invoices', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('invoicesrpc.Invoices', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class Invoices(object):
"""
Comments in this file will be directly parsed into the API
Documentation as descriptions of the associated method, message, or field.
These descriptions should go right above the definition of the object, and
can be in either block or // comment format.
An RPC method can be matched to an lncli command by placing a line in the
beginning of the description in exactly the following format:
lncli: `methodname`
Failure to specify the exact name of the command will cause documentation
generation to fail.
More information on how exactly the gRPC documentation is generated from
this proto file can be found here:
https://github.com/lightninglabs/lightning-api
Invoices is a service that can be used to create, accept, settle and cancel
invoices.
"""
@staticmethod
def SubscribeSingleInvoice(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/invoicesrpc.Invoices/SubscribeSingleInvoice',
invoices__pb2.SubscribeSingleInvoiceRequest.SerializeToString,
lightning__pb2.Invoice.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def CancelInvoice(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/invoicesrpc.Invoices/CancelInvoice',
invoices__pb2.CancelInvoiceMsg.SerializeToString,
invoices__pb2.CancelInvoiceResp.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def AddHoldInvoice(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/invoicesrpc.Invoices/AddHoldInvoice',
invoices__pb2.AddHoldInvoiceRequest.SerializeToString,
invoices__pb2.AddHoldInvoiceResp.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def SettleInvoice(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/invoicesrpc.Invoices/SettleInvoice',
invoices__pb2.SettleInvoiceMsg.SerializeToString,
invoices__pb2.SettleInvoiceResp.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def LookupInvoiceV2(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/invoicesrpc.Invoices/LookupInvoiceV2',
invoices__pb2.LookupInvoiceMsg.SerializeToString,
lightning__pb2.Invoice.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def HtlcModifier(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(
request_iterator,
target,
'/invoicesrpc.Invoices/HtlcModifier',
invoices__pb2.HtlcModifyResponse.SerializeToString,
invoices__pb2.HtlcModifyRequest.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -8,13 +8,15 @@ from typing import Optional
import grpc import grpc
from loguru import logger from loguru import logger
import lnbits.wallets.lnd_grpc_files.invoices_pb2 as invoices
import lnbits.wallets.lnd_grpc_files.invoices_pb2_grpc as invoicesrpc
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln
import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
import lnbits.wallets.lnd_grpc_files.router_pb2 as router import lnbits.wallets.lnd_grpc_files.router_pb2 as router
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
from lnbits.helpers import normalize_endpoint from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash from lnbits.utils.crypto import random_secret_and_hash
from lnbits.wallets.lnd_grpc_files.router_pb2_grpc import RouterStub
from .base import ( from .base import (
InvoiceResponse, InvoiceResponse,
@ -98,7 +100,8 @@ class LndWallet(Wallet):
f"{self.endpoint}:{self.port}", composite_creds f"{self.endpoint}:{self.port}", composite_creds
) )
self.rpc = lnrpc.LightningStub(channel) self.rpc = lnrpc.LightningStub(channel)
self.routerpc = routerrpc.RouterStub(channel) self.routerpc = RouterStub(channel)
self.invoicesrpc = invoicesrpc.InvoicesStub(channel)
def metadata_callback(self, _, callback): def metadata_callback(self, _, callback):
callback([("macaroon", self.macaroon)], None) callback([("macaroon", self.macaroon)], None)
@ -108,7 +111,7 @@ class LndWallet(Wallet):
async def status(self) -> StatusResponse: async def status(self) -> StatusResponse:
try: try:
resp = await self.rpc.ChannelBalance(ln.ChannelBalanceRequest()) resp = await self.rpc.ChannelBalance(ln.ChannelBalanceRequest()) # type: ignore
except Exception as exc: except Exception as exc:
return StatusResponse(f"Unable to connect, got: '{exc}'", 0) return StatusResponse(f"Unable to connect, got: '{exc}'", 0)
@ -144,7 +147,7 @@ class LndWallet(Wallet):
data["r_hash"] = bytes.fromhex(payment_hash) data["r_hash"] = bytes.fromhex(payment_hash)
data["r_preimage"] = bytes.fromhex(preimage) data["r_preimage"] = bytes.fromhex(preimage)
try: try:
req = ln.Invoice(**data) req = ln.Invoice(**data) # type: ignore
resp = await self.rpc.AddInvoice(req) resp = await self.rpc.AddInvoice(req)
# response model # response model
# { # {
@ -168,7 +171,7 @@ class LndWallet(Wallet):
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
# fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000) # fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
req = router.SendPaymentRequest( req = router.SendPaymentRequest( # type: ignore
payment_request=bolt11, payment_request=bolt11,
fee_limit_msat=fee_limit_msat, fee_limit_msat=fee_limit_msat,
timeout_seconds=30, timeout_seconds=30,
@ -227,7 +230,7 @@ class LndWallet(Wallet):
# that use different checking_id formats # that use different checking_id formats
raise ValueError raise ValueError
resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash)) resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash)) # type: ignore
if resp.settled: if resp.settled:
return PaymentSuccessStatus(preimage=resp.r_preimage.hex()) return PaymentSuccessStatus(preimage=resp.r_preimage.hex())
@ -271,7 +274,7 @@ class LndWallet(Wallet):
try: try:
resp = self.routerpc.TrackPaymentV2( resp = self.routerpc.TrackPaymentV2(
router.TrackPaymentRequest(payment_hash=r_hash) router.TrackPaymentRequest(payment_hash=r_hash) # type: ignore
) )
async for payment in resp: async for payment in resp:
if len(payment.htlcs) and statuses[payment.status]: if len(payment.htlcs) and statuses[payment.status]:
@ -288,7 +291,7 @@ class LndWallet(Wallet):
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running: while settings.lnbits_running:
try: try:
request = ln.InvoiceSubscription() request = ln.InvoiceSubscription() # type: ignore
async for i in self.rpc.SubscribeInvoices(request): async for i in self.rpc.SubscribeInvoices(request):
if not i.settled: if not i.settled:
continue continue
@ -301,3 +304,65 @@ class LndWallet(Wallet):
"retrying in 5 seconds" "retrying in 5 seconds"
) )
await asyncio.sleep(5) await asyncio.sleep(5)
async def create_hold_invoice(
self,
amount: int,
payment_hash: str,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
data: dict = {
"description_hash": b"",
"value": amount,
"hash": hex_to_bytes(payment_hash),
"private": True,
"memo": memo or "",
}
if kwargs.get("expiry"):
data["expiry"] = kwargs["expiry"]
if description_hash:
data["description_hash"] = description_hash
elif unhashed_description:
data["description_hash"] = sha256(unhashed_description).digest()
try:
req = invoices.AddHoldInvoiceRequest(**data) # type: ignore
res = await self.invoicesrpc.AddHoldInvoice(req)
logger.debug(f"AddHoldInvoice response: {res}")
except Exception as exc:
logger.warning(exc)
error_message = str(exc)
return InvoiceResponse(ok=False, error_message=error_message)
return InvoiceResponse(
ok=True, checking_id=payment_hash, payment_request=str(res.payment_request)
)
async def settle_hold_invoice(self, preimage: str) -> InvoiceResponse:
try:
req = invoices.SettleInvoiceMsg(preimage=hex_to_bytes(preimage)) # type: ignore
await self.invoicesrpc.SettleInvoice(req)
except grpc.aio.AioRpcError as exc:
return InvoiceResponse(
ok=False, error_message=exc.details() or "unknown grpc exception"
)
except Exception as exc:
logger.warning(exc)
return InvoiceResponse(ok=False, error_message=str(exc))
return InvoiceResponse(ok=True, preimage=preimage)
async def cancel_hold_invoice(self, payment_hash: str) -> InvoiceResponse:
try:
req = invoices.CancelInvoiceMsg(payment_hash=hex_to_bytes(payment_hash)) # type: ignore
res = await self.invoicesrpc.CancelInvoice(req)
logger.debug(f"CancelInvoice response: {res}")
except Exception as exc:
logger.warning(exc)
# If we cannot cancel the invoice, we return an error message
# and True for ok that should be ignored by the service
return InvoiceResponse(
ok=False, checking_id=payment_hash, error_message=str(exc)
)
# If we reach here, the invoice was successfully canceled and payment failed
return InvoiceResponse(True, checking_id=payment_hash)

View file

@ -28,7 +28,7 @@ from .macaroon import load_macaroon
class LndRestWallet(Wallet): class LndRestWallet(Wallet):
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" """https://api.lightning.community/#lnd-rest-api-reference"""
__node_cls__ = LndRestNode __node_cls__ = LndRestNode
features = [Feature.nodemanager] features = [Feature.nodemanager]
@ -322,3 +322,77 @@ class LndRestWallet(Wallet):
" seconds" " seconds"
) )
await asyncio.sleep(5) await asyncio.sleep(5)
async def create_hold_invoice(
self,
amount: int,
payment_hash: str,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**_,
) -> InvoiceResponse:
data: dict = {
"value": amount,
"private": True,
"hash": base64.b64encode(bytes.fromhex(payment_hash)).decode("ascii"),
}
if description_hash:
data["description_hash"] = base64.b64encode(description_hash).decode(
"ascii"
)
elif unhashed_description:
data["description_hash"] = base64.b64encode(
hashlib.sha256(unhashed_description).digest()
).decode("ascii")
else:
data["memo"] = memo or ""
try:
r = await self.client.post(url="/v2/invoices/hodl", json=data)
r.raise_for_status()
data = r.json()
except httpx.HTTPStatusError as exc:
logger.warning(exc)
return InvoiceResponse(ok=False, error_message=exc.response.text)
except Exception as exc:
logger.error(exc)
return InvoiceResponse(ok=False, error_message=str(exc))
payment_request = data["payment_request"]
payment_hash = base64.b64encode(bytes.fromhex(payment_hash)).decode("ascii")
return InvoiceResponse(
ok=True, checking_id=payment_hash, payment_request=payment_request
)
async def settle_hold_invoice(self, preimage: str) -> InvoiceResponse:
data: dict = {
"preimage": base64.b64encode(bytes.fromhex(preimage)).decode("ascii")
}
try:
r = await self.client.post(url="/v2/invoices/settle", json=data)
r.raise_for_status()
return InvoiceResponse(ok=True, preimage=preimage)
except httpx.HTTPStatusError as exc:
logger.warning(exc)
return InvoiceResponse(ok=False, error_message=exc.response.text)
except Exception as exc:
logger.error(exc)
return InvoiceResponse(ok=False, error_message=str(exc))
async def cancel_hold_invoice(self, payment_hash: str) -> InvoiceResponse:
rhash = bytes.fromhex(payment_hash)
try:
r = await self.client.post(
url="/v2/invoices/cancel",
json={"payment_hash": base64.b64encode(rhash).decode("ascii")},
)
r.raise_for_status()
logger.debug(f"Cancel hold invoice response: {r.text}")
return InvoiceResponse(ok=True, checking_id=payment_hash)
except httpx.HTTPStatusError as exc:
return InvoiceResponse(ok=False, error_message=exc.response.text)
except Exception as exc:
logger.error(exc)
return InvoiceResponse(ok=False, error_message=str(exc))

View file

@ -0,0 +1,118 @@
import asyncio
import pytest
from lnbits.core.models import Payment
from lnbits.core.services.payments import (
cancel_hold_invoice,
create_invoice,
get_standalone_payment,
settle_hold_invoice,
)
from lnbits.exceptions import InvoiceError
from lnbits.utils.crypto import random_secret_and_hash
from ..helpers import funding_source, is_fake
from .helpers import pay_real_invoice
@pytest.mark.anyio
@pytest.mark.skipif(is_fake, reason="this only works in regtest")
@pytest.mark.skipif(
funding_source.__class__.__name__ in ["LndRestWallet", "LndWallet"],
reason="this should not raise for lnd",
)
async def test_pay_raise_unsupported(app):
payment_hash = "0" * 32
payment = Payment(
checking_id=payment_hash,
amount=1000,
wallet_id="fake_wallet_id",
bolt11="fake_holdinvoice",
payment_hash=payment_hash,
fee=0,
)
with pytest.raises(InvoiceError):
await create_invoice(
wallet_id="fake_wallet_id",
amount=1000,
memo="fake_holdinvoice",
payment_hash=payment_hash,
)
with pytest.raises(InvoiceError):
await settle_hold_invoice(payment, payment_hash)
with pytest.raises(InvoiceError):
await cancel_hold_invoice(payment)
@pytest.mark.anyio
@pytest.mark.skipif(is_fake, reason="this only works in regtest")
@pytest.mark.skipif(
funding_source.__class__.__name__ not in ["LndRestWallet", "LndWallet"],
reason="this only works for lndrest",
)
async def test_cancel_real_hold_invoice(app, from_wallet):
_, payment_hash = random_secret_and_hash()
payment = await create_invoice(
wallet_id=from_wallet.id,
amount=1000,
memo="test_cancel_holdinvoice",
payment_hash=payment_hash,
)
assert payment.amount == 1000 * 1000
assert payment.memo == "test_cancel_holdinvoice"
assert payment.status == "pending"
assert payment.wallet_id == from_wallet.id
payment = await cancel_hold_invoice(payment=payment)
assert payment.ok is True
updated_payment = await get_standalone_payment(payment_hash, incoming=True)
assert updated_payment
assert updated_payment.status == "failed"
@pytest.mark.anyio
@pytest.mark.skipif(is_fake, reason="this only works in regtest")
@pytest.mark.skipif(
funding_source.__class__.__name__ not in ["LndRestWallet", "LndWallet"],
reason="this only works for lndrest",
)
async def test_settle_real_hold_invoice(app, from_wallet):
preimage, payment_hash = random_secret_and_hash()
payment = await create_invoice(
wallet_id=from_wallet.id,
amount=1000,
memo="test_settle_holdinvoice",
payment_hash=payment_hash,
)
assert payment.amount == 1000 * 1000
assert payment.memo == "test_settle_holdinvoice"
assert payment.status == "pending"
assert payment.wallet_id == from_wallet.id
# invoice should still be open
with pytest.raises(InvoiceError):
await settle_hold_invoice(payment=payment, preimage=preimage)
def pay_invoice():
pay_real_invoice(payment.bolt11)
async def settle():
await asyncio.sleep(1)
invoice_response = await settle_hold_invoice(payment=payment, preimage=preimage)
assert invoice_response.ok is True
coro = asyncio.to_thread(pay_invoice)
task = asyncio.create_task(coro)
await asyncio.gather(task, settle())
await asyncio.sleep(1)
updated_payment = await get_standalone_payment(payment_hash, incoming=True)
assert updated_payment
assert updated_payment.status == "success"