Some checks failed
ci.yml / fix(cassettes): break the same-second tie with the machine's counter (pull_request) Failing after 0s
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.
1037 lines
38 KiB
Python
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,
|
|
},
|
|
}
|