diff --git a/lnbits/core/templates/node/_tab_channels.html b/lnbits/core/templates/node/_tab_channels.html index ac9796d2..2fe7fe60 100644 --- a/lnbits/core/templates/node/_tab_channels.html +++ b/lnbits/core/templates/node/_tab_channels.html @@ -29,7 +29,46 @@ - + + + +

+ + + + + +
+ + +
+
+
+
+ +
-
+
+
+ Peer ID + +
+
+ Fees + + + ppm, + msat + +
+
+ Channel ID + +
+ this.formatMsat(row.amount), sortable: true + }, + { + name: 'memo', + align: 'left', + label: this.$t('memo'), + field: 'memo' } ], pagination: { @@ -201,12 +209,12 @@ } } }, - created: function () { + created() { this.getInfo() this.get1MLStats() }, watch: { - tab: function (val) { + tab(val) { if (val === 'transactions' && !this.paymentsTable.data.length) { this.getPayments() this.getInvoices() @@ -220,14 +228,14 @@ checkChanges() { return !_.isEqual(this.settings, this.formData) }, - filteredChannels: function () { + filteredChannels() { return this.stateFilters ? this.channels.data.filter(channel => { return this.stateFilters.find(({value}) => value == channel.state) }) : this.channels.data }, - totalBalance: function () { + totalBalance() { return this.filteredChannels.reduce( (balance, channel) => { balance.local_msat += channel.balance.local_msat @@ -240,10 +248,10 @@ } }, methods: { - formatMsat: function (msat) { + formatMsat(msat) { return LNbits.utils.formatMsat(msat) }, - api: function (method, url, options) { + api(method, url, options) { const params = new URLSearchParams(options?.query) return LNbits.api .request(method, `/node/api/v1${url}?${params}`, {}, options?.data) @@ -251,23 +259,29 @@ LNbits.utils.notifyApiError(error) }) }, - getChannels: function () { + getChannel(channel_id) { + return this.api('GET', `/channels/${channel_id}`).then(response => { + this.setFeeDialog.data.fee_ppm = response.data.fee_ppm + this.setFeeDialog.data.fee_base_msat = response.data.fee_base_msat + }) + }, + getChannels() { return this.api('GET', '/channels').then(response => { this.channels.data = response.data }) }, - getInfo: function () { + getInfo() { return this.api('GET', '/info').then(response => { this.info = response.data this.channel_stats = response.data.channel_stats }) }, - get1MLStats: function () { + get1MLStats() { return this.api('GET', '/rank').then(response => { this.ranks = response.data }) }, - getPayments: function (props) { + getPayments(props) { if (props) { this.paymentsTable.pagination = props.pagination } @@ -281,7 +295,7 @@ this.paymentsTable.pagination.rowsNumber = response.data.total }) }, - getInvoices: function (props) { + getInvoices(props) { if (props) { this.invoiceTable.pagination = props.pagination } @@ -295,14 +309,13 @@ this.invoiceTable.pagination.rowsNumber = response.data.total }) }, - getPeers: function () { + getPeers() { return this.api('GET', '/peers').then(response => { this.peers.data = response.data console.log('peers', this.peers) }) }, - connectPeer: function () { - console.log('peer', this.connectPeerDialog) + connectPeer() { this.api('POST', '/peers', {data: this.connectPeerDialog.data}).then( () => { this.connectPeerDialog.show = false @@ -310,7 +323,7 @@ } ) }, - disconnectPeer: function (id) { + disconnectPeer(id) { LNbits.utils .confirmDialog('Do you really wanna disconnect this peer?') .onOk(() => { @@ -324,7 +337,17 @@ }) }) }, - openChannel: function () { + setChannelFee(channel_id) { + this.api('PUT', `/channels/${channel_id}`, { + data: this.setFeeDialog.data + }) + .then(response => { + this.setFeeDialog.show = false + this.getChannels() + }) + .catch(LNbits.utils.notifyApiError) + }, + openChannel() { this.api('POST', '/channels', {data: this.openChannelDialog.data}) .then(response => { this.openChannelDialog.show = false @@ -334,7 +357,7 @@ console.log(error) }) }, - showCloseChannelDialog: function (channel) { + showCloseChannelDialog(channel) { this.closeChannelDialog.show = true this.closeChannelDialog.data = { force: false, @@ -342,7 +365,7 @@ ...channel.point } }, - closeChannel: function () { + closeChannel() { this.api('DELETE', '/channels', { query: this.closeChannelDialog.data }).then(response => { @@ -350,15 +373,20 @@ this.getChannels() }) }, - showOpenChannelDialog: function (peer_id) { + showSetFeeDialog(channel_id) { + this.setFeeDialog.show = true + this.setFeeDialog.channel_id = channel_id + this.getChannel(channel_id) + }, + showOpenChannelDialog(peer_id) { this.openChannelDialog.show = true this.openChannelDialog.data = {peer_id, funding_amount: 0} }, - showNodeInfoDialog: function (node) { + showNodeInfoDialog(node) { this.nodeInfoDialog.show = true this.nodeInfoDialog.data = node }, - showTransactionDetailsDialog: function (details) { + showTransactionDetailsDialog(details) { this.transactionDetailsDialog.show = true this.transactionDetailsDialog.data = details console.log('details', details) diff --git a/lnbits/core/views/node_api.py b/lnbits/core/views/node_api.py index 905ab508..8f5cefa8 100644 --- a/lnbits/core/views/node_api.py +++ b/lnbits/core/views/node_api.py @@ -95,6 +95,14 @@ async def api_get_channels( return await node.get_channels() +@node_router.get("/channels/{channel_id}") +async def api_get_channel( + channel_id: str, + node: Node = Depends(require_node), +) -> Optional[NodeChannel]: + return await node.get_channel(channel_id) + + @super_node_router.post("/channels", response_model=ChannelPoint) async def api_create_channel( node: Node = Depends(require_node), @@ -125,7 +133,17 @@ async def api_delete_channel( ) -@node_router.get("/payments", response_model=Page[NodePayment]) +@super_node_router.put("/channels/{channel_id}") +async def api_set_channel_fees( + channel_id: str, + node: Node = Depends(require_node), + fee_ppm: int = Body(None), + fee_base_msat: int = Body(None), +): + await node.set_channel_fee(channel_id, fee_base_msat, fee_ppm) + + +@node_router.get("/payments") async def api_get_payments( node: Node = Depends(require_node), filters: Filters = Depends(parse_filters(NodePaymentsFilters)), @@ -138,7 +156,7 @@ async def api_get_payments( return await node.get_payments(filters) -@node_router.get("/invoices", response_model=Page[NodeInvoice]) +@node_router.get("/invoices") async def api_get_invoices( node: Node = Depends(require_node), filters: Filters = Depends(parse_filters(NodeInvoiceFilters)), @@ -151,7 +169,7 @@ async def api_get_invoices( return await node.get_invoices(filters) -@node_router.get("/peers", response_model=List[NodePeerInfo]) +@node_router.get("/peers") async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]: return await node.get_peers() diff --git a/lnbits/nodes/base.py b/lnbits/nodes/base.py index 166eedef..b436eaad 100644 --- a/lnbits/nodes/base.py +++ b/lnbits/nodes/base.py @@ -38,15 +38,22 @@ class ChannelPoint(BaseModel): funding_txid: str output_index: int + def __str__(self): + return f"{self.funding_txid}:{self.output_index}" + 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] + # could be optional for closing/pending channels on lndrest + id: Optional[str] = None + short_id: Optional[str] = None + point: Optional[ChannelPoint] = None + name: Optional[str] = None + color: Optional[str] = None + fee_ppm: Optional[int] = None + fee_base_msat: Optional[int] = None class ChannelStats(BaseModel): @@ -144,7 +151,6 @@ class NodePaymentsFilters(FilterModel): class Node(ABC): - wallet: Wallet def __init__(self, wallet: Wallet): self.wallet = wallet @@ -161,7 +167,7 @@ class Node(ABC): @abstractmethod async def _get_id(self) -> str: - pass + raise NotImplementedError async def get_peers(self) -> list[NodePeerInfo]: peer_ids = await self.get_peer_ids() @@ -169,15 +175,15 @@ class Node(ABC): @abstractmethod async def get_peer_ids(self) -> list[str]: - pass + raise NotImplementedError @abstractmethod async def connect_peer(self, uri: str): - pass + raise NotImplementedError @abstractmethod async def disconnect_peer(self, peer_id: str): - pass + raise NotImplementedError @abstractmethod async def _get_peer_info(self, peer_id: str) -> NodePeerInfo: @@ -200,7 +206,7 @@ class Node(ABC): push_amount: Optional[int] = None, fee_rate: Optional[int] = None, ) -> ChannelPoint: - pass + raise NotImplementedError @abstractmethod async def close_channel( @@ -209,15 +215,23 @@ class Node(ABC): point: Optional[ChannelPoint] = None, force: bool = False, ): - pass + raise NotImplementedError + + @abstractmethod + async def get_channel(self, channel_id: str) -> Optional[NodeChannel]: + raise NotImplementedError @abstractmethod async def get_channels(self) -> list[NodeChannel]: - pass + raise NotImplementedError + + @abstractmethod + async def set_channel_fee(self, channel_id: str, base_msat: int, ppm: int): + raise NotImplementedError @abstractmethod async def get_info(self) -> NodeInfoResponse: - pass + raise NotImplementedError async def get_public_info(self) -> PublicNodeInfo: info = await self.get_info() @@ -227,10 +241,10 @@ class Node(ABC): async def get_payments( self, filters: Filters[NodePaymentsFilters] ) -> Page[NodePayment]: - pass + raise NotImplementedError @abstractmethod async def get_invoices( self, filters: Filters[NodeInvoiceFilters] ) -> Page[NodeInvoice]: - pass + raise NotImplementedError diff --git a/lnbits/nodes/cln.py b/lnbits/nodes/cln.py index 72e4b8a4..396b62f9 100644 --- a/lnbits/nodes/cln.py +++ b/lnbits/nodes/cln.py @@ -66,6 +66,28 @@ class CoreLightningNode(Node): fn = getattr(self.wallet.ln, method) return await loop.run_in_executor(None, lambda: fn(*args, **kwargs)) + def _parse_state(self, state: str) -> ChannelState: + if state == "CHANNELD_NORMAL": + return ChannelState.ACTIVE + if state in ( + # wait for force close + "AWAITING_UNILATERAL", + # waiting for close + "CHANNELD_SHUTTING_DOWN", + # waiting for open + "CHANNELD_AWAITING_LOCKIN", + "OPENINGD", + ): + return ChannelState.PENDING + if state in ( + "CHANNELD_CLOSING", + "CLOSINGD_COMPLETE", + "CLOSINGD_SIGEXCHANGE", + "ONCHAIN", + ): + return ChannelState.CLOSED + return ChannelState.INACTIVE + @catch_rpc_errors async def connect_peer(self, uri: str): # https://docs.corelightning.org/reference/lightning-connect @@ -202,48 +224,45 @@ class CoreLightningNode(Node): else: return NodePeerInfo(id=node["nodeid"]) + @catch_rpc_errors + async def set_channel_fee(self, channel_id: str, base_msat: int, ppm: int): + await self.ln_rpc("setchannel", channel_id, feebase=base_msat, feeppm=ppm) + + @catch_rpc_errors + async def get_channel(self, channel_id: str) -> Optional[NodeChannel]: + channels = await self.get_channels() + for channel in channels: + if channel.id == channel_id: + return channel + return None + @catch_rpc_errors async def get_channels(self) -> list[NodeChannel]: - funds = await self.ln_rpc("listfunds") + channels = await self.ln_rpc("listpeerchannels") nodes = await self.ln_rpc("listnodes") nodes_by_id = {n["nodeid"]: n for n in nodes["nodes"]} return [ NodeChannel( + id=ch["channel_id"], short_id=ch.get("short_channel_id"), point=ChannelPoint( funding_txid=ch["funding_txid"], - output_index=ch["funding_output"], + output_index=ch["funding_outnum"], ), 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"], + local_msat=ch["spendable_msat"], + remote_msat=ch["receivable_msat"], + total_msat=ch["total_msat"], ), + fee_ppm=ch["fee_proportional_millionths"], + fee_base_msat=ch["fee_base_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 - ) - ) - ), + state=self._parse_state(ch["state"]), ) - for ch in funds["channels"] + for ch in channels["channels"] ] @catch_rpc_errors diff --git a/lnbits/nodes/lndrest.py b/lnbits/nodes/lndrest.py index ac337b51..bc01e9e0 100644 --- a/lnbits/nodes/lndrest.py +++ b/lnbits/nodes/lndrest.py @@ -41,6 +41,14 @@ def _decode_bytes(data: str) -> str: return base64.b64decode(data).hex() +def _encode_bytes(data: str) -> str: + return base64.b64encode(bytes.fromhex(data)).decode() + + +def _encode_urlsafe_bytes(data: str) -> str: + return base64.urlsafe_b64encode(bytes.fromhex(data)).decode() + + def _parse_channel_point(raw: str) -> ChannelPoint: funding_tx, output_index = raw.split(":") return ChannelPoint( @@ -129,15 +137,12 @@ class LndRestNode(Node): 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, - } - ), + json={ + "node_pubkey": _encode_bytes(peer_id), + "sat_per_vbyte": fee_rate, + "local_funding_amount": local_amount, + "push_sat": push_amount, + }, ) return ChannelPoint( # WHY IS THIS REVERSED?! @@ -184,6 +189,66 @@ class LndRestNode(Node): asyncio.create_task(self._close_channel(point, force)) # noqa: RUF006 + async def set_channel_fee(self, channel_id: str, base_msat: int, ppm: int): + # https://lightning.engineering/api-docs/api/lnd/lightning/update-channel-policy/ + channel = await self.get_channel(channel_id) + if not channel: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Channel not found" + ) + if not channel.point: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required" + ) + await self.request( + "POST", + "/v1/chanpolicy", + json={ + "base_fee_msat": base_msat, + "fee_rate_ppm": ppm, + "chan_point": { + "funding_txid_str": channel.point.funding_txid, + "output_index": channel.point.output_index, + }, + # https://docs.lightning.engineering/lightning-network-tools/lnd/optimal-configuration-of-a-routing-node#channel-defaults + "time_lock_delta": 80, + # 'max_htlc_msat': , + # 'min_htlc_msat': , + # 'inbound_fee': , + }, + ) + + async def get_channel(self, channel_id: str) -> Optional[NodeChannel]: + channel_info = await self.get(f"/v1/graph/edge/{channel_id}") + peer_id = channel_info["node2_pub"] + peer_b64 = _encode_urlsafe_bytes(peer_id) + channels = await self.get(f"/v1/channels?peer={peer_b64}") + if "error" in channel_info and "error" in channels: + return None + for channel in channels["channels"]: + if channel["chan_id"] == channel_id: + peer_info = await self.get_peer_info(peer_id) + return NodeChannel( + id=channel.get("chan_id"), + peer_id=peer_info.id, + name=peer_info.alias, + color=peer_info.color, + state=( + ChannelState.ACTIVE + if channel["active"] + else ChannelState.INACTIVE + ), + fee_ppm=channel_info["node1_policy"]["fee_rate_milli_msat"], + fee_base_msat=channel_info["node1_policy"]["fee_base_msat"], + 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"]), + ), + ) + return None + async def get_channels(self) -> list[NodeChannel]: normal, pending, closed = await asyncio.gather( self.get("/v1/channels"), @@ -203,6 +268,7 @@ class LndRestNode(Node): state=state, name=info.alias, color=info.color, + id=channel.get("chan_id", "node is for pending channels"), point=_parse_channel_point(channel["channel_point"]), balance=ChannelBalance( local_msat=msat(channel["local_balance"]), @@ -222,6 +288,7 @@ class LndRestNode(Node): info = await self.get_peer_info(channel["remote_pubkey"]) channels.append( NodeChannel( + id=channel.get("chan_id", "node is for closing channels"), peer_id=info.id, state=ChannelState.CLOSED, name=info.alias, @@ -239,6 +306,7 @@ class LndRestNode(Node): info = await self.get_peer_info(channel["remote_pubkey"]) channels.append( NodeChannel( + id=channel["chan_id"], short_id=channel["chan_id"], point=_parse_channel_point(channel["channel_point"]), peer_id=channel["remote_pubkey"], diff --git a/tests/regtest/test_x_node_api.py b/tests/regtest/test_x_node_api.py index bff5d5b2..30e0b7fb 100644 --- a/tests/regtest/test_x_node_api.py +++ b/tests/regtest/test_x_node_api.py @@ -99,6 +99,55 @@ async def test_node_payments(node_client, real_invoice, adminkey_headers_from): ) +@pytest.mark.anyio +async def test_get_channel(node_client): + # lndrest is slow / async with channel commands + await asyncio.sleep(3) + response = await node_client.get("/node/api/v1/channels") + assert response.status_code == 200 + channels = parse_obj_as(list[NodeChannel], response.json()) + + ch = random.choice( + [channel for channel in channels if channel.state == ChannelState.ACTIVE] + ) + assert ch, "No active channel found" + assert ch.point, "No channel point found" + + await asyncio.sleep(3) + response = await node_client.get(f"/node/api/v1/channels/{ch.id}") + assert response.status_code == 200 + + channel = parse_obj_as(NodeChannel, response.json()) + assert channel.id == ch.id + + +@pytest.mark.anyio +async def test_set_channel_fees(node_client): + # lndrest is slow / async with channel commands + await asyncio.sleep(3) + response = await node_client.get("/node/api/v1/channels") + assert response.status_code == 200 + channels = parse_obj_as(list[NodeChannel], response.json()) + + ch = random.choice( + [channel for channel in channels if channel.state == ChannelState.ACTIVE] + ) + assert ch, "No active channel found" + assert ch.point, "No channel point found" + + response = await node_client.put( + f"/node/api/v1/channels/{ch.id}", json={"fee_base_msat": 42, "fee_ppm": 69} + ) + assert response.status_code == 200 + + await asyncio.sleep(3) + response = await node_client.get(f"/node/api/v1/channels/{ch.id}") + assert response.status_code == 200 + channel = parse_obj_as(NodeChannel, response.json()) + assert channel.fee_ppm == 69 + assert channel.fee_base_msat == 42 + + @pytest.mark.anyio async def test_channel_management(node_client): async def get_channels():