From 3687c298e0fba9a4ee4a76725b75b1aaa0fa77b2 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Sun, 11 Aug 2024 23:43:22 +0200 Subject: [PATCH 01/26] lnmarkets stuff --- cashu/core/settings.py | 7 + cashu/lightning/__init__.py | 1 + cashu/lightning/lnmarkets.py | 330 +++++++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 cashu/lightning/lnmarkets.py diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 5104cf15..3fe524b5 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -218,6 +218,12 @@ class CoreLightningRestFundingSource(MintSettings): mint_corelightning_rest_macaroon: Optional[str] = Field(default=None) mint_corelightning_rest_cert: Optional[str] = Field(default=None) +class LNMarketsRestFundingSource(MintSettings): + mint_lnmarkets_rest_url: Optional[str] = Field(default=None) + mint_lnmarkets_rest_access_key: Optional[str] = Field(default=None) + mint_lnmarkets_rest_passphrase: Optional[str] = Field(default=None) + mint_lnmarkets_rest_secret: Optional[str] = Field(default=None) + class Settings( EnvSettings, @@ -225,6 +231,7 @@ class Settings( LndRestFundingSource, CoreLightningRestFundingSource, CLNRestFundingSource, + LNMarketsRestFundingSource, FakeWalletSettings, MintLimits, MintBackends, diff --git a/cashu/lightning/__init__.py b/cashu/lightning/__init__.py index dfa66b94..d930cb9e 100644 --- a/cashu/lightning/__init__.py +++ b/cashu/lightning/__init__.py @@ -5,6 +5,7 @@ from .corelightningrest import CoreLightningRestWallet # noqa: F401 from .fake import FakeWallet # noqa: F401 from .lnbits import LNbitsWallet # noqa: F401 +from .lnmarkets import LNMarketsWallet # noqa: F401 from .lnd_grpc.lnd_grpc import LndRPCWallet # noqa: F401 from .lndrest import LndRestWallet # noqa: F401 from .strike import StrikeWallet # noqa: F401 diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py new file mode 100644 index 00000000..7459ee44 --- /dev/null +++ b/cashu/lightning/lnmarkets.py @@ -0,0 +1,330 @@ +from ..core.settings import settings +import os +import re +import httpx +import asyncio +import hmac +import base64 +import json +import time +import hashlib +from enum import Enum + +from loguru import logger + +from typing import Dict, Union, Optional, AsyncGenerator + +from bolt11 import decode +from ..core.helpers import fee_reserve +from ..core.models import PostMeltQuoteRequest + +from ..core.base import Amount, MeltQuote, Unit +from .base import ( + InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, + PaymentResponse, + PaymentStatus, + StatusResponse, +) + +class Method(Enum): + POST = 1 + GET = 2 + DELETE = 3 + PUT = 4 + + def __str__(self): + return self.name + +class LNMarketsWallet(LightningBackend): + """https://docs.lnmarkets.com/api""" + supports_mpp = False + supports_incoming_payment_stream = False + supported_units = set([Unit.sat, Unit.usd]) + + def __init__(self, unit: Unit, **kwargs): + self.assert_unit_supported(unit) + self.unit = unit + self.endpoint = settings.mint_lnmarkets_rest_url or "https://api.lnmarkets.com" + + if re.match(r"^https?://[a-zA-Z0-9.-]+(?:/[a-zA-Z0-9.-]+)*$", self.endpoint) is None: + raise Exception("Invalid API endpoint") + + access_key = settings.mint_lnmarkets_rest_access_key + secret = settings.mint_lnmarkets_rest_secret + passphrase = settings.mint_lnmarkets_rest_passphrase + + if not access_key: + raise Exception("No API access key provided") + if not secret: + raise Exception("No API secret provided") + if not passphrase: + raise Exception("No API passphrase provided") + + self.secret = secret #base64.b64decode(secret) + self.headers: Dict[str, Union[str, int]] = { + "LNM-ACCESS-KEY": access_key, + "LNM-ACCESS-PASSPHRASE": passphrase, + "Content-Type": "application/json" + } + + self.client = httpx.AsyncClient( + verify=not settings.debug + ) + + async def get_request_headers(self, method: Method, path: str, data: dict) -> dict: + timestamp = time.time_ns() // 1000000 # timestamp in milliseconds + params = "" + if method == Method.GET: + for key, value in data.items(): + params += f"&{key}={value}" + params = params.strip("&") + elif method == Method.POST: + params = json.dumps(data, separators=(",", ":")) + else: + raise Exception("Method not allowed. Something is wrong with the code.") + + signature = base64.b64encode(hmac.new( + self.secret.encode(), + f"{timestamp}{str(method)}{path}{params}".encode(), # bytes from utf-8 string + hashlib.sha256, + ).digest()) + logger.debug(f"{timestamp}{str(method)}{path}{params}") + headers = self.headers.copy() + headers["LNM-ACCESS-TIMESTAMP"] = str(timestamp) + headers["LNM-ACCESS-SIGNATURE"] = signature.decode() + logger.debug(f"{headers = }") + return headers + + async def status(self) -> StatusResponse: + headers = await self.get_request_headers(Method.GET, "/v2/user", {}) + try: + r = await self.client.get(url=f"{self.endpoint}/v2/user", timeout=15, headers=headers) + r.raise_for_status() + except Exception as exc: + return StatusResponse( + error_message=f"Failed to connect to {self.endpoint} due to: {exc}", + balance=0, + ) + + try: + data: dict = r.json() + except Exception: + return StatusResponse( + error_message=( + f"Received invalid response from {self.endpoint}: {r.text}" + ), + balance=0, + ) + if "detail" in data: + return StatusResponse( + error_message=f"LNMarkets error: {data['detail']}", balance=0 + ) + + if self.unit == Unit.usd: + return StatusResponse(error_message=None, balance=data["synthetic_usd_balance"]) + return StatusResponse(error_message=None, balance=data["balance"]) + + async def create_invoice( + self, + amount: Amount, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + ) -> InvoiceResponse: + self.assert_unit_supported(amount.unit) + + data = None + path = None + if self.unit == Unit.usd: + amount_usd = float(amount.to_float_string()) + amount_usd = int(amount_usd) if float(int(amount_usd)) == amount_usd else amount_usd + data = {"amount": amount_usd, "currency": "usd"} + path = "/v2/user/deposit/susd" + else: + data = {"amount": amount.amount} + path = "/v2/user/deposit" + logger.debug(f"{data = } {path = }") + assert data and path + headers = await self.get_request_headers(Method.POST, path, data) + try: + r = await self.client.post( + url=f"{self.endpoint}{path}", + json=data, + headers=headers, + ) + r.raise_for_status() + except Exception as e: + logger.error("Error while creating invoice: "+str(e)) + return InvoiceResponse( + ok=False, + error_message="Error while creating invoice: "+str(e), + ) + + data = None + try: + data = r.json() + except Exception: + return InvoiceResponse( + ok=False, + error_message=( + f"Received invalid response from {self.endpoint}: {r.text}" + ), + ) + + checking_id, payment_request = data["depositId"], data["paymentRequest"] + assert isinstance(checking_id, str) and isinstance(payment_request, str) + + return InvoiceResponse( + ok=True, + checking_id=checking_id, + payment_request=payment_request, + ) + + async def pay_invoice( + self, quote: MeltQuote, fee_limit_msat: int + ) -> PaymentResponse: + self.assert_unit_supported(Unit[quote.unit]) + + data = {"invoice": quote.request} + path = "v2/user/withdraw" + if self.unit == Unit.usd: + data["quote_id"] = quote.checking_id + + headers = await self.get_request_headers(Method.POST, path, data) + try: + r = await self.client.post( + url=f"{self.endpoint}{path}", + json=data, + headers=headers, + timeout=None, + ) + r.raise_for_status() + except Exception as e: + logger.error(f"LNMarkets withdrawal unsuccessful: {str(e)}") + return PaymentResponse(error_message=f"LNMarkets withdrawal unsuccessful: {str(e)}") + + try: + data = r.json() + except Exception: + logger.error(f"LNMarkets withdrawal unsuccessful: {r.text}") + return PaymentResponse(error_message=f"LNMarkets withdrawal unsuccessful: {r.text}") + + # payment_preimage = ?? + # no payment preimage by lnmarkets :( + checking_id = data["id"] + payment_fee = int(data["fee"]) + return PaymentResponse( + ok=True, + checking_id=checking_id, + fee=Amount(unit=Unit.sat, amount=payment_fee), + ) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + path = f"/v2/user/deposit/{checking_id}" + headers = await self.get_request_headers(Method.GET, path, {}) + try: + r = await self.client.get( + url=f"{self.endpoint}{path}", + headers=headers, + timeout=None, + ) + r.raise_for_status() + except Exception as e: + logger.error(f"get invoice status unsuccessful: {str(e)}") + return PaymentStatus(paid=None) + + data = None + try: + data = r.json() + except Exception: + logger.error(f"get invoice status unsuccessful: {r.text}") + return PaymentStatus(paid=None) + return PaymentStatus(paid=data["success"]) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + path = f"/v2/user/withdrawals/{checking_id}" + data: dict = {} + headers = await self.get_request_headers(Method.GET, path, data) + + try: + r = await self.client.get( + url=f"{self.endpoint}{path}", + headers=headers, + timeout=None, + ) + r.raise_for_status() + except Exception: + return PaymentStatus(paid=None) + + try: + data = r.json() + except Exception: + return PaymentStatus(paid=None) + + if not data["success"]: + return PaymentStatus(paid=None) + + return PaymentStatus( + paid=data["success"], + fee=Amount(unit=Unit.sat, amount=data["fee"]), + ) + + async def get_payment_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + self.assert_unit_supported(Unit[melt_quote.unit]) + invoice_obj = decode(melt_quote.request) + assert invoice_obj.amount_msat, "invoice has no amount." + amount_msat = int(invoice_obj.amount_msat) + fees_msat = fee_reserve(amount_msat) + fees = Amount(unit=Unit.msat, amount=fees_msat) + amount = Amount(unit=Unit.msat, amount=amount_msat) + # SAT + if self.unit == Unit.sat: + return PaymentQuoteResponse( + checking_id=invoice_obj.payment_hash, + fee=fees.to(self.unit, round="up"), + amount=amount.to(self.unit, round="up"), + ) + # sUSD + else: + # We get the current rate + path = "/v2/futures/ticker" + headers = await self.get_request_headers(Method.GET, path, {}) + + # We let any eventual exception crash the request + r = await self.client.get(f"{self.endpoint}{path}", + headers=headers, + timeout=None, + ) + r.raise_for_status() + + data = r.json() + price = data["bidPrice"] # BTC/USD + amount_usd = round((amount.amount / 10**8) * price, 2) + + # We request a quote to pay a precise amount of sats from the usd balance, then calculate + # the usd amount and usd fee reserve + data = {"amount": amount.to(Unit.sat), "currency": "btc"} + path = "/v2/user/withdraw/susd" + headers = await self.get_request_headers(Method.POST, path, data) + + r = await self.client.post(f"{self.endpoint}{path}", + json=data, + headers=headers, + timeout=None, + ) + r.raise_for_status() + + # calculate fee based on returned sat `fee_reserve` + data = r.json() + fee_reserve_usd = round((data["fee_reserve"] / 10**8) * price, 2) + return PaymentQuoteResponse( + checking_id=data["quote_id"], + fee=Amount.from_float(fee_reserve_usd, self.unit), + amount=Amount.from_float(amount_usd, self.unit) + ) + + async def paid_invoices_stream(self): + raise NotImplementedError("paid_invoices_stream not implemented") \ No newline at end of file From 4cdb96fdf76841b6de7d89c401a09f5ef4b94e97 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 12 Aug 2024 10:10:33 +0200 Subject: [PATCH 02/26] more logging and some error fixes --- cashu/lightning/lnmarkets.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 7459ee44..eb847f1c 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -103,6 +103,7 @@ async def status(self) -> StatusResponse: r = await self.client.get(url=f"{self.endpoint}/v2/user", timeout=15, headers=headers) r.raise_for_status() except Exception as exc: + logger.error(f"Failed to connect to {self.endpoint} due to: {exc}") return StatusResponse( error_message=f"Failed to connect to {self.endpoint} due to: {exc}", balance=0, @@ -111,6 +112,7 @@ async def status(self) -> StatusResponse: try: data: dict = r.json() except Exception: + logger.error(f"Received invalid response from {self.endpoint}: {r.text}") return StatusResponse( error_message=( f"Received invalid response from {self.endpoint}: {r.text}" @@ -137,6 +139,8 @@ async def create_invoice( data = None path = None if self.unit == Unit.usd: + # The ""signature"" of LNMarkets is the sketchiest thing I have ever seen. + # I don't know who came up with that. Anyway, we do this trick to avoid messing it up. amount_usd = float(amount.to_float_string()) amount_usd = int(amount_usd) if float(int(amount_usd)) == amount_usd else amount_usd data = {"amount": amount_usd, "currency": "usd"} @@ -144,6 +148,7 @@ async def create_invoice( else: data = {"amount": amount.amount} path = "/v2/user/deposit" + logger.debug(f"{data = } {path = }") assert data and path headers = await self.get_request_headers(Method.POST, path, data) @@ -165,6 +170,7 @@ async def create_invoice( try: data = r.json() except Exception: + logger.error( f"Received invalid response from {self.endpoint}: {r.text}") return InvoiceResponse( ok=False, error_message=( @@ -187,7 +193,7 @@ async def pay_invoice( self.assert_unit_supported(Unit[quote.unit]) data = {"invoice": quote.request} - path = "v2/user/withdraw" + path = "/v2/user/withdraw" if self.unit == Unit.usd: data["quote_id"] = quote.checking_id @@ -254,20 +260,23 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: timeout=None, ) r.raise_for_status() - except Exception: + except Exception as e: + logger.error(f"getting invoice status unsuccessful: {str(e)}") return PaymentStatus(paid=None) try: data = r.json() except Exception: + logger.error(f"getting invoice status unsuccessful: {r.text}") return PaymentStatus(paid=None) + logger.debug(f"payment status: {data}") if not data["success"]: return PaymentStatus(paid=None) return PaymentStatus( paid=data["success"], - fee=Amount(unit=Unit.sat, amount=data["fee"]), + fee=Amount(unit=Unit.sat, amount=int(data["fee"])), ) async def get_payment_quote( @@ -280,7 +289,7 @@ async def get_payment_quote( fees_msat = fee_reserve(amount_msat) fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) - # SAT + # SAT -- unfortunately, there is no way of asking the fee_reserve to lnmarkets beforehand in this case. if self.unit == Unit.sat: return PaymentQuoteResponse( checking_id=invoice_obj.payment_hash, From 31a2e44f83e59ea06b79b52d58c54d04f1ee58b7 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 12 Aug 2024 10:13:30 +0200 Subject: [PATCH 03/26] make format --- cashu/lightning/__init__.py | 2 +- cashu/lightning/lnmarkets.py | 23 ++++++++++------------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/cashu/lightning/__init__.py b/cashu/lightning/__init__.py index d930cb9e..6481b431 100644 --- a/cashu/lightning/__init__.py +++ b/cashu/lightning/__init__.py @@ -5,9 +5,9 @@ from .corelightningrest import CoreLightningRestWallet # noqa: F401 from .fake import FakeWallet # noqa: F401 from .lnbits import LNbitsWallet # noqa: F401 -from .lnmarkets import LNMarketsWallet # noqa: F401 from .lnd_grpc.lnd_grpc import LndRPCWallet # noqa: F401 from .lndrest import LndRestWallet # noqa: F401 +from .lnmarkets import LNMarketsWallet # noqa: F401 from .strike import StrikeWallet # noqa: F401 backend_settings = [ diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index eb847f1c..24c7d5c5 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -1,24 +1,20 @@ -from ..core.settings import settings -import os -import re -import httpx -import asyncio -import hmac import base64 +import hashlib +import hmac import json +import re import time -import hashlib from enum import Enum +from typing import Dict, Optional, Union +import httpx +from bolt11 import decode from loguru import logger -from typing import Dict, Union, Optional, AsyncGenerator - -from bolt11 import decode +from ..core.base import Amount, MeltQuote, Unit from ..core.helpers import fee_reserve from ..core.models import PostMeltQuoteRequest - -from ..core.base import Amount, MeltQuote, Unit +from ..core.settings import settings from .base import ( InvoiceResponse, LightningBackend, @@ -28,6 +24,7 @@ StatusResponse, ) + class Method(Enum): POST = 1 GET = 2 @@ -74,7 +71,7 @@ def __init__(self, unit: Unit, **kwargs): ) async def get_request_headers(self, method: Method, path: str, data: dict) -> dict: - timestamp = time.time_ns() // 1000000 # timestamp in milliseconds + timestamp = time.time_ns() // 10**6 # timestamp in milliseconds params = "" if method == Method.GET: for key, value in data.items(): From f108ed8444d3b20b30b72790bb7c4a5bdebe95b5 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 12 Aug 2024 10:17:46 +0200 Subject: [PATCH 04/26] small corrections --- cashu/lightning/lnmarkets.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 24c7d5c5..a10f27ff 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -59,11 +59,10 @@ def __init__(self, unit: Unit, **kwargs): if not passphrase: raise Exception("No API passphrase provided") - self.secret = secret #base64.b64decode(secret) + self.secret = secret self.headers: Dict[str, Union[str, int]] = { "LNM-ACCESS-KEY": access_key, - "LNM-ACCESS-PASSPHRASE": passphrase, - "Content-Type": "application/json" + "LNM-ACCESS-PASSPHRASE": passphrase } self.client = httpx.AsyncClient( @@ -91,6 +90,8 @@ async def get_request_headers(self, method: Method, path: str, data: dict) -> di headers = self.headers.copy() headers["LNM-ACCESS-TIMESTAMP"] = str(timestamp) headers["LNM-ACCESS-SIGNATURE"] = signature.decode() + if method == Method.POST: + headers["Content-Type"] = "application/json" logger.debug(f"{headers = }") return headers From 89208c33a07d681d3b323f619cc0405cae65a1b3 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 12 Aug 2024 15:48:38 +0200 Subject: [PATCH 05/26] amount usd equation fix + trying to fix fee conversion bug on `PaymentResponse` yielded by `pay_invoice`, with little success. --- cashu/lightning/lnmarkets.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index a10f27ff..3116469d 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -217,11 +217,10 @@ async def pay_invoice( # payment_preimage = ?? # no payment preimage by lnmarkets :( checking_id = data["id"] - payment_fee = int(data["fee"]) return PaymentResponse( ok=True, checking_id=checking_id, - fee=Amount(unit=Unit.sat, amount=payment_fee), + fee=Amount(unit=Unit.usd, amount=quote.fee_reserve), ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: @@ -309,11 +308,14 @@ async def get_payment_quote( data = r.json() price = data["bidPrice"] # BTC/USD - amount_usd = round((amount.amount / 10**8) * price, 2) + price = float(price) / 10**8 + logger.debug(f"bid price: {price} sat/USD") + amount_usd = round(float(amount.to(Unit.sat).amount) * price, 2) + logger.debug(f"amount USD: {amount_usd}") # We request a quote to pay a precise amount of sats from the usd balance, then calculate # the usd amount and usd fee reserve - data = {"amount": amount.to(Unit.sat), "currency": "btc"} + data = {"amount": amount.to(Unit.sat).amount, "currency": "btc"} path = "/v2/user/withdraw/susd" headers = await self.get_request_headers(Method.POST, path, data) @@ -326,7 +328,7 @@ async def get_payment_quote( # calculate fee based on returned sat `fee_reserve` data = r.json() - fee_reserve_usd = round((data["fee_reserve"] / 10**8) * price, 2) + fee_reserve_usd = round(float(data["fee_reserve"]) * price, 2) return PaymentQuoteResponse( checking_id=data["quote_id"], fee=Amount.from_float(fee_reserve_usd, self.unit), From d6f424e1eeac5788ac59104a2a68f6574fee5d5d Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 12 Aug 2024 19:00:03 +0200 Subject: [PATCH 06/26] remove sat support --- cashu/lightning/lnmarkets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 3116469d..d2d9dfd0 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -38,7 +38,7 @@ class LNMarketsWallet(LightningBackend): """https://docs.lnmarkets.com/api""" supports_mpp = False supports_incoming_payment_stream = False - supported_units = set([Unit.sat, Unit.usd]) + supported_units = set([Unit.usd]) def __init__(self, unit: Unit, **kwargs): self.assert_unit_supported(unit) From fed9d0b288b3555ff3d9e10ef3733d363621462b Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 12 Aug 2024 20:25:01 +0200 Subject: [PATCH 07/26] can now also specify paths in `access_key`, `secret`, `passphrase` --- cashu/lightning/lnmarkets.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index d2d9dfd0..9fbe0f8f 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -59,6 +59,17 @@ def __init__(self, unit: Unit, **kwargs): if not passphrase: raise Exception("No API passphrase provided") + # You can specify paths instead + if os.path.exists(access_key): + with open(access_key, "r") as f: + access_key = f.read() + if os.path.exists(secret): + with open(secret, "r") as f: + secret = f.read() + if os.path.exists(passphrase): + with open(passphrase, "r") as f: + passphrase = f.read() + self.secret = secret self.headers: Dict[str, Union[str, int]] = { "LNM-ACCESS-KEY": access_key, From 8140b19d1b3a279465a1688fda3a6979e507a38b Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 12 Aug 2024 20:28:45 +0200 Subject: [PATCH 08/26] fix missing import --- cashu/lightning/lnmarkets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 9fbe0f8f..5ba06b9c 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -2,6 +2,7 @@ import hashlib import hmac import json +import os import re import time from enum import Enum From 1aefc69512d7cdb0d6594e1579f63801cb78a7bc Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Tue, 13 Aug 2024 22:44:41 +0200 Subject: [PATCH 09/26] update `env.example`, remove mean comment about the hmac auth token --- .env.example | 7 +++++++ cashu/lightning/lnmarkets.py | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index c10fa187..c3c40bc8 100644 --- a/.env.example +++ b/.env.example @@ -106,6 +106,13 @@ MINT_BLINK_KEY=blink_abcdefgh # Use with StrikeWallet for BTC, USD, and EUR MINT_STRIKE_KEY=ABC123 +# Use with LNMarketsWallet for USD +# you can also specify paths from which `ACCESS_KEY`, `SECRET` and `PASSPHRASE` will be read +MINT_LNMARKETS_REST_URL="https://api.lnmarkets.com" +MINT_LNMARKETS_REST_ACCESS_KEY="" +MINT_LNMARKETS_REST_SECRET="" +MINT_LNMARKETS_REST_PASSPHRASE="" + # fee to reserve in percent of the amount LIGHTNING_FEE_PERCENT=1.0 # minimum fee to reserve diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 5ba06b9c..e344964c 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -149,8 +149,7 @@ async def create_invoice( data = None path = None if self.unit == Unit.usd: - # The ""signature"" of LNMarkets is the sketchiest thing I have ever seen. - # I don't know who came up with that. Anyway, we do this trick to avoid messing it up. + # We do this trick to avoid messing it up. amount_usd = float(amount.to_float_string()) amount_usd = int(amount_usd) if float(int(amount_usd)) == amount_usd else amount_usd data = {"amount": amount_usd, "currency": "usd"} From a7a6174a74083ad91939cb1dc7fdcc25f272ac61 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Tue, 20 Aug 2024 14:49:51 +0200 Subject: [PATCH 10/26] `lastPrice` instead of `bidPrice` --- cashu/lightning/lnmarkets.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index e344964c..33c67d4b 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -291,6 +291,7 @@ async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: self.assert_unit_supported(Unit[melt_quote.unit]) + logger.debug(f"{melt_quote = }") invoice_obj = decode(melt_quote.request) assert invoice_obj.amount_msat, "invoice has no amount." amount_msat = int(invoice_obj.amount_msat) @@ -318,7 +319,7 @@ async def get_payment_quote( r.raise_for_status() data = r.json() - price = data["bidPrice"] # BTC/USD + price = data["lastPrice"] # BTC/USD price = float(price) / 10**8 logger.debug(f"bid price: {price} sat/USD") amount_usd = round(float(amount.to(Unit.sat).amount) * price, 2) @@ -347,4 +348,4 @@ async def get_payment_quote( ) async def paid_invoices_stream(self): - raise NotImplementedError("paid_invoices_stream not implemented") \ No newline at end of file + raise NotImplementedError("paid_invoices_stream not implemented") From efab7cf082a7b581267fa34b4002a0e0a10352b0 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Fri, 30 Aug 2024 12:14:50 +0200 Subject: [PATCH 11/26] Better error messages --- cashu/lightning/lnmarkets.py | 54 ++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 33c67d4b..f9969e4f 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -24,6 +24,7 @@ PaymentStatus, StatusResponse, ) +from ..core.errors import CashuError class Method(Enum): @@ -35,6 +36,15 @@ class Method(Enum): def __str__(self): return self.name +def raise_if_err(r): + if r.status_code != 200: + if r.status_code >= 400: + error_message = r.json()['message'] + else: + error_message = r.text + logger.error(error_message) + raise CashuError(error_message) + class LNMarketsWallet(LightningBackend): """https://docs.lnmarkets.com/api""" supports_mpp = False @@ -111,11 +121,10 @@ async def status(self) -> StatusResponse: headers = await self.get_request_headers(Method.GET, "/v2/user", {}) try: r = await self.client.get(url=f"{self.endpoint}/v2/user", timeout=15, headers=headers) - r.raise_for_status() - except Exception as exc: - logger.error(f"Failed to connect to {self.endpoint} due to: {exc}") + raise_if_err(r) + except CashuError as exc: return StatusResponse( - error_message=f"Failed to connect to {self.endpoint} due to: {exc}", + error_message=f"Failed to connect to {self.endpoint} due to: {exc.detail}", balance=0, ) @@ -129,10 +138,6 @@ async def status(self) -> StatusResponse: ), balance=0, ) - if "detail" in data: - return StatusResponse( - error_message=f"LNMarkets error: {data['detail']}", balance=0 - ) if self.unit == Unit.usd: return StatusResponse(error_message=None, balance=data["synthetic_usd_balance"]) @@ -167,12 +172,11 @@ async def create_invoice( json=data, headers=headers, ) - r.raise_for_status() - except Exception as e: - logger.error("Error while creating invoice: "+str(e)) + raise_if_err(r) + except CashuError as e: return InvoiceResponse( ok=False, - error_message="Error while creating invoice: "+str(e), + error_message=f"Error while creating invoice: {e.detail}", ) data = None @@ -214,10 +218,9 @@ async def pay_invoice( headers=headers, timeout=None, ) - r.raise_for_status() - except Exception as e: - logger.error(f"LNMarkets withdrawal unsuccessful: {str(e)}") - return PaymentResponse(error_message=f"LNMarkets withdrawal unsuccessful: {str(e)}") + raise_if_err(r) + except CashuError as e: + return PaymentResponse(error_message=f"LNMarkets withdrawal unsuccessful: {e.detail}") try: data = r.json() @@ -243,9 +246,8 @@ async def get_invoice_status(self, checking_id: str) -> PaymentStatus: headers=headers, timeout=None, ) - r.raise_for_status() - except Exception as e: - logger.error(f"get invoice status unsuccessful: {str(e)}") + raise_if_err(r) + except CashuError: return PaymentStatus(paid=None) data = None @@ -267,9 +269,8 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: headers=headers, timeout=None, ) - r.raise_for_status() - except Exception as e: - logger.error(f"getting invoice status unsuccessful: {str(e)}") + raise_if_err(r) + except CashuError: return PaymentStatus(paid=None) try: @@ -291,13 +292,13 @@ async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: self.assert_unit_supported(Unit[melt_quote.unit]) - logger.debug(f"{melt_quote = }") invoice_obj = decode(melt_quote.request) assert invoice_obj.amount_msat, "invoice has no amount." amount_msat = int(invoice_obj.amount_msat) fees_msat = fee_reserve(amount_msat) fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) + # SAT -- unfortunately, there is no way of asking the fee_reserve to lnmarkets beforehand in this case. if self.unit == Unit.sat: return PaymentQuoteResponse( @@ -311,13 +312,12 @@ async def get_payment_quote( path = "/v2/futures/ticker" headers = await self.get_request_headers(Method.GET, path, {}) - # We let any eventual exception crash the request r = await self.client.get(f"{self.endpoint}{path}", headers=headers, timeout=None, ) - r.raise_for_status() - + raise_if_err(r) + data = r.json() price = data["lastPrice"] # BTC/USD price = float(price) / 10**8 @@ -336,7 +336,7 @@ async def get_payment_quote( headers=headers, timeout=None, ) - r.raise_for_status() + raise_if_err(r) # calculate fee based on returned sat `fee_reserve` data = r.json() From dca102f015b6aad94a0de6477eaa933e14e9b431 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Fri, 30 Aug 2024 12:16:13 +0200 Subject: [PATCH 12/26] make format --- cashu/lightning/lnmarkets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index f9969e4f..77b08d47 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -13,6 +13,7 @@ from loguru import logger from ..core.base import Amount, MeltQuote, Unit +from ..core.errors import CashuError from ..core.helpers import fee_reserve from ..core.models import PostMeltQuoteRequest from ..core.settings import settings @@ -24,7 +25,6 @@ PaymentStatus, StatusResponse, ) -from ..core.errors import CashuError class Method(Enum): From 2ba4b4d7cf2abe09ce981b028aa0edf012cb5d66 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 5 Sep 2024 16:55:59 +0200 Subject: [PATCH 13/26] fixed fee estimation + enable unit sat support --- cashu/lightning/lnmarkets.py | 36 ++++++++++-------------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 77b08d47..385c81cc 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -8,6 +8,7 @@ from enum import Enum from typing import Dict, Optional, Union +from math import ceil import httpx from bolt11 import decode from loguru import logger @@ -38,7 +39,7 @@ def __str__(self): def raise_if_err(r): if r.status_code != 200: - if r.status_code >= 400: + if 400 <= r.status_code < 500: error_message = r.json()['message'] else: error_message = r.text @@ -49,7 +50,7 @@ class LNMarketsWallet(LightningBackend): """https://docs.lnmarkets.com/api""" supports_mpp = False supports_incoming_payment_stream = False - supported_units = set([Unit.usd]) + supported_units = set([Unit.usd, Unit.sat]) def __init__(self, unit: Unit, **kwargs): self.assert_unit_supported(unit) @@ -154,7 +155,7 @@ async def create_invoice( data = None path = None if self.unit == Unit.usd: - # We do this trick to avoid messing it up. + # We do this trick to avoid messing up the signature. amount_usd = float(amount.to_float_string()) amount_usd = int(amount_usd) if float(int(amount_usd)) == amount_usd else amount_usd data = {"amount": amount_usd, "currency": "usd"} @@ -295,36 +296,19 @@ async def get_payment_quote( invoice_obj = decode(melt_quote.request) assert invoice_obj.amount_msat, "invoice has no amount." amount_msat = int(invoice_obj.amount_msat) - fees_msat = fee_reserve(amount_msat) - fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) - # SAT -- unfortunately, there is no way of asking the fee_reserve to lnmarkets beforehand in this case. + # SAT: the max fee is reportedly min(100, 0.5% * amount_sat) if self.unit == Unit.sat: + amount_sat = amount.to(Unit.sat).amount + max_fee = min(101, ceil(5e-3 * amount_sat)) return PaymentQuoteResponse( checking_id=invoice_obj.payment_hash, - fee=fees.to(self.unit, round="up"), + fee=Amount(self.unit, max_fee), amount=amount.to(self.unit, round="up"), ) # sUSD else: - # We get the current rate - path = "/v2/futures/ticker" - headers = await self.get_request_headers(Method.GET, path, {}) - - r = await self.client.get(f"{self.endpoint}{path}", - headers=headers, - timeout=None, - ) - raise_if_err(r) - - data = r.json() - price = data["lastPrice"] # BTC/USD - price = float(price) / 10**8 - logger.debug(f"bid price: {price} sat/USD") - amount_usd = round(float(amount.to(Unit.sat).amount) * price, 2) - logger.debug(f"amount USD: {amount_usd}") - # We request a quote to pay a precise amount of sats from the usd balance, then calculate # the usd amount and usd fee reserve data = {"amount": amount.to(Unit.sat).amount, "currency": "btc"} @@ -338,9 +322,9 @@ async def get_payment_quote( ) raise_if_err(r) - # calculate fee based on returned sat `fee_reserve` data = r.json() - fee_reserve_usd = round(float(data["fee_reserve"]) * price, 2) + fee_reserve_usd = float(data["fee_reserve"]) + amount_usd = float(data["amount"]) return PaymentQuoteResponse( checking_id=data["quote_id"], fee=Amount.from_float(fee_reserve_usd, self.unit), From 6bc4b54f8206a74d8ef309707a70d1ae19140687 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Thu, 5 Sep 2024 16:56:25 +0200 Subject: [PATCH 14/26] format --- cashu/lightning/lnmarkets.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 385c81cc..93f69462 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -6,16 +6,15 @@ import re import time from enum import Enum +from math import ceil from typing import Dict, Optional, Union -from math import ceil import httpx from bolt11 import decode from loguru import logger from ..core.base import Amount, MeltQuote, Unit from ..core.errors import CashuError -from ..core.helpers import fee_reserve from ..core.models import PostMeltQuoteRequest from ..core.settings import settings from .base import ( From 67128e4986a6b28bf5add7afe430122dc050cf5e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 6 Sep 2024 12:19:31 +0200 Subject: [PATCH 15/26] add constants and exhaustive if..else --- cashu/lightning/lnmarkets.py | 80 +++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 93f69462..19feb0b5 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -26,6 +26,9 @@ StatusResponse, ) +SAT_MAX_FEE_PERCENT = 1 # 1% of the amount in satoshis +SAT_MIN_FEE_SAT = 101 # 101 satoshis + class Method(Enum): POST = 1 @@ -36,17 +39,20 @@ class Method(Enum): def __str__(self): return self.name + def raise_if_err(r): if r.status_code != 200: if 400 <= r.status_code < 500: - error_message = r.json()['message'] + error_message = r.json()["message"] else: error_message = r.text logger.error(error_message) raise CashuError(error_message) + class LNMarketsWallet(LightningBackend): """https://docs.lnmarkets.com/api""" + supports_mpp = False supports_incoming_payment_stream = False supported_units = set([Unit.usd, Unit.sat]) @@ -56,9 +62,12 @@ def __init__(self, unit: Unit, **kwargs): self.unit = unit self.endpoint = settings.mint_lnmarkets_rest_url or "https://api.lnmarkets.com" - if re.match(r"^https?://[a-zA-Z0-9.-]+(?:/[a-zA-Z0-9.-]+)*$", self.endpoint) is None: + if ( + re.match(r"^https?://[a-zA-Z0-9.-]+(?:/[a-zA-Z0-9.-]+)*$", self.endpoint) + is None + ): raise Exception("Invalid API endpoint") - + access_key = settings.mint_lnmarkets_rest_access_key secret = settings.mint_lnmarkets_rest_secret passphrase = settings.mint_lnmarkets_rest_passphrase @@ -84,15 +93,13 @@ def __init__(self, unit: Unit, **kwargs): self.secret = secret self.headers: Dict[str, Union[str, int]] = { "LNM-ACCESS-KEY": access_key, - "LNM-ACCESS-PASSPHRASE": passphrase + "LNM-ACCESS-PASSPHRASE": passphrase, } - self.client = httpx.AsyncClient( - verify=not settings.debug - ) + self.client = httpx.AsyncClient(verify=not settings.debug) async def get_request_headers(self, method: Method, path: str, data: dict) -> dict: - timestamp = time.time_ns() // 10**6 # timestamp in milliseconds + timestamp = time.time_ns() // 10**6 # timestamp in milliseconds params = "" if method == Method.GET: for key, value in data.items(): @@ -103,11 +110,13 @@ async def get_request_headers(self, method: Method, path: str, data: dict) -> di else: raise Exception("Method not allowed. Something is wrong with the code.") - signature = base64.b64encode(hmac.new( - self.secret.encode(), - f"{timestamp}{str(method)}{path}{params}".encode(), # bytes from utf-8 string - hashlib.sha256, - ).digest()) + signature = base64.b64encode( + hmac.new( + self.secret.encode(), + f"{timestamp}{str(method)}{path}{params}".encode(), # bytes from utf-8 string + hashlib.sha256, + ).digest() + ) logger.debug(f"{timestamp}{str(method)}{path}{params}") headers = self.headers.copy() headers["LNM-ACCESS-TIMESTAMP"] = str(timestamp) @@ -120,7 +129,9 @@ async def get_request_headers(self, method: Method, path: str, data: dict) -> di async def status(self) -> StatusResponse: headers = await self.get_request_headers(Method.GET, "/v2/user", {}) try: - r = await self.client.get(url=f"{self.endpoint}/v2/user", timeout=15, headers=headers) + r = await self.client.get( + url=f"{self.endpoint}/v2/user", timeout=15, headers=headers + ) raise_if_err(r) except CashuError as exc: return StatusResponse( @@ -140,9 +151,11 @@ async def status(self) -> StatusResponse: ) if self.unit == Unit.usd: - return StatusResponse(error_message=None, balance=data["synthetic_usd_balance"]) + return StatusResponse( + error_message=None, balance=data["synthetic_usd_balance"] + ) return StatusResponse(error_message=None, balance=data["balance"]) - + async def create_invoice( self, amount: Amount, @@ -156,7 +169,9 @@ async def create_invoice( if self.unit == Unit.usd: # We do this trick to avoid messing up the signature. amount_usd = float(amount.to_float_string()) - amount_usd = int(amount_usd) if float(int(amount_usd)) == amount_usd else amount_usd + amount_usd = ( + int(amount_usd) if float(int(amount_usd)) == amount_usd else amount_usd + ) data = {"amount": amount_usd, "currency": "usd"} path = "/v2/user/deposit/susd" else: @@ -183,14 +198,14 @@ async def create_invoice( try: data = r.json() except Exception: - logger.error( f"Received invalid response from {self.endpoint}: {r.text}") + logger.error(f"Received invalid response from {self.endpoint}: {r.text}") return InvoiceResponse( ok=False, error_message=( f"Received invalid response from {self.endpoint}: {r.text}" ), ) - + checking_id, payment_request = data["depositId"], data["paymentRequest"] assert isinstance(checking_id, str) and isinstance(payment_request, str) @@ -199,7 +214,7 @@ async def create_invoice( checking_id=checking_id, payment_request=payment_request, ) - + async def pay_invoice( self, quote: MeltQuote, fee_limit_msat: int ) -> PaymentResponse: @@ -220,14 +235,18 @@ async def pay_invoice( ) raise_if_err(r) except CashuError as e: - return PaymentResponse(error_message=f"LNMarkets withdrawal unsuccessful: {e.detail}") + return PaymentResponse( + error_message=f"LNMarkets withdrawal unsuccessful: {e.detail}" + ) try: data = r.json() except Exception: logger.error(f"LNMarkets withdrawal unsuccessful: {r.text}") - return PaymentResponse(error_message=f"LNMarkets withdrawal unsuccessful: {r.text}") - + return PaymentResponse( + error_message=f"LNMarkets withdrawal unsuccessful: {r.text}" + ) + # payment_preimage = ?? # no payment preimage by lnmarkets :( checking_id = data["id"] @@ -249,7 +268,7 @@ async def get_invoice_status(self, checking_id: str) -> PaymentStatus: raise_if_err(r) except CashuError: return PaymentStatus(paid=None) - + data = None try: data = r.json() @@ -278,7 +297,7 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: except Exception: logger.error(f"getting invoice status unsuccessful: {r.text}") return PaymentStatus(paid=None) - + logger.debug(f"payment status: {data}") if not data["success"]: return PaymentStatus(paid=None) @@ -300,21 +319,22 @@ async def get_payment_quote( # SAT: the max fee is reportedly min(100, 0.5% * amount_sat) if self.unit == Unit.sat: amount_sat = amount.to(Unit.sat).amount - max_fee = min(101, ceil(5e-3 * amount_sat)) + max_fee = min(SAT_MIN_FEE_SAT, ceil(SAT_MAX_FEE_PERCENT / 100 * amount_sat)) return PaymentQuoteResponse( checking_id=invoice_obj.payment_hash, fee=Amount(self.unit, max_fee), amount=amount.to(self.unit, round="up"), ) # sUSD - else: + elif self.unit == Unit.usd: # We request a quote to pay a precise amount of sats from the usd balance, then calculate # the usd amount and usd fee reserve data = {"amount": amount.to(Unit.sat).amount, "currency": "btc"} path = "/v2/user/withdraw/susd" headers = await self.get_request_headers(Method.POST, path, data) - r = await self.client.post(f"{self.endpoint}{path}", + r = await self.client.post( + f"{self.endpoint}{path}", json=data, headers=headers, timeout=None, @@ -327,8 +347,10 @@ async def get_payment_quote( return PaymentQuoteResponse( checking_id=data["quote_id"], fee=Amount.from_float(fee_reserve_usd, self.unit), - amount=Amount.from_float(amount_usd, self.unit) + amount=Amount.from_float(amount_usd, self.unit), ) + else: + raise NotImplementedError() async def paid_invoices_stream(self): raise NotImplementedError("paid_invoices_stream not implemented") From 2cd3e1b95f955a6746933f7ca812b09c17d7ed87 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 6 Sep 2024 17:03:43 +0200 Subject: [PATCH 16/26] hide lnmarkets secrets --- cashu/mint/startup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index f846061f..dca26f74 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -35,6 +35,9 @@ "mint_lnd_rest_invoice_macaroon", "mint_corelightning_rest_macaroon", "mint_clnrest_rune", + "mint_lnmarkets_rest_access_key", + "mint_lnmarkets_rest_passphrase", + "mint_lnmarkets_rest_secret", ]: value = "********" if value is not None else None From 1e106e5f03b23ce0834e2a210338dce8475a2d36 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 6 Sep 2024 17:25:54 +0200 Subject: [PATCH 17/26] fee min->max --- cashu/lightning/lnmarkets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 19feb0b5..924afcba 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -73,11 +73,11 @@ def __init__(self, unit: Unit, **kwargs): passphrase = settings.mint_lnmarkets_rest_passphrase if not access_key: - raise Exception("No API access key provided") + raise Exception("No LNMarkets API access key provided") if not secret: - raise Exception("No API secret provided") + raise Exception("No LNMarkets API secret provided") if not passphrase: - raise Exception("No API passphrase provided") + raise Exception("No LNMarkets API passphrase provided") # You can specify paths instead if os.path.exists(access_key): @@ -319,7 +319,7 @@ async def get_payment_quote( # SAT: the max fee is reportedly min(100, 0.5% * amount_sat) if self.unit == Unit.sat: amount_sat = amount.to(Unit.sat).amount - max_fee = min(SAT_MIN_FEE_SAT, ceil(SAT_MAX_FEE_PERCENT / 100 * amount_sat)) + max_fee = max(SAT_MIN_FEE_SAT, ceil(SAT_MAX_FEE_PERCENT / 100 * amount_sat)) return PaymentQuoteResponse( checking_id=invoice_obj.payment_hash, fee=Amount(self.unit, max_fee), From bd4ef85b5f96178e820c1f403646ad3b95457b02 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 6 Sep 2024 17:57:46 +0200 Subject: [PATCH 18/26] adjust logs --- cashu/lightning/lnmarkets.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 924afcba..ceb4f936 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -117,13 +117,11 @@ async def get_request_headers(self, method: Method, path: str, data: dict) -> di hashlib.sha256, ).digest() ) - logger.debug(f"{timestamp}{str(method)}{path}{params}") headers = self.headers.copy() headers["LNM-ACCESS-TIMESTAMP"] = str(timestamp) headers["LNM-ACCESS-SIGNATURE"] = signature.decode() if method == Method.POST: headers["Content-Type"] = "application/json" - logger.debug(f"{headers = }") return headers async def status(self) -> StatusResponse: @@ -235,17 +233,13 @@ async def pay_invoice( ) raise_if_err(r) except CashuError as e: - return PaymentResponse( - error_message=f"LNMarkets withdrawal unsuccessful: {e.detail}" - ) + return PaymentResponse(error_message=f"payment failed: {e.detail}") try: data = r.json() except Exception: - logger.error(f"LNMarkets withdrawal unsuccessful: {r.text}") - return PaymentResponse( - error_message=f"LNMarkets withdrawal unsuccessful: {r.text}" - ) + logger.error(f"payment failed: {r.text}") + return PaymentResponse(error_message=f"payment failed: {r.text}") # payment_preimage = ?? # no payment preimage by lnmarkets :( From ab6baa0fba59e970f1c33bccdb2565e56045637f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 6 Sep 2024 18:01:08 +0200 Subject: [PATCH 19/26] fix fee unit --- cashu/lightning/lnmarkets.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index ceb4f936..b3004dcb 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -241,13 +241,12 @@ async def pay_invoice( logger.error(f"payment failed: {r.text}") return PaymentResponse(error_message=f"payment failed: {r.text}") - # payment_preimage = ?? - # no payment preimage by lnmarkets :( + # lnmarkets does not provide a payment_preimage :( checking_id = data["id"] return PaymentResponse( ok=True, checking_id=checking_id, - fee=Amount(unit=Unit.usd, amount=quote.fee_reserve), + fee=Amount(unit=self.unit, amount=quote.fee_reserve), ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: From c104a28a797ef40bc874ed37923a509e461c9419 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 6 Sep 2024 18:06:44 +0200 Subject: [PATCH 20/26] fee return working for sat --- cashu/lightning/lnmarkets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index b3004dcb..55bc33de 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -243,10 +243,11 @@ async def pay_invoice( # lnmarkets does not provide a payment_preimage :( checking_id = data["id"] + fee_paid = int(data["fee"]) return PaymentResponse( ok=True, checking_id=checking_id, - fee=Amount(unit=self.unit, amount=quote.fee_reserve), + fee=Amount(unit=self.unit, amount=fee_paid), ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: From 8b9ce72b5421eb01187ef2a4289edd2c425a775f Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 9 Sep 2024 11:05:55 +0200 Subject: [PATCH 21/26] fee return conversion for unit usd --- cashu/lightning/lnmarkets.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 55bc33de..c6dd471d 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -220,9 +220,29 @@ async def pay_invoice( data = {"invoice": quote.request} path = "/v2/user/withdraw" + futures_ticker_path = "/v2/futures/ticker" + btc_price = 0.0 + + # * If USD, we set the `quote_id` of the request to the checking_id + # * If USD, we fetch the ticker price for conversion. + # This is a TEMPORARY measure until we can get the correct return fee + # from LNMarkets if self.unit == Unit.usd: data["quote_id"] = quote.checking_id - + price_data = None + try: + r = await self.client.get( + url=f"{self.endpoint}{futures_ticker_path}" + ) + raise_if_err(r) + price_data = r.json() + except (CashuError, json.JSONDecodeError) as e: + if isinstance(e, CashuError): + return PaymentResponse(error_message=f"payment failed: {e.detail}") + elif isinstance(e, json.JSONDecodeError): + return PaymentResponse(error_message=f"payment failed: {str(e)}") + btc_price = float(price_data["lastPrice"]) + headers = await self.get_request_headers(Method.POST, path, data) try: r = await self.client.post( @@ -244,6 +264,11 @@ async def pay_invoice( # lnmarkets does not provide a payment_preimage :( checking_id = data["id"] fee_paid = int(data["fee"]) + + # if USD, we need to convert the returned fee: sat -> cents + if self.unit == Unit.usd: + fee_paid_usd = fee_paid / 1e8 * btc_price # sat -> usd + fee_paid = ceil(fee_paid_usd * 100) # usd -> cents return PaymentResponse( ok=True, checking_id=checking_id, @@ -310,7 +335,7 @@ async def get_payment_quote( amount_msat = int(invoice_obj.amount_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) - # SAT: the max fee is reportedly min(100, 0.5% * amount_sat) + # SAT: the max fee is reportedly max(100, 0.5% * amount_sat) if self.unit == Unit.sat: amount_sat = amount.to(Unit.sat).amount max_fee = max(SAT_MIN_FEE_SAT, ceil(SAT_MAX_FEE_PERCENT / 100 * amount_sat)) From cb33b4f0aa058943eb5eb3e8a860ed774ad464ef Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 9 Sep 2024 11:50:46 +0200 Subject: [PATCH 22/26] explicit utf-8 encoding --- cashu/lightning/lnmarkets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index c6dd471d..cd99ca6c 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -112,14 +112,14 @@ async def get_request_headers(self, method: Method, path: str, data: dict) -> di signature = base64.b64encode( hmac.new( - self.secret.encode(), - f"{timestamp}{str(method)}{path}{params}".encode(), # bytes from utf-8 string + self.secret.encode("utf-8"), + f"{timestamp}{str(method)}{path}{params}".encode("utf-8"), # bytes from utf-8 string hashlib.sha256, ).digest() ) headers = self.headers.copy() headers["LNM-ACCESS-TIMESTAMP"] = str(timestamp) - headers["LNM-ACCESS-SIGNATURE"] = signature.decode() + headers["LNM-ACCESS-SIGNATURE"] = signature.decode("utf-8") if method == Method.POST: headers["Content-Type"] = "application/json" return headers From b9a41b288649b179600de9845d60dc0798a32f5f Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Fri, 18 Oct 2024 15:39:19 +0200 Subject: [PATCH 23/26] keep up with new melt logic --- cashu/lightning/lnmarkets.py | 41 ++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index cd99ca6c..a30d4f05 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -23,6 +23,7 @@ PaymentQuoteResponse, PaymentResponse, PaymentStatus, + PaymentResult, StatusResponse, ) @@ -238,9 +239,15 @@ async def pay_invoice( price_data = r.json() except (CashuError, json.JSONDecodeError) as e: if isinstance(e, CashuError): - return PaymentResponse(error_message=f"payment failed: {e.detail}") + return PaymentResponse( + result=PaymentResult.FAILED, + error_message=f"payment failed: {e.detail}" + ) elif isinstance(e, json.JSONDecodeError): - return PaymentResponse(error_message=f"payment failed: {str(e)}") + return PaymentResponse( + result=PaymentResult.FAILED, + error_message=f"payment failed: {str(e)}" + ) btc_price = float(price_data["lastPrice"]) headers = await self.get_request_headers(Method.POST, path, data) @@ -253,13 +260,19 @@ async def pay_invoice( ) raise_if_err(r) except CashuError as e: - return PaymentResponse(error_message=f"payment failed: {e.detail}") + return PaymentResponse( + result=PaymentResult.UNKNOWN, + error_message=f"payment might have failed: {e.detail}" + ) try: data = r.json() except Exception: - logger.error(f"payment failed: {r.text}") - return PaymentResponse(error_message=f"payment failed: {r.text}") + logger.error(f"payment might have failed: {r.text}") + return PaymentResponse( + result=PaymentResult.UNKNOWN, + error_message=f"payment might have failed: {r.text}" + ) # lnmarkets does not provide a payment_preimage :( checking_id = data["id"] @@ -270,7 +283,7 @@ async def pay_invoice( fee_paid_usd = fee_paid / 1e8 * btc_price # sat -> usd fee_paid = ceil(fee_paid_usd * 100) # usd -> cents return PaymentResponse( - ok=True, + result=PaymentResult.PENDING, checking_id=checking_id, fee=Amount(unit=self.unit, amount=fee_paid), ) @@ -286,15 +299,15 @@ async def get_invoice_status(self, checking_id: str) -> PaymentStatus: ) raise_if_err(r) except CashuError: - return PaymentStatus(paid=None) + return PaymentStatus(result=PaymentResult.UNKNOWN) data = None try: data = r.json() except Exception: logger.error(f"get invoice status unsuccessful: {r.text}") - return PaymentStatus(paid=None) - return PaymentStatus(paid=data["success"]) + return PaymentStatus(result=PaymentResult.UNKNOWN) + return PaymentStatus(result=PaymentResult.SETTLED if data["success"] else PaymentResult.FAILED) async def get_payment_status(self, checking_id: str) -> PaymentStatus: path = f"/v2/user/withdrawals/{checking_id}" @@ -309,20 +322,20 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: ) raise_if_err(r) except CashuError: - return PaymentStatus(paid=None) + return PaymentStatus(result=PaymentResult.UNKNOWN) try: data = r.json() except Exception: logger.error(f"getting invoice status unsuccessful: {r.text}") - return PaymentStatus(paid=None) + return PaymentStatus(result=PaymentResult.UNKNOWN) logger.debug(f"payment status: {data}") - if not data["success"]: - return PaymentStatus(paid=None) + if not "success" in data: + return PaymentStatus(result=PaymentResult.UNKNOWN) return PaymentStatus( - paid=data["success"], + result=PaymentResult.SETTLED if data["success"] else PaymentResult.FAILED, fee=Amount(unit=Unit.sat, amount=int(data["fee"])), ) From b39636fc3bfeb0bd5b9aa084bb9f89fbef2303f9 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Fri, 18 Oct 2024 15:43:26 +0200 Subject: [PATCH 24/26] make format --- cashu/lightning/lnmarkets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index a30d4f05..cd16894f 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -22,8 +22,8 @@ LightningBackend, PaymentQuoteResponse, PaymentResponse, - PaymentStatus, PaymentResult, + PaymentStatus, StatusResponse, ) @@ -331,7 +331,7 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: return PaymentStatus(result=PaymentResult.UNKNOWN) logger.debug(f"payment status: {data}") - if not "success" in data: + if "success" not in data: return PaymentStatus(result=PaymentResult.UNKNOWN) return PaymentStatus( From 4994ddaa9906b50380bec0830447cae12ccf0674 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Mon, 4 Nov 2024 14:28:55 +0100 Subject: [PATCH 25/26] `get_payment_status` FAILED/PENDING logic with timeout. --- cashu/lightning/lnmarkets.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index cd16894f..99df24a3 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -307,7 +307,7 @@ async def get_invoice_status(self, checking_id: str) -> PaymentStatus: except Exception: logger.error(f"get invoice status unsuccessful: {r.text}") return PaymentStatus(result=PaymentResult.UNKNOWN) - return PaymentStatus(result=PaymentResult.SETTLED if data["success"] else PaymentResult.FAILED) + return PaymentStatus(result=PaymentResult.SETTLED if data["success"] else PaymentResult.PENDING) async def get_payment_status(self, checking_id: str) -> PaymentStatus: path = f"/v2/user/withdrawals/{checking_id}" @@ -334,10 +334,18 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: if "success" not in data: return PaymentStatus(result=PaymentResult.UNKNOWN) - return PaymentStatus( - result=PaymentResult.SETTLED if data["success"] else PaymentResult.FAILED, - fee=Amount(unit=Unit.sat, amount=int(data["fee"])), - ) + if data["success"]: + return PaymentStatus( + result=PaymentResult.SETTLED, + fee=Amount(unit=Unit.sat, amount=int(data["fee"])) + ) + else: + # TIMEOUT 30 seconds + now = int(time.time()) + if 0 <= (now - int(data["ts"])) < 30: + return PaymentStatus(result=PaymentResult.PENDING) + else: + return PaymentStatus(result=PaymentResult.FAILED) async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest From c510540965a003cce0d43ccbf6366b24f69d0d24 Mon Sep 17 00:00:00 2001 From: lollerfirst Date: Tue, 12 Nov 2024 17:50:42 +0100 Subject: [PATCH 26/26] payment timestamp is in milliseconds --- cashu/lightning/lnmarkets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cashu/lightning/lnmarkets.py b/cashu/lightning/lnmarkets.py index 99df24a3..0ff5a55f 100644 --- a/cashu/lightning/lnmarkets.py +++ b/cashu/lightning/lnmarkets.py @@ -342,7 +342,8 @@ async def get_payment_status(self, checking_id: str) -> PaymentStatus: else: # TIMEOUT 30 seconds now = int(time.time()) - if 0 <= (now - int(data["ts"])) < 30: + payment_timestamp = int(data["ts"]) // 1000 + if 0 <= (now - payment_timestamp) < 30: return PaymentStatus(result=PaymentResult.PENDING) else: return PaymentStatus(result=PaymentResult.FAILED)