From b975765e55914a35b03f8010a1960b8b340db988 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 27 Oct 2026 12:31:19 +0000 Subject: [PATCH 1/4] feat: support core onchain wallets in Events --- Makefile | 2 +- models.py | 7 ++ services.py | 12 ++ static/js/i18n/de.js | 13 ++ static/js/i18n/en.js | 13 ++ static/js/i18n/es.js | 13 ++ static/js/index.js | 56 ++++++++- static/js/index.vue | 62 ++++++++-- tests/test_events.py | 269 +++++++++++++++++++++++++++++++++++++++++- tests/test_onchain.js | 98 +++++++++++++++ views_api.py | 115 +++++++++++------- 11 files changed, 597 insertions(+), 63 deletions(-) create mode 100644 tests/test_onchain.js diff --git a/Makefile b/Makefile index 2e7c4a9..7b2a9ee 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ test: uv run pytest test-js: - node --test tests/test_registration.js + node --test tests/test_*.js install-pre-commit-hook: @echo "Installing pre-commit hook to git" @echo "Uninstall the hook with uv run pre-commit uninstall" diff --git a/models.py b/models.py index 4b933e5..5e86fe4 100644 --- a/models.py +++ b/models.py @@ -1,3 +1,4 @@ +import re from datetime import date, datetime from uuid import uuid4 @@ -55,6 +56,12 @@ class EventExtra(PublicEventExtra): onchain_zeroconf: bool = False onchain_fasttrack: bool = False + @validator("onchain_wallet_id") + def validate_onchain_wallet_id(cls, value): + if value and not re.fullmatch(r"(?:core:)?[a-zA-Z0-9_-]+", value): + raise ValueError("Invalid onchain wallet reference") + return value + class CreateEvent(BaseModel): wallet: str diff --git a/services.py b/services.py index 942584e..d841ac5 100644 --- a/services.py +++ b/services.py @@ -82,6 +82,16 @@ async def get_satspay_charge(api_key: str, charge_id: str) -> dict[str, Any]: return resp.json() +async def fetch_core_onchain_wallets(api_key: str) -> list[dict[str, Any]]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/satspay/api/v1/onchain-wallets", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]: async with httpx.AsyncClient() as client: resp = await client.post( @@ -89,6 +99,8 @@ async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]: headers={"X-API-KEY": api_key}, json=data, ) + if resp.status_code == 400: + raise ValueError(resp.json().get("detail", "Cannot create SatsPay charge.")) resp.raise_for_status() return resp.json() diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index ca3c2ea..5c35b85 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -65,10 +65,23 @@ window.i18n.global.mergeLocaleMessage('de', { onchain_payments: 'Onchain-Zahlungen', onchain_desc: 'Bitcoin-Onchain-Zahlungen akzeptieren. Erfordert die installierten und aktivierten Erweiterungen SatsPay und Watchonly.', + onchain_sources_desc: + 'Bitcoin-Onchain-Zahlungen über SatsPay mit einer LNbits-Onchain-Wallet oder der Watchonly-Erweiterung akzeptieren.', accept_onchain: 'Onchain-Zahlungen akzeptieren', watchonly_wallet: 'Watchonly-Wallet', watchonly_wallet_hint: 'Bitcoin-Watchonly-Wallet für den Empfang von Onchain-Zahlungen', + onchain_wallet: 'Onchain-Wallet', + onchain_wallet_hint: + 'LNbits- oder Watchonly-Wallet für den Empfang von Onchain-Zahlungen', + onchain_wallet_not_ready: 'Nicht bereit', + create_onchain_wallet: 'Onchain-Wallet erstellen', + onchain_wallet_setup_required: + 'Richte diese Onchain-Wallet vor dem Empfang ein.', + onchain_wallet_backup_required: + 'Sichere diese Onchain-Wallet vor dem Empfang.', + setup_onchain_wallet: 'Wallet einrichten', + backup_onchain_wallet: 'Wallet sichern', onchain_zeroconf: 'Zero-Conf (unbestätigte Transaktionen akzeptieren)', onchain_fasttrack: 'Fasttrack (ausstehende als bezahlt behandeln)', update_event: 'Veranstaltung aktualisieren', diff --git a/static/js/i18n/en.js b/static/js/i18n/en.js index 6f8d08f..c9e5126 100644 --- a/static/js/i18n/en.js +++ b/static/js/i18n/en.js @@ -63,10 +63,23 @@ window.i18n.global.mergeLocaleMessage('en', { onchain_payments: 'Onchain payments', onchain_desc: 'Accept Bitcoin onchain payments. Requires the SatsPay and Watchonly extensions to be installed and enabled.', + onchain_sources_desc: + 'Accept Bitcoin onchain payments through SatsPay using an LNbits onchain wallet or the Watchonly extension.', accept_onchain: 'Accept onchain payments', watchonly_wallet: 'Watchonly wallet', watchonly_wallet_hint: 'Bitcoin watchonly wallet for receiving onchain payments', + onchain_wallet: 'Onchain wallet', + onchain_wallet_hint: + 'LNbits or Watchonly wallet for receiving onchain payments', + onchain_wallet_not_ready: 'Not ready', + create_onchain_wallet: 'Create onchain wallet', + onchain_wallet_setup_required: + 'Set up this onchain wallet before receiving.', + onchain_wallet_backup_required: + 'Back up this onchain wallet before receiving.', + setup_onchain_wallet: 'Set up wallet', + backup_onchain_wallet: 'Back up wallet', onchain_zeroconf: 'Zero-conf (accept unconfirmed transactions)', onchain_fasttrack: 'Fasttrack (treat pending as paid)', update_event: 'Update Event', diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index 24c6e48..b7be524 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -67,10 +67,23 @@ window.i18n.global.mergeLocaleMessage('es', { onchain_payments: 'Pagos onchain', onchain_desc: 'Aceptar pagos Bitcoin onchain. Requiere las extensiones SatsPay y Watchonly instaladas y habilitadas.', + onchain_sources_desc: + 'Aceptar pagos Bitcoin onchain mediante SatsPay con una cartera onchain de LNbits o la extensión Watchonly.', accept_onchain: 'Aceptar pagos onchain', watchonly_wallet: 'Cartera watchonly', watchonly_wallet_hint: 'Cartera Bitcoin watchonly para recibir pagos onchain', + onchain_wallet: 'Cartera onchain', + onchain_wallet_hint: + 'Cartera de LNbits o Watchonly para recibir pagos onchain', + onchain_wallet_not_ready: 'No está lista', + create_onchain_wallet: 'Crear cartera onchain', + onchain_wallet_setup_required: + 'Configura esta cartera onchain antes de recibir pagos.', + onchain_wallet_backup_required: + 'Haz una copia de seguridad de esta cartera onchain antes de recibir pagos.', + setup_onchain_wallet: 'Configurar cartera', + backup_onchain_wallet: 'Respaldar cartera', onchain_zeroconf: 'Zero-conf (aceptar transacciones no confirmadas)', onchain_fasttrack: 'Fasttrack (tratar pendientes como pagados)', update_event: 'Actualizar evento', diff --git a/static/js/index.js b/static/js/index.js index b7d535c..15613f2 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,6 +1,43 @@ window.PageEvents = { template: '#page-events', computed: { + lightningWalletOptions() { + const walletIds = this.g.user.wallets + .filter( + wallet => + ['lightning', 'lightning-shared'].includes(wallet.walletType) && + wallet.canReceivePayments + ) + .map(wallet => wallet.id) + return this.g.user.walletOptions.filter(wallet => + walletIds.includes(wallet.value) + ) + }, + onchainWalletOptions() { + const core = this.coreOnchainWallets.map(wallet => ({ + label: + `LNbits: ${wallet.name}` + + (wallet.status === 'ready' + ? '' + : ` (${this.$t('events.onchain_wallet_not_ready')})`), + value: `core:${wallet.id}` + })) + const watchonly = this.onchainWallets.map(wallet => ({ + label: `Watchonly: ${wallet.title || wallet.id}`, + value: wallet.id + })) + return [...core, ...watchonly] + }, + selectedCoreWallet() { + const reference = this.formDialog.data.extra?.onchain_wallet_id + if (!reference?.startsWith('core:')) return null + return ( + this.coreOnchainWallets.find(w => w.id === reference.slice(5)) || null + ) + }, + hasCoreOnchainWallet() { + return this.g.user.wallets.some(wallet => wallet.walletType === 'onchain') + }, eventsColumns() { return [ { @@ -150,7 +187,9 @@ window.PageEvents = { } } }, - onchainWallets: [] + onchainWallets: [], + coreOnchainWallets: [], + onchainMessages: [] } }, methods: { @@ -175,6 +214,9 @@ window.PageEvents = { return value.length > 4 ? `${value.slice(0, 4)}...` : value }, async loadOnchainWallets() { + this.onchainWallets = [] + this.coreOnchainWallets = [] + this.onchainMessages = [] const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) @@ -186,10 +228,18 @@ window.PageEvents = { wallet.adminkey ) this.onchainWallets = data.available ? data.wallets || [] : [] - } catch { - this.onchainWallets = [] + this.coreOnchainWallets = data.core_wallets || [] + this.onchainMessages = [data.core_message, data.message].filter(Boolean) + } catch (error) { + LNbits.utils.notifyApiError(error) } }, + createOnchainWallet() { + this.g.newWalletType = 'onchain' + }, + openOnchainWallet(walletId) { + this.$router.push({path: '/wallet', query: {wal: walletId}}) + }, async confirmOnchainTicket(ticket) { const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet}) if (!wallet) return diff --git a/static/js/index.vue b/static/js/index.vue index e7ddaee..4a14a65 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -374,8 +374,13 @@ dense emit-value v-model="formDialog.data.wallet" - :options="g.user.walletOptions" + :options="lightningWalletOptions" :label="$t('events.wallet_label')" + @update:model-value=" + () => + formDialog.data.extra.onchain_enabled && + loadOnchainWallets() + " > @@ -638,7 +643,7 @@
+ + + + + +
+ +
items.find(w => w.id === query.id)}, + LNbits: { + api: {request: async () => ({data})}, + utils: { + notifyApiError: error => { + throw error + } + } + } + } + vm.runInNewContext( + fs.readFileSync(path.join(__dirname, '../static/js/index.js'), 'utf8'), + context + ) + const component = context.window.PageEvents + const state = { + g: {user: {wallets: [{id: 'merchant', adminkey: 'key'}]}}, + formDialog: { + data: {wallet: 'merchant', extra: {onchain_wallet_id: 'watch-id'}} + }, + onchainWallets: [], + coreOnchainWallets: [], + $t: key => key + } + return {component, state} +} + +test('core choices precede watchonly and loading preserves saved selections', async () => { + const {component, state} = page({ + available: true, + wallets: [{id: 'watch-id', title: 'Cold'}], + core_wallets: [ + {id: 'core-id', name: 'Savings', status: 'ready'}, + {id: 'setup-id', name: 'New', status: 'setup'} + ] + }) + await component.methods.loadOnchainWallets.call(state) + const options = component.computed.onchainWalletOptions.call(state) + assert.deepEqual( + Array.from(options, option => option.value), + ['core:core-id', 'core:setup-id', 'watch-id'] + ) + assert.equal(options[0].label, 'LNbits: Savings') + assert.match(options[1].label, /not_ready/) + assert.equal(options[2].label, 'Watchonly: Cold') + assert.equal(state.formDialog.data.extra.onchain_wallet_id, 'watch-id') + state.formDialog.data.extra.onchain_wallet_id = 'core:setup-id' + assert.equal( + component.computed.selectedCoreWallet.call(state).status, + 'setup' + ) +}) + +test('watchonly failure leaves core choices available without auto-selecting', async () => { + const {component, state} = page({ + available: false, + wallets: [], + core_wallets: [{id: 'core-id', name: 'Savings', status: 'ready'}], + message: 'Watchonly unavailable' + }) + state.formDialog.data.extra.onchain_wallet_id = null + await component.methods.loadOnchainWallets.call(state) + assert.equal(component.computed.onchainWalletOptions.call(state).length, 1) + assert.equal(state.onchainMessages[0], 'Watchonly unavailable') + assert.equal(state.formDialog.data.extra.onchain_wallet_id, null) + component.methods.createOnchainWallet.call(state) + assert.equal(state.g.newWalletType, 'onchain') + state.$router = {push: route => assert.equal(route.query.wal, 'core-id')} + component.methods.openOnchainWallet.call(state, 'core-id') +}) + +test('invoice wallet choices exclude non-Lightning and receive-disabled wallets', () => { + const {component, state} = page({}) + state.g.user.wallets = [ + {id: 'ln', walletType: 'lightning', canReceivePayments: true}, + {id: 'shared', walletType: 'lightning-shared', canReceivePayments: true}, + {id: 'readonly', walletType: 'lightning-shared', canReceivePayments: false}, + {id: 'chain', walletType: 'onchain', canReceivePayments: true}, + {id: 'fiat', walletType: 'fiat', canReceivePayments: true} + ] + state.g.user.walletOptions = state.g.user.wallets.map(wallet => ({ + value: wallet.id + })) + const choices = component.computed.lightningWalletOptions.call(state) + assert.deepEqual( + choices.map(wallet => wallet.value), + ['ln', 'shared'] + ) +}) diff --git a/views_api.py b/views_api.py index 01387a3..66dbfe0 100644 --- a/views_api.py +++ b/views_api.py @@ -67,6 +67,7 @@ ) from .services import ( create_satspay_charge, + fetch_core_onchain_wallets, fetch_watchonly_config, fetch_watchonly_wallets, get_event_base_url, @@ -89,25 +90,21 @@ async def _get_watchonly_status(wallet) -> dict[str, Any]: + unavailable = { + "available": False, + "message": "Watchonly wallets could not be loaded.", + "network": None, + "wallets": [], + "mempool_endpoint": None, + } try: config = await fetch_watchonly_config(wallet.inkey) network = config.get("network") if not network: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Watchonly extension returned an invalid network.", - ) + return unavailable wallets = await fetch_watchonly_wallets(wallet.inkey, network) - except HTTPException: - raise - except Exception as exc: - return { - "available": False, - "message": f"Watchonly extension is not reachable: {exc!s}", - "network": None, - "wallets": [], - "mempool_endpoint": None, - } + except Exception: + return unavailable return { "available": True, "message": None, @@ -170,7 +167,16 @@ async def api_events( async def api_onchain_status( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict[str, Any]: - return await _get_watchonly_status(wallet.wallet) + status = await _get_watchonly_status(wallet.wallet) + try: + status["core_wallets"] = await fetch_core_onchain_wallets(wallet.wallet.inkey) + status["core_message"] = None + except Exception: + status["core_wallets"] = [] + status["core_message"] = ( + "LNbits onchain wallets could not be loaded via SatsPay." + ) + return status @events_api_router.get("/{event_id}", response_model=PublicEvent) @@ -208,44 +214,56 @@ async def api_get_event(event_id: str) -> Event: @events_api_router.post("") -@events_api_router.put("/{event_id}") async def api_event_create( data: CreateEvent, wallet: WalletTypeInfo = Depends(require_admin_key), - event_id: str | None = None, ) -> Event: - if event_id: - event = await get_event(event_id) - if not event: + user = await get_user(wallet.wallet.user) + if not user or data.wallet not in user.wallet_ids: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your wallet.") + reference = data.extra.onchain_wallet_id + if data.extra.onchain_enabled and reference and reference.startswith("core:"): + if reference[5:] not in user.wallet_ids: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + status_code=HTTPStatus.FORBIDDEN, detail="Not your onchain wallet." ) + return await create_event(data) - if event.wallet != wallet.wallet.id: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Not your event." - ) - event = Event( - **{ - **event.model_dump(), - **data.model_dump(), - "id": event.id, - "wallet": event.wallet, - "time": event.time, - "sold": event.sold, - "canceled": event.canceled, - "close_processed": False, - } - ) - event = await update_event(event) - else: - if data.wallet != wallet.wallet.id: + +@events_api_router.put("/{event_id}") +async def api_event_update( + event_id: str, + data: CreateEvent, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Event: + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + user = await get_user(wallet.wallet.user) + if not user or event.wallet not in user.wallet_ids: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") + if data.wallet not in user.wallet_ids: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your wallet.") + reference = data.extra.onchain_wallet_id + if data.extra.onchain_enabled and reference and reference.startswith("core:"): + if reference[5:] not in user.wallet_ids: raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Not your wallet." + status_code=HTTPStatus.FORBIDDEN, detail="Not your onchain wallet." ) - event = await create_event(data) - - return event + event = Event( + **{ + **event.dict(), + **data.dict(), + "id": event.id, + "time": event.time, + "sold": event.sold, + "canceled": event.canceled, + "close_processed": False, + } + ) + return await update_event(event) @events_api_router.put("/{event_id}/cancel") @@ -561,11 +579,17 @@ async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentR "time": 1440, }, ) - except Exception as exc: + except ValueError as exc: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"Failed to create SatsPay charge: {exc}", ) from exc + except Exception as exc: + logger.warning("Could not create Events SatsPay charge.") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Failed to create SatsPay charge. Check SatsPay is available.", + ) from exc await create_ticket( payment_hash=ticket_id, @@ -688,6 +712,7 @@ async def api_ticket_delete( await delete_ticket(ticket_id) +@tickets_api_router.get("/{ticket_id}/satspay-webhook") @tickets_api_router.post("/{ticket_id}/satspay-webhook") async def api_ticket_satspay_webhook(ticket_id: str) -> None: ticket = await get_ticket(ticket_id) From baf181e77942744b0d02a801e35e442d3795b00a Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 27 Oct 2026 12:31:23 +0000 Subject: [PATCH 2/4] fix: update ticket payment status after checkout redirect --- static/js/ticket.js | 20 ++++++++++++++- tests/test_ticket.js | 61 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/test_ticket.js diff --git a/static/js/ticket.js b/static/js/ticket.js index 6ff55b6..3d71d96 100644 --- a/static/js/ticket.js +++ b/static/js/ticket.js @@ -5,7 +5,8 @@ window.PageEventsTicket = { ticketId: null, ticket: null, printMode: false, - qrSrc: '' + qrSrc: '', + paymentWebsocket: null } }, methods: { @@ -28,6 +29,9 @@ window.PageEventsTicket = { }) } }, + beforeUnmount() { + this.paymentWebsocket?.close() + }, async created() { this.ticketId = this.$route.params.id this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent( @@ -39,6 +43,20 @@ window.PageEventsTicket = { `/events/api/v1/tickets/${this.ticketId}` ) this.ticket = data + if (!data.paid) { + const url = new URL(window.location) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.pathname = `/events/api/v1/tickets/ws/${this.ticketId}` + url.search = '' + url.hash = '' + this.paymentWebsocket = new WebSocket(url.toString()) + this.paymentWebsocket.onmessage = event => { + if (JSON.parse(event.data).paid === true) { + this.ticket.paid = true + this.paymentWebsocket.close() + } + } + } } catch (error) { LNbits.utils.notifyApiError(error) } diff --git a/tests/test_ticket.js b/tests/test_ticket.js new file mode 100644 index 0000000..f48e485 --- /dev/null +++ b/tests/test_ticket.js @@ -0,0 +1,61 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const test = require('node:test') +const vm = require('node:vm') + +for (const paid of [false, true]) { + test(`ticket paid=${paid} handles payment after redirect`, async () => { + const sockets = [] + const context = { + window: { + location: + 'https://tickets.example/events/ticket/ticket?ignored=1#ignored', + addEventListener() {} + }, + URL, + encodeURIComponent, + WebSocket: class { + constructor(url) { + this.url = url + this.closed = false + sockets.push(this) + } + close() { + this.closed = true + } + }, + LNbits: { + api: {request: async () => ({data: {paid}})}, + utils: { + notifyApiError: error => { + throw error + } + } + } + } + vm.runInNewContext( + fs.readFileSync(path.join(__dirname, '../static/js/ticket.js'), 'utf8'), + context + ) + const component = context.window.PageEventsTicket + const state = {...component.data(), $route: {params: {id: 'ticket'}}} + await component.created.call(state) + assert.equal(sockets.length, paid ? 0 : 1) + if (!paid) { + const socket = sockets[0] + assert.equal( + socket.url, + 'wss://tickets.example/events/api/v1/tickets/ws/ticket' + ) + socket.onmessage({data: JSON.stringify({paid: false})}) + assert.equal(state.ticket.paid, false) + socket.onmessage({data: JSON.stringify({paid: true})}) + assert.equal(state.ticket.paid, true) + assert.equal(socket.closed, true) + socket.closed = false + component.beforeUnmount.call(state) + assert.equal(socket.closed, true) + } + }) +} From a96316e30cd0828df923cea12e6570acc5919587 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 27 Oct 2026 12:38:35 +0000 Subject: [PATCH 3/4] revert: remove ticket payment websocket --- static/js/ticket.js | 20 +-------------- tests/test_ticket.js | 61 -------------------------------------------- 2 files changed, 1 insertion(+), 80 deletions(-) delete mode 100644 tests/test_ticket.js diff --git a/static/js/ticket.js b/static/js/ticket.js index 3d71d96..6ff55b6 100644 --- a/static/js/ticket.js +++ b/static/js/ticket.js @@ -5,8 +5,7 @@ window.PageEventsTicket = { ticketId: null, ticket: null, printMode: false, - qrSrc: '', - paymentWebsocket: null + qrSrc: '' } }, methods: { @@ -29,9 +28,6 @@ window.PageEventsTicket = { }) } }, - beforeUnmount() { - this.paymentWebsocket?.close() - }, async created() { this.ticketId = this.$route.params.id this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent( @@ -43,20 +39,6 @@ window.PageEventsTicket = { `/events/api/v1/tickets/${this.ticketId}` ) this.ticket = data - if (!data.paid) { - const url = new URL(window.location) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.pathname = `/events/api/v1/tickets/ws/${this.ticketId}` - url.search = '' - url.hash = '' - this.paymentWebsocket = new WebSocket(url.toString()) - this.paymentWebsocket.onmessage = event => { - if (JSON.parse(event.data).paid === true) { - this.ticket.paid = true - this.paymentWebsocket.close() - } - } - } } catch (error) { LNbits.utils.notifyApiError(error) } diff --git a/tests/test_ticket.js b/tests/test_ticket.js deleted file mode 100644 index f48e485..0000000 --- a/tests/test_ticket.js +++ /dev/null @@ -1,61 +0,0 @@ -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') -const test = require('node:test') -const vm = require('node:vm') - -for (const paid of [false, true]) { - test(`ticket paid=${paid} handles payment after redirect`, async () => { - const sockets = [] - const context = { - window: { - location: - 'https://tickets.example/events/ticket/ticket?ignored=1#ignored', - addEventListener() {} - }, - URL, - encodeURIComponent, - WebSocket: class { - constructor(url) { - this.url = url - this.closed = false - sockets.push(this) - } - close() { - this.closed = true - } - }, - LNbits: { - api: {request: async () => ({data: {paid}})}, - utils: { - notifyApiError: error => { - throw error - } - } - } - } - vm.runInNewContext( - fs.readFileSync(path.join(__dirname, '../static/js/ticket.js'), 'utf8'), - context - ) - const component = context.window.PageEventsTicket - const state = {...component.data(), $route: {params: {id: 'ticket'}}} - await component.created.call(state) - assert.equal(sockets.length, paid ? 0 : 1) - if (!paid) { - const socket = sockets[0] - assert.equal( - socket.url, - 'wss://tickets.example/events/api/v1/tickets/ws/ticket' - ) - socket.onmessage({data: JSON.stringify({paid: false})}) - assert.equal(state.ticket.paid, false) - socket.onmessage({data: JSON.stringify({paid: true})}) - assert.equal(state.ticket.paid, true) - assert.equal(socket.closed, true) - socket.closed = false - component.beforeUnmount.call(state) - assert.equal(socket.closed, true) - } - }) -} From 3c25c191920d1437c447740c97e33571f0af5531 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 2 Nov 2026 15:32:18 +0000 Subject: [PATCH 4/4] final fix --- models.py | 3 ++- static/js/index.js | 6 +----- tests/test_events.py | 32 +++++++++++++++++++++++++++++++- tests/test_onchain.js | 10 ++++++---- views_api.py | 4 +++- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/models.py b/models.py index 5e86fe4..3410585 100644 --- a/models.py +++ b/models.py @@ -56,7 +56,8 @@ class EventExtra(PublicEventExtra): onchain_zeroconf: bool = False onchain_fasttrack: bool = False - @validator("onchain_wallet_id") + @field_validator("onchain_wallet_id") + @classmethod def validate_onchain_wallet_id(cls, value): if value and not re.fullmatch(r"(?:core:)?[a-zA-Z0-9_-]+", value): raise ValueError("Invalid onchain wallet reference") diff --git a/static/js/index.js b/static/js/index.js index 15613f2..f4fbf81 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -3,11 +3,7 @@ window.PageEvents = { computed: { lightningWalletOptions() { const walletIds = this.g.user.wallets - .filter( - wallet => - ['lightning', 'lightning-shared'].includes(wallet.walletType) && - wallet.canReceivePayments - ) + .filter(wallet => wallet.canReceivePayments) .map(wallet => wallet.id) return this.g.user.walletOptions.filter(wallet => walletIds.includes(wallet.value) diff --git a/tests/test_events.py b/tests/test_events.py index 89b63f3..8b51475 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -105,6 +105,11 @@ async def payment_database(database, monkeypatch): @pytest.fixture def app(monkeypatch): + monkeypatch.setattr( + views_api, + "get_user_active_extensions_ids", + AsyncMock(return_value=["watchonly"]), + ) monkeypatch.setattr( views_api, "get_user", @@ -800,6 +805,31 @@ async def test_core_configuration_requires_wallet_ownership( assert save.await_count == int(expected == 200) +@pytest.mark.asyncio +async def test_onchain_status_skips_disabled_watchonly(app, monkeypatch): + authorize(app, KeyType.admin) + monkeypatch.setattr( + views_api, "get_user_active_extensions_ids", AsyncMock(return_value=[]) + ) + config = AsyncMock(side_effect=RuntimeError("Watchonly is not installed")) + watch = AsyncMock() + core = AsyncMock(return_value=[{"id": "core-id", "status": "ready"}]) + monkeypatch.setattr(views_api, "fetch_watchonly_config", config) + monkeypatch.setattr(views_api, "fetch_watchonly_wallets", watch) + monkeypatch.setattr(views_api, "fetch_core_onchain_wallets", core) + async with AsyncClient( + transport=ASGITransport(app), base_url="http://test" + ) as client: + response = await client.get("/events/api/v1/events/onchain/status") + assert response.status_code == 200 + data = response.json() + assert data["core_wallets"] == core.return_value + assert data["message"] is None + assert not data["available"] and not data["wallets"] + config.assert_not_awaited() + watch.assert_not_awaited() + + @pytest.mark.asyncio @pytest.mark.parametrize("failed_source", ["watchonly", "watchonly-invalid", "core"]) async def test_onchain_sources_fail_independently(app, monkeypatch, failed_source): @@ -978,7 +1008,7 @@ async def test_satspay_webhook_verifies_charge_before_fulfilment( method, f"/events/api/v1/tickets/{ticket.id}/satspay-webhook" ) saved = await crud.get_ticket(ticket.id) - assert saved.paid == paid + assert saved and saved.paid == paid assert notify.call_count == int(paid) assert event.sold == int(paid) charge.assert_awaited_with("merchant-key", "charge") diff --git a/tests/test_onchain.js b/tests/test_onchain.js index af8d3ec..a7f8211 100644 --- a/tests/test_onchain.js +++ b/tests/test_onchain.js @@ -78,7 +78,7 @@ test('watchonly failure leaves core choices available without auto-selecting', a component.methods.openOnchainWallet.call(state, 'core-id') }) -test('invoice wallet choices exclude non-Lightning and receive-disabled wallets', () => { +test('invoice wallet choices respect core options and receive permissions', () => { const {component, state} = page({}) state.g.user.wallets = [ {id: 'ln', walletType: 'lightning', canReceivePayments: true}, @@ -87,9 +87,11 @@ test('invoice wallet choices exclude non-Lightning and receive-disabled wallets' {id: 'chain', walletType: 'onchain', canReceivePayments: true}, {id: 'fiat', walletType: 'fiat', canReceivePayments: true} ] - state.g.user.walletOptions = state.g.user.wallets.map(wallet => ({ - value: wallet.id - })) + state.g.user.walletOptions = [ + {value: 'ln'}, + {value: 'shared'}, + {value: 'readonly'} + ] const choices = component.computed.lightningWalletOptions.call(state) assert.deepEqual( choices.map(wallet => wallet.value), diff --git a/views_api.py b/views_api.py index 66dbfe0..aeaf333 100644 --- a/views_api.py +++ b/views_api.py @@ -15,7 +15,7 @@ WebSocketDisconnect, ) from fastapi.responses import StreamingResponse -from lnbits.core.crud import get_user +from lnbits.core.crud import get_user, get_user_active_extensions_ids from lnbits.core.crud.assets import get_public_asset from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import KeyType, WalletTypeInfo @@ -98,6 +98,8 @@ async def _get_watchonly_status(wallet) -> dict[str, Any]: "mempool_endpoint": None, } try: + if "watchonly" not in await get_user_active_extensions_ids(wallet.user): + return {**unavailable, "message": None} config = await fetch_watchonly_config(wallet.inkey) network = config.get("network") if not network: