From 4b1e2ac5eda5861517d62366c46a930e5094d849 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 3 Nov 2026 12:11:07 +0000 Subject: [PATCH] feat: support core onchain wallets in Streamalerts --- README.md | 2 +- crud.py | 4 +- helpers.py | 9 ++ static/js/index.js | 186 +++++++++++++++++++++++----- templates/streamalerts/display.html | 31 ++--- templates/streamalerts/index.html | 91 +++++++++----- tests/test_onchain.js | 95 ++++++++++++++ tests/test_onchain.py | 184 +++++++++++++++++++++++++++ views_api.py | 80 ++++++++++-- 9 files changed, 589 insertions(+), 93 deletions(-) create mode 100644 tests/test_onchain.js create mode 100644 tests/test_onchain.py diff --git a/README.md b/README.md index e86527c..60c09fe 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ At the moment, the only service that has an open API to work with is Streamlabs, For now, simply set the "Redirect URI" to `http://localhost`, you will change this soon. Then, hit create: ![image](https://user-images.githubusercontent.com/28876473/127759264-ae91539a-5694-4096-a478-80eb02b7b594.png) -1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Onchain Wallet (watch-only) (to accept on-chain donations) extenions: +1. In LNbits, enable Stream Alerts and SatsPay. To accept onchain donations, select a core LNbits onchain wallet or enable Watchonly and select a Watchonly wallet. The wallet must match SatsPay's network: ![image](https://user-images.githubusercontent.com/28876473/127759486-0e3420c2-c498-4bf9-932e-0abfa17bd478.png) 1. Create a "NEW SERVICE" using the button. Fill in all the information (you get your Client ID and Secret from the Streamlabs App page): ![image](https://user-images.githubusercontent.com/28876473/127759512-8e8b4e90-2a64-422a-bf0a-5508d0630bed.png) diff --git a/crud.py b/crud.py index 5921175..3e2e740 100644 --- a/crud.py +++ b/crud.py @@ -60,11 +60,13 @@ async def post_donation(donation_id: str) -> dict: return {"message": "StreamElements not yet supported!"} else: return {"message": "Unsopported servicename"} + response.raise_for_status() + result = response.json() await db.execute( "UPDATE streamalerts.donations SET posted = :posted WHERE id = :id", {"id": donation_id, "posted": True}, ) - return response.json() + return result async def create_service(data: CreateService) -> Service: diff --git a/helpers.py b/helpers.py index cc4edcb..a1b455b 100644 --- a/helpers.py +++ b/helpers.py @@ -4,6 +4,15 @@ from .models import ChargeStatus +async def get_satspay_network() -> str: + async with httpx.AsyncClient() as client: + response = await client.get( + f"http://{settings.host}:{settings.port}/satspay/api/v1/settings/public" + ) + response.raise_for_status() + return response.json()["network"] + + async def create_charge(data: dict, api_key: str) -> str: async with httpx.AsyncClient() as client: headers = {"X-API-KEY": api_key} diff --git a/static/js/index.js b/static/js/index.js index 8cc62ee..8536e66 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -16,6 +16,8 @@ window.app = Vue.createApp({ services: [], donations: [], walletLinks: [], + coreOnchainWallets: [], + loadingOnchainWallets: false, servicesTable: { columns: [ { @@ -39,7 +41,7 @@ window.app = Vue.createApp({ { name: 'onchain address', align: 'left', - label: 'Onchain Address', + label: 'Onchain Wallet', field: 'onchain' }, { @@ -106,21 +108,116 @@ window.app = Vue.createApp({ } } }, + computed: { + editingService() { + return this.services.find( + service => service.id === this.serviceDialog.data.id + ) + }, + 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' ? '' : ' (Not ready)'}`, + value: `core:${wallet.id}` + })) + const watchonly = this.walletLinks.map(wallet => ({ + label: `Watchonly: ${wallet.title || wallet.id}`, + value: wallet.id + })) + return [...core, ...watchonly] + }, + selectedCoreWallet() { + const reference = this.serviceDialog.data.onchain + 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') + }, + onchainSelectionInvalid() { + if (!this.serviceDialog.chain) return false + const reference = this.serviceDialog.data.onchain + if (reference && reference === this.editingService?.onchain) return false + return ( + !this.onchainWalletOptions.some(option => option.value === reference) || + (reference?.startsWith('core:') && + this.selectedCoreWallet?.status !== 'ready') + ) + } + }, methods: { - getWalletLinks() { - LNbits.api - .request( + async getWalletLinks() { + if (this.loadingOnchainWallets) return + this.loadingOnchainWallets = true + this.walletLinks = [] + this.coreOnchainWallets = [] + const inkey = this.g.user.wallets[0].inkey + try { + const { + data: {network} + } = await LNbits.api.request('GET', '/satspay/api/v1/settings/public') + const core = LNbits.api + .request('GET', '/satspay/api/v1/onchain-wallets', inkey) + .then(({data}) => { + this.coreOnchainWallets = data.filter( + wallet => wallet.network === network + ) + }) + .catch(LNbits.utils.notifyApiError) + if (this.g.user.extensions.includes('watchonly')) { + await Promise.all([core, this.getWatchonlyWallets(network, inkey)]) + } else { + await core + } + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.loadingOnchainWallets = false + } + }, + async getWatchonlyWallets(network, inkey) { + try { + const {data: config} = await LNbits.api.request( 'GET', - '/watchonly/api/v1/wallet', - this.g.user.wallets[0].inkey + '/watchonly/api/v1/config', + inkey ) - .then(response => { - for (i = 0; i < response.data.length; i++) { - this.walletLinks.push(response.data[i].id) - } + if (config.network !== network) { + this.$q.notify({ + type: 'warning', + message: `Watchonly must use the SatsPay network (${network}).` + }) return - }) - .catch(LNbits.utils.notifyApiError) + } + const {data} = await LNbits.api.request( + 'GET', + `/watchonly/api/v1/wallet?network=${encodeURIComponent(network)}`, + inkey + ) + this.walletLinks = data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + createOnchainWallet() { + this.g.newWalletType = 'onchain' + }, + openOnchainWallet(walletId) { + this.$router.push({path: '/wallet', query: {wal: walletId}}) + }, + openServiceDialog() { + this.serviceDialog.data = {} + this.serviceDialog.chain = false + this.serviceDialog.show = true }, getDonations() { LNbits.api @@ -173,33 +270,59 @@ window.app = Vue.createApp({ }) }, sendServiceData() { - const wallet = _.findWhere(this.g.user.wallets, { - id: this.serviceDialog.data.wallet - }) - const data = this.serviceDialog.data - - this.createService(wallet, data) - }, - - createService(wallet, data) { - LNbits.api - .request('POST', '/streamalerts/api/v1/services', wallet.adminkey, data) + if (this.onchainSelectionInvalid) return + const data = { + ...this.serviceDialog.data, + onchain: this.serviceDialog.chain + ? this.serviceDialog.data.onchain + : null + } + const wallet = this.g.user.wallets.find( + wallet => wallet.id === data.wallet + ) + return LNbits.api + .request( + data.id ? 'PUT' : 'POST', + '/streamalerts/api/v1/services' + (data.id ? `/${data.id}` : ''), + wallet.adminkey, + data + ) .then(response => { - this.services.push(mapStreamAlerts(response.data)) + const service = mapStreamAlerts(response.data) + if (data.id) { + this.services = this.services.map(row => + row.id === data.id ? service : row + ) + } else { + this.services.push(service) + } this.serviceDialog.show = false this.serviceDialog.data = {} }) .catch(LNbits.utils.notifyApiError) }, updateserviceDialog(serviceId) { - const link = _.findWhere(this.services, {id: serviceId}) - this.serviceDialog.data.id = link.id - this.serviceDialog.data.wallet = link.wallet - this.serviceDialog.data.twitchuser = link.twitchuser - this.serviceDialog.data.servicename = link.servicename - this.serviceDialog.data.client_id = link.client_id - this.serviceDialog.data.client_secret = link.client_secret + const { + id, + wallet, + twitchuser, + servicename, + client_id, + client_secret, + onchain + } = this.services.find(service => service.id === serviceId) + this.serviceDialog.data = { + id, + wallet, + twitchuser, + servicename, + client_id, + client_secret, + onchain + } + this.serviceDialog.chain = Boolean(onchain) this.serviceDialog.show = true + if (this.serviceDialog.chain) this.getWalletLinks() }, deleteService(servicesId) { const services = _.findWhere(this.services, {id: servicesId}) @@ -228,7 +351,6 @@ window.app = Vue.createApp({ created() { if (this.g.user.wallets.length) { - this.getWalletLinks() this.getDonations() this.getServices() } diff --git a/templates/streamalerts/display.html b/templates/streamalerts/display.html index a853b60..b83178f 100644 --- a/templates/streamalerts/display.html +++ b/templates/streamalerts/display.html @@ -56,7 +56,6 @@
Donate Bitcoin to {{ twitchuser }}
data() { return { paymentReq: null, - redirectUrl: null, donationDialog: { show: false, data: { @@ -74,19 +73,23 @@
Donate Bitcoin to {{ twitchuser }}
}, methods: { - submitInvoice() { - axios - .post('/streamalerts/api/v1/donations', { - service: '{{ service }}', - name: this.donationDialog.data.name, - sats: this.donationDialog.data.sats, - message: this.donationDialog.data.message - }) - .then(response => { - this.redirect_url = response.data.redirect_url - console.log(this.redirect_url) - window.location.href = this.redirect_url - }) + async submitInvoice() { + try { + const {data} = await LNbits.api.request( + 'POST', + '/streamalerts/api/v1/donations', + null, + { + service: '{{ service }}', + name: this.donationDialog.data.name || 'Anonymous', + sats: this.donationDialog.data.sats, + message: this.donationDialog.data.message + } + ) + window.location.href = data.redirect_url + } catch (error) { + LNbits.utils.notifyApiError(error) + } } } }) diff --git a/templates/streamalerts/index.html b/templates/streamalerts/index.html index 3b1009f..948d478 100644 --- a/templates/streamalerts/index.html +++ b/templates/streamalerts/index.html @@ -4,7 +4,7 @@
- New Service @@ -76,6 +76,14 @@
Services
v-text="col.value" > + -
-
-
- -
-
- - - Onchain Wallet (watch-only) extension MUST be activated and - have a wallet - - -
-
-
-
+ +
+ :options="onchainWalletOptions" + label="Onchain Wallet" + hint="Receive through SatsPay using an LNbits or Watchonly wallet" + > + + +
+ +
label="Twitch Username *" > hint="The service you use for alerts. (Currently only Streamlabs)" > label="Client ID *" > >
Update Service - - Create Service + :disable="!serviceDialog.data.wallet || !serviceDialog.data.client_id || !serviceDialog.data.client_secret || !serviceDialog.data.twitchuser || onchainSelectionInvalid" + :label="serviceDialog.data.id ? 'Update Service' : 'Create Service'" + > Cancel diff --git a/tests/test_onchain.js b/tests/test_onchain.js new file mode 100644 index 0000000..f4f17d8 --- /dev/null +++ b/tests/test_onchain.js @@ -0,0 +1,95 @@ +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: {}, + windowMixin: {}, + 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('editing the onchain choice keeps the existing service and does not submit its token', async () => { + const requests = [] + const state = page(async (method, url, key, data) => { + requests.push({method, url, key, data}) + return {data} + }, []) + state.services = [ + { + id: 'service', + state: 'public-link', + wallet: 'merchant', + token: 'token', + client_id: 'client', + client_secret: 'secret', + servicename: 'Streamlabs', + twitchuser: 'streamer', + onchain: null + } + ] + state.updateserviceDialog('service') + state.serviceDialog.chain = true + state.serviceDialog.data.onchain = 'core:main' + state.coreOnchainWallets = [{id: 'main', status: 'ready'}] + await state.sendServiceData() + + assert.equal(requests[0].method, 'PUT') + assert.equal(requests[0].url, '/streamalerts/api/v1/services/service') + assert.equal(requests[0].key, 'admin') + assert.equal(requests[0].data.onchain, 'core:main') + assert.equal(requests[0].data.token, undefined) + assert.equal(state.services.length, 1) +}) diff --git a/tests/test_onchain.py b/tests/test_onchain.py new file mode 100644 index 0000000..0240b79 --- /dev/null +++ b/tests/test_onchain.py @@ -0,0 +1,184 @@ +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import FastAPI, HTTPException +from lnbits.core.models import KeyType, User, Wallet, WalletTypeInfo + +from .. import crud, views_api +from ..models import ChargeStatus, CreateService, Service + +TIME = datetime(2025, 1, 1, tzinfo=timezone.utc) + + +@pytest.fixture +def merchant(monkeypatch): + wallet = Wallet( + id="merchant", + name="Donations", + 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, "get_wallet", AsyncMock(return_value=wallet)) + return WalletTypeInfo(KeyType.admin, wallet) + + +@pytest.fixture +def service(monkeypatch): + service = Service( + id="service", + state="public-link", + twitchuser="streamer", + client_id="client", + client_secret="secret", + wallet="merchant", + servicename="Streamlabs", + authenticated=True, + token="token", + onchain="watchonly-id", + ) + monkeypatch.setattr(views_api, "get_service", AsyncMock(return_value=service)) + return service + + +@pytest.mark.asyncio +@pytest.mark.parametrize("edit", [False, True]) +@pytest.mark.parametrize( + "wallet, reference, status", + [ + ("merchant", "core:chain", 400), + ("merchant", "core:foreign", 403), + ("foreign", "core:chain", 403), + ], +) +async def test_invalid_wallet_cannot_be_saved( + monkeypatch, merchant, service, edit, wallet, reference, status +): + monkeypatch.setattr( + views_api, "get_satspay_network", AsyncMock(return_value="Mainnet") + ) + save = AsyncMock() + monkeypatch.setattr(views_api, "create_service", save) + monkeypatch.setattr(views_api, "update_service", save) + data = CreateService.model_validate( + {**service.model_dump(), "wallet": wallet, "onchain": reference} + ) + with pytest.raises(HTTPException) as exc: + if edit: + await views_api.api_update_service(service.id, data, merchant) + else: + await views_api.api_create_service(data, merchant) + assert exc.value.status_code == status + assert service.onchain == "watchonly-id" + save.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "reference, method", + [("core:chain", "GET"), ("watchonly-id", "POST"), (None, "POST")], +) +async def test_donation_checkout_and_paid_alert( + monkeypatch, merchant, service, reference, method +): + service.onchain = reference + monkeypatch.setattr( + views_api, "get_satspay_network", AsyncMock(return_value="Testnet4") + ) + save_service = AsyncMock(side_effect=lambda updated: updated) + monkeypatch.setattr(views_api, "update_service", save_service) + if reference == "core:chain": + service.onchain = "watchonly-id" + data = CreateService.model_validate( + {**service.model_dump(), "onchain": reference} + ) + await views_api.api_update_service(service.id, data, merchant) + assert (service.state, service.token, service.authenticated) == ( + "public-link", + "token", + True, + ) + + app = FastAPI() + app.include_router(views_api.streamalerts_api_router, prefix="/streamalerts") + create_charge = AsyncMock(return_value="charge") + create_donation = AsyncMock() + monkeypatch.setattr(views_api, "btc_price", AsyncMock(return_value=100_000)) + monkeypatch.setattr(views_api, "create_charge", create_charge) + monkeypatch.setattr(views_api, "create_donation", create_donation) + monkeypatch.setattr( + views_api, + "get_donation", + AsyncMock(return_value=SimpleNamespace(id="charge", wallet="merchant")), + ) + get_status = AsyncMock( + side_effect=[ + ChargeStatus(id="charge", paid=False), + ChargeStatus(id="charge", paid=True), + ] + ) + post = AsyncMock(return_value={"sent": True}) + monkeypatch.setattr(views_api, "get_charge_status", get_status) + monkeypatch.setattr(views_api, "post_donation", post) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="http://test" + ) as client: + checkout = await client.post( + "/streamalerts/api/v1/donations", + json={"service": service.id, "sats": 1000}, + ) + assert checkout.json() == {"redirect_url": "/satspay/charge"} + webhook = "/streamalerts/api/v1/postdonation" + # SatsPay sends a JSON body without a content type on GET. + payload = '{"id":"charge","paid":true}' + assert ( + await client.request(method, webhook, content=payload) + ).status_code == 400 + post.assert_not_awaited() + paid = await client.request(method, webhook, content=payload) + assert paid.json() == {"sent": True} + assert paid.headers["Cache-Control"] == "no-store" + + assert create_charge.await_args is not None + assert create_charge.await_args.kwargs["data"]["onchainwallet"] == reference + assert create_charge.await_args.kwargs["api_key"] == "invoice" + assert create_donation.await_args is not None + assert create_donation.await_args.kwargs["donation_id"] == "charge" + get_status.assert_awaited_with("charge", "invoice") + post.assert_awaited_once_with("charge") + + +@pytest.mark.asyncio +async def test_failed_streamlabs_delivery_leaves_donation_unposted( + monkeypatch, service +): + + donation = SimpleNamespace( + posted=False, + service=service.id, + name="Donor", + message="", + amount=1, + cur_code="USD", + ) + monkeypatch.setattr(crud, "get_donation", AsyncMock(return_value=donation)) + monkeypatch.setattr(crud, "get_service", AsyncMock(return_value=service)) + save = AsyncMock() + monkeypatch.setattr(crud.db, "execute", save) + client = httpx.AsyncClient + transport = httpx.MockTransport(lambda request: httpx.Response(503)) + monkeypatch.setattr(crud.httpx, "AsyncClient", lambda: client(transport=transport)) + + with pytest.raises(httpx.HTTPStatusError): + await crud.post_donation("charge") + save.assert_not_awaited() diff --git a/views_api.py b/views_api.py index db82960..457dbee 100644 --- a/views_api.py +++ b/views_api.py @@ -1,6 +1,7 @@ from http import HTTPStatus -from fastapi import APIRouter, Depends, HTTPException, Query, Request +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import RedirectResponse from lnbits.core.crud import get_user, get_wallet from lnbits.core.models import WalletTypeInfo @@ -22,19 +23,24 @@ update_donation, update_service, ) -from .helpers import create_charge, delete_charge, get_charge_status +from .helpers import ( + create_charge, + delete_charge, + get_charge_status, + get_satspay_network, +) from .models import CreateDonation, CreateService, Donation, Service, ValidateDonation streamalerts_api_router = APIRouter() -@streamalerts_api_router.post( - "/api/v1/services", dependencies=[Depends(require_admin_key)] -) -async def api_create_service(data: CreateService) -> Service: +@streamalerts_api_router.post("/api/v1/services") +async def api_create_service( + data: CreateService, key_info: WalletTypeInfo = Depends(require_admin_key) +) -> Service: """Create a service, which holds data about how/where to post donations""" - service = await create_service(data) - return service + await _validate_service_wallets(data, key_info.wallet.user) + return await create_service(data) @streamalerts_api_router.get("/api/v1/getaccess/{service_id}") @@ -121,7 +127,22 @@ async def api_create_donation(data: CreateDonation, request: Request): "onchainwallet": service.onchain, "user": wallet.user, } - charge_id = await create_charge(data=create_charge_data, api_key=wallet.inkey) + try: + charge_id = await create_charge(data=create_charge_data, api_key=wallet.inkey) + except Exception as exc: + 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.BAD_GATEWAY, detail="Unable to create payment." + ) from exc await create_donation( data=data, wallet=service.wallet, @@ -131,11 +152,13 @@ async def api_create_donation(data: CreateDonation, request: Request): return {"redirect_url": f"/satspay/{charge_id}"} +@streamalerts_api_router.get("/api/v1/postdonation") @streamalerts_api_router.post("/api/v1/postdonation") -async def api_post_donation(data: ValidateDonation): +async def api_post_donation(data: ValidateDonation, response: Response): """Post a paid donation to Stremalabs/StreamElements. This endpoint acts as a webhook for the SatsPayServer extension.""" + response.headers["Cache-Control"] = "no-store" donation_id = data.id donation = await get_donation(donation_id) if not donation: @@ -147,8 +170,13 @@ async def api_post_donation(data: ValidateDonation): assert wallet, f"Could not fetch wallet: {donation.wallet}" charge = await get_charge_status(donation_id, wallet.inkey) - if charge and charge.paid: - return await post_donation(donation_id) + if charge.id == donation.id and charge.paid: + try: + return await post_donation(donation_id) + except httpx.HTTPError as exc: + raise HTTPException( + HTTPStatus.BAD_GATEWAY, detail="Unable to deliver the stream alert." + ) from exc else: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Not a paid charge!" @@ -229,6 +257,7 @@ async def api_update_service( status_code=HTTPStatus.FORBIDDEN, detail="Not your service." ) + await _validate_service_wallets(data, key_info.wallet.user, service.onchain) for k, v in data.model_dump().items(): setattr(service, k, v) @@ -274,3 +303,30 @@ async def api_delete_service( donations = await delete_service(service_id) for d in donations: await delete_charge(d, key_info.wallet.adminkey) + + +async def _validate_service_wallets( + data: CreateService, 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}).", + )