updated watchonly

This commit is contained in:
benarc 2020-12-01 19:54:16 +00:00
parent 52956a62a2
commit a838706090
12 changed files with 1419 additions and 0 deletions

View File

@ -0,0 +1,4 @@
# Watch Only wallet
Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API.

View File

@ -0,0 +1,8 @@
from quart import Blueprint
watchonly_ext: Blueprint = Blueprint("watchonly", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
from .views import * # noqa

View File

@ -0,0 +1,8 @@
{
"name": "Watch Only",
"short_description": "Onchain watch only wallets",
"icon": "visibility",
"contributors": [
"arcbtc"
]
}

View File

@ -0,0 +1,186 @@
from typing import List, Optional, Union
from lnbits.db import open_ext_db
from .models import Wallets, Payments, Addresses, Mempool
from lnbits.helpers import urlsafe_short_hash
from embit import bip32
from embit import ec
from embit.networks import NETWORKS
from embit import base58
from embit.util import hashlib
import io
from embit.util import secp256k1
from embit import hashes
from binascii import hexlify
from quart import jsonify
from embit import script
from embit import ec
from embit.networks import NETWORKS
from binascii import unhexlify, hexlify, a2b_base64, b2a_base64
########################ADDRESSES#######################
def get_derive_address(wallet_id: str, num: int):
wallet = get_watch_wallet(wallet_id)
k = bip32.HDKey.from_base58(str(wallet[2]))
child = k.derive([0, num])
address = script.p2wpkh(child).address()
return address
def get_fresh_address(wallet_id: str) -> Addresses:
wallet = get_watch_wallet(wallet_id)
address = get_derive_address(wallet_id, wallet[4] + 1)
update_watch_wallet(wallet_id = wallet_id, address_no = wallet[4] + 1)
with open_ext_db("watchonly") as db:
db.execute(
"""
INSERT INTO addresses (
address,
wallet,
amount
)
VALUES (?, ?, ?)
""",
(address, wallet_id, 0),
)
return get_address(address)
def get_address(address: str) -> Addresses:
with open_ext_db("watchonly") as db:
row = db.fetchone("SELECT * FROM addresses WHERE address = ?", (address,))
return Addresses.from_row(row) if row else None
def get_addresses(wallet_id: str) -> List[Addresses]:
with open_ext_db("watchonly") as db:
rows = db.fetchall("SELECT * FROM addresses WHERE wallet = ?", (wallet_id,))
return [Addresses(**row) for row in rows]
##########################WALLETS####################
def create_watch_wallet(*, user: str, masterpub: str, title: str) -> Wallets:
wallet_id = urlsafe_short_hash()
with open_ext_db("watchonly") as db:
db.execute(
"""
INSERT INTO wallets (
id,
user,
masterpub,
title,
address_no,
amount
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(wallet_id, user, masterpub, title, 0, 0),
)
# weallet_id = db.cursor.lastrowid
address = get_fresh_address(wallet_id)
return get_watch_wallet(wallet_id)
def get_watch_wallet(wallet_id: str) -> Wallets:
with open_ext_db("watchonly") as db:
row = db.fetchone("SELECT * FROM wallets WHERE id = ?", (wallet_id,))
return Wallets.from_row(row) if row else None
def get_watch_wallets(user: str) -> List[Wallets]:
with open_ext_db("watchonly") as db:
rows = db.fetchall("SELECT * FROM wallets WHERE user = ?", (user,))
return [Wallets(**row) for row in rows]
def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("watchonly") as db:
db.execute(f"UPDATE wallets SET {q} WHERE id = ?", (*kwargs.values(), wallet_id))
row = db.fetchone("SELECT * FROM wallets WHERE id = ?", (wallet_id,))
return Wallets.from_row(row) if row else None
def delete_watch_wallet(wallet_id: str) -> None:
with open_ext_db("watchonly") as db:
db.execute("DELETE FROM wallets WHERE id = ?", (wallet_id,))
###############PAYMENTS##########################
def create_payment(*, user: str, ex_key: str, description: str, amount: int) -> Payments:
address = get_fresh_address(ex_key)
payment_id = urlsafe_short_hash()
with open_ext_db("watchonly") as db:
db.execute(
"""
INSERT INTO payments (
payment_id,
user,
ex_key,
address,
amount
)
VALUES (?, ?, ?, ?, ?)
""",
(payment_id, user, ex_key, address, amount),
)
payment_id = db.cursor.lastrowid
return get_payment(payment_id)
def get_payment(payment_id: str) -> Payments:
with open_ext_db("watchonly") as db:
row = db.fetchone("SELECT * FROM payments WHERE id = ?", (payment_id,))
return Payments.from_row(row) if row else None
def get_payments(user: str) -> List[Payments]:
with open_ext_db("watchonly") as db:
rows = db.fetchall("SELECT * FROM payments WHERE user IN ?", (user,))
return [Payments.from_row(row) for row in rows]
def delete_payment(payment_id: str) -> None:
with open_ext_db("watchonly") as db:
db.execute("DELETE FROM payments WHERE id = ?", (payment_id,))
######################MEMPOOL#######################
def create_mempool(user: str) -> Mempool:
with open_ext_db("watchonly") as db:
db.execute(
"""
INSERT INTO mempool (
user,
endpoint
)
VALUES (?, ?)
""",
(user, 'https://mempool.space'),
)
row = db.fetchone("SELECT * FROM mempool WHERE user = ?", (user,))
return Mempool.from_row(row) if row else None
def update_mempool(user: str, **kwargs) -> Optional[Mempool]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("watchonly") as db:
db.execute(f"UPDATE mempool SET {q} WHERE user = ?", (*kwargs.values(), user))
row = db.fetchone("SELECT * FROM mempool WHERE user = ?", (user,))
return Mempool.from_row(row) if row else None
def get_mempool(user: str) -> Mempool:
with open_ext_db("watchonly") as db:
row = db.fetchone("SELECT * FROM mempool WHERE user = ?", (user,))
return Mempool.from_row(row) if row else None

View File

@ -0,0 +1,48 @@
def m001_initial(db):
"""
Initial wallet table.
"""
db.execute(
"""
CREATE TABLE IF NOT EXISTS wallets (
id TEXT NOT NULL PRIMARY KEY,
user TEXT,
masterpub TEXT NOT NULL,
title TEXT NOT NULL,
address_no INTEGER NOT NULL DEFAULT 0,
amount INTEGER NOT NULL
);
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS addresses (
address TEXT NOT NULL PRIMARY KEY,
wallet TEXT NOT NULL,
amount INTEGER NOT NULL
);
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS payments (
id TEXT NOT NULL PRIMARY KEY,
user TEXT,
masterpub TEXT NOT NULL,
address TEXT NOT NULL,
time_to_pay INTEGER NOT NULL,
amount INTEGER NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
);
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS mempool (
user TEXT NOT NULL,
endpoint TEXT NOT NULL
);
"""
)

View File

@ -0,0 +1,44 @@
from sqlite3 import Row
from typing import NamedTuple
class Wallets(NamedTuple):
id: str
user: str
masterpub: str
title: str
address_no: int
amount: int
@classmethod
def from_row(cls, row: Row) -> "Wallets":
return cls(**dict(row))
class Payments(NamedTuple):
id: str
user: str
ex_key: str
address: str
time_to_pay: str
amount: int
time: int
@classmethod
def from_row(cls, row: Row) -> "Payments":
return cls(**dict(row))
class Addresses(NamedTuple):
address: str
wallet: str
amount: int
@classmethod
def from_row(cls, row: Row) -> "Addresses":
return cls(**dict(row))
class Mempool(NamedTuple):
user: str
endpoint: str
@classmethod
def from_row(cls, row: Row) -> "Mempool":
return cls(**dict(row))

View File

@ -0,0 +1,141 @@
<q-card>
<q-card-section>
<p>The WatchOnly extension uses https://mempool.block for blockchain data.<br />
<small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
>
</p>
</q-card-section>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="List pay links">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /pay/api/v1/links</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;pay_link_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}pay/api/v1/links -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get a pay link">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/pay/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create a pay link"
>
<q-card>
<q-card-section>
<code><span class="text-green">POST</span> /pay/api/v1/links</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"description": &lt;string&gt; "amount": &lt;integer&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}pay/api/v1/links -d
'{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Update a pay link"
>
<q-card>
<q-card-section>
<code
><span class="text-green">PUT</span>
/pay/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"description": &lt;string&gt;, "amount": &lt;integer&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt; -d
'{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a pay link"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/pay/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt;
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,54 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-sm-6 col-md-5 col-lg-4">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<div class="text-center">
<a href="lightning:{{ link.lnurl }}">
<q-responsive :ratio="1" class="q-mx-md">
<qrcode
value="{{ link.lnurl }}"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText('{{ link.lnurl }}')"
>Copy LNURL</q-btn
>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-sm-6 col-md-5 col-lg-4 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mb-sm q-mt-none">
LNbits LNURL-pay link
</h6>
<p class="q-my-none">
Use a LNURL compatible bitcoin wallet to claim the sats.
</p>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "lnurlp/_lnurl.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin]
})
</script>
{% endblock %}

View File

@ -0,0 +1,710 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
{% raw %}
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
>New wallet</q-btn
>
<q-btn unelevated color="deep-purple"
icon="edit">
<div class="cursor-pointer">
<q-tooltip>
Point to another Mempool
</q-tooltip>
{{ this.mempool.endpoint }}
<q-popup-edit v-model="mempool.endpoint">
<q-input color="accent" v-model="mempool.endpoint">
</q-input>
<center><q-btn
flat
dense
@click="updateMempool()"
v-close-popup
>set</q-btn>
<q-btn
flat
dense
v-close-popup
>cancel</q-btn>
</center>
</q-popup-edit>
</div>
</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Wallets</h5>
</div>
<div class="col-auto">
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search">
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table
flat
dense
:data="walletLinks"
row-key="id"
:columns="WalletsTable.columns"
:pagination.sync="WalletsTable.pagination"
:filter="filter"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props" auto-width>
<div v-if="col.name == 'id'"></div>
<div v-else>
{{ col.label }}
</div>
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="toll"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="formDialogPayLink.show = true"
>
<q-tooltip>
Payment link
</q-tooltip>
</q-btn>
<q-btn
unelevated
dense
size="xs"
icon="dns"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)"
>
<q-tooltip>
Adresses
</q-tooltip>
</q-btn>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteWalletLink(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props" auto-width>
<div v-if="col.name == 'id'"></div>
<div v-else>
{{ col.value }}
</div>
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Paylinks</h5>
</div>
<div class="col-auto">
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search">
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
{% endraw %}
</div>
</div>
<q-table
flat
dense
:data="payLinks"
row-key="id"
:columns="PaylinksTable.columns"
:pagination.sync="PaylinksTable.pagination"
:filter="filter"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props" auto-width>
<div v-if="col.name == 'id'"></div>
<div v-else>
{{ col.label }}
</div>
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteWalletLink(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props" auto-width>
<div v-if="col.name == 'id'"></div>
<div v-else>
{{ col.value }}
</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
LNbits WatchOnly Extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "watchonly/_api_docs.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.title"
type="text"
label="Title"
></q-input>
<q-input
filled
type="textarea"
v-model="formDialog.data.masterpub"
height="50px"
autogrow
label="Master Public Key, either xpub, ypub, zpub"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="deep-purple"
type="submit"
>Update Watch-only Wallet</q-btn
>
<q-btn
v-else
unelevated
color="deep-purple"
:disable="
formDialog.data.masterpub == null ||
formDialog.data.title == null"
type="submit"
>Create Watch-only Wallet</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="formDialogPayLink.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialogPayLink.data.title"
type="text"
label="Title"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogPayLink.data.amount"
type="number"
label="Amount (sats)"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogPayLink.data.time"
type="number"
label="Time (mins)"
> </q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialogPayLink.data.id"
unelevated
color="deep-purple"
type="submit"
>Update Paylink</q-btn
>
<q-btn
v-else
unelevated
color="deep-purple"
:disable="
formDialogPayLink.data.time == null ||
formDialogPayLink.data.amount == null"
type="submit"
>Create Paylink</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="Addresses.show" position="top">
<q-card v-if="Addresses.data" class="q-pa-lg lnbits__dialog-card">
{% raw %}
<h5 class="text-subtitle1 q-my-none">Addresses</h5>
<q-separator></q-separator><br/>
<p><strong>Current:</strong>
{{ Addresses.data[0].address }}
<q-btn
flat
dense
size="ms"
icon="visibility"
type="a"
:href="mempool.endpoint + '/address/' + Addresses.data[0].address"
target="_blank"
></q-btn>
</p>
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="Addresses.data[0].address"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<p style="word-break: break-all;">
<br /><br />
Table of addresses and amount will go here...
</p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="copyText(Addresses.show, 'LNURL copied to clipboard!')"
class="q-ml-sm"
>Get fresh address</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<style>
</style>
<script>
Vue.component(VueQrcode.name, VueQrcode)
var locationPath = [
window.location.protocol,
'//',
window.location.hostname,
window.location.pathname
].join('')
var mapWalletLink = function (obj) {
obj._data = _.clone(obj)
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
filter: '',
checker: null,
walletLinks: [],
Addresses: {
show: false,
data: null
},
mempool:{
endpoint:""
},
WalletsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{
name: 'title',
align: 'left',
label: 'Title',
field: 'title'
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount'
},
{
name: 'masterpub',
align: 'left',
label: 'MasterPub',
field: 'masterpub'
},
],
pagination: {
rowsPerPage: 10
}
},
PaylinksTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{
name: 'title',
align: 'left',
label: 'Title',
field: 'title'
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount'
},
{
name: 'time',
align: 'left',
label: 'time',
field: 'time'
},
],
pagination: {
rowsPerPage: 10
}
},
AddressTable: {
columns: [
{
name: 'address',
align: 'left',
label: 'Address',
field: 'address'
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount'
},
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {}
},
formDialogPayLink: {
show: false,
data: {}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
getAddresses: function (walletID) {
var self = this
LNbits.api
.request(
'GET',
'/watchonly/api/v1/addresses/' + walletID,
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.walletLinks = response.data.map(function (obj) {
self.Addresses.data = response.data
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
addressRedirect: function (address){
window.location.href = this.mempool.endpoint + "/address/" + address;
},
getMempool: function () {
var self = this
LNbits.api
.request(
'GET',
'/watchonly/api/v1/mempool',
this.g.user.wallets[0].inkey
)
.then(function (response) {
console.log(response.data.endpoint)
self.mempool.endpoint = response.data.endpoint
console.log(this.mempool.endpoint)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
updateMempool: function () {
var self = this
var wallet = this.g.user.wallets[0]
LNbits.api
.request(
'PUT',
'/watchonly/api/v1/mempool',
wallet.inkey, self.mempool)
.then(function (response) {
self.mempool.endpoint = response.data.endpoint
self.walletLinks.push(mapwalletLink(response.data))
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getWalletLinks: function () {
var self = this
LNbits.api
.request(
'GET',
'/watchonly/api/v1/wallet',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.walletLinks = response.data.map(function (obj) {
return mapWalletLink(obj)
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
closeFormDialog: function () {
this.formDialog.data = {
is_unique: false
}
},
openQrCodeDialog: function (linkId) {
var getAddresses = this.getAddresses
getAddresses(linkId)
this.Addresses.show = true
},
openUpdateDialog: function (linkId) {
var link = _.findWhere(this.walletLinks, {id: linkId})
this.formDialog.data = _.clone(link._data)
this.formDialog.show = true
},
sendFormData: function () {
var wallet = this.g.user.wallets[0]
var data = _.omit(this.formDialog.data, 'wallet')
data.wait_time =
data.wait_time *
{
seconds: 1,
minutes: 60,
hours: 3600
}[this.formDialog.secondMultiplier]
if (data.id) {
this.updateWalletLink(wallet, data)
} else {
this.createWalletLink(wallet, data)
}
},
updateWalletLink: function (wallet, data) {
var self = this
LNbits.api
.request(
'PUT',
'/watchonly/api/v1/wallet/' + data.id,
wallet.inkey, data)
.then(function (response) {
self.walletLinks = _.reject(self.walletLinks, function (obj) {
return obj.id === data.id
})
self.walletLinks.push(mapWalletLink(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
createWalletLink: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/watchonly/api/v1/wallet', wallet.inkey, data)
.then(function (response) {
self.walletLinks.push(mapWalletLink(response.data))
self.formDialog.show = false
console.log(response.data[1][1])
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteWalletLink: function (linkId) {
var self = this
var link = _.findWhere(this.walletLinks, {id: linkId})
console.log(self.g.user.wallets[0].adminkey)
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/watchonly/api/v1/wallet/' + linkId,
self.g.user.wallets[0].inkey
)
.then(function (response) {
self.walletLinks = _.reject(self.walletLinks, function (obj) {
return obj.id === linkId
})})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls)
},
},
created: function () {
if (this.g.user.wallets.length) {
var getWalletLinks = this.getWalletLinks
getWalletLinks()
var getMempool = this.getMempool
getMempool()
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,21 @@
from quart import g, abort, render_template
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.watchonly import watchonly_ext
from .crud import get_payment
@watchonly_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("watchonly/index.html", user=g.user)
@watchonly_ext.route("/<payment_id>")
async def display(payment_id):
link = get_payment(payment_id) or abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
return await render_template("watchonly/display.html", link=link)

View File

@ -0,0 +1,194 @@
import hashlib
from quart import g, jsonify, request, url_for
from http import HTTPStatus
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.extensions.watchonly import watchonly_ext
from .crud import (
create_watch_wallet,
get_watch_wallet,
get_watch_wallets,
update_watch_wallet,
delete_watch_wallet,
create_payment,
get_payment,
get_payments,
delete_payment,
create_mempool,
update_mempool,
get_mempool,
get_addresses,
get_fresh_address,
get_address
)
###################WALLETS#############################
@watchonly_ext.route("/api/v1/wallet", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_wallets_retrieve():
try:
return (
jsonify([wallet._asdict() for wallet in get_watch_wallets(g.wallet.user)]), HTTPStatus.OK
)
except:
return (
jsonify({"message": "Cant fetch."}),
HTTPStatus.UPGRADE_REQUIRED,
)
@watchonly_ext.route("/api/v1/wallet/<wallet_id>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_wallet_retrieve(wallet_id):
wallet = get_watch_wallet(wallet_id)
if not wallet:
return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND
return jsonify({wallet}), HTTPStatus.OK
@watchonly_ext.route("/api/v1/wallet", methods=["POST"])
@watchonly_ext.route("/api/v1/wallet/<wallet_id>", methods=["PUT"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"masterpub": {"type": "string", "empty": False, "required": True},
"title": {"type": "string", "empty": False, "required": True},
}
)
async def api_wallet_create_or_update(wallet_id=None):
print("g.data")
if not wallet_id:
wallet = create_watch_wallet(user=g.wallet.user, masterpub=g.data["masterpub"], title=g.data["title"])
mempool = get_mempool(g.wallet.user)
if not mempool:
create_mempool(user=g.wallet.user)
return jsonify(wallet._asdict()), HTTPStatus.CREATED
else:
wallet = update_watch_wallet(wallet_id=wallet_id, **g.data)
return jsonify(wallet._asdict()), HTTPStatus.OK
@watchonly_ext.route("/api/v1/wallet/<wallet_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_wallet_delete(wallet_id):
wallet = get_watch_wallet(wallet_id)
if not wallet:
return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND
delete_watch_wallet(wallet_id)
return jsonify({"deleted": "true"}), HTTPStatus.NO_CONTENT
#############################ADDRESSES##########################
@watchonly_ext.route("/api/v1/address/<wallet_id>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_fresh_address(wallet_id):
address = get_fresh_address(wallet_id)
if not address:
return jsonify({"message": "something went wrong"}), HTTPStatus.NOT_FOUND
return jsonify({address}), HTTPStatus.OK
@watchonly_ext.route("/api/v1/addresses/<wallet_id>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_get_addresses(wallet_id):
addresses = get_addresses(wallet_id)
print(addresses)
if not addresses:
return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND
return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK
#############################PAYEMENTS##########################
@watchonly_ext.route("/api/v1/payment", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_payments_retrieve():
try:
return (
jsonify(get_payments(g.wallet.user)),
HTTPStatus.OK,
)
except:
return (
jsonify({"message": "Cant fetch."}),
HTTPStatus.UPGRADE_REQUIRED,
)
@watchonly_ext.route("/api/v1/payment/<payment_id>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_payment_retrieve(payment_id):
payment = get_payment(payment_id)
if not payment:
return jsonify({"message": "payment does not exist"}), HTTPStatus.NOT_FOUND
return jsonify({payment}), HTTPStatus.OK
@watchonly_ext.route("/api/v1/payment", methods=["POST"])
@watchonly_ext.route("/api/v1/payment/<payment_id>", methods=["PUT"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"ex_key": {"type": "string", "empty": False, "required": True},
"pub_key": {"type": "string", "empty": False, "required": True},
"time_to_pay": {"type": "integer", "min": 1, "required": True},
"amount": {"type": "integer", "min": 1, "required": True},
}
)
async def api_payment_create_or_update(payment_id=None):
if not payment_id:
payment = create_payment(g.wallet.user, g.data.ex_key, g.data.pub_key, g.data.amount)
return jsonify(get_payment(payment)), HTTPStatus.CREATED
else:
payment = update_payment(payment_id, g.data)
return jsonify({payment}), HTTPStatus.OK
@watchonly_ext.route("/api/v1/payment/<payment_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_payment_delete(payment_id):
payment = get_watch_wallet(payment_id)
if not payment:
return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND
delete_watch_wallet(payment_id)
return "", HTTPStatus.NO_CONTENT
#############################MEMPOOL##########################
@watchonly_ext.route("/api/v1/mempool", methods=["PUT"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"endpoint": {"type": "string", "empty": False, "required": True},
}
)
async def api_update_mempool():
mempool = update_mempool(user=g.wallet.user, **g.data)
return jsonify(mempool._asdict()), HTTPStatus.OK
@watchonly_ext.route("/api/v1/mempool", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_get_mempool():
mempool = get_mempool(g.wallet.user)
if not mempool:
mempool = create_mempool(user=g.wallet.user)
return jsonify(mempool._asdict()), HTTPStatus.OK

View File

@ -46,3 +46,4 @@ trio==0.16.0
typing-extensions==3.7.4.3
werkzeug==1.0.1
wsproto==1.0.0
embit==0.1.2