From de01531f4dafb23f0a1d7c75096248ebf6c20b36 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Thu, 19 Mar 2026 16:52:08 +0000 Subject: [PATCH 01/10] add watchonly-backed onchain payment flow --- __init__.py | 9 +- crud.py | 60 ++++++++++++- migrations.py | 30 +++++++ models.py | 35 ++++++++ services.py | 80 ++++++++++++++++- tasks.py | 154 ++++++++++++++++++++++++++++----- views_api.py | 233 +++++++++++++++++++++++++++++++++++++++++++++----- 7 files changed, 556 insertions(+), 45 deletions(-) diff --git a/__init__.py b/__init__.py index a6e22fb..0f8a435 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,7 @@ from loguru import logger from .crud import db -from .tasks import wait_for_paid_invoices +from .tasks import poll_onchain_payments, wait_for_paid_invoices from .views import tpos_generic_router from .views_api import tpos_api_router from .views_atm import tpos_atm_router @@ -37,8 +37,11 @@ def tpos_stop(): def tpos_start(): from lnbits.tasks import create_permanent_unique_task - task = create_permanent_unique_task("ext_tpos", wait_for_paid_invoices) - scheduled_tasks.append(task) + invoice_task = create_permanent_unique_task("ext_tpos", wait_for_paid_invoices) + onchain_task = create_permanent_unique_task( + "ext_tpos_onchain", poll_onchain_payments + ) + scheduled_tasks.extend([invoice_task, onchain_task]) __all__ = [ diff --git a/crud.py b/crud.py index 33fe23a..e903236 100644 --- a/crud.py +++ b/crud.py @@ -2,7 +2,7 @@ from lnbits.helpers import urlsafe_short_hash from .helpers import serialize_inventory_tags -from .models import CreateTposData, LnurlCharge, Tpos, TposClean +from .models import CreateTposData, LnurlCharge, Tpos, TposClean, TposPayment db = Database("ext_tpos") @@ -70,3 +70,61 @@ async def get_tposs(wallet_ids: str | list[str]) -> list[Tpos]: async def delete_tpos(tpos_id: str) -> None: await db.execute("DELETE FROM tpos.pos WHERE id = :id", {"id": tpos_id}) + await db.execute("DELETE FROM tpos.payments WHERE tpos_id = :id", {"id": tpos_id}) + + +async def create_tpos_payment(payment: TposPayment) -> TposPayment: + await db.insert("tpos.payments", payment) + return payment + + +async def get_tpos_payment(payment_id: str) -> TposPayment | None: + return await db.fetchone( + "SELECT * FROM tpos.payments WHERE id = :id", + {"id": payment_id}, + TposPayment, + ) + + +async def get_tpos_payment_by_hash(payment_hash: str) -> TposPayment | None: + return await db.fetchone( + "SELECT * FROM tpos.payments WHERE payment_hash = :payment_hash", + {"payment_hash": payment_hash}, + TposPayment, + ) + + +async def get_tpos_payment_by_onchain_address(address: str) -> TposPayment | None: + return await db.fetchone( + "SELECT * FROM tpos.payments WHERE onchain_address = :address", + {"address": address}, + TposPayment, + ) + + +async def get_pending_tpos_payments() -> list[TposPayment]: + return await db.fetchall( + """ + SELECT * FROM tpos.payments + WHERE paid = false AND onchain_address IS NOT NULL + ORDER BY created_at ASC + """, + model=TposPayment, + ) + + +async def get_latest_tpos_payments(tpos_id: str, limit: int = 5) -> list[TposPayment]: + return await db.fetchall( + f""" + SELECT * FROM tpos.payments + WHERE tpos_id = :tpos_id AND paid = true + ORDER BY updated_at DESC LIMIT {int(limit)} + """, + {"tpos_id": tpos_id}, + TposPayment, + ) + + +async def update_tpos_payment(payment: TposPayment) -> TposPayment: + await db.update("tpos.payments", payment) + return payment diff --git a/migrations.py b/migrations.py index a569fc0..0df71bb 100644 --- a/migrations.py +++ b/migrations.py @@ -260,3 +260,33 @@ async def m021_add_cash_settlement(db: Database): await db.execute(""" ALTER TABLE tpos.pos ADD allow_cash_settlement BOOLEAN DEFAULT false; """) + + +async def m022_add_onchain_settings_and_payments(db: Database): + await db.execute(""" + ALTER TABLE tpos.pos ADD onchain_enabled BOOLEAN DEFAULT false; + """) + await db.execute(""" + ALTER TABLE tpos.pos ADD onchain_wallet_id TEXT NULL; + """) + await db.execute(""" + ALTER TABLE tpos.pos ADD onchain_zero_conf BOOLEAN DEFAULT true; + """) + await db.execute(""" + CREATE TABLE tpos.payments ( + id TEXT PRIMARY KEY, + tpos_id TEXT NOT NULL, + payment_hash TEXT NOT NULL UNIQUE, + amount INTEGER NOT NULL DEFAULT 0, + paid BOOLEAN DEFAULT false, + payment_method TEXT NULL, + onchain_address TEXT NULL, + onchain_wallet_id TEXT NULL, + onchain_zero_conf BOOLEAN DEFAULT true, + mempool_endpoint TEXT NULL, + balance INTEGER NOT NULL DEFAULT 0, + pending INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """) diff --git a/models.py b/models.py index 2e69f12..faa7fac 100644 --- a/models.py +++ b/models.py @@ -72,6 +72,9 @@ class CreateTposData(BaseModel): stripe_card_payments: bool = False stripe_reader_id: str | None = None allow_cash_settlement: bool = Field(False) + onchain_enabled: bool = Field(False) + onchain_wallet_id: str | None = None + onchain_zero_conf: bool = Field(True) @validator("tax_default", pre=True, always=True) def default_tax_when_none(cls, v): @@ -108,6 +111,9 @@ class TposClean(BaseModel): stripe_card_payments: bool = False stripe_reader_id: str | None = None allow_cash_settlement: bool = False + onchain_enabled: bool = False + onchain_wallet_id: str | None = None + onchain_zero_conf: bool = True @property def withdraw_maximum(self) -> int: @@ -132,6 +138,35 @@ class Tpos(TposClean, BaseModel): tip_wallet: str | None = None +class TposPayment(BaseModel): + id: str + tpos_id: str + payment_hash: str + amount: int = 0 + paid: bool = False + payment_method: str | None = None + onchain_address: str | None = None + onchain_wallet_id: str | None = None + onchain_zero_conf: bool = True + mempool_endpoint: str | None = None + balance: int = 0 + pending: int = 0 + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + +class TposInvoiceResponse(BaseModel): + payment_hash: str + bolt11: str + payment_request: str + tpos_payment_id: str + payment_options: list[str] = Field(default_factory=list) + onchain_address: str | None = None + unified_qr: str | None = None + payment_method: str | None = None + extra: dict[str, Any] = Field(default_factory=dict) + + class LnurlCharge(BaseModel): id: str tpos_id: str diff --git a/services.py b/services.py index b65b4e2..88e0a62 100644 --- a/services.py +++ b/services.py @@ -1,7 +1,11 @@ from typing import Any import httpx -from lnbits.core.crud import get_wallet +from lnbits.core.crud import ( + get_installed_extension, + get_user_active_extensions_ids, + get_wallet, +) from lnbits.core.models import User from lnbits.helpers import create_access_token from lnbits.settings import settings @@ -121,6 +125,80 @@ def inventory_available_for_user(user: User | None) -> bool: return bool(user and "inventory" in (user.extensions or [])) +async def watchonly_available_for_user(user_id: str) -> bool: + installed = await get_installed_extension("watchonly") + if not installed or not installed.active: + return False + active_extensions = await get_user_active_extensions_ids(user_id) + return "watchonly" in active_extensions + + +async def fetch_watchonly_config(api_key: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet", + headers={"X-API-KEY": api_key}, + params={"network": network}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/address/{wallet_id}", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_onchain_balance(mempool_endpoint: str, onchain_address: str) -> dict[str, Any]: + endpoint = (mempool_endpoint or "https://mempool.space").rstrip("/") + async with httpx.AsyncClient() as client: + resp = await client.get(f"{endpoint}/api/address/{onchain_address}/txs") + resp.raise_for_status() + data = resp.json() + confirmed_txs = [tx for tx in data if tx["status"]["confirmed"]] + unconfirmed_txs = [tx for tx in data if not tx["status"]["confirmed"]] + return { + "confirmed": sum_transactions(onchain_address, confirmed_txs), + "unconfirmed": sum_transactions(onchain_address, unconfirmed_txs), + "txids": [tx["txid"] for tx in data], + } + + +def sum_outputs(address: str, vouts: list[dict[str, Any]]) -> int: + return sum( + vout["value"] for vout in vouts if vout.get("scriptpubkey_address") == address + ) + + +def sum_transactions(address: str, txs: list[dict[str, Any]]) -> int: + return sum(sum_outputs(address, tx.get("vout", [])) for tx in txs) + + async def push_order_to_orders( user_id: str, payment, diff --git a/tasks.py b/tasks.py index 5d17ae9..d2f1a0b 100644 --- a/tasks.py +++ b/tasks.py @@ -1,6 +1,8 @@ import asyncio +import json from lnbits.core.crud import get_user_active_extensions_ids, get_wallet +from lnbits.core.crud.payments import get_standalone_payment, update_payment from lnbits.core.models import Payment from lnbits.core.services import ( create_invoice, @@ -11,8 +13,17 @@ from lnbits.tasks import register_invoice_listener from loguru import logger -from .crud import get_tpos -from .services import deduct_inventory_stock, push_order_to_orders +from .crud import ( + get_pending_tpos_payments, + get_tpos, + get_tpos_payment_by_hash, + update_tpos_payment, +) +from .services import ( + deduct_inventory_stock, + fetch_onchain_balance, + push_order_to_orders, +) async def wait_for_paid_invoices(): @@ -24,6 +35,53 @@ async def wait_for_paid_invoices(): await on_invoice_paid(payment) +async def poll_onchain_payments(): + while True: + pending_payments = await get_pending_tpos_payments() + for tpos_payment in pending_payments: + if not tpos_payment.onchain_address or not tpos_payment.mempool_endpoint: + continue + try: + balance = await fetch_onchain_balance( + tpos_payment.mempool_endpoint, tpos_payment.onchain_address + ) + confirmed_balance = int(balance["confirmed"]) + unconfirmed_balance = int(balance["unconfirmed"]) + settled_balance = ( + confirmed_balance + unconfirmed_balance + if tpos_payment.onchain_zero_conf + else confirmed_balance + ) + changed = ( + tpos_payment.balance != settled_balance + or tpos_payment.pending != unconfirmed_balance + ) + tpos_payment.balance = settled_balance + tpos_payment.pending = unconfirmed_balance + if settled_balance >= tpos_payment.amount: + tpos_payment.paid = True + tpos_payment.payment_method = "onchain" + if changed or tpos_payment.paid: + await update_tpos_payment(tpos_payment) + await websocket_updater( + tpos_payment.payment_hash, + json.dumps( + { + "pending": not tpos_payment.paid, + "payment_hash": tpos_payment.payment_hash, + "onchain_balance": tpos_payment.balance, + "onchain_pending": tpos_payment.pending, + "payment_method": tpos_payment.payment_method, + } + ), + ) + if tpos_payment.paid: + await settle_onchain_tpos_payment(tpos_payment) + except Exception as exc: + logger.warning(f"tpos: onchain polling failed: {exc}") + await asyncio.sleep(10) + + async def on_invoice_paid(payment: Payment) -> None: if ( not payment.extra @@ -31,7 +89,51 @@ async def on_invoice_paid(payment: Payment) -> None: or payment.extra.get("tipSplitted") ): return + + payment_method = payment.extra.get("payment_method") or _payment_method(payment) + tpos_payment = await get_tpos_payment_by_hash(payment.payment_hash) + if tpos_payment and not tpos_payment.paid: + tpos_payment.paid = True + tpos_payment.payment_method = payment_method + await update_tpos_payment(tpos_payment) + + if payment.extra.get("tpos_processed"): + return + + await process_paid_tpos_payment(payment, payment_method=payment_method) + + +async def settle_onchain_tpos_payment(tpos_payment) -> None: + payment = await get_standalone_payment(tpos_payment.payment_hash, incoming=True) + if not payment or not payment.extra or payment.extra.get("tag") != "tpos": + return + + if payment.extra.get("tpos_processed"): + return + + payment.extra["payment_method"] = "onchain" + payment.extra["settled_by_onchain"] = True + await update_payment(payment) + await process_paid_tpos_payment(payment, payment_method="onchain") + + +async def process_paid_tpos_payment( + payment: Payment, *, payment_method: str = "lightning" +) -> None: + if ( + not payment.extra + or payment.extra.get("tag") != "tpos" + or payment.extra.get("tipSplitted") + ): + return + + payment.extra["tpos_processed"] = True + payment.extra["payment_method"] = payment_method + await update_payment(payment) + tip_amount = payment.extra.get("tip_amount") + tpos_id = payment.extra.get("tpos_id") + assert tpos_id stripped_payment = { "amount": payment.amount, @@ -39,11 +141,10 @@ async def on_invoice_paid(payment: Payment) -> None: "checking_id": payment.checking_id, "payment_hash": payment.payment_hash, "bolt11": payment.bolt11, + "pending": False, + "payment_method": payment_method, } - tpos_id = payment.extra.get("tpos_id") - assert tpos_id - tpos = await get_tpos(tpos_id) assert tpos if payment.extra.get("lnaddress") and payment.extra["lnaddress"] != "": @@ -52,19 +153,21 @@ async def on_invoice_paid(payment: Payment) -> None: if address: try: pr = await get_pr_from_lnurl(address, int(calc_amount)) - except Exception as e: - logger.error(f"tpos: Error getting payment request from lnurl: {e}") - return - - payment.extra["lnaddress"] = "" - paid_payment = await pay_invoice( - payment_request=pr, - wallet_id=payment.wallet_id, - extra={**payment.extra}, - ) - logger.debug(f"tpos: LNaddress paid cut: {paid_payment.checking_id}") - - await websocket_updater(tpos_id, str(stripped_payment)) + except Exception as exc: + logger.error(f"tpos: Error getting payment request from lnurl: {exc}") + pr = None + + if pr: + payment.extra["lnaddress"] = "" + paid_payment = await pay_invoice( + payment_request=pr, + wallet_id=payment.wallet_id, + extra={**payment.extra}, + ) + logger.debug(f"tpos: LNaddress paid cut: {paid_payment.checking_id}") + + await websocket_updater(tpos_id, json.dumps(stripped_payment)) + await websocket_updater(payment.payment_hash, json.dumps(stripped_payment)) await maybe_push_order(payment, tpos) @@ -76,11 +179,14 @@ async def on_invoice_paid(payment: Payment) -> None: logger.warning(f"tpos: inventory deduction failed: {exc}") if not tip_amount: - # no tip amount + return + if payment_method == "onchain": + logger.warning("tpos: skipping tip split for onchain payment.") return wallet_id = tpos.tip_wallet - assert wallet_id + if not wallet_id: + return tip_payment = await create_invoice( wallet_id=wallet_id, @@ -98,6 +204,14 @@ async def on_invoice_paid(payment: Payment) -> None: logger.debug(f"tpos: tip invoice paid: {paid_payment.checking_id}") +def _payment_method(payment: Payment) -> str: + if payment.extra.get("fiat_method") == "cash": + return "cash" + if payment.extra.get("fiat_payment_request", "").startswith("pi_"): + return "fiat" + return "lightning" + + async def maybe_push_order(payment: Payment, tpos) -> None: wallet = await get_wallet(payment.wallet_id) if not wallet: diff --git a/views_api.py b/views_api.py index 161363c..3d10cfe 100644 --- a/views_api.py +++ b/views_api.py @@ -9,7 +9,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from lnbits.core.crud import ( get_account, - get_latest_payments_by_extension, get_standalone_payment, get_user, get_wallet, @@ -41,8 +40,11 @@ from .crud import ( create_tpos, + create_tpos_payment, delete_tpos, + get_latest_tpos_payments, get_tpos, + get_tpos_payment_by_hash, get_tposs, update_tpos, ) @@ -65,11 +67,18 @@ ReceiptPrint, TapToPay, Tpos, + TposInvoiceResponse, + TposPayment, ) from .services import ( + fetch_onchain_address, + fetch_watchonly_config, + fetch_watchonly_wallet, + fetch_watchonly_wallets, get_default_inventory, get_inventory_items_for_tpos, inventory_available_for_user, + watchonly_available_for_user, ) tpos_api_router = APIRouter() @@ -85,7 +94,9 @@ def _two_year_token_expiry_minutes() -> int: return max(1, int((expires_at - now).total_seconds() // 60)) -def _build_receipt_data(tpos: Tpos, payment: Payment) -> ReceiptData: +def _build_receipt_data( + tpos: Tpos, payment: Payment, tpos_payment: TposPayment | None = None +) -> ReceiptData: extra = payment.extra or {} details = extra.get("details") or {} items = details.get("items") or [] @@ -101,7 +112,7 @@ def _build_receipt_data(tpos: Tpos, payment: Payment) -> ReceiptData: ] return ReceiptData( - paid=payment.success, + paid=payment.success or bool(tpos_payment and tpos_payment.paid), extra=ReceiptExtraData( amount=int(extra.get("amount") or 0), paid_in_fiat=bool(extra.get("paid_in_fiat")), @@ -123,6 +134,129 @@ def _build_receipt_data(tpos: Tpos, payment: Payment) -> ReceiptData: ) +async def _get_watchonly_status(wallet) -> dict[str, Any]: + if not await watchonly_available_for_user(wallet.user): + return { + "available": False, + "message": "Watchonly extension must be enabled for this user.", + "network": None, + "wallets": [], + } + + try: + config = await fetch_watchonly_config(wallet.inkey) + network = config.get("network") + wallets = await fetch_watchonly_wallets(wallet.inkey, network) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Watchonly extension is not reachable: {exc!s}", + ) from exc + + return { + "available": True, + "message": None, + "network": network, + "wallets": wallets, + "mempool_endpoint": config.get("mempool_endpoint"), + } + + +async def _validate_watchonly_settings( + *, + wallet, + onchain_enabled: bool, + onchain_wallet_id: str | None, +) -> dict[str, Any] | None: + if not onchain_enabled: + return None + if not onchain_wallet_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Watchonly wallet is required when onchain payments are enabled.", + ) + + status = await _get_watchonly_status(wallet) + if not status["available"]: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=status["message"] or "Watchonly extension is not available.", + ) + + try: + watch_wallet = await fetch_watchonly_wallet(wallet.inkey, onchain_wallet_id) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Cannot access watchonly wallet: {exc!s}", + ) from exc + + if watch_wallet.get("network") != status["network"]: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Watchonly wallet network does not match the user watchonly config.", + ) + + return { + "watch_wallet": watch_wallet, + "network": status["network"], + "mempool_endpoint": status["mempool_endpoint"], + } + + +def _build_bip21(onchain_address: str, amount_sat: int, bolt11: str | None = None) -> str: + amount_btc = amount_sat / 100_000_000 + bip21 = f"bitcoin:{onchain_address.upper()}?amount={amount_btc:.8f}" + if bolt11: + bip21 += f"&lightning={bolt11.upper()}" + return bip21 + + +def _payment_method_from_payment(payment: Payment) -> str: + if payment.extra.get("fiat_method") == "cash": + return "cash" + if payment.extra.get("fiat_payment_request", "").startswith("pi_"): + return "fiat" + return "lightning" + + +def _serialize_tpos_invoice_response( + payment: Payment, tpos_payment: TposPayment +) -> TposInvoiceResponse: + payment_method = _payment_method_from_payment(payment) + payment_request = "lightning:" + payment.bolt11.upper() + if payment_method == "cash": + payment_request = "cash" + elif payment.extra.get("fiat_payment_request") and not payment.extra.get( + "fiat_payment_request", "" + ).startswith("pi_"): + payment_request = payment.extra["fiat_payment_request"] + elif payment_method == "fiat": + payment_request = "tap_to_pay" + + options = [payment_method] + unified_qr = None + if tpos_payment.onchain_address: + options = ["uqr", "lightning", "onchain"] + unified_qr = _build_bip21( + tpos_payment.onchain_address, + tpos_payment.amount, + payment.bolt11 if payment_method == "lightning" else payment.bolt11, + ) + + return TposInvoiceResponse( + payment_hash=payment.payment_hash, + bolt11=payment.bolt11, + payment_request=payment_request, + tpos_payment_id=tpos_payment.id, + payment_options=options, + onchain_address=tpos_payment.onchain_address, + unified_qr=unified_qr, + payment_method=payment_method, + extra=payment.extra or {}, + ) + + @tpos_api_router.get("/api/v1/tposs", status_code=HTTPStatus.OK) async def api_tposs( all_wallets: bool = Query(False), @@ -153,11 +287,23 @@ async def api_inventory_status( } +@tpos_api_router.get("/api/v1/onchain/status", status_code=HTTPStatus.OK) +async def api_onchain_status( + key_info: WalletTypeInfo = Depends(require_admin_key), +) -> dict[str, Any]: + return await _get_watchonly_status(key_info.wallet) + + @tpos_api_router.post("/api/v1/tposs", status_code=HTTPStatus.CREATED) async def api_tpos_create( data: CreateTposData, wallet: WalletTypeInfo = Depends(require_admin_key) ): data.wallet = wallet.wallet.id + await _validate_watchonly_settings( + wallet=wallet.wallet, + onchain_enabled=data.onchain_enabled, + onchain_wallet_id=data.onchain_wallet_id, + ) user = await get_user(wallet.wallet.user) if not (user and user.super_user): data.allow_cash_settlement = False @@ -192,6 +338,15 @@ async def api_tpos_update( raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") user = await get_user(wallet.wallet.user) update_payload = data.dict(exclude_unset=True) + desired_onchain_enabled = update_payload.get("onchain_enabled", tpos.onchain_enabled) + desired_onchain_wallet_id = update_payload.get( + "onchain_wallet_id", tpos.onchain_wallet_id + ) + await _validate_watchonly_settings( + wallet=wallet.wallet, + onchain_enabled=desired_onchain_enabled, + onchain_wallet_id=desired_onchain_wallet_id, + ) desired_currency = update_payload.get("currency", tpos.currency) if desired_currency == "sats": update_payload["allow_cash_settlement"] = False @@ -331,7 +486,7 @@ async def api_tpos_create_wrapper_token( ) async def api_tpos_create_invoice( tpos_id: str, data: CreateTposInvoice, request: Request -) -> Payment: +) -> dict[str, Any]: tpos = await get_tpos(tpos_id) if not tpos: @@ -440,25 +595,57 @@ async def api_tpos_create_invoice( new_checking_id = f"internal_cash_{payment.payment_hash}" await update_payment_checking_id(payment.checking_id, new_checking_id) payment.checking_id = new_checking_id - payment_request_for_display = "lightning:" + payment.bolt11.upper() - fiat_payment_request = payment.extra.get("fiat_payment_request") - if cash_method: - payment_request_for_display = "cash" - elif fiat_payment_request and not fiat_payment_request.startswith("pi_"): - payment_request_for_display = fiat_payment_request - elif fiat_payment_request and fiat_payment_request.startswith("pi_"): - payment_request_for_display = "tap_to_pay" + + onchain_address = None + mempool_endpoint = None + if tpos.onchain_enabled and not data.pay_in_fiat: + wallet_record = await get_wallet(tpos.wallet) + if not wallet_record: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Wallet not found for this TPoS.", + ) + validation = await _validate_watchonly_settings( + wallet=wallet_record, + onchain_enabled=tpos.onchain_enabled, + onchain_wallet_id=tpos.onchain_wallet_id, + ) + assert validation + address_data = await fetch_onchain_address( + wallet_record.inkey, tpos.onchain_wallet_id or "" + ) + onchain_address = address_data.get("address") + mempool_endpoint = validation.get("mempool_endpoint") + + tpos_payment = await create_tpos_payment( + TposPayment( + id=uuid4().hex, + tpos_id=tpos_id, + payment_hash=payment.payment_hash, + amount=int(data.amount + (data.tip_amount or 0)), + onchain_address=onchain_address, + onchain_wallet_id=tpos.onchain_wallet_id, + onchain_zero_conf=tpos.onchain_zero_conf, + mempool_endpoint=mempool_endpoint, + ) + ) + response_payload = _serialize_tpos_invoice_response(payment, tpos_payment) if tpos.enable_remote: payload = { "type": "invoice_created", "tpos_id": tpos_id, "payment_hash": payment.payment_hash, - "payment_request": payment_request_for_display, + "payment_request": response_payload.payment_request, "paid_in_fiat": data.pay_in_fiat, "amount_fiat": data.amount_fiat, "tip_amount": data.tip_amount, "exchange_rate": data.exchange_rate if data.exchange_rate else None, + "tpos_payment_id": response_payload.tpos_payment_id, + "payment_options": response_payload.payment_options, + "onchain_address": response_payload.onchain_address, + "unified_qr": response_payload.unified_qr, + "payment_method": response_payload.payment_method, } await websocket_updater(tpos_id, json.dumps(payload)) @@ -476,7 +663,7 @@ async def api_tpos_create_invoice( payment_hash=payment.payment_hash, ) await websocket_updater(tpos_id, json.dumps(tap_to_pay_payload.dict())) - return payment + return response_payload.dict() except Exception as exc: raise HTTPException( @@ -486,9 +673,12 @@ async def api_tpos_create_invoice( @tpos_api_router.get("/api/v1/tposs/{tpos_id}/invoices") async def api_tpos_get_latest_invoices(tpos_id: str): - payments = await get_latest_payments_by_extension(ext_name="tpos", ext_id=tpos_id) + tpos_payments = await get_latest_tpos_payments(tpos_id) result = [] - for payment in payments: + for tpos_payment in tpos_payments: + payment = await get_standalone_payment(tpos_payment.payment_hash, incoming=True) + if not payment: + continue details = payment.extra.get("details", {}) currency = details.get("currency", None) exchange_rate = details.get("exchangeRate") or payment.extra.get("exchangeRate") @@ -497,9 +687,10 @@ async def api_tpos_get_latest_invoices(tpos_id: str): "checking_id": payment.checking_id, "amount": payment.amount, "time": payment.time, - "pending": payment.pending, + "pending": not tpos_payment.paid, "currency": currency, "exchange_rate": exchange_rate, + "payment_method": tpos_payment.payment_method, } ) return result @@ -594,10 +785,11 @@ async def api_tpos_check_invoice( raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="TPoS payment does not exist." ) + tpos_payment = await get_tpos_payment_by_hash(payment_hash) if extra: - return _build_receipt_data(tpos, payment).to_api_dict() - return {"paid": payment.success} + return _build_receipt_data(tpos, payment, tpos_payment).to_api_dict() + return {"paid": payment.success or bool(tpos_payment and tpos_payment.paid)} @tpos_api_router.post( @@ -626,7 +818,8 @@ async def api_tpos_print_invoice( receipt_type: Literal["receipt", "order_receipt"] = ( "order_receipt" if data.receipt_type == "order_receipt" else "receipt" ) - receipt = _build_receipt_data(tpos, payment) + tpos_payment = await get_tpos_payment_by_hash(payment_hash) + receipt = _build_receipt_data(tpos, payment, tpos_payment) payload = ReceiptPrint( tpos_id=tpos_id, payment_hash=payment_hash, From e67db67038409aa7c7c5161c7914cf559c3161f4 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Thu, 19 Mar 2026 16:52:11 +0000 Subject: [PATCH 02/10] add unified qr and onchain payment ui --- static/components/payment-dialog.js | 140 ++++++++++++++++++++++++++++ static/js/index.js | 54 ++++++++++- static/js/tpos.js | 86 +++++++++++------ templates/tpos/dialogs.html | 40 ++------ templates/tpos/index.html | 52 +++++++++++ templates/tpos/tpos.html | 1 + 6 files changed, 311 insertions(+), 62 deletions(-) create mode 100644 static/components/payment-dialog.js diff --git a/static/components/payment-dialog.js b/static/components/payment-dialog.js new file mode 100644 index 0000000..f118046 --- /dev/null +++ b/static/components/payment-dialog.js @@ -0,0 +1,140 @@ +window.app.component('tpos-payment-dialog', { + name: 'tpos-payment-dialog', + props: [ + 'dialogData', + 'isMobileLandscaped', + 'activePaymentAmountFormatted', + 'activePaymentAmountWithTipFormatted', + 'tipOptions', + 'tipAmountFormatted', + 'nfcTagReading' + ], + emits: ['copy'], + data() { + return { + tab: 'ln' + } + }, + computed: { + hasUnifiedQr() { + return Boolean(this.dialogData?.unified_qr) + }, + hasLightning() { + return Boolean(this.lightningValue) + }, + hasOnchain() { + return Boolean(this.dialogData?.onchain_address) + }, + lightningValue() { + if (this.dialogData?.lightning_payment_request) { + return this.dialogData.lightning_payment_request + } + if ( + this.dialogData?.payment_request && + (this.dialogData.payment_request.startsWith('lightning:') || + this.dialogData.payment_request.toUpperCase().startsWith('LNURL')) + ) { + return this.dialogData.payment_request + } + return null + } + }, + watch: { + dialogData: { + handler() { + if (this.hasUnifiedQr) { + this.tab = 'uqr' + } else if (this.hasLightning) { + this.tab = 'ln' + } else if (this.hasOnchain) { + this.tab = 'btc' + } + }, + immediate: true, + deep: true + } + }, + methods: { + copyCurrentValue() { + if (this.tab === 'uqr' && this.dialogData?.unified_qr) { + this.$emit('copy', this.dialogData.unified_qr) + } else if (this.tab === 'btc' && this.dialogData?.onchain_address) { + this.$emit('copy', this.dialogData.onchain_address) + } else if (this.lightningValue) { + this.$emit('copy', this.lightningValue) + } + } + }, + template: ` +
+
+ + + + + + + + + + + + + + + + +
+ + + +
+ +
+
+
+
+

${ activePaymentAmountWithTipFormatted }

+
+ ${ activePaymentAmountFormatted } + (+ ${ tipAmountFormatted } tip) +
+ + + NFC supported + + NFC not supported +
+
+ Copy invoice + Close +
+
+ ` +}) diff --git a/static/js/index.js b/static/js/index.js index 702ee09..09f7a80 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -47,6 +47,9 @@ const mapTpos = obj => { : [] obj.only_show_sats_on_bitcoin = obj.only_show_sats_on_bitcoin ?? true obj.allow_cash_settlement = Boolean(obj.allow_cash_settlement) + obj.onchain_enabled = Boolean(obj.onchain_enabled) + obj.onchain_wallet_id = obj.onchain_wallet_id || null + obj.onchain_zero_conf = obj.onchain_zero_conf ?? true obj.useWrapper = false obj.posLocation = '' obj.auth = '' @@ -74,6 +77,13 @@ window.app = Vue.createApp({ tags: [], omit_tags: [] }, + onchainStatus: { + available: false, + message: null, + network: null, + wallets: [], + mempool_endpoint: null + }, tpossTable: { columns: [ {name: 'name', align: 'left', label: 'Name', field: 'name'}, @@ -141,7 +151,10 @@ window.app = Vue.createApp({ fiat: false, stripe_card_payments: false, stripe_reader_id: '', - allow_cash_settlement: false + allow_cash_settlement: false, + onchain_enabled: false, + onchain_wallet_id: null, + onchain_zero_conf: true }, advanced: { tips: false, @@ -233,7 +246,9 @@ window.app = Vue.createApp({ !data.name || !data.currency || !data.wallet || - (this.formDialog.advanced.otc && !data.withdraw_limit) + (this.formDialog.advanced.otc && !data.withdraw_limit) || + (data.onchain_enabled && + (!this.onchainStatus.available || !data.onchain_wallet_id)) ) }, inventoryModeOptions() { @@ -263,6 +278,12 @@ window.app = Vue.createApp({ !!this.formDialog.data.currency && this.formDialog.data.currency !== 'sats' ) + }, + onchainWalletOptions() { + return (this.onchainStatus.wallets || []).map(wallet => ({ + label: wallet.title, + value: wallet.id + })) } }, methods: { @@ -285,7 +306,10 @@ window.app = Vue.createApp({ fiat: false, stripe_card_payments: false, stripe_reader_id: '', - allow_cash_settlement: false + allow_cash_settlement: false, + onchain_enabled: false, + onchain_wallet_id: null, + onchain_zero_conf: true } this.formDialog.advanced = {tips: false, otc: false} }, @@ -321,6 +345,25 @@ window.app = Vue.createApp({ console.error(error) } }, + async loadOnchainStatus() { + if (!this.g.user.wallets.length) return + try { + const {data} = await LNbits.api.request( + 'GET', + '/tpos/api/v1/onchain/status', + this.g.user.wallets[0].adminkey + ) + this.onchainStatus = data + } catch (error) { + this.onchainStatus = { + available: false, + message: error.response?.data?.detail || 'Watchonly unavailable.', + network: null, + wallets: [], + mempool_endpoint: null + } + } + }, sendTposData() { const data = { ...this.formDialog.data, @@ -355,6 +398,10 @@ window.app = Vue.createApp({ if (data.currency === 'sats') { data.allow_cash_settlement = false } + if (!data.onchain_enabled) { + data.onchain_wallet_id = null + data.onchain_zero_conf = true + } const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) @@ -760,6 +807,7 @@ window.app = Vue.createApp({ if (this.g.user.wallets.length) { this.getTposs() this.loadInventoryStatus() + this.loadOnchainStatus() } LNbits.api .request('GET', '/api/v1/currencies') diff --git a/static/js/tpos.js b/static/js/tpos.js index e64523a..b6b72bb 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -73,7 +73,15 @@ window.app = Vue.createApp({ }, invoiceDialog: { show: false, - data: {}, + data: { + payment_hash: null, + payment_request: null, + lightning_payment_request: null, + onchain_address: null, + unified_qr: null, + payment_options: [], + payment_method: null + }, dismissMsg: null, paymentChecker: null, internalMemo: null @@ -380,20 +388,60 @@ window.app = Vue.createApp({ this.tipAmount = payload.tip_amount || this.tipAmount this.exchangeRate = payload.exchange_rate || this.exchangeRate - this.openInvoiceDialog(payload.payment_hash, payload.payment_request) + this.openInvoiceDialog(payload) this.subscribeToPaymentWS(payload.payment_hash) }, - openInvoiceDialog(paymentHash, paymentRequest) { + normalizeInvoiceDialogData(paymentData, paymentRequest = null) { + if (typeof paymentData === 'string') { + return { + payment_hash: paymentData, + payment_request: paymentRequest, + lightning_payment_request: + paymentRequest && + paymentRequest !== 'cash' && + paymentRequest !== 'tap_to_pay' + ? paymentRequest + : null, + onchain_address: null, + unified_qr: null, + payment_options: ['lightning'], + payment_method: 'lightning' + } + } + + const lightningPaymentRequest = + paymentData.payment_request && + paymentData.payment_request !== 'cash' && + paymentData.payment_request !== 'tap_to_pay' + ? paymentData.payment_request + : paymentData.bolt11 + ? 'lightning:' + paymentData.bolt11.toUpperCase() + : null + + return { + payment_hash: paymentData.payment_hash, + payment_request: paymentData.payment_request, + lightning_payment_request: lightningPaymentRequest, + onchain_address: paymentData.onchain_address || null, + unified_qr: paymentData.unified_qr || null, + payment_options: paymentData.payment_options || ['lightning'], + payment_method: paymentData.payment_method || 'lightning' + } + }, + openInvoiceDialog(paymentData, paymentRequest = null) { + const dialogData = this.normalizeInvoiceDialogData( + paymentData, + paymentRequest + ) if ( this.invoiceDialog.show && - this.invoiceDialog.data.payment_hash === paymentHash + this.invoiceDialog.data.payment_hash === dialogData.payment_hash ) { return } - this.invoiceDialog.data.payment_hash = paymentHash - this.invoiceDialog.data.payment_request = paymentRequest + this.invoiceDialog.data = dialogData this.invoiceDialog.show = true - if (paymentRequest !== 'cash') { + if (dialogData.lightning_payment_request) { this.readNfcTag() this.invoiceDialog.dismissMsg = Quasar.Notify.create({ timeout: 0, @@ -1018,27 +1066,7 @@ window.app = Vue.createApp({ null, params ) - let paymentRequest = 'lightning:' + data.bolt11.toUpperCase() - if (data.extra?.fiat_method === 'cash') { - paymentRequest = 'cash' - } else if ( - data.extra?.fiat_payment_request && - !data.extra.fiat_payment_request.startsWith('pi_') - ) { - paymentRequest = data.extra.fiat_payment_request - } else if ( - data.extra?.fiat_payment_request && - data.extra.fiat_payment_request.startsWith('pi_') - ) { - paymentRequest = 'tap_to_pay' - } - if ( - !data.extra?.fiat_payment_request && - data.extra?.fiat_method !== 'cash' - ) { - paymentRequest = 'lightning:' + data.bolt11.toUpperCase() - } - this.openInvoiceDialog(data.payment_hash, paymentRequest) + this.openInvoiceDialog(data) this.subscribeToPaymentWS(data.payment_hash) } catch (error) { console.error(error) @@ -1221,7 +1249,7 @@ window.app = Vue.createApp({ }) }, payInvoice(lnurl) { - const payment_request = this.invoiceDialog.data.payment_request + const payment_request = this.invoiceDialog.data.lightning_payment_request .toLowerCase() .replace('lightning:', '') return axios diff --git a/templates/tpos/dialogs.html b/templates/tpos/dialogs.html index 5f25803..a2eb433 100644 --- a/templates/tpos/dialogs.html +++ b/templates/tpos/dialogs.html @@ -43,36 +43,16 @@
- -
-

${ activePaymentAmountWithTipFormatted }

-
- ${ activePaymentAmountFormatted } - (+ ${ tipAmountFormatted } tip) -
- - - NFC supported - - NFC not supported -
-
- Copy invoice - Close -
+
diff --git a/templates/tpos/index.html b/templates/tpos/index.html index 9db2b82..0e11fe5 100644 --- a/templates/tpos/index.html +++ b/templates/tpos/index.html @@ -459,6 +459,58 @@
{{SITE_TITLE}} TPoS extension
+
+
+ + + + + +
+
+
+
+ + + +
+
+ +
+
+ + + If disabled, TPoS waits for the first confirmation before completing the sale. + + +
+
+ From 1ef634861ea8733edb2fedb2a86b94a59cad0dfa Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Fri, 20 Mar 2026 14:02:25 +0000 Subject: [PATCH 03/10] make it work only works for testnet3 --- services.py | 13 +- static/components/payment-dialog.js | 199 +++++++++++++++++----------- static/js/tpos.js | 60 ++++++--- 3 files changed, 182 insertions(+), 90 deletions(-) diff --git a/services.py b/services.py index 88e0a62..e2174a0 100644 --- a/services.py +++ b/services.py @@ -174,8 +174,19 @@ async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: return resp.json() -async def fetch_onchain_balance(mempool_endpoint: str, onchain_address: str) -> dict[str, Any]: +def normalize_mempool_endpoint(mempool_endpoint: str | None, onchain_address: str) -> str: endpoint = (mempool_endpoint or "https://mempool.space").rstrip("/") + if "/testnet" in endpoint or "/signet" in endpoint: + return endpoint + if onchain_address.lower().startswith("tb1"): + return f"{endpoint}/testnet" + return endpoint + + +async def fetch_onchain_balance( + mempool_endpoint: str, onchain_address: str +) -> dict[str, Any]: + endpoint = normalize_mempool_endpoint(mempool_endpoint, onchain_address) async with httpx.AsyncClient() as client: resp = await client.get(f"{endpoint}/api/address/{onchain_address}/txs") resp.raise_for_status() diff --git a/static/components/payment-dialog.js b/static/components/payment-dialog.js index f118046..6f0be8a 100644 --- a/static/components/payment-dialog.js +++ b/static/components/payment-dialog.js @@ -37,6 +37,29 @@ window.app.component('tpos-payment-dialog', { return this.dialogData.payment_request } return null + }, + amountSummary() { + return this.activePaymentAmountFormatted || '' + }, + totalSummary() { + return this.activePaymentAmountWithTipFormatted || '' + }, + tipSummary() { + return this.tipOptions ? `(+ ${this.tipAmountFormatted} tip)` : '' + }, + currentCopyValue() { + if (this.tab === 'uqr' && this.dialogData?.unified_qr) { + return this.dialogData.unified_qr + } + if (this.tab === 'btc' && this.dialogData?.onchain_address) { + return this.dialogData.onchain_address + } + return this.lightningValue + }, + onchainHref() { + return this.dialogData?.onchain_address + ? `bitcoin:${this.dialogData.onchain_address}` + : '' } }, watch: { @@ -44,9 +67,13 @@ window.app.component('tpos-payment-dialog', { handler() { if (this.hasUnifiedQr) { this.tab = 'uqr' - } else if (this.hasLightning) { + return + } + if (this.hasLightning) { this.tab = 'ln' - } else if (this.hasOnchain) { + return + } + if (this.hasOnchain) { this.tab = 'btc' } }, @@ -56,85 +83,109 @@ window.app.component('tpos-payment-dialog', { }, methods: { copyCurrentValue() { - if (this.tab === 'uqr' && this.dialogData?.unified_qr) { - this.$emit('copy', this.dialogData.unified_qr) - } else if (this.tab === 'btc' && this.dialogData?.onchain_address) { - this.$emit('copy', this.dialogData.onchain_address) - } else if (this.lightningValue) { - this.$emit('copy', this.lightningValue) + if (this.currentCopyValue) { + this.$emit('copy', this.currentCopyValue) } } }, template: ` -
-
- - - - - + + + + + + - - - - + + + + - - - + + + - -
- - - -
- -
-
-
-
-

${ activePaymentAmountWithTipFormatted }

-
- ${ activePaymentAmountFormatted } - (+ ${ tipAmountFormatted } tip) -
- - - NFC supported - - NFC not supported -
-
- Copy invoice - Close + + + + + + + + + + + + + + + + +
+
+ +
-
+ + + NFC supported + +
+ + + + + + ` }) diff --git a/static/js/tpos.js b/static/js/tpos.js index b6b72bb..80ed176 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -1088,37 +1088,67 @@ window.app = Vue.createApp({ this.cashValidating = false } }, + finalizeSuccessfulPayment(paymentHash) { + Quasar.Notify.create({ + type: 'positive', + message: 'Invoice Paid!' + }) + this.invoiceDialog.show = false + this.invoiceDialog.internalMemo = null + this.clearCart() + this.showComplete() + if (this.enablePrint) { + this.promptPrintType(paymentHash) + } + }, + startPaymentChecker(paymentHash) { + if (this.invoiceDialog.paymentChecker) { + clearInterval(this.invoiceDialog.paymentChecker) + } + this.invoiceDialog.paymentChecker = setInterval(async () => { + try { + const {data} = await LNbits.api.request( + 'GET', + `/tpos/api/v1/tposs/${this.tposId}/invoices/${paymentHash}` + ) + if (data.paid) { + clearInterval(this.invoiceDialog.paymentChecker) + this.invoiceDialog.paymentChecker = null + this.finalizeSuccessfulPayment(paymentHash) + } + } catch (error) { + console.warn('TPoS payment status check failed:', error) + } + }, 3000) + }, subscribeToPaymentWS(paymentHash) { if (this.paymentWsByHash[paymentHash]) return + this.startPaymentChecker(paymentHash) try { - const url = new URL(window.location) - url.protocol = url.protocol === 'https:' ? 'wss' : 'ws' - url.pathname = `/api/v1/ws/${paymentHash}` + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const url = new URL(`/api/v1/ws/${paymentHash}`, window.location.origin) + url.protocol = wsProtocol const ws = new WebSocket(url) this.paymentWsByHash[paymentHash] = ws ws.onmessage = async ({data}) => { const payment = JSON.parse(data) if (payment.pending === false) { - Quasar.Notify.create({ - type: 'positive', - message: 'Invoice Paid!' - }) - this.invoiceDialog.show = false - this.invoiceDialog.internalMemo = null - this.clearCart() - this.showComplete() - if (this.enablePrint) { - this.promptPrintType(paymentHash) + if (this.invoiceDialog.paymentChecker) { + clearInterval(this.invoiceDialog.paymentChecker) + this.invoiceDialog.paymentChecker = null } + this.finalizeSuccessfulPayment(paymentHash) ws.close() } } + ws.onerror = err => { + console.warn('TPoS payment websocket error:', err) + } ws.onclose = () => { delete this.paymentWsByHash[paymentHash] } } catch (err) { - console.warn(err) - LNbits.utils.notifyApiError(err) + console.warn('TPoS payment websocket setup failed:', err) } }, readNfcTag() { From 8495ec175f022bc2e7da1fe5ff0c3754c93fb062 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 23 Mar 2026 11:13:42 +0000 Subject: [PATCH 04/10] simplified flow for onchain --- models.py | 3 +- services.py | 4 +- static/components/payment-dialog.js | 165 +++++++--------------------- static/js/tpos.js | 65 +++++++---- tasks.py | 11 +- templates/tpos/dialogs.html | 22 +++- templates/tpos/index.html | 3 +- views_api.py | 63 ++++++----- 8 files changed, 152 insertions(+), 184 deletions(-) diff --git a/models.py b/models.py index faa7fac..2996e4a 100644 --- a/models.py +++ b/models.py @@ -28,6 +28,7 @@ class CreateTposInvoice(BaseModel): internal_memo: str | None = Query(None, max_length=512) pay_in_fiat: bool = Query(False) fiat_method: str | None = Query(None) + payment_method: str | None = Query(None) amount_fiat: float | None = Query(None, ge=0.0) tip_amount_fiat: float | None = Query(None, ge=0.0) @@ -162,7 +163,7 @@ class TposInvoiceResponse(BaseModel): tpos_payment_id: str payment_options: list[str] = Field(default_factory=list) onchain_address: str | None = None - unified_qr: str | None = None + onchain_amount_sat: int | None = None payment_method: str | None = None extra: dict[str, Any] = Field(default_factory=dict) diff --git a/services.py b/services.py index e2174a0..9400c96 100644 --- a/services.py +++ b/services.py @@ -174,7 +174,9 @@ async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: return resp.json() -def normalize_mempool_endpoint(mempool_endpoint: str | None, onchain_address: str) -> str: +def normalize_mempool_endpoint( + mempool_endpoint: str | None, onchain_address: str +) -> str: endpoint = (mempool_endpoint or "https://mempool.space").rstrip("/") if "/testnet" in endpoint or "/signet" in endpoint: return endpoint diff --git a/static/components/payment-dialog.js b/static/components/payment-dialog.js index 6f0be8a..8332b10 100644 --- a/static/components/payment-dialog.js +++ b/static/components/payment-dialog.js @@ -10,33 +10,15 @@ window.app.component('tpos-payment-dialog', { 'nfcTagReading' ], emits: ['copy'], - data() { - return { - tab: 'ln' - } - }, computed: { - hasUnifiedQr() { - return Boolean(this.dialogData?.unified_qr) - }, - hasLightning() { - return Boolean(this.lightningValue) + isOnchain() { + return this.dialogData?.payment_method === 'onchain' }, - hasOnchain() { - return Boolean(this.dialogData?.onchain_address) - }, - lightningValue() { - if (this.dialogData?.lightning_payment_request) { - return this.dialogData.lightning_payment_request - } - if ( - this.dialogData?.payment_request && - (this.dialogData.payment_request.startsWith('lightning:') || - this.dialogData.payment_request.toUpperCase().startsWith('LNURL')) - ) { - return this.dialogData.payment_request + qrValue() { + if (this.isOnchain) { + return this.dialogData?.onchain_address || null } - return null + return this.dialogData?.lightning_payment_request || null }, amountSummary() { return this.activePaymentAmountFormatted || '' @@ -47,117 +29,46 @@ window.app.component('tpos-payment-dialog', { tipSummary() { return this.tipOptions ? `(+ ${this.tipAmountFormatted} tip)` : '' }, - currentCopyValue() { - if (this.tab === 'uqr' && this.dialogData?.unified_qr) { - return this.dialogData.unified_qr - } - if (this.tab === 'btc' && this.dialogData?.onchain_address) { - return this.dialogData.onchain_address - } - return this.lightningValue - }, onchainHref() { return this.dialogData?.onchain_address ? `bitcoin:${this.dialogData.onchain_address}` : '' } }, - watch: { - dialogData: { - handler() { - if (this.hasUnifiedQr) { - this.tab = 'uqr' - return - } - if (this.hasLightning) { - this.tab = 'ln' - return - } - if (this.hasOnchain) { - this.tab = 'btc' - } - }, - immediate: true, - deep: true - } - }, methods: { copyCurrentValue() { - if (this.currentCopyValue) { - this.$emit('copy', this.currentCopyValue) + if (this.qrValue) { + this.$emit('copy', this.qrValue) } } }, template: ` - - - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - + @@ -170,11 +81,15 @@ window.app.component('tpos-payment-dialog', { v-text="tipSummary" >
- + NFC supported -
+
@@ -182,8 +97,8 @@ window.app.component('tpos-payment-dialog', { outline color="grey" @click="copyCurrentValue()" - :disable="!currentCopyValue" - label="Copy invoice" + :disable="!qrValue" + :label="isOnchain ? 'Copy address' : 'Copy invoice'" > diff --git a/static/js/tpos.js b/static/js/tpos.js index 80ed176..f44ef3d 100644 --- a/static/js/tpos.js +++ b/static/js/tpos.js @@ -44,6 +44,7 @@ window.app = Vue.createApp({ currency: null, fiatProvider: null, allowCashSettlement: false, + onchainEnabled: false, payInFiat: false, fiatMethod: 'checkout', atmPremium: tpos.withdraw_premium / 100, @@ -78,7 +79,7 @@ window.app = Vue.createApp({ payment_request: null, lightning_payment_request: null, onchain_address: null, - unified_qr: null, + onchain_amount_sat: null, payment_options: [], payment_method: null }, @@ -403,27 +404,29 @@ window.app = Vue.createApp({ ? paymentRequest : null, onchain_address: null, - unified_qr: null, + onchain_amount_sat: null, payment_options: ['lightning'], payment_method: 'lightning' } } const lightningPaymentRequest = - paymentData.payment_request && - paymentData.payment_request !== 'cash' && - paymentData.payment_request !== 'tap_to_pay' - ? paymentData.payment_request - : paymentData.bolt11 - ? 'lightning:' + paymentData.bolt11.toUpperCase() - : null + paymentData.payment_method === 'onchain' + ? null + : paymentData.payment_request && + paymentData.payment_request !== 'cash' && + paymentData.payment_request !== 'tap_to_pay' + ? paymentData.payment_request + : paymentData.bolt11 + ? 'lightning:' + paymentData.bolt11.toUpperCase() + : null return { payment_hash: paymentData.payment_hash, payment_request: paymentData.payment_request, lightning_payment_request: lightningPaymentRequest, onchain_address: paymentData.onchain_address || null, - unified_qr: paymentData.unified_qr || null, + onchain_amount_sat: paymentData.onchain_amount_sat || null, payment_options: paymentData.payment_options || ['lightning'], payment_method: paymentData.payment_method || 'lightning' } @@ -972,16 +975,22 @@ window.app = Vue.createApp({ selectPaymentMethod(method) { this.currency_choice = false if (this._currencyResolver) { - if (method == 'fiat_tap') { - this.fiatMethod = 'terminal' - method = 'fiat' - } else if (method == 'fiat') { - this.fiatMethod = 'checkout' - } else if (method == 'cash') { - this.fiatMethod = 'cash' - method = 'fiat' - } else if (method == 'btc') { - this.fiatMethod = 'checkout' + switch (method) { + case 'fiat_tap': + this.fiatMethod = 'terminal' + method = 'fiat' + break + case 'fiat': + this.fiatMethod = 'checkout' + break + case 'cash': + this.fiatMethod = 'cash' + method = 'fiat' + break + case 'btc': + case 'btc_onchain': + this.fiatMethod = 'checkout' + break } this._currencyResolver(method) this._currencyResolver = null @@ -996,7 +1005,8 @@ window.app = Vue.createApp({ exchange_rate: this.exchangeRate, internal_memo: this.invoiceDialog.internalMemo || null, pay_in_fiat: this.payInFiat, - fiat_method: this.fiatMethod + fiat_method: this.fiatMethod, + payment_method: this.invoiceDialog.data.payment_method || 'btc' } if (this.currency != g.settings.denomination) { params.amount_fiat = paymentAmount @@ -1053,9 +1063,16 @@ window.app = Vue.createApp({ return } - if (this.fiatProvider || this.allowCashSettlement) { + if ( + this.fiatProvider || + this.allowCashSettlement || + this.onchainEnabled + ) { const method = await this.showPaymentMethod() this.payInFiat = method === 'fiat' + this.invoiceDialog.data.payment_method = method + } else { + this.invoiceDialog.data.payment_method = 'btc' } const params = this.buildInvoiceParams() @@ -1125,7 +1142,8 @@ window.app = Vue.createApp({ if (this.paymentWsByHash[paymentHash]) return this.startPaymentChecker(paymentHash) try { - const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const wsProtocol = + window.location.protocol === 'https:' ? 'wss:' : 'ws:' const url = new URL(`/api/v1/ws/${paymentHash}`, window.location.origin) url.protocol = wsProtocol const ws = new WebSocket(url) @@ -1638,6 +1656,7 @@ window.app = Vue.createApp({ new URL(window.location.href).searchParams.get('wrapper') === 'true' this.fiatProvider = tpos.fiat_provider this.allowCashSettlement = Boolean(tpos.allow_cash_settlement) + this.onchainEnabled = Boolean(tpos.onchain_enabled) this.tip_options = tpos.tip_options == 'null' ? null : tpos.tip_options diff --git a/tasks.py b/tasks.py index d2f1a0b..47ad857 100644 --- a/tasks.py +++ b/tasks.py @@ -10,7 +10,7 @@ pay_invoice, websocket_updater, ) -from lnbits.tasks import register_invoice_listener +from lnbits.tasks import internal_invoice_queue_put, register_invoice_listener from loguru import logger from .crud import ( @@ -108,13 +108,13 @@ async def settle_onchain_tpos_payment(tpos_payment) -> None: if not payment or not payment.extra or payment.extra.get("tag") != "tpos": return - if payment.extra.get("tpos_processed"): + if payment.success: return payment.extra["payment_method"] = "onchain" payment.extra["settled_by_onchain"] = True await update_payment(payment) - await process_paid_tpos_payment(payment, payment_method="onchain") + await internal_invoice_queue_put(payment.checking_id) async def process_paid_tpos_payment( @@ -180,9 +180,6 @@ async def process_paid_tpos_payment( if not tip_amount: return - if payment_method == "onchain": - logger.warning("tpos: skipping tip split for onchain payment.") - return wallet_id = tpos.tip_wallet if not wallet_id: @@ -205,6 +202,8 @@ async def process_paid_tpos_payment( def _payment_method(payment: Payment) -> str: + if payment.extra.get("payment_method"): + return str(payment.extra["payment_method"]) if payment.extra.get("fiat_method") == "cash": return "cash" if payment.extra.get("fiat_payment_request", "").startswith("pi_"): diff --git a/templates/tpos/dialogs.html b/templates/tpos/dialogs.html index a2eb433..d4b1050 100644 --- a/templates/tpos/dialogs.html +++ b/templates/tpos/dialogs.html @@ -375,7 +375,7 @@
size="xl" color="primary" rounded - aria-label="Bitcoin" + aria-label="Lightning Network" @click="selectPaymentMethod('btc')" >
@@ -383,6 +383,26 @@
class="text-h4 text-weight-bold" v-text="bitcoinSymbol" > + Lightning +
+ +
+
+ +
+ + Onchain
diff --git a/templates/tpos/index.html b/templates/tpos/index.html index 0e11fe5..0d4de82 100644 --- a/templates/tpos/index.html +++ b/templates/tpos/index.html @@ -506,7 +506,8 @@
{{SITE_TITLE}} TPoS extension
label="Accept onchain payment at 0-conf" > - If disabled, TPoS waits for the first confirmation before completing the sale. + If disabled, TPoS waits for the first confirmation before + completing the sale.
diff --git a/views_api.py b/views_api.py index 3d10cfe..a7662a8 100644 --- a/views_api.py +++ b/views_api.py @@ -204,15 +204,9 @@ async def _validate_watchonly_settings( } -def _build_bip21(onchain_address: str, amount_sat: int, bolt11: str | None = None) -> str: - amount_btc = amount_sat / 100_000_000 - bip21 = f"bitcoin:{onchain_address.upper()}?amount={amount_btc:.8f}" - if bolt11: - bip21 += f"&lightning={bolt11.upper()}" - return bip21 - - def _payment_method_from_payment(payment: Payment) -> str: + if payment.extra.get("payment_method"): + return str(payment.extra["payment_method"]) if payment.extra.get("fiat_method") == "cash": return "cash" if payment.extra.get("fiat_payment_request", "").startswith("pi_"): @@ -233,16 +227,12 @@ def _serialize_tpos_invoice_response( payment_request = payment.extra["fiat_payment_request"] elif payment_method == "fiat": payment_request = "tap_to_pay" + elif payment_method == "onchain" and tpos_payment.onchain_address: + payment_request = tpos_payment.onchain_address options = [payment_method] - unified_qr = None if tpos_payment.onchain_address: - options = ["uqr", "lightning", "onchain"] - unified_qr = _build_bip21( - tpos_payment.onchain_address, - tpos_payment.amount, - payment.bolt11 if payment_method == "lightning" else payment.bolt11, - ) + options = ["btc", "btc_onchain"] return TposInvoiceResponse( payment_hash=payment.payment_hash, @@ -251,7 +241,9 @@ def _serialize_tpos_invoice_response( tpos_payment_id=tpos_payment.id, payment_options=options, onchain_address=tpos_payment.onchain_address, - unified_qr=unified_qr, + onchain_amount_sat=( + tpos_payment.amount if tpos_payment.onchain_address else None + ), payment_method=payment_method, extra=payment.extra or {}, ) @@ -338,7 +330,9 @@ async def api_tpos_update( raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.") user = await get_user(wallet.wallet.user) update_payload = data.dict(exclude_unset=True) - desired_onchain_enabled = update_payload.get("onchain_enabled", tpos.onchain_enabled) + desired_onchain_enabled = update_payload.get( + "onchain_enabled", tpos.onchain_enabled + ) desired_onchain_wallet_id = update_payload.get( "onchain_wallet_id", tpos.onchain_wallet_id ) @@ -533,11 +527,17 @@ async def api_tpos_create_invoice( } cash_method = data.pay_in_fiat and data.fiat_method == "cash" + onchain_method = data.payment_method == "btc_onchain" if cash_method and not tpos.allow_cash_settlement: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Cash settlement is not enabled for this TPoS.", ) + if onchain_method and not tpos.onchain_enabled: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Onchain payments are not enabled for this TPoS.", + ) currency = tpos.currency if data.pay_in_fiat else "sat" amount = data.amount + (data.tip_amount or 0.0) if data.pay_in_fiat: @@ -557,18 +557,23 @@ async def api_tpos_create_invoice( "paid_in_fiat": data.pay_in_fiat, "base_url": str(request.base_url), } - if cash_method: + if cash_method or onchain_method: wallet = await get_wallet(tpos.wallet) if wallet: account = await get_account(wallet.user) if account: existing = {label.name for label in account.extra.labels or []} - if "cash" not in existing: + label_name = "cash" if cash_method else "onchain" + label_description = ( + "Cash payment" if cash_method else "Onchain payment" + ) + label_color = "#FFC107" if cash_method else "#ED8403" + if label_name not in existing: account.extra.labels.append( UserLabel( - name="cash", - description="Cash payment", - color="#FFC107", + name=label_name, + description=label_description, + color=label_color, ) ) await update_account(account) @@ -578,6 +583,8 @@ async def api_tpos_create_invoice( extra["fiat_method"] = data.fiat_method if data.fiat_method else "checkout" if data.fiat_method == "terminal" and tpos.stripe_reader_id: extra["terminal"] = {"reader_id": tpos.stripe_reader_id} + if onchain_method: + extra["payment_method"] = "onchain" invoice_data = CreateInvoice( unit=currency, out=False, @@ -587,18 +594,22 @@ async def api_tpos_create_invoice( fiat_provider=( tpos.fiat_provider if data.pay_in_fiat and not cash_method else None ), - internal=bool(cash_method), - labels=["cash"] if cash_method else [], + internal=bool(cash_method or onchain_method), + labels=["cash"] if cash_method else (["onchain"] if onchain_method else []), ) payment = await create_payment_request(tpos.wallet, invoice_data) if cash_method: new_checking_id = f"internal_cash_{payment.payment_hash}" await update_payment_checking_id(payment.checking_id, new_checking_id) payment.checking_id = new_checking_id + elif onchain_method: + new_checking_id = f"internal_onchain_{payment.payment_hash}" + await update_payment_checking_id(payment.checking_id, new_checking_id) + payment.checking_id = new_checking_id onchain_address = None mempool_endpoint = None - if tpos.onchain_enabled and not data.pay_in_fiat: + if onchain_method: wallet_record = await get_wallet(tpos.wallet) if not wallet_record: raise HTTPException( @@ -644,7 +655,7 @@ async def api_tpos_create_invoice( "tpos_payment_id": response_payload.tpos_payment_id, "payment_options": response_payload.payment_options, "onchain_address": response_payload.onchain_address, - "unified_qr": response_payload.unified_qr, + "onchain_amount_sat": response_payload.onchain_amount_sat, "payment_method": response_payload.payment_method, } await websocket_updater(tpos_id, json.dumps(payload)) From 2c0d6299911b0600e83af5fbd89d9ee8cc9f6024 Mon Sep 17 00:00:00 2001 From: arcbtc Date: Wed, 25 Mar 2026 12:18:02 +0000 Subject: [PATCH 05/10] bip21 for onchain --- static/components/payment-dialog.js | 31 +++++++++++++++++++++++++---- templates/tpos/_cart.html | 6 +++--- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/static/components/payment-dialog.js b/static/components/payment-dialog.js index 8332b10..bd6f45c 100644 --- a/static/components/payment-dialog.js +++ b/static/components/payment-dialog.js @@ -14,9 +14,34 @@ window.app.component('tpos-payment-dialog', { isOnchain() { return this.dialogData?.payment_method === 'onchain' }, + onchainUri() { + const address = this.dialogData?.onchain_address + if (!address) return null + + const params = new URLSearchParams() + const satAmount = Number(this.dialogData?.onchain_amount_sat || 0) + + if (satAmount > 0) { + const btcAmount = (satAmount / 100000000) + .toFixed(8) + .replace(/\.?0+$/, '') + params.set('amount', btcAmount) + } + + if (tpos?.name) { + params.set('label', `LNbits TPoS ${tpos.name}`) + } else { + params.set('label', 'LNbits TPoS') + } + + params.set('message', 'Thank you for your order') + + const query = params.toString() + return `bitcoin:${address}${query ? `?${query}` : ''}` + }, qrValue() { if (this.isOnchain) { - return this.dialogData?.onchain_address || null + return this.onchainUri } return this.dialogData?.lightning_payment_request || null }, @@ -30,9 +55,7 @@ window.app.component('tpos-payment-dialog', { return this.tipOptions ? `(+ ${this.tipAmountFormatted} tip)` : '' }, onchainHref() { - return this.dialogData?.onchain_address - ? `bitcoin:${this.dialogData.onchain_address}` - : '' + return this.onchainUri || '' } }, methods: { diff --git a/templates/tpos/_cart.html b/templates/tpos/_cart.html index ecb1c02..775914d 100644 --- a/templates/tpos/_cart.html +++ b/templates/tpos/_cart.html @@ -169,11 +169,11 @@
class="cursor-pointer" > -
{% include "tpos/_options_fab.html" %}
+
{% include "tpos/_options_fab.html" %}