diff --git a/crud.py b/crud.py index 930915e..ccbdcdc 100644 --- a/crud.py +++ b/crud.py @@ -72,7 +72,11 @@ async def get_tickets_paginated( wallet_where.append(f":{key}") values[key] = wallet_id - where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"] + where = [ + f"wallet IN ({', '.join(wallet_where)})", + "(paid = true OR extra LIKE :onchain_pattern)", + ] + values["onchain_pattern"] = '%"onchain": true%' return await db.fetch_page( "SELECT * FROM events.ticket", diff --git a/models.py b/models.py index a16f7dd..a5399f2 100644 --- a/models.py +++ b/models.py @@ -113,6 +113,8 @@ class TicketExtra(BaseModel): nostr_notification_sent: bool = False refunded: bool = False onchain: bool = False + onchain_address: str | None = None + onchain_mempool_endpoint: str | None = None class CreateTicket(BaseModel): diff --git a/services.py b/services.py index 84c16e0..1e80dc8 100644 --- a/services.py +++ b/services.py @@ -72,6 +72,31 @@ async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: return resp.json() +async def check_onchain_payment(ticket: Ticket) -> bool: + address = ticket.extra.onchain_address + endpoint = ticket.extra.onchain_mempool_endpoint + expected_sats = ticket.extra.sats_paid or 0 + if not address or not endpoint: + return False + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{endpoint}/api/address/{address}/txs", + timeout=10.0, + ) + resp.raise_for_status() + txs = resp.json() + for tx in txs: + if not tx.get("status", {}).get("confirmed"): + continue + for vout in tx.get("vout", []): + if ( + vout.get("scriptpubkey_address") == address + and vout.get("value", 0) >= expected_sats + ): + return True + return False + + async def set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: return ticket diff --git a/static/js/display.js b/static/js/display.js index a5cc160..896a9e8 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -34,7 +34,8 @@ window.PageEventsDisplay = { mempoolEndpoint: null }, paymentDismissMsg: null, - paymentWebsocket: null + paymentWebsocket: null, + onchainPollTimer: null } }, async created() { @@ -148,6 +149,10 @@ window.PageEventsDisplay = { }, closeReceiveDialog() { + if (this.onchainPollTimer) { + clearTimeout(this.onchainPollTimer) + this.onchainPollTimer = null + } if (this.paymentDismissMsg) { this.paymentDismissMsg() this.paymentDismissMsg = null @@ -262,10 +267,29 @@ window.PageEventsDisplay = { window.open(this.paymentReq, '_blank', 'noopener') } this.paymentWatcher(this.paymentHash) + if (isOnchain) this.startOnchainPolling(this.paymentHash) } catch (error) { LNbits.utils.notifyApiError(error) } }, + startOnchainPolling(paymentHash) { + if (this.onchainPollTimer) clearTimeout(this.onchainPollTimer) + const poll = async () => { + if (!this.receive.show) return + try { + await LNbits.api.request( + 'POST', + `/events/api/v1/tickets/${paymentHash}/onchain-check`, + null + ) + // confirmed — WebSocket fires paymentSuccess, don't reschedule + } catch (error) { + if (!this.receive.show) return + this.onchainPollTimer = setTimeout(poll, 30000) + } + } + this.onchainPollTimer = setTimeout(poll, 30000) + }, paymentWatcher(paymentHash) { if (this.paymentWebsocket) { this.paymentWebsocket.close() @@ -291,8 +315,12 @@ window.PageEventsDisplay = { console.error('WebSocket error:', error) } ws.onclose = () => { - if (this.paymentWebsocket === ws) { - this.paymentWebsocket = null + if (this.paymentWebsocket !== ws) return + this.paymentWebsocket = null + if (this.receive.show) { + setTimeout(() => { + if (this.receive.show) this.paymentWatcher(paymentHash) + }, 3000) } } } diff --git a/views_api.py b/views_api.py index 782fd5b..3dd807b 100644 --- a/views_api.py +++ b/views_api.py @@ -66,6 +66,7 @@ get_active_ticket_waves, ) from .services import ( + check_onchain_payment, fetch_onchain_address, fetch_watchonly_config, fetch_watchonly_wallet, @@ -619,6 +620,8 @@ async def api_ticket_create( onchain_amount_sat if payment_method == "onchain" else payment.sat ), "onchain": payment_method == "onchain", + "onchain_address": onchain_address, + "onchain_mempool_endpoint": onchain_mempool_endpoint, }, ) @@ -692,6 +695,33 @@ async def api_ticket_delete( await delete_ticket(ticket_id) +@tickets_api_router.post("/{payment_hash}/onchain-check") +async def api_ticket_onchain_check(payment_hash: str) -> Ticket: + ticket = await get_ticket(payment_hash) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + if ticket.paid: + return ticket + if not ticket.extra.onchain: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Not an onchain ticket.", + ) + found = await check_onchain_payment(ticket) + if not found: + raise HTTPException( + status_code=HTTPStatus.PAYMENT_REQUIRED, + detail="Payment not found on chain.", + ) + ticket = await set_ticket_paid(ticket) + send_ticket_notification_in_background(ticket) + for queue in payment_listeners.get(payment_hash, []): + queue.put_nowait(ticket) + return ticket + + @tickets_api_router.put("/{payment_hash}/onchain-confirm") async def api_ticket_onchain_confirm( payment_hash: str,