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
104
models.py
104
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:<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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue