diff --git a/lnbits/extensions/satspay/README.md b/lnbits/extensions/satspay/README.md new file mode 100644 index 00000000..d52547ae --- /dev/null +++ b/lnbits/extensions/satspay/README.md @@ -0,0 +1,27 @@ +# SatsPay Server + +## Create onchain and LN charges. Includes webhooks! + +Easilly create invoices that support Lightning Network and on-chain BTC payment. + +1. Create a "NEW CHARGE"\ + ![new charge](https://i.imgur.com/fUl6p74.png) +2. Fill out the invoice fields + - set a descprition for the payment + - the amount in sats + - the time, in minutes, the invoice is valid for, after this period the invoice can't be payed + - set a webhook that will get the transaction details after a successful payment + - set to where the user should redirect after payment + - set the text for the button that will show after payment (not setting this, will display "NONE" in the button) + - select if you want onchain payment, LN payment or both + - depending on what you select you'll have to choose the respective wallets where to receive your payment\ + ![charge form](https://i.imgur.com/F10yRiW.png) +3. The charge will appear on the _Charges_ section\ + ![charges](https://i.imgur.com/zqHpVxc.png) +4. Your costumer/payee will get the payment page + - they can choose to pay on LN\ + ![offchain payment](https://i.imgur.com/4191SMV.png) + - or pay on chain\ + ![onchain payment](https://i.imgur.com/wzLRR5N.png) +5. You can check the state of your charges in LNBits\ + ![invoice state](https://i.imgur.com/JnBd22p.png) diff --git a/lnbits/extensions/satspay/__init__.py b/lnbits/extensions/satspay/__init__.py new file mode 100644 index 00000000..7b7f0bde --- /dev/null +++ b/lnbits/extensions/satspay/__init__.py @@ -0,0 +1,25 @@ +import asyncio + +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_satspay") + + +satspay_ext: APIRouter = APIRouter( + prefix="/satspay", + tags=["satspay"] +) + +def satspay_renderer(): + return template_renderer( + [ + "lnbits/extensions/satspay/templates", + ] + ) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/satspay/config.json b/lnbits/extensions/satspay/config.json new file mode 100644 index 00000000..beb0071c --- /dev/null +++ b/lnbits/extensions/satspay/config.json @@ -0,0 +1,8 @@ +{ + "name": "SatsPay Server", + "short_description": "Create onchain and LN charges", + "icon": "payment", + "contributors": [ + "arcbtc" + ] +} diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py new file mode 100644 index 00000000..e707dc00 --- /dev/null +++ b/lnbits/extensions/satspay/crud.py @@ -0,0 +1,122 @@ +from typing import List, Optional, Union + +# from lnbits.db import open_ext_db +from . import db +from .models import Charges, CreateCharge + +from lnbits.helpers import urlsafe_short_hash + +import httpx +from lnbits.core.services import create_invoice, check_invoice_status +from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool + + +###############CHARGES########################## + + +async def create_charge( + user: str, + data: CreateCharge +) -> Charges: + charge_id = urlsafe_short_hash() + if data.onchainwallet: + wallet = await get_watch_wallet(data.onchainwallet) + onchain = await get_fresh_address(data.onchainwallet) + onchainaddress = onchain.address + else: + onchainaddress = None + if data.lnbitswallet: + payment_hash, payment_request = await create_invoice( + wallet_id=data.lnbitswallet, amount=data.amount, memo=charge_id + ) + else: + payment_hash = None + payment_request = None + await db.execute( + """ + INSERT INTO satspay.charges ( + id, + "user", + description, + onchainwallet, + onchainaddress, + lnbitswallet, + payment_request, + payment_hash, + webhook, + completelink, + completelinktext, + time, + amount, + balance + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + charge_id, + user, + data.description, + data.onchainwallet, + onchainaddress, + data.lnbitswallet, + payment_request, + payment_hash, + data.webhook, + data.completelink, + data.completelinktext, + data.time, + data.amount, + 0, + ), + ) + return await get_charge(charge_id) + + +async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE satspay.charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id) + ) + row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None + + +async def get_charge(charge_id: str) -> Charges: + row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None + + +async def get_charges(user: str) -> List[Charges]: + rows = await db.fetchall( + """SELECT * FROM satspay.charges WHERE "user" = ?""", (user,) + ) + return [Charges.from_row(row) for row in rows] + + +async def delete_charge(charge_id: str) -> None: + await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,)) + + +async def check_address_balance(charge_id: str) -> List[Charges]: + charge = await get_charge(charge_id) + if not charge.paid: + if charge.onchainaddress: + mempool = await get_mempool(charge.user) + try: + async with httpx.AsyncClient() as client: + r = await client.get( + mempool.endpoint + "/api/address/" + charge.onchainaddress + ) + respAmount = r.json()["chain_stats"]["funded_txo_sum"] + if respAmount >= charge.balance: + await update_charge(charge_id=charge_id, balance=respAmount) + except Exception: + pass + if charge.lnbitswallet: + invoice_status = await check_invoice_status( + charge.lnbitswallet, charge.payment_hash + ) + if invoice_status.paid: + return await update_charge(charge_id=charge_id, balance=charge.amount) + row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) + return Charges.from_row(row) if row else None diff --git a/lnbits/extensions/satspay/migrations.py b/lnbits/extensions/satspay/migrations.py new file mode 100644 index 00000000..87446c80 --- /dev/null +++ b/lnbits/extensions/satspay/migrations.py @@ -0,0 +1,28 @@ +async def m001_initial(db): + """ + Initial wallet table. + """ + + await db.execute( + """ + CREATE TABLE satspay.charges ( + id TEXT NOT NULL PRIMARY KEY, + "user" TEXT, + description TEXT, + onchainwallet TEXT, + onchainaddress TEXT, + lnbitswallet TEXT, + payment_request TEXT, + payment_hash TEXT, + webhook TEXT, + completelink TEXT, + completelinktext TEXT, + time INTEGER, + amount INTEGER, + balance INTEGER DEFAULT 0, + timestamp TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py new file mode 100644 index 00000000..4cf3efad --- /dev/null +++ b/lnbits/extensions/satspay/models.py @@ -0,0 +1,50 @@ +from sqlite3 import Row +from typing import Optional +from fastapi.param_functions import Query +from pydantic import BaseModel +import time + +class CreateCharge(BaseModel): + onchainwallet: str = Query(None) + lnbitswallet: str = Query(None) + description: str = Query(...) + webhook: str = Query(None) + completelink: str = Query(None) + completelinktext: str = Query(None) + time: int = Query(..., ge=1) + amount: int = Query(..., ge=1) + +class Charges(BaseModel): + id: str + user: str + description: Optional[str] + onchainwallet: Optional[str] + onchainaddress: Optional[str] + lnbitswallet: Optional[str] + payment_request: str + payment_hash: str + webhook: Optional[str] + completelink: Optional[str] + completelinktext: Optional[str] = "Back to Merchant" + time: int + amount: int + balance: int + timestamp: int + + @classmethod + def from_row(cls, row: Row) -> "Charges": + return cls(**dict(row)) + + @property + def time_elapsed(self): + if (self.timestamp + (self.time * 60)) >= time.time(): + return False + else: + return True + + @property + def paid(self): + if self.balance >= self.amount: + return True + else: + return False diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html new file mode 100644 index 00000000..af95cbf2 --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -0,0 +1,171 @@ + + +

