lnbits-legend/lnbits/wallets/boltz.py

228 lines
8.7 KiB
Python
Raw Normal View History

import asyncio
from typing import AsyncGenerator, Optional
from bolt11.decode import decode
from grpc.aio import AioRpcError
from loguru import logger
from lnbits.settings import settings
from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2, boltzrpc_pb2_grpc
from lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc import grpc
from lnbits.wallets.macaroon.macaroon import load_macaroon
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
Wallet,
)
class BoltzWallet(Wallet):
"""
Utilizing Boltz Client gRPC interface
gRPC Bindings can be updated by running lnbits/wallets/boltz_grpc_files/update.sh
"""
async def cleanup(self):
logger.warning("Cleaning up BoltzWallet...")
def __init__(self):
if not settings.boltz_client_endpoint:
raise ValueError(
"cannot initialize BoltzWallet: missing boltz_client_endpoint"
)
if not settings.boltz_client_wallet:
raise ValueError(
"cannot initialize BoltzWallet: missing boltz_client_wallet"
)
self.endpoint = self.normalize_endpoint(
settings.boltz_client_endpoint, add_proto=True
)
if settings.boltz_client_macaroon:
self.metadata = [
("macaroon", load_macaroon(settings.boltz_client_macaroon))
]
else:
self.metadata = None
if settings.boltz_client_cert:
cert = open(settings.boltz_client_cert, "rb").read()
creds = grpc.ssl_channel_credentials(cert)
channel = grpc.aio.secure_channel(settings.boltz_client_endpoint, creds)
else:
channel = grpc.aio.insecure_channel(settings.boltz_client_endpoint)
self.rpc = boltzrpc_pb2_grpc.BoltzStub(channel)
self.wallet_id: int = 0
async def status(self) -> StatusResponse:
try:
request = boltzrpc_pb2.GetWalletRequest(name=settings.boltz_client_wallet)
response: boltzrpc_pb2.Wallet = await self.rpc.GetWallet(
request, metadata=self.metadata
)
except AioRpcError as exc:
return StatusResponse(
exc.details()
+ " make sure you have macaroon and certificate configured, unless your client runs without", # noqa: E501
0,
)
self.wallet_id = response.id
return StatusResponse(None, response.balance.total * 1000)
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**_,
) -> InvoiceResponse:
pair = boltzrpc_pb2.Pair(to=boltzrpc_pb2.LBTC)
request = boltzrpc_pb2.CreateReverseSwapRequest(
amount=amount,
pair=pair,
wallet_id=self.wallet_id,
accept_zero_conf=True,
external_pay=True,
)
if memo is not None:
# boltz rejects nbsp char (produced by JS Intl.NumberFormat api)
request.description = memo.replace("\xa0", " ")
response: boltzrpc_pb2.CreateReverseSwapResponse
try:
response = await self.rpc.CreateReverseSwap(request, metadata=self.metadata)
except AioRpcError as exc:
return InvoiceResponse(ok=False, error_message=exc.details())
return InvoiceResponse(
ok=True, checking_id=response.id, payment_request=response.invoice
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC})
try:
pair_info: boltzrpc_pb2.PairInfo
pair_request = boltzrpc_pb2.GetPairInfoRequest(
type=boltzrpc_pb2.SUBMARINE, pair=pair
)
pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata)
invoice = decode(bolt11)
assert invoice.amount_msat, "amountless invoice"
service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100
estimate = service_fee + pair_info.fees.miner_fees * 1000
if estimate > fee_limit_msat:
error = f"fee of {estimate} msat exceeds limit of {fee_limit_msat} msat"
return PaymentResponse(ok=False, error_message=error)
request = boltzrpc_pb2.CreateSwapRequest(
invoice=bolt11,
pair=pair,
wallet_id=self.wallet_id,
zero_conf=True,
send_from_internal=True,
)
response: boltzrpc_pb2.CreateSwapResponse
response = await self.rpc.CreateSwap(request, metadata=self.metadata)
# empty swap id means that the invoice included a magic routing hint and was
# paid on the liquid network directly
# docs: https://docs.boltz.exchange/api/magic-routing-hints
if response.id == "":
# note that there is no way to provide a checking id here,
# but there is no need since it immediately is considered as successfull
return PaymentResponse(
ok=True,
checking_id=response.id,
)
except AioRpcError as exc:
return PaymentResponse(ok=False, error_message=exc.details())
try:
info_request = boltzrpc_pb2.GetSwapInfoRequest(id=response.id)
info: boltzrpc_pb2.GetSwapInfoResponse
async for info in self.rpc.GetSwapInfoStream(
info_request, metadata=self.metadata
):
if info.swap.state == boltzrpc_pb2.SUCCESSFUL:
return PaymentResponse(
ok=True,
checking_id=response.id,
fee_msat=(info.swap.onchain_fee + info.swap.service_fee) * 1000,
preimage=info.swap.preimage,
)
elif info.swap.error != "":
return PaymentResponse(ok=False, error_message=info.swap.error)
return PaymentResponse(
ok=False, error_message="stream stopped unexpectedly"
)
except AioRpcError as exc:
return PaymentResponse(ok=False, error_message=exc.details())
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata
)
swap = response.reverse_swap
except AioRpcError:
return PaymentPendingStatus()
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
return PaymentSuccessStatus(
fee_msat=(
(swap.service_fee + swap.onchain_fee) * 1000 + swap.routing_fee_msat
),
preimage=swap.preimage,
)
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
return PaymentPendingStatus()
return PaymentFailedStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata
)
swap = response.swap
except AioRpcError:
return PaymentPendingStatus()
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
return PaymentSuccessStatus(
fee_msat=(swap.service_fee + swap.onchain_fee) * 1000,
preimage=swap.preimage,
)
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
return PaymentPendingStatus()
return PaymentFailedStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
try:
request = boltzrpc_pb2.GetSwapInfoRequest()
info: boltzrpc_pb2.GetSwapInfoResponse
async for info in self.rpc.GetSwapInfoStream(
request, metadata=self.metadata
):
reverse = info.reverse_swap
if reverse and reverse.state == boltzrpc_pb2.SUCCESSFUL:
yield reverse.id
except Exception as exc:
logger.error(
f"lost connection to boltz client swap stream: '{exc}', retrying in"
" 5 seconds"
)
await asyncio.sleep(5)