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.
1209 lines
45 KiB
Python
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,
|
|
},
|
|
}
|