This commit is contained in:
fusion44 2026-07-09 01:29:24 +00:00 committed by GitHub
commit 30fc76e5d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 461 additions and 100 deletions

View file

@ -1,6 +1,17 @@
from fastapi import HTTPException, status
class OpenChannelPushAmountError(HTTPException):
"""Raised when a the push amount is lower than the channel size."""
def __init__(self, local_funding, push_amt):
self.status_code = status.HTTP_400_BAD_REQUEST
self.detail = (
f"Push amount {push_amt} must be lower than "
f"local funding amount {local_funding}"
)
class NodeNotFoundError(HTTPException):
"""Raised when a node is not found in the graph."""

View file

@ -908,12 +908,17 @@ class LnNodeCLNgRPC(LightningNodeBase):
@logger.catch(exclude=(HTTPException,))
async def channel_open(
self, local_funding_amount: int, node_URI: str, target_confs: int
self,
local_funding_amount: int,
node_URI: str,
target_confs: int,
push_amount_sat: int,
) -> str:
logger.trace(
(
f"channel_open(local_funding_amount={local_funding_amount}, "
f"node_URI={node_URI}, target_confs={target_confs})"
f"node_URI={node_URI}, target_confs={target_confs}, "
f"push_amount_sat={push_amount_sat})"
)
)
@ -931,8 +936,11 @@ class LnNodeCLNgRPC(LightningNodeBase):
h = bytes.fromhex(node_URI.split("@")[0])
req = ln.FundchannelRequest(
id=h,
amount=lnp.AmountOrAll(amount=lnp.Amount(msat=local_funding_amount)),
amount=lnp.AmountOrAll(
amount=lnp.Amount(msat=local_funding_amount * 1000)
),
feerate=fee_rate,
push_msat=lnp.Amount(msat=push_amount_sat * 1000),
)
except TypeError as e:
logger.error(f"channel_open() failed at ln.FundchannelRequest(): {e}")
@ -986,10 +994,15 @@ class LnNodeCLNgRPC(LightningNodeBase):
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=details)
@logger.catch(exclude=(HTTPException,))
async def channel_list(self) -> List[Channel]:
logger.trace("channel_list()")
async def channel_list(
self,
include_closed: bool,
peer_alias_lookup: bool,
) -> List[Channel]:
logger.trace(f"channel_list({include_closed}, {peer_alias_lookup})")
try:
# TODO: switch to ListPeerChannels
res = await self._cln_stub.ListFunds(ln.ListfundsRequest())
peer_ids = [c.peer_id for c in res.channels]
peer_res = await asyncio.gather(

View file

@ -718,21 +718,35 @@ class LnNodeCLNjRPC(LightningNodeBase):
@logger.catch(exclude=(HTTPException,))
async def channel_open(
self, local_funding_amount: int, node_URI: str, target_confs: int
self,
local_funding_amount: int,
node_URI: str,
target_confs: int,
push_amount_sat: int,
) -> str:
logger.trace(
(
f"channel_open(local_funding_amount={local_funding_amount}, "
f"node_URI={node_URI}, target_confs={target_confs})"
f"logger.channel_open(local_funding_amount={local_funding_amount}, "
f"node_URI={node_URI}, target_confs={target_confs}, "
f"push_amount_sat={push_amount_sat})"
)
)
# https://lightning.readthedocs.io/lightning-fundchannel.7.html
# fundchannel id amount [feerate] [announce] [minconf] [utxos] [push_msat]
# [close_to] [request_amt] [compact_lease] [reserve]
await self.connect_peer(node_URI)
fee_rate = calc_fee_rate_str(None, target_confs)
pub = node_URI.split("@")[0]
params = {"id": pub, "amount": local_funding_amount, "feerate": fee_rate}
params = {
"id": pub,
"amount": local_funding_amount,
"feerate": fee_rate,
}
if push_amount_sat is not None and push_amount_sat > 0:
params["push_msat"] = push_amount_sat * 1000
res = await self._send_request("fundchannel", params)
if "error" in res:
@ -815,20 +829,26 @@ class LnNodeCLNjRPC(LightningNodeBase):
return str(nodes[0]["alias"])
@logger.catch(exclude=(HTTPException,))
async def channel_list(self) -> List[Channel]:
logger.trace("channel_list()")
async def channel_list(
self,
include_closed: bool,
peer_alias_lookup: bool,
) -> List[Channel]:
logger.trace(f"channel_list({include_closed}, {peer_alias_lookup})")
res = await self._send_request("listfunds")
res = await self._send_request("listpeerchannels")
if "error" in res:
self._raise_internal_server_error("listing channels", res)
res = res["result"]
peer_ids = [c["peer_id"] for c in res["channels"]]
peers = await asyncio.gather(
*[alias_or_empty(self.peer_resolve_alias, p) for p in peer_ids],
return_exceptions=True,
)
peers = [""] * len(res["channels"])
if peer_alias_lookup:
peer_ids = [c["peer_id"] for c in res["channels"]]
peers = await asyncio.gather(
*[alias_or_empty(self.peer_resolve_alias, p) for p in peer_ids],
return_exceptions=True,
)
channels = []
for c, p in zip(res["channels"], peers):

View file

@ -116,7 +116,11 @@ class LightningNodeBase:
@abstractmethod
async def channel_open(
self, local_funding_amount: int, node_URI: str, target_confs: int
self,
local_funding_amount: int,
node_URI: str,
target_confs: int,
push_amount_sat: int,
) -> str:
raise NotImplementedError()
@ -125,7 +129,11 @@ class LightningNodeBase:
raise NotImplementedError()
@abstractmethod
async def channel_list(self) -> List[Channel]:
async def channel_list(
self,
include_closed: bool,
peer_alias_lookup: bool,
) -> List[Channel]:
raise NotImplementedError()
@abstractmethod

View file

@ -16,10 +16,12 @@ import app.lightning.impl.protos.lnd.walletunlocker_pb2 as unlocker
import app.lightning.impl.protos.lnd.walletunlocker_pb2_grpc as unlockerrpc
from app.api.config import config as dconfig
from app.api.utils import SSE, broadcast_sse_msg, config_get_hex_str
from app.lightning.exceptions import NodeNotFoundError
from app.lightning.exceptions import NodeNotFoundError, OpenChannelPushAmountError
from app.lightning.impl.ln_base import LightningNodeBase
from app.lightning.models import (
Channel,
ChannelInitiator,
ChannelState,
FeeRevenue,
ForwardSuccessEvent,
GenericTx,
@ -818,12 +820,17 @@ This will show more debug information.
@logger.catch(exclude=(HTTPException,))
async def channel_open(
self, local_funding_amount: int, node_URI: str, target_confs: int
self,
local_funding_amount: int,
node_URI: str,
target_confs: int,
push_amount_sat: int,
) -> str:
logger.trace(
(
f"logger.channel_open(local_funding_amount={local_funding_amount}, "
f"node_URI={node_URI}, target_confs={target_confs})"
f"node_URI={node_URI}, target_confs={target_confs}, "
f"push_amount_sat={push_amount_sat})"
)
)
@ -853,14 +860,28 @@ This will show more debug information.
node_pubkey=bytes.fromhex(pubkey),
local_funding_amount=local_funding_amount,
target_conf=target_confs,
push_sat=push_amount_sat,
)
async for response in self._lnd_stub.OpenChannel(r):
return str(response.chan_pending.txid.hex())
except grpc.aio._call.AioRpcError as error:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error.details()
)
details = error.details()
if (
"amount pushed to remote peer for initial state must "
"be below the local funding amount"
) in details:
raise OpenChannelPushAmountError(local_funding_amount, push_amount_sat)
if "EOF" in details:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=(
'Got "EOF" from LND. Is the peer reachable '
"and the pubkey correct?"
),
)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=details)
@logger.catch(exclude=(HTTPException, NodeNotFoundError))
async def peer_resolve_alias(self, node_pub: str) -> str:
@ -882,29 +903,152 @@ This will show more debug information.
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=details)
@logger.catch(exclude=(HTTPException,))
async def channel_list(self) -> List[Channel]:
logger.trace("logger.channel_list()")
async def channel_list(
self,
include_closed: bool,
peer_alias_lookup: bool,
) -> List[Channel]:
logger.trace(f"channel_list({include_closed}, {peer_alias_lookup})")
try:
request = ln.ListChannelsRequest()
response = await self._lnd_stub.ListChannels(request)
res = await asyncio.gather(
*[
self._lnd_stub.PendingChannels(ln.PendingChannelsRequest()),
self._lnd_stub.ListChannels(
ln.ListChannelsRequest(peer_alias_lookup=peer_alias_lookup)
),
(
self._lnd_stub.ClosedChannels(ln.ListChannelsRequest())
if include_closed
else None
),
]
)
async def _chan_data(c, state: ChannelState):
_channel: Channel = None
if state == ChannelState.NORMAL:
_channel = Channel(
active=c.active,
state=state,
# use channel point as id because thats
# needed for closing the channel with lnd
channel_id=c.channel_point,
peer_publickey=c.remote_pubkey,
balance_local=c.local_balance,
balance_remote=c.remote_balance,
balance_capacity=c.capacity,
initiator=ChannelInitiator.from_lnd_grpc(c.initiator),
)
elif state == ChannelState.OPENING:
_channel = Channel(
active=False,
state=state,
# use channel point as id because thats
# needed for closing the channel with lnd
channel_id=c.channel.channel_point,
peer_publickey=c.channel.remote_node_pub,
balance_local=c.channel.local_balance,
balance_remote=c.channel.remote_balance,
balance_capacity=c.channel.capacity,
initiator=ChannelInitiator.from_lnd_grpc(c.channel.initiator),
)
elif state == ChannelState.CLOSING:
closer = ChannelInitiator.UNKNOWN
if "ChanStatusLocalCloseInitiator" in c.channel.chan_status_flags:
closer = ChannelInitiator.LOCAL
elif (
"ChanStatusRemoteCloseInitiator" in c.channel.chan_status_flags
):
closer = ChannelInitiator.REMOTE
_channel = Channel(
active=False,
state=state,
# use channel point as id because thats
# needed for closing the channel with lnd
channel_id=c.channel.channel_point,
peer_publickey=c.channel.remote_node_pub,
balance_local=c.channel.local_balance,
balance_remote=c.channel.remote_balance,
balance_capacity=c.channel.capacity,
initiator=ChannelInitiator.from_lnd_grpc(c.channel.initiator),
closer=closer,
)
elif state == ChannelState.FORCE_CLOSING:
closer = ChannelInitiator.UNKNOWN
if "ChanStatusLocalCloseInitiator" in c.channel.chan_status_flags:
closer = ChannelInitiator.LOCAL
elif (
"ChanStatusRemoteCloseInitiator" in c.channel.chan_status_flags
):
closer = ChannelInitiator.REMOTE
_channel = Channel(
active=False,
state=state,
# use channel point as id because thats
# needed for closing the channel with lnd
channel_id=c.channel.channel_point,
peer_publickey=c.channel.remote_node_pub,
balance_local=c.recovered_balance,
balance_remote=c.channel.remote_balance,
balance_capacity=c.channel.capacity,
initiator=ChannelInitiator.from_lnd_grpc(c.channel.initiator),
closer=closer,
)
elif state == ChannelState.CLOSED:
_channel = Channel(
active=False,
state=state,
# use channel point as id because thats
# needed for closing the channel with lnd
channel_id=c.channel_point,
peer_publickey=c.remote_pubkey,
balance_local=c.settled_balance,
balance_remote=c.capacity - c.settled_balance,
balance_capacity=c.capacity,
initiator=ChannelInitiator.from_lnd_grpc(c.open_initiator),
closer=ChannelInitiator.from_lnd_grpc(c.close_initiator),
)
if _channel is None:
logger.error("Unhandled channel state {state}")
return None
# LND returns "unable to lookup peer alias: alias for node
# not found" when peer_alias_lookup is True and node_pub is
# not found. We ignore this and just return an empty string
if (
peer_alias_lookup
and hasattr(c, "peer_alias")
and "unable to lookup peer alias: alias for node not found"
not in c.peer_alias
):
_channel.peer_alias = c.peer_alias
elif peer_alias_lookup and not hasattr(c, "peer_alias"):
_channel.peer_alias = await alias_or_empty(
self.peer_resolve_alias, _channel.peer_publickey
)
return _channel
channels = []
for channel_grpc in response.channels:
channel = Channel.from_lnd_grpc(channel_grpc)
channel.peer_alias = await alias_or_empty(
self.peer_resolve_alias, channel.peer_publickey
)
channels.append(channel)
for c in res[0].pending_open_channels:
channels.append(await _chan_data(c, ChannelState.OPENING))
for c in res[0].pending_force_closing_channels:
channels.append(await _chan_data(c, ChannelState.FORCE_CLOSING))
for c in res[0].waiting_close_channels:
channels.append(await _chan_data(c, ChannelState.CLOSING))
request = ln.PendingChannelsRequest()
response = await self._lnd_stub.PendingChannels(request)
for channel_grpc in response.pending_open_channels:
channel = Channel.from_lnd_grpc_pending(channel_grpc.channel)
channel.peer_alias = await alias_or_empty(
self.peer_resolve_alias, channel.peer_publickey
)
channels.append(channel)
for c in res[1].channels: # open
channels.append(await _chan_data(c, ChannelState.NORMAL))
if not include_closed:
return channels
for c in res[2].channels:
channels.append(await _chan_data(c, ChannelState.CLOSED))
return channels
@ -920,7 +1064,10 @@ This will show more debug information.
)
if ":" not in channel_id:
raise ValueError("channel_id must contain : for lnd")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="channel_id must contain : for lnd",
)
try:
funding_txid = channel_id.split(":")[0]
@ -931,7 +1078,7 @@ This will show more debug information.
funding_txid_str=funding_txid, output_index=int(output_index)
),
force=force_close,
target_conf=6,
target_conf=None if force_close else 6,
)
async for response in self._lnd_stub.CloseChannel(request):
return str(response.close_pending.txid.hex())

