feat: nodemanager, view and edit channels fees (#2818)

This commit is contained in:
dni ⚡ 2024-12-20 09:48:09 +01:00 committed by GitHub
parent 0c5b909c7a
commit 3900d2871d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 360 additions and 93 deletions

View file

@ -29,7 +29,46 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="openChannelDialog.show" position="top"> <q-dialog v-model="setFeeDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<label class="text-h6">Set Channel Fee</label>
<p class="text-caption" v-text="setFeeDialog.channel_id"></p>
<q-separator></q-separator>
<q-form class="q-gutter-md">
<q-input
dense
type="number"
filled
v-model.number="setFeeDialog.data.fee_ppm"
label="Fee Rate PPM"
></q-input>
<q-input
dense
type="number"
filled
v-model.number="setFeeDialog.data.fee_base_msat"
label="Fee Base msat"
></q-input>
<div class="row q-mt-lg">
<q-btn
:label="$t('set')"
color="primary"
@click="setChannelFee(setFeeDialog.channel_id)"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="openChannelDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md"> <q-form class="q-gutter-md">
<q-input <q-input
@ -171,10 +210,41 @@
<q-tr :props="props"> <q-tr :props="props">
<div class="q-pb-sm"> <div class="q-pb-sm">
<div class="row items-center q-gutter-sm"> <div class="row items-center q-gutter-sm">
<div <div class="text-subtitle1" v-text="props.row.name"></div>
class="text-subtitle1 col-grow" <div class="text-caption" v-if="props.row.peer_id">
v-text="props.row.name" <span>Peer ID</span>
></div> <q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.peer_id)"
></q-btn>
</div>
<div class="text-caption col-grow">
<span>Fees</span>
<q-btn
size="xs"
flat
dense
icon="settings"
@click="showSetFeeDialog(props.row.id)"
></q-btn>
<span v-if="props.row.fee_ppm">
<span v-text="props.row.fee_ppm"></span> ppm,
<span v-text="props.row.fee_base_msat"></span> msat
</span>
</div>
<div class="text-caption" v-if="props.row.id">
<span>Channel ID</span>
<q-btn
size="xs"
flat
dense
icon="content_paste"
@click="copyText(props.row.id)"
></q-btn>
</div>
<div class="text-caption" v-if="props.row.short_id"> <div class="text-caption" v-if="props.row.short_id">
<span v-text="props.row.short_id"></span> <span v-text="props.row.short_id"></span>
<q-btn <q-btn
@ -201,6 +271,7 @@
color="pink" color="pink"
></q-btn> ></q-btn>
</div> </div>
<lnbits-channel-balance <lnbits-channel-balance
:balance="props.row.balance" :balance="props.row.balance"
:color="props.row.color" :color="props.row.color"

View file

