mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-25 15:10:41 +01:00
484 lines
15 KiB
Python
484 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
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
""",
|
||
|
)
|