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