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)