spirekeeper/models.py
Padreug 9abf695fd5
Some checks failed
ci.yml / feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31) (pull_request) Failing after 0s
feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31)
Wires the server-side per-transaction cash-in ceiling the `create_withdraw`
handler already enforces (it read the value defensively via getattr; this
makes it a first-class config field).

- migrations.py m012: ADD COLUMN super_config.max_cash_in_sats INTEGER (NULL
  = no cap).
- models.py: SuperConfig.max_cash_in_sats + UpdateSuperConfigData field with a
  >= 0 validator.
- super-fee dialog: a "Max cash-in per transaction (sats)" input; blank sends
  null (the PUT skips null, preserving the current value — set 0 to reject
  every cash-in). crud `update_super_config` and the PUT endpoint flow the
  field through automatically (dynamic dict update; check_super_user gated).

Why a sats cap and not the bunker ACL: the ACL / usage caps (#28) gate call
*rate*, not *sats*, and `principal_sats` is necessarily ATM-attested — so a
single in-rate call could request an arbitrarily large payout. This bounds a
compromised/buggy machine to one capped transaction.

Verified on the dev stack: m012 runs, the model round-trips the column
(GET returns the set value), and a negative value is rejected.
2026-06-22 12:51:59 +02:00

870 lines
31 KiB
Python

# Satoshi Machine v2 — Pydantic data models.
#
# The v2 schema replaces the Lamassu-era single-config, super-only data model
# with a per-operator multi-machine layout that ingests bitSpire settlements
# over Nostr kind-21000. See migrations.py::m005_satmachine_v2_overhaul and
# the plan at ~/.claude/plans/snug-gliding-shamir.md.
from datetime import datetime
from pydantic import BaseModel, validator
# =============================================================================
# Machines — one row per bitSpire ATM, owned by exactly one operator.
# =============================================================================
class CreateMachineData(BaseModel):
"""Operator adds a machine to their fleet by Nostr npub.
`wallet_id` is the LNbits wallet that will receive bitSpire settlements
for this machine. The same operator can own multiple machines; each
machine gets its own wallet so per-machine accounting via Payment.tag
(set to "satmachine:{machine_npub}") works natively.
`operator_cash_*_fee_fraction` is the per-machine operator fee charged on
top of the platform-wide super fee. Both fractions sit on top of the
super's per-direction fractions and are calculated against principal,
not against any fee total. See aiolabs/satmachineadmin#37 / #38.
"""
# Optional: blank = register the machine UNPAIRED — the bunker mints its
# identity at pairing (model A1, the normal path). Supplying an npub here
# is the development self-key path (a machine that holds its own signing
# key); see views_api.api_create_machine.
machine_npub: str | None = None
wallet_id: str
name: str | None = None
location: str | None = None
fiat_code: str = "GTQ"
operator_cash_in_fee_fraction: float = 0.0
operator_cash_out_fee_fraction: float = 0.0
@validator("operator_cash_in_fee_fraction", "operator_cash_out_fee_fraction")
def _operator_fee_in_unit_range(cls, v):
if v is None:
return 0.0
if v < 0 or v > 1:
raise ValueError("operator fee fraction must be between 0 and 1")
return round(float(v), 4)
class Machine(BaseModel):
id: str
operator_user_id: str
machine_npub: str | None # NULL until paired (or supplied on the dev self-key path)
wallet_id: str
name: str | None
location: str | None
fiat_code: str
is_active: bool
operator_cash_in_fee_fraction: float = 0.0
operator_cash_out_fee_fraction: float = 0.0
# NIP-46 bunker pairing (S0 / #9). NULL until the spire is first paired.
bunker_spire_key_name: str | None = None
paired_at: datetime | None = None
created_at: datetime
updated_at: datetime
class UpdateMachineData(BaseModel):
name: str | None = None
location: str | None = None
fiat_code: str | None = None
is_active: bool | None = None
wallet_id: str | None = None
operator_cash_in_fee_fraction: float | None = None
operator_cash_out_fee_fraction: float | None = None
@validator("operator_cash_in_fee_fraction", "operator_cash_out_fee_fraction")
def _operator_fee_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("operator fee fraction must be between 0 and 1")
return round(float(v), 4)
class PairMachineData(BaseModel):
"""Body for POST /machines/{id}/pair (S0 / #9). `relays` are the relays
the spire will use for its own events (kind-21000/30078) — typically the
operator's nostrrelay. `bunker_relay` overrides the relay embedded in the
seed's `bunker://` URL (the relay the spire uses to *reach* the bunker);
when omitted it defaults to `settings.lnbits_nsec_bunker_url`. Set it when
the relay lnbits uses to reach the bunker differs from the one the spire
must reach — e.g. an internal docker hostname (`ws://lnbits:5001/…`) vs a
LAN/public URL (`ws://192.168.0.32:5001/…`), or any split-relay deploy.
`duration_hours` optionally time-bounds the spire's connect token
(None = non-expiring)."""
relays: list[str]
bunker_relay: str | None = None
duration_hours: int | None = None
@validator("duration_hours")
def _positive_duration(cls, v):
if v is not None and v <= 0:
raise ValueError("duration_hours must be positive when set")
return v
# =============================================================================
# DCA Clients — LP registrations, scoped per (machine, user).
# =============================================================================
class CreateDcaClientData(BaseModel):
"""Operator enrols an LP at one of their machines.
Pure (machine, LP) tuple — no wallet, no mode, no autoforward. Those
live on the per-user `dca_lp` row, written by the LP themselves via
satmachineclient. An LP must have onboarded (have a `dca_lp` row)
before deposits can be recorded against this enrolment; enrolment
itself works either way.
"""
machine_id: str
user_id: str
username: str | None = None
class DcaClient(BaseModel):
id: str
machine_id: str
user_id: str
username: str | None
status: str
created_at: datetime
updated_at: datetime
# Computed at SELECT time via LEFT JOIN on dca_lp. Lets the operator
# UI render "pending onboarding" badges and disable deposit creation
# without a second round-trip per row.
lp_onboarded: bool = False
class UpdateDcaClientData(BaseModel):
"""Operator-side updates to an enrolment. The operator can only edit
fields that aren't LP-controlled (username display, status). Wallet
/ mode / autoforward changes go through satmachineclient against
`dca_lp` instead."""
username: str | None = None
status: str | None = None
class DcaLpPreferences(BaseModel):
"""Per-user DCA preferences, owned by the LP.
Created on first satmachineclient dashboard access (the extension
auto-seeds `dca_wallet_id` with the LP's first/default LNbits wallet
— they can change it from the dashboard). All distribution decisions
(where do the sats go, do we forward to an LN address, what's the
default mode) read from here, joined onto `dca_clients` by user_id.
"""
user_id: str
dca_wallet_id: str
default_dca_mode: str # 'flow' | 'fixed'
fixed_mode_daily_limit: float | None
autoforward_ln_address: str | None
autoforward_enabled: bool
created_at: datetime
updated_at: datetime
class UpsertDcaLpData(BaseModel):
"""satmachineclient writes this on first onboarding / when the LP
edits their preferences. All fields optional on update — pass only
the ones being changed."""
dca_wallet_id: str | None = None
default_dca_mode: str | None = None
fixed_mode_daily_limit: float | None = None
autoforward_ln_address: str | None = None
autoforward_enabled: bool | None = None
class ClientBalanceSummary(BaseModel):
client_id: str
machine_id: str
total_deposits: float # confirmed deposits in fiat
total_payments: float # DCA fiat-equivalent distributed
remaining_balance: float # deposits - payments
currency: str
# =============================================================================
# Deposits — fiat the operator (or super) records against an LP at a machine.
# =============================================================================
class CreateDepositData(BaseModel):
"""Operator records a fiat deposit against an LP enrolment.
`currency` is server-set from the target machine's `fiat_code` at
write time — the API ignores any value the client submits. Each
machine currently handles exactly one currency (`dca_machines.
fiat_code`); allowing the operator to pick a different one at
deposit time would either be a typo or a future multi-currency
feature that doesn't exist yet (`aiolabs/satmachineadmin#26`).
"""
client_id: str
machine_id: str
amount: float
notes: str | None = None
@validator("amount")
def round_amount(cls, v):
if v is not None:
return round(float(v), 2)
return v
class DcaDeposit(BaseModel):
id: str
client_id: str
machine_id: str
creator_user_id: str
amount: float
currency: str
status: str # 'pending' | 'confirmed' | 'rejected'
notes: str | None
created_at: datetime
confirmed_at: datetime | None
class UpdateDepositData(BaseModel):
"""Operator edits on a pending deposit. `currency` removed — see
`CreateDepositData`; the currency is bound to the machine and not
editable after the row lands."""
amount: float | None = None
notes: str | None = None
@validator("amount")
def round_amount(cls, v):
if v is not None:
return round(float(v), 2)
return v
class UpdateDepositStatusData(BaseModel):
status: str # 'pending' | 'confirmed' | 'rejected'
notes: str | None = None
# =============================================================================
# Settlements — one per bitSpire kind-21000 event.
# =============================================================================
# platform_fee_sats and operator_fee_sats are absolute audit-grade values.
# Today they equal the contractual split; tomorrow (post-v1 promo engine)
# they record who-forgave-what. DO NOT collapse them into a single fraction.
# See plan section "Customer discounts & promotions (post-v1)".
class CreateDcaSettlementData(BaseModel):
machine_id: str
payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table)
bitspire_event_id: str | None = None # reserved for direct-Nostr ingestion
bitspire_txid: str | None = None
wire_sats: int
fiat_amount: float
fiat_code: str = "GTQ"
exchange_rate: float
principal_sats: int
fee_sats: int
platform_fee_sats: int
operator_fee_sats: int
tx_type: str # 'cash_out' | 'cash_in'
# Phase-1 observability column (aiolabs/satmachineadmin#38).
# `bitspire_fee_sats - (platform_fee_sats + operator_fee_sats)` —
# positive means bitspire over-reported, negative means under-reported.
# Recorded unconditionally; WARN-logged when |delta| > tolerance. NULL
# only on pre-#38 rows.
fee_mismatch_sats: int | None = None
bills_json: str | None = None
cassettes_json: str | None = None
class DcaSettlement(BaseModel):
id: str
machine_id: str
payment_hash: str
bitspire_event_id: str | None
bitspire_txid: str | None
wire_sats: int
fiat_amount: float
fiat_code: str
exchange_rate: float
principal_sats: int
fee_sats: int
platform_fee_sats: int
operator_fee_sats: int
tx_type: str
fee_mismatch_sats: int | None = None
bills_json: str | None
cassettes_json: str | None
# 'pending' (default at insert)
# 'processing' (claim taken by distribution processor)
# 'processed' (all legs paid)
# 'partial' (operator marked partial-dispense after the fact)
# 'refunded' (operator-initiated refund)
# 'errored' (operational distribution failure — retry path applies)
# 'rejected' (Nostr attribution cross-check failed at land time;
# never went near distribution. error_message holds the
# reason. Retry is wrong — investigate the machine.)
status: str
error_message: str | None
processed_at: datetime | None
created_at: datetime
# Append-only audit memo. Populated when an operator triggers an in-place
# adjustment (partial-dispense, manual reconciliation override). Each
# entry timestamped + records original values so the overwrite is
# auditable from the settlement detail view alone. Never edited in place.
notes: str | None = None
# Optimistic-lock claim token written when status flips to 'processing'.
# Two concurrent process_settlement invocations can't both win the claim
# (only one matching read-back). Cleared back to NULL when the leg-
# writing pass completes (status='processed' or 'errored').
processing_claim: str | None = None
# =============================================================================
# Commission splits — operator-defined remainder allocation per machine.
# =============================================================================
# machine_id=NULL means operator's default; non-null means per-machine override.
# Sum of fraction across rows for a (operator_user_id, machine_id) scope must
# be 1.0, enforced at write-time in crud.py.
class CommissionSplitLeg(BaseModel):
"""Single leg of an operator's commission-split rule set.
`target` accepts any of (splitpayments pattern):
- LNbits wallet id
- LNbits wallet invoice key (resolved server-side via get_wallet_for_key)
- Lightning address (user@domain)
- LNURL string (bech32 LNURL...)
"""
target: str
label: str | None = None
fraction: float
sort_order: int = 0
@validator("target")
def non_empty_target(cls, v):
v = (v or "").strip()
if not v:
raise ValueError("target cannot be empty")
return v
@validator("fraction")
def fraction_in_unit_range(cls, v):
if v < 0 or v > 1:
raise ValueError("fraction must be between 0 and 1")
return round(float(v), 4)
class CommissionSplit(BaseModel):
id: str
machine_id: str | None # None = operator's default ruleset
operator_user_id: str
target: str
label: str | None
fraction: float
sort_order: int
created_at: datetime
class SetCommissionSplitsData(BaseModel):
"""Replaces the entire ruleset for a given scope.
`machine_id=None` writes the operator's default ruleset (applies to any
machine without an explicit override). Otherwise scoped per machine.
"""
machine_id: str | None = None
legs: list[CommissionSplitLeg]
@validator("legs")
def legs_sum_to_one(cls, v):
total = round(sum(leg.fraction for leg in v), 4)
if abs(total - 1.0) > 0.0001:
raise ValueError(f"split fractions must sum to 1.0 (got {total})")
return v
# =============================================================================
# Payments — every distribution leg (DCA / super_fee / split / settle / etc.)
# =============================================================================
class CreateDcaPaymentData(BaseModel):
settlement_id: str | None = None
client_id: str | None = None
machine_id: str
operator_user_id: str
leg_type: str
# 'dca' | 'super_fee' | 'operator_split' | 'settlement' | 'autoforward' | 'refund'
destination_wallet_id: str | None = None
destination_ln_address: str | None = None
amount_sats: int
amount_fiat: float | None = None
exchange_rate: float | None = None
transaction_time: datetime
external_payment_hash: str | None = None
class DcaPayment(BaseModel):
id: str
settlement_id: str | None
client_id: str | None
machine_id: str
operator_user_id: str
leg_type: str
destination_wallet_id: str | None
destination_ln_address: str | None
amount_sats: int
amount_fiat: float | None
exchange_rate: float | None
transaction_time: datetime
external_payment_hash: str | None
status: str
# Leg status enum:
# 'pending' — row written, payment not yet attempted
# 'completed' — pay_invoice succeeded; sats moved
# 'failed' — pay_invoice errored; sats stayed at source
# 'voided' — superseded (e.g. partial-dispense recompute voided
# the previous pending/failed leg)
# 'skipped' — intentionally not paid (no super wallet configured,
# no commission ruleset, no exchange rate, no LPs)
# 'refunded' — reserved for future refund flows
error_message: str | None
created_at: datetime
# =============================================================================
# Telemetry — sparse beacon (kind-30078) + fleet snapshot (kind-30079) state.
# =============================================================================
class TelemetrySnapshot(BaseModel):
machine_id: str
# Beacon (kind-30078) — all fields are nullable because the upstream payload
# is sparse today. As lamassu-next#43 lands, the post-#43 columns fill in.
beacon_cash_in: bool | None = None
beacon_cash_out: bool | None = None
beacon_cash_level: str | None = None
beacon_fiat: str | None = None
beacon_model: str | None = None
beacon_name: str | None = None
beacon_location: str | None = None
beacon_geo: str | None = None
beacon_fees_json: str | None = None
beacon_limits_json: str | None = None
beacon_denominations_json: str | None = None
beacon_version: str | None = None
beacon_received_at: datetime | None = None
# Fleet telemetry (kind-30079) — operator-only, awaits lamassu-next#42.
telemetry_json: str | None = None
telemetry_received_at: datetime | None = None
# =============================================================================
# Super config — singleton row with the platform fee.
# =============================================================================
class SuperConfig(BaseModel):
id: str
super_cash_in_fee_fraction: float = 0.0
super_cash_out_fee_fraction: float = 0.0
super_fee_wallet_id: str | None
# Per-transaction cash-in ceiling in sats (#31). The bunker ACL gates call
# rate, not sats, so this bounds a single ATM-attested principal. NULL = no
# cap.
max_cash_in_sats: int | None = None
updated_at: datetime
class UpdateSuperConfigData(BaseModel):
super_cash_in_fee_fraction: float | None = None
super_cash_out_fee_fraction: float | None = None
super_fee_wallet_id: str | None = None
max_cash_in_sats: int | None = None
@validator(
"super_cash_in_fee_fraction",
"super_cash_out_fee_fraction",
)
def _fee_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("super fee fraction must be between 0 and 1")
return round(float(v), 4)
@validator("max_cash_in_sats")
def _cap_non_negative(cls, v):
if v is None:
return v
if v < 0:
raise ValueError("max_cash_in_sats must be >= 0")
return int(v)
# =============================================================================
# Operator UX action carriers — partial-tx and balance-settlement features.
# =============================================================================
class PartialDispenseData(BaseModel):
"""Resolves spirekeeper#1 — operator confirms actual bills dispensed
when bitSpire reports an error mid-dispense.
Either `dispensed_fraction` (0..1) for ratio-based recompute, or
`dispensed_sats` for explicit recompute. Exactly one must be set.
"""
settlement_id: str
dispensed_fraction: float | None = None
dispensed_sats: int | None = None
notes: str | None = None
@validator("dispensed_fraction")
def fraction_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("dispensed_fraction must be between 0 and 1")
return v
class StuckSettlementsResponse(BaseModel):
"""Operator worklist surfacing settlements that didn't process cleanly.
Four categories, segregated so the UI can render them with the
right affordances (investigate / retry / force-error):
- rejected: Nostr attribution cross-check failed at land time —
the kind-21000 invoice signer didn't match the machine identity.
Distribution never ran. Retry is *wrong* for these: the row was
misrouted, not operationally failed. Operator investigates the
machine.
- errored: distribution ran and one or more legs reported a payment
error. Operator retry endpoint handles these directly.
- stuck_pending: landed but never picked up by the processor
(listener crashed before invoking process_settlement, or the
claim was lost). Older than `threshold_minutes`.
- stuck_processing: claim was taken but no completion in
`threshold_minutes`. Processor likely crashed mid-flight.
Operator can force-recover via POST .../force-reset.
"""
threshold_minutes: int
rejected: list # list[DcaSettlement]
errored: list
stuck_pending: list
stuck_processing: list
class AppendSettlementNoteData(BaseModel):
"""Operator-authored free-form note on a settlement.
Notes are prepended (newest first) to the settlement's `notes` column,
with a UTC timestamp and the author's user id so each entry is
accountable. Useful for cash-drawer reconciliation context, off-the-
record refund records, or any narrative an operator wants to attach
for future reference.
"""
note: str
@validator("note")
def non_empty(cls, v):
v = v.strip() if isinstance(v, str) else v
if not v:
raise ValueError("note cannot be empty")
if len(v) > 2000:
raise ValueError("note too long (max 2000 chars)")
return v
class SettleBalanceData(BaseModel):
"""Resolves spirekeeper#2 — operator settles small remaining LP balance
from their own wallet at a specified exchange rate.
Use case: an LP has a small remaining fiat balance (e.g. 47 GTQ) that
keeps shrinking proportionally on each new transaction (Zeno's paradox).
Operator hits 'Settle', specifies the exchange rate they're willing to
honor, and the system pays out the remaining balance in sats from the
operator's wallet. The LP's balance goes to zero; settlement legs count
against the LP's balance summary alongside DCA legs.
"""
funding_wallet_id: str
# The exchange rate the operator is settling at (sats per 1 fiat unit).
# Operator picks the rate so they can use exchange spot, a market
# midpoint, or a favorable rate as a gesture. Required and explicit so
# there's no ambiguity about what rate was used.
exchange_rate: float
# If None, settle the LP's full remaining balance. Else partial.
amount_fiat: float | None = None
notes: str | None = None
@validator("exchange_rate")
def positive_rate(cls, v):
if v is None or v <= 0:
raise ValueError("exchange_rate must be > 0 (sats per fiat unit)")
return float(v)
@validator("amount_fiat")
def round_amount(cls, v):
if v is None:
return v
if v <= 0:
raise ValueError("amount_fiat must be > 0 if specified")
return round(float(v), 2)
# =============================================================================
# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1).
# =============================================================================
# Schema is position-keyed per the coordinated v1.1 redesign at coord-log
# 2026-05-30T18:30Z + 18:45Z. The earlier denomination-keyed shape (m007)
# was wrong: real machines have N cassettes of the same denomination for
# cash-out throughput, and operators need to swap cartridge denominations
# during refill ($20 bay becomes a $50 bay) without re-provisioning.
#
# Wire shape:
# {"positions": {"<position_str>": {"denomination": N, "count": M}}}
#
# Editable surface per row:
# - denomination: yes (operator swaps cartridges during refill)
# - count: yes (refill / decrement)
# Read-only per row:
# - position: hardware bay number; the slot count is fixed by the
# dispenser model (e.g., Tejo has 4 positions).
#
# No "denomination must be unique within payload" constraint: multiple
# same-denom cassettes are operationally valid. The ATM HAL distributes
# a dispense request greedy across all positions matching the requested
# denomination (lamassu-next#56 v1.1 HAL refactor).
#
# state_* columns are reserved nullable for the v2 reverse-channel
# reconciliation consumer (bitspire-cassettes-state:<atm_pubkey_hex>).
# v1 populates them on bootstrap-event receipt but the UI doesn't render
# reconciliation. state_denomination (added in m008) lets v2 highlight
# operator-believed-vs-ATM-reported denomination drift per slot.
class CassetteConfig(BaseModel):
machine_id: str
position: int
denomination: int
count: int
updated_at: datetime
updated_by: str | None
state_denomination: int | None
state_count: int | None
state_at: datetime | None
state_event_id: str | None
class UpsertCassetteConfigData(BaseModel):
"""Operator edits a single cassette row's denomination or count from
the dashboard. Both fields optional; pass only those changed.
Position is not edited — it's the row's identity (hardware bay)."""
denomination: int | None = None
count: int | None = None
@validator("denomination")
def denomination_positive(cls, v):
if v is None:
return v
if v <= 0:
raise ValueError("denomination must be > 0")
return v
@validator("count")
def count_non_negative(cls, v):
if v is None:
return v
if v < 0:
raise ValueError("count must be >= 0")
return v
class CassettePayloadRow(BaseModel):
"""One position's payload values in the wire-format
`{"positions": {"<pos>": {"denomination", "count"}}}`."""
denomination: int
count: int
@validator("denomination")
def denomination_positive(cls, v):
if v <= 0:
raise ValueError("denomination must be > 0")
return v
@validator("count")
def count_non_negative(cls, v):
if v < 0:
raise ValueError("count must be >= 0")
return v
class PublishCassettesPayload(BaseModel):
"""The decrypted JSON content of a kind-30078 cassette event, both
directions:
- operator → ATM (d-tag `bitspire-cassettes:<atm_pubkey_hex>`)
- ATM → operator (d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`)
Wire shape: `{"positions": {"<pos_str>": {"denomination", "count"}}}`.
JSON object keys are always strings; the validator coerces back to
int on parse. The position key set MUST match what the receiver
already has (slot count is hardware-fixed; no add/remove from this
payload).
No denomination-unique constraint: multiple same-denom cassettes are
operationally valid (cash-out throughput on a popular denom).
"""
positions: dict[int, CassettePayloadRow]
@validator("positions", pre=True)
def coerce_string_keys_to_int(cls, v):
if not isinstance(v, dict):
raise ValueError("positions must be a dict")
out = {}
for k, val in v.items():
try:
key_int = int(k)
except (TypeError, ValueError) as exc:
raise ValueError(f"position key {k!r} is not an int") from exc
if key_int <= 0:
raise ValueError(f"position must be > 0 (got {key_int})")
out[key_int] = val
return out
def to_wire_dict(self) -> dict:
"""Serialise back to the wire format with string keys for JSON
object compatibility. Used by the publisher to build the kind-30078
event content before NIP-44 v2 encryption."""
return {
"positions": {
str(pos): {
"denomination": row.denomination,
"count": row.count,
}
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,
},
}