spirekeeper/models.py
Padreug 5f60b3fe31
Some checks failed
ci.yml / fix(cassettes): break the same-second tie with the machine's counter (pull_request) Failing after 0s
fix(cassettes): break the same-second tie with the machine's counter
The ordering gate compares created_at, which NIP-01 defines at one-second
granularity. A dispense and the publish that follows it land inside one
second routinely, so the report was dropped and the operator kept the
pre-dispense count until the next heartbeat five minutes later.

The machine bumps a counter on every local change to a bay count and
carries it in its state document. m015 stores it per row, and the gate
consults it only when the stamps are equal, where created_at carries no
information at all.

Only on equality, deliberately. A machine whose state.db was replaced
restarts its counter at zero while its wall clock keeps moving forward;
gating on the counter across different stamps would lock that machine out
for good. Equal stamps with no counter on either side stay closed, which
costs one heartbeat and risks nothing.
2026-09-23 12:58:04 +02:00

1037 lines
38 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, root_validator, 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
# Set when the machine reports that a dispense ended without a reliable
# count of what left the bay; cleared by the machine's own report. The
# dashboard turns this into a prompt to open the bay and recount.
counts_uncertain_since: 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.
`relays` is optional: when omitted it defaults to the relay the transport
listens on (derived from the transport config), so the operator needn't
supply one (bitspire#70). `duration_hours` optionally time-bounds the
spire's connect token (None = non-expiring)."""
relays: list[str] | None = None
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
# The machine's own counter, bumped on every local count change. Breaks a
# same-second tie in the ordering gate, where created_at cannot.
state_seq: int | None = None
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 content of the ATM → operator state document
(d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`).
It carried the operator → ATM direction too until v2 moved that to
PublishCassetteOpsPayload. This is now the machine reporting what it
holds, and the machine is the only writer of those counts.
Wire shape: `{"positions": {"<pos_str>": {"denomination", "count"}}}`
plus the optional fields below. JSON object keys are always strings; the
validator coerces back to int on parse.
No denomination-unique constraint: multiple same-denom cassettes are
operationally valid (cash-out throughput on a popular denom).
The optional fields are all absent on older machines, so every one of them
defaults to a value meaning "this machine does not report that yet" rather
than to a value that would be wrong:
- `applied_ops`: operation ids the machine has applied. This is the
acknowledgement, and the only one an addressable event can carry — a
relay returns OK for an event it then discards, so the publisher is
never told anything. An empty list reads as "nothing acknowledged",
which is correct for a machine that has not yet applied any.
- `seq`: the machine's own monotonic counter, bumped on every local count
change. Regression detection independent of created_at, which is only
second-granular and can be forced by a bad clock.
- `counts_uncertain_since`: set when a dispense ended without the
dispenser reporting what it moved, so the counts above are the
machine's best guess rather than a measurement.
"""
positions: dict[int, CassettePayloadRow]
applied_ops: list[str] = []
seq: int | None = None
counts_uncertain_since: int | None = None
@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()
}
}
# =============================================================================
# Cassette operations — operator → ATM v2 (bitspire ADR-004)
# =============================================================================
# The operator no longer publishes counts. It publishes what it DID, and the
# machine — which holds the notes — keeps the running total. A value with one
# writer cannot be clobbered, which is the point: the old absolute-count wire
# let a form loaded before a dispense discard that dispense when published, and
# nothing in an addressable event can tell the loser it lost.
#
# Wire shape (kind-30078 content, NIP-44 v2 encrypted, schema_version 2):
# {
# "schema_version": 2,
# "ops": [
# {"id": "<uuid>", "at": 1790106060, "type": "refill",
# "position": 2, "bills": 100},
# {"id": "<uuid>", "at": 1790106061, "type": "empty", "position": 3},
# {"id": "<uuid>", "at": 1790106062, "type": "recount",
# "position": 1, "count": 37},
# {"id": "<uuid>", "at": 1790106063, "type": "set_denomination",
# "position": 1, "denomination": 50}
# ]
# }
#
# `ops` is a WINDOW of recent operations, not just the newest. An event the
# machine missed is carried again by the next one, so the channel heals itself
# without the operator noticing. `id` is the idempotency key: deltas are not
# idempotent and addressable events are re-delivered on reconnect, so the
# machine records what it applied and ignores repeats.
#
# The vocabulary mirrors lamassu-server's cash_unit_operation_type
# (refill / empty / count-change), which is where the ancestor of this fleet
# landed after the same problem.
CASSETTE_OP_TYPES = ("refill", "empty", "recount", "set_denomination")
class CassetteOp(BaseModel):
"""One operator intent against one bay, as stored and as published.
Exactly one of bills/count/denomination is meaningful, decided by op_type:
- refill → bills, the number of notes ADDED (a delta)
- empty → none; the bay was emptied
- recount → count, an absolute the operator physically counted
- set_denomination → denomination, what is now loaded in that bay
`recount` is the only absolute, and deliberately so: it is what an operator
opening a bay and counting actually does, and it is auditable as a distinct
act rather than being indistinguishable from a stale form.
"""
id: str
machine_id: str
position: int
op_type: str
bills: int | None = None
count: int | None = None
denomination: int | None = None
created_at: datetime
created_by: str | None = None
acked_at: datetime | None = None
@validator("op_type")
def _known_op_type(cls, v):
if v not in CASSETTE_OP_TYPES:
raise ValueError(f"op_type must be one of {CASSETTE_OP_TYPES}, got {v!r}")
return v
@validator("position")
def _position_positive(cls, v):
if v <= 0:
raise ValueError(f"position must be > 0, got {v}")
return v
def to_wire_dict(self) -> dict:
"""The published form. Drops the fields this op_type does not use, so
the machine never has to guess which of three nullable columns applies."""
out: dict = {
"id": self.id,
"at": int(self.created_at.timestamp()),
"type": self.op_type,
"position": self.position,
}
if self.op_type == "refill":
out["bills"] = self.bills
elif self.op_type == "recount":
out["count"] = self.count
elif self.op_type == "set_denomination":
out["denomination"] = self.denomination
return out
class CreateCassetteOpData(BaseModel):
"""Operator submits one operation from the dashboard.
Validated per type here rather than at the endpoint so an instance is
always publishable, matching FeeConfigPayload's contract.
"""
position: int
op_type: str
bills: int | None = None
count: int | None = None
denomination: int | None = None
@validator("op_type")
def _known_op_type(cls, v):
if v not in CASSETTE_OP_TYPES:
raise ValueError(f"op_type must be one of {CASSETTE_OP_TYPES}, got {v!r}")
return v
@validator("position")
def _position_positive(cls, v):
if v <= 0:
raise ValueError(f"position must be > 0, got {v}")
return v
@validator("bills")
def _bills_positive(cls, v):
if v is not None and v <= 0:
raise ValueError("bills must be > 0 (a refill adds notes)")
return v
@validator("count")
def _count_non_negative(cls, v):
if v is not None and v < 0:
raise ValueError("count must be >= 0")
return v
@validator("denomination")
def _denomination_positive(cls, v):
if v is not None and v <= 0:
raise ValueError("denomination must be > 0")
return v
@root_validator(skip_on_failure=True)
def _field_matches_type(cls, values):
required = {
"refill": "bills",
"recount": "count",
"set_denomination": "denomination",
"empty": None,
}[values.get("op_type")]
if required is not None and values.get(required) is None:
raise ValueError(f"{values['op_type']} requires `{required}`")
for field in ("bills", "count", "denomination"):
if field != required and values.get(field) is not None:
raise ValueError(f"{values['op_type']} must not carry `{field}`")
return values
class PublishCassetteOpsPayload(BaseModel):
"""The decrypted content of a v2 operator → ATM cassette event."""
schema_version: int = 2
ops: list[CassetteOp]
def to_wire_dict(self) -> dict:
return {
"schema_version": self.schema_version,
"ops": [op.to_wire_dict() for op in self.ops],
}
# =============================================================================
# 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,
},
}