Fork of satmachineadmin's v2-bitspire line into its own repo. Renames
both identifiers so this extension is fully independent of the original
satmachineadmin install (which remains in service):
- extension id satmachineadmin -> spirekeeper
(router prefix, static path/static_url_for, module symbols, task
names, templates dir, config/manifest paths)
- database name satoshimachine -> spirekeeper
(Database(ext_spirekeeper), all schema-qualified table refs)
Also resets versioning to 0.1.0, sets the display name + manifest to
spirekeeper/aiolabs, and fixes the placeholder pyproject description.
Historical aiolabs/satmachineadmin#N issue references in comments are
left pointing at the original repo where those issues live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
151 lines
6 KiB
Python
151 lines
6 KiB
Python
"""
|
|
Fee-config Nostr transport — operator → ATM kind-30078 publish.
|
|
|
|
Layer 2 of the operator-configurable fee architecture
|
|
(aiolabs/satmachineadmin#37 parent, #39 this layer). Pairs with the
|
|
bitspire consumer at `aiolabs/lamassu-next#57`.
|
|
|
|
Wire format locked at coord-log §2026-06-01T14:25Z:
|
|
|
|
kind = 30078 (NIP-78, replaceable)
|
|
tags = [
|
|
["d", "bitspire-fees:<atm_pubkey_hex>"],
|
|
["p", "<atm_pubkey_hex>"],
|
|
]
|
|
content = NIP-44 v2 encrypted JSON of FeeConfigPayload.to_wire_dict()
|
|
pubkey = operator pubkey
|
|
sig = operator signature
|
|
|
|
Producer-side invariants (enforced via FeeConfigPayload validators):
|
|
- cash_*_fee_fraction ≤ 0.15 (cap, mirrored on bitspire consumer)
|
|
- |total - components sum| < 1e-6 (consistency assert)
|
|
- schema_version integer ≥ 1
|
|
|
|
Soft-fail discipline (matches `cassette_transport.publish_to_atm`):
|
|
relay/signer/bunker hiccups log + return None rather than raising,
|
|
so a fee-config trigger from a CRUD endpoint can't break the
|
|
underlying machine create/update on a transient transport failure.
|
|
Hard-raises on configuration errors (cap exceeded, operator has no
|
|
pubkey) since those indicate a bug, not a transient.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from loguru import logger
|
|
|
|
from .models import FeeConfigPayload, FeePayloadComponents, Machine, SuperConfig
|
|
from .nostr_publish import (
|
|
NostrPublishError,
|
|
OperatorIdentityMissing,
|
|
RelayUnavailable,
|
|
SignerUnavailable,
|
|
publish_encrypted_kind_30078,
|
|
)
|
|
|
|
_D_TAG_FEES_PREFIX = "bitspire-fees:"
|
|
|
|
|
|
def _atm_hex_pubkey(machine: Machine) -> str:
|
|
"""Canonicalise machine.machine_npub to lowercase hex. Used for both
|
|
the d-tag suffix and the NIP-44 v2 recipient pubkey. Same shape as
|
|
cassette_transport's local helper — kept module-local since it's a
|
|
one-liner over `normalize_public_key` and inlining would invert the
|
|
abstraction direction (transport-module-knows-about-Machine is
|
|
correct; nostr_publish doesn't know about Machine)."""
|
|
from lnbits.utils.nostr import normalize_public_key
|
|
|
|
return normalize_public_key(machine.machine_npub).lower()
|
|
|
|
|
|
def _fees_d_tag(atm_pubkey_hex: str) -> str:
|
|
return f"{_D_TAG_FEES_PREFIX}{atm_pubkey_hex}"
|
|
|
|
|
|
def build_fee_payload(
|
|
super_config: SuperConfig, machine: Machine
|
|
) -> FeeConfigPayload:
|
|
"""Compose a FeeConfigPayload from current super-config + per-machine
|
|
fractions. FeeConfigPayload's validators enforce the cap +
|
|
consistency invariants — this function constructs and validates
|
|
in one step; a returned payload is wire-shippable.
|
|
|
|
Raises ValueError (via Pydantic) if any directional total exceeds
|
|
the 0.15 cap. That's a hard error because the upstream API layer
|
|
(views_api._assert_machine_fee_cap_safe + _assert_super_config_cap_safe)
|
|
should have rejected the create/update that produced this state.
|
|
If we reach here with a cap-violating state, something bypassed the
|
|
API guards and we'd rather refuse-to-publish than ship a malformed
|
|
event.
|
|
"""
|
|
super_in = float(super_config.super_cash_in_fee_fraction)
|
|
super_out = float(super_config.super_cash_out_fee_fraction)
|
|
op_in = float(machine.operator_cash_in_fee_fraction)
|
|
op_out = float(machine.operator_cash_out_fee_fraction)
|
|
|
|
return FeeConfigPayload(
|
|
schema_version=1,
|
|
cash_in_fee_fraction=round(super_in + op_in, 4),
|
|
cash_out_fee_fraction=round(super_out + op_out, 4),
|
|
components=FeePayloadComponents(
|
|
super_cash_in=super_in,
|
|
super_cash_out=super_out,
|
|
operator_cash_in=op_in,
|
|
operator_cash_out=op_out,
|
|
),
|
|
)
|
|
|
|
|
|
async def publish_fee_config(
|
|
machine: Machine,
|
|
super_config: SuperConfig,
|
|
operator_user_id: str,
|
|
) -> dict | None:
|
|
"""Build, validate, encrypt, sign, publish the fee-config event for
|
|
`machine` to the ATM at `machine.machine_npub`.
|
|
|
|
Returns the signed event dict on success (caller may log event.id
|
|
for audit). Returns None on soft-fail (transport-layer errors:
|
|
relay unreachable, signer offline, bunker timeout) — these are
|
|
transient and the caller's underlying CRUD operation should succeed
|
|
independent of publish success. Logs WARNING on soft-fail.
|
|
|
|
Raises on hard configuration error:
|
|
- OperatorIdentityMissing — operator has no Nostr pubkey on file
|
|
(caller's API layer should refuse the operation before we get
|
|
here, but we propagate as HTTP 400 if it slips through)
|
|
- ValueError (from FeeConfigPayload validators) — cap violation
|
|
or sum/components mismatch, indicates an API-guard bypass
|
|
"""
|
|
payload = build_fee_payload(super_config, machine)
|
|
atm_pubkey_hex = _atm_hex_pubkey(machine)
|
|
try:
|
|
signed = await publish_encrypted_kind_30078(
|
|
operator_user_id=operator_user_id,
|
|
recipient_pubkey_hex=atm_pubkey_hex,
|
|
d_tag=_fees_d_tag(atm_pubkey_hex),
|
|
payload=payload.to_wire_dict(),
|
|
log_context=(
|
|
f"fee config (machine={machine.id}, "
|
|
f"cash_in={payload.cash_in_fee_fraction}, "
|
|
f"cash_out={payload.cash_out_fee_fraction})"
|
|
),
|
|
)
|
|
except (SignerUnavailable, RelayUnavailable) as exc:
|
|
logger.warning(
|
|
f"spirekeeper: fee-config publish soft-fail for machine "
|
|
f"{machine.id} ({machine.name or machine.machine_npub[:12]}): "
|
|
f"{type(exc).__name__}: {exc}. Underlying CRUD operation "
|
|
"succeeded; operator can re-trigger publish via the next "
|
|
"machine edit or super-config save."
|
|
)
|
|
return None
|
|
except NostrPublishError as exc:
|
|
# Truly unexpected transport error — log + soft-fail. We still
|
|
# don't break the caller's CRUD path; a future publish attempt
|
|
# (next machine edit / next super edit) will retry.
|
|
logger.warning(
|
|
f"spirekeeper: fee-config publish unexpected transport "
|
|
f"error for machine {machine.id}: {type(exc).__name__}: {exc}"
|
|
)
|
|
return None
|
|
return signed
|