revert(v2): drop NIP-78 fleet publishing (privacy by default)

Pulls the kind:30078 per-machine config + fleet roster publish path
introduced at 131ff92. The default-public posture leaked operator
fleet composition (which npubs they run, where they're located, fiat
codes) to whatever relays nostrclient was configured with — a robbery
/ competitor-intel / extortion target surface the operator never
opted into.

Privacy by default is the operator's stated preference: nothing about
the fleet goes on relays unless the operator explicitly opts in via a
future toggle. Roster lookups now read from satmachineadmin's local
DB only (the S6 LNbits-side roster-gating becomes a local-DB-read
story, not a public-relay subscription).

Pre-launch — no external consumer to coordinate with, so the rip-out
is clean. Future opt-in publishing tracked in follow-up issue.

Removed:
 - nostr_publish.py (publish_machine_config / publish_fleet_roster /
   tombstone_machine_config / _sign_as_operator hybrid)
 - The three publish call sites in api_create_machine /
   api_update_machine / api_delete_machine.

Heartbeat-style public metadata (the kind of info bitSpire already
emits about machine liveness, location, active state) is still a
legitimate publish target — but that's the ATM's job, not the
operator's. Designed in the follow-up issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-26 23:20:24 +02:00
commit dcd08748a7
2 changed files with 0 additions and 310 deletions

View file

@ -1,288 +0,0 @@
# 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:<machine_id>` — 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": [<operator_pubkey>],
# "#d": ["bitspire-config:<machine_id>"]} → per-machine config
# REQ ... {"kinds": [30078], "authors": [<operator_pubkey>],
# "#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=<atm_npub>` 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` on behalf of the operator.
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 an available signer — covers
(a) accounts created via non-Nostr login that never set up identity,
(b) accounts where the server has only the pubkey
(`ClientSideOnlySigner`),
(c) post-bunker future (lnbits#18) where signing routes through a
NIP-46 bunker that isn't reachable.
Soft-failure is the right behaviour publishing kind:30078 is a
side-effect of machine CRUD, not a precondition for it. The machine
row still gets written; only the public-facing event is skipped.
Routing: post-`aiolabs/lnbits#17` (signer abstraction) we go through
`lnbits.core.signers.resolve_signer`, which transparently handles
`LocalSigner` (envelope-encrypted nsec at rest, decrypted on demand)
and `ClientSideOnlySigner` (raises `SignerUnavailableError` we
treat as soft-fail). On pre-#17 lnbits versions the import fails and
we fall back to a direct `account.prvkey` read so this code keeps
working during the #17 cascade rollout window. Both paths produce
identical signed events; the hybrid avoids a hard ordering
dependency between this extension and the lnbits #17 PR landing.
"""
account = await get_account(operator_user_id)
if account is None or not account.pubkey:
logger.warning(
f"satmachineadmin: operator {operator_user_id[:8]}... has no "
f"Nostr pubkey on file; skipping kind:{event['kind']} publish. "
"Onboard via the LNbits Nostr-login flow."
)
return None
# `created_at` is part of the BIP-340 event-id hash; must be set
# before signing so both code paths below see the same value.
event["created_at"] = int(time.time())
try:
from lnbits.core.signers import ( # type: ignore[import-not-found]
SignerError,
SignerUnavailableError,
resolve_signer,
)
except ImportError:
# Pre-#17 lnbits — direct prvkey read. Identical to the
# original implementation; the abstraction takes over once
# #17 cascades to this host.
if not account.prvkey:
logger.warning(
f"satmachineadmin: operator {operator_user_id[:8]}... has "
f"no signing key on file; skipping kind:{event['kind']} "
f"publish. Onboard via the LNbits Nostr-login flow, or "
f"wait for aiolabs/lnbits#18 bunker integration."
)
return None
private_key = coincurve.PrivateKey(bytes.fromhex(account.prvkey))
return sign_event(event, account.pubkey, private_key)
# Post-#17 lnbits — route through the signer abstraction.
try:
signer = resolve_signer(account)
except SignerError as exc:
logger.warning(
f"satmachineadmin: signer resolve failed for operator "
f"{operator_user_id[:8]}...: {exc}. Skipping kind:"
f"{event['kind']} publish."
)
return None
if not signer.can_sign():
logger.warning(
f"satmachineadmin: operator {operator_user_id[:8]}... has a "
f"client-side-only signer; server can't publish kind:"
f"{event['kind']} on their behalf. Wait for bunker "
f"integration (lnbits#18) or operator-driven publishing."
)
return None
try:
return signer.sign_event(event)
except SignerUnavailableError as exc:
logger.warning(
f"satmachineadmin: signer unavailable for operator "
f"{operator_user_id[:8]}...: {exc}. Skipping kind:"
f"{event['kind']} publish."
)
return None
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=<machine_npub>` so external observers can find the
config event for a specific ATM via a single filter:
`{"kinds": [30078], "#p": ["<atm_npub>"]}`.
"""
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=<atm_npub>` 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)

View file

@ -53,11 +53,6 @@ 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,
@ -109,12 +104,6 @@ async def api_create_machine(
) -> Machine:
await _assert_wallet_owned_by(data.wallet_id, user.id)
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
@ -153,11 +142,6 @@ 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
@ -171,12 +155,6 @@ 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)
# =============================================================================