@ -52,7 +52,7 @@
} }
}, },
mixins: [window.windowMixin], mixins: [window.windowMixin],
data: function () { data() {
return { return {
isSuperUser: false, isSuperUser: false,
wallet: {}, wallet: {},
@ -79,6 +79,14 @@
data: {} data: {}
}, },
setFeeDialog: {
show: false,
data: {
fee_ppm: 0,
fee_base_msat: 0
}
},
openChannelDialog: { openChannelDialog: {
show: false, show: false,
data: {} data: {}
@ -118,12 +126,6 @@
name: 'pending', name: 'pending',
label: '' label: ''
}, },
{
name: 'memo',
align: 'left',
label: this.$t('memo'),
field: 'memo'
},
{ {
name: 'date', name: 'date',
align: 'left', align: 'left',
@ -149,6 +151,12 @@
align: 'right', align: 'right',
label: 'Destination', label: 'Destination',
field: 'destination' field: 'destination'
},
{
name: 'memo',
align: 'left',
label: this.$t('memo'),
field: 'memo'
} }
], ],
pagination: { pagination: {
@ -165,16 +173,10 @@
name: 'pending', name: 'pending',
label: '' label: ''
}, },
{
name: 'memo',
align: 'left',
label: this.$t('memo'),
field: 'memo'
},
{ {
name: 'paid_at', name: 'paid_at',
field: 'paid_at', field: 'paid_at',
align: 'right', align: 'left',
label: 'Paid at', label: 'Paid at',
sortable: true sortable: true
}, },
@ -182,7 +184,7 @@
name: 'expiry', name: 'expiry',
label: this.$t('expiry'), label: this.$t('expiry'),
field: 'expiry', field: 'expiry',
align: 'right', align: 'left',
sortable: true sortable: true
}, },
{ {
@ -190,6 +192,12 @@
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')', label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
field: row => this.formatMsat(row.amount), field: row => this.formatMsat(row.amount),
sortable: true sortable: true
},
{
name: 'memo',
align: 'left',
label: this.$t('memo'),
field: 'memo'
} }
], ],
pagination: { pagination: {
@ -201,12 +209,12 @@
} }
} }
}, },
created: function () { created() {
this.getInfo() this.getInfo()
this.get1MLStats() this.get1MLStats()
}, },
watch: { watch: {
tab: function (val) { tab(val) {
if (val === 'transactions' && !this.paymentsTable.data.length) { if (val === 'transactions' && !this.paymentsTable.data.length) {
this.getPayments() this.getPayments()
this.getInvoices() this.getInvoices()
@ -220,14 +228,14 @@
checkChanges() { checkChanges() {
return !_.isEqual(this.settings, this.formData) return !_.isEqual(this.settings, this.formData)
}, },
filteredChannels: function () { filteredChannels() {
return this.stateFilters return this.stateFilters
? this.channels.data.filter(channel => { ? this.channels.data.filter(channel => {
return this.stateFilters.find(({value}) => value == channel.state) return this.stateFilters.find(({value}) => value == channel.state)
}) })
: this.channels.data : this.channels.data
}, },
totalBalance: function () { totalBalance() {
return this.filteredChannels.reduce( return this.filteredChannels.reduce(
(balance, channel) => { (balance, channel) => {
balance.local_msat += channel.balance.local_msat balance.local_msat += channel.balance.local_msat
@ -240,10 +248,10 @@
} }
}, },
methods: { methods: {
formatMsat: function (msat) { formatMsat(msat) {
return LNbits.utils.formatMsat(msat) return LNbits.utils.formatMsat(msat)
}, },
api: function (method, url, options) { api(method, url, options) {
const params = new URLSearchParams(options?.query) const params = new URLSearchParams(options?.query)
return LNbits.api return LNbits.api
.request(method, `/node/api/v1${url}?${params}`, {}, options?.data) .request(method, `/node/api/v1${url}?${params}`, {}, options?.data)
@ -251,23 +259,29 @@
LNbits.utils.notifyApiError(error) 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 => { return this.api('GET', '/channels').then(response => {
this.channels.data = response.data this.channels.data = response.data
}) })
}, },
getInfo: function () { getInfo() {
return this.api('GET', '/info').then(response => { return this.api('GET', '/info').then(response => {
this.info = response.data this.info = response.data
this.channel_stats = response.data.channel_stats this.channel_stats = response.data.channel_stats
}) })
}, },
get1MLStats: function () { get1MLStats() {
return this.api('GET', '/rank').then(response => { return this.api('GET', '/rank').then(response => {
this.ranks = response.data this.ranks = response.data
}) })
}, },
getPayments: function (props) { getPayments(props) {
if (props) { if (props) {
this.paymentsTable.pagination = props.pagination this.paymentsTable.pagination = props.pagination
} }
@ -281,7 +295,7 @@
this.paymentsTable.pagination.rowsNumber = response.data.total this.paymentsTable.pagination.rowsNumber = response.data.total
}) })
}, },
getInvoices: function (props) { getInvoices(props) {
if (props) { if (props) {
this.invoiceTable.pagination = props.pagination this.invoiceTable.pagination = props.pagination
} }
@ -295,14 +309,13 @@
this.invoiceTable.pagination.rowsNumber = response.data.total this.invoiceTable.pagination.rowsNumber = response.data.total
}) })
}, },
getPeers: function () { getPeers() {
return this.api('GET', '/peers').then(response => { return this.api('GET', '/peers').then(response => {
this.peers.data = response.data this.peers.data = response.data
console.log('peers', this.peers) console.log('peers', this.peers)
}) })
}, },
connectPeer: function () { connectPeer() {
console.log('peer', this.connectPeerDialog)
this.api('POST', '/peers', {data: this.connectPeerDialog.data}).then( this.api('POST', '/peers', {data: this.connectPeerDialog.data}).then(
() => { () => {
this.connectPeerDialog.show = false this.connectPeerDialog.show = false
@ -310,7 +323,7 @@
} }
) )
}, },
disconnectPeer: function (id) { disconnectPeer(id) {
LNbits.utils LNbits.utils
.confirmDialog('Do you really wanna disconnect this peer?') .confirmDialog('Do you really wanna disconnect this peer?')
.onOk(() => { .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}) this.api('POST', '/channels', {data: this.openChannelDialog.data})
.then(response => { .then(response => {
this.openChannelDialog.show = false this.openChannelDialog.show = false
@ -334,7 +357,7 @@
console.log(error) console.log(error)
}) })
}, },
showCloseChannelDialog: function (channel) { showCloseChannelDialog(channel) {
this.closeChannelDialog.show = true this.closeChannelDialog.show = true
this.closeChannelDialog.data = { this.closeChannelDialog.data = {
force: false, force: false,
@ -342,7 +365,7 @@
...channel.point ...channel.point
} }
}, },
closeChannel: function () { closeChannel() {
this.api('DELETE', '/channels', { this.api('DELETE', '/channels', {
query: this.closeChannelDialog.data query: this.closeChannelDialog.data
}).then(response => { }).then(response => {
@ -350,15 +373,20 @@
this.getChannels() 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.show = true
this.openChannelDialog.data = {peer_id, funding_amount: 0} this.openChannelDialog.data = {peer_id, funding_amount: 0}
}, },
showNodeInfoDialog: function (node) { showNodeInfoDialog(node) {
this.nodeInfoDialog.show = true this.nodeInfoDialog.show = true
this.nodeInfoDialog.data = node this.nodeInfoDialog.data = node
}, },
showTransactionDetailsDialog: function (details) { showTransactionDetailsDialog(details) {
this.transactionDetailsDialog.show = true this.transactionDetailsDialog.show = true
this.transactionDetailsDialog.data = details this.transactionDetailsDialog.data = details
console.log('details', details) console.log('details', details)

View file

@ -95,6 +95,14 @@ async def api_get_channels(
return await node.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) @super_node_router.post("/channels", response_model=ChannelPoint)
async def api_create_channel( async def api_create_channel(
node: Node = Depends(require_node), 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( async def api_get_payments(
node: Node = Depends(require_node), node: Node = Depends(require_node),
filters: Filters = Depends(parse_filters(NodePaymentsFilters)), filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
@ -138,7 +156,7 @@ async def api_get_payments(
return await node.get_payments(filters) return await node.get_payments(filters)
@node_router.get("/invoices", response_model=Page[NodeInvoice]) @node_router.get("/invoices")
async def api_get_invoices( async def api_get_invoices(
node: Node = Depends(require_node), node: Node = Depends(require_node),
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)), filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
@ -151,7 +169,7 @@ async def api_get_invoices(
return await node.get_invoices(filters) 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]: async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]:
return await node.get_peers() return await node.get_peers()

View file

@ -38,15 +38,22 @@ class ChannelPoint(BaseModel):
funding_txid: str funding_txid: str
output_index: int output_index: int
def __str__(self):
return f"{self.funding_txid}:{self.output_index}"
class NodeChannel(BaseModel): class NodeChannel(BaseModel):
short_id: Optional[str] = None
point: Optional[ChannelPoint] = None
peer_id: str peer_id: str
balance: ChannelBalance balance: ChannelBalance
state: ChannelState state: ChannelState
name: Optional[str] # could be optional for closing/pending channels on lndrest
color: Optional[str] 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): class ChannelStats(BaseModel):
@ -144,7 +151,6 @@ class NodePaymentsFilters(FilterModel):
class Node(ABC): class Node(ABC):
wallet: Wallet
def __init__(self, wallet: Wallet): def __init__(self, wallet: Wallet):
self.wallet = wallet self.wallet = wallet
@ -161,7 +167,7 @@ class Node(ABC):
@abstractmethod @abstractmethod
async def _get_id(self) -> str: async def _get_id(self) -> str:
pass raise NotImplementedError
async def get_peers(self) -> list[NodePeerInfo]: async def get_peers(self) -> list[NodePeerInfo]:
peer_ids = await self.get_peer_ids() peer_ids = await self.get_peer_ids()
@ -169,15 +175,15 @@ class Node(ABC):
@abstractmethod @abstractmethod
async def get_peer_ids(self) -> list[str]: async def get_peer_ids(self) -> list[str]:
pass raise NotImplementedError
@abstractmethod @abstractmethod
async def connect_peer(self, uri: str): async def connect_peer(self, uri: str):
pass raise NotImplementedError
@abstractmethod @abstractmethod
async def disconnect_peer(self, peer_id: str): async def disconnect_peer(self, peer_id: str):
pass raise NotImplementedError
@abstractmethod @abstractmethod
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo: async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
@ -200,7 +206,7 @@ class Node(ABC):
push_amount: Optional[int] = None, push_amount: Optional[int] = None,
fee_rate: Optional[int] = None, fee_rate: Optional[int] = None,
) -> ChannelPoint: ) -> ChannelPoint:
pass raise NotImplementedError
@abstractmethod @abstractmethod
async def close_channel( async def close_channel(
@ -209,15 +215,23 @@ class Node(ABC):
point: Optional[ChannelPoint] = None, point: Optional[ChannelPoint] = None,
force: bool = False, force: bool = False,
): ):
pass raise NotImplementedError
@abstractmethod
async def get_channel(self, channel_id: str) -> Optional[NodeChannel]:
raise NotImplementedError
@abstractmethod @abstractmethod
async def get_channels(self) -> list[NodeChannel]: 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 @abstractmethod
async def get_info(self) -> NodeInfoResponse: async def get_info(self) -> NodeInfoResponse:
pass raise NotImplementedError
async def get_public_info(self) -> PublicNodeInfo: async def get_public_info(self) -> PublicNodeInfo:
info = await self.get_info() info = await self.get_info()
@ -227,10 +241,10 @@ class Node(ABC):
async def get_payments( async def get_payments(
self, filters: Filters[NodePaymentsFilters] self, filters: Filters[NodePaymentsFilters]
) -> Page[NodePayment]: ) -> Page[NodePayment]:
pass raise NotImplementedError
@abstractmethod @abstractmethod
async def get_invoices( async def get_invoices(
self, filters: Filters[NodeInvoiceFilters] self, filters: Filters[NodeInvoiceFilters]
) -> Page[NodeInvoice]: ) -> Page[NodeInvoice]:
pass raise NotImplementedError

View file

@ -66,6 +66,28 @@ class CoreLightningNode(Node):
fn = getattr(self.wallet.ln, method) fn = getattr(self.wallet.ln, method)
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs)) 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 @catch_rpc_errors
async def connect_peer(self, uri: str): async def connect_peer(self, uri: str):
# https://docs.corelightning.org/reference/lightning-connect # https://docs.corelightning.org/reference/lightning-connect
@ -202,48 +224,45 @@ class CoreLightningNode(Node):
else: else:
return NodePeerInfo(id=node["nodeid"]) 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 @catch_rpc_errors
async def get_channels(self) -> list[NodeChannel]: 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 = await self.ln_rpc("listnodes")
nodes_by_id = {n["nodeid"]: n for n in nodes["nodes"]} nodes_by_id = {n["nodeid"]: n for n in nodes["nodes"]}
return [ return [
NodeChannel( NodeChannel(
id=ch["channel_id"],
short_id=ch.get("short_channel_id"), short_id=ch.get("short_channel_id"),
point=ChannelPoint( point=ChannelPoint(
funding_txid=ch["funding_txid"], funding_txid=ch["funding_txid"],
output_index=ch["funding_output"], output_index=ch["funding_outnum"],
), ),
peer_id=ch["peer_id"], peer_id=ch["peer_id"],
balance=ChannelBalance( balance=ChannelBalance(
local_msat=ch["our_amount_msat"], local_msat=ch["spendable_msat"],
remote_msat=ch["amount_msat"] - ch["our_amount_msat"], remote_msat=ch["receivable_msat"],
total_msat=ch["amount_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"), name=nodes_by_id.get(ch["peer_id"], {}).get("alias"),
color=nodes_by_id.get(ch["peer_id"], {}).get("color"), color=nodes_by_id.get(ch["peer_id"], {}).get("color"),
state=( state=self._parse_state(ch["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"] for ch in channels["channels"]
] ]
@catch_rpc_errors @catch_rpc_errors

View file

@ -41,6 +41,14 @@ def _decode_bytes(data: str) -> str:
return base64.b64decode(data).hex() 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: def _parse_channel_point(raw: str) -> ChannelPoint:
funding_tx, output_index = raw.split(":") funding_tx, output_index = raw.split(":")
return ChannelPoint( return ChannelPoint(
@ -129,15 +137,12 @@ class LndRestNode(Node):
response = await self.request( response = await self.request(
"POST", "POST",
"/v1/channels", "/v1/channels",
data=json.dumps( json={
{ "node_pubkey": _encode_bytes(peer_id),
# 'node_pubkey': base64.b64encode(peer_id.encode()).decode(), "sat_per_vbyte": fee_rate,
"node_pubkey_string": peer_id, "local_funding_amount": local_amount,
"sat_per_vbyte": fee_rate, "push_sat": push_amount,
"local_funding_amount": local_amount, },
"push_sat": push_amount,
}
),
) )
return ChannelPoint( return ChannelPoint(
# WHY IS THIS REVERSED?! # WHY IS THIS REVERSED?!
@ -184,6 +189,66 @@ class LndRestNode(Node):
asyncio.create_task(self._close_channel(point, force)) # noqa: RUF006 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': <uint64>,
# 'min_htlc_msat': <uint64>,
# 'inbound_fee': <InboundFee>,
},
)
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]: async def get_channels(self) -> list[NodeChannel]:
normal, pending, closed = await asyncio.gather( normal, pending, closed = await asyncio.gather(
self.get("/v1/channels"), self.get("/v1/channels"),
@ -203,6 +268,7 @@ class LndRestNode(Node):
state=state, state=state,
name=info.alias, name=info.alias,
color=info.color, color=info.color,
id=channel.get("chan_id", "node is for pending channels"),
point=_parse_channel_point(channel["channel_point"]), point=_parse_channel_point(channel["channel_point"]),
balance=ChannelBalance( balance=ChannelBalance(
local_msat=msat(channel["local_balance"]), local_msat=msat(channel["local_balance"]),
@ -222,6 +288,7 @@ class LndRestNode(Node):
info = await self.get_peer_info(channel["remote_pubkey"]) info = await self.get_peer_info(channel["remote_pubkey"])
channels.append( channels.append(
NodeChannel( NodeChannel(
id=channel.get("chan_id", "node is for closing channels"),
peer_id=info.id, peer_id=info.id,
state=ChannelState.CLOSED, state=ChannelState.CLOSED,
name=info.alias, name=info.alias,
@ -239,6 +306,7 @@ class LndRestNode(Node):
info = await self.get_peer_info(channel["remote_pubkey"]) info = await self.get_peer_info(channel["remote_pubkey"])
channels.append( channels.append(
NodeChannel( NodeChannel(
id=channel["chan_id"],
short_id=channel["chan_id"], short_id=channel["chan_id"],
point=_parse_channel_point(channel["channel_point"]), point=_parse_channel_point(channel["channel_point"]),
peer_id=channel["remote_pubkey"], peer_id=channel["remote_pubkey"],

View file

@ -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 @pytest.mark.anyio
async def test_channel_management(node_client): async def test_channel_management(node_client):
async def get_channels(): async def get_channels():