spirekeeper/fee_transport.py
Padreug a059e3f596 refactor: rename extension identity to spirekeeper
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>
2026-06-13 22:30:05 +02:00

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