+ SatsPayServer, create Onchain/LN charges.
WARNING: If using with the + WatchOnly extension, we highly reccomend using a fresh extended public Key + specifically for SatsPayServer!
+ + Created by, Ben Arc +

+
+ + + + + POST /satspay/api/v1/charge +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/charge -d + '{"onchainwallet": <string, watchonly_wallet_id>, + "description": <string>, "webhook":<string>, "time": + <integer>, "amount": <integer>, "lnbitswallet": + <string, lnbits_wallet_id>}' -H "Content-type: + application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/charge/<charge_id> + -d '{"onchainwallet": <string, watchonly_wallet_id>, + "description": <string>, "webhook":<string>, "time": + <integer>, "amount": <integer>, "lnbitswallet": + <string, lnbits_wallet_id>}' -H "Content-type: + application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}" + +
+
+
+ + + + + GET + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/charge/<charge_id> + -H "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET /satspay/api/v1/charges +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/charges -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /satspay/api/v1/charge/<charge_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/charge/<charge_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /satspay/api/v1/charges/balance/<charge_id> +
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<charge_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+
+
diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html new file mode 100644 index 00000000..5b0282b6 --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/display.html @@ -0,0 +1,319 @@ +{% extends "public.html" %} {% block page %} +
+ +
+
+
{{ charge.description }}
+
+
+
+
Time elapsed
+
+
+
Charge paid
+
+
+ + + + Awaiting payment... + + {% raw %} {{ newTimeLeft }} {% endraw %} + + + +
+
+
+
+ Charge ID: {{ charge.id }} +
+ {% raw %} Total to pay: {{ charge_amount }}sats
+ Amount paid: {{ charge_balance }}

+ Amount due: {{ charge_amount - charge_balance }}sats {% endraw %} +
+
+ +
+
+
+ + + bitcoin onchain payment method not available + + + + pay with lightning + +
+
+ + + bitcoin lightning payment method not available + + + + pay onchain + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+ Pay this
+ lightning-network invoice
+
+ + + + + +
+ Copy invoice +
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+ Send {{ charge.amount }}sats
+ to this onchain address
+
+ + + + + +
+ Copy address +
+
+
+
+
+
+
+ +{% endblock %} {% block scripts %} + + + +{% endblock %} diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html new file mode 100644 index 00000000..d941e90b --- /dev/null +++ b/lnbits/extensions/satspay/templates/satspay/index.html @@ -0,0 +1,557 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + {% raw %} + New charge + + + + + + +
+
+
Charges
+
+ +
+ + + + Export to CSV +
+
+ + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} satspay Extension +
+
+ + + {% include "satspay/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ + + Watch-Only extension MUST be activated and have a wallet + + +
+
+
+
+ +
+
+
+ +
+ +
+ + + +
+ Create Charge + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + +{% endblock %} diff --git a/lnbits/extensions/satspay/views.py b/lnbits/extensions/satspay/views.py new file mode 100644 index 00000000..020b5897 --- /dev/null +++ b/lnbits/extensions/satspay/views.py @@ -0,0 +1,30 @@ +from fastapi.param_functions import Depends +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from starlette.requests import Request +from lnbits.core.models import User +from lnbits.core.crud import get_wallet +from lnbits.decorators import check_user_exists +from http import HTTPStatus + +from fastapi.templating import Jinja2Templates + +from . import satspay_ext, satspay_renderer +from .crud import get_charge + +templates = Jinja2Templates(directory="templates") + +@satspay_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return satspay_renderer().TemplateResponse("satspay/index.html", {"request": request,"user": user.dict()}) + + +@satspay_ext.get("/{charge_id}", response_class=HTMLResponse) +async def display(request: Request, charge_id): + charge = await get_charge(charge_id) + if not charge: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Charge link does not exist." + ) + return satspay_renderer().TemplateResponse("satspay/display.html", {"request": request, "charge": charge}) diff --git a/lnbits/extensions/satspay/views_api.py b/lnbits/extensions/satspay/views_api.py new file mode 100644 index 00000000..0bfe5f04 --- /dev/null +++ b/lnbits/extensions/satspay/views_api.py @@ -0,0 +1,142 @@ +import hashlib + +from http import HTTPStatus +import httpx + +from fastapi import Query +from fastapi.params import Depends + +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse # type: ignore + + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type + +from lnbits.extensions.satspay import satspay_ext +from .models import CreateCharge +from .crud import ( + create_charge, + update_charge, + get_charge, + get_charges, + delete_charge, + check_address_balance, +) + +#############################CHARGES########################## + + +@satspay_ext.post("/api/v1/charge") +@satspay_ext.put("/api/v1/charge/{charge_id}") + +async def api_charge_create_or_update(data: CreateCharge, wallet: WalletTypeInfo = Depends(get_key_type), charge_id=None): + if not charge_id: + charge = await create_charge(user=wallet.wallet.user, data=data) + return charge.dict() + else: + charge = await update_charge(charge_id=charge_id, data=data) + return charge.dict() + + +@satspay_ext.get("/api/v1/charges") +async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): + try: + return [ + { + **charge.dict(), + **{"time_elapsed": charge.time_elapsed}, + **{"paid": charge.paid}, + } + for charge in await get_charges(wallet.wallet.user) + ] + except: + return "" + + +@satspay_ext.get("/api/v1/charge/{charge_id}") +async def api_charge_retrieve(charge_id, wallet: WalletTypeInfo = Depends(get_key_type)): + charge = await get_charge(charge_id) + + if not charge: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Charge does not exist." + ) + + return { + **charge.dict(), + **{"time_elapsed": charge.time_elapsed}, + **{"paid": charge.paid}, + } + + +@satspay_ext.delete("/api/v1/charge/{charge_id}") +async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_type)): + charge = await get_charge(charge_id) + + if not charge: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Charge does not exist." + ) + + await delete_charge(charge_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +#############################BALANCE########################## + + +@satspay_ext.get("/api/v1/charges/balance/{charge_id}") +async def api_charges_balance(charge_id): + + charge = await check_address_balance(charge_id) + + if not charge: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Charge does not exist." + ) + + if charge.paid and charge.webhook: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + charge.webhook, + json={ + "id": charge.id, + "description": charge.description, + "onchainaddress": charge.onchainaddress, + "payment_request": charge.payment_request, + "payment_hash": charge.payment_hash, + "time": charge.time, + "amount": charge.amount, + "balance": charge.balance, + "paid": charge.paid, + "timestamp": charge.timestamp, + "completelink": charge.completelink, + }, + timeout=40, + ) + except AssertionError: + charge.webhook = None + return charge.dict() + + +#############################MEMPOOL########################## + + +@satspay_ext.put("/api/v1/mempool") +async def api_update_mempool(endpoint: str = Query(...), wallet: WalletTypeInfo = Depends(get_key_type)): + mempool = await update_mempool(endpoint, user=wallet.wallet.user) + return mempool.dict() + + +@satspay_ext.route("/api/v1/mempool") +async def api_get_mempool(wallet: WalletTypeInfo = Depends(get_key_type)): + mempool = await get_mempool(wallet.wallet.user) + if not mempool: + mempool = await create_mempool(user=wallet.wallet.user) + return mempool.dict() diff --git a/lnbits/extensions/watchonly/README.md b/lnbits/extensions/watchonly/README.md new file mode 100644 index 00000000..d93f7162 --- /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"\ + ![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 diff --git a/lnbits/extensions/watchonly/__init__.py b/lnbits/extensions/watchonly/__init__.py new file mode 100644 index 00000000..8a1632f5 --- /dev/null +++ b/lnbits/extensions/watchonly/__init__.py @@ -0,0 +1,25 @@ +import asyncio + +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_watchonly") + + +watchonly_ext: APIRouter = APIRouter( + prefix="/watchonly", + tags=["watchonly"] +) + +def watchonly_renderer(): + return template_renderer( + [ + "lnbits/extensions/watchonly/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 00000000..48c19ef0 --- /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 00000000..f9b92c2f --- /dev/null +++ b/lnbits/extensions/watchonly/crud.py @@ -0,0 +1,214 @@ +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 + print("PARSE", parse_key(masterpub)) + 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.masterpub + 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.address_no + 1) + + await update_watch_wallet(wallet_id=wallet_id, address_no=wallet.address_no + 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 00000000..05c229b5 --- /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 00000000..2fc4bf2e --- /dev/null +++ b/lnbits/extensions/watchonly/models.py @@ -0,0 +1,39 @@ +from sqlite3 import Row +from fastapi.param_functions import Query +from pydantic import BaseModel + +class CreateWallet(BaseModel): + masterpub: str = Query("") + title: str = Query("") + +class Wallets(BaseModel): + 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(BaseModel): + user: str + endpoint: str + + @classmethod + def from_row(cls, row: Row) -> "Mempool": + return cls(**dict(row)) + + +class Addresses(BaseModel): + 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 00000000..9d6bb6ac --- /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: {{ + 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: {{ 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: {{ 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: {{ + 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: {{ + 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: {{ 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: {{ + 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: + {{ user.wallets[0].adminkey }}" + +
+
+
+
+
diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html new file mode 100644 index 00000000..e70f8a23 --- /dev/null +++ b/lnbits/extensions/watchonly/templates/watchonly/index.html @@ -0,0 +1,648 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + {% raw %} + New wallet + + +
+ Point to another Mempool + {{ this.mempool.endpoint }} + + +
+ set + cancel +
+
+
+
+
+
+ + + +
+
+
Wallets
+
+
+ + + +
+
+ + + + +
+
+ + +
+
{{satBtc(utxos.total)}}
+ + {{utxos.sats ? ' sats' : ' BTC'}} +
+
+ + + +
+
+
Transactions
+
+
+ + + +
+
+ + + + +
+
+
+ + {% endraw %} + +
+ + +
+ {{SITE_TITLE}} Watch Only Extension +
+
+ + + {% include "watchonly/_api_docs.html" %} + +
+
+ + + + + + + + +
+ Create Watch-only Wallet + Cancel +
+
+
+
+ + + + {% raw %} +
Addresses
+
+

+ Current: + {{ currentaddress }} + +

+ + + +

+ + + + {{ data.address }} + + + + +

+ +
+ Get fresh address + Close +
+
+
+ {% endraw %} +
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + +{% endblock %} diff --git a/lnbits/extensions/watchonly/views.py b/lnbits/extensions/watchonly/views.py new file mode 100644 index 00000000..c56f0b9c --- /dev/null +++ b/lnbits/extensions/watchonly/views.py @@ -0,0 +1,32 @@ +from http import HTTPStatus +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from starlette.requests import Request +from fastapi.params import Depends + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import watchonly_ext, watchonly_renderer +# from .crud import get_payment + +from fastapi.templating import Jinja2Templates + +templates = Jinja2Templates(directory="templates") + + +@watchonly_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return watchonly_renderer().TemplateResponse("watchonly/index.html", {"request": request,"user": user.dict()}) + + +# @watchonly_ext.get("/{charge_id}", response_class=HTMLResponse) +# async def display(request: Request, charge_id): +# link = get_payment(charge_id) +# if not link: +# raise HTTPException( +# status_code=HTTPStatus.NOT_FOUND, +# detail="Charge link does not exist." +# ) +# +# return watchonly_renderer().TemplateResponse("watchonly/display.html", {"request": request,"link": link.dict()}) diff --git a/lnbits/extensions/watchonly/views_api.py b/lnbits/extensions/watchonly/views_api.py new file mode 100644 index 00000000..8b3d92c5 --- /dev/null +++ b/lnbits/extensions/watchonly/views_api.py @@ -0,0 +1,132 @@ +import hashlib +from http import HTTPStatus +import httpx +import json + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type + +from fastapi import Query +from fastapi.params import Depends +from starlette.exceptions import HTTPException +from .models import CreateWallet + +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.get("/api/v1/wallet") +async def api_wallets_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): + + try: + return [wallet.dict() for wallet in await get_watch_wallets(wallet.wallet.user)] + except: + return "" + + +@watchonly_ext.get("/api/v1/wallet/{wallet_id}") +async def api_wallet_retrieve(wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)): + w_wallet = await get_watch_wallet(wallet_id) + + if not w_wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Wallet does not exist." + ) + + return w_wallet.dict() + + +@watchonly_ext.post("/api/v1/wallet") +async def api_wallet_create_or_update(data: CreateWallet, wallet_id=None, w: WalletTypeInfo = Depends(get_key_type)): + try: + wallet = await create_watch_wallet( + user=w.wallet.user, masterpub=data.masterpub, title=data.title + ) + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(e) + ) + + mempool = await get_mempool(w.wallet.user) + if not mempool: + create_mempool(user=w.wallet.user) + return wallet.dict() + + +@watchonly_ext.delete("/api/v1/wallet/{wallet_id}") +async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(get_key_type)): + wallet = await get_watch_wallet(wallet_id) + + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Wallet does not exist." + ) + + await delete_watch_wallet(wallet_id) + + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +#############################ADDRESSES########################## + + +@watchonly_ext.get("/api/v1/address/{wallet_id}") +async def api_fresh_address(wallet_id, w: WalletTypeInfo = Depends(get_key_type)): + await get_fresh_address(wallet_id) + + addresses = await get_addresses(wallet_id) + + return [address.dict() for address in addresses] + + +@watchonly_ext.get("/api/v1/addresses/{wallet_id}") + +async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)): + wallet = await get_watch_wallet(wallet_id) + + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Wallet does not exist." + ) + + addresses = await get_addresses(wallet_id) + + if not addresses: + await get_fresh_address(wallet_id) + addresses = await get_addresses(wallet_id) + + return [address.dict() for address in addresses] + + +#############################MEMPOOL########################## + + +@watchonly_ext.put("/api/v1/mempool") +async def api_update_mempool(endpoint: str = Query(...), w: WalletTypeInfo = Depends(get_key_type)): + mempool = await update_mempool(endpoint, user=w.wallet.user) + return mempool.dict() + + +@watchonly_ext.get("/api/v1/mempool") +async def api_get_mempool(w: WalletTypeInfo = Depends(get_key_type)): + mempool = await get_mempool(w.wallet.user) + if not mempool: + mempool = await create_mempool(user=w.wallet.user) + return mempool.dict() diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py index dadc52e0..3b5f42fc 100644 --- a/lnbits/extensions/withdraw/lnurl.py +++ b/lnbits/extensions/withdraw/lnurl.py @@ -1,11 +1,11 @@ +from fastapi.param_functions import Query +from fastapi import HTTPException import shortuuid # type: ignore from http import HTTPStatus from datetime import datetime from lnbits.core.services import pay_invoice -from fastapi.param_functions import Query from starlette.requests import Request -from starlette.exceptions import HTTPException from . import withdraw_ext from .crud import get_withdraw_link_by_hash, update_withdraw_link @@ -39,60 +39,19 @@ async def api_lnurl_response(request: Request, unique_hash): return link.lnurl_response(request).dict() -# FOR LNURLs WHICH ARE UNIQUE - - -@withdraw_ext.get("/api/v1/lnurl/{unique_hash}/{id_unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_multi_response") -async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash): - link = await get_withdraw_link_by_hash(unique_hash) - - if not link: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="LNURL-withdraw not found." - ) - # return ( - # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, - # HTTPStatus.OK, - # ) - - if link.is_spent: - raise HTTPException( - # WHAT STATUS_CODE TO USE?? - detail="Withdraw is spent." - ) - # return ( - # {"status": "ERROR", "reason": "Withdraw is spent."}, - # HTTPStatus.OK, - # ) - - useslist = link.usescsv.split(",") - found = False - for x in useslist: - tohash = link.id + link.unique_hash + str(x) - if id_unique_hash == shortuuid.uuid(name=tohash): - found = True - if not found: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="LNURL-withdraw not found." - ) - # return ( - # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, - # HTTPStatus.OK, - # ) - - return link.lnurl_response(req=request).dict() - # CALLBACK -@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_callback") -async def api_lnurl_callback(unique_hash, k1: str = Query(...), pr: str = Query(...)): +@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback") +async def api_lnurl_callback(request: Request, + unique_hash: str=Query(...), + k1: str = Query(...), + payment_request: str = Query(..., alias="pr") + ): link = await get_withdraw_link_by_hash(unique_hash) - payment_request = pr now = int(datetime.now().timestamp()) + if not link: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, @@ -163,3 +122,48 @@ async def api_lnurl_callback(unique_hash, k1: str = Query(...), pr: str = Query( return {"status": "ERROR", "reason": str(e)} return {"status": "OK"} + +# FOR LNURLs WHICH ARE UNIQUE + + +@withdraw_ext.get("/api/v1/lnurl/{unique_hash}/{id_unique_hash}", status_code=HTTPStatus.OK, name="withdraw.api_lnurl_multi_response") +async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash): + link = await get_withdraw_link_by_hash(unique_hash) + + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="LNURL-withdraw not found." + ) + # return ( + # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, + # HTTPStatus.OK, + # ) + + if link.is_spent: + raise HTTPException( + # WHAT STATUS_CODE TO USE?? + detail="Withdraw is spent." + ) + # return ( + # {"status": "ERROR", "reason": "Withdraw is spent."}, + # HTTPStatus.OK, + # ) + + useslist = link.usescsv.split(",") + found = False + for x in useslist: + tohash = link.id + link.unique_hash + str(x) + if id_unique_hash == shortuuid.uuid(name=tohash): + found = True + if not found: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="LNURL-withdraw not found." + ) + # return ( + # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, + # HTTPStatus.OK, + # ) + + return link.lnurl_response(request).dict()