feat(v2): operator fee-config Nostr publisher (closes #39) #43

Merged
padreug merged 3 commits from feat/fee-transport into v2-bitspire 2026-06-01 18:20:09 +00:00
3 changed files with 580 additions and 0 deletions
Showing only changes of commit 12f39226f0 - Show all commits

feat(v2): fee_transport — kind-30078 publisher for operator fee config (#39 2/3)

Adds the second operator-pushed kind-30078 document type alongside
cassette config (#29). Wire format locked at coord-log §2026-06-01T14:25Z.

models.py:
- FeePayloadComponents — producer-mandatory `components` sub-object
  with super + operator splits per direction. Consumer-optional in v1
  but ships on every payload from this producer for audit + future-
  promo extensibility.
- FeeConfigPayload — the wire-format envelope. Pydantic validators
  enforce: cash_*_fee_fraction in [0, 0.15] (cap per direction);
  |total - (super + operator)| < 1e-6 (consistency assert per the
  §07:33Z lnbits advisory, mirrored on bitspire's #57 consumer side);
  schema_version integer ≥ 1.

fee_transport.py:
- build_fee_payload(super_config, machine) — compose + validate in
  one call; returned payload is wire-shippable. Raises ValueError
  (via Pydantic) if the constructed totals violate the cap. That
  shouldn't happen in practice because the API guards in
  views_api._assert_machine_fee_cap_safe + _assert_super_config_cap_safe
  refuse cap-violating writes; if it does, refuse-to-publish rather
  than ship a malformed event.
- publish_fee_config(machine, super_config, operator_user_id) —
  builds, encrypts, signs, publishes via the shared
  publish_encrypted_kind_30078 helper from nostr_publish. d-tag is
  `bitspire-fees:<atm_pubkey_hex>` per spec; recipient is the ATM
  npub canonicalised to hex; signer is the operator.
- Soft-fail discipline matches cassette_transport.publish_to_atm —
  transport-layer errors (RelayUnavailable / SignerUnavailable /
  OperatorIdentityMissing) log WARN + return None so trigger callers
  (api_create_machine etc.) don't break on transient transport hiccups.
  Cap violations are NOT soft-fail since they indicate an API-guard
  bypass and need operator attention.

Tests (18 cases, all green):
- 9 FeeConfigPayload validator cases (well-formed accept, wire round-
  trip, cap violations per direction, exact-cap acceptance, sum/
  components mismatch per direction, schema_version ≥ 1, zero-zero
  free-charge ATM)
- 4 build_fee_payload composition cases (basic, asymmetric directions,
  super-only-no-operator default, cap violation at build time)
- 5 publish_fee_config soft-fail discipline cases (relay unavailable,
  signer unavailable, operator identity missing, publish success with
  d-tag + recipient + payload-shape assertions, cap violation raises
  before reaching publish)

182/182 tests green.

Refs: aiolabs/satmachineadmin#37 (parent), #39 (Layer 2), coord-log
§2026-06-01T14:25Z (locked wire format), §2026-06-01T07:33Z (lnbits
consistency-assert advisory).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Padreug 2026-06-01 20:00:29 +02:00

151
fee_transport.py Normal file
View file

@ -0,0 +1,151 @@
"""
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"satmachineadmin: 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"satmachineadmin: fee-config publish unexpected transport "
f"error for machine {machine.id}: {type(exc).__name__}: {exc}"
)
return None
return signed

104
models.py
View file

@ -721,3 +721,107 @@ class PublishCassettesPayload(BaseModel):
for pos, row in self.positions.items()
}
}
# =============================================================================
# Fee-config Nostr payload — operator → ATM (aiolabs/satmachineadmin#39)
# =============================================================================
# Locked wire format per coord-log §2026-06-01T14:25Z:
# {
# "schema_version": 1,
# "cash_in_fee_fraction": super_cash_in + operator_cash_in,
# "cash_out_fee_fraction": super_cash_out + operator_cash_out,
# "components": {
# "super_cash_in": float,
# "super_cash_out": float,
# "operator_cash_in": float,
# "operator_cash_out": float
# }
# }
#
# Producer invariants (refuse-to-publish if violated):
# - cash_*_fee_fraction ≤ 0.15 (cap, defense in depth — bitspire
# consumer enforces the same)
# - |cash_in_fee_fraction - (super_cash_in + operator_cash_in)| < 1e-6
# - |cash_out_fee_fraction - (super_cash_out + operator_cash_out)| < 1e-6
# - All six fractions in [0.0, 0.15]
# - schema_version is integer ≥ 1
# v1 consumers ignore unknown top-level keys per the locked spec.
class FeePayloadComponents(BaseModel):
"""The producer-mandatory `components` sub-object that splits the
summed `cash_*_fee_fraction` totals back into their super + operator
halves. Audit + future-promo substrate; consumer-optional in v1."""
super_cash_in: float
super_cash_out: float
operator_cash_in: float
operator_cash_out: float
class FeeConfigPayload(BaseModel):
"""The decrypted JSON content of a kind-30078 fee-config event
(operator ATM, d-tag `bitspire-fees:<atm_pubkey_hex>`).
Built from a Machine row + the SuperConfig singleton via
`fee_transport.build_fee_payload`. Validates the cap +
sum-vs-components consistency at construction time so any caller
that holds a FeeConfigPayload instance has a wire-shippable payload.
"""
schema_version: int = 1
cash_in_fee_fraction: float
cash_out_fee_fraction: float
components: FeePayloadComponents
@validator("schema_version")
def _schema_version_at_least_v1(cls, v):
if v < 1:
raise ValueError(f"schema_version must be >= 1, got {v}")
return v
@validator("cash_in_fee_fraction", "cash_out_fee_fraction")
def _total_in_unit_range(cls, v):
# Imported here rather than at module top to avoid a circular
# import (calculations imports nothing from models, but keep the
# dependency direction explicit at the call site).
from .calculations import MAX_FEE_FRACTION_PER_DIRECTION
if v < 0 or v > MAX_FEE_FRACTION_PER_DIRECTION:
raise ValueError(
f"fee fraction must be in [0, {MAX_FEE_FRACTION_PER_DIRECTION}], "
f"got {v}"
)
return round(float(v), 4)
@validator("components", always=True)
def _components_sum_matches_totals(cls, v, values):
sum_in = round(v.super_cash_in + v.operator_cash_in, 4)
sum_out = round(v.super_cash_out + v.operator_cash_out, 4)
total_in = values.get("cash_in_fee_fraction")
total_out = values.get("cash_out_fee_fraction")
if total_in is not None and abs(total_in - sum_in) > 1e-6:
raise ValueError(
f"cash_in_fee_fraction={total_in} doesn't match components "
f"sum super({v.super_cash_in}) + operator({v.operator_cash_in}) = {sum_in}"
)
if total_out is not None and abs(total_out - sum_out) > 1e-6:
raise ValueError(
f"cash_out_fee_fraction={total_out} doesn't match components "
f"sum super({v.super_cash_out}) + operator({v.operator_cash_out}) = {sum_out}"
)
return v
def to_wire_dict(self) -> dict:
return {
"schema_version": self.schema_version,
"cash_in_fee_fraction": self.cash_in_fee_fraction,
"cash_out_fee_fraction": self.cash_out_fee_fraction,
"components": {
"super_cash_in": self.components.super_cash_in,
"super_cash_out": self.components.super_cash_out,
"operator_cash_in": self.components.operator_cash_in,
"operator_cash_out": self.components.operator_cash_out,
},
}

325
tests/test_fee_transport.py Normal file
View file

@ -0,0 +1,325 @@
"""
Tests for `fee_transport.py` and `models.FeeConfigPayload`
Layer 2 of the operator-configurable fee architecture
(aiolabs/satmachineadmin#39).
Three concerns covered:
1. FeeConfigPayload validators enforce the locked wire-format
invariants (cap 0.15 per direction, components sum matches totals,
schema_version 1).
2. `build_fee_payload(super_config, machine)` composes a payload
from current DB rows. Wraps construction + validation in one call.
3. `publish_fee_config(machine, super_config, operator_user_id)`
soft-fail discipline: transport errors log + return None, hard
errors (cap-violating state) propagate.
"""
from datetime import datetime
import pytest
from .. import fee_transport
from ..fee_transport import build_fee_payload, publish_fee_config
from ..models import FeeConfigPayload, FeePayloadComponents, Machine, SuperConfig
from ..nostr_publish import (
OperatorIdentityMissing,
RelayUnavailable,
SignerUnavailable,
)
_NOW = datetime(2026, 6, 1, 12, 0, 0)
_ATM_PUBKEY_HEX = (
"522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
)
def _machine(op_in: float = 0.05, op_out: float = 0.05) -> Machine:
return Machine(
id="m1",
operator_user_id="op1",
machine_npub=_ATM_PUBKEY_HEX,
wallet_id="w1",
name="sintra",
location=None,
fiat_code="EUR",
is_active=True,
operator_cash_in_fee_fraction=op_in,
operator_cash_out_fee_fraction=op_out,
created_at=_NOW,
updated_at=_NOW,
)
def _super(in_frac: float = 0.03, out_frac: float = 0.03) -> SuperConfig:
return SuperConfig(
id="default",
super_cash_in_fee_fraction=in_frac,
super_cash_out_fee_fraction=out_frac,
super_fee_wallet_id="super-wallet",
updated_at=_NOW,
)
# ---------------------------------------------------------------------------
# FeeConfigPayload — wire-format validators
# ---------------------------------------------------------------------------
class TestFeeConfigPayloadValidators:
def _components(
self, s_in: float = 0.03, s_out: float = 0.03, o_in: float = 0.05, o_out: float = 0.05
) -> FeePayloadComponents:
return FeePayloadComponents(
super_cash_in=s_in,
super_cash_out=s_out,
operator_cash_in=o_in,
operator_cash_out=o_out,
)
def test_well_formed_payload_accepts(self):
payload = FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.08,
cash_out_fee_fraction=0.08,
components=self._components(),
)
assert payload.schema_version == 1
assert payload.cash_in_fee_fraction == 0.08
assert payload.cash_out_fee_fraction == 0.08
def test_to_wire_dict_round_trips(self):
original = FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.08,
cash_out_fee_fraction=0.1077,
components=self._components(o_out=0.0777),
)
wire = original.to_wire_dict()
rebuilt = FeeConfigPayload(**wire)
assert rebuilt.cash_in_fee_fraction == 0.08
assert rebuilt.cash_out_fee_fraction == 0.1077
assert rebuilt.components.operator_cash_out == 0.0777
def test_cap_violation_cash_in_rejects(self):
# cap is 0.15 per direction.
with pytest.raises(ValueError, match="fee fraction must be in"):
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.16,
cash_out_fee_fraction=0.08,
components=self._components(s_in=0.10, o_in=0.06),
)
def test_cap_violation_cash_out_rejects(self):
with pytest.raises(ValueError, match="fee fraction must be in"):
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.08,
cash_out_fee_fraction=0.20,
components=self._components(s_out=0.10, o_out=0.10),
)
def test_exact_cap_accepted(self):
"""0.15 exactly is the upper bound — must accept."""
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.15,
cash_out_fee_fraction=0.15,
components=self._components(s_in=0.10, s_out=0.10, o_in=0.05, o_out=0.05),
)
def test_inconsistent_total_vs_components_rejects_cash_in(self):
"""sum(super_cash_in + operator_cash_in) must equal
cash_in_fee_fraction within 1e-6."""
with pytest.raises(ValueError, match="cash_in_fee_fraction"):
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.09, # claims 9%
cash_out_fee_fraction=0.08,
components=self._components(), # actually 0.03 + 0.05 = 0.08
)
def test_inconsistent_total_vs_components_rejects_cash_out(self):
with pytest.raises(ValueError, match="cash_out_fee_fraction"):
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.08,
cash_out_fee_fraction=0.10, # claims 10%
components=self._components(), # actually 0.08
)
def test_schema_version_zero_rejects(self):
with pytest.raises(ValueError, match="schema_version must be"):
FeeConfigPayload(
schema_version=0,
cash_in_fee_fraction=0.08,
cash_out_fee_fraction=0.08,
components=self._components(),
)
def test_zero_fractions_accepted(self):
"""Free-charge ATM — both super + operator at 0 → totals 0."""
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.0,
cash_out_fee_fraction=0.0,
components=self._components(s_in=0.0, s_out=0.0, o_in=0.0, o_out=0.0),
)
# ---------------------------------------------------------------------------
# build_fee_payload — composition from SuperConfig + Machine
# ---------------------------------------------------------------------------
class TestBuildFeePayload:
def test_basic_composition(self):
payload = build_fee_payload(_super(0.03, 0.03), _machine(0.05, 0.05))
assert payload.cash_in_fee_fraction == 0.08
assert payload.cash_out_fee_fraction == 0.08
assert payload.components.super_cash_in == 0.03
assert payload.components.operator_cash_in == 0.05
def test_different_directions(self):
"""Cash-in and cash-out can differ — payload preserves both."""
payload = build_fee_payload(_super(0.03, 0.05), _machine(0.0333, 0.0777))
assert payload.cash_in_fee_fraction == 0.0633
assert payload.cash_out_fee_fraction == 0.1277
def test_super_only_no_operator(self):
"""Pre-Layer-2 default — machine has 0/0 operator fees; payload
carries super-only totals. This is the 'publish on machine create'
path's expected shape."""
payload = build_fee_payload(_super(0.03, 0.03), _machine(0.0, 0.0))
assert payload.cash_in_fee_fraction == 0.03
assert payload.cash_out_fee_fraction == 0.03
def test_cap_violation_at_build_time_raises(self):
"""If the API guards were bypassed and the DB has a cap-violating
state, build_fee_payload refuses rather than ship a bad payload."""
with pytest.raises(ValueError, match="fee fraction must be in"):
build_fee_payload(_super(0.10, 0.03), _machine(0.10, 0.0))
# 0.10 + 0.10 = 0.20 > 0.15
# ---------------------------------------------------------------------------
# publish_fee_config — soft-fail discipline
# ---------------------------------------------------------------------------
class TestPublishFeeConfigSoftFail:
def test_relay_unavailable_returns_none_logs_warning(
self, monkeypatch, loguru_capture
):
async def fake_publish(**kwargs):
raise RelayUnavailable("nostrclient extension is not installed")
monkeypatch.setattr(
fee_transport, "publish_encrypted_kind_30078", fake_publish
)
import asyncio
result = asyncio.run(publish_fee_config(_machine(), _super(), "op1"))
assert result is None
assert any(
"soft-fail" in m and "RelayUnavailable" in m for m in loguru_capture
)
def test_signer_unavailable_returns_none_logs_warning(
self, monkeypatch, loguru_capture
):
async def fake_publish(**kwargs):
raise SignerUnavailable("bunker unreachable")
monkeypatch.setattr(
fee_transport, "publish_encrypted_kind_30078", fake_publish
)
import asyncio
result = asyncio.run(publish_fee_config(_machine(), _super(), "op1"))
assert result is None
assert any(
"soft-fail" in m and "SignerUnavailable" in m for m in loguru_capture
)
def test_operator_identity_missing_returns_none_logs_warning(
self, monkeypatch, loguru_capture
):
"""OperatorIdentityMissing is a NostrPublishError but not a
transport one currently soft-fails at the same layer. The
caller may want to convert this to HTTP 400 in future if the
operator-facing UX needs a hard signal, but v1 keeps it soft
because a partially-onboarded operator shouldn't crash machine
create."""
async def fake_publish(**kwargs):
raise OperatorIdentityMissing("no pubkey on file")
monkeypatch.setattr(
fee_transport, "publish_encrypted_kind_30078", fake_publish
)
import asyncio
result = asyncio.run(publish_fee_config(_machine(), _super(), "op1"))
assert result is None
def test_publish_success_returns_signed_event(self, monkeypatch):
signed = {
"id": "ev1",
"kind": 30078,
"pubkey": "op_pubkey",
"content": "ciphertext",
"tags": [["d", f"bitspire-fees:{_ATM_PUBKEY_HEX}"], ["p", _ATM_PUBKEY_HEX]],
"created_at": 1780000000,
"sig": "ff" * 32,
}
captured = {}
async def fake_publish(**kwargs):
captured.update(kwargs)
return signed
monkeypatch.setattr(
fee_transport, "publish_encrypted_kind_30078", fake_publish
)
import asyncio
result = asyncio.run(publish_fee_config(_machine(), _super(), "op1"))
assert result is signed
# Verify d-tag matches the locked spec
assert captured["d_tag"] == f"bitspire-fees:{_ATM_PUBKEY_HEX}"
assert captured["recipient_pubkey_hex"] == _ATM_PUBKEY_HEX
# Payload shape carries components per the §14:25Z lock
payload = captured["payload"]
assert payload["schema_version"] == 1
assert payload["cash_in_fee_fraction"] == 0.08
assert "components" in payload
def test_cap_violation_raises_does_not_soft_fail(self, monkeypatch):
"""build_fee_payload raises ValueError at construction time on
cap-violating state. That's a hard configuration error (API
guards bypassed), not a transient transport issue, so it
propagates. publish_encrypted_kind_30078 is never reached."""
called = {"count": 0}
async def fake_publish(**kwargs):
called["count"] += 1
return {}
monkeypatch.setattr(
fee_transport, "publish_encrypted_kind_30078", fake_publish
)
import asyncio
with pytest.raises(ValueError, match="fee fraction must be in"):
asyncio.run(
publish_fee_config(
_machine(op_in=0.10, op_out=0.0),
_super(in_frac=0.10, out_frac=0.0),
"op1",
)
)
assert called["count"] == 0