View file

@ -3,6 +3,8 @@
# clone lnd repo
# cd into lnd/lnrpc
# git clone https://github.com/googleapis/googleapis.git
# poetry shell
# bash gen_protos_python.sh
set -e

View file

@ -179,10 +179,20 @@ class LnNodeCLNgRPCBlitz(LnNodeCLNgRPC):
yield i
async def channel_open(
self, local_funding_amount: int, node_URI: str, target_confs: int
self,
local_funding_amount: int,
node_URI: str,
target_confs: int,
push_amount_sat: int,
) -> str:
self._check_if_locked()
return await super().channel_open(local_funding_amount, node_URI, target_confs)
return await super().channel_open(
local_funding_amount,
node_URI,
target_confs,
push_amount_sat,
)
async def peer_resolve_alias(self, node_pub: str) -> str:
self._check_if_locked()

View file

@ -450,52 +450,152 @@ class RouteHint(BaseModel):
return cls(hop_hints=hop_hints)
class ChannelInitiator(str, Enum):
UNKNOWN = "unknown"
LOCAL = "local"
REMOTE = "remote"
BOTH = "both"
@classmethod
def from_cln(cls, i: str) -> "ChannelInitiator":
if i == "local":
return cls.LOCAL
if i == "remote":
return cls.REMOTE
raise NotImplementedError(f"Unknown CLN channel initiator id: {i}")
@classmethod
def from_lnd_grpc(cls, i: Union[int, bool]) -> "ChannelInitiator":
# LND only returns whether we are initiator
# or not for NORMAL state channels
if i is True:
return cls.LOCAL
if i is False:
return cls.REMOTE
# LND differentiates further with pending channels
if i == 1:
return cls.LOCAL
if i == 2:
return cls.REMOTE
if i == 3:
return cls.BOTH
if i > 3:
# leave a notice in the logs
# that we don't know this case yet
logger.warning(f"Unknown channel initiator id: {i}")
return cls.UNKNOWN
class ChannelState(str, Enum):
OPENING = "opening"
NORMAL = "normal"
CLOSING = "closing"
FORCE_CLOSING = "force_closing"
CLOSED = "closed"
ABANDONED = "abandoned"
UNKNOWN = "unknown"
@classmethod
def from_cln_json(cls, s) -> "ChannelState":
openings = [
"OPENINGD",
"CHANNELD_AWAITING_LOCKIN",
"DUALOPEND_OPEN_INIT",
"DUALOPEND_AWAITING_LOCKIN",
]
closings = [
"CHANNELD_SHUTTING_DOWN",
"CLOSINGD_SIGEXCHANGE",
"CLOSINGD_COMPLETE",
"FUNDING_SPEND_SEEN",
]
if s in openings:
return cls.OPENING
if s == "CHANNELD_NORMAL":
return cls.NORMAL
if s in closings:
return cls.CLOSING
if s == "AWAITING_UNILATERAL":
return cls.FORCE_CLOSING
if s == "ONCHAIN":
return cls.CLOSED
return cls.UNKNOWN
class Channel(BaseModel):
channel_id: Optional[str]
active: Optional[bool]
channel_id: str = Query(
"",
description="The unique identifier of the channel",
)
peer_publickey: Optional[str]
peer_alias: Optional[str]
state: ChannelState = Query(..., description="The state of the channel")
balance_local: Optional[int]
balance_remote: Optional[int]
balance_capacity: Optional[int]
active: bool = Query(
...,
description=(
"Whether the channel is active or not. "
"True if the channel is not closing and the peer is online."
),
)
@classmethod
def from_lnd_grpc(cls, c) -> "Channel":
return cls(
active=c.active,
# use channel point as id because thats needed
# for closing the channel with lnd
channel_id=c.channel_point,
peer_publickey=c.remote_pubkey,
peer_alias="n/a",
balance_local=c.local_balance,
balance_remote=c.remote_balance,
balance_capacity=c.capacity,
)
peer_publickey: str = Query(
...,
description="The public key of the peer",
)
@classmethod
def from_lnd_grpc_pending(cls, c) -> "Channel":
return cls(
active=False,
# use channel point as id because thats needed
# for closing the channel with lnd
channel_id=c.channel_point,
peer_publickey=c.remote_node_pub,
peer_alias="n/a",
balance_local=-1,
balance_remote=-1,
balance_capacity=c.capacity,
)
peer_alias: str = Query(
"",
description="The alias of the peer if available",
)
balance_local: int = Query(
0,
description="This node's current balance in this channel",
)
balance_remote: int = Query(
0,
description="The counterparty's current balance in this channel",
)
balance_capacity: int = Query(
0,
description="The total capacity of the channel",
)
dual_funded: Optional[bool] = Query(
None,
description="Whether the channel was dual funded or not",
)
initiator: ChannelInitiator = Query(
ChannelInitiator.UNKNOWN,
description="Whether the channel was initiated by us, our peer or both",
)
closer: Union[ChannelInitiator, None] = Query(
None,
description=(
"If state is closing, this shows who initiated the close. "
"None, if not in a closing state."
),
)
@classmethod
def from_cln_grpc(cls, c, peer_alias="n/a") -> "Channel":
# TODO: get alias and balance of the channel
return cls(
active=c.connected,
state=ChannelState.UNKNOWN,
# state=ChannelState.from_cln_jrpc(c.state),
# use channel point as id because thats needed
# for closing the channel with lnd
# for closing the channel with lnd
channel_id=c.short_channel_id,
peer_publickey=c.peer_id.hex(),
peer_alias=peer_alias,
@ -505,17 +605,25 @@ class Channel(BaseModel):
)
@classmethod
def from_cln_jrpc(cls, c, peer_alias="n/a") -> "Channel":
# TODO: get alias and balance of the channel
def from_cln_jrpc(cls, c, peer_alias="") -> "Channel":
state = ChannelState.from_cln_json(c["state"])
return cls(
active=c["connected"],
channel_id=c["short_channel_id"] if "short_channel_id" in c else None,
active=c["peer_connected"],
state=state,
channel_id=c["short_channel_id"] if "short_channel_id" in c else "",
peer_publickey=c["peer_id"],
peer_alias=peer_alias,
balance_local=parse_cln_msat(c["our_amount_msat"]),
balance_remote=parse_cln_msat(c["amount_msat"])
- parse_cln_msat(c["our_amount_msat"]),
balance_capacity=parse_cln_msat(c["amount_msat"]),
balance_local=parse_cln_msat(c["to_us_msat"]) if "to_us_msat" in c else 0,
balance_remote=(
parse_cln_msat(c["total_msat"]) - parse_cln_msat(c["to_us_msat"])
if "total_msat" in c
else 0
),
balance_capacity=(
parse_cln_msat(c["total_msat"]) if "total_msat" in c else 0
),
initiator=ChannelInitiator.from_cln(c["opener"]),
closer=ChannelInitiator.from_cln(c["closer"]) if "closer" in c else None,
)

View file

@ -358,10 +358,32 @@ async def send_coins_path(input: SendCoinsInput):
},
)
async def open_channel_path(
local_funding_amount: int, node_URI: str, target_confs: int = 3
local_funding_amount: int = Query(
..., description="The amount of satoshis to commit to the channel."
),
node_URI: str = Query(
...,
description=(
"The URI of the peer to open a channel with. "
"Format: <pubkey>@<host>:<port>"
),
),
target_confs: int = Query(
3, description="The block target for the funding transaction."
),
push_amount_sat: int = Query(
# Note: LND only supports satoshis as push amount, so we do the same.
None,
description="The amount of sats to push to the peer.",
),
):
try:
return await channel_open(local_funding_amount, node_URI, target_confs)
return await channel_open(
local_funding_amount,
node_URI,
target_confs,
push_amount_sat,
)
except HTTPException:
raise
except NotImplementedError as r:
@ -373,15 +395,27 @@ async def open_channel_path(
@router.get(
"/list-channels",
name=f"{_PREFIX}.list-channels",
summary="Returns a list of open channels",
summary="Returns a list of all channels",
response_model=List[Channel],
response_description="A list of all open channels.",
response_description="A list of all channels.",
dependencies=[Depends(JWTBearer())],
responses=responses,
)
async def list_channels_path():
async def list_channels_path(
include_closed: bool = Query(
True, description="If true, then include closed channels."
),
peer_alias_lookup: bool = Query(
False,
description=(
"If true, then include the peer alias of the channel. "
"⚠️ Enabling this flag does come with a performance cost "
"in the form of another roundtrip to the node."
),
),
) -> List[Channel]:
try:
return await channel_list()
return await channel_list(include_closed, peer_alias_lookup)
except HTTPException:
raise
except NotImplementedError as r:
@ -394,7 +428,10 @@ async def list_channels_path():
"/close-channel",
name=f"{_PREFIX}.close-channel",
summary="close a channel",
description="For additional information see [LND docs](https://api.lightning.community/#closechannel)",
description=(
"For additional information see "
"[LND docs](https://api.lightning.community/#closechannel)"
),
dependencies=[Depends(JWTBearer())],
response_model=str,
responses=responses,

View file

@ -140,7 +140,7 @@ async def send_payment(
async def channel_open(
local_funding_amount: int, node_URI: str, target_confs: int
local_funding_amount: int, node_URI: str, target_confs: int, push_amount_sat: int
) -> str:
if local_funding_amount < 1:
raise ValueError("funding amount needs to be positive")
@ -154,12 +154,17 @@ async def channel_open(
if "@" not in node_URI:
raise ValueError("node_URI must contain @ with node physical address")
res = await ln.channel_open(local_funding_amount, node_URI, target_confs)
res = await ln.channel_open(
local_funding_amount, node_URI, target_confs, push_amount_sat
)
return res
async def channel_list() -> List[Channel]:
res = await ln.channel_list()
async def channel_list(
include_closed: bool,
peer_alias_lookup: bool,
) -> List[Channel]:
res = await ln.channel_list(include_closed, peer_alias_lookup)
return res