diff --git a/fee_transport.py b/fee_transport.py new file mode 100644 index 0000000..1dc0ec7 --- /dev/null +++ b/fee_transport.py @@ -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:"], + ["p", ""], + ] + 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 diff --git a/models.py b/models.py index 1094f6a..f7f84e4 100644 --- a/models.py +++ b/models.py @@ -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:`). + + 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, + }, + } diff --git a/tests/test_fee_transport.py b/tests/test_fee_transport.py new file mode 100644 index 0000000..58bbf03 --- /dev/null +++ b/tests/test_fee_transport.py @@ -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