feat: Blink funding source (#2477)

* 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>
This commit is contained in:
Pavol Rusnak 2024-07-19 22:32:02 +02:00 committed by GitHub
parent eda7e35c61
commit 7298c4664b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 668 additions and 5 deletions

View File

@ -28,7 +28,7 @@ PORT=5000
######################################
# which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
@ -91,6 +91,11 @@ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
# BlinkWallet
BLINK_API_ENDPOINT=https://api.blink.sv/graphql
BLINK_WS_ENDPOINT=wss://ws.blink.sv/graphql
BLINK_TOKEN=BLINK_TOKEN
# PhoenixdWallet
PHOENIXD_API_ENDPOINT=http://localhost:9740/
PHOENIXD_API_PASSWORD=PHOENIXD_KEY

View File

@ -76,6 +76,15 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey
### Blink
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate a Blink API key after logging in or creating a new Blink account at: https://dashboard.blink.sv. For more info visit: https://dev.blink.sv/api/auth#create-an-api-key```
- `LNBITS_BACKEND_WALLET_CLASS`: **BlinkWallet**
- `BLINK_API_ENDPOINT`: https://api.blink.sv/graphql
- `BLINK_WS_ENDPOINT`: wss://ws.blink.sv/graphql
- `BLINK_TOKEN`: BlinkToken
### Alby
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new

View File

@ -218,6 +218,12 @@ class LnPayFundingSource(LNbitsSettings):
lnpay_admin_key: Optional[str] = Field(default=None)
class BlinkFundingSource(LNbitsSettings):
blink_api_endpoint: Optional[str] = Field(default="https://api.blink.sv/graphql")
blink_ws_endpoint: Optional[str] = Field(default="wss://ws.blink.sv/graphql")
blink_token: Optional[str] = Field(default=None)
class ZBDFundingSource(LNbitsSettings):
zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/")
zbd_api_key: Optional[str] = Field(default=None)
@ -266,6 +272,7 @@ class FundingSourcesSettings(
LndRestFundingSource,
LndGrpcFundingSource,
LnPayFundingSource,
BlinkFundingSource,
AlbyFundingSource,
ZBDFundingSource,
PhoenixdFundingSource,
@ -415,14 +422,15 @@ class SuperUserSettings(LNbitsSettings):
lnbits_allowed_funding_sources: list[str] = Field(
default=[
"AlbyWallet",
"FakeWallet",
"BlinkWallet",
"CoreLightningRestWallet",
"CoreLightningWallet",
"EclairWallet",
"LNbitsWallet",
"LndRestWallet",
"FakeWallet",
"LNPayWallet",
"LNbitsWallet",
"LnTipsWallet",
"LndRestWallet",
"LndWallet",
"OpenNodeWallet",
"PhoenixdWallet",

File diff suppressed because one or more lines are too long

View File

@ -110,6 +110,15 @@ Vue.component('lnbits-funding-sources', {
lnbits_key: 'Admin Key'
}
],
[
'BlinkWallet',
'Blink',
{
blink_api_endpoint: 'Endpoint',
blink_ws_endpoint: 'WebSocket',
blink_token: 'Key'
}
],
[
'AlbyWallet',
'Alby',

View File

@ -8,6 +8,7 @@ from lnbits.settings import settings
from lnbits.wallets.base import Wallet
from .alby import AlbyWallet
from .blink import BlinkWallet
from .cliche import ClicheWallet
from .corelightning import CoreLightningWallet

483
lnbits/wallets/blink.py Normal file
View File

@ -0,0 +1,483 @@
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
}
}
}
}
}
""",
)

148
tests/wallets/test_blink.py Normal file
View File

@ -0,0 +1,148 @@
import os
import pytest
from loguru import logger
from lnbits.settings import settings
from lnbits.wallets import BlinkWallet, get_funding_source, set_funding_source
settings.lnbits_backend_wallet_class = "BlinkWallet"
settings.blink_token = "mock"
settings.blink_api_endpoint = "https://api.blink.sv/graphql"
# Check if BLINK_TOKEN environment variable is set
use_real_api = os.environ.get("BLINK_TOKEN") is not None
logger.info(f"use_real_api: {use_real_api}")
if use_real_api:
headers = {
"Content-Type": "application/json",
"X-API-KEY": os.environ.get("BLINK_TOKEN"),
}
settings.blink_token = os.environ.get("BLINK_TOKEN")
logger.info(
f"settings.lnbits_backend_wallet_class: {settings.lnbits_backend_wallet_class}"
)
logger.info(f"settings.blink_api_endpoint: {settings.blink_api_endpoint}")
logger.info(f"settings.blink_token: {settings.blink_token}")
set_funding_source()
funding_source = get_funding_source()
assert isinstance(funding_source, BlinkWallet)
@pytest.fixture(scope="session")
def payhash():
# put your external payment hash here
payment_hash = "14d7899c3456bcd78f7f18a70d782b8eadb2de974e80dc5120e133032423dcda"
return payment_hash
@pytest.fixture(scope="session")
def outbound_bolt11():
# put your outbound bolt11 here
bolt11 = "lnbc1u1pjl0uhypp5yxvdqq923atm9ywkpgtu3yxv9w2n44ensrkwfyagvmzqhml2x9gqdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrsssp5h3qlnnlfqekquacwwj9yu7fhujyzxhzqegpxenscw45pgv6xakfq9qyyssqqjruygw0jrcg3365jksxn6yhsxx7c5pdjrjdlyvuhs7xh8r409h4e3kucc54kgh34pscaq3mg7hn55l8a0qszgzex80amwrp4gkdgqcpkse88y" # noqa: E501
return bolt11
@pytest.mark.asyncio
async def test_environment_variables():
if use_real_api:
assert "X-API-KEY" in headers, "X-API-KEY is not present in headers"
assert isinstance(headers["X-API-KEY"], str), "X-API-KEY is not a string"
else:
assert True, "BLINK_TOKEN is not set. Skipping test using mock api"
@pytest.mark.asyncio
async def test_get_wallet_id():
if use_real_api:
wallet_id = await funding_source._init_wallet_id()
logger.info(f"test_get_wallet_id: {wallet_id}")
assert wallet_id
else:
assert True, "BLINK_TOKEN is not set. Skipping test using mock api"
@pytest.mark.asyncio
async def test_status():
if use_real_api:
status = await funding_source.status()
logger.info(f"test_status: {status}")
assert status
else:
assert True, "BLINK_TOKEN is not set. Skipping test using mock api"
@pytest.mark.asyncio
async def test_create_invoice():
if use_real_api:
invoice_response = await funding_source.create_invoice(amount=1000, memo="test")
assert invoice_response.ok is True
assert invoice_response.payment_request
assert invoice_response.checking_id
logger.info(f"test_create_invoice: ok: {invoice_response.ok}")
logger.info(
f"test_create_invoice: payment_request: {invoice_response.payment_request}"
)
payment_status = await funding_source.get_invoice_status(
invoice_response.checking_id
)
assert payment_status.paid is None # still pending
logger.info(
f"test_create_invoice: PaymentStatus is Still Pending: {payment_status.paid is None}" # noqa: E501
)
logger.info(
f"test_create_invoice: PaymentStatusfee_msat: {payment_status.fee_msat}"
)
logger.info(
f"test_create_invoice: PaymentStatus preimage: {payment_status.preimage}"
)
else:
assert True, "BLINK_TOKEN is not set. Skipping test using mock api"
@pytest.mark.asyncio
async def test_pay_invoice_self_payment():
if use_real_api:
invoice_response = await funding_source.create_invoice(amount=100, memo="test")
assert invoice_response.ok is True
bolt11 = invoice_response.payment_request
assert bolt11 is not None
payment_response = await funding_source.pay_invoice(bolt11, fee_limit_msat=100)
assert payment_response.ok is False # can't pay self
assert payment_response.error_message
else:
assert True, "BLINK_TOKEN is not set. Skipping test using mock api"
@pytest.mark.asyncio
async def test_outbound_invoice_payment(outbound_bolt11):
if use_real_api:
payment_response = await funding_source.pay_invoice(
outbound_bolt11, fee_limit_msat=100
)
assert payment_response.ok is True
assert payment_response.checking_id
logger.info(f"test_outbound_invoice_payment: ok: {payment_response.ok}")
logger.info(
f"test_outbound_invoice_payment: checking_id: {payment_response.checking_id}" # noqa: E501
)
else:
assert True, "BLINK_TOKEN is not set. Skipping test using mock api"
@pytest.mark.asyncio
async def test_get_payment_status(payhash):
if use_real_api:
payment_status = await funding_source.get_payment_status(payhash)
assert payment_status.paid
logger.info(f"test_get_payment_status: payment_status: {payment_status.paid}")
else:
assert True, "BLINK_TOKEN is not set. Skipping test using mock api"