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>
This commit is contained in:
parent
aeaee1f568
commit
12f39226f0
3 changed files with 580 additions and 0 deletions
325
tests/test_fee_transport.py
Normal file
325
tests/test_fee_transport.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue