feat: add Breez SDK wallet (#1897)

* add Breez SDK wallet
* use more description status classes
* fix: add try-except

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
callebtc 2024-08-06 10:06:21 +02:00 committed by GitHub
parent 235f8a6c19
commit 0015314e11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 390 additions and 4 deletions

View file

@ -28,7 +28,7 @@ PORT=5000
###################################### ######################################
# which fundingsources are allowed in the admin ui # which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet" LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, BreezSdkWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities, # VoidWallet is just a fallback that works without any actual Lightning capabilities,
@ -117,6 +117,14 @@ ECLAIR_PASS=eclairpw
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips LNTIPS_API_ENDPOINT=https://ln.tips
# BreezSdkWallet
BREEZ_API_KEY=KEY
BREEZ_GREENLIGHT_SEED=SEED
# A Greenlight invite code or Greenlight partner certificate/key can be used
BREEZ_GREENLIGHT_INVITE_CODE=CODE
BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRING
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
###################################### ######################################
####### Auth Configurations ########## ####### Auth Configurations ##########
###################################### ######################################

View file

@ -110,6 +110,17 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/ - `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword - `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
### Breez SDK
A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
- `BREEZ_API_KEY`: ...
- `BREEZ_GREENLIGHT_SEED`: ...
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
### Cliche Wallet ### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000 - `CLICHE_ENDPOINT`: ws://127.0.0.1:12000

View file

@ -484,6 +484,32 @@
</a> </a>
</div> </div>
</div> </div>
<div class="row">
<div class="col">
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div class="col q-pl-md">
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
</div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<a <a

View file

@ -258,6 +258,14 @@ class LnTipsFundingSource(LNbitsSettings):
lntips_invoice_key: Optional[str] = Field(default=None) lntips_invoice_key: Optional[str] = Field(default=None)
class BreezSdkFundingSource(LNbitsSettings):
breez_api_key: Optional[str] = Field(default=None)
breez_greenlight_seed: Optional[str] = Field(default=None)
breez_greenlight_invite_code: Optional[str] = Field(default=None)
breez_greenlight_device_key: Optional[str] = Field(default=None)
breez_greenlight_device_cert: Optional[str] = Field(default=None)
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600) lightning_invoice_expiry: int = Field(default=3600)
@ -279,6 +287,7 @@ class FundingSourcesSettings(
OpenNodeFundingSource, OpenNodeFundingSource,
SparkFundingSource, SparkFundingSource,
LnTipsFundingSource, LnTipsFundingSource,
BreezSdkFundingSource,
): ):
lnbits_backend_wallet_class: str = Field(default="VoidWallet") lnbits_backend_wallet_class: str = Field(default="VoidWallet")
@ -423,6 +432,7 @@ class SuperUserSettings(LNbitsSettings):
default=[ default=[
"AlbyWallet", "AlbyWallet",
"BlinkWallet", "BlinkWallet",
"BreezSdkWallet",
"CoreLightningRestWallet", "CoreLightningRestWallet",
"CoreLightningWallet", "CoreLightningWallet",
"EclairWallet", "EclairWallet",

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -165,6 +165,17 @@ Vue.component('lnbits-funding-sources', {
spark_url: 'Endpoint', spark_url: 'Endpoint',
spark_token: 'Token' spark_token: 'Token'
} }
],
[
'BreezSdkWallet',
'Breez SDK',
{
breez_api_key: 'Breez API Key',
breez_greenlight_seed: 'Greenlight Seed',
breez_greenlight_device_key: 'Greenlight Device Key',
breez_greenlight_device_cert: 'Greenlight Device Cert',
breez_greenlight_invite_code: 'Greenlight Invite Code'
}
] ]
] ]
} }

View file

@ -9,6 +9,7 @@ from lnbits.wallets.base import Wallet
from .alby import AlbyWallet from .alby import AlbyWallet
from .blink import BlinkWallet from .blink import BlinkWallet
from .breez import BreezSdkWallet
from .cliche import ClicheWallet from .cliche import ClicheWallet
from .corelightning import CoreLightningWallet from .corelightning import CoreLightningWallet

281
lnbits/wallets/breez.py Normal file
View file

