feat(v2): publish operator-signed kind:30078 fleet roster + per-machine config (S4)
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:<machine_id>` — per-machine config event, one
per machine. Tagged with `p=<atm_npub>` so external observers
can filter by ATM pubkey: `{"#p": ["<atm_npub>"]}`.
- `bitspire-fleet` — aggregate roster across the operator's
active fleet. Lists every machine's atm_pubkey + display fields.
Tagged with `p=<atm_npub>` 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/<encrypted_ws_id>`. 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=<atm_npub>` 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) <noreply@anthropic.com>
This commit is contained in:
parent
d717a6e214
commit
131ff92aa8
2 changed files with 251 additions and 1 deletions
227
nostr_publish.py
Normal file
227
nostr_publish.py
Normal file
|
|
@ -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:<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` 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=<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)
|
||||||
25
views_api.py
25
views_api.py
|
|
@ -53,6 +53,11 @@ from .distribution import (
|
||||||
process_settlement,
|
process_settlement,
|
||||||
settle_lp_balance,
|
settle_lp_balance,
|
||||||
)
|
)
|
||||||
|
from .nostr_publish import (
|
||||||
|
publish_fleet_roster,
|
||||||
|
publish_machine_config,
|
||||||
|
tombstone_machine_config,
|
||||||
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
AppendSettlementNoteData,
|
AppendSettlementNoteData,
|
||||||
ClientBalanceSummary,
|
ClientBalanceSummary,
|
||||||
|
|
@ -103,7 +108,14 @@ async def api_create_machine(
|
||||||
data: CreateMachineData, user: User = Depends(check_user_exists)
|
data: CreateMachineData, user: User = Depends(check_user_exists)
|
||||||
) -> Machine:
|
) -> Machine:
|
||||||
await _assert_wallet_owned_by(data.wallet_id, user.id)
|
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])
|
@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)
|
updated = await update_machine(machine_id, data)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
|
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
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -154,6 +171,12 @@ async def api_delete_machine(
|
||||||
if machine is None or machine.operator_user_id != user.id:
|
if machine is None or machine.operator_user_id != user.id:
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
|
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
|
||||||
await delete_machine(machine_id)
|
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)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue