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
151
fee_transport.py
Normal file
151
fee_transport.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue