diff --git a/lnbits/wallets/sparkl2.py b/lnbits/wallets/sparkl2.py index e565fa884..fa2394520 100644 --- a/lnbits/wallets/sparkl2.py +++ b/lnbits/wallets/sparkl2.py @@ -80,22 +80,40 @@ class SparkL2Wallet(Wallet): async def status(self) -> StatusResponse: try: - res = await self._request("POST", "/v1/balance") - status = res.get("status") + r = await self.client.post("/v1/balance", timeout=30) + r.raise_for_status() + data = r.json() + if not isinstance(data, dict) or len(data) == 0: + return StatusResponse("no data", 0) + + error_message = self._extract_error_message(data) + if error_message: + return StatusResponse(self._server_error_message(error_message), 0) + + status = data.get("status") if status == "missing_mnemonic": await self._check_sidecar_mnemonic() return StatusResponse("Spark sidecar mnemonic not set", 0) - balance_msat = res.get("balance_msat") + balance_msat = data.get("balance_msat") if balance_msat is not None: return StatusResponse(None, int(balance_msat)) - balance_sats = res.get("balance_sats") + balance_sats = data.get("balance_sats") if balance_sats is None: - return StatusResponse("Spark sidecar: missing balance.", 0) + return StatusResponse("no data", 0) return StatusResponse(None, int(balance_sats) * 1000) + except json.JSONDecodeError as e: + logger.warning(e) + return StatusResponse("Server error: 'invalid json response'", 0) + except httpx.HTTPStatusError as e: + logger.warning(e) + error_message = self._extract_http_error_message(e.response) + if error_message: + return StatusResponse(self._server_error_message(error_message), 0) + return StatusResponse(self._connect_error_message(), 0) except Exception as e: logger.warning(e) - return StatusResponse(f"Spark sidecar status error: {e}", 0) + return StatusResponse(self._connect_error_message(), 0) async def create_invoice( self, @@ -121,13 +139,28 @@ class SparkL2Wallet(Wallet): "description_hash": description_hash_hex, "expiry_seconds": expiry_secs, } - res = await self._request("POST", "/v1/invoices", payload) - bolt11 = res.get("payment_request") - checking_id = res.get("checking_id") + r = await self.client.post("/v1/invoices", json=payload, timeout=30) + r.raise_for_status() + data = r.json() + + if not isinstance(data, dict): + return InvoiceResponse( + ok=False, error_message=self._server_error_message(r.text) + ) + + error_message = self._extract_error_message(data) + if error_message: + return InvoiceResponse( + ok=False, + error_message=self._server_error_message(error_message), + ) + + bolt11 = data.get("payment_request") + checking_id = data.get("checking_id") if not bolt11 or not checking_id: return InvoiceResponse( ok=False, - error_message="Spark sidecar invoice response missing fields.", + error_message="Server error: 'missing required fields'", ) self.pending_invoices.append(checking_id) @@ -135,10 +168,24 @@ class SparkL2Wallet(Wallet): ok=True, payment_request=bolt11, checking_id=checking_id, - preimage=res.get("preimage", None), + preimage=data.get("preimage", None), ) + except json.JSONDecodeError: + return InvoiceResponse( + ok=False, error_message="Server error: 'invalid json response'" + ) + except httpx.HTTPStatusError as e: + logger.warning(e) + error_message = self._extract_http_error_message(e.response) + if error_message: + return InvoiceResponse( + ok=False, + error_message=self._server_error_message(error_message), + ) + return InvoiceResponse(ok=False, error_message=self._connect_error_message()) except Exception as e: - return InvoiceResponse(ok=False, error_message=str(e)) + logger.warning(e) + return InvoiceResponse(ok=False, error_message=self._connect_error_message()) async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: try: @@ -158,27 +205,53 @@ class SparkL2Wallet(Wallet): "max_fee_sats": max_fee_sats, "payment_hash": payment_hash, } - res = await self._request("POST", "/v1/payments", payload) - checking_id = payment_hash or res.get("checking_id") + r = await self.client.post("/v1/payments", json=payload, timeout=30) + r.raise_for_status() + data = r.json() + + if not isinstance(data, dict): + return PaymentResponse(error_message=self._server_error_message(r.text)) + + error_message = self._extract_error_message(data) + if error_message: + return PaymentResponse(error_message=error_message) + + if len(data) == 0: + return PaymentResponse( + error_message="Server error: 'missing required fields'" + ) + + status = data.get("status") + fee_msat = data.get("fee_msat") + preimage = data.get("preimage") + ok = self._map_payment_ok(status) if status else None + if ok is False: + return PaymentResponse(ok=False) + + checking_id = payment_hash or data.get("checking_id") if not checking_id: return PaymentResponse( - ok=False, - error_message="Spark sidecar payment response missing checking_id.", + error_message="Server error: 'missing required fields'" ) - status = res.get("status") - fee_msat = res.get("fee_msat") - ok = None - if status: - ok = self._map_payment_ok(status) + return PaymentResponse( ok=ok, checking_id=checking_id, fee_msat=int(fee_msat) if fee_msat is not None else None, - preimage=res.get("preimage"), + preimage=preimage, ) + except json.JSONDecodeError: + return PaymentResponse(error_message="Server error: 'invalid json response'") + except httpx.HTTPStatusError as e: + logger.warning(e) + error_message = self._extract_http_error_message(e.response) + if error_message: + return PaymentResponse(error_message=error_message) + return PaymentResponse(error_message=self._connect_error_message()) except Exception as e: - return PaymentResponse(ok=False, error_message=str(e)) + logger.warning(e) + return PaymentResponse(error_message=self._connect_error_message()) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: try: @@ -330,6 +403,32 @@ class SparkL2Wallet(Wallet): return False return None + def _connect_error_message(self) -> str: + return f"Unable to connect to {self.endpoint}." + + @staticmethod + def _server_error_message(message: str) -> str: + return f"Server error: '{message}'" + + @staticmethod + def _extract_error_message(data: Any) -> str | None: + if not isinstance(data, dict): + return None + for key in ("error", "message", "detail", "reason"): + value = data.get(key) + if value: + return str(value) + return None + + def _extract_http_error_message(self, response: httpx.Response | None) -> str | None: + if response is None: + return None + try: + data = response.json() + except Exception: + return None + return self._extract_error_message(data) + async def _check_sidecar_mnemonic(self): if settings.spark_l2_mnemonic: valid = mnemonic_is_valid(settings.spark_l2_mnemonic) diff --git a/tests/wallets/fixtures/json/fixtures_rest.json b/tests/wallets/fixtures/json/fixtures_rest.json index 3206b0e5b..3c6a59df9 100644 --- a/tests/wallets/fixtures/json/fixtures_rest.json +++ b/tests/wallets/fixtures/json/fixtures_rest.json @@ -49,6 +49,14 @@ "phoenixd_api_password": "f171ba022a764e679eef950b21fb1c04f171ba022a764e679eef950b21fb1c04", "user_agent": "LNbits/Tests" } + }, + "sparkl2": { + "wallet_class": "SparkL2Wallet", + "settings": { + "spark_l2_external_endpoint": "http://127.0.0.1:8555", + "spark_l2_external_api_key": "mock-spark-l2-api-key", + "user_agent": "LNbits/Tests" + } } }, "functions": { @@ -115,6 +123,16 @@ }, "method": "GET" } + }, + "sparkl2": { + "status_endpoint": { + "uri": "/v1/balance", + "headers": { + "X-Api-Key": "mock-spark-l2-api-key", + "User-Agent": "LNbits/Tests" + }, + "method": "POST" + } } }, "tests": [ @@ -190,6 +208,16 @@ } } ] + }, + "sparkl2": { + "status_endpoint": [ + { + "response_type": "json", + "response": { + "balance_sats": 55 + } + } + ] } } }, @@ -255,6 +283,16 @@ "response": "test-error" } ] + }, + "sparkl2": { + "status_endpoint": [ + { + "response_type": "json", + "response": { + "error": "\"test-error\"" + } + } + ] } } }, @@ -313,6 +351,14 @@ "response": {} } ] + }, + "sparkl2": { + "status_endpoint": [ + { + "response_type": "json", + "response": {} + } + ] } } }, @@ -371,6 +417,14 @@ "response": "data-not-json" } ] + }, + "sparkl2": { + "status_endpoint": [ + { + "response_type": "data", + "response": "data-not-json" + } + ] } } }, @@ -447,6 +501,17 @@ } } ] + }, + "sparkl2": { + "status_endpoint": [ + { + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] } } }, @@ -523,6 +588,16 @@ }, "method": "POST" } + }, + "sparkl2": { + "create_invoice_endpoint": { + "uri": "/v1/invoices", + "headers": { + "X-Api-Key": "mock-spark-l2-api-key", + "User-Agent": "LNbits/Tests" + }, + "method": "POST" + } } }, "tests": [ @@ -638,6 +713,24 @@ } } ] + }, + "sparkl2": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "amount_sats": 555, + "memo": "Test Invoice", + "description_hash": null, + "expiry_seconds": null + }, + "response_type": "json", + "response": { + "checking_id": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "payment_request": "lnbc5550n1pnq9jg3sp52rvwstvjcypjsaenzdh0h30jazvzsf8aaye0julprtth9kysxtuspp5e5s3z7felv4t9zrcc6wpn7ehvjl5yzewanzl5crljdl3jgeffyhqdq2f38xy6t5wvxqzjccqpjrzjq0yzeq76ney45hmjlnlpvu0nakzy2g35hqh0dujq8ujdpr2e42pf2rrs6vqpgcsqqqqqqqqqqqqqqeqqyg9qxpqysgqwftcx89k5pp28435pgxfl2vx3ksemzxccppw2j9yjn0ngr6ed7wj8ztc0d5kmt2mvzdlcgrludhz7jncd5l5l9w820hc4clpwhtqj3gq62g66n" + } + } + ] } } }, @@ -735,6 +828,23 @@ } } ] + }, + "sparkl2": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "amount_sats": 555, + "memo": "Test Invoice", + "description_hash": null, + "expiry_seconds": null + }, + "response_type": "json", + "response": { + "error": "Test Error" + } + } + ] } } }, @@ -845,6 +955,21 @@ } } ] + }, + "sparkl2": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "amount_sats": 555, + "memo": "Test Invoice", + "description_hash": null, + "expiry_seconds": null + }, + "response_type": "json", + "response": {} + } + ] } } }, @@ -942,6 +1067,21 @@ "response": "data-not-json" } ] + }, + "sparkl2": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "amount_sats": 555, + "memo": "Test Invoice", + "description_hash": null, + "expiry_seconds": null + }, + "response_type": "data", + "response": "data-not-json" + } + ] } } }, @@ -1057,6 +1197,24 @@ } } ] + }, + "sparkl2": { + "create_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "amount_sats": 555, + "memo": "Test Invoice", + "description_hash": null, + "expiry_seconds": null + }, + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] } } }, @@ -1155,6 +1313,16 @@ }, "method": "POST" } + }, + "sparkl2": { + "pay_invoice_endpoint": { + "uri": "/v1/payments", + "headers": { + "X-Api-Key": "mock-spark-l2-api-key", + "User-Agent": "LNbits/Tests" + }, + "method": "POST" + } } }, "tests": [ @@ -1309,6 +1477,24 @@ } } ] + }, + "sparkl2": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "max_fee_sats": 25, + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "response_type": "json", + "response": { + "status": "LIGHTNING_PAYMENT_SUCCEEDED", + "fee_msat": 30000, + "preimage": "0000000000000000000000000000000000000000000000000000000000000000" + } + } + ] } } }, @@ -1361,7 +1547,23 @@ "alby": {}, "eclair": [], "lnbits": [], - "phoenixd": [] + "phoenixd": [], + "sparkl2": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "max_fee_sats": 25, + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "response_type": "json", + "response": { + "status": "LIGHTNING_PAYMENT_FAILED" + } + } + ] + } } }, { @@ -1505,7 +1707,24 @@ } ], "lnbits": [], - "phoenixd": [] + "phoenixd": [], + "sparkl2": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "max_fee_sats": 25, + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "response_type": "json", + "response": { + "status": "PAYMENT_PENDING", + "preimage": "0000000000000000000000000000000000000000000000000000000000000000" + } + } + ] + } } }, { @@ -1598,6 +1817,22 @@ } } ] + }, + "sparkl2": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "max_fee_sats": 25, + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "response_type": "json", + "response": { + "error": "Test Error" + } + } + ] } } }, @@ -1695,6 +1930,20 @@ } } ] + }, + "sparkl2": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "max_fee_sats": 25, + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "response_type": "json", + "response": {} + } + ] } } }, @@ -1813,6 +2062,20 @@ "response": "data-not-json" } ] + }, + "sparkl2": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "max_fee_sats": 25, + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "response_type": "data", + "response": "data-not-json" + } + ] } } }, @@ -1943,6 +2206,23 @@ } } ] + }, + "sparkl2": { + "pay_invoice_endpoint": [ + { + "request_type": "json", + "request_body": { + "bolt11": "lnbc210n1pjlgal5sp5xr3uwlfm7ltumdjyukhys0z2rw6grgm8me9k4w9vn05zt9svzzjspp5ud2jdfpaqn5c2k2vphatsjypfafyk8rcvkvwexnrhmwm94ex4jtqdqu24hxjapq23jhxapqf9h8vmmfvdjscqpjrzjqta942048v7qxh5x7pxwplhmtwfl0f25cq23jh87rhx7lgrwwvv86r90guqqnwgqqqqqqqqqqqqqqpsqyg9qxpqysgqylngsyg960lltngzy90e8n22v4j2hvjs4l4ttuy79qqefjv8q87q9ft7uhwdjakvnsgk44qyhalv6ust54x98whl3q635hkwgsyw8xgqjl7jwu", + "max_fee_sats": 25, + "payment_hash": "e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96" + }, + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] } } }, @@ -2030,6 +2310,16 @@ }, "method": "GET" } + }, + "sparkl2": { + "get_invoice_status_endpoint": { + "uri": "/v1/invoices/e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "headers": { + "X-Api-Key": "mock-spark-l2-api-key", + "User-Agent": "LNbits/Tests" + }, + "method": "GET" + } } }, "tests": [ @@ -2125,6 +2415,24 @@ } } ] + }, + "sparkl2": { + "get_invoice_status_endpoint": [ + { + "description": "LIGHTNING_PAYMENT_RECEIVED", + "response_type": "json", + "response": { + "status": "LIGHTNING_PAYMENT_RECEIVED" + } + }, + { + "description": "TRANSFER_COMPLETED", + "response_type": "json", + "response": { + "status": "TRANSFER_COMPLETED" + } + } + ] } } }, @@ -2193,7 +2501,8 @@ } } ] - } + }, + "sparkl2": {} } }, { @@ -2400,6 +2709,35 @@ "response": "data-not-json" } ] + }, + "sparkl2": { + "get_invoice_status_endpoint": [ + { + "description": "no data", + "response_type": "json", + "response": {} + }, + { + "description": "pending status", + "response_type": "json", + "response": { + "status": "PAYMENT_PENDING" + } + }, + { + "description": "bad json", + "response_type": "data", + "response": "data-not-json" + }, + { + "description": "http 404", + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] } } }, @@ -2482,6 +2820,16 @@ }, "method": "GET" } + }, + "sparkl2": { + "get_payment_status_endpoint": { + "uri": "/v1/payments/e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96", + "headers": { + "X-Api-Key": "mock-spark-l2-api-key", + "User-Agent": "LNbits/Tests" + }, + "method": "GET" + } } }, "tests": [ @@ -2587,6 +2935,28 @@ } } ] + }, + "sparkl2": { + "get_payment_status_endpoint": [ + { + "description": "LIGHTNING_PAYMENT_SUCCEEDED", + "response_type": "json", + "response": { + "status": "LIGHTNING_PAYMENT_SUCCEEDED", + "fee_msat": 1000, + "preimage": "0000000000000000000000000000000000000000000000000000000000000000" + } + }, + { + "description": "TRANSFER_COMPLETED", + "response_type": "json", + "response": { + "status": "TRANSFER_COMPLETED", + "fee_msat": 1000, + "preimage": "0000000000000000000000000000000000000000000000000000000000000000" + } + } + ] } } }, @@ -2675,7 +3045,8 @@ } } ] - } + }, + "sparkl2": {} } }, { @@ -2933,6 +3304,35 @@ "response": "data-not-json" } ] + }, + "sparkl2": { + "get_payment_status_endpoint": [ + { + "description": "pending status", + "response_type": "json", + "response": { + "status": "PAYMENT_PENDING" + } + }, + { + "description": "no data", + "response_type": "json", + "response": {} + }, + { + "description": "bad json", + "response_type": "data", + "response": "data-not-json" + }, + { + "description": "http 404", + "response_type": "response", + "response": { + "response": "Not Found", + "status": 404 + } + } + ] } } },