mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2024-11-19 01:43:42 +01:00
[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:
parent
c536df0dae
commit
eb73daffe9
@ -6,6 +6,7 @@ from .views.api import api_router
|
|||||||
|
|
||||||
# this compat is needed for usermanager extension
|
# this compat is needed for usermanager extension
|
||||||
from .views.generic import generic_router, update_user_extension
|
from .views.generic import generic_router, update_user_extension
|
||||||
|
from .views.node_api import node_router, public_node_router, super_node_router
|
||||||
from .views.public_api import public_router
|
from .views.public_api import public_router
|
||||||
|
|
||||||
# backwards compatibility for extensions
|
# backwards compatibility for extensions
|
||||||
@ -17,4 +18,7 @@ def init_core_routers(app):
|
|||||||
app.include_router(generic_router)
|
app.include_router(generic_router)
|
||||||
app.include_router(public_router)
|
app.include_router(public_router)
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
app.include_router(node_router)
|
||||||
|
app.include_router(super_node_router)
|
||||||
|
app.include_router(public_node_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
|
178
lnbits/core/static/js/node.js
Normal file
178
lnbits/core/static/js/node.js
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
function shortenNodeId(nodeId) {
|
||||||
|
return nodeId
|
||||||
|
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
|
||||||
|
: '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.component('lnbits-node-ranks', {
|
||||||
|
props: ['ranks'],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
user: {},
|
||||||
|
stats: [
|
||||||
|
{label: 'Capacity', key: 'capacity'},
|
||||||
|
{label: 'Channels', key: 'channelcount'},
|
||||||
|
{label: 'Age', key: 'age'},
|
||||||
|
{label: 'Growth', key: 'growth'},
|
||||||
|
{label: 'Availability', key: 'availability'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<q-card class='q-my-none'>
|
||||||
|
<div class='column q-ma-md'>
|
||||||
|
<h5 class='text-subtitle1 text-bold q-my-none'>1ml Node Rank</h5>
|
||||||
|
<div v-for='stat in stats' class='q-gutter-sm'>
|
||||||
|
<div class='row items-center'>
|
||||||
|
<div class='col-9'>{{ stat.label }}</div>
|
||||||
|
<div class='col-3 text-subtitle1 text-bold'>
|
||||||
|
{{ (this.ranks && this.ranks[stat.key]) ?? '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.component('lnbits-channel-stats', {
|
||||||
|
props: ['stats'],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
states: [
|
||||||
|
{label: 'Active', value: 'active', color: 'green'},
|
||||||
|
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||||
|
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||||
|
{label: 'Closed', value: 'closed', color: 'red'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<q-card>
|
||||||
|
<div class='column q-ma-md'>
|
||||||
|
<h5 class='text-subtitle1 text-bold q-my-none'>Channels</h5>
|
||||||
|
<div v-for='state in states' class='q-gutter-sm'>
|
||||||
|
<div class='row'>
|
||||||
|
<div class='col-9'>
|
||||||
|
<q-badge rounded size='md' :color='state.color'>{{ state.label }}</q-badge>
|
||||||
|
</div>
|
||||||
|
<div class='col-3 text-subtitle1 text-bold'>
|
||||||
|
{{ (stats?.counts && stats.counts[state.value]) ?? "-" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
`,
|
||||||
|
created: function () {
|
||||||
|
if (window.user) {
|
||||||
|
this.user = LNbits.map.user(window.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.component('lnbits-stat', {
|
||||||
|
props: ['title', 'amount', 'msat', 'btc'],
|
||||||
|
computed: {
|
||||||
|
value: function () {
|
||||||
|
return (
|
||||||
|
this.amount ??
|
||||||
|
(this.btc
|
||||||
|
? LNbits.utils.formatSat(this.btc)
|
||||||
|
: LNbits.utils.formatMsat(this.msat))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class='text-overline text-primary'>
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class='text-h4 text-bold q-my-none'>{{ value }}</span>
|
||||||
|
<span class='text-h5' v-if='msat != undefined'>sats</span>
|
||||||
|
<span class='text-h5' v-if='btc != undefined'>BTC</span>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.component('lnbits-node-qrcode', {
|
||||||
|
props: ['info'],
|
||||||
|
mixins: [windowMixin],
|
||||||
|
template: `
|
||||||
|
<q-card class="my-card">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">
|
||||||
|
<div style="text-align: center">
|
||||||
|
<qrcode
|
||||||
|
:value="info.addresses[0]"
|
||||||
|
:options="{width: 250}"
|
||||||
|
v-if='info.addresses[0]'
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
<div v-else class='text-subtitle1'>
|
||||||
|
No addresses available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions vertical>
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
unelevated
|
||||||
|
size="md"
|
||||||
|
@click="copyText(info.id)"
|
||||||
|
>Public Key<q-tooltip> Click to copy </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.component('lnbits-node-info', {
|
||||||
|
props: ['info'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showDialog: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mixins: [windowMixin],
|
||||||
|
methods: {
|
||||||
|
shortenNodeId
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class='row items-baseline q-gutter-x-sm'>
|
||||||
|
<div class='text-h4 text-bold'>{{ this.info.alias }}</div>
|
||||||
|
<div class='row items-center q-gutter-sm'>
|
||||||
|
<div class='text-subtitle1 text-light'>{{ this.info.backend_name }}</div>
|
||||||
|
<q-badge
|
||||||
|
:style='\`background-color: #\${this.info.color}\`'
|
||||||
|
class='text-bold'
|
||||||
|
>
|
||||||
|
#{{ this.info.color }}
|
||||||
|
</q-badge>
|
||||||
|
<div class='text-bold'>{{ shortenNodeId(this.info.id) }}</div>
|
||||||
|
<q-btn
|
||||||
|
size='xs'
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon='content_paste'
|
||||||
|
@click='copyText(info.id)'
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
size='xs'
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon='qr_code'
|
||||||
|
@click='showDialog = true'
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<q-dialog v-model="showDialog">
|
||||||
|
<lnbits-node-qrcode :info='info'></lnbits-node-qrcode>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
@ -1,6 +1,6 @@
|
|||||||
// update cache version every time there is a new deployment
|
// update cache version every time there is a new deployment
|
||||||
// so the service worker reinitializes the cache
|
// so the service worker reinitializes the cache
|
||||||
const CACHE_VERSION = 54
|
const CACHE_VERSION = 55
|
||||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||||
|
|
||||||
const getApiKey = request => {
|
const getApiKey = request => {
|
||||||
|
@ -26,6 +26,29 @@
|
|||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div v-if="'{{LNBITS_NODE_UI_AVAILABLE}}' === 'True'">
|
||||||
|
<div>Node Management</div>
|
||||||
|
<q-toggle
|
||||||
|
label="Node UI"
|
||||||
|
v-model="formData.lnbits_node_ui"
|
||||||
|
></q-toggle>
|
||||||
|
<q-toggle
|
||||||
|
v-if="formData.lnbits_node_ui"
|
||||||
|
label="Public node UI"
|
||||||
|
v-model="formData.lnbits_public_node_ui"
|
||||||
|
></q-toggle>
|
||||||
|
<q-toggle
|
||||||
|
v-if="formData.lnbits_node_ui"
|
||||||
|
label="Transactions Tab (Disable on large CLN nodes)"
|
||||||
|
v-model="formData.lnbits_node_ui_transactions"
|
||||||
|
></q-toggle>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
<p v-else>Node Management not supported by active funding source</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
312
lnbits/core/templates/node/_tab_channels.html
Normal file
312
lnbits/core/templates/node/_tab_channels.html
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
<q-tab-panel name="channels">
|
||||||
|
<q-dialog v-model="connectPeerDialog.show">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
type="text"
|
||||||
|
filled
|
||||||
|
v-model="connectPeerDialog.data.uri"
|
||||||
|
label="Node URI"
|
||||||
|
hint="pubkey@host:port"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
:label="$t('connect')"
|
||||||
|
color="primary"
|
||||||
|
@click="connectPeer"
|
||||||
|
></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-form class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
type="text"
|
||||||
|
filled
|
||||||
|
v-model="openChannelDialog.data.peer_id"
|
||||||
|
label="Peer ID"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
filled
|
||||||
|
v-model.number="openChannelDialog.data.funding_amount"
|
||||||
|
label="Funding Amount"
|
||||||
|
></q-input>
|
||||||
|
<q-expansion-item icon="warning" label="Advanced">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="column q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
filled
|
||||||
|
v-model.number="openChannelDialog.data.push_amount"
|
||||||
|
label="Push Amount"
|
||||||
|
hint="This gifts sats to the other side!"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
filled
|
||||||
|
v-model.number="openChannelDialog.data.fee_rate"
|
||||||
|
label="Fee Rate"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
:label="$t('open')"
|
||||||
|
color="primary"
|
||||||
|
@click="openChannel"
|
||||||
|
></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="closeChannelDialog.show">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form class="q-gutter-md">
|
||||||
|
<div>
|
||||||
|
<q-checkbox v-model="closeChannelDialog.data.force" label="Force" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
:label="$t('close')"
|
||||||
|
color="primary"
|
||||||
|
@click="closeChannel"
|
||||||
|
></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-card-section class="q-pa-none">
|
||||||
|
<div class="row q-col-gutter-lg">
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<q-card class="full-height">
|
||||||
|
<q-card-section class="q-gutter-y-sm">
|
||||||
|
<div class="row items-center q-mt-none q-gutter-x-sm no-wrap">
|
||||||
|
<div class="col-grow text-h6 q-my-none col-grow">Channels</div>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
v-model="channels.filter"
|
||||||
|
placeholder="Search..."
|
||||||
|
class="col-auto"
|
||||||
|
></q-input>
|
||||||
|
<q-select
|
||||||
|
dense
|
||||||
|
size="sm"
|
||||||
|
style="min-width: 200px"
|
||||||
|
filled
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
v-model="stateFilters"
|
||||||
|
:options="this.states"
|
||||||
|
class="col-auto"
|
||||||
|
></q-select>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
size="md"
|
||||||
|
class="col-auto"
|
||||||
|
@click="showOpenChannelDialog()"
|
||||||
|
>
|
||||||
|
Open channel
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
{% raw %}
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle1 col-grow">Total</div>
|
||||||
|
<lnbits-channel-balance
|
||||||
|
:balance="this.totalBalance"
|
||||||
|
></lnbits-channel-balance>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="this.filteredChannels"
|
||||||
|
:filter="channels.filter"
|
||||||
|
no-data-label="No channels opened"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<div class="q-pb-sm">
|
||||||
|
<div class="row items-center q-gutter-sm">
|
||||||
|
<div class="text-subtitle1 col-grow">
|
||||||
|
{{props.row.name}}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption" v-if="props.row.short_id">
|
||||||
|
{{ props.row.short_id }}
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="content_paste"
|
||||||
|
@click="copyText(props.row.short_id)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<q-badge
|
||||||
|
rounded
|
||||||
|
:color="states.find(s => s.value == props.row.state)?.color"
|
||||||
|
>
|
||||||
|
{{ states.find(s => s.value == props.row.state)?.label
|
||||||
|
}}
|
||||||
|
</q-badge>
|
||||||
|
<q-btn
|
||||||
|
:disable='props.row.state !== "active"'
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="md"
|
||||||
|
@click="showCloseChannelDialog(props.row)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<lnbits-channel-balance
|
||||||
|
:balance="props.row.balance"
|
||||||
|
:color="props.row.color"
|
||||||
|
></lnbits-channel-balance>
|
||||||
|
</div>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<q-card class="full-height">
|
||||||
|
<q-card-section class="column q-gutter-y-sm">
|
||||||
|
{% raw %}
|
||||||
|
<div
|
||||||
|
class="row items-center q-mt-none justify-between q-gutter-x-md no-wrap"
|
||||||
|
>
|
||||||
|
<div class="col-grow text-h6 q-my-none">Peers</div>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
v-model="peers.filter"
|
||||||
|
placeholder="Search..."
|
||||||
|
class="col-auto"
|
||||||
|
></q-input>
|
||||||
|
<q-btn
|
||||||
|
class="col-auto"
|
||||||
|
color="primary"
|
||||||
|
@click="connectPeerDialog.show = true"
|
||||||
|
>
|
||||||
|
Connect Peer
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="peers.data"
|
||||||
|
:filter="peers.filter"
|
||||||
|
no-data-label="No transactions made yet"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props" style="height: 0"> </q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<div class="row no-wrap items-center q-gutter-sm">
|
||||||
|
<div class="q-my-sm col-grow">
|
||||||
|
<div class="text-subtitle1 text-bold">
|
||||||
|
{{ props.row.alias }}
|
||||||
|
</div>
|
||||||
|
<div class="row items-center q-gutter-sm">
|
||||||
|
<q-badge
|
||||||
|
:style="`background-color: #${props.row.color}`"
|
||||||
|
class="text-bold"
|
||||||
|
>
|
||||||
|
#{{ props.row.color }}
|
||||||
|
</q-badge>
|
||||||
|
<div class="text-bold">
|
||||||
|
{{ shortenNodeId(props.row.id) }}
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="content_paste"
|
||||||
|
@click="copyText(props.row.id)"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="qr_code"
|
||||||
|
@click="showNodeInfoDialog(props.row)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
@click="showOpenChannelDialog(props.row.id)"
|
||||||
|
>
|
||||||
|
Open channel
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="md"
|
||||||
|
@click="disconnectPeer(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-tab-panel>
|
70
lnbits/core/templates/node/_tab_dashboard.html
Normal file
70
lnbits/core/templates/node/_tab_dashboard.html
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<q-tab-panel name="dashboard">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
{% raw %}
|
||||||
|
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||||
|
<div class="row q-col-gutter-lg q-mt-sm">
|
||||||
|
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||||
|
<div class="row q-col-gutter-md q-pb-lg">
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Total Capacity"
|
||||||
|
:msat="this.channel_stats.total_capacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat title="Balance" :msat="this.info.balance_msat" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Fees collected"
|
||||||
|
:msat="this.info.fees?.total_msat"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Onchain Balance"
|
||||||
|
:btc="this.info.onchain_balance_sat / 100000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Onchain Confirmed"
|
||||||
|
:btc="this.info.onchain_confirmed_sat / 100000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('avg_channel_size')"
|
||||||
|
:msat="this.channel_stats.avg_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('biggest_channel_size')"
|
||||||
|
:msat="this.channel_stats.biggest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('smallest_channel_size')"
|
||||||
|
:msat="this.channel_stats.smallest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||||
|
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||||
|
<lnbits-channel-stats
|
||||||
|
:stats="this.channel_stats"
|
||||||
|
></lnbits-channel-stats>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-tab-panel>
|
316
lnbits/core/templates/node/_tab_transactions.html
Normal file
316
lnbits/core/templates/node/_tab_transactions.html
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
<q-tab-panel name="transactions">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-dialog v-model="transactionDetailsDialog.show">
|
||||||
|
<q-card class="my-card">
|
||||||
|
<q-card-section>
|
||||||
|
{% raw %}
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<div
|
||||||
|
v-if="transactionDetailsDialog.data.isIn && transactionDetailsDialog.data.pending"
|
||||||
|
>
|
||||||
|
<q-icon
|
||||||
|
size="18px"
|
||||||
|
:name="'call_received'"
|
||||||
|
:color="'green'"
|
||||||
|
></q-icon>
|
||||||
|
<span v-text="$t('payment_received')"></span>
|
||||||
|
</div>
|
||||||
|
<div class="row q-my-md">
|
||||||
|
<div class="col-3"><b v-text="$t('payment_hash')"></b>:</div>
|
||||||
|
<div class="col-9 text-wrap mono">
|
||||||
|
{{ transactionDetailsDialog.data.payment_hash }}
|
||||||
|
<q-icon
|
||||||
|
name="content_copy"
|
||||||
|
@click="copyText(transactionDetailsDialog.data.payment_hash)"
|
||||||
|
size="1em"
|
||||||
|
color="grey"
|
||||||
|
class="q-mb-xs cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
v-if="transactionDetailsDialog.data.preimage && !transactionDetailsDialog.data.pending"
|
||||||
|
>
|
||||||
|
<div class="col-3"><b v-text="$t('payment_proof')"></b>:</div>
|
||||||
|
<div class="col-9 text-wrap mono">
|
||||||
|
{{ transactionDetailsDialog.data.preimage }}
|
||||||
|
<q-icon
|
||||||
|
name="content_copy"
|
||||||
|
@click="copyText(transactionDetailsDialog.data.preimage)"
|
||||||
|
size="1em"
|
||||||
|
color="grey"
|
||||||
|
class="q-mb-xs cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="transactionDetailsDialog.data.bolt11"
|
||||||
|
class="text-center q-mb-lg"
|
||||||
|
>
|
||||||
|
<a :href="'lightning:' + transactionDetailsDialog.data.bolt11">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
|
<qrcode
|
||||||
|
:value="'lightning:' + transactionDetailsDialog.data.bolt11.toUpperCase()"
|
||||||
|
:options="{width: 340}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(transactionDetailsDialog.data.bolt11)"
|
||||||
|
:label="$t('copy_invoice')"
|
||||||
|
class="q-mt-sm"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endraw %}
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md q-pb-lg"></div>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-lg">
|
||||||
|
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-sm">
|
||||||
|
<div class="col text-h6 q-my-none">Payments</div>
|
||||||
|
<q-input
|
||||||
|
v-if="payments.length > 10"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
v-model="paymentsTable.filter"
|
||||||
|
debounce="300"
|
||||||
|
placeholder="Search by tag, memo, amount"
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="paymentsTable.data"
|
||||||
|
:columns="paymentsTable.columns"
|
||||||
|
:pagination.sync="paymentsTable.pagination"
|
||||||
|
row-key="payment_hash"
|
||||||
|
no-data-label="No transactions made yet"
|
||||||
|
:filter="paymentsTable.filter"
|
||||||
|
@request="getPayments"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:body-cell-pending="props">
|
||||||
|
<q-td auto-width class="text-center">
|
||||||
|
<q-icon
|
||||||
|
v-if="!props.row.pending"
|
||||||
|
size="xs"
|
||||||
|
name="call_made"
|
||||||
|
color="green"
|
||||||
|
@click="showTransactionDetailsDialog(props.row)"
|
||||||
|
></q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else
|
||||||
|
size="xs"
|
||||||
|
name="settings_ethernet"
|
||||||
|
color="grey"
|
||||||
|
@click="showTransactionDetailsDialog(props.row)"
|
||||||
|
>
|
||||||
|
<q-tooltip>Pending</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-dialog v-model="props.row.expand" :props="props">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<div v-if="props.row.isIn && props.row.pending">
|
||||||
|
<q-icon
|
||||||
|
name="settings_ethernet"
|
||||||
|
color="grey"
|
||||||
|
></q-icon>
|
||||||
|
<span v-text="$t('invoice_waiting')"></span>
|
||||||
|
<lnbits-payment-details
|
||||||
|
:payment="props.row"
|
||||||
|
></lnbits-payment-details>
|
||||||
|
<div
|
||||||
|
v-if="props.row.bolt11"
|
||||||
|
class="text-center q-mb-lg"
|
||||||
|
>
|
||||||
|
<a :href="'lightning:' + props.row.bolt11">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
|
<qrcode
|
||||||
|
:value="'lightning:' + props.row.bolt11.toUpperCase()"
|
||||||
|
:options="{width: 340}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(props.row.bolt11)"
|
||||||
|
:label="$t('copy_invoice')"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
|
:label="$t('close')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="props.row.isPaid && props.row.isIn">
|
||||||
|
<q-icon
|
||||||
|
size="18px"
|
||||||
|
:name="'call_received'"
|
||||||
|
:color="'green'"
|
||||||
|
></q-icon>
|
||||||
|
<span v-text="$t('payment_received')"></span>
|
||||||
|
<lnbits-payment-details
|
||||||
|
:payment="props.row"
|
||||||
|
></lnbits-payment-details>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="props.row.isPaid && props.row.isOut">
|
||||||
|
<q-icon
|
||||||
|
size="18px"
|
||||||
|
:name="'call_made'"
|
||||||
|
:color="'pink'"
|
||||||
|
></q-icon>
|
||||||
|
<span v-text="$t('payment_sent')"></span>
|
||||||
|
<lnbits-payment-details
|
||||||
|
:payment="props.row"
|
||||||
|
></lnbits-payment-details>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="props.row.isOut && props.row.pending">
|
||||||
|
<q-icon
|
||||||
|
name="settings_ethernet"
|
||||||
|
color="grey"
|
||||||
|
></q-icon>
|
||||||
|
<span v-text="$t('outgoing_payment_pending')"></span>
|
||||||
|
<lnbits-payment-details
|
||||||
|
:payment="props.row"
|
||||||
|
></lnbits-payment-details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body-cell-date="props">
|
||||||
|
<q-td auto-width key="date" :props="props">
|
||||||
|
<lnbits-date :ts="props.row.time"></lnbits-date>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body-cell-destination="props">
|
||||||
|
<q-td auto-width key="destination">
|
||||||
|
<div class="row items-center justify-between no-wrap">
|
||||||
|
<q-badge
|
||||||
|
:style="`background-color: #${props.row.destination?.color}`"
|
||||||
|
class="text-bold"
|
||||||
|
>
|
||||||
|
{{ props.row.destination?.alias }}
|
||||||
|
</q-badge>
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="content_paste"
|
||||||
|
@click="copyText(info.id)"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
size="xs"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
icon="qr_code"
|
||||||
|
@click="showNodeInfoDialog(props.row.destination)"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-sm">
|
||||||
|
<div class="col text-h6 q-my-none">Invoices</div>
|
||||||
|
<q-input
|
||||||
|
v-if="payments.length > 10"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
v-model="paymentsTable.filter"
|
||||||
|
debounce="300"
|
||||||
|
placeholder="Search by tag, memo, amount"
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="invoiceTable.data"
|
||||||
|
:columns="invoiceTable.columns"
|
||||||
|
:pagination.sync="invoiceTable.pagination"
|
||||||
|
no-data-label="No transactions made yet"
|
||||||
|
:filter="invoiceTable.filter"
|
||||||
|
@request="getInvoices"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:body-cell-pending="props">
|
||||||
|
<q-td auto-width class="text-center">
|
||||||
|
<q-icon
|
||||||
|
v-if="!props.row.pending"
|
||||||
|
size="xs"
|
||||||
|
name="call_received"
|
||||||
|
color="green"
|
||||||
|
@click="showTransactionDetailsDialog(props.row)"
|
||||||
|
></q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-else
|
||||||
|
size="xs"
|
||||||
|
name="settings_ethernet"
|
||||||
|
color="grey"
|
||||||
|
@click="showTransactionDetailsDialog(props.row)"
|
||||||
|
>
|
||||||
|
<q-tooltip>Pending</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body-cell-paid_at="props">
|
||||||
|
<q-td auto-width :props="props">
|
||||||
|
<lnbits-date
|
||||||
|
v-if="props.row.paid_at"
|
||||||
|
:ts="props.row.paid_at"
|
||||||
|
></lnbits-date>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body-cell-expiry="props">
|
||||||
|
<q-td auto-width :props="props">
|
||||||
|
<lnbits-date
|
||||||
|
v-if="props.row.expiry"
|
||||||
|
:ts="props.row.expiry"
|
||||||
|
></lnbits-date>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-tab-panel>
|
500
lnbits/core/templates/node/index.html
Normal file
500
lnbits/core/templates/node/index.html
Normal file
@ -0,0 +1,500 @@
|
|||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
|
||||||
|
<q-dialog v-model="nodeInfoDialog.show">
|
||||||
|
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<div class="q-pa-md">
|
||||||
|
<div class="q-gutter-y-md">
|
||||||
|
<q-tabs v-model="tab" active-color="primary" align="justify">
|
||||||
|
<q-tab
|
||||||
|
name="dashboard"
|
||||||
|
:label="$t('dashboard')"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
|
<q-tab
|
||||||
|
name="channels"
|
||||||
|
:label="$t('channels')"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
|
<q-tab
|
||||||
|
name="transactions"
|
||||||
|
:label="$t('transactions')"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
></q-tab>
|
||||||
|
</q-tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-form name="settings_form" id="settings_form">
|
||||||
|
<q-tab-panels v-model="tab" animated>
|
||||||
|
{% include "node/_tab_dashboard.html" %} {% include
|
||||||
|
"node/_tab_channels.html" %} {% include "node/_tab_transactions.html"
|
||||||
|
%}
|
||||||
|
</q-tab-panels>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script src="/core/static/js/node.js"></script>
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
Vue.use(VueQrcodeReader)
|
||||||
|
|
||||||
|
{% raw %}
|
||||||
|
Vue.component('lnbits-stat',
|
||||||
|
{
|
||||||
|
props: ['title', 'amount', 'msat', 'btc'],
|
||||||
|
computed: {
|
||||||
|
value: function () {
|
||||||
|
return this.amount ?? (this.btc ? LNbits.utils.formatSat(this.btc) : LNbits.utils.formatMsat(this.msat))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class='text-overline text-primary'>
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class='text-h4 text-bold q-my-none'>{{ value }}</span>
|
||||||
|
<span class='text-h5' v-if='msat != undefined'>sats</span>
|
||||||
|
<span class='text-h5' v-if='btc != undefined'>BTC</span>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Vue.component('lnbits-channel-balance',
|
||||||
|
{
|
||||||
|
props: ['balance', 'color'],
|
||||||
|
methods: {
|
||||||
|
formatMsat: function (msat) {
|
||||||
|
return LNbits.utils.formatMsat(msat)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<div class="row items-center justify-between">
|
||||||
|
<span class="text-weight-thin">
|
||||||
|
Local: {{ formatMsat(balance.local_msat) }}
|
||||||
|
sats
|
||||||
|
</span>
|
||||||
|
<span class="text-weight-thin">
|
||||||
|
Remote: {{ formatMsat(balance.remote_msat) }}
|
||||||
|
sats
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-linear-progress
|
||||||
|
rounded
|
||||||
|
size="25px"
|
||||||
|
:value="balance.local_msat / balance.total_msat"
|
||||||
|
:color="color"
|
||||||
|
:style="\`color: #\${this.color}\`"
|
||||||
|
>
|
||||||
|
<div class="absolute-full flex flex-center">
|
||||||
|
<q-badge
|
||||||
|
color="white"
|
||||||
|
text-color="accent"
|
||||||
|
:label="formatMsat(balance.total_msat) + ' sats'"
|
||||||
|
>
|
||||||
|
{{ balance.alias }}
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</q-linear-progress>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Vue.component('lnbits-date',
|
||||||
|
{
|
||||||
|
props: ['ts'],
|
||||||
|
computed: {
|
||||||
|
date: function () {
|
||||||
|
return Quasar.utils.date.formatDate(
|
||||||
|
new Date(this.ts * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
dateFrom: function () {
|
||||||
|
return moment(this.date).fromNow()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<q-tooltip>{{ this.date }}</q-tooltip>
|
||||||
|
{{ this.dateFrom }}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Vue.component('lnbits-date',
|
||||||
|
{
|
||||||
|
props: ['ts'],
|
||||||
|
computed: {
|
||||||
|
date: function () {
|
||||||
|
return Quasar.utils.date.formatDate(
|
||||||
|
new Date(this.ts * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
dateFrom: function () {
|
||||||
|
return moment(this.date).fromNow()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<q-tooltip>{{ this.date }}</q-tooltip>
|
||||||
|
{{ this.dateFrom }}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
config: {
|
||||||
|
globalProperties: {
|
||||||
|
LNbits,
|
||||||
|
msg: 'hello'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
isSuperUser: false,
|
||||||
|
wallet: {},
|
||||||
|
tab: 'dashboard',
|
||||||
|
payments: 1000,
|
||||||
|
info: {},
|
||||||
|
channel_stats: {},
|
||||||
|
|
||||||
|
channels: {
|
||||||
|
data: [],
|
||||||
|
filter: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
activeBalance: {},
|
||||||
|
ranks: {},
|
||||||
|
|
||||||
|
peers: {
|
||||||
|
data: [],
|
||||||
|
filter: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
connectPeerDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
openChannelDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeChannelDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
nodeInfoDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
transactionDetailsDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
states: [
|
||||||
|
{label: 'Active', value: 'active', color: 'green'},
|
||||||
|
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||||
|
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||||
|
{label: 'Closed', value: 'closed', color: 'red'}
|
||||||
|
],
|
||||||
|
|
||||||
|
stateFilters: [
|
||||||
|
{label: 'Active', value: 'active'},
|
||||||
|
{label: 'Pending', value: 'pending'}
|
||||||
|
],
|
||||||
|
|
||||||
|
paymentsTable: {
|
||||||
|
data: [],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'pending',
|
||||||
|
label: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'memo',
|
||||||
|
align: 'left',
|
||||||
|
label: this.$t('memo'),
|
||||||
|
field: 'memo'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'date',
|
||||||
|
align: 'left',
|
||||||
|
label: this.$t('date'),
|
||||||
|
field: 'date',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sat',
|
||||||
|
align: 'right',
|
||||||
|
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||||
|
field: row => this.formatMsat(row.amount),
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fee',
|
||||||
|
align: 'right',
|
||||||
|
label: this.$t('fee') + ' (m' + LNBITS_DENOMINATION + ')',
|
||||||
|
field: 'fee'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'destination',
|
||||||
|
align: 'right',
|
||||||
|
label: 'Destination',
|
||||||
|
field: 'destination'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10,
|
||||||
|
page: 1,
|
||||||
|
rowsNumber: 10
|
||||||
|
},
|
||||||
|
filter: null
|
||||||
|
},
|
||||||
|
invoiceTable: {
|
||||||
|
data: [],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'pending',
|
||||||
|
label: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'memo',
|
||||||
|
align: 'left',
|
||||||
|
label: this.$t('memo'),
|
||||||
|
field: 'memo'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'paid_at',
|
||||||
|
field: 'paid_at',
|
||||||
|
align: 'right',
|
||||||
|
label: 'Paid at',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'expiry',
|
||||||
|
label: this.$t('expiry'),
|
||||||
|
field: 'expiry',
|
||||||
|
align: 'right',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'amount',
|
||||||
|
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||||
|
field: row => this.formatMsat(row.amount),
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10,
|
||||||
|
page: 1,
|
||||||
|
rowsNumber: 10
|
||||||
|
},
|
||||||
|
filter: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
this.getInfo()
|
||||||
|
this.get1MLStats()
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
tab: function (val) {
|
||||||
|
if (val === 'transactions' && !this.paymentsTable.data.length) {
|
||||||
|
this.getPayments()
|
||||||
|
this.getInvoices()
|
||||||
|
} else if (val === 'channels' && !this.channels.data.length) {
|
||||||
|
this.getChannels()
|
||||||
|
this.getPeers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
checkChanges() {
|
||||||
|
return !_.isEqual(this.settings, this.formData)
|
||||||
|
},
|
||||||
|
filteredChannels: function () {
|
||||||
|
return this.stateFilters ? this.channels.data.filter(channel => {
|
||||||
|
return this.stateFilters.find(({value}) => value == channel.state)
|
||||||
|
}) : this.channels.data
|
||||||
|
},
|
||||||
|
totalBalance: function () {
|
||||||
|
return this.filteredChannels.reduce(
|
||||||
|
(balance, channel) => {
|
||||||
|
balance.local_msat += channel.balance.local_msat
|
||||||
|
balance.remote_msat += channel.balance.remote_msat
|
||||||
|
balance.total_msat += channel.balance.total_msat
|
||||||
|
return balance
|
||||||
|
},
|
||||||
|
{local_msat: 0, remote_msat: 0, total_msat: 0}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatMsat: function (msat) {
|
||||||
|
return LNbits.utils.formatMsat(msat)
|
||||||
|
},
|
||||||
|
api: function (method, url, options) {
|
||||||
|
const params = new URLSearchParams(options?.query)
|
||||||
|
params.set('usr', this.g.user.id)
|
||||||
|
return LNbits.api.request(method, `/node/api/v1${url}?${params}`, {}, options?.data)
|
||||||
|
.catch(error => {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getChannels: function () {
|
||||||
|
return this.api('GET', '/channels')
|
||||||
|
.then(response => {
|
||||||
|
this.channels.data = response.data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getInfo: function () {
|
||||||
|
return this.api('GET', '/info')
|
||||||
|
.then(response => {
|
||||||
|
this.info = response.data
|
||||||
|
this.channel_stats = response.data.channel_stats
|
||||||
|
})
|
||||||
|
},
|
||||||
|
get1MLStats: function () {
|
||||||
|
return this.api('GET', '/rank')
|
||||||
|
.then(response => {
|
||||||
|
this.ranks = response.data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getPayments: function (props) {
|
||||||
|
if (props) {
|
||||||
|
this.paymentsTable.pagination = props.pagination
|
||||||
|
}
|
||||||
|
let pagination = this.paymentsTable.pagination
|
||||||
|
const query = {
|
||||||
|
limit: pagination.rowsPerPage,
|
||||||
|
offset: ((pagination.page - 1) * pagination.rowsPerPage) ?? 0,
|
||||||
|
}
|
||||||
|
return this.api('GET', '/payments', {query})
|
||||||
|
.then(response => {
|
||||||
|
this.paymentsTable.data = response.data.data
|
||||||
|
this.paymentsTable.pagination.rowsNumber = response.data.total
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getInvoices: function (props) {
|
||||||
|
if (props) {
|
||||||
|
this.invoiceTable.pagination = props.pagination
|
||||||
|
}
|
||||||
|
let pagination = this.invoiceTable.pagination
|
||||||
|
const query = {
|
||||||
|
limit: pagination.rowsPerPage,
|
||||||
|
offset: ((pagination.page - 1) * pagination.rowsPerPage) ?? 0,
|
||||||
|
}
|
||||||
|
return this.api('GET', '/invoices', {query})
|
||||||
|
.then(response => {
|
||||||
|
this.invoiceTable.data = response.data.data
|
||||||
|
this.invoiceTable.pagination.rowsNumber = response.data.total
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getPeers: function () {
|
||||||
|
return this.api('GET', '/peers')
|
||||||
|
.then(response => {
|
||||||
|
this.peers.data = response.data
|
||||||
|
console.log('peers', this.peers)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
connectPeer: function () {
|
||||||
|
console.log('peer', this.connectPeerDialog)
|
||||||
|
this.api('POST', '/peers', {data: this.connectPeerDialog.data})
|
||||||
|
.then(() => {
|
||||||
|
this.connectPeerDialog.show = false
|
||||||
|
this.getPeers()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
disconnectPeer: function(id) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
'Do you really wanna disconnect this peer?'
|
||||||
|
)
|
||||||
|
.onOk(() => {
|
||||||
|
this.api('DELETE', `/peers/${id}`)
|
||||||
|
.then(response => {
|
||||||
|
this.$q.notify({
|
||||||
|
message: 'Disconnected',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
this.needsRestart = true
|
||||||
|
this.getPeers()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openChannel: function () {
|
||||||
|
this.api('POST', '/channels', {data: this.openChannelDialog.data})
|
||||||
|
.then(response => {
|
||||||
|
this.openChannelDialog.show = false
|
||||||
|
this.getChannels()
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.log(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
showCloseChannelDialog: function (channel) {
|
||||||
|
this.closeChannelDialog.show = true
|
||||||
|
this.closeChannelDialog.data = {force: false, short_id: channel.short_id, ...channel.point}
|
||||||
|
},
|
||||||
|
closeChannel: function () {
|
||||||
|
this.api('DELETE', '/channels', {query: this.closeChannelDialog.data})
|
||||||
|
.then(response => {
|
||||||
|
this.closeChannelDialog.show = false
|
||||||
|
this.getChannels()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
showOpenChannelDialog: function (peer_id) {
|
||||||
|
this.openChannelDialog.show = true
|
||||||
|
this.openChannelDialog.data = {peer_id, funding_amount: 0}
|
||||||
|
},
|
||||||
|
showNodeInfoDialog: function (node) {
|
||||||
|
this.nodeInfoDialog.show = true
|
||||||
|
this.nodeInfoDialog.data = node
|
||||||
|
},
|
||||||
|
showTransactionDetailsDialog: function (details) {
|
||||||
|
this.transactionDetailsDialog.show = true
|
||||||
|
this.transactionDetailsDialog.data = details
|
||||||
|
console.log('details', details)
|
||||||
|
},
|
||||||
|
exportCSV: function () {
|
||||||
|
|
||||||
|
},
|
||||||
|
shortenNodeId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
143
lnbits/core/templates/node/public.html
Normal file
143
lnbits/core/templates/node/public.html
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
{% extends "public.html" %} {% from "macros.jinja" import window_vars with
|
||||||
|
context %} {% block page %}
|
||||||
|
|
||||||
|
<div class="q-ma-lg-xl q-mx-auto q-ma-xl" style="max-width: 1048px">
|
||||||
|
<lnbits-node-info :info="this.info"></lnbits-node-info>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-lg q-mt-sm">
|
||||||
|
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||||
|
<div class="row q-col-gutter-md q-pb-lg">
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
title="Total Capacity"
|
||||||
|
:msat="this.channel_stats.total_capacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat title="Peers" :amount="this.info.num_peers" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('avg_channel_size')"
|
||||||
|
:msat="this.channel_stats.avg_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('biggest_channel_size')"
|
||||||
|
:msat="this.channel_stats.biggest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('smallest_channel_size')"
|
||||||
|
:msat="this.channel_stats.smallest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 q-gutter-y-md">
|
||||||
|
<lnbits-stat
|
||||||
|
:title="$t('smallest_channel_size')"
|
||||||
|
:msat="this.channel_stats.smallest_size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column col-12 col-md-4 q-gutter-y-md">
|
||||||
|
<lnbits-node-ranks :ranks="this.ranks"></lnbits-node-ranks>
|
||||||
|
<lnbits-channel-stats :stats="this.channel_stats"></lnbits-channel-stats>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script src="/core/static/js/node.js"></script>
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
Vue.use(VueQrcodeReader)
|
||||||
|
|
||||||
|
{% raw %}
|
||||||
|
|
||||||
|
{% endraw %}
|
||||||
|
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
config: {
|
||||||
|
globalProperties: {
|
||||||
|
LNbits,
|
||||||
|
msg: 'hello'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
isSuperUser: false,
|
||||||
|
wallet: {},
|
||||||
|
tab: 'dashboard',
|
||||||
|
payments: 1000,
|
||||||
|
info: {},
|
||||||
|
channel_stats: {},
|
||||||
|
channels: [],
|
||||||
|
activeBalance: {},
|
||||||
|
ranks: {},
|
||||||
|
|
||||||
|
peers: [],
|
||||||
|
|
||||||
|
connectPeerDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
openChannelDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeChannelDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
nodeInfoDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
|
||||||
|
states: [
|
||||||
|
{label: 'Active', value: 'active', color: 'green'},
|
||||||
|
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||||
|
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||||
|
{label: 'Closed', value: 'closed', color: 'red'}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
this.getInfo()
|
||||||
|
this.get1MLStats()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatMsat: function (msat) {
|
||||||
|
return LNbits.utils.formatMsat(msat)
|
||||||
|
},
|
||||||
|
api: function (method, url, data) {
|
||||||
|
return LNbits.api.request(method, '/node/public/api/v1' + url, {}, data)
|
||||||
|
},
|
||||||
|
getInfo: function () {
|
||||||
|
this.api('GET', '/info', {})
|
||||||
|
.then(response => {
|
||||||
|
this.info = response.data
|
||||||
|
this.channel_stats = response.data.channel_stats
|
||||||
|
})
|
||||||
|
},
|
||||||
|
get1MLStats: function () {
|
||||||
|
this.api('GET', '/rank', {})
|
||||||
|
.then(response => {
|
||||||
|
this.ranks = response.data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -48,7 +48,7 @@ async def api_auditor():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/admin/api/v1/settings/")
|
@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings])
|
||||||
async def api_get_settings(
|
async def api_get_settings(
|
||||||
user: User = Depends(check_admin),
|
user: User = Depends(check_admin),
|
||||||
) -> Optional[AdminSettings]:
|
) -> Optional[AdminSettings]:
|
||||||
|
@ -425,6 +425,44 @@ async def manifest(request: Request, usr: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@generic_router.get("/node", response_class=HTMLResponse)
|
||||||
|
async def node(request: Request, user: User = Depends(check_admin)):
|
||||||
|
if not settings.lnbits_node_ui:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||||
|
|
||||||
|
WALLET = get_wallet_class()
|
||||||
|
_, balance = await WALLET.status()
|
||||||
|
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
"node/index.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user.dict(),
|
||||||
|
"settings": settings.dict(),
|
||||||
|
"balance": balance,
|
||||||
|
"wallets": user.wallets[0].dict(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@generic_router.get("/node/public", response_class=HTMLResponse)
|
||||||
|
async def node_public(request: Request):
|
||||||
|
if not settings.lnbits_public_node_ui:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||||
|
|
||||||
|
WALLET = get_wallet_class()
|
||||||
|
_, balance = await WALLET.status()
|
||||||
|
|
||||||
|
return template_renderer().TemplateResponse(
|
||||||
|
"node/public.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"settings": settings.dict(),
|
||||||
|
"balance": balance,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@generic_router.get("/admin", response_class=HTMLResponse)
|
@generic_router.get("/admin", response_class=HTMLResponse)
|
||||||
async def index(request: Request, user: User = Depends(check_admin)):
|
async def index(request: Request, user: User = Depends(check_admin)):
|
||||||
if not settings.lnbits_admin_ui:
|
if not settings.lnbits_admin_ui:
|
||||||
|
188
lnbits/core/views/node_api.py
Normal file
188
lnbits/core/views/node_api.py
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
from http import HTTPStatus
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||||
|
from lnbits.nodes import get_node_class
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from ...db import Filters, Page
|
||||||
|
from ...nodes.base import (
|
||||||
|
ChannelPoint,
|
||||||
|
Node,
|
||||||
|
NodeChannel,
|
||||||
|
NodeInfoResponse,
|
||||||
|
NodeInvoice,
|
||||||
|
NodeInvoiceFilters,
|
||||||
|
NodePayment,
|
||||||
|
NodePaymentsFilters,
|
||||||
|
NodePeerInfo,
|
||||||
|
PublicNodeInfo,
|
||||||
|
)
|
||||||
|
from ...utils.cache import cache
|
||||||
|
|
||||||
|
|
||||||
|
def require_node():
|
||||||
|
NODE = get_node_class()
|
||||||
|
if not NODE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_IMPLEMENTED,
|
||||||
|
detail="Active backend does not implement Node API",
|
||||||
|
)
|
||||||
|
if not settings.lnbits_node_ui:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
detail="Not enabled",
|
||||||
|
)
|
||||||
|
return NODE
|
||||||
|
|
||||||
|
|
||||||
|
def check_public():
|
||||||
|
if not (settings.lnbits_node_ui and settings.lnbits_public_node_ui):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
detail="Not enabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
node_router = APIRouter(prefix="/node/api/v1", dependencies=[Depends(check_admin)])
|
||||||
|
super_node_router = APIRouter(
|
||||||
|
prefix="/node/api/v1", dependencies=[Depends(check_super_user)]
|
||||||
|
)
|
||||||
|
public_node_router = APIRouter(
|
||||||
|
prefix="/node/public/api/v1", dependencies=[Depends(check_public)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get(
|
||||||
|
"/ok",
|
||||||
|
description="Check if node api can be enabled",
|
||||||
|
status_code=200,
|
||||||
|
dependencies=[Depends(require_node)],
|
||||||
|
)
|
||||||
|
async def api_get_ok():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@public_node_router.get("/info", response_model=PublicNodeInfo)
|
||||||
|
async def api_get_public_info(node: Node = Depends(require_node)) -> PublicNodeInfo:
|
||||||
|
return await cache.save_result(node.get_public_info, key="node:public_info")
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get("/info")
|
||||||
|
async def api_get_info(
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
) -> Optional[NodeInfoResponse]:
|
||||||
|
return await node.get_info()
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get("/channels")
|
||||||
|
async def api_get_channels(
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
) -> Optional[List[NodeChannel]]:
|
||||||
|
return await node.get_channels()
|
||||||
|
|
||||||
|
|
||||||
|
@super_node_router.post("/channels", response_model=ChannelPoint)
|
||||||
|
async def api_create_channel(
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
peer_id: str = Body(),
|
||||||
|
funding_amount: int = Body(),
|
||||||
|
push_amount: Optional[int] = Body(None),
|
||||||
|
fee_rate: Optional[int] = Body(None),
|
||||||
|
):
|
||||||
|
return await node.open_channel(peer_id, funding_amount, push_amount, fee_rate)
|
||||||
|
|
||||||
|
|
||||||
|
@super_node_router.delete("/channels")
|
||||||
|
async def api_delete_channel(
|
||||||
|
short_id: Optional[str],
|
||||||
|
funding_txid: Optional[str],
|
||||||
|
output_index: Optional[int],
|
||||||
|
force: bool = False,
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
) -> Optional[List[NodeChannel]]:
|
||||||
|
return await node.close_channel(
|
||||||
|
short_id,
|
||||||
|
ChannelPoint(funding_txid=funding_txid, output_index=output_index)
|
||||||
|
if funding_txid is not None and output_index is not None
|
||||||
|
else None,
|
||||||
|
force,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get("/payments", response_model=Page[NodePayment])
|
||||||
|
async def api_get_payments(
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
|
||||||
|
) -> Optional[Page[NodePayment]]:
|
||||||
|
if not settings.lnbits_node_ui_transactions:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="You can enable node transactions in the Admin UI",
|
||||||
|
)
|
||||||
|
return await node.get_payments(filters)
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get("/invoices", response_model=Page[NodeInvoice])
|
||||||
|
async def api_get_invoices(
|
||||||
|
node: Node = Depends(require_node),
|
||||||
|
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
|
||||||
|
) -> Optional[Page[NodeInvoice]]:
|
||||||
|
if not settings.lnbits_node_ui_transactions:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="You can enable node transactions in the Admin UI",
|
||||||
|
)
|
||||||
|
return await node.get_invoices(filters)
|
||||||
|
|
||||||
|
|
||||||
|
@node_router.get("/peers", response_model=List[NodePeerInfo])
|
||||||
|
async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]:
|
||||||
|
return await node.get_peers()
|
||||||
|
|
||||||
|
|
||||||
|
@super_node_router.post("/peers")
|
||||||
|
async def api_connect_peer(
|
||||||
|
uri: str = Body(embed=True), node: Node = Depends(require_node)
|
||||||
|
):
|
||||||
|
return await node.connect_peer(uri)
|
||||||
|
|
||||||
|
|
||||||
|
@super_node_router.delete("/peers/{peer_id}")
|
||||||
|
async def api_disconnect_peer(peer_id: str, node: Node = Depends(require_node)):
|
||||||
|
return await node.disconnect_peer(peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeRank(BaseModel):
|
||||||
|
capacity: Optional[int]
|
||||||
|
channelcount: Optional[int]
|
||||||
|
age: Optional[int]
|
||||||
|
growth: Optional[int]
|
||||||
|
availability: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
# Same for public and private api
|
||||||
|
@node_router.get(
|
||||||
|
"/rank",
|
||||||
|
description="Retrieve node ranks from https://1ml.com",
|
||||||
|
response_model=Optional[NodeRank],
|
||||||
|
)
|
||||||
|
@public_node_router.get(
|
||||||
|
"/rank",
|
||||||
|
description="Retrieve node ranks from https://1ml.com",
|
||||||
|
response_model=Optional[NodeRank],
|
||||||
|
)
|
||||||
|
async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[NodeRank]:
|
||||||
|
node_id = await node.get_id()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(url=f"https://1ml.com/node/{node_id}/json", timeout=15)
|
||||||
|
try:
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["noderank"]
|
||||||
|
except httpx.HTTPStatusError:
|
||||||
|
raise HTTPException(status_code=404, detail="Node not found on 1ml.com")
|
@ -7,6 +7,7 @@ import shortuuid
|
|||||||
from pydantic.schema import field_schema
|
from pydantic.schema import field_schema
|
||||||
|
|
||||||
from lnbits.jinja2_templating import Jinja2Templates
|
from lnbits.jinja2_templating import Jinja2Templates
|
||||||
|
from lnbits.nodes import get_node_class
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
@ -51,6 +52,10 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
|
|||||||
t.env.globals["COMMIT_VERSION"] = settings.lnbits_commit
|
t.env.globals["COMMIT_VERSION"] = settings.lnbits_commit
|
||||||
t.env.globals["LNBITS_VERSION"] = settings.version
|
t.env.globals["LNBITS_VERSION"] = settings.version
|
||||||
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
t.env.globals["LNBITS_ADMIN_UI"] = settings.lnbits_admin_ui
|
||||||
|
t.env.globals["LNBITS_NODE_UI"] = (
|
||||||
|
settings.lnbits_node_ui and get_node_class() is not None
|
||||||
|
)
|
||||||
|
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
||||||
t.env.globals["EXTENSIONS"] = [
|
t.env.globals["EXTENSIONS"] = [
|
||||||
e
|
e
|
||||||
for e in get_valid_extensions()
|
for e in get_valid_extensions()
|
||||||
|
15
lnbits/nodes/__init__.py
Normal file
15
lnbits/nodes/__init__.py
Normal file
@ -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
|
223
lnbits/nodes/base.py
Normal file
223
lnbits/nodes/base.py
Normal file
@ -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
|
323
lnbits/nodes/cln.py
Normal file
323
lnbits/nodes/cln.py
Normal file
@ -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,
|
||||||
|
)
|
382
lnbits/nodes/lndrest.py
Normal file
382
lnbits/nodes/lndrest.py
Normal file
@ -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,
|
||||||
|
)
|
@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import inspect
|
import inspect
|
||||||
@ -226,6 +228,16 @@ class WebPushSettings(LNbitsSettings):
|
|||||||
lnbits_webpush_privkey: str = Field(default=None)
|
lnbits_webpush_privkey: str = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeUISettings(LNbitsSettings):
|
||||||
|
# on-off switch for node ui
|
||||||
|
lnbits_node_ui: bool = Field(default=False)
|
||||||
|
# whether to display the public node ui (only if lnbits_node_ui is True)
|
||||||
|
lnbits_public_node_ui: bool = Field(default=False)
|
||||||
|
# can be used to disable the transactions tab in the node ui
|
||||||
|
# (recommended for large cln nodes)
|
||||||
|
lnbits_node_ui_transactions: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
class EditableSettings(
|
class EditableSettings(
|
||||||
UsersSettings,
|
UsersSettings,
|
||||||
ExtensionsSettings,
|
ExtensionsSettings,
|
||||||
@ -236,6 +248,7 @@ class EditableSettings(
|
|||||||
BoltzExtensionSettings,
|
BoltzExtensionSettings,
|
||||||
LightningSettings,
|
LightningSettings,
|
||||||
WebPushSettings,
|
WebPushSettings,
|
||||||
|
NodeUISettings,
|
||||||
):
|
):
|
||||||
@validator(
|
@validator(
|
||||||
"lnbits_admin_users",
|
"lnbits_admin_users",
|
||||||
@ -421,6 +434,12 @@ except Exception:
|
|||||||
|
|
||||||
settings.version = importlib.metadata.version("lnbits")
|
settings.version = importlib.metadata.version("lnbits")
|
||||||
|
|
||||||
|
# printing environment variable for debugging
|
||||||
|
if not settings.lnbits_admin_ui:
|
||||||
|
logger.debug("Environment Settings:")
|
||||||
|
for key, value in settings.dict(exclude_none=True).items():
|
||||||
|
logger.debug(f"{key}: {value}")
|
||||||
|
|
||||||
|
|
||||||
def get_wallet_class():
|
def get_wallet_class():
|
||||||
"""
|
"""
|
||||||
|
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
@ -3,7 +3,25 @@ window.localisation.en = {
|
|||||||
server: 'Server',
|
server: 'Server',
|
||||||
theme: 'Theme',
|
theme: 'Theme',
|
||||||
funding: 'Funding',
|
funding: 'Funding',
|
||||||
|
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
|
apps: 'Apps',
|
||||||
|
channels: 'Channels',
|
||||||
|
transactions: 'Transactions',
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
manage_node: 'Manage Node',
|
||||||
|
total_capacity: 'Total Capacity',
|
||||||
|
avg_channel_size: 'Avg. Channel Size',
|
||||||
|
biggest_channel_size: 'Biggest Channel Size',
|
||||||
|
smallest_channel_size: 'Smallest Channel Size',
|
||||||
|
number_of_channels: 'Number of Channels',
|
||||||
|
active_channels: 'Active Channels',
|
||||||
|
connect_peer: 'Connect Peer',
|
||||||
|
connect: 'Connect',
|
||||||
|
open_channel: 'Open Channel',
|
||||||
|
open: 'Open',
|
||||||
|
close_channel: 'Close Channel',
|
||||||
|
close: 'Close',
|
||||||
restart: 'Restart server',
|
restart: 'Restart server',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
save_tooltip: 'Save your changes',
|
save_tooltip: 'Save your changes',
|
||||||
|
@ -221,7 +221,7 @@ window.LNbits = {
|
|||||||
obj.expirydateFrom = moment(obj.expirydate).fromNow()
|
obj.expirydateFrom = moment(obj.expirydate).fromNow()
|
||||||
obj.msat = obj.amount
|
obj.msat = obj.amount
|
||||||
obj.sat = obj.msat / 1000
|
obj.sat = obj.msat / 1000
|
||||||
obj.tag = obj.extra.tag
|
obj.tag = obj.extra?.tag
|
||||||
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
|
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
|
||||||
obj.isIn = obj.amount > 0
|
obj.isIn = obj.amount > 0
|
||||||
obj.isOut = obj.amount < 0
|
obj.isOut = obj.amount < 0
|
||||||
@ -262,6 +262,9 @@ window.LNbits = {
|
|||||||
formatSat: function (value) {
|
formatSat: function (value) {
|
||||||
return new Intl.NumberFormat(window.LOCALE).format(value)
|
return new Intl.NumberFormat(window.LOCALE).format(value)
|
||||||
},
|
},
|
||||||
|
formatMsat: function (value) {
|
||||||
|
return this.formatSat(value / 1000)
|
||||||
|
},
|
||||||
notifyApiError: function (error) {
|
notifyApiError: function (error) {
|
||||||
var types = {
|
var types = {
|
||||||
400: 'warning',
|
400: 'warning',
|
||||||
@ -348,6 +351,7 @@ window.windowMixin = {
|
|||||||
i18n: window.i18n,
|
i18n: window.i18n,
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
|
toggleSubs: true,
|
||||||
g: {
|
g: {
|
||||||
offline: !navigator.onLine,
|
offline: !navigator.onLine,
|
||||||
visibleDrawer: false,
|
visibleDrawer: false,
|
||||||
@ -451,7 +455,7 @@ window.windowMixin = {
|
|||||||
return !obj.hidden
|
return !obj.hidden
|
||||||
})
|
})
|
||||||
.filter(function (obj) {
|
.filter(function (obj) {
|
||||||
if (window.user.admin) return obj
|
if (window.user?.admin) return obj
|
||||||
return !obj.isAdminOnly
|
return !obj.isAdminOnly
|
||||||
})
|
})
|
||||||
.map(function (obj) {
|
.map(function (obj) {
|
||||||
|
@ -178,6 +178,7 @@ Vue.component('lnbits-extension-list', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Vue.component('lnbits-admin-ui', {
|
Vue.component('lnbits-admin-ui', {
|
||||||
|
props: ['showNode'],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
extensions: [],
|
extensions: [],
|
||||||
@ -195,6 +196,14 @@ Vue.component('lnbits-admin-ui', {
|
|||||||
<q-item-label lines="1" class="text-caption" v-text="$t('manage_server')"></q-item-label>
|
<q-item-label lines="1" class="text-caption" v-text="$t('manage_server')"></q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
<q-item v-if='showNode' clickable tag="a" :href="['/node?usr=', user.id].join('')">
|
||||||
|
<q-item-section side>
|
||||||
|
<q-icon name="developer_board" color="grey-5" size="md"></q-icon>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label lines="1" class="text-caption" v-text="$t('manage_node')"></q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
@ -228,6 +228,7 @@
|
|||||||
|
|
||||||
<lnbits-admin-ui
|
<lnbits-admin-ui
|
||||||
v-if="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
v-if="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||||
|
:show-node="'{{LNBITS_NODE_UI}}' == 'True'"
|
||||||
></lnbits-admin-ui>
|
></lnbits-admin-ui>
|
||||||
<lnbits-extension-list class="q-pb-xl"></lnbits-extension-list>
|
<lnbits-extension-list class="q-pb-xl"></lnbits-extension-list>
|
||||||
</q-drawer>
|
</q-drawer>
|
||||||
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import importlib
|
import importlib
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from lnbits.nodes import set_node_class
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.wallets.base import Wallet
|
from lnbits.wallets.base import Wallet
|
||||||
|
|
||||||
@ -27,6 +28,8 @@ def set_wallet_class(class_name: Optional[str] = None):
|
|||||||
wallet_class = getattr(wallets_module, backend_wallet_class)
|
wallet_class = getattr(wallets_module, backend_wallet_class)
|
||||||
global WALLET
|
global WALLET
|
||||||
WALLET = wallet_class()
|
WALLET = wallet_class()
|
||||||
|
if WALLET.__node_cls__:
|
||||||
|
set_node_class(WALLET.__node_cls__(WALLET))
|
||||||
|
|
||||||
|
|
||||||
def get_wallet_class() -> Wallet:
|
def get_wallet_class() -> Wallet:
|
||||||
@ -34,7 +37,7 @@ def get_wallet_class() -> Wallet:
|
|||||||
|
|
||||||
|
|
||||||
wallets_module = importlib.import_module("lnbits.wallets")
|
wallets_module = importlib.import_module("lnbits.wallets")
|
||||||
FAKE_WALLET: Wallet = FakeWallet()
|
FAKE_WALLET = FakeWallet()
|
||||||
|
|
||||||
# initialize as fake wallet
|
# initialize as fake wallet
|
||||||
WALLET: Wallet = FAKE_WALLET
|
WALLET: Wallet = FAKE_WALLET
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import AsyncGenerator, Coroutine, NamedTuple, Optional
|
from typing import TYPE_CHECKING, AsyncGenerator, Coroutine, NamedTuple, Optional, Type
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from lnbits.nodes.base import Node
|
||||||
|
|
||||||
|
|
||||||
class StatusResponse(NamedTuple):
|
class StatusResponse(NamedTuple):
|
||||||
@ -51,6 +56,8 @@ class Wallet(ABC):
|
|||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
__node_cls__: Optional[Type[Node]] = None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def status(self) -> Coroutine[None, None, StatusResponse]:
|
def status(self) -> Coroutine[None, None, StatusResponse]:
|
||||||
pass
|
pass
|
||||||
@ -61,6 +68,7 @@ class Wallet(ABC):
|
|||||||
amount: int,
|
amount: int,
|
||||||
memo: Optional[str] = None,
|
memo: Optional[str] = None,
|
||||||
description_hash: Optional[bytes] = None,
|
description_hash: Optional[bytes] = None,
|
||||||
|
unhashed_description: Optional[bytes] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Coroutine[None, None, InvoiceResponse]:
|
) -> Coroutine[None, None, InvoiceResponse]:
|
||||||
pass
|
pass
|
||||||
|
@ -7,6 +7,7 @@ from bolt11.exceptions import Bolt11Exception
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pyln.client import LightningRpc, RpcError
|
from pyln.client import LightningRpc, RpcError
|
||||||
|
|
||||||
|
from lnbits.nodes.cln import CoreLightningNode
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@ -25,6 +26,8 @@ async def run_sync(func) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
class CoreLightningWallet(Wallet):
|
class CoreLightningWallet(Wallet):
|
||||||
|
__node_cls__ = CoreLightningNode
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.rpc = settings.corelightning_rpc or settings.clightning_rpc
|
self.rpc = settings.corelightning_rpc or settings.clightning_rpc
|
||||||
self.ln = LightningRpc(self.rpc)
|
self.ln = LightningRpc(self.rpc)
|
||||||
@ -117,6 +120,9 @@ class CoreLightningWallet(Wallet):
|
|||||||
"bolt11": bolt11,
|
"bolt11": bolt11,
|
||||||
"maxfeepercent": f"{fee_limit_percent:.11}",
|
"maxfeepercent": f"{fee_limit_percent:.11}",
|
||||||
"exemptfee": 0,
|
"exemptfee": 0,
|
||||||
|
# so fee_limit_percent is applied even on payments with fee < 5000
|
||||||
|
# millisatoshi (which is default value of exemptfee)
|
||||||
|
"description": invoice.description,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
r = await run_sync(lambda: self.ln.call("pay", payload))
|
r = await run_sync(lambda: self.ln.call("pay", payload))
|
||||||
|
@ -7,6 +7,7 @@ from typing import AsyncGenerator, Dict, Optional
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.nodes.lndrest import LndRestNode
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
@ -22,6 +23,8 @@ from .macaroon import AESCipher, load_macaroon
|
|||||||
class LndRestWallet(Wallet):
|
class LndRestWallet(Wallet):
|
||||||
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
|
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
|
||||||
|
|
||||||
|
__node_cls__ = LndRestNode
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
endpoint = settings.lnd_rest_endpoint
|
endpoint = settings.lnd_rest_endpoint
|
||||||
cert = settings.lnd_rest_cert
|
cert = settings.lnd_rest_cert
|
||||||
|
@ -102,6 +102,14 @@ async def to_user():
|
|||||||
yield user
|
yield user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def from_super_user(from_user):
|
||||||
|
prev = settings.super_user
|
||||||
|
settings.super_user = from_user.id
|
||||||
|
yield from_user
|
||||||
|
settings.super_user = prev
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="session")
|
@pytest_asyncio.fixture(scope="session")
|
||||||
async def superuser():
|
async def superuser():
|
||||||
user = await get_user(settings.super_user)
|
user = await get_user(settings.super_user)
|
||||||
|
171
tests/core/views/test_node_api.py
Normal file
171
tests/core/views/test_node_api.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import parse_obj_as
|
||||||
|
|
||||||
|
from lnbits import bolt11
|
||||||
|
from lnbits.nodes.base import ChannelPoint, ChannelState, NodeChannel
|
||||||
|
from tests.conftest import pytest_asyncio, settings
|
||||||
|
|
||||||
|
from ...helpers import (
|
||||||
|
WALLET,
|
||||||
|
get_random_invoice_data,
|
||||||
|
get_unconnected_node_uri,
|
||||||
|
mine_blocks,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
WALLET.__node_cls__ is None, reason="Cant test if node implementation isnt avilable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def node_client(client, from_super_user):
|
||||||
|
settings.lnbits_node_ui = True
|
||||||
|
settings.lnbits_public_node_ui = False
|
||||||
|
settings.lnbits_node_ui_transactions = True
|
||||||
|
params = client.params
|
||||||
|
client.params = {"usr": from_super_user.id}
|
||||||
|
yield client
|
||||||
|
client.params = params
|
||||||
|
settings.lnbits_node_ui = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture()
|
||||||
|
async def public_node_client(node_client):
|
||||||
|
settings.lnbits_public_node_ui = True
|
||||||
|
yield node_client
|
||||||
|
settings.lnbits_public_node_ui = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_node_info_not_found(client):
|
||||||
|
response = await client.get("/node/api/v1/info")
|
||||||
|
assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_node_info_not_found(node_client):
|
||||||
|
response = await node_client.get("/node/public/api/v1/info")
|
||||||
|
assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_node_info(public_node_client):
|
||||||
|
response = await public_node_client.get("/node/public/api/v1/info")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_node_info(node_client, from_super_user):
|
||||||
|
response = await node_client.get("/node/api/v1/info")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_node_invoices(inkey_headers_from, node_client):
|
||||||
|
data = await get_random_invoice_data()
|
||||||
|
response = await node_client.post(
|
||||||
|
"/api/v1/payments", json=data, headers=inkey_headers_from
|
||||||
|
)
|
||||||
|
invoice = response.json()
|
||||||
|
|
||||||
|
response = await node_client.get("/node/api/v1/invoices", params={"limit": 1})
|
||||||
|
assert response.status_code == 200
|
||||||
|
invoices = response.json()["data"]
|
||||||
|
assert len(invoices) == 1
|
||||||
|
assert invoices[0]["payment_hash"] == invoice["payment_hash"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_node_payments(node_client, real_invoice, adminkey_headers_from):
|
||||||
|
response = await node_client.post(
|
||||||
|
"/api/v1/payments", json=real_invoice, headers=adminkey_headers_from
|
||||||
|
)
|
||||||
|
assert response.status_code < 300
|
||||||
|
|
||||||
|
response = await node_client.get("/node/api/v1/payments", params={"limit": 1})
|
||||||
|
assert response.status_code == 200
|
||||||
|
payments = response.json()["data"]
|
||||||
|
assert len(payments) == 1
|
||||||
|
assert (
|
||||||
|
payments[0]["payment_hash"]
|
||||||
|
== bolt11.decode(real_invoice["bolt11"]).payment_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_channel_management(node_client):
|
||||||
|
async def get_channels():
|
||||||
|
response = await node_client.get("/node/api/v1/channels")
|
||||||
|
assert response.status_code == 200
|
||||||
|
return parse_obj_as(list[NodeChannel], response.json())
|
||||||
|
|
||||||
|
data = await get_channels()
|
||||||
|
close = random.choice(
|
||||||
|
[channel for channel in data if channel.state == ChannelState.ACTIVE]
|
||||||
|
)
|
||||||
|
assert close, "No active channel found"
|
||||||
|
|
||||||
|
response = await node_client.delete(
|
||||||
|
"/node/api/v1/channels",
|
||||||
|
params={"short_id": close.short_id, **close.point.dict()},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = await get_channels()
|
||||||
|
assert any(
|
||||||
|
channel.point == close.point and channel.state == ChannelState.CLOSED
|
||||||
|
for channel in data
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await node_client.post(
|
||||||
|
"/node/api/v1/channels",
|
||||||
|
json={
|
||||||
|
"peer_id": close.peer_id,
|
||||||
|
"funding_amount": 100000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
created = ChannelPoint(**response.json())
|
||||||
|
data = await get_channels()
|
||||||
|
assert any(
|
||||||
|
channel.point == created and channel.state == ChannelState.PENDING
|
||||||
|
for channel in data
|
||||||
|
)
|
||||||
|
|
||||||
|
# mine some blocks so that the newly created channel eventually
|
||||||
|
# gets confirmed to avoid a situation where no channels are
|
||||||
|
# left for testing
|
||||||
|
mine_blocks(5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_peer_management(node_client):
|
||||||
|
connect_uri = get_unconnected_node_uri()
|
||||||
|
id = connect_uri.split("@")[0]
|
||||||
|
response = await node_client.post("/node/api/v1/peers", json={"uri": connect_uri})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = await node_client.get("/node/api/v1/peers")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert any(peer["id"] == id for peer in response.json())
|
||||||
|
|
||||||
|
response = await node_client.delete(f"/node/api/v1/peers/{id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
response = await node_client.get("/node/api/v1/peers")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert not any(peer["id"] == id for peer in response.json())
|
||||||
|
|
||||||
|
response = await node_client.delete(f"/node/api/v1/peers/{id}")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_invalid_uri(node_client):
|
||||||
|
response = await node_client.post("/node/api/v1/peers", json={"uri": "invalid"})
|
||||||
|
assert response.status_code == 400
|
@ -53,6 +53,17 @@ docker_bitcoin_cli = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
docker_lightning_unconnected_cli = [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"lnbits-legend-lnd-2-1",
|
||||||
|
"lncli",
|
||||||
|
"--network",
|
||||||
|
"regtest",
|
||||||
|
"--rpcserver=lnd-2",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(cmd: list) -> str:
|
def run_cmd(cmd: list) -> str:
|
||||||
timeout = 20
|
timeout = 20
|
||||||
process = Popen(cmd, stdout=PIPE, stderr=PIPE)
|
process = Popen(cmd, stdout=PIPE, stderr=PIPE)
|
||||||
@ -124,6 +135,14 @@ def mine_blocks(blocks: int = 1) -> str:
|
|||||||
return run_cmd(cmd)
|
return run_cmd(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def get_unconnected_node_uri() -> str:
|
||||||
|
cmd = docker_lightning_unconnected_cli.copy()
|
||||||
|
cmd.append("getinfo")
|
||||||
|
info = run_cmd_json(cmd)
|
||||||
|
pubkey = info["identity_pubkey"]
|
||||||
|
return f"{pubkey}@lnd-2:9735"
|
||||||
|
|
||||||
|
|
||||||
def create_onchain_address(address_type: str = "bech32") -> str:
|
def create_onchain_address(address_type: str = "bech32") -> str:
|
||||||
cmd = docker_bitcoin_cli.copy()
|
cmd = docker_bitcoin_cli.copy()
|
||||||
cmd.extend(["getnewaddress", address_type])
|
cmd.extend(["getnewaddress", address_type])
|
||||||
|
Loading…
Reference in New Issue
Block a user