From 131ff92aa85e98f4387b239491adcac0dcf387e2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 26 May 2026 20:28:26 +0200 Subject: [PATCH] feat(v2): publish operator-signed kind:30078 fleet roster + per-machine config (S4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes aiolabs/satmachineadmin#18 (S4 — NIP-78 per-machine config + fleet roster). On every machine create/update/delete, publish two operator-signed kind:30078 (NIP-78 addressable) events via the `nostrclient` LNbits extension: - `bitspire-config:` — per-machine config event, one per machine. Tagged with `p=` so external observers can filter by ATM pubkey: `{"#p": [""]}`. - `bitspire-fleet` — aggregate roster across the operator's active fleet. Lists every machine's atm_pubkey + display fields. Tagged with `p=` per active machine. Delete path tombstones the per-machine config (replaceable kind:30078 with `content.deleted=true`) and re-publishes the roster without the machine — external readers see the tombstone OR the absence from the roster. Implementation choice — direct in-process singleton import (path b from the pre-flight check, not the WebSocket path a): from nostrclient.router import nostr_client nostr_client.relay_manager.publish_message(json.dumps(["EVENT", e])) Bypasses the public/private WebSocket entirely. Cleaner than going through `wss://localhost/nostrclient/api/v1/`. Same cross-extension import pattern lnbits core uses for nostrmarket.services + nostrrelay.crud (guarded by try/except). Soft-failure throughout: - nostrclient extension not installed → log warning + skip. - Operator account has no Nostr keypair on file (account never went through Nostr-login flow, or post-bunker future where nsec is moved off-disk per lnbits#18) → log warning + skip. - The settlement / distribution path does NOT depend on the publish — these events exist for external observers, not internal flow control. Out of scope (intentionally): - ATM-side consumer in lamassu-next (forward-looking, will read `#p=` to learn its operator's config). - LNbits-server-side roster-gating in the nostr-transport handler (S6 / lnbits#14 Item 3 — needs satmachineadmin to publish first; this commit lays the groundwork). - Operator's NIP-65 relay list as the publish target (today we use whatever nostrclient is configured with; future per-operator relay lists can live on accounts.relays or similar). m006 (the canonical-vocabulary rename migration shipped at d717a6e) ran cleanly against the regtest container on lnbits restart. Co-Authored-By: Claude Opus 4.7 (1M context) --- nostr_publish.py | 227 +++++++++++++++++++++++++++++++++++++++++++++++ views_api.py | 25 +++++- 2 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 nostr_publish.py diff --git a/nostr_publish.py b/nostr_publish.py new file mode 100644 index 0000000..a9d5168 --- /dev/null +++ b/nostr_publish.py @@ -0,0 +1,227 @@ +# Satoshi Machine v2 — kind:30078 publisher (S4 — NIP-78 fleet roster). +# +# Publishes operator-signed kind:30078 (NIP-78 addressable) events to the +# `nostrclient` LNbits extension on every machine CRUD event. Two d-tags: +# +# - `bitspire-config:` — per-machine config, one event each +# - `bitspire-fleet` — aggregate roster across operator's +# active fleet +# +# Read flow for external observers (status pages, the future lnbits +# nostr-transport roster-gating in S6, etc.): +# +# REQ ... {"kinds": [30078], "authors": [], +# "#d": ["bitspire-config:"]} → per-machine config +# REQ ... {"kinds": [30078], "authors": [], +# "#d": ["bitspire-fleet"]} → fleet roster +# +# Soft-failure model: publishing is best-effort. If the operator has no +# Nostr keypair on file (account never went through Nostr-login flow), if +# the `nostrclient` extension isn't installed, or if no relays are +# currently connected, the publish logs a warning and returns. The +# settlement / distribution path does NOT depend on the publish — these +# events exist for external observers, not internal flow control. +# +# Cross-codebase: this is the producer side. Future ATM-side consumer +# (lamassu-next) reads kind:30078 events with `#p=` to learn +# its operator's config; future LNbits-server-side roster-gating +# (lnbits#14 Item 3, S6) reads `bitspire-fleet` events to gate auto- +# account creation. Both are out of scope for this commit. + +from __future__ import annotations + +import json +import time +from typing import Optional + +import coincurve +from lnbits.core.crud.users import get_account +from lnbits.utils.nostr import sign_event +from loguru import logger + +from .crud import get_machines_for_operator +from .models import Machine + +_KIND_NIP78 = 30078 +_D_TAG_CONFIG_PREFIX = "bitspire-config:" +_D_TAG_FLEET = "bitspire-fleet" + + +def _machine_config_d_tag(machine_id: str) -> str: + return f"{_D_TAG_CONFIG_PREFIX}{machine_id}" + + +async def _publish_signed_event(signed_event: dict) -> None: + """Send a signed Nostr event to all configured relays via the + `nostrclient` extension's singleton RelayManager. + + Lazy import + try/except so satmachineadmin doesn't hard-fail at boot + when nostrclient isn't installed — pattern matches the cross-extension + import guards in `lnbits.core.services.users` (nostrmarket / nostrrelay). + """ + try: + from nostrclient.router import nostr_client # type: ignore[import-not-found] + except ImportError: + d_tag = next( + (t[1] for t in signed_event.get("tags", []) if t and t[0] == "d"), + "?", + ) + logger.warning( + "satmachineadmin: nostrclient extension not installed; " + f"skipping kind:{signed_event.get('kind')} publish " + f"(d={d_tag})" + ) + return + msg = json.dumps(["EVENT", signed_event]) + nostr_client.relay_manager.publish_message(msg) + + +async def _sign_as_operator( + operator_user_id: str, event: dict +) -> Optional[dict]: + """Sign `event` using the operator's stored Nostr nsec. + + Mutates `event` to add `created_at` (now), `pubkey`, `id`, and `sig`. + Returns the signed event; returns None (with a warning log) if the + operator account doesn't have a pubkey + nsec pair on file — covers + (a) accounts created via non-Nostr login that never set up identity, + (b) post-bunker future (lnbits#18) where the nsec is moved off-disk + and the bunker client isn't yet wired through here, + (c) misconfiguration. + + Soft-failure is the right behaviour — publishing kind:30078 is a + side-effect of CRUD, not a precondition for it. The machine row + still gets written; only the public-facing event is skipped. + """ + account = await get_account(operator_user_id) + if account is None or not account.pubkey or not account.prvkey: + logger.warning( + f"satmachineadmin: operator {operator_user_id[:8]}... has no " + f"Nostr keypair on file; skipping kind:{event['kind']} publish. " + "Onboard via the LNbits Nostr-login flow, or wait for " + "aiolabs/lnbits#18 bunker integration." + ) + return None + event["created_at"] = int(time.time()) + event["pubkey"] = account.pubkey + private_key = coincurve.PrivateKey(bytes.fromhex(account.prvkey)) + return sign_event(event, account.pubkey, private_key) + + +async def publish_machine_config(machine: Machine) -> None: + """Publish a per-machine kind:30078 config event signed by the operator. + + Idempotent — kind:30078 is replaceable, keyed by `(author_pubkey, + d-tag)`. Re-publishing simply replaces the previous version at + compliant relays. Safe to call from any CRUD path that mutates + machine fields visible in the published payload. + + Tagged with `p=` so external observers can find the + config event for a specific ATM via a single filter: + `{"kinds": [30078], "#p": [""]}`. + """ + content = json.dumps( + { + "atm_pubkey": machine.machine_npub, + "machine_id": machine.id, + "name": machine.name, + "location": machine.location, + "fiat_code": machine.fiat_code, + "is_active": machine.is_active, + } + ) + event: dict = { + "kind": _KIND_NIP78, + "tags": [ + ["d", _machine_config_d_tag(machine.id)], + ["p", machine.machine_npub], + ], + "content": content, + } + signed = await _sign_as_operator(machine.operator_user_id, event) + if signed is not None: + await _publish_signed_event(signed) + + +async def publish_fleet_roster(operator_user_id: str) -> None: + """Publish the operator's aggregate fleet roster as kind:30078. + + Lists every active machine's `atm_pubkey` + basic display fields. + External observers consume this to answer "is this ATM npub a real + machine of operator X?" without having to enumerate `bitspire-config:*` + events. The future LNbits-side roster-gating (S6 / lnbits#14 Item 3) + will read this event to gate auto-account-from-npub. + + Tagged with one `p=` per active machine — lets a filter + by ATM pubkey return both the machine's own config event AND the + operator's roster that lists it. Replaceable; safe to re-publish + after every CRUD. + """ + machines = await get_machines_for_operator(operator_user_id) + active = [m for m in machines if m.is_active] + content = json.dumps( + { + "machines": [ + { + "atm_pubkey": m.machine_npub, + "machine_id": m.id, + "name": m.name, + "location": m.location, + "fiat_code": m.fiat_code, + } + for m in active + ], + } + ) + event: dict = { + "kind": _KIND_NIP78, + "tags": [ + ["d", _D_TAG_FLEET], + *[["p", m.machine_npub] for m in active], + ], + "content": content, + } + signed = await _sign_as_operator(operator_user_id, event) + if signed is not None: + await _publish_signed_event(signed) + + +async def tombstone_machine_config( + operator_user_id: str, machine_id: str, machine_npub: str +) -> None: + """Mark a per-machine config event as deleted (tombstone pattern). + + kind:30078 is replaceable — the operator can't truly "delete" the + event, but they can replace it with a marker. Two NIP-compliant + options: + (a) Publish a new kind:30078 with the same d-tag whose content + signals deletion (e.g. `{"deleted": true}`). Relays replace + the previous payload but keep the tombstone. External readers + treat `content.deleted == true` as "this machine is gone." + (b) Publish a kind:5 (NIP-09 deletion) referencing the (kind, + d-tag) of the to-be-deleted event. Compliant relays drop the + original; non-compliant relays may keep both. + + We use (a) — pragmatic, survives non-NIP-09 relays, and the content + is small enough that the storage cost is negligible. The fleet + roster publish that follows on a delete then omits the machine, + so the roster + the tombstone together tell the full story. + """ + content = json.dumps( + { + "machine_id": machine_id, + "deleted": True, + "deleted_at": int(time.time()), + } + ) + event: dict = { + "kind": _KIND_NIP78, + "tags": [ + ["d", _machine_config_d_tag(machine_id)], + ["p", machine_npub], + ], + "content": content, + } + signed = await _sign_as_operator(operator_user_id, event) + if signed is not None: + await _publish_signed_event(signed) diff --git a/views_api.py b/views_api.py index 1beb7ac..6491f31 100644 --- a/views_api.py +++ b/views_api.py @@ -53,6 +53,11 @@ from .distribution import ( process_settlement, settle_lp_balance, ) +from .nostr_publish import ( + publish_fleet_roster, + publish_machine_config, + tombstone_machine_config, +) from .models import ( AppendSettlementNoteData, ClientBalanceSummary, @@ -103,7 +108,14 @@ async def api_create_machine( data: CreateMachineData, user: User = Depends(check_user_exists) ) -> Machine: await _assert_wallet_owned_by(data.wallet_id, user.id) - return await create_machine(user.id, data) + machine = await create_machine(user.id, data) + # NIP-78 (kind:30078) publish — operator-signed per-machine config + + # refreshed fleet roster so external observers see the new machine. + # Soft-failure: best-effort, log + skip if nostrclient or operator key + # not available (see nostr_publish module docstring). + await publish_machine_config(machine) + await publish_fleet_roster(user.id) + return machine @satmachineadmin_api_router.get("/api/v1/dca/machines", response_model=list[Machine]) @@ -141,6 +153,11 @@ async def api_update_machine( updated = await update_machine(machine_id, data) if updated is None: raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") + # Re-publish: per-machine config picks up the changed fields, fleet + # roster picks up any is_active flip (deactivated machines drop out + # of the roster but their per-machine config stays — replaceable). + await publish_machine_config(updated) + await publish_fleet_roster(user.id) return updated @@ -154,6 +171,12 @@ async def api_delete_machine( if machine is None or machine.operator_user_id != user.id: raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") await delete_machine(machine_id) + # Tombstone the per-machine config (replaceable event with + # `content.deleted=true`) + refresh the roster without the machine. + # External readers see the tombstone OR the absence from the roster + # and treat it as gone. + await tombstone_machine_config(user.id, machine_id, machine.machine_npub) + await publish_fleet_roster(user.id) # =============================================================================