diff --git a/lnbits/extensions/watchonly/README.md b/lnbits/extensions/watchonly/README.md
new file mode 100644
index 000000000..d93f7162d
--- /dev/null
+++ b/lnbits/extensions/watchonly/README.md
@@ -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
diff --git a/lnbits/extensions/watchonly/__init__.py b/lnbits/extensions/watchonly/__init__.py
new file mode 100644
index 000000000..b8df31978
--- /dev/null
+++ b/lnbits/extensions/watchonly/__init__.py
@@ -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
diff --git a/lnbits/extensions/watchonly/config.json b/lnbits/extensions/watchonly/config.json
new file mode 100644
index 000000000..48c19ef07
--- /dev/null
+++ b/lnbits/extensions/watchonly/config.json
@@ -0,0 +1,8 @@
+{
+ "name": "Watch Only",
+ "short_description": "Onchain watch only wallets",
+ "icon": "visibility",
+ "contributors": [
+ "arcbtc"
+ ]
+}
diff --git a/lnbits/extensions/watchonly/crud.py b/lnbits/extensions/watchonly/crud.py
new file mode 100644
index 000000000..bd301eb44
--- /dev/null
+++ b/lnbits/extensions/watchonly/crud.py
@@ -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
diff --git a/lnbits/extensions/watchonly/migrations.py b/lnbits/extensions/watchonly/migrations.py
new file mode 100644
index 000000000..05c229b53
--- /dev/null
+++ b/lnbits/extensions/watchonly/migrations.py
@@ -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
+ );
+ """
+ )
diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py
new file mode 100644
index 000000000..b9faa6019
--- /dev/null
+++ b/lnbits/extensions/watchonly/models.py
@@ -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))
diff --git a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html
new file mode 100644
index 000000000..97fdb8a90
--- /dev/null
+++ b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html
@@ -0,0 +1,244 @@
+
+ Watch Only extension uses mempool.space
+ For use with "account Extended Public Key"
+ https://iancoleman.io/bip39/
+
+
Created by,
+ Ben Arc (using,
+ Embit)
+ GET /watchonly/api/v1/wallet
+ Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<wallets_object>, ...]
+ Curl example
+ curl -X GET {{ request.url_root }}api/v1/wallet -H "X-Api-Key: {{
+ g.user.wallets[0].inkey }}"
+
+ GET
+ /watchonly/api/v1/wallet/<wallet_id>
+ Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json)
+
+
+ Returns 201 CREATED (application/json)
+
+ [<wallet_object>, ...]
+ Curl example
+ curl -X GET {{ request.url_root }}api/v1/wallet/<wallet_id>
+ -H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+
+ POST /watchonly/api/v1/wallet
+ Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json)
+
+
+ Returns 201 CREATED (application/json)
+
+ [<wallet_object>, ...]
+ Curl example
+ 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 }}"
+
+ DELETE
+ /watchonly/api/v1/wallet/<wallet_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Returns 204 NO CONTENT
+
+
Curl example
+ curl -X DELETE {{ request.url_root
+ }}api/v1/wallet/<wallet_id> -H "X-Api-Key: {{
+ g.user.wallets[0].adminkey }}"
+
+ GET
+ /watchonly/api/v1/addresses/<wallet_id>
+ Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<address_object>, ...]
+ Curl example
+ curl -X GET {{ request.url_root
+ }}api/v1/addresses/<wallet_id> -H "X-Api-Key: {{
+ g.user.wallets[0].inkey }}"
+
+ GET
+ /watchonly/api/v1/address/<wallet_id>
+ Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<address_object>, ...]
+ Curl example
+ curl -X GET {{ request.url_root }}api/v1/address/<wallet_id>
+ -H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+
+ GET /watchonly/api/v1/mempool
+ Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<mempool_object>, ...]
+ Curl example
+ curl -X GET {{ request.url_root }}api/v1/mempool -H "X-Api-Key: {{
+ g.user.wallets[0].adminkey }}"
+
+ POST
+ /watchonly/api/v1/mempool
+ Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json)
+
+
+ Returns 201 CREATED (application/json)
+
+ [<mempool_object>, ...]
+ Curl example
+ 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 }}"
+
+
+ Current:
+ {{ currentaddress }}
+
+