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..3410585 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,13 @@ class EventExtra(PublicEventExtra): onchain_zeroconf: bool = False onchain_fasttrack: bool = False + @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") + 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..f4fbf81 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,6 +1,39 @@ window.PageEvents = { template: '#page-events', computed: { + lightningWalletOptions() { + const walletIds = this.g.user.wallets + .filter(wallet => 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 +183,9 @@ window.PageEvents = { } } }, - onchainWallets: [] + onchainWallets: [], + coreOnchainWallets: [], + onchainMessages: [] } }, methods: { @@ -175,6 +210,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 +224,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 respect core options and receive permissions', () => { + 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 = [ + {value: 'ln'}, + {value: 'shared'}, + {value: 'readonly'} + ] + 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..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 @@ -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,23 @@ 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: + 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: - 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 +169,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 +216,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 +581,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 +714,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)