import asyncio import hashlib import json from typing import AsyncGenerator, Optional import httpx from loguru import logger from pydantic import BaseModel from websockets.client import WebSocketClientProtocol, connect from websockets.typing import Subprotocol from lnbits import bolt11 from lnbits.settings import settings from .base import ( InvoiceResponse, PaymentResponse, PaymentStatus, StatusResponse, Wallet, ) class BlinkWallet(Wallet): """https://dev.blink.sv/""" def __init__(self): if not settings.blink_api_endpoint: raise ValueError( "cannot initialize BlinkWallet: missing blink_api_endpoint" ) if not settings.blink_ws_endpoint: raise ValueError("cannot initialize BlinkWallet: missing blink_ws_endpoint") if not settings.blink_token: raise ValueError("cannot initialize BlinkWallet: missing blink_token") self.endpoint = self.normalize_endpoint(settings.blink_api_endpoint) self.auth = { "X-API-KEY": settings.blink_token, "User-Agent": settings.user_agent, } self.ws_endpoint = self.normalize_endpoint(settings.blink_ws_endpoint) self.ws_auth = { "type": "connection_init", "payload": {"X-API-KEY": settings.blink_token}, } self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.auth) self.ws: Optional[WebSocketClientProtocol] = None self._wallet_id = None @property def wallet_id(self): if self._wallet_id: return self._wallet_id raise ValueError("Wallet id not initialized.") async def cleanup(self): try: await self.client.aclose() except RuntimeError as e: logger.warning(f"Error closing wallet connection: {e}") try: if self.ws: await self.ws.close(reason="Shutting down.") except RuntimeError as e: logger.warning(f"Error closing websocket connection: {e}") async def status(self) -> StatusResponse: try: await self._init_wallet_id() payload = {"query": q.balance_query, "variables": {}} response = await self._graphql_query(payload) wallets = ( response.get("data", {}) .get("me", {}) .get("defaultAccount", {}) .get("wallets", []) ) btc_balance = next( ( wallet["balance"] for wallet in wallets if wallet["walletCurrency"] == "BTC" ), None, ) if btc_balance is None: return StatusResponse("No BTC balance", 0) # multiply balance by 1000 to get msats balance return StatusResponse(None, btc_balance * 1000) except ValueError as exc: return StatusResponse(str(exc), 0) except Exception as exc: logger.warning(exc) return StatusResponse(f"Unable to connect, got: '{exc}'", 0) async def create_invoice( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None, unhashed_description: Optional[bytes] = None, **kwargs, ) -> InvoiceResponse: # https://dev.blink.sv/api/btc-ln-receive invoice_variables = { "input": { "amount": amount, "recipientWalletId": self.wallet_id, } } if description_hash: invoice_variables["input"]["descriptionHash"] = description_hash.hex() elif unhashed_description: invoice_variables["input"]["descriptionHash"] = hashlib.sha256( unhashed_description ).hexdigest() else: invoice_variables["input"]["memo"] = memo or "" data = {"query": q.invoice_query, "variables": invoice_variables} try: response = await self._graphql_query(data) errors = ( response.get("data", {}) .get("lnInvoiceCreateOnBehalfOfRecipient", {}) .get("errors", {}) ) if len(errors) > 0: error_message = errors[0].get("message") return InvoiceResponse(False, None, None, error_message) payment_request = ( response.get("data", {}) .get("lnInvoiceCreateOnBehalfOfRecipient", {}) .get("invoice", {}) .get("paymentRequest", None) ) checking_id = ( response.get("data", {}) .get("lnInvoiceCreateOnBehalfOfRecipient", {}) .get("invoice", {}) .get("paymentHash", None) ) return InvoiceResponse(True, checking_id, payment_request, None) except json.JSONDecodeError: return InvoiceResponse( False, None, None, "Server error: 'invalid json response'" ) except Exception as exc: logger.warning(exc) return InvoiceResponse( False, None, None, f"Unable to connect to {self.endpoint}." ) async def pay_invoice( self, bolt11_invoice: str, fee_limit_msat: int ) -> PaymentResponse: # https://dev.blink.sv/api/btc-ln-send # Future: add check fee estimate is < fee_limit_msat before paying invoice payment_variables = { "input": { "paymentRequest": bolt11_invoice, "walletId": self.wallet_id, "memo": "Payment memo", } } data = {"query": q.payment_query, "variables": payment_variables} try: response = await self._graphql_query(data) errors = ( response.get("data", {}) .get("lnInvoicePaymentSend", {}) .get("errors", {}) ) if len(errors) > 0: error_message = errors[0].get("message") return PaymentResponse(False, None, None, None, error_message) checking_id = bolt11.decode(bolt11_invoice).payment_hash payment_status = await self.get_payment_status(checking_id) fee_msat = payment_status.fee_msat preimage = payment_status.preimage return PaymentResponse(True, checking_id, fee_msat, preimage, None) except Exception as exc: logger.info(f"Failed to pay invoice {bolt11_invoice}") logger.warning(exc) return PaymentResponse( None, None, None, None, f"Unable to connect to {self.endpoint}." ) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: statuses = { "EXPIRED": False, "PENDING": None, "PAID": True, } variables = {"paymentHash": checking_id, "walletId": self.wallet_id} data = {"query": q.status_query, "variables": variables} try: response = await self._graphql_query(data) if response.get("errors") is not None: logger.trace(response.get("errors")) return PaymentStatus(None) status = response["data"]["me"]["defaultAccount"]["walletById"][ "invoiceByPaymentHash" ]["paymentStatus"] return PaymentStatus(statuses[status]) except Exception as e: logger.warning(f"Error getting invoice status: {e}") return PaymentStatus(None) async def get_payment_status(self, checking_id: str) -> PaymentStatus: variables = { "walletId": self.wallet_id, "transactionsByPaymentHash": checking_id, } data = {"query": q.tx_query, "variables": variables} statuses = { "FAILURE": False, "EXPIRED": False, "PENDING": None, "PAID": True, "SUCCESS": True, } try: response = await self._graphql_query(data) response_data = response.get("data") assert response_data is not None txs_data = ( response_data.get("me", {}) .get("defaultAccount", {}) .get("walletById", {}) .get("transactionsByPaymentHash", []) ) tx_data = next((t for t in txs_data if t.get("direction") == "SEND"), None) assert tx_data, "No SEND data found." fee = tx_data.get("settlementFee") preimage = tx_data.get("settlementVia", {}).get("preImage") status = tx_data.get("status") return PaymentStatus( paid=statuses[status], fee_msat=fee * 1000, preimage=preimage ) except Exception as e: logger.error(f"Error getting payment status: {e}") return PaymentStatus(None) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: subscription_id = "blink_payment_stream" while settings.lnbits_running: try: async with connect( self.ws_endpoint, subprotocols=[Subprotocol("graphql-transport-ws")] ) as ws: logger.info("Connected to blink invoices stream.") self.ws = ws await ws.send(json.dumps(self.ws_auth)) confirmation = await ws.recv() ack = json.loads(confirmation) assert ( ack.get("type") == "connection_ack" ), "Websocket connection not acknowledged." logger.info("Websocket connection acknowledged.") subscription_req = { "id": subscription_id, "type": "subscribe", "payload": {"query": q.my_updates_query, "variables": {}}, } await ws.send(json.dumps(subscription_req)) while settings.lnbits_running: message = await ws.recv() resp = json.loads(message) if resp.get("id") != subscription_id: continue tx = ( resp.get("payload", {}) .get("data", {}) .get("myUpdates", {}) .get("update", {}) .get("transaction", {}) ) if tx.get("direction") != "RECEIVE": continue if not tx.get("initiationVia"): continue payment_hash = tx.get("initiationVia").get("paymentHash") if payment_hash: yield payment_hash except Exception as exc: logger.error( f"lost connection to blink invoices stream: '{exc}'" "retrying in 5 seconds" ) await asyncio.sleep(5) async def _graphql_query(self, payload) -> dict: response = await self.client.post(self.endpoint, json=payload, timeout=10) response.raise_for_status() return response.json() async def _init_wallet_id(self) -> str: """ Get the defaultAccount wallet id, required for payments. """ if self._wallet_id: return self._wallet_id try: payload = { "query": q.wallet_query, "variables": {}, } response = await self._graphql_query(payload) wallets = ( response.get("data", {}) .get("me", {}) .get("defaultAccount", {}) .get("wallets", []) ) btc_wallet_ids = [ wallet["id"] for wallet in wallets if wallet["walletCurrency"] == "BTC" ] if not btc_wallet_ids: raise ValueError("BTC Wallet not found") self._wallet_id = btc_wallet_ids[0] return self._wallet_id except Exception as exc: logger.warning(exc) raise ValueError(f"Unable to connect to '{self.endpoint}'") from exc class BlinkGrafqlQueries(BaseModel): balance_query: str invoice_query: str payment_query: str status_query: str wallet_query: str tx_query: str my_updates_query: str q = BlinkGrafqlQueries( balance_query=""" query Me { me { defaultAccount { wallets { walletCurrency balance } } } } """, invoice_query=""" mutation LnInvoiceCreateOnBehalfOfRecipient( $input: LnInvoiceCreateOnBehalfOfRecipientInput! ) { lnInvoiceCreateOnBehalfOfRecipient(input: $input) { invoice { paymentRequest paymentHash paymentSecret satoshis } errors { message } } } """, payment_query=""" mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { lnInvoicePaymentSend(input: $input) { status errors { message path code } } } """, status_query=""" query InvoiceByPaymentHash($walletId: WalletId!, $paymentHash: PaymentHash!) { me { defaultAccount { walletById(walletId: $walletId) { invoiceByPaymentHash(paymentHash: $paymentHash) { ... on LnInvoice { paymentStatus } } } } } } """, wallet_query=""" query me { me { defaultAccount { wallets { id walletCurrency } } } } """, tx_query=""" query TransactionsByPaymentHash( $walletId: WalletId! $transactionsByPaymentHash: PaymentHash! ) { me { defaultAccount { walletById(walletId: $walletId) { walletCurrency ... on BTCWallet { transactionsByPaymentHash(paymentHash: $transactionsByPaymentHash) { settlementFee status direction settlementVia { ... on SettlementViaLn { preImage } } } } } } } } """, my_updates_query=""" subscription { myUpdates { update { ... on LnUpdate { transaction { initiationVia { ... on InitiationViaLn { paymentHash } } direction } } } } } """, )