@ -0,0 +1,281 @@
import base64
try:
import breez_sdk # type: ignore
BREEZ_SDK_INSTALLED = True
except ImportError:
BREEZ_SDK_INSTALLED = False
if not BREEZ_SDK_INSTALLED:
class BreezSdkWallet: # pyright: ignore
def __init__(self):
raise RuntimeError(
"Breez SDK is not installed. "
"Ask admin to run `poetry install -E breez` to install it."
)
else:
import asyncio
from pathlib import Path
from typing import AsyncGenerator, Optional
from loguru import logger
from lnbits import bolt11 as lnbits_bolt11
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
UnsupportedError,
Wallet,
)
breez_event_queue: asyncio.Queue = asyncio.Queue()
def load_bytes(source: str, extension: str) -> Optional[bytes]:
# first check if it can be read from a file
if source.split(".")[-1] == extension:
with open(source, "rb") as f:
source_bytes = f.read()
return source_bytes
else:
# else check the source string can be converted from hex
try:
return bytes.fromhex(source)
except ValueError:
pass
# else convert from base64
try:
return base64.b64decode(source)
except Exception:
pass
return None
def load_greenlight_credentials() -> (
Optional[
breez_sdk.GreenlightCredentials # pyright: ignore[reportUnboundVariable]
]
):
if (
settings.breez_greenlight_device_key
and settings.breez_greenlight_device_cert
):
device_key_bytes = load_bytes(settings.breez_greenlight_device_key, "pem")
device_cert_bytes = load_bytes(settings.breez_greenlight_device_cert, "crt")
if not device_key_bytes or not device_cert_bytes:
raise ValueError(
"cannot initialize BreezSdkWallet: "
"cannot decode breez_greenlight_device_key "
"or breez_greenlight_device_cert"
)
return breez_sdk.GreenlightCredentials( # pyright: ignore[reportUnboundVariable]
developer_key=list(device_key_bytes),
developer_cert=list(device_cert_bytes),
)
return None
class SDKListener(
breez_sdk.EventListener # pyright: ignore[reportUnboundVariable]
):
def on_event(self, event):
logger.debug(event)
breez_event_queue.put_nowait(event)
class BreezSdkWallet(Wallet): # type: ignore[no-redef]
def __init__(self):
if not settings.breez_greenlight_seed:
raise ValueError(
"cannot initialize BreezSdkWallet: missing breez_greenlight_seed"
)
if not settings.breez_api_key:
raise ValueError(
"cannot initialize BreezSdkWallet: missing breez_api_key"
)
if (
settings.breez_greenlight_device_key
and not settings.breez_greenlight_device_cert
):
raise ValueError(
"cannot initialize BreezSdkWallet: "
"missing breez_greenlight_device_cert"
)
if (
settings.breez_greenlight_device_cert
and not settings.breez_greenlight_device_key
):
raise ValueError(
"cannot initialize BreezSdkWallet: "
"missing breez_greenlight_device_key"
)
self.config = breez_sdk.default_config(
breez_sdk.EnvironmentType.PRODUCTION,
settings.breez_api_key,
breez_sdk.NodeConfig.GREENLIGHT(
config=breez_sdk.GreenlightNodeConfig(
partner_credentials=load_greenlight_credentials(),
invite_code=settings.breez_greenlight_invite_code,
)
),
)
breez_sdk_working_dir = Path(settings.lnbits_data_folder, "breez-sdk")
breez_sdk_working_dir.mkdir(parents=True, exist_ok=True)
self.config.working_dir = breez_sdk_working_dir.absolute().as_posix()
try:
seed = breez_sdk.mnemonic_to_seed(settings.breez_greenlight_seed)
connect_request = breez_sdk.ConnectRequest(self.config, seed)
self.sdk_services = breez_sdk.connect(connect_request, SDKListener())
except Exception as exc:
logger.warning(exc)
raise ValueError(f"cannot initialize BreezSdkWallet: {exc!s}") from exc
async def cleanup(self):
self.sdk_services.disconnect()
async def status(self) -> StatusResponse:
try:
node_info: breez_sdk.NodeState = self.sdk_services.node_info()
except Exception as exc:
return StatusResponse(f"Failed to connect to breez, got: '{exc}...'", 0)
return StatusResponse(None, int(node_info.channels_balance_msat))
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
# if description_hash or unhashed_description:
# raise UnsupportedError("description_hash and unhashed_description")
try:
if description_hash and not unhashed_description:
raise UnsupportedError(
"'description_hash' unsupported by Greenlight, provide"
" 'unhashed_description'"
)
breez_invoice: breez_sdk.ReceivePaymentResponse = (
self.sdk_services.receive_payment(
breez_sdk.ReceivePaymentRequest(
amount * 1000, # breez uses msat
(
unhashed_description.decode()
if unhashed_description
else memo
),
preimage=kwargs.get("preimage"),
opening_fee_params=None,
use_description_hash=True if unhashed_description else None,
)
)
)
return InvoiceResponse(
True,
breez_invoice.ln_invoice.payment_hash,
breez_invoice.ln_invoice.bolt11,
None,
)
except Exception as e:
logger.warning(e)
return InvoiceResponse(False, None, None, str(e))
async def pay_invoice(
self, bolt11: str, fee_limit_msat: int
) -> PaymentResponse:
invoice = lnbits_bolt11.decode(bolt11)
try:
send_payment_request = breez_sdk.SendPaymentRequest(bolt11=bolt11)
send_payment_response: breez_sdk.SendPaymentResponse = (
self.sdk_services.send_payment(send_payment_request)
)
payment: breez_sdk.Payment = send_payment_response.payment
except Exception as exc:
logger.warning(exc)
try:
# try to report issue to Breez to improve LSP routing
self.sdk_services.report_issue(
breez_sdk.ReportIssueRequest.PAYMENT_FAILURE(
breez_sdk.ReportPaymentFailureDetails(invoice.payment_hash)
)
)
except Exception as ex:
logger.info(ex)
# assume that payment failed?
return PaymentResponse(
False, None, None, None, f"payment failed: {exc}"
)
if payment.status != breez_sdk.PaymentStatus.COMPLETE:
return PaymentResponse(False, None, None, None, "payment is pending")
# let's use the payment_hash as the checking_id
checking_id = invoice.payment_hash
return PaymentResponse(
True,
checking_id,
payment.fee_msat,
payment.details.data.payment_preimage,
None,
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
checking_id
)
if payment is None:
return PaymentPendingStatus()
if payment.payment_type != breez_sdk.PaymentType.RECEIVED:
logger.warning(f"unexpected payment type: {payment.status}")
return PaymentPendingStatus()
if payment.status == breez_sdk.PaymentStatus.FAILED:
return PaymentFailedStatus()
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
return PaymentSuccessStatus()
return PaymentPendingStatus()
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
checking_id
)
if payment is None:
return PaymentPendingStatus()
if payment.payment_type != breez_sdk.PaymentType.SENT:
logger.warning(f"unexpected payment type: {payment.status}")
return PaymentPendingStatus()
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
return PaymentSuccessStatus(
fee_msat=payment.fee_msat,
preimage=payment.details.data.payment_preimage,
)
if payment.status == breez_sdk.PaymentStatus.FAILED:
return PaymentFailedStatus()
return PaymentPendingStatus()
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
event = await breez_event_queue.get()
if event.is_invoice_paid():
yield event.details.payment_hash

39
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]] [[package]]
name = "anyio" name = "anyio"
@ -329,6 +329,40 @@ click = "*"
ecdsa = "*" ecdsa = "*"
secp256k1 = "*" secp256k1 = "*"
[[package]]
name = "breez-sdk"
version = "0.5.0"
description = "Python language bindings for the Breez SDK"
optional = true
python-versions = "*"
files = [
{file = "breez_sdk-0.5.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6a73f8b8c2b74798278b8d4ca7757738d7425d216b0edbe281d4260e6a7a331d"},
{file = "breez_sdk-0.5.0-cp310-cp310-manylinux_2_31_aarch64.whl", hash = "sha256:c46bf3ebd3f184d1c9d72c81eeb3b134ecae87551b979a3bc4379d70216451f6"},
{file = "breez_sdk-0.5.0-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:fd8b285c855f3d3c464962b8a3add5ccd01f57a7f37ddab36de36481903aa63a"},
{file = "breez_sdk-0.5.0-cp310-cp310-win32.whl", hash = "sha256:bff9ea24736e847922e008ad83799d388f2112e701376decb56a9023402ce5ce"},
{file = "breez_sdk-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:ba94423afe27eec0e35191a4f288011c1e8b3174d029154fc87f5dc1cd9afdad"},
{file = "breez_sdk-0.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a76bdd69b144f796c299342f1989c3f73e05c969c8698d10f002465033ed8d3a"},
{file = "breez_sdk-0.5.0-cp311-cp311-manylinux_2_31_aarch64.whl", hash = "sha256:62c70ebc7c77cb948beb680c8f506b0dff6588826b0f3a07eb219fa0c45e5595"},
{file = "breez_sdk-0.5.0-cp311-cp311-manylinux_2_31_x86_64.whl", hash = "sha256:b48fa4e5ae6b5299fc7dc865a52a14ad2eb95736840e0eb12d6136a3aeeb1763"},
{file = "breez_sdk-0.5.0-cp311-cp311-win32.whl", hash = "sha256:2bd289fbf7da5249022060fd3f4c53b29fa25aefbde7c4fc4c0557fef6eef00a"},
{file = "breez_sdk-0.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:987f52476cf7726720535b5d04c3b86f469094284298fef3404dafad5c854342"},
{file = "breez_sdk-0.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:9c87c691c6fee3d87a12bac1a03e4370ba97142f9e8e74af900f27b98704efa5"},
{file = "breez_sdk-0.5.0-cp312-cp312-manylinux_2_31_aarch64.whl", hash = "sha256:1e5283bb734c814097b62c681341aff687dc5e894ace2e9845e977e04dba6dd3"},
{file = "breez_sdk-0.5.0-cp312-cp312-manylinux_2_31_x86_64.whl", hash = "sha256:d9950e90710a04167c5da6990d84d4212361fc0119b5bdfcfd2ba8bb14511327"},
{file = "breez_sdk-0.5.0-cp312-cp312-win32.whl", hash = "sha256:39549ee886dc5f50c0e7961d3789705de1795d1551f397f74b5c6e5c82be09ed"},
{file = "breez_sdk-0.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:b2bac63004779e41d3c89c26d89162e16b37969725aa87b64287163d79a86f03"},
{file = "breez_sdk-0.5.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:f7470682dc6eec94bd82e968b124a9e261bd286bf0c97880ded3f53609b570c5"},
{file = "breez_sdk-0.5.0-cp38-cp38-manylinux_2_31_aarch64.whl", hash = "sha256:fefde0e66380489f4cde08d6dbcd1fdacd68adc229c1bf6b60e1d6c18c342cb6"},
{file = "breez_sdk-0.5.0-cp38-cp38-manylinux_2_31_x86_64.whl", hash = "sha256:fac91a5afafaffe2de6586b0e2a65253e1c01ad882aebc27335d7ef6486b3400"},
{file = "breez_sdk-0.5.0-cp38-cp38-win32.whl", hash = "sha256:fd901b0576db9e20e923086e2009264a1230d0a1eaeb8ca1e197fa1dd1f8b30c"},
{file = "breez_sdk-0.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:577648a87a5a552199a0422174aea47ba8a5ed9d18d8afd249e88e0fa0a61309"},
{file = "breez_sdk-0.5.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:a643641f7c3586bd13e491e5a3778b5b2dc222ebb072b3fd9084366176948846"},
{file = "breez_sdk-0.5.0-cp39-cp39-manylinux_2_31_aarch64.whl", hash = "sha256:5367652c5389c5fcc65a779ebc52322327af3b4ac343448f3ac9254f4a305f43"},
{file = "breez_sdk-0.5.0-cp39-cp39-manylinux_2_31_x86_64.whl", hash = "sha256:40aead32105c42ebb0c7389718d33bd7384eab633fde46a47034b71fa28493cb"},
{file = "breez_sdk-0.5.0-cp39-cp39-win32.whl", hash = "sha256:636804daa36264317a45f905f0c7d881ace7259d84654577f969aedfc27c8de8"},
{file = "breez_sdk-0.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:9fb8335ae6c8d71a47350d53965194cf803e73bd4cba1f658fe0ab556a9d8471"},
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2024.7.4" version = "2024.7.4"
@ -2995,9 +3029,10 @@ doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linke
test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
[extras] [extras]
breez = ["breez-sdk"]
liquid = ["wallycore"] liquid = ["wallycore"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10 | ^3.9" python-versions = "^3.10 | ^3.9"
content-hash = "3795e179851835839e5f89f51704309942915386a67a00b7c9408ff78b4fea41" content-hash = "6890197bd92c72c54aec6b498ddd6b6df51b95884f6ad29dd3655d8e4c9cfd00"

View file

@ -57,8 +57,11 @@ environs = "9.5.0"
python-crontab = "3.0.0" python-crontab = "3.0.0"
# needed for liquid support boltz # needed for liquid support boltz
wallycore = {version = "^1.0.0", optional = true} wallycore = {version = "^1.0.0", optional = true}
# needed for breez funding source
breez-sdk = {version = "0.5.0", optional = true}
[tool.poetry.extras] [tool.poetry.extras]
breez = ["breez-sdk"]
liquid = ["wallycore"] liquid = ["wallycore"]
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]