spirekeeper/models.py
Padreug b8a5e6352a feat(schema): dispense outcome on settlements, dispense_reports, cash-out hold mirror (ADR-005)
m016: an append-only `dispense_reports` table (lamassu-server's
cash_out_actions shape — one row per report the machine sent, so a
retry, a late report and a remediation report stay distinct);
dispense_confirmed / dispense_error / dispense_error_code /
dispense_raw_code / dispense_error_class / dispense_reported_at /
dispensed_fiat_cents on dca_settlements; cash_out_held_since / _reason /
_code on dca_machines beside counts_uncertain_since.

Settlement lifecycle gains awaiting_dispense (cash_out at insert — paid,
waiting for the machine's report), partial_pending (some notes out,
value short; held whole until the operator records the resolution) and
cash_owed (nothing out; legs never run). dispense_unreported is derived
by the worklist, not stored.

resume_cash_out joins CASSETTE_OP_TYPES as a machine-wide op: position 0,
no position on the wire, no bay fields. It rides the operator channel the
machine already consumes; the machine honours it only if stamped after
the hold began. A recount releases the hold too.

crud: get_settlement_by_txid (the join the machine's extra.txid already
provides), apply_dispense_outcome (copies the report onto the settlement
and finally writes bills_json / cassettes_json with what actually came
out), the dispense_reports accessors incl. adopting a report that
arrived before its payment, set_machine_cash_out_hold, and the three
new worklist buckets.
2026-10-10 21:51:51 +02:00

