mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2024-11-19 18:11:30 +01:00
333 lines
11 KiB
Python
333 lines
11 KiB
Python
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["amount_msat"] / 1000 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.get("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.get("bolt11") or invoice.get("bolt12"),
|
|
amount=(
|
|
# normal invoice
|
|
invoice.get("amount_msat")
|
|
# keysend or paid amountless invoice
|
|
or invoice.get("amount_received_msat")
|
|
# unpaid amountless invoice
|
|
or 0
|
|
),
|
|
preimage=invoice.get("payment_preimage"),
|
|
memo=invoice.get("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,
|
|
)
|