mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-23 22:47:05 +01:00
* feat: Blink funding source * chore: make bundle * Blink review 01 (#2575) * refactor: mark `graphql_query` as private (`_` prefix) * feat: set default value for `blink_api_endpoint` * fix: raise if HTTP call failed * refactor: move private method to the bottom * refactor: make `wallet_id` a property * fix: key mapping for attribute * chore: fix `mypy` * chore: fix `make check` * refactor: extract query strings * refactor: extract `BlinkGrafqlQueries` class * chore: code clean-up * chore: add `try-catch` * refactor: extract `tx_query` * chore: format grapfhql queries * fix: set funding source class * chore: `make format` * fix: test by following the other patterns * Update docs/guide/wallets.md Co-authored-by: openoms <43343391+openoms@users.noreply.github.com> * feat: add websocket connection to blink (#2577) * feat: add websocket connection to blink * feat: close websocket on shutdown * feat: add `blink_ws_endpoint` to the UI * fix: use `SEND` tx for `settlementFee` * refactor: remove `else` when `if` has `return` * fix: remove test env file --------- Co-authored-by: bitkarrot <73979971+bitkarrot@users.noreply.github.com> Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com> Co-authored-by: openoms <43343391+openoms@users.noreply.github.com>
483 lines
15 KiB
Python
483 lines
15 KiB
Python
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""",
|
|
)
|