[FEAT] Node Managment (#1895)
* [FEAT] Node Managment feat: node dashboard channels and transactions fix: update channel variables better types refactor ui add onchain balances and backend_name mock values for fake wallet remove app tab start implementing peers and channel management peer and channel management implement channel closing add channel states, better errors seperate payments and invoices on transactions tab display total channel balance feat: optional public page feat: show node address fix: port conversion feat: details dialog on transactions fix: peer info without alias fix: rename channel balances small improvements to channels tab feat: pagination on transactions tab test caching transactions refactor: move WALLET into wallets module fix: backwards compatibility refactor: move get_node_class to nodes modules post merge bundle fundle feat: disconnect peer feat: initial lnd support only use filtered channels for total balance adjust closing logic add basic node tests add setting for disabling transactions tab revert unnecessary changes add tests for invoices and payments improve payment and invoice implementations the previously used invoice fixture has a session scope, but a new invoice is required tests and bug fixes for channels api use query instead of body in channel delete delete requests should generally not use a body take node id through path instead of body for delete endpoint add peer management tests more tests for errors improve error handling rename id and pubkey to peer_id for consistency remove dead code fix http status codes make cache keys safer cache node public info comments for node settings rename node prop in frontend adjust tests to new status codes cln: use amount_msat instead of value for onchain balance turn transactions tab off by default enable transactions in tests only allow super user to create or delete fix prop name in admin navbar --------- Co-authored-by: jacksn <jkranawetter05@gmail.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
from typing import Optional
|
||||
|
||||
from .base import Node
|
||||
|
||||
|
||||
def get_node_class() -> Optional[Node]:
|
||||
return NODE
|
||||
|
||||
|
||||
def set_node_class(node: Node):
|
||||
global NODE
|
||||
NODE = node
|
||||
|
||||
|
||||
NODE: Optional[Node] = None
|
||||
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.db import FilterModel, Filters, Page
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lnbits.wallets.base import Wallet
|
||||
|
||||
|
||||
class NodePeerInfo(BaseModel):
|
||||
id: str
|
||||
alias: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
last_timestamp: Optional[int] = None
|
||||
addresses: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ChannelState(Enum):
|
||||
ACTIVE = "active"
|
||||
PENDING = "pending"
|
||||
CLOSED = "closed"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class ChannelBalance(BaseModel):
|
||||
local_msat: int
|
||||
remote_msat: int
|
||||
total_msat: int
|
||||
|
||||
|
||||
class ChannelPoint(BaseModel):
|
||||
funding_txid: str
|
||||
output_index: int
|
||||
|
||||
|
||||
class NodeChannel(BaseModel):
|
||||
short_id: Optional[str] = None
|
||||
point: Optional[ChannelPoint] = None
|
||||
peer_id: str
|
||||
balance: ChannelBalance
|
||||
state: ChannelState
|
||||
name: Optional[str]
|
||||
color: Optional[str]
|
||||
|
||||
|
||||
class ChannelStats(BaseModel):
|
||||
counts: dict[ChannelState, int]
|
||||
avg_size: int
|
||||
biggest_size: Optional[int]
|
||||
smallest_size: Optional[int]
|
||||
total_capacity: int
|
||||
|
||||
@classmethod
|
||||
def from_list(cls, channels: list[NodeChannel]):
|
||||
counts: dict[ChannelState, int] = {}
|
||||
for channel in channels:
|
||||
counts[channel.state] = counts.get(channel.state, 0) + 1
|
||||
|
||||
return cls(
|
||||
counts=counts,
|
||||
avg_size=int(
|
||||
sum(channel.balance.total_msat for channel in channels) / len(channels)
|
||||
),
|
||||
biggest_size=max(channel.balance.total_msat for channel in channels),
|
||||
smallest_size=min(channel.balance.total_msat for channel in channels),
|
||||
total_capacity=sum(channel.balance.total_msat for channel in channels),
|
||||
)
|
||||
|
||||
|
||||
class NodeFees(BaseModel):
|
||||
total_msat: int
|
||||
daily_msat: Optional[int] = None
|
||||
weekly_msat: Optional[int] = None
|
||||
monthly_msat: Optional[int] = None
|
||||
|
||||
|
||||
class PublicNodeInfo(BaseModel):
|
||||
id: str
|
||||
backend_name: str
|
||||
alias: str
|
||||
color: str
|
||||
num_peers: int
|
||||
blockheight: int
|
||||
channel_stats: ChannelStats
|
||||
addresses: list[str]
|
||||
|
||||
|
||||
class NodeInfoResponse(PublicNodeInfo):
|
||||
onchain_balance_sat: int
|
||||
onchain_confirmed_sat: int
|
||||
fees: NodeFees
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class NodePayment(BaseModel):
|
||||
pending: bool
|
||||
amount: int
|
||||
fee: Optional[int] = None
|
||||
memo: Optional[str] = None
|
||||
time: int
|
||||
bolt11: str
|
||||
preimage: Optional[str]
|
||||
payment_hash: str
|
||||
expiry: Optional[float] = None
|
||||
destination: Optional[NodePeerInfo] = None
|
||||
|
||||
|
||||
class NodeInvoice(BaseModel):
|
||||
pending: bool
|
||||
amount: int
|
||||
memo: Optional[str]
|
||||
bolt11: str
|
||||
preimage: Optional[str]
|
||||
payment_hash: str
|
||||
paid_at: Optional[int] = None
|
||||
expiry: Optional[int] = None
|
||||
|
||||
|
||||
class NodeInvoiceFilters(FilterModel):
|
||||
pass
|
||||
|
||||
|
||||
class NodePaymentsFilters(FilterModel):
|
||||
pass
|
||||
|
||||
|
||||
class Node(ABC):
|
||||
wallet: Wallet
|
||||
|
||||
def __init__(self, wallet: Wallet):
|
||||
self.wallet = wallet
|
||||
self.id: Optional[str] = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
async def get_id(self):
|
||||
if not self.id:
|
||||
self.id = await self._get_id()
|
||||
return self.id
|
||||
|
||||
@abstractmethod
|
||||
async def _get_id(self) -> str:
|
||||
pass
|
||||
|
||||
async def get_peers(self) -> list[NodePeerInfo]:
|
||||
peer_ids = await self.get_peer_ids()
|
||||
return [await self.get_peer_info(peer_id) for peer_id in peer_ids]
|
||||
|
||||
@abstractmethod
|
||||
async def get_peer_ids(self) -> list[str]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def connect_peer(self, uri: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
pass
|
||||
|
||||
async def get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
key = f"node:peers:{peer_id}"
|
||||
info = cache.get(key)
|
||||
if not info:
|
||||
info = await self._get_peer_info(peer_id)
|
||||
if info.last_timestamp:
|
||||
cache.set(key, info)
|
||||
return info
|
||||
|
||||
@abstractmethod
|
||||
async def open_channel(
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
) -> ChannelPoint:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_channels(self) -> List[NodeChannel]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_info(self) -> NodeInfoResponse:
|
||||
pass
|
||||
|
||||
async def get_public_info(self) -> PublicNodeInfo:
|
||||
info = await self.get_info()
|
||||
return PublicNodeInfo(**info.__dict__)
|
||||
|
||||
@abstractmethod
|
||||
async def get_payments(
|
||||
self, filters: Filters[NodePaymentsFilters]
|
||||
) -> Page[NodePayment]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_invoices(
|
||||
self, filters: Filters[NodeInvoiceFilters]
|
||||
) -> Page[NodeInvoice]:
|
||||
pass
|
||||
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from lnbits.db import Filters, Page
|
||||
|
||||
from ..utils.cache import cache
|
||||
|
||||
try:
|
||||
from pyln.client import RpcError # type: ignore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# override the false type
|
||||
class RpcError(RpcError): # type: ignore
|
||||
error: dict
|
||||
|
||||
except ImportError: # pragma: nocover
|
||||
LightningRpc = None
|
||||
|
||||
from lnbits.nodes.base import (
|
||||
ChannelBalance,
|
||||
ChannelPoint,
|
||||
ChannelState,
|
||||
ChannelStats,
|
||||
Node,
|
||||
NodeFees,
|
||||
NodeInvoice,
|
||||
NodeInvoiceFilters,
|
||||
NodePaymentsFilters,
|
||||
NodePeerInfo,
|
||||
)
|
||||
|
||||
from .base import NodeChannel, NodeInfoResponse, NodePayment
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lnbits.wallets import CoreLightningWallet
|
||||
|
||||
|
||||
def catch_rpc_errors(f):
|
||||
async def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return await f(*args, **kwargs)
|
||||
except RpcError as e:
|
||||
if e.error["code"] == -32602:
|
||||
raise HTTPException(status_code=400, detail=e.error["message"])
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=e.error["message"])
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class CoreLightningNode(Node):
|
||||
wallet: CoreLightningWallet
|
||||
|
||||
async def ln_rpc(self, method, *args, **kwargs) -> dict:
|
||||
loop = asyncio.get_event_loop()
|
||||
fn = getattr(self.wallet.ln, method)
|
||||
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
|
||||
|
||||
@catch_rpc_errors
|
||||
async def connect_peer(self, uri: str):
|
||||
# https://docs.corelightning.org/reference/lightning-connect
|
||||
try:
|
||||
await self.ln_rpc("connect", uri)
|
||||
except RpcError as e:
|
||||
if e.error["code"] == 400:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=e.error["message"])
|
||||
else:
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
try:
|
||||
await self.ln_rpc("disconnect", peer_id)
|
||||
except RpcError as e:
|
||||
if e.error["code"] == -1:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
detail=e.error["message"],
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
async def open_channel(
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
) -> ChannelPoint:
|
||||
try:
|
||||
result = await self.ln_rpc(
|
||||
"fundchannel",
|
||||
peer_id,
|
||||
amount=local_amount,
|
||||
push_msat=int(push_amount * 1000) if push_amount else None,
|
||||
feerate=fee_rate,
|
||||
)
|
||||
return ChannelPoint(
|
||||
funding_txid=result["txid"],
|
||||
output_index=result["outnum"],
|
||||
)
|
||||
except RpcError as e:
|
||||
message = e.error["message"]
|
||||
|
||||
if "amount: should be a satoshi amount" in message:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
detail="The amount is not a valid satoshi amount.",
|
||||
)
|
||||
|
||||
if "Unknown peer" in message:
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(
|
||||
"We where able to connect to the peer but CLN "
|
||||
"can't find it when opening a channel."
|
||||
),
|
||||
)
|
||||
|
||||
if "Owning subdaemon openingd died" in message:
|
||||
# https://github.com/ElementsProject/lightning/issues/2798#issuecomment-511205719
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
"Likely the peer didn't like our channel opening "
|
||||
"proposal and disconnected from us."
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
"Number of pending channels exceed maximum" in message
|
||||
or "exceeds maximum chan size of 10 BTC" in message
|
||||
or "Could not afford" in message
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message)
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
if not short_id:
|
||||
raise HTTPException(status_code=400, detail="Short id required")
|
||||
try:
|
||||
await self.ln_rpc("close", short_id)
|
||||
except RpcError as e:
|
||||
message = e.error["message"]
|
||||
if (
|
||||
"Short channel ID not active:" in message
|
||||
or "Short channel ID not found" in message
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=message)
|
||||
else:
|
||||
raise
|
||||
|
||||
@catch_rpc_errors
|
||||
async def _get_id(self) -> str:
|
||||
info = await self.ln_rpc("getinfo")
|
||||
return info["id"]
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_peer_ids(self) -> List[str]:
|
||||
peers = await self.ln_rpc("listpeers")
|
||||
return [p["id"] for p in peers["peers"] if p["connected"]]
|
||||
|
||||
@catch_rpc_errors
|
||||
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
result = await self.ln_rpc("listnodes", peer_id)
|
||||
nodes = result["nodes"]
|
||||
if len(nodes) == 0:
|
||||
return NodePeerInfo(id=peer_id)
|
||||
node = nodes[0]
|
||||
if "last_timestamp" in node:
|
||||
return NodePeerInfo(
|
||||
id=node["nodeid"],
|
||||
alias=node["alias"],
|
||||
color=node["color"],
|
||||
last_timestamp=node["last_timestamp"],
|
||||
addresses=[
|
||||
address["address"] + ":" + str(address["port"])
|
||||
for address in node["addresses"]
|
||||
],
|
||||
)
|
||||
else:
|
||||
return NodePeerInfo(id=node["nodeid"])
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_channels(self) -> List[NodeChannel]:
|
||||
funds = await self.ln_rpc("listfunds")
|
||||
nodes = await self.ln_rpc("listnodes")
|
||||
nodes_by_id = {n["nodeid"]: n for n in nodes["nodes"]}
|
||||
|
||||
return [
|
||||
NodeChannel(
|
||||
short_id=ch.get("short_channel_id"),
|
||||
point=ChannelPoint(
|
||||
funding_txid=ch["funding_txid"],
|
||||
output_index=ch["funding_output"],
|
||||
),
|
||||
peer_id=ch["peer_id"],
|
||||
balance=ChannelBalance(
|
||||
local_msat=ch["our_amount_msat"],
|
||||
remote_msat=ch["amount_msat"] - ch["our_amount_msat"],
|
||||
total_msat=ch["amount_msat"],
|
||||
),
|
||||
name=nodes_by_id.get(ch["peer_id"], {}).get("alias"),
|
||||
color=nodes_by_id.get(ch["peer_id"], {}).get("color"),
|
||||
state=(
|
||||
ChannelState.ACTIVE
|
||||
if ch["state"] == "CHANNELD_NORMAL"
|
||||
else ChannelState.PENDING
|
||||
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
|
||||
else ChannelState.CLOSED
|
||||
if ch["state"]
|
||||
in (
|
||||
"CHANNELD_CLOSING",
|
||||
"CLOSINGD_COMPLETE",
|
||||
"CLOSINGD_SIGEXCHANGE",
|
||||
"ONCHAIN",
|
||||
)
|
||||
else ChannelState.INACTIVE
|
||||
),
|
||||
)
|
||||
for ch in funds["channels"]
|
||||
]
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_info(self) -> NodeInfoResponse:
|
||||
info = await self.ln_rpc("getinfo")
|
||||
funds = await self.ln_rpc("listfunds")
|
||||
|
||||
channels = await self.get_channels()
|
||||
active_channels = [
|
||||
channel for channel in channels if channel.state == ChannelState.ACTIVE
|
||||
]
|
||||
return NodeInfoResponse(
|
||||
id=info["id"],
|
||||
backend_name="CLN",
|
||||
alias=info["alias"],
|
||||
color=info["color"],
|
||||
onchain_balance_sat=sum(output["value"] for output in funds["outputs"]),
|
||||
onchain_confirmed_sat=sum(
|
||||
output["amount_msat"] / 1000
|
||||
for output in funds["outputs"]
|
||||
if output["status"] == "confirmed"
|
||||
),
|
||||
channel_stats=ChannelStats.from_list(channels),
|
||||
num_peers=info["num_peers"],
|
||||
blockheight=info["blockheight"],
|
||||
balance_msat=sum(channel.balance.local_msat for channel in active_channels),
|
||||
fees=NodeFees(total_msat=info["fees_collected_msat"]),
|
||||
addresses=[address["address"] for address in info["address"]],
|
||||
)
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_payments(
|
||||
self, filters: Filters[NodePaymentsFilters]
|
||||
) -> Page[NodePayment]:
|
||||
async def get_payments():
|
||||
result = await self.ln_rpc("listpays")
|
||||
return [
|
||||
NodePayment(
|
||||
bolt11=pay["bolt11"],
|
||||
amount=pay["amount_msat"],
|
||||
fee=int(pay["amount_msat"]) - int(pay["amount_sent_msat"]),
|
||||
memo=pay.get("description"),
|
||||
time=pay["created_at"],
|
||||
preimage=pay.get("preimage"),
|
||||
payment_hash=pay["payment_hash"],
|
||||
pending=pay["status"] != "complete",
|
||||
destination=await self.get_peer_info(pay["destination"]),
|
||||
)
|
||||
for pay in reversed(result["pays"])
|
||||
if pay["status"] != "failed"
|
||||
]
|
||||
|
||||
results = await cache.save_result(get_payments, key="node:payments")
|
||||
count = len(results)
|
||||
if filters.offset:
|
||||
results = results[filters.offset :]
|
||||
if filters.limit:
|
||||
results = results[: filters.limit]
|
||||
return Page(data=results, total=count)
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_invoices(
|
||||
self, filters: Filters[NodeInvoiceFilters]
|
||||
) -> Page[NodeInvoice]:
|
||||
result = await cache.save_result(
|
||||
lambda: self.ln_rpc("listinvoices"), key="node:invoices"
|
||||
)
|
||||
invoices = result["invoices"]
|
||||
invoices.reverse()
|
||||
count = len(invoices)
|
||||
if filters.offset:
|
||||
invoices = invoices[filters.offset :]
|
||||
if filters.limit:
|
||||
invoices = invoices[: filters.limit]
|
||||
return Page(
|
||||
data=[
|
||||
NodeInvoice(
|
||||
bolt11=invoice["bolt11"],
|
||||
amount=invoice["amount_msat"],
|
||||
preimage=invoice.get("payment_preimage"),
|
||||
memo=invoice["description"],
|
||||
paid_at=invoice.get("paid_at"),
|
||||
expiry=invoice["expires_at"],
|
||||
payment_hash=invoice["payment_hash"],
|
||||
pending=invoice["status"] != "paid",
|
||||
)
|
||||
for invoice in invoices
|
||||
],
|
||||
total=count,
|
||||
)
|
||||
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from httpx import HTTPStatusError
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.nodes import Node
|
||||
from lnbits.nodes.base import (
|
||||
ChannelBalance,
|
||||
ChannelPoint,
|
||||
ChannelState,
|
||||
ChannelStats,
|
||||
NodeChannel,
|
||||
NodeFees,
|
||||
NodeInfoResponse,
|
||||
NodeInvoice,
|
||||
NodeInvoiceFilters,
|
||||
NodePayment,
|
||||
NodePaymentsFilters,
|
||||
NodePeerInfo,
|
||||
PublicNodeInfo,
|
||||
)
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lnbits.wallets import LndRestWallet
|
||||
|
||||
|
||||
def msat(raw: str) -> int:
|
||||
return int(raw) * 1000
|
||||
|
||||
|
||||
def _decode_bytes(data: str) -> str:
|
||||
return base64.b64decode(data).hex()
|
||||
|
||||
|
||||
def _parse_channel_point(raw: str) -> ChannelPoint:
|
||||
funding_tx, output_index = raw.split(":")
|
||||
return ChannelPoint(
|
||||
funding_txid=funding_tx,
|
||||
output_index=int(output_index),
|
||||
)
|
||||
|
||||
|
||||
class LndRestNode(Node):
|
||||
wallet: LndRestWallet
|
||||
|
||||
async def request(
|
||||
self, method: str, path: str, json: Optional[dict] = None, **kwargs
|
||||
):
|
||||
response = await self.wallet.client.request(
|
||||
method, f"{self.wallet.endpoint}{path}", json=json, **kwargs
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except HTTPStatusError as e:
|
||||
json = e.response.json()
|
||||
if json:
|
||||
error = json.get("error") or json
|
||||
raise HTTPException(e.response.status_code, detail=error.get("message"))
|
||||
return response.json()
|
||||
|
||||
def get(self, path: str, **kwargs):
|
||||
return self.request("GET", path, **kwargs)
|
||||
|
||||
async def _get_id(self) -> str:
|
||||
info = await self.get("/v1/getinfo")
|
||||
return info["identity_pubkey"]
|
||||
|
||||
async def get_peer_ids(self) -> list[str]:
|
||||
response = await self.get("/v1/peers")
|
||||
return [p["pub_key"] for p in response["peers"]]
|
||||
|
||||
async def connect_peer(self, uri: str):
|
||||
try:
|
||||
pubkey, host = uri.split("@")
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail="Invalid peer URI")
|
||||
await self.request(
|
||||
"POST",
|
||||
"/v1/peers",
|
||||
json={
|
||||
"addr": {"pubkey": pubkey, "host": host},
|
||||
"perm": True,
|
||||
"timeout": 30,
|
||||
},
|
||||
)
|
||||
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
try:
|
||||
await self.request("DELETE", "/v1/peers/" + peer_id)
|
||||
except HTTPException as e:
|
||||
if "unable to disconnect" in e.detail:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, detail="Peer is not connected"
|
||||
)
|
||||
raise
|
||||
|
||||
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
try:
|
||||
response = await self.get("/v1/graph/node/" + peer_id)
|
||||
except HTTPException:
|
||||
return NodePeerInfo(id=peer_id)
|
||||
node = response["node"]
|
||||
return NodePeerInfo(
|
||||
id=peer_id,
|
||||
alias=node["alias"],
|
||||
color=node["color"].strip("#"),
|
||||
last_timestamp=node["last_update"],
|
||||
addresses=[a["addr"] for a in node["addresses"]],
|
||||
)
|
||||
|
||||
async def open_channel(
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
) -> ChannelPoint:
|
||||
response = await self.request(
|
||||
"POST",
|
||||
"/v1/channels",
|
||||
data=json.dumps(
|
||||
{
|
||||
# 'node_pubkey': base64.b64encode(peer_id.encode()).decode(),
|
||||
"node_pubkey_string": peer_id,
|
||||
"sat_per_vbyte": fee_rate,
|
||||
"local_funding_amount": local_amount,
|
||||
"push_sat": push_amount,
|
||||
}
|
||||
),
|
||||
)
|
||||
return ChannelPoint(
|
||||
# WHY IS THIS REVERSED?!
|
||||
funding_txid=bytes(
|
||||
reversed(base64.b64decode(response["funding_txid_bytes"]))
|
||||
).hex(),
|
||||
output_index=response["output_index"],
|
||||
)
|
||||
|
||||
async def _close_channel(
|
||||
self,
|
||||
point: ChannelPoint,
|
||||
force: bool = False,
|
||||
):
|
||||
async with self.wallet.client.stream(
|
||||
"DELETE",
|
||||
f"{self.wallet.endpoint}/v1/channels/{point.funding_txid}/{point.output_index}",
|
||||
params={"force": force},
|
||||
timeout=None,
|
||||
) as stream:
|
||||
async for chunk in stream.aiter_text():
|
||||
if chunk:
|
||||
chunk = json.loads(chunk)
|
||||
logger.info(f"LND Channel close update: {chunk['result']}")
|
||||
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
force: bool = False,
|
||||
):
|
||||
if not point:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
|
||||
)
|
||||
|
||||
asyncio.create_task(self._close_channel(point, force))
|
||||
|
||||
async def get_channels(self) -> List[NodeChannel]:
|
||||
normal, pending, closed = await asyncio.gather(
|
||||
self.get("/v1/channels"),
|
||||
self.get("/v1/channels/pending"),
|
||||
self.get("/v1/channels/closed"),
|
||||
)
|
||||
|
||||
channels = []
|
||||
|
||||
async def parse_pending(raw_channels, state):
|
||||
for channel in raw_channels:
|
||||
channel = channel["channel"]
|
||||
info = await self.get_peer_info(channel["remote_node_pub"])
|
||||
channels.append(
|
||||
NodeChannel(
|
||||
peer_id=info.id,
|
||||
state=state,
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
balance=ChannelBalance(
|
||||
local_msat=msat(channel["local_balance"]),
|
||||
remote_msat=msat(channel["remote_balance"]),
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await parse_pending(pending["pending_open_channels"], ChannelState.PENDING)
|
||||
await parse_pending(
|
||||
pending["pending_force_closing_channels"], ChannelState.CLOSED
|
||||
)
|
||||
await parse_pending(pending["waiting_close_channels"], ChannelState.CLOSED)
|
||||
|
||||
for channel in closed["channels"]:
|
||||
info = await self.get_peer_info(channel["remote_pubkey"])
|
||||
channels.append(
|
||||
NodeChannel(
|
||||
peer_id=info.id,
|
||||
state=ChannelState.CLOSED,
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
balance=ChannelBalance(
|
||||
local_msat=0,
|
||||
remote_msat=0,
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
for channel in normal["channels"]:
|
||||
info = await self.get_peer_info(channel["remote_pubkey"])
|
||||
channels.append(
|
||||
NodeChannel(
|
||||
short_id=channel["chan_id"],
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
peer_id=channel["remote_pubkey"],
|
||||
balance=ChannelBalance(
|
||||
local_msat=msat(channel["local_balance"]),
|
||||
remote_msat=msat(channel["remote_balance"]),
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
state=ChannelState.ACTIVE
|
||||
if channel["active"]
|
||||
else ChannelState.INACTIVE,
|
||||
# name=channel['peer_alias'],
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
)
|
||||
)
|
||||
|
||||
return channels
|
||||
|
||||
async def get_public_info(self) -> PublicNodeInfo:
|
||||
info = await self.get("/v1/getinfo")
|
||||
channels = await self.get_channels()
|
||||
return PublicNodeInfo(
|
||||
backend_name="LND",
|
||||
id=info["identity_pubkey"],
|
||||
color=info["color"].lstrip("#"),
|
||||
alias=info["alias"],
|
||||
num_peers=info["num_peers"],
|
||||
blockheight=info["block_height"],
|
||||
addresses=info["uris"],
|
||||
channel_stats=ChannelStats.from_list(channels),
|
||||
)
|
||||
|
||||
async def get_info(self) -> NodeInfoResponse:
|
||||
public = await self.get_public_info()
|
||||
onchain = await self.get("/v1/balance/blockchain")
|
||||
fee_report = await self.get("/v1/fees")
|
||||
balance = await self.get("/v1/balance/channels")
|
||||
return NodeInfoResponse(
|
||||
**public.dict(),
|
||||
onchain_balance_sat=onchain["total_balance"],
|
||||
onchain_confirmed_sat=onchain["confirmed_balance"],
|
||||
balance_msat=balance["local_balance"]["msat"],
|
||||
fees=NodeFees(
|
||||
total_msat=0,
|
||||
daily_msat=fee_report["day_fee_sum"],
|
||||
weekly_msat=fee_report["week_fee_sum"],
|
||||
monthly_msat=fee_report["month_fee_sum"],
|
||||
),
|
||||
)
|
||||
|
||||
async def get_payments(
|
||||
self, filters: Filters[NodePaymentsFilters]
|
||||
) -> Page[NodePayment]:
|
||||
count_key = "node:payments_count"
|
||||
payments_count = cache.get(count_key)
|
||||
if not payments_count and filters.offset:
|
||||
# this forces fetching the payments count
|
||||
await self.get_payments(Filters(limit=1))
|
||||
payments_count = cache.get(count_key)
|
||||
|
||||
if filters.offset and payments_count:
|
||||
index_offset = max(payments_count + 1 - filters.offset, 0)
|
||||
else:
|
||||
index_offset = 0
|
||||
|
||||
response = await self.get(
|
||||
"/v1/payments",
|
||||
params={
|
||||
"index_offset": index_offset,
|
||||
"max_payments": filters.limit,
|
||||
"include_incomplete": True,
|
||||
"reversed": True,
|
||||
"count_total_payments": not index_offset,
|
||||
},
|
||||
)
|
||||
|
||||
if not filters.offset:
|
||||
payments_count = int(response["total_num_payments"])
|
||||
|
||||
cache.set(count_key, payments_count)
|
||||
|
||||
payments = [
|
||||
NodePayment(
|
||||
payment_hash=payment["payment_hash"],
|
||||
pending=payment["status"] == "IN_FLIGHT",
|
||||
amount=payment["value_msat"],
|
||||
fee=payment["fee_msat"],
|
||||
time=payment["creation_date"],
|
||||
destination=await self.get_peer_info(
|
||||
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
|
||||
)
|
||||
if payment["htlcs"]
|
||||
else None,
|
||||
bolt11=payment["payment_request"],
|
||||
preimage=payment["payment_preimage"],
|
||||
)
|
||||
for payment in response["payments"]
|
||||
]
|
||||
|
||||
payments.sort(key=lambda p: p.time, reverse=True)
|
||||
|
||||
return Page(data=payments, total=payments_count or 0)
|
||||
|
||||
async def get_invoices(
|
||||
self, filters: Filters[NodeInvoiceFilters]
|
||||
) -> Page[NodeInvoice]:
|
||||
last_invoice_key = "node:last_invoice_index"
|
||||
last_invoice_index = cache.get(last_invoice_key)
|
||||
if not last_invoice_index and filters.offset:
|
||||
# this forces fetching the last invoice index so
|
||||
await self.get_invoices(Filters(limit=1))
|
||||
last_invoice_index = cache.get(last_invoice_key)
|
||||
|
||||
if filters.offset and last_invoice_index:
|
||||
index_offset = max(last_invoice_index + 1 - filters.offset, 0)
|
||||
else:
|
||||
index_offset = 0
|
||||
|
||||
response = await self.get(
|
||||
"/v1/invoices",
|
||||
params={
|
||||
"index_offset": index_offset,
|
||||
"num_max_invoices": filters.limit,
|
||||
"reversed": True,
|
||||
},
|
||||
)
|
||||
|
||||
if not filters.offset:
|
||||
last_invoice_index = int(response["last_index_offset"])
|
||||
|
||||
cache.set(last_invoice_key, last_invoice_index)
|
||||
|
||||
invoices = [
|
||||
NodeInvoice(
|
||||
payment_hash=_decode_bytes(invoice["r_hash"]),
|
||||
amount=invoice["value_msat"],
|
||||
memo=invoice["memo"],
|
||||
pending=invoice["state"] == "OPEN",
|
||||
paid_at=invoice["settle_date"],
|
||||
expiry=invoice["creation_date"] + invoice["expiry"],
|
||||
preimage=_decode_bytes(invoice["r_preimage"]),
|
||||
bolt11=invoice["payment_request"],
|
||||
)
|
||||
for invoice in reversed(response["invoices"])
|
||||
]
|
||||
|
||||
return Page(
|
||||
data=invoices,
|
||||
total=last_invoice_index or 0,
|
||||
)
|
||||
Reference in New Issue
Block a user