mirror of
https://github.com/lnbits/lnbits.git
synced 2025-09-20 13:04:23 +02:00
whatchonly ext added
This commit is contained in:
19
lnbits/extensions/watchonly/README.md
Normal file
19
lnbits/extensions/watchonly/README.md
Normal 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"\
|
||||||
|

|
||||||
|
2. Fill the requested fields:
|
||||||
|
- give the wallet a name
|
||||||
|
- paste an Extended Public Key (xpub, ypub, zpub)
|
||||||
|
- click "CREATE WATCH-ONLY WALLET"\
|
||||||
|

|
||||||
|
3. You can then access your onchain addresses\
|
||||||
|

|
||||||
|
4. You can then generate bitcoin onchain adresses from LNbits\
|
||||||
|

|
||||||
|
|
||||||
|
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
|
13
lnbits/extensions/watchonly/__init__.py
Normal file
13
lnbits/extensions/watchonly/__init__.py
Normal 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
|
8
lnbits/extensions/watchonly/config.json
Normal file
8
lnbits/extensions/watchonly/config.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "Watch Only",
|
||||||
|
"short_description": "Onchain watch only wallets",
|
||||||
|
"icon": "visibility",
|
||||||
|
"contributors": [
|
||||||
|
"arcbtc"
|
||||||
|
]
|
||||||
|
}
|
212
lnbits/extensions/watchonly/crud.py
Normal file
212
lnbits/extensions/watchonly/crud.py
Normal 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
|
36
lnbits/extensions/watchonly/migrations.py
Normal file
36
lnbits/extensions/watchonly/migrations.py
Normal 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
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
35
lnbits/extensions/watchonly/models.py
Normal file
35
lnbits/extensions/watchonly/models.py
Normal 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))
|
244
lnbits/extensions/watchonly/templates/watchonly/_api_docs.html
Normal file
244
lnbits/extensions/watchonly/templates/watchonly/_api_docs.html
Normal 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": <invoice_key>}</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>[<wallets_object>, ...]</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/<wallet_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</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>[<wallet_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/wallet/<wallet_id>
|
||||||
|
-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": <admin_key>}</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>[<wallet_object>, ...]</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":
|
||||||
|
<string>, "masterpub": <string>}' -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/<wallet_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</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/<wallet_id> -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/<wallet_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</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>[<address_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root
|
||||||
|
}}api/v1/addresses/<wallet_id> -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/<wallet_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</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>[<address_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.url_root }}api/v1/address/<wallet_id>
|
||||||
|
-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": <admin_key>}</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>[<mempool_object>, ...]</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": <admin_key>}</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>[<mempool_object>, ...]</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":
|
||||||
|
<string>}' -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>
|
649
lnbits/extensions/watchonly/templates/watchonly/index.html
Normal file
649
lnbits/extensions/watchonly/templates/watchonly/index.html
Normal 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 %}
|
22
lnbits/extensions/watchonly/views.py
Normal file
22
lnbits/extensions/watchonly/views.py
Normal 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)
|
138
lnbits/extensions/watchonly/views_api.py
Normal file
138
lnbits/extensions/watchonly/views_api.py
Normal 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
|
Reference in New Issue
Block a user