diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py index 23d391b7b..968c9ab01 100644 --- a/lnbits/extensions/satspay/crud.py +++ b/lnbits/extensions/satspay/crud.py @@ -1,15 +1,15 @@ +import json from typing import List, Optional -import httpx +from loguru import logger from lnbits.core.services import create_invoice from lnbits.core.views.api import api_payment from lnbits.helpers import urlsafe_short_hash from ..watchonly.crud import get_config, get_fresh_address - -# from lnbits.db import open_ext_db from . import db +from .helpers import fetch_onchain_balance from .models import Charges, CreateCharge ###############CHARGES########################## @@ -18,6 +18,10 @@ from .models import Charges, CreateCharge async def create_charge(user: str, data: CreateCharge) -> Charges: charge_id = urlsafe_short_hash() if data.onchainwallet: + config = await get_config(user) + data.extra = json.dumps( + {"mempool_endpoint": config.mempool_endpoint, "network": config.network} + ) onchain = await get_fresh_address(data.onchainwallet) onchainaddress = onchain.address else: @@ -48,9 +52,10 @@ async def create_charge(user: str, data: CreateCharge) -> Charges: completelinktext, time, amount, - balance + balance, + extra ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( charge_id, @@ -67,6 +72,7 @@ async def create_charge(user: str, data: CreateCharge) -> Charges: data.time, data.amount, 0, + data.extra, ), ) return await get_charge(charge_id) @@ -98,34 +104,20 @@ 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]: +async def check_address_balance(charge_id: str) -> Optional[Charges]: charge = await get_charge(charge_id) + if not charge.paid: if charge.onchainaddress: - config = await get_charge_config(charge_id) try: - async with httpx.AsyncClient() as client: - r = await client.get( - config.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 + respAmount = await fetch_onchain_balance(charge) + if respAmount > charge.balance: + await update_charge(charge_id=charge_id, balance=respAmount) + except Exception as e: + logger.warning(e) if charge.lnbitswallet: invoice_status = await api_payment(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 - - -async def get_charge_config(charge_id: str): - row = await db.fetchone( - """SELECT "user" FROM satspay.charges WHERE id = ?""", (charge_id,) - ) - return await get_config(row.user) + return await get_charge(charge_id) diff --git a/lnbits/extensions/satspay/helpers.py b/lnbits/extensions/satspay/helpers.py index 2d15b5578..2aa83e1f5 100644 --- a/lnbits/extensions/satspay/helpers.py +++ b/lnbits/extensions/satspay/helpers.py @@ -1,8 +1,11 @@ +import httpx +from loguru import logger + from .models import Charges -def compact_charge(charge: Charges): - return { +def public_charge(charge: Charges): + c = { "id": charge.id, "description": charge.description, "onchainaddress": charge.onchainaddress, @@ -13,5 +16,38 @@ def compact_charge(charge: Charges): "balance": charge.balance, "paid": charge.paid, "timestamp": charge.timestamp, - "completelink": charge.completelink, # should be secret? + "time_elapsed": charge.time_elapsed, + "time_left": charge.time_left, + "paid": charge.paid, } + + if charge.paid: + c["completelink"] = charge.completelink + + return c + + +async def call_webhook(charge: Charges): + async with httpx.AsyncClient() as client: + try: + r = await client.post( + charge.webhook, + json=public_charge(charge), + timeout=40, + ) + return {"webhook_success": r.is_success, "webhook_message": r.reason_phrase} + except Exception as e: + logger.warning(f"Failed to call webhook for charge {charge.id}") + logger.warning(e) + return {"webhook_success": False, "webhook_message": str(e)} + + +async def fetch_onchain_balance(charge: Charges): + endpoint = ( + f"{charge.config.mempool_endpoint}/testnet" + if charge.config.network == "Testnet" + else charge.config.mempool_endpoint + ) + async with httpx.AsyncClient() as client: + r = await client.get(endpoint + "/api/address/" + charge.onchainaddress) + return r.json()["chain_stats"]["funded_txo_sum"] diff --git a/lnbits/extensions/satspay/migrations.py b/lnbits/extensions/satspay/migrations.py index 87446c800..2579961f5 100644 --- a/lnbits/extensions/satspay/migrations.py +++ b/lnbits/extensions/satspay/migrations.py @@ -26,3 +26,14 @@ async def m001_initial(db): ); """ ) + + +async def m002_add_charge_extra_data(db): + """ + Add 'extra' column for storing various config about the charge (JSON format) + """ + await db.execute( + """ALTER TABLE satspay.charges + ADD COLUMN extra TEXT DEFAULT '{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}'; + """ + ) diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py index daf63f429..1e7c95c99 100644 --- a/lnbits/extensions/satspay/models.py +++ b/lnbits/extensions/satspay/models.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timedelta from sqlite3 import Row from typing import Optional @@ -15,6 +16,14 @@ class CreateCharge(BaseModel): completelinktext: str = Query(None) time: int = Query(..., ge=1) amount: int = Query(..., ge=1) + extra: str = "{}" + + +class ChargeConfig(BaseModel): + mempool_endpoint: Optional[str] + network: Optional[str] + webhook_success: Optional[bool] = False + webhook_message: Optional[str] class Charges(BaseModel): @@ -28,6 +37,7 @@ class Charges(BaseModel): webhook: Optional[str] completelink: Optional[str] completelinktext: Optional[str] = "Back to Merchant" + extra: str = "{}" time: int amount: int balance: int @@ -54,3 +64,11 @@ class Charges(BaseModel): return True else: return False + + @property + def config(self) -> ChargeConfig: + charge_config = json.loads(self.extra) + return ChargeConfig(**charge_config) + + def must_call_webhook(self): + return self.webhook and self.paid and self.config.webhook_success == False diff --git a/lnbits/extensions/satspay/static/js/utils.js b/lnbits/extensions/satspay/static/js/utils.js index 9b4abbfca..929279554 100644 --- a/lnbits/extensions/satspay/static/js/utils.js +++ b/lnbits/extensions/satspay/static/js/utils.js @@ -14,15 +14,14 @@ const retryWithDelay = async function (fn, retryCount = 0) { } const mapCharge = (obj, oldObj = {}) => { - const charge = _.clone(obj) + const charge = {...oldObj, ...obj} charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time charge.time = minutesToTime(obj.time) charge.timeLeft = minutesToTime(obj.time_left) - charge.expanded = false charge.displayUrl = ['/satspay/', obj.id].join('') - charge.expanded = oldObj.expanded + charge.expanded = oldObj.expanded || false charge.pendingBalance = oldObj.pendingBalance || 0 return charge } diff --git a/lnbits/extensions/satspay/tasks.py b/lnbits/extensions/satspay/tasks.py index 46c16bbc9..ce54b44a2 100644 --- a/lnbits/extensions/satspay/tasks.py +++ b/lnbits/extensions/satspay/tasks.py @@ -1,4 +1,5 @@ import asyncio +import json from loguru import logger @@ -7,7 +8,8 @@ from lnbits.extensions.satspay.crud import check_address_balance, get_charge from lnbits.helpers import get_current_extension_name from lnbits.tasks import register_invoice_listener -# from .crud import get_ticket, set_ticket_paid +from .crud import update_charge +from .helpers import call_webhook async def wait_for_paid_invoices(): @@ -30,4 +32,9 @@ async def on_invoice_paid(payment: Payment) -> None: return await payment.set_pending(False) - await check_address_balance(charge_id=charge.id) + charge = await check_address_balance(charge_id=charge.id) + + if charge.must_call_webhook(): + resp = await call_webhook(charge) + extra = {**charge.config.dict(), **resp} + await update_charge(charge_id=charge.id, extra=json.dumps(extra)) diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html index 12288c809..a24ed84c7 100644 --- a/lnbits/extensions/satspay/templates/satspay/display.html +++ b/lnbits/extensions/satspay/templates/satspay/display.html @@ -109,7 +109,7 @@ @@ -131,7 +131,7 @@ @@ -170,13 +170,17 @@ name="check" style="color: green; font-size: 21.4em" > - +
+
+ +
+
@@ -218,7 +222,7 @@
@@ -303,7 +311,8 @@ data() { return { charge: JSON.parse('{{charge_data | tojson}}'), - mempool_endpoint: '{{mempool_endpoint}}', + mempoolEndpoint: '{{mempool_endpoint}}', + network: '{{network}}', pendingFunds: 0, ws: null, newProgress: 0.4, @@ -316,19 +325,19 @@ cancelListener: () => {} } }, + computed: { + mempoolHostname: function () { + let hostname = new URL(this.mempoolEndpoint).hostname + if (this.network === 'Testnet') { + hostname += '/testnet' + } + return hostname + } + }, methods: { - startPaymentNotifier() { - this.cancelListener() - if (!this.lnbitswallet) return - this.cancelListener = LNbits.events.onInvoicePaid( - this.wallet, - payment => { - this.checkInvoiceBalance() - } - ) - }, checkBalances: async function () { - if (this.charge.hasStaleBalance) return + if (!this.charge.payment_request && this.charge.hasOnchainStaleBalance) + return try { const {data} = await LNbits.api.request( 'GET', @@ -345,7 +354,7 @@ const { bitcoin: {addresses: addressesAPI} } = mempoolJS({ - hostname: new URL(this.mempool_endpoint).hostname + hostname: new URL(this.mempoolEndpoint).hostname }) try { @@ -353,7 +362,8 @@ address: this.charge.onchainaddress }) const newBalance = utxos.reduce((t, u) => t + u.value, 0) - this.charge.hasStaleBalance = this.charge.balance === newBalance + this.charge.hasOnchainStaleBalance = + this.charge.balance === newBalance this.pendingFunds = utxos .filter(u => !u.status.confirmed) @@ -388,10 +398,10 @@ const { bitcoin: {websocket} } = mempoolJS({ - hostname: new URL(this.mempool_endpoint).hostname + hostname: new URL(this.mempoolEndpoint).hostname }) - this.ws = new WebSocket('wss://mempool.space/api/v1/ws') + this.ws = new WebSocket(`wss://${this.mempoolHostname}/api/v1/ws`) this.ws.addEventListener('open', x => { if (this.charge.onchainaddress) { this.trackAddress(this.charge.onchainaddress) @@ -428,13 +438,10 @@ } }, created: async function () { - if (this.charge.lnbitswallet) this.payInvoice() + if (this.charge.payment_request) this.payInvoice() else this.payOnchain() - await this.checkBalances() - // empty for onchain - this.wallet.inkey = '{{ wallet_inkey }}' - this.startPaymentNotifier() + await this.checkBalances() if (!this.charge.paid) { this.loopRefresh() diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html index 396200cf1..60c4d5199 100644 --- a/lnbits/extensions/satspay/templates/satspay/index.html +++ b/lnbits/extensions/satspay/templates/satspay/index.html @@ -203,9 +203,14 @@ :href="props.row.webhook" target="_blank" style="color: unset; text-decoration: none" - >{{props.row.webhook || props.row.webhook}}{{props.row.webhook}}
+
+ + {{props.row.webhook_message }} + +
ID:
@@ -409,10 +414,11 @@ balance: null, walletLinks: [], chargeLinks: [], - onchainwallet: '', + onchainwallet: null, rescanning: false, mempool: { - endpoint: '' + endpoint: '', + network: 'Mainnet' }, chargesTable: { @@ -505,6 +511,7 @@ methods: { cancelCharge: function (data) { this.formDialogCharge.data.description = '' + this.formDialogCharge.data.onchain = false this.formDialogCharge.data.onchainwallet = '' this.formDialogCharge.data.lnbitswallet = '' this.formDialogCharge.data.time = null @@ -518,7 +525,7 @@ try { const {data} = await LNbits.api.request( 'GET', - '/watchonly/api/v1/wallet', + `/watchonly/api/v1/wallet?network=${this.mempool.network}`, this.g.user.wallets[0].inkey ) this.walletLinks = data.map(w => ({ @@ -538,6 +545,7 @@ 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) { @@ -577,7 +585,8 @@ const data = this.formDialogCharge.data data.amount = parseInt(data.amount) data.time = parseInt(data.time) - data.onchainwallet = this.onchainwallet?.id + data.lnbitswallet = data.lnbits ? data.lnbitswallet : null + data.onchainwallet = data.onchain ? this.onchainwallet?.id : null this.createCharge(wallet, data) }, refreshActiveChargesBalance: async function () { @@ -695,8 +704,8 @@ }, created: async function () { await this.getCharges() - await this.getWalletLinks() await this.getWalletConfig() + await this.getWalletLinks() setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000) await this.rescanOnchainAddresses() setInterval(() => this.rescanOnchainAddresses(), 10 * 1000) diff --git a/lnbits/extensions/satspay/views.py b/lnbits/extensions/satspay/views.py index b789bf8fe..7b769a20e 100644 --- a/lnbits/extensions/satspay/views.py +++ b/lnbits/extensions/satspay/views.py @@ -6,12 +6,12 @@ from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import HTMLResponse -from lnbits.core.crud import get_wallet from lnbits.core.models import User from lnbits.decorators import check_user_exists +from lnbits.extensions.satspay.helpers import public_charge from . import satspay_ext, satspay_renderer -from .crud import get_charge, get_charge_config +from .crud import get_charge templates = Jinja2Templates(directory="templates") @@ -30,18 +30,13 @@ async def display(request: Request, charge_id: str): raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist." ) - wallet = await get_wallet(charge.lnbitswallet) - onchainwallet_config = await get_charge_config(charge_id) - inkey = wallet.inkey if wallet else None - mempool_endpoint = ( - onchainwallet_config.mempool_endpoint if onchainwallet_config else None - ) + return satspay_renderer().TemplateResponse( "satspay/display.html", { "request": request, - "charge_data": charge.dict(), - "wallet_inkey": inkey, - "mempool_endpoint": mempool_endpoint, + "charge_data": public_charge(charge), + "mempool_endpoint": charge.config.mempool_endpoint, + "network": charge.config.network, }, ) diff --git a/lnbits/extensions/satspay/views_api.py b/lnbits/extensions/satspay/views_api.py index e1b87c41f..bfff55a21 100644 --- a/lnbits/extensions/satspay/views_api.py +++ b/lnbits/extensions/satspay/views_api.py @@ -1,6 +1,6 @@ +import json from http import HTTPStatus -import httpx from fastapi.params import Depends from starlette.exceptions import HTTPException @@ -20,7 +20,7 @@ from .crud import ( get_charges, update_charge, ) -from .helpers import compact_charge +from .helpers import call_webhook, public_charge from .models import CreateCharge #############################CHARGES########################## @@ -58,6 +58,7 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): **{"time_elapsed": charge.time_elapsed}, **{"time_left": charge.time_left}, **{"paid": charge.paid}, + **{"webhook_message": charge.config.webhook_message}, } for charge in await get_charges(wallet.wallet.user) ] @@ -119,19 +120,9 @@ async def api_charge_balance(charge_id): 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=compact_charge(charge), - timeout=40, - ) - except AssertionError: - charge.webhook = None - return { - **compact_charge(charge), - **{"time_elapsed": charge.time_elapsed}, - **{"time_left": charge.time_left}, - **{"paid": charge.paid}, - } + if charge.must_call_webhook(): + resp = await call_webhook(charge) + extra = {**charge.config.dict(), **resp} + await update_charge(charge_id=charge.id, extra=json.dumps(extra)) + + return {**public_charge(charge)} diff --git a/lnbits/tasks.py b/lnbits/tasks.py index 94e43dcfd..de3c69aa7 100644 --- a/lnbits/tasks.py +++ b/lnbits/tasks.py @@ -124,7 +124,7 @@ async def check_pending_payments(): while True: async with db.connect() as conn: - logger.debug( + logger.info( f"Task: checking all pending payments (incoming={incoming}, outgoing={outgoing}) of last 15 days" ) start_time: float = time.time() @@ -140,15 +140,15 @@ async def check_pending_payments(): for payment in pending_payments: await payment.check_status(conn=conn) - logger.debug( + logger.info( f"Task: pending check finished for {len(pending_payments)} payments (took {time.time() - start_time:0.3f} s)" ) # we delete expired invoices once upon the first pending check if incoming: - logger.debug("Task: deleting all expired invoices") + logger.info("Task: deleting all expired invoices") start_time: float = time.time() await delete_expired_invoices(conn=conn) - logger.debug( + logger.info( f"Task: expired invoice deletion finished (took {time.time() - start_time:0.3f} s)" )