From 3aed85e51d4988f62244fa8c76a23c8713e720fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Wed, 28 Aug 2024 12:55:32 +0200 Subject: [PATCH] feat: make extra more versatile and use satspay config instead of onchainwallet config (#59) --- crud.py | 9 +---- helpers.py | 46 ++++++++------------- migrations.py | 11 +++++ models.py | 35 ++++------------ static/js/display.js | 11 ++--- static/js/index.js | 35 +++++----------- tasks.py | 5 +-- templates/satspay/display.html | 2 +- templates/satspay/index.html | 1 + views.py | 14 +++++-- views_api.py | 74 ++++++++++++++++++++++++++-------- 11 files changed, 121 insertions(+), 122 deletions(-) diff --git a/crud.py b/crud.py index 9179d9a..7a4772b 100644 --- a/crud.py +++ b/crud.py @@ -1,4 +1,3 @@ -import json from typing import Optional from lnbits.core.services import create_invoice @@ -11,7 +10,6 @@ CreateSatsPayTheme, SatspaySettings, SatsPayTheme, - WalletAccountConfig, ) db = Database("ext_satspay") @@ -20,17 +18,12 @@ async def create_charge( user: str, data: CreateCharge, - config: Optional[WalletAccountConfig] = None, onchainaddress: Optional[str] = None, ) -> Charge: - data = CreateCharge(**data.dict()) charge_id = urlsafe_short_hash() if data.onchainwallet: - if not onchainaddress or not config: + if not onchainaddress: raise Exception(f"Wallet '{data.onchainwallet}' can no longer be accessed.") - data.extra = json.dumps( - {"mempool_endpoint": config.mempool_endpoint, "network": config.network} - ) assert data.amount, "Amount is required" if data.lnbitswallet: diff --git a/helpers.py b/helpers.py index e161a8a..7d0a741 100644 --- a/helpers.py +++ b/helpers.py @@ -1,11 +1,10 @@ -import json - import httpx from lnbits.core.crud import get_standalone_payment from lnbits.settings import settings from loguru import logger -from .models import Charge, OnchainBalance, WalletAccountConfig +from .crud import get_or_create_satspay_settings +from .models import Charge, OnchainBalance async def call_webhook(charge: Charge): @@ -36,20 +35,10 @@ async def call_webhook(charge: Charge): return {"webhook_success": False, "webhook_message": str(e)} -def get_endpoint(charge: Charge) -> str: - assert charge.config.mempool_endpoint, "No mempool endpoint configured" - return ( - f"{charge.config.mempool_endpoint}/testnet" - if charge.config.network == "Testnet" - else charge.config.mempool_endpoint or "" - ) - - -async def fetch_onchain_balance( - onchain_address: str, mempool_endpoint: str -) -> OnchainBalance: +async def fetch_onchain_balance(onchain_address: str) -> OnchainBalance: + settings = await get_or_create_satspay_settings() async with httpx.AsyncClient() as client: - res = await client.get(f"{mempool_endpoint}/api/address/{onchain_address}") + res = await client.get(f"{settings.mempool_url}/api/address/{onchain_address}") res.raise_for_status() data = res.json() confirmed = data["chain_stats"]["funded_txo_sum"] @@ -57,29 +46,28 @@ async def fetch_onchain_balance( return OnchainBalance(confirmed=confirmed, unconfirmed=unconfirmed) -async def fetch_onchain_config( - wallet_id: str, api_key: str -) -> tuple[WalletAccountConfig, str]: +async def fetch_onchain_config_network(api_key: str) -> str: async with httpx.AsyncClient() as client: - headers = {"X-API-KEY": api_key} r = await client.get( url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config", - headers=headers, + headers={"X-API-KEY": api_key}, ) r.raise_for_status() config = r.json() + return config["network"] + +async def fetch_onchain_address(wallet_id: str, api_key: str) -> str: + async with httpx.AsyncClient() as client: r = await client.get( url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/address/{wallet_id}", - headers=headers, + headers={"X-API-KEY": api_key}, ) r.raise_for_status() address_data = r.json() - - if not address_data: + if not address_data and "address" not in address_data: raise ValueError("Cannot fetch new address!") - - return WalletAccountConfig.parse_obj(config), address_data["address"] + return address_data["address"] async def check_charge_balance(charge: Charge) -> Charge: @@ -95,9 +83,7 @@ async def check_charge_balance(charge: Charge) -> Charge: if charge.onchainaddress: try: - balance = await fetch_onchain_balance( - charge.onchainaddress, charge.config.mempool_endpoint - ) + balance = await fetch_onchain_balance(charge.onchainaddress) if ( balance.confirmed != charge.balance or balance.unconfirmed != charge.pending @@ -115,6 +101,6 @@ async def check_charge_balance(charge: Charge) -> Charge: if charge.webhook: resp = await call_webhook(charge) - charge.extra = json.dumps({**charge.config.dict(), **resp}) + charge.add_extra(resp) return charge diff --git a/migrations.py b/migrations.py index 58c2513..72ead72 100644 --- a/migrations.py +++ b/migrations.py @@ -175,3 +175,14 @@ async def m011_persist_paid(db): ) except OperationalError: pass + + +async def m012_add_setting_network(db): + """ + Add 'network' column for storing the network + """ + try: + await db.execute("ALTER TABLE satspay.settings ADD COLUMN network TEXT") + await db.execute("UPDATE satspay.settings SET network = 'Mainnet'") + except OperationalError: + pass diff --git a/models.py b/models.py index adf7a3e..22496d5 100644 --- a/models.py +++ b/models.py @@ -7,14 +7,10 @@ from fastapi.param_functions import Query from pydantic import BaseModel -DEFAULT_MEMPOOL_ENDPOINT = "https://mempool.space" -DEFAULT_MEMPOOL_CONFIG = ( - '{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}' -) - class SatspaySettings(BaseModel): - mempool_url: str = DEFAULT_MEMPOOL_ENDPOINT + mempool_url: str = "https://mempool.space" + network: str = "Mainnet" class CreateCharge(BaseModel): @@ -28,18 +24,10 @@ class CreateCharge(BaseModel): time: int = Query(..., ge=1) amount: Optional[int] = Query(None, ge=1) zeroconf: bool = Query(False) - extra: str = DEFAULT_MEMPOOL_CONFIG custom_css: Optional[str] = Query(None) currency: str = Query(None) currency_amount: Optional[float] = Query(None) - - -class ChargeConfig(BaseModel): - mempool_endpoint: str - network: Optional[str] - webhook_message: Optional[str] - webhook_success: bool = False - misc: dict = {} + extra: Optional[str] = Query(None) class Charge(BaseModel): @@ -55,7 +43,6 @@ class Charge(BaseModel): completelink: Optional[str] completelinktext: Optional[str] = "Back to Merchant" custom_css: Optional[str] - extra: str = DEFAULT_MEMPOOL_CONFIG time: int amount: int zeroconf: bool @@ -66,11 +53,11 @@ class Charge(BaseModel): currency: Optional[str] = None currency_amount: Optional[float] = None paid: bool = False + extra: Optional[str] = None - @property - def config(self) -> ChargeConfig: - charge_config = json.loads(self.extra) - return ChargeConfig(**charge_config) + def add_extra(self, extra: dict): + old_extra = json.loads(self.extra) if self.extra else {} + self.extra = json.dumps({**old_extra, **extra}) @property def public(self): @@ -110,14 +97,6 @@ class SatsPayTheme(BaseModel): user: str -class WalletAccountConfig(BaseModel): - mempool_endpoint: str - receive_gap_limit: int - change_gap_limit: int - sats_denominated: bool - network: str - - class OnchainBalance(BaseModel): confirmed: int unconfirmed: int diff --git a/static/js/display.js b/static/js/display.js index 6f3f7c4..58f0d10 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -5,7 +5,7 @@ new Vue({ data() { return { charge: mapCharge(charge_data), - network: network, + mempool_url: mempool_url, ws: null, wallet: { inkey: '' @@ -15,12 +15,9 @@ new Vue({ }, computed: { mempoolLink() { - const onchainaddress = this.charge.onchainaddress - if (this.network === 'Testnet') { - return `https://mempool.space/testnet/address/${onchainaddress}` - } else { - return `https://mempool.space/address/${onchainaddress}` - } + // remove trailing slash + const url = this.mempool_url.replace(/\/$/, '') + return `${url}/address/${this.charge.onchainaddress}` }, unifiedQR() { const bitcoin = (this.charge.onchainaddress || '').toUpperCase() diff --git a/static/js/index.js b/static/js/index.js index 6e510fe..06a2d3b 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -10,8 +10,13 @@ new Vue({ return { currencies: [], fiatRates: {}, - settings: {}, settings: [ + { + type: 'str', + description: + 'Network used by OnchainWallet extension Wallet. default: `Mainnet`, or `Testnet` for testnet', + name: 'network' + }, { type: 'str', description: @@ -21,6 +26,7 @@ new Vue({ ], filter: '', admin: admin, + network: network, balance: null, walletLinks: [], chargeLinks: [], @@ -28,12 +34,7 @@ new Vue({ themeOptions: [], onchainwallet: '', rescanning: false, - mempool: { - endpoint: '', - network: 'Mainnet' - }, showAdvanced: false, - chargesTable: { columns: [ { @@ -171,11 +172,12 @@ new Vue({ getWalletLinks: async function () { try { - const {data} = await LNbits.api.request( + let {data} = await LNbits.api.request( 'GET', - `/watchonly/api/v1/wallet?network=${this.mempool.network}`, + `/watchonly/api/v1/wallet?network=${this.network}`, this.g.user.wallets[0].adminkey ) + data = data.filter(w => w.network === this.network) this.walletLinks = data.map(w => ({ id: w.id, label: w.title + ' - ' + w.id @@ -184,22 +186,6 @@ new Vue({ LNbits.utils.notifyApiError(error) } }, - - getWalletConfig: async function () { - try { - const {data} = await LNbits.api.request( - 'GET', - '/watchonly/api/v1/config', - this.g.user.wallets[0].inkey - ) - this.mempool.endpoint = data.mempool_endpoint - this.mempool.network = data.network || 'Mainnet' - const url = new URL(this.mempool.endpoint) - this.mempool.hostname = url.hostname - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, getOnchainWalletName: function (walletId) { const wallet = this.walletLinks.find(w => w.id === walletId) if (!wallet) return 'unknown' @@ -426,7 +412,6 @@ new Vue({ await this.getThemes() } await this.getCharges() - await this.getWalletConfig() await this.getWalletLinks() LNbits.api .request('GET', '/api/v1/currencies') diff --git a/tasks.py b/tasks.py index 6fbfaf0..d67a364 100644 --- a/tasks.py +++ b/tasks.py @@ -1,5 +1,4 @@ import asyncio -import json from fastapi import WebSocket from lnbits.core.models import Payment @@ -56,7 +55,7 @@ async def on_invoice_paid(payment: Payment) -> None: await send_success_websocket(charge) if charge.webhook: resp = await call_webhook(charge) - charge.extra = json.dumps({**charge.config.dict(), **resp}) + charge.add_extra(resp) await update_charge(charge) @@ -111,5 +110,5 @@ async def _handle_ws_message(address: str, data: dict): stop_onchain_listener(address) if charge.webhook: resp = await call_webhook(charge) - charge.extra = json.dumps({**charge.config.dict(), **resp}) + charge.add_extra(resp) await update_charge(charge) diff --git a/templates/satspay/display.html b/templates/satspay/display.html index 94d511e..3c612d5 100644 --- a/templates/satspay/display.html +++ b/templates/satspay/display.html @@ -196,7 +196,7 @@ {% endblock %} {% block scripts %} diff --git a/templates/satspay/index.html b/templates/satspay/index.html index ec79da0..4ae8577 100644 --- a/templates/satspay/index.html +++ b/templates/satspay/index.html @@ -618,6 +618,7 @@
{% endblock %} {% block scripts %} {{ window_vars(user) }} diff --git a/views.py b/views.py index 241a6d7..946c812 100644 --- a/views.py +++ b/views.py @@ -17,7 +17,7 @@ from lnbits.settings import settings from starlette.responses import HTMLResponse -from .crud import get_charge, get_theme +from .crud import get_charge, get_or_create_satspay_settings, get_theme from .tasks import public_ws_listeners templates = Jinja2Templates(directory="templates") @@ -30,9 +30,15 @@ def satspay_renderer(): @satspay_generic_router.get("/", response_class=HTMLResponse) async def index(request: Request, user: User = Depends(check_user_exists)): + settings = await get_or_create_satspay_settings() return satspay_renderer().TemplateResponse( "satspay/index.html", - {"request": request, "user": user.dict(), "admin": user.admin}, + { + "request": request, + "user": user.dict(), + "admin": user.admin, + "network": settings.network, + }, ) @@ -43,14 +49,14 @@ async def display_charge(request: Request, charge_id: str): raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist." ) + settings = await get_or_create_satspay_settings() return satspay_renderer().TemplateResponse( "satspay/display.html", { "request": request, "charge_data": json.dumps(charge.public), "custom_css": charge.custom_css, - "mempool_endpoint": charge.config.mempool_endpoint, - "network": charge.config.network, + "mempool_url": settings.mempool_url, }, ) diff --git a/views_api.py b/views_api.py index c4a1524..eebee66 100644 --- a/views_api.py +++ b/views_api.py @@ -1,7 +1,8 @@ from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException -from lnbits.core.models import WalletTypeInfo +from lnbits.core.crud import get_wallet +from lnbits.core.models import Wallet, WalletTypeInfo from lnbits.decorators import ( check_admin, require_admin_key, @@ -20,16 +21,32 @@ update_charge, update_satspay_settings, ) -from .helpers import check_charge_balance, fetch_onchain_config +from .helpers import ( + check_charge_balance, + fetch_onchain_address, + fetch_onchain_config_network, +) from .models import Charge, CreateCharge, SatspaySettings from .tasks import start_onchain_listener, stop_onchain_listener satspay_api_router = APIRouter() +async def _get_wallet_network(wallet: Wallet) -> str: + try: + network = await fetch_onchain_config_network(wallet.inkey) + except Exception as exc: + logger.error(f"Error fetching onchain config: {exc!s}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Error fetching onchain config.", + ) from exc + return network + + @satspay_api_router.post("/api/v1/charge") async def api_charge_create( - data: CreateCharge, wallet: WalletTypeInfo = Depends(require_invoice_key) + data: CreateCharge, key_type: WalletTypeInfo = Depends(require_invoice_key) ) -> Charge: if not data.amount and not data.currency_amount: raise HTTPException( @@ -39,21 +56,48 @@ async def api_charge_create( if data.currency and data.currency_amount: rate = await get_fiat_rate_satoshis(data.currency) data.amount = round(rate * data.currency_amount) + if not data.onchainwallet and not data.lnbitswallet: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="either onchainwallet or lnbitswallet are required.", + ) + if data.lnbitswallet: + lnbitswallet = await get_wallet(data.lnbitswallet) + if not lnbitswallet: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="LNbits wallet does not exist.", + ) + if lnbitswallet.user != key_type.wallet.user: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="LNbits wallet does not belong to you.", + ) if data.onchainwallet: + settings = await get_or_create_satspay_settings() + network = await _get_wallet_network(key_type.wallet) + if network != settings.network: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Onchain network mismatch. {network} != {settings.network}", + ) try: - config, new_address = await fetch_onchain_config( - data.onchainwallet, wallet.wallet.inkey + new_address = await fetch_onchain_address( + data.onchainwallet, key_type.wallet.inkey ) start_onchain_listener(new_address) return await create_charge( - user=wallet.wallet.user, + user=key_type.wallet.user, onchainaddress=new_address, data=data, - config=config, ) except Exception as exc: logger.error(f"Error fetching onchain config: {exc}") - return await create_charge(user=wallet.wallet.user, data=data) + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Error fetching onchain address.", + ) from exc + return await create_charge(user=key_type.wallet.user, data=data) @satspay_api_router.get("/api/v1/charges") @@ -63,18 +107,16 @@ async def api_charges_retrieve( return await get_charges(wallet.wallet.user) -""" -This endpoint is used by the woocommerce plugin to check if the status of a charge -is paid. you can refresh the success page of the webshop to trigger this endpoint. -useful if the webhook is not working or fails for some reason. -https://github.com/lnbits/woocommerce-payment-gateway/blob/main/lnbits.php#L312 -""" - - @satspay_api_router.get( "/api/v1/charge/{charge_id}", dependencies=[Depends(require_invoice_key)] ) async def api_charge_retrieve(charge_id: str) -> dict: + """ + This endpoint is used by the woocommerce plugin to check if the status of a charge + is paid. you can refresh the success page of the webshop to trigger this endpoint. + useful if the webhook is not working or fails for some reason. + https://github.com/lnbits/woocommerce-payment-gateway/blob/main/lnbits.php#L312 + """ charge = await get_charge(charge_id) if not charge: raise HTTPException(