1209 lines
45 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
# ADR-005 §5: the machine has latched cash-out off after a terminal
# dispenser fault. Mirrored from its state document. Cleared when the
# machine reports the hold released (an operator recount or a
# resume_cash_out op). The dashboard shows it and offers the button.
cash_out_held_since: datetime | None = None
cash_out_held_reason: str | None = None
cash_out_held_code: str | 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
# Lifecycle (bitspire ADR-005 §1 — payment is authorization, the
# machine's dispense report is capture; distribution waits for capture):
# 'awaiting_dispense' (cash_out at insert: paid, waiting for the report)
# 'pending' (cash_in at insert; cash_out once dispense_confirmed)
# 'processing' (claim taken by distribution processor)
# 'processed' (all legs paid)
# 'partial_pending' (report says some notes out, value short — holds
# EVERYTHING until the operator records how the shortfall
# was resolved; then one distribution at the final amount)
# 'cash_owed' (report says nothing out — legs never run, funds stay in
# the machine wallet, the customer is owed; worklist first)
# 'partial' (operator confirmed a partial amount; distributed scaled)
# '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.)
# 'dispense_unreported' is NOT stored: the worklist derives it from
# awaiting_dispense rows older than its threshold.
status: str
error_message: str | None
processed_at: datetime | None
created_at: datetime
# ADR-005 §2 — copied from the machine's report. The three lamassu
# fields plus raw_code / error_class; dispensed_fiat_cents is what the
# hardware says physically left, which pre-fills partial-dispense.
dispense_confirmed: bool | None = None
dispense_error: str | None = None
dispense_error_code: str | None = None
dispense_raw_code: str | None = None
dispense_error_class: str | None = None
dispense_reported_at: datetime | None = None
dispensed_fiat_cents: int | None = None
# 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
# =============================================================================
# Dispense outcome (bitspire ADR-005 §2) — machine → spirekeeper `report_dispense`
# =============================================================================
class DispenseReportBill(BaseModel):
denomination: int
requested: int
dispensed: int
rejected: int
class DispenseReportCassette(BaseModel):
position: int
denomination: int
provisioned: int
dispensed: int
rejected: int
class DispenseReportIn(BaseModel):
"""The RPC body as the machine sends it. Mirrors @bitSpire/lnbits
DispenseReportBody. Field names follow lamassu-server's cash_out_txs /
cash_out_actions (dispense_confirmed, error, error_code). Idempotent on
(txid, at): the machine resends until acked; a byte-identical resend is
acknowledged without a new row."""
txid: str
payment_hash: str | None = None
tx_type: str = "cash_out"
dispense_confirmed: bool
error: str | None = None
error_code: str | None = None
raw_code: str | None = None
error_class: str | None = None # 'terminal' | 'recoverable' | 'inventory'
fiat_cents: int
currency: str
bills: list[DispenseReportBill] = []
cassettes: list[DispenseReportCassette] = []
counts_uncertain: bool = False
remediates_txid: str | None = None
at: int
@validator("txid")
def _txid_present(cls, v):
if not v or not v.strip():
raise ValueError("txid is required")
return v.strip()
@validator("tx_type")
def _cash_out_only(cls, v):
if v != "cash_out":
raise ValueError("report_dispense is for cash_out transactions only")
return v
@validator("error_class")
def _known_class(cls, v):
if v is not None and v not in ("terminal", "recoverable", "inventory"):
raise ValueError(
f"error_class must be terminal|recoverable|inventory, got {v!r}"
)
return v
@validator("fiat_cents")
def _fiat_non_negative(cls, v):
if v < 0:
raise ValueError("fiat_cents must be >= 0")
return v
@property
def dispensed_fiat_cents(self) -> int:
"""What the hardware says physically left, in cents."""
return sum(b.denomination * b.dispensed for b in self.bills) * 100
@property
def total_dispensed_notes(self) -> int:
return sum(b.dispensed for b in self.bills)
class DispenseReport(BaseModel):
"""One stored report (append-only — a retry, a late report and a
remediation report are distinct rows). `settlement_id` is NULL when the
payment this report refers to was never seen by this server."""
id: str
machine_id: str
settlement_id: str | None
txid: str
payment_hash: str | None
dispense_confirmed: bool
error: str | None
error_code: str | None
raw_code: str | None
error_class: str | None
fiat_cents: int
currency: str
bills_json: str
cassettes_json: str
counts_uncertain: bool = False
remediates_txid: str | None = None
reported_at: datetime
received_at: datetime
# =============================================================================
# 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
# ADR-005 §6 — the only buckets whose meaning is "a customer is owed
# money"; rendered first.
cash_owed: list = [] # list[DcaSettlement]
partial_pending: list = []
# awaiting_dispense older than the threshold: the machine never reported.
# A machine on an old build lands here too — that is the upgrade path.
dispense_unreported: list = []
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
# ADR-005 §5 — additive like counts_uncertain_since. Present while the
# machine refuses cash-out after a terminal dispenser fault.
cash_out_held_since: int | None = None
cash_out_held_reason: str | None = None
cash_out_held_code: str | 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",
"resume_cash_out",
)
# ADR-005 §5: `resume_cash_out` is not a cassette operation — it releases the
# machine's cash-out hold without touching a bay — but it rides the same
# operator event, with the same id/at dedup shape, because that channel is the
# one the machine already consumes. It is machine-wide, so its position is 0
# and its wire form carries no position at all. A recount also releases the
# hold (same "operator at the open machine" gesture).
MACHINE_WIDE_OP_TYPES = ("resume_cash_out",)
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
@root_validator(skip_on_failure=True)
def _position_matches_scope(cls, values):
pos, typ = values.get("position"), values.get("op_type")
if typ in MACHINE_WIDE_OP_TYPES:
if pos != 0:
raise ValueError(
f"{typ} is machine-wide; position must be 0, got {pos}"
)
elif pos is None or pos <= 0:
raise ValueError(f"position must be > 0, got {pos}")
return values
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,
}
if self.op_type in MACHINE_WIDE_OP_TYPES:
return out
out["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
@root_validator(skip_on_failure=True)
def _position_matches_scope(cls, values):
pos, typ = values.get("position"), values.get("op_type")
if typ in MACHINE_WIDE_OP_TYPES:
if pos != 0:
raise ValueError(
f"{typ} is machine-wide; position must be 0, got {pos}"
)
elif pos is None or pos <= 0:
raise ValueError(f"position must be > 0, got {pos}")
return values
@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,
"resume_cash_out": 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,
},
}