whatchonly ext added

This commit is contained in:
Tiago vasconcelos 2021-10-14 11:45:30 +01:00
parent 43f36afeb0
commit ec89244d7f
10 changed files with 1376 additions and 0 deletions

View File

@ -0,0 +1,19 @@
# Watch Only wallet
## Monitor an onchain wallet and generate addresses for onchain payments
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.
1. Start by clicking "NEW WALLET"\
![new wallet](https://i.imgur.com/vgbAB7c.png)
2. Fill the requested fields:
- give the wallet a name
- paste an Extended Public Key (xpub, ypub, zpub)
- click "CREATE WATCH-ONLY WALLET"\
![fill wallet form](https://i.imgur.com/UVoG7LD.png)
3. You can then access your onchain addresses\
![get address](https://i.imgur.com/zkxTQ6l.png)
4. You can then generate bitcoin onchain adresses from LNbits\
![onchain address](https://i.imgur.com/4KVSSJn.png)
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension

View File

@ -0,0 +1,13 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_watchonly")
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,212 @@
from typing import List, Optional
from . import db
from .models import Wallets, Addresses, Mempool
from lnbits.helpers import urlsafe_short_hash
from embit.descriptor import Descriptor, Key # type: ignore
from embit.descriptor.arguments import AllowedDerivation # type: ignore
from embit.networks import NETWORKS # type: ignore
##########################WALLETS####################
def detect_network(k):
version = k.key.version
for network_name in NETWORKS:
net = NETWORKS[network_name]
# not found in this network
if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]:
return net
def parse_key(masterpub: str):
"""Parses masterpub or descriptor and returns a tuple: (Descriptor, network)
To create addresses use descriptor.derive(num).address(network=network)
"""
network = None
# probably a single key
if "(" not in masterpub:
k = Key.from_string(masterpub)
if not k.is_extended:
raise ValueError("The key is not a master public key")
if k.is_private:
raise ValueError("Private keys are not allowed")
# check depth
if k.key.depth != 3:
raise ValueError(
"Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors."
)
# if allowed derivation is not provided use default /{0,1}/*
if k.allowed_derivation is None:
k.allowed_derivation = AllowedDerivation.default()
# get version bytes
version = k.key.version
for network_name in NETWORKS:
net = NETWORKS[network_name]
# not found in this network
if version in [net["xpub"], net["ypub"], net["zpub"]]:
network = net
if version == net["xpub"]:
desc = Descriptor.from_string("pkh(%s)" % str(k))
elif version == net["ypub"]:
desc = Descriptor.from_string("sh(wpkh(%s))" % str(k))
elif version == net["zpub"]:
desc = Descriptor.from_string("wpkh(%s)" % str(k))
break
# we didn't find correct version
if network is None:
raise ValueError("Unknown master public key version")
else:
desc = Descriptor.from_string(masterpub)
if not desc.is_wildcard:
raise ValueError("Descriptor should have wildcards")
for k in desc.keys:
if k.is_extended:
net = detect_network(k)
if net is None:
raise ValueError(f"Unknown version: {k}")
if network is not None and network != net:
raise ValueError("Keys from different networks")
network = net
return desc, network
async def create_watch_wallet(*, user: str, masterpub: str, title: str) -> Wallets:
# check the masterpub is fine, it will raise an exception if not
parse_key(masterpub)
wallet_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO watchonly.wallets (
id,
"user",
masterpub,
title,
address_no,
balance
)
VALUES (?, ?, ?, ?, ?, ?)
""",
# address_no is -1 so fresh address on empty wallet can get address with index 0
(wallet_id, user, masterpub, title, -1, 0),
)
return await get_watch_wallet(wallet_id)
async def get_watch_wallet(wallet_id: str) -> Optional[Wallets]:
row = await db.fetchone(
"SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,)
)
return Wallets.from_row(row) if row else None
async def get_watch_wallets(user: str) -> List[Wallets]:
rows = await db.fetchall(
"""SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,)
)
return [Wallets(**row) for row in rows]
async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE watchonly.wallets SET {q} WHERE id = ?", (*kwargs.values(), wallet_id)
)
row = await db.fetchone(
"SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,)
)
return Wallets.from_row(row) if row else None
async def delete_watch_wallet(wallet_id: str) -> None:
await db.execute("DELETE FROM watchonly.wallets WHERE id = ?", (wallet_id,))
########################ADDRESSES#######################
async def get_derive_address(wallet_id: str, num: int):
wallet = await get_watch_wallet(wallet_id)
key = wallet[2]
desc, network = parse_key(key)
return desc.derive(num).address(network=network)
async def get_fresh_address(wallet_id: str) -> Optional[Addresses]:
wallet = await get_watch_wallet(wallet_id)
if not wallet:
return None
address = await get_derive_address(wallet_id, wallet[4] + 1)
await update_watch_wallet(wallet_id=wallet_id, address_no=wallet[4] + 1)
masterpub_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO watchonly.addresses (
id,
address,
wallet,
amount
)
VALUES (?, ?, ?, ?)
""",
(masterpub_id, address, wallet_id, 0),
)
return await get_address(address)
async def get_address(address: str) -> Optional[Addresses]:
row = await db.fetchone(
"SELECT * FROM watchonly.addresses WHERE address = ?", (address,)
)
return Addresses.from_row(row) if row else None
async def get_addresses(wallet_id: str) -> List[Addresses]:
rows = await db.fetchall(
"SELECT * FROM watchonly.addresses WHERE wallet = ?", (wallet_id,)
)
return [Addresses(**row) for row in rows]
######################MEMPOOL#######################
async def create_mempool(user: str) -> Optional[Mempool]:
await db.execute(
"""
INSERT INTO watchonly.mempool ("user",endpoint)
VALUES (?, ?)
""",
(user, "https://mempool.space"),
)
row = await db.fetchone(
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
)
return Mempool.from_row(row) if row else None
async def update_mempool(user: str, **kwargs) -> Optional[Mempool]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"""UPDATE watchonly.mempool SET {q} WHERE "user" = ?""",
(*kwargs.values(), user),
)
row = await db.fetchone(
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
)
return Mempool.from_row(row) if row else None
async def get_mempool(user: str) -> Mempool:
row = await db.fetchone(
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
)
return Mempool.from_row(row) if row else None

View File

@ -0,0 +1,36 @@
async def m001_initial(db):
"""
Initial wallet table.
"""
await db.execute(
"""
CREATE TABLE watchonly.wallets (
id TEXT NOT NULL PRIMARY KEY,
"user" TEXT,
masterpub TEXT NOT NULL,
title TEXT NOT NULL,
address_no INTEGER NOT NULL DEFAULT 0,
balance INTEGER NOT NULL
);
"""
)
await db.execute(
"""
CREATE TABLE watchonly.addresses (
id TEXT NOT NULL PRIMARY KEY,
address TEXT NOT NULL,
wallet TEXT NOT NULL,
amount INTEGER NOT NULL
);
"""
)
await db.execute(
"""
CREATE TABLE watchonly.mempool (
"user" TEXT NOT NULL,
endpoint TEXT NOT NULL
);
"""
)

View File

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

View File

@ -0,0 +1,244 @@
<q-card>
<q-card-section>
<p>
Watch Only extension uses mempool.space<br />
For use with "account Extended Public Key"
<a href="https://iancoleman.io/bip39/">https://iancoleman.io/bip39/</a>
<small>
<br />Created by,
<a target="_blank" href="https://github.com/arcbtc">Ben Arc</a> (using,
<a target="_blank" href="https://github.com/diybitcoinhardware/embit"
>Embit</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 wallets">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /watchonly/api/v1/wallet</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;wallets_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/wallet -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 wallet details"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/watchonly/api/v1/wallet/&lt;wallet_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>[&lt;wallet_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/wallet/&lt;wallet_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 wallet">
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span> /watchonly/api/v1/wallet</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>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>[&lt;wallet_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/wallet -d '{"title":
&lt;string&gt;, "masterpub": &lt;string&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 wallet"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/watchonly/api/v1/wallet/&lt;wallet_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
}}api/v1/wallet/&lt;wallet_id&gt; -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="List addresses">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/watchonly/api/v1/addresses/&lt;wallet_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 200 OK (application/json)
</h5>
<code>[&lt;address_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}api/v1/addresses/&lt;wallet_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="Get fresh address"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/watchonly/api/v1/address/&lt;wallet_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 200 OK (application/json)
</h5>
<code>[&lt;address_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/address/&lt;wallet_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="Get mempool.space details"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /watchonly/api/v1/mempool</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>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;mempool_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/mempool -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 mempool.space"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/watchonly/api/v1/mempool</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>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>[&lt;mempool_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root }}api/v1/mempool -d '{"endpoint":
&lt;string&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>
</q-card>

View File

@ -0,0 +1,649 @@
{% 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="primary" @click="formDialog.show = true"
>New wallet
</q-btn>
<q-btn unelevated color="primary" 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
>
{{ col.label }}
</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="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="deleteWalletLink(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
{{ col.value }}
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
<q-card>
<div class="row justify-center q-gutter-x-md items-center">
<div class="text-h3 q-pa-sm">{{satBtc(utxos.total)}}</div>
<q-btn flat @click="utxos.sats = !utxos.sats">
{{utxos.sats ? ' sats' : ' BTC'}}</q-btn
>
</div>
</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">Transactions</h5>
</div>
<div class="col-auto">
<q-input
borderless
dense
debounce="300"
v-model="TxosTable.filter"
placeholder="Search"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table
flat
dense
:data="utxos.data"
row-key="txid"
:columns="TxosTable.columns"
:pagination.sync="TxosTable.pagination"
:filter="TxosTable.filter"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
:class="col.value == true ? 'text-green-13 text-weight-bold' : ''"
>
{{ col.name == 'value' ? satBtc(col.value) : col.value }}
</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
{% endraw %}
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Watch Only 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="Account Extended Public Key; xpub, ypub, zpub; Bitcoin Descriptor"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
: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="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>
{{ currentaddress }}
<q-btn
flat
dense
size="ms"
icon="visibility"
type="a"
:href="mempool.endpoint + '/address/' + currentaddress"
target="_blank"
></q-btn>
</p>
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="currentaddress"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<p style="word-break: break-all">
<q-scroll-area style="height: 200px; max-width: 100%">
<q-list bordered v-for="data in Addresses.data.slice().reverse()">
<q-item>
<q-item-section>{{ data.address }}</q-item-section>
<q-btn
flat
dense
size="ms"
icon="visibility"
type="a"
:href="mempool.endpoint + '/address/' + data.address"
target="_blank"
></q-btn>
</q-item>
</q-list>
</q-scroll-area>
</p>
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="getFreshAddress(current)"
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>
{% endraw %}
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script type="text/javascript" src="https://mempool.space/mempool.js"></script>
<style></style>
<script>
Vue.component(VueQrcode.name, VueQrcode)
Vue.filter('reverse', function (value) {
// slice to make a copy of array, then reverse the copy
return value.slice().reverse()
})
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
}
var mapAddresses = 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: '',
balance: null,
checker: null,
walletLinks: [],
AddressesLinks: [],
currentaddress: '',
Addresses: {
show: false,
data: null
},
utxos: {
data: [],
total: 0,
sats: true
},
mempool: {
endpoint: ''
},
WalletsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{
name: 'title',
align: 'left',
label: 'Title',
field: 'title'
},
{
name: 'masterpub',
align: 'left',
label: 'MasterPub',
field: 'masterpub'
}
],
pagination: {
rowsPerPage: 10
}
},
TxosTable: {
columns: [
{
name: 'value',
align: 'left',
label: 'Value',
field: 'value',
sortable: true
},
{
name: 'confirmed',
align: 'center',
label: 'Confirmed',
field: 'confirmed',
sortable: true
},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
},
{
name: 'txid',
align: 'left',
label: 'ID',
field: 'txid',
sortable: true
}
],
pagination: {
rowsPerPage: 10
},
uxtosFilter: ''
},
formDialog: {
show: false,
data: {}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
getAddressDetails: function (address) {
LNbits.api
.request(
'GET',
'/watchonly/api/v1/mempool/' + address,
this.g.user.wallets[0].inkey
)
.then(function (response) {
return reponse.data
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
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.Addresses.data = response.data
self.currentaddress =
self.Addresses.data[self.Addresses.data.length - 1].address
self.AddressesLinks = response.data.map(function (obj) {
return mapAddresses(obj)
})
self.fetchUtxos()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getFreshAddress: function (walletID) {
var self = this
LNbits.api
.request(
'GET',
'/watchonly/api/v1/address/' + walletID,
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.Addresses.data = response.data
self.currentaddress =
self.Addresses.data[self.Addresses.data.length - 1].address
})
},
getMempool: function () {
var self = this
LNbits.api
.request(
'GET',
'/watchonly/api/v1/mempool',
this.g.user.wallets[0].adminkey
)
.then(function (response) {
self.mempool.endpoint = response.data.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.adminkey,
self.mempool
)
.then(function (response) {
self.mempool.endpoint = response.data.endpoint
self.walletLinks.push(mapwalletLink(response.data))
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
fetchUtxos: async function () {
const {
bitcoin: {addresses}
} = mempoolJS()
const address = this.AddressesLinks.map(x => x.address)
const fetchUtxo = async () => {
let txs = []
// const address = [
// '3Er3w82WqPLL4ew23taUZcFQwbZq6PE9TK',
// '16CwtWRwQYLojaVZZFCgnaM6SQuNefqwrc',
// '1KZB6FqnnMWySk75uvFKuPzHct1tMXHSSR'
// ] //test addresses
for (add of address) {
let addressTxsUtxo = await addresses.getAddressTxsUtxo({
address: add
})
txs = [...txs, ...addressTxsUtxo]
}
return txs
}
let utxos = await fetchUtxo()
utxos = utxos
.reduce((ac, x) => {
if (!ac)
return [
{
txid: x.txid,
confirmed: x.status.confirmed,
value: x.value,
date: moment(x.status?.block_time * 1000).format('LLL'),
sort: x.status?.block_time
}
]
if (!ac.some(y => y.txid == x.txid))
return [
...ac,
{
txid: x.txid,
confirmed: x.status.confirmed,
value: x.value,
date: moment(x.status?.block_time * 1000).format('LLL'),
sort: x.status?.block_time
}
]
return
}, [])
.sort((a, b) => b.sort - a.sort)
let total = utxos.reduce((total, y) => {
total += y?.value || 0
return total
}, 0)
this.utxos.data = utxos
this.utxos.total = total
},
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) {
self.getAddresses(obj.id)
return mapWalletLink(obj)
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
closeFormDialog: function () {
this.formDialog.data = {
is_unique: false
}
},
openQrCodeDialog: function (linkId) {
var self = this
var getAddresses = this.getAddresses
this.getAddresses(linkId)
self.current = linkId
self.Addresses.show = true
},
sendFormData: function () {
var wallet = this.g.user.wallets[0]
var data = _.omit(this.formDialog.data, 'wallet')
this.createWalletLink(wallet, data)
},
createWalletLink: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/watchonly/api/v1/wallet', wallet.adminkey, data)
.then(function (response) {
self.walletLinks.push(mapWalletLink(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteWalletLink: function (linkId) {
var self = this
var link = _.findWhere(this.walletLinks, {id: linkId})
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].adminkey
)
.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)
},
satBtc(val) {
return this.utxos.sats
? LNbits.utils.formatSat(val)
: val == 0
? 0.0
: (val / 100000000).toFixed(8)
}
},
created: function () {
if (this.g.user.wallets.length) {
var getMempool = this.getMempool
getMempool()
var getWalletLinks = this.getWalletLinks
getWalletLinks()
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,22 @@
from quart import g, abort, render_template
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from . import watchonly_ext
@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("/<charge_id>")
async def display(charge_id):
link = get_payment(charge_id) or abort(
HTTPStatus.NOT_FOUND, "Charge link does not exist."
)
return await render_template("watchonly/display.html", link=link)

View File

@ -0,0 +1,138 @@
import hashlib
from quart import g, jsonify, url_for, request
from http import HTTPStatus
import httpx
import json
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,
get_fresh_address,
get_addresses,
create_mempool,
update_mempool,
get_mempool,
)
###################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 await get_watch_wallets(g.wallet.user)]
),
HTTPStatus.OK,
)
except:
return ""
@watchonly_ext.route("/api/v1/wallet/<wallet_id>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_wallet_retrieve(wallet_id):
wallet = await get_watch_wallet(wallet_id)
if not wallet:
return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND
return jsonify(wallet._asdict()), HTTPStatus.OK
@watchonly_ext.route("/api/v1/wallet", methods=["POST"])
@api_check_wallet_key("admin")
@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):
try:
wallet = await create_watch_wallet(
user=g.wallet.user, masterpub=g.data["masterpub"], title=g.data["title"]
)
except Exception as e:
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
mempool = await get_mempool(g.wallet.user)
if not mempool:
create_mempool(user=g.wallet.user)
return jsonify(wallet._asdict()), HTTPStatus.CREATED
@watchonly_ext.route("/api/v1/wallet/<wallet_id>", methods=["DELETE"])
@api_check_wallet_key("admin")
async def api_wallet_delete(wallet_id):
wallet = await get_watch_wallet(wallet_id)
if not wallet:
return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND
await 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):
await get_fresh_address(wallet_id)
addresses = await get_addresses(wallet_id)
return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK
@watchonly_ext.route("/api/v1/addresses/<wallet_id>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_get_addresses(wallet_id):
wallet = await get_watch_wallet(wallet_id)
if not wallet:
return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND
addresses = await get_addresses(wallet_id)
if not addresses:
await get_fresh_address(wallet_id)
addresses = await get_addresses(wallet_id)
return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK
#############################MEMPOOL##########################
@watchonly_ext.route("/api/v1/mempool", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"endpoint": {"type": "string", "empty": False, "required": True},
}
)
async def api_update_mempool():
mempool = await 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("admin")
async def api_get_mempool():
mempool = await get_mempool(g.wallet.user)
if not mempool:
mempool = await create_mempool(user=g.wallet.user)
return jsonify(mempool._asdict()), HTTPStatus.OK