+
+
+
+
+
>
Update TipJar
-
- Create TipJar
+ :disable="!tipjarDialog.data.name || !tipjarDialog.data.wallet || onchainSelectionInvalid"
+ :label="tipjarDialog.data.id ? 'Update TipJar' : 'Create TipJar'"
+ >
Cancel
diff --git a/tests/test_onchain.js b/tests/test_onchain.js
new file mode 100644
index 0000000..cf07896
--- /dev/null
+++ b/tests/test_onchain.js
@@ -0,0 +1,86 @@
+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')
+
+function page(request, extensions = ['watchonly']) {
+ const context = {
+ window: {},
+ Vue: {createApp: component => component},
+ Quasar: {date: {formatDate: () => ''}},
+ LOCALE: 'en',
+ _: {findWhere: (items, {id}) => items.find(item => item.id === id)},
+ LNbits: {api: {request}, utils: {notifyApiError: () => {}}}
+ }
+ vm.runInNewContext(
+ fs.readFileSync(path.join(__dirname, '../static/js/index.js'), 'utf8'),
+ context
+ )
+ const component = context.window.app
+ const state = {
+ ...component.data(),
+ ...component.methods,
+ g: {
+ user: {
+ extensions,
+ wallets: [{id: 'merchant', adminkey: 'admin', inkey: 'invoice'}]
+ }
+ }
+ }
+ for (const [name, get] of Object.entries(component.computed)) {
+ Object.defineProperty(state, name, {get: () => get.call(state)})
+ }
+ return state
+}
+
+test('discovery filters by SatsPay network and tolerates Watchonly failure', async () => {
+ const calls = []
+ const state = page(async (method, url, key) => {
+ calls.push([url, key])
+ if (url.endsWith('/settings/public')) return {data: {network: 'Mainnet'}}
+ if (url.endsWith('/onchain-wallets'))
+ return {
+ data: [
+ {id: 'main', name: 'Main', status: 'ready', network: 'Mainnet'},
+ {id: 'test', name: 'Test', status: 'ready', network: 'Testnet4'}
+ ]
+ }
+ throw new Error('Watchonly unavailable')
+ })
+
+ await state.getWalletLinks()
+ assert.deepEqual(
+ Array.from(state.onchainWalletOptions, option => option.value),
+ ['core:main']
+ )
+ assert.deepEqual(calls.slice(1), [
+ ['/satspay/api/v1/onchain-wallets', 'invoice'],
+ ['/watchonly/api/v1/config', 'invoice']
+ ])
+})
+
+test('an existing jar can be edited without discovery and keeps its payment destination', async () => {
+ const calls = []
+ const state = page(async (method, url, key, data) => {
+ if (method === 'GET') throw new Error('Discovery unavailable')
+ calls.push({method, url, key})
+ return {data}
+ }, [])
+ const jar = {
+ id: 'jar',
+ wallet: 'merchant',
+ name: 'Before',
+ onchain: 'core:chain'
+ }
+ state.tipjars = [jar]
+ state.updatetipjarDialog(jar.id)
+ state.tipjarDialog.data.name = 'After'
+ await state.sendTipJarData()
+ assert.deepEqual(calls, [
+ {method: 'PUT', url: '/tipjar/api/v1/tipjars/jar', key: 'admin'}
+ ])
+ assert.equal(state.tipjars[0].onchain, 'core:chain')
+ assert.equal(state.tipjars[0].name, 'After')
+ assert.equal(jar.name, 'Before')
+})
diff --git a/tests/test_onchain.py b/tests/test_onchain.py
new file mode 100644
index 0000000..79974bb
--- /dev/null
+++ b/tests/test_onchain.py
@@ -0,0 +1,116 @@
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock
+
+import httpx
+import pytest
+from fastapi import HTTPException
+from lnbits.core.models import KeyType, User, Wallet, WalletTypeInfo
+
+from .. import views_api
+from ..models import CreateTipJar, CreateTips, TipJar
+
+TIME = datetime(2025, 1, 1, tzinfo=timezone.utc)
+
+
+@pytest.fixture
+def merchant(monkeypatch):
+ wallet = Wallet(
+ id="merchant",
+ name="Tips",
+ user="owner",
+ adminkey="admin",
+ inkey="invoice",
+ created_at=TIME,
+ updated_at=TIME,
+ )
+ chain = wallet.model_copy(
+ update={"id": "chain", "wallet_type": "onchain", "onchain_network": "Testnet4"}
+ )
+ user = User(id="owner", wallets=[wallet, chain], created_at=TIME, updated_at=TIME)
+ monkeypatch.setattr(views_api, "get_user", AsyncMock(return_value=user))
+ monkeypatch.setattr(views_api, "check_satspay", AsyncMock(return_value=True))
+ return WalletTypeInfo(KeyType.admin, wallet)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("edit", [False, True])
+@pytest.mark.parametrize(
+ "wallet, onchain, status",
+ [
+ ("merchant", "core:chain", 400),
+ ("merchant", "core:foreign", 403),
+ ("foreign", "core:chain", 403),
+ ],
+)
+async def test_reject_invalid_wallet_before_saving(
+ merchant, monkeypatch, edit, wallet, onchain, status
+):
+ jar = TipJar(id="jar", name="Before", wallet="merchant", onchain="watch-id")
+ monkeypatch.setattr(views_api, "get_tipjar", AsyncMock(return_value=jar))
+ monkeypatch.setattr(
+ views_api, "get_satspay_network", AsyncMock(return_value="Mainnet")
+ )
+ create = AsyncMock()
+ update = AsyncMock()
+ monkeypatch.setattr(views_api, "create_tipjar", create)
+ monkeypatch.setattr(views_api, "update_tipjar", update)
+
+ data = CreateTipJar(name="After", wallet=wallet, onchain=onchain)
+ with pytest.raises(HTTPException) as exc:
+ if edit:
+ await views_api.api_update_tipjar(jar.id, data, merchant)
+ else:
+ await views_api.api_create_tipjar(data, merchant)
+ assert exc.value.status_code == status
+ create.assert_not_awaited()
+ update.assert_not_awaited()
+ assert (jar.name, jar.onchain) == ("Before", "watch-id")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("reference", ["core:unavailable", " "])
+async def test_edit_preserves_saved_reference_without_discovery(
+ merchant, monkeypatch, reference
+):
+ jar = TipJar(id="jar", name="Before", wallet="merchant", onchain=reference)
+ monkeypatch.setattr(views_api, "get_tipjar", AsyncMock(return_value=jar))
+ network = AsyncMock(side_effect=httpx.ConnectError("SatsPay unavailable"))
+ monkeypatch.setattr(views_api, "get_satspay_network", network)
+ save = AsyncMock(side_effect=lambda updated: updated)
+ monkeypatch.setattr(views_api, "update_tipjar", save)
+
+ data = CreateTipJar(name="After", wallet="merchant", onchain=reference)
+ result = await views_api.api_update_tipjar(jar.id, data, merchant)
+ assert (result.name, result.onchain) == ("After", reference)
+ save.assert_awaited_once_with(result)
+ network.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_checkout_forwards_core_reference_only_above_limit(merchant, monkeypatch):
+ jar = TipJar(
+ id="jar",
+ name="Tips",
+ wallet="merchant",
+ onchain="core:chain",
+ onchain_limit=100,
+ )
+ monkeypatch.setattr(views_api, "get_tipjar", AsyncMock(return_value=jar))
+ monkeypatch.setattr(
+ views_api, "get_wallet", AsyncMock(return_value=merchant.wallet)
+ )
+ charge = AsyncMock(return_value="charge")
+ monkeypatch.setattr(views_api, "create_charge", charge)
+ monkeypatch.setattr(views_api, "create_tip", AsyncMock())
+
+ for sats in (100, 101):
+ tip = CreateTips(name="Donor", tipjar=jar.id, sats=sats, message="")
+ assert await views_api.api_create_tip(tip) == {
+ "redirect_url": "/satspay/charge"
+ }
+ assert [
+ call.kwargs["data"]["onchainwallet"] for call in charge.await_args_list
+ ] == [
+ None,
+ "core:chain",
+ ]
diff --git a/views_api.py b/views_api.py
index 754d23d..c43be2d 100644
--- a/views_api.py
+++ b/views_api.py
@@ -1,5 +1,6 @@
from http import HTTPStatus
+import httpx
from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.models import WalletTypeInfo
@@ -18,7 +19,7 @@
update_tip,
update_tipjar,
)
-from .helpers import check_satspay, create_charge, delete_charge
+from .helpers import check_satspay, create_charge, delete_charge, get_satspay_network
from .models import CreateTip, CreateTipJar, CreateTips, Tip, TipJar
tipjar_api_router = APIRouter()
@@ -30,6 +31,7 @@ async def api_create_tipjar(
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> TipJar:
"""Create a tipjar, which holds data about how/where to post tips"""
+ await _validate_tipjar_wallets(data, key_type.wallet.user)
if not await check_satspay(key_type.wallet.adminkey):
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
@@ -71,8 +73,9 @@ async def api_create_tip(data: CreateTips) -> dict:
detail="SatsPay is not enabled.",
)
+ onchain = tipjar.onchain
if tipjar.onchain_limit and (sats <= tipjar.onchain_limit):
- tipjar.onchain = None
+ onchain = None
name = data.name or "Anonymous"
@@ -83,7 +86,7 @@ async def api_create_tip(data: CreateTips) -> dict:
"webhook": tipjar.webhook or None,
"name": name,
"description": message,
- "onchainwallet": tipjar.onchain or None,
+ "onchainwallet": onchain or None,
"lnbitswallet": tipjar.wallet,
"completelink": f"/tipjar/{tipjar_id}",
"completelinktext": "Thanks for the tip!",
@@ -93,9 +96,18 @@ async def api_create_tip(data: CreateTips) -> dict:
api_key=wallet.inkey,
)
except Exception as exc:
- msg = f"Failed to create charge: {exc!s}"
+ if (
+ isinstance(exc, httpx.HTTPStatusError)
+ and exc.response.status_code == HTTPStatus.BAD_REQUEST
+ ):
+ try:
+ detail = exc.response.json().get("detail")
+ except (ValueError, AttributeError):
+ detail = None
+ if isinstance(detail, str):
+ raise HTTPException(HTTPStatus.BAD_REQUEST, detail=detail) from exc
raise HTTPException(
- status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=msg
+ status_code=HTTPStatus.BAD_GATEWAY, detail="Unable to create payment."
) from exc
tip = Tip(
@@ -182,6 +194,7 @@ async def api_update_tipjar(
if tipjar.wallet != key_type.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your tipjar.")
+ await _validate_tipjar_wallets(data, key_type.wallet.user, tipjar.onchain)
for k, v in data.model_dump().items():
setattr(tipjar, k, v)
tipjar = await update_tipjar(tipjar)
@@ -238,3 +251,30 @@ async def api_delete_tipjar(
await delete_tipjar(tipjar_id)
return "", HTTPStatus.NO_CONTENT
+
+
+async def _validate_tipjar_wallets(
+ data: CreateTipJar, owner_id: str, saved_onchain: str | None = None
+) -> None:
+ user = await get_user(owner_id)
+ if not user or data.wallet not in user.wallet_ids:
+ raise HTTPException(HTTPStatus.FORBIDDEN, detail="Not your wallet.")
+ if not data.onchain or data.onchain == saved_onchain:
+ return
+ if not data.onchain.startswith("core:"):
+ return
+ wallet = user.get_wallet(data.onchain.removeprefix("core:"))
+ if not wallet or not wallet.is_onchain_wallet:
+ raise HTTPException(HTTPStatus.FORBIDDEN, detail="Not your onchain wallet.")
+
+ try:
+ network = await get_satspay_network()
+ except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
+ raise HTTPException(
+ HTTPStatus.BAD_GATEWAY, detail="Unable to load the SatsPay network."
+ ) from exc
+ if (wallet.onchain_network or "Mainnet") != network:
+ raise HTTPException(
+ HTTPStatus.BAD_REQUEST,
+ detail=f"Select an onchain wallet on the SatsPay network ({network}).",
+ )