From 021cf64c1e82208b32200d491601c6c9cf2a5a56 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Wed, 23 Jun 2021 16:13:48 +0100 Subject: [PATCH 1/7] Added eclair backend --- .env.example | 6 +- lnbits/wallets/__init__.py | 1 + lnbits/wallets/eclair.py | 166 +++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 lnbits/wallets/eclair.py diff --git a/.env.example b/.env.example index cc70644c..38add8fd 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ LNBITS_FORCE_HTTPS=true LNBITS_SERVICE_FEE="0.0" # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC), -# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet +# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, EclairWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet # VoidWallet is just a fallback that works without any actual Lightning capabilities, # just so you can see the UI before dealing with this file. @@ -56,3 +56,7 @@ LNTXBOT_KEY=LNTXBOT_ADMIN_KEY # OpenNodeWallet OPENNODE_API_ENDPOINT=https://api.opennode.com/ OPENNODE_KEY=OPENNODE_ADMIN_KEY + +# EclairWallet +ECLAIR_URL=http://127.0.0.1:8080 +ECLAIR_PASS=eclair_password diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index 10a17c6f..e1b37f6f 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -9,3 +9,4 @@ from .lnpay import LNPayWallet from .lnbits import LNbitsWallet from .lndrest import LndRestWallet from .spark import SparkWallet +from .eclair import EclairWallet diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py new file mode 100644 index 00000000..2f29501c --- /dev/null +++ b/lnbits/wallets/eclair.py @@ -0,0 +1,166 @@ +import trio +import json +import httpx +import random +import base64 +import urllib.parse +from os import getenv +from typing import Optional, AsyncGenerator +from trio_websocket import open_websocket_url + +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, +) + +class EclairError(Exception): + pass + + +class UnknownError(Exception): + pass + + +class EclairWallet(Wallet): + def __init__(self): + url = getenv("ECLAIR_URL") + self.url = url[:-1] if url.endswith("/") else url + + passw = getenv("ECLAIR_PASS") + encodedAuth = base64.b64encode(f":{passw}".encode("utf-8")) + auth = str(encodedAuth, "utf-8") + self.auth = {"Authorization": f"Basic {auth}"} + + def __getattr__(self, key): + async def call(*args, **kwargs): + if args and kwargs: + raise TypeError( + f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}" + ) + elif args: + params = args + elif kwargs: + params = kwargs + else: + params = {} + + try: + async with httpx.AsyncClient() as client: + r = await client.post( + self.url + "/" + key, + headers=self.auth, + data=params, + timeout=40, + ) + except (OSError, httpx.ConnectError, httpx.RequestError) as exc: + raise UnknownError("error connecting to eclair: " + str(exc)) + + try: + data = r.json() + if "error" in data: + print('ERROR', data["error"]) + raise EclairError(data["error"]) + except: + raise UnknownError(r.text) + + #if r.error: + # print('ERROR', r) + # if r.status_code == 401: + # raise EclairError("Access key invalid!") + + #raise EclairError(data.error) + return data + + return call + + async def status(self) -> StatusResponse: + try: + funds = await self.usablebalances() + except (httpx.ConnectError, httpx.RequestError): + return StatusResponse("Couldn't connect to Eclair server", 0) + except (EclairError, UnknownError) as e: + return StatusResponse(str(e), 0) + if not funds: + return StatusResponse("Funding wallet has no funds", 0) + + return StatusResponse( + None, + funds[0]["canSend"] * 1000, + ) + + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + ) -> InvoiceResponse: + if description_hash: + raise Unsupported("description_hash") + + try: + r = await self.createinvoice( + amountMsat=amount * 1000, + description=memo or "", + exposeprivatechannels=True, + ) + ok, checking_id, payment_request, error_message = True, r["paymentHash"], r["serialized"], "" + except (EclairError, UnknownError) as e: + ok, payment_request, error_message = False, None, str(e) + + return InvoiceResponse(ok, checking_id, payment_request, error_message) + + async def pay_invoice(self, bolt11: str) -> PaymentResponse: + try: + r = await self.payinvoice(invoice=bolt11, blocking=True) + except (EclairError, UnknownError) as exc: + return PaymentResponse(False, None, 0, None, str(exc)) + + preimage = r["paymentPreimage"] + return PaymentResponse(True, r["paymentHash"], 0, preimage, None) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + try: + r = await self.getreceivedinfo(paymentHash=checking_id) + + except (EclairError, UnknownError): + return PaymentStatus(None) + + if r["status"]["type"] != "received": + return PaymentStatus(False) + return PaymentStatus(True) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + # check if it's 32 bytes hex + if len(checking_id) != 64: + return PaymentStatus(None) + try: + int(checking_id, 16) + except ValueError: + return PaymentStatus(None) + + try: + r = await self.getsentinfo(paymentHash=checking_id) + except (EclairError, UnknownError): + return PaymentStatus(None) + + raise KeyError("supplied an invalid checking_id") + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + url = urllib.parse.urlsplit(self.url) + ws_url = f"ws://{url.netloc}/ws" + + try: + async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + message = await ws.get_message() + if message["type"] == "payment-received": + print('Received message: %s' % message) + yield message["paymentHash"] + + except OSError as ose: + pass + + print("lost connection to eclair's websocket, retrying in 5 seconds") + await trio.sleep(5) From 4585d97324aa2ce7ea244c2b5ab35293a97634f7 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Wed, 23 Jun 2021 18:50:01 +0100 Subject: [PATCH 2/7] small fix on paid_invoices_stream --- lnbits/wallets/eclair.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 2f29501c..9c8fa540 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -152,15 +152,17 @@ class EclairWallet(Wallet): url = urllib.parse.urlsplit(self.url) ws_url = f"ws://{url.netloc}/ws" - try: - async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: - message = await ws.get_message() - if message["type"] == "payment-received": - print('Received message: %s' % message) - yield message["paymentHash"] + while True: + try: + async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + message = await ws.get_message() + if "payment-received" in message["type"]: + print('Received message: %s' % message) + yield message["paymentHash"] - except OSError as ose: - pass + except OSError as ose: + print('OSE', ose) + pass - print("lost connection to eclair's websocket, retrying in 5 seconds") - await trio.sleep(5) + print("lost connection to eclair's websocket, retrying in 5 seconds") + await trio.sleep(5) From 756b121105848b4042f3e54bc5857afd637a46d4 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 25 Jun 2021 10:15:50 +0100 Subject: [PATCH 3/7] print errors for tracking/debugging --- lnbits/wallets/eclair.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 9c8fa540..59d2ffdf 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -61,7 +61,7 @@ class EclairWallet(Wallet): try: data = r.json() if "error" in data: - print('ERROR', data["error"]) + print(f"ERROR-{key}", data["error"]) raise EclairError(data["error"]) except: raise UnknownError(r.text) @@ -156,8 +156,9 @@ class EclairWallet(Wallet): try: async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: message = await ws.get_message() + print('Received message: %s' % message) + if "payment-received" in message["type"]: - print('Received message: %s' % message) yield message["paymentHash"] except OSError as ose: From 2cd6626ca1c59d99a445426eba6922c0e00f7e4c Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 29 Apr 2022 11:39:27 +0100 Subject: [PATCH 4/7] get status --- lnbits/wallets/__init__.py | 14 +++++++------- lnbits/wallets/eclair.py | 25 ++++++++++++++----------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index f3f25103..8a2ca1a5 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -1,12 +1,12 @@ # flake8: noqa -from .void import VoidWallet from .clightning import CLightningWallet -from .lntxbot import LntxbotWallet -from .opennode import OpenNodeWallet -from .lnpay import LNPayWallet -from .lnbits import LNbitsWallet -from .lndrest import LndRestWallet -from .spark import SparkWallet from .eclair import EclairWallet from .fake import FakeWallet +from .lnbits import LNbitsWallet +from .lndrest import LndRestWallet +from .lnpay import LNPayWallet +from .lntxbot import LntxbotWallet +from .opennode import OpenNodeWallet +from .spark import SparkWallet +from .void import VoidWallet diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 59d2ffdf..9b5aca7d 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -1,21 +1,24 @@ -import trio -import json -import httpx -import random +import asyncio import base64 +import json +import random import urllib.parse from os import getenv -from typing import Optional, AsyncGenerator -from trio_websocket import open_websocket_url +from typing import AsyncGenerator, Optional + +import httpx +from websockets import connect from .base import ( - StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, + StatusResponse, + Unsupported, Wallet, ) + class EclairError(Exception): pass @@ -154,11 +157,11 @@ class EclairWallet(Wallet): while True: try: - async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: - message = await ws.get_message() + async with connect(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + message = await ws.recv() print('Received message: %s' % message) - if "payment-received" in message["type"]: + if "type" in message and "payment-received" in message.type: yield message["paymentHash"] except OSError as ose: @@ -166,4 +169,4 @@ class EclairWallet(Wallet): pass print("lost connection to eclair's websocket, retrying in 5 seconds") - await trio.sleep(5) + await asyncio.sleep(5) From 96df280d49e13d090b3f336e504c698dc2a3c79e Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 5 May 2022 15:44:34 +0100 Subject: [PATCH 5/7] adding fees --- lnbits/wallets/eclair.py | 232 ++++++++++++++++++++++----------------- 1 file changed, 130 insertions(+), 102 deletions(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 9b5aca7d..e2ba7d36 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -1,20 +1,23 @@ import asyncio import base64 import json -import random import urllib.parse from os import getenv -from typing import AsyncGenerator, Optional +from typing import AsyncGenerator, Dict, Optional import httpx from websockets import connect +from websockets.exceptions import ( + ConnectionClosed, + ConnectionClosedError, + ConnectionClosedOK, +) from .base import ( InvoiceResponse, PaymentResponse, PaymentStatus, StatusResponse, - Unsupported, Wallet, ) @@ -26,73 +29,37 @@ class EclairError(Exception): class UnknownError(Exception): pass - class EclairWallet(Wallet): def __init__(self): url = getenv("ECLAIR_URL") self.url = url[:-1] if url.endswith("/") else url + self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws" + passw = getenv("ECLAIR_PASS") encodedAuth = base64.b64encode(f":{passw}".encode("utf-8")) auth = str(encodedAuth, "utf-8") self.auth = {"Authorization": f"Basic {auth}"} - def __getattr__(self, key): - async def call(*args, **kwargs): - if args and kwargs: - raise TypeError( - f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}" - ) - elif args: - params = args - elif kwargs: - params = kwargs - else: - params = {} - - try: - async with httpx.AsyncClient() as client: - r = await client.post( - self.url + "/" + key, - headers=self.auth, - data=params, - timeout=40, - ) - except (OSError, httpx.ConnectError, httpx.RequestError) as exc: - raise UnknownError("error connecting to eclair: " + str(exc)) - - try: - data = r.json() - if "error" in data: - print(f"ERROR-{key}", data["error"]) - raise EclairError(data["error"]) - except: - raise UnknownError(r.text) - - #if r.error: - # print('ERROR', r) - # if r.status_code == 401: - # raise EclairError("Access key invalid!") - - #raise EclairError(data.error) - return data - - return call async def status(self) -> StatusResponse: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/usablebalances", + headers=self.auth, + timeout=40 + ) try: - funds = await self.usablebalances() - except (httpx.ConnectError, httpx.RequestError): - return StatusResponse("Couldn't connect to Eclair server", 0) - except (EclairError, UnknownError) as e: - return StatusResponse(str(e), 0) - if not funds: - return StatusResponse("Funding wallet has no funds", 0) + data = r.json() + except: + return StatusResponse( + f"Failed to connect to {self.url}, got: '{r.text[:200]}...'", 0 + ) + + if r.is_error: + return StatusResponse(data["error"], 0) - return StatusResponse( - None, - funds[0]["canSend"] * 1000, - ) + return StatusResponse(None, data[0]["canSend"] * 1000) async def create_invoice( self, @@ -100,73 +67,134 @@ class EclairWallet(Wallet): memo: Optional[str] = None, description_hash: Optional[bytes] = None, ) -> InvoiceResponse: + + data: Dict = {"amountMsat": amount * 1000} if description_hash: - raise Unsupported("description_hash") + data["description_hash"] = description_hash.hex() + else: + data["description"] = memo or "" - try: - r = await self.createinvoice( - amountMsat=amount * 1000, - description=memo or "", - exposeprivatechannels=True, - ) - ok, checking_id, payment_request, error_message = True, r["paymentHash"], r["serialized"], "" - except (EclairError, UnknownError) as e: - ok, payment_request, error_message = False, None, str(e) + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/createinvoice", + headers=self.auth, + data=data, + timeout=40 + ) - return InvoiceResponse(ok, checking_id, payment_request, error_message) + if r.is_error: + try: + data = r.json() + error_message = data["error"] + except: + error_message = r.text + pass - async def pay_invoice(self, bolt11: str) -> PaymentResponse: - try: - r = await self.payinvoice(invoice=bolt11, blocking=True) - except (EclairError, UnknownError) as exc: - return PaymentResponse(False, None, 0, None, str(exc)) + return InvoiceResponse(False, None, None, error_message) + + data = r.json() + return InvoiceResponse(True, data["paymentHash"], data["serialized"], None) + + + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/payinvoice", + headers=self.auth, + data={"invoice": bolt11, "blocking": True}, + timeout=40, + ) + + if "error" in r.json(): + try: + data = r.json() + error_message = data["error"] + except: + error_message = r.text + pass + return PaymentResponse(False, None, 0, None, error_message) + + data = r.json() + + + checking_id = data["paymentHash"] + preimage = data["paymentPreimage"] + + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/getsentinfo", + headers=self.auth, + data={"paymentHash": checking_id}, + timeout=40, + ) + + if "error" in r.json(): + try: + data = r.json() + error_message = data["error"] + except: + error_message = r.text + pass + return PaymentResponse(False, None, 0, None, error_message) + + data = r.json() + fees = [i["status"] for i in data] + fee_msat = sum([i["feesPaid"] for i in fees]) + + return PaymentResponse(True, checking_id, fee_msat, preimage, None) + - preimage = r["paymentPreimage"] - return PaymentResponse(True, r["paymentHash"], 0, preimage, None) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: - try: - r = await self.getreceivedinfo(paymentHash=checking_id) + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/getreceivedinfo", + headers=self.auth, + data={"paymentHash": checking_id} + ) + data = r.json() - except (EclairError, UnknownError): + if r.is_error or "error" in data: return PaymentStatus(None) - if r["status"]["type"] != "received": + if data["status"]["type"] != "received": return PaymentStatus(False) - return PaymentStatus(True) + + return PaymentStatus(True) async def get_payment_status(self, checking_id: str) -> PaymentStatus: - # check if it's 32 bytes hex - if len(checking_id) != 64: - return PaymentStatus(None) - try: - int(checking_id, 16) - except ValueError: - return PaymentStatus(None) + async with httpx.AsyncClient() as client: + r = await client.post( + url=f"{self.url}/getsentinfo", + headers=self.auth, + data={"paymentHash": checking_id} - try: - r = await self.getsentinfo(paymentHash=checking_id) - except (EclairError, UnknownError): - return PaymentStatus(None) + ) - raise KeyError("supplied an invalid checking_id") + data = r.json()[0] + + if r.is_error: + return PaymentStatus(None) + + if data["status"]["type"] != "sent": + return PaymentStatus(False) + + return PaymentStatus(True) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - url = urllib.parse.urlsplit(self.url) - ws_url = f"ws://{url.netloc}/ws" - - while True: - try: - async with connect(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + + try: + async with connect(self.ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + while True: message = await ws.recv() - print('Received message: %s' % message) + message = json.loads(message) - if "type" in message and "payment-received" in message.type: + if message and message["type"] == "payment-received": yield message["paymentHash"] - except OSError as ose: - print('OSE', ose) - pass + except (OSError, ConnectionClosedOK, ConnectionClosedError, ConnectionClosed) as ose: + print('OSE', ose) + pass print("lost connection to eclair's websocket, retrying in 5 seconds") await asyncio.sleep(5) From 1695d0ce0ea5c3791eab90d717b000cd1bae9358 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 5 May 2022 15:47:25 +0100 Subject: [PATCH 6/7] send 0 fees when not available --- lnbits/wallets/eclair.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index e2ba7d36..04fd498f 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -135,7 +135,7 @@ class EclairWallet(Wallet): except: error_message = r.text pass - return PaymentResponse(False, None, 0, None, error_message) + return PaymentResponse(True, checking_id, 0, preimage, error_message) ## ?? is this ok ?? data = r.json() fees = [i["status"] for i in data] From e0504a4e0c16d64e91984bdde83a71ee686e8564 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 5 May 2022 15:55:01 +0100 Subject: [PATCH 7/7] add eclair variables to .env example --- .env.example | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 4849fd06..14a87d02 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,7 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador" # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, -# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet +# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet # VoidWallet is just a fallback that works without any actual Lightning capabilities, # just so you can see the UI before dealing with this file. @@ -77,4 +77,8 @@ OPENNODE_KEY=OPENNODE_ADMIN_KEY # FakeWallet FAKE_WALLET_SECRET="ToTheMoon1" -LNBITS_DENOMINATION=sats \ No newline at end of file +LNBITS_DENOMINATION=sats + +# EclairWallet +ECLAIR_URL=http://127.0.0.1:8283 +ECLAIR_PASS=eclairpw \ No newline at end of file