spirekeeper/bitspire.py
Padreug d52a3bfafe
Some checks failed
ci.yml / fix: guard every machine_npub deref against unpaired machines (None) (pull_request) Failing after 0s
fix: guard every machine_npub deref against unpaired machines (None)
machine_npub became nullable in #29/m011 (register-unpaired flow), but
several consumers still assumed it's non-None and crashed
`normalize_public_key(None)` with `AttributeError: 'NoneType' object has no
attribute 'startswith'`. On the demo (which had an unpaired machine) this
broke the platform-fee update (500) and spammed the cassette consumer with
errors every 2s. The #29 create/pair paths were guarded; these were missed:

- views_api `api_update_super_config`: the "republish fee to every active
  machine" loop → skip unpaired (they get their config at pairing).
- cassette_transport `build_state_d_tags_for_machines`: skip unpaired (no
  state-beacon d-tag yet) — the cassette-consumer loop crash.
- crud `get_machine_by_atm_pubkey_hex`: its `except (ValueError,
  AssertionError)` didn't catch the AttributeError; skip unpaired before
  normalize — the cassette event-handler crash.
- bitspire `assert_nostr_attribution`: reject (SettlementAttributionError) an
  unpaired machine instead of crashing the payment listener.
- views_api cassettes/publish endpoint: 400 (not paired) instead of crashing
  publish_to_atm.

Verified on the dev stack: with an unpaired active machine present, the
cassette consumer registers (skipping it) and runs clean — no AttributeError.
2026-06-22 16:45:29 +02:00

351 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Satoshi Machine v2 — bitSpire payment parser.
#
# Translates an inbound LNbits Payment into a CreateDcaSettlementData by
# reading the canonical split fields bitSpire stamps on Payment.extra per
# aiolabs/lamassu-next#44 (`source: "bitspire"`, `principal_sats`,
# `fee_sats`, `exchange_rate`, etc.).
#
# No back-derivation. If Payment.extra is missing the bitSpire stamp or
# any required field, we raise SettlementMetadataError and the caller
# records the settlement as 'rejected' for upstream investigation — the
# Lamassu-era reverse-derivation from gross-with-commission-baked-in is
# obsolete now that the wire carries principal_sats and fee_sats
# directly.
from __future__ import annotations
import json
from typing import Any, Optional
from loguru import logger
from .calculations import split_principal_based
from .models import CreateDcaSettlementData, Machine, SuperConfig
# Sentinel value bitSpire sets in Payment.extra.source so we know an inbound
# payment originated from an ATM cash-out and not some other extension or
# customer-initiated transfer.
BITSPIRE_SOURCE = "bitspire"
def _coerce_int(v: Any) -> Optional[int]:
if v is None:
return None
try:
return int(v)
except (TypeError, ValueError):
return None
def _coerce_float(v: Any) -> Optional[float]:
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _coerce_str(v: Any) -> Optional[str]:
if v is None:
return None
return str(v) if not isinstance(v, str) else v
def _json_dumps(v: Any) -> Optional[str]:
if v is None:
return None
try:
return json.dumps(v)
except (TypeError, ValueError):
return None
def is_bitspire_payment(extra: dict) -> bool:
"""True if Payment.extra carries the bitSpire source marker (post-#44)."""
return isinstance(extra, dict) and extra.get("source") == BITSPIRE_SOURCE
class SettlementAttributionError(ValueError):
"""The signer of the kind-21000 invoice doesn't match the machine identity.
Raised by `assert_nostr_attribution`. The caller records the
settlement with `status='rejected'` and the exception message in
`error_message`, then skips distribution.
"""
class SettlementInvariantError(ValueError):
"""A sat-amount or fee-fraction value violates the cross-codebase
canonical invariants (see
`~/.claude/projects/.../memory/reference_sat_amount_vocabulary.md`).
Raised by `_assert_sat_invariants`. Caller treats it like
SettlementAttributionError — record as rejected, don't distribute.
A breach means something upstream (bitSpire, the relay, a buggy
consumer) is stamping garbage on Payment.extra; we don't want to
quietly silently distribute against corrupt numbers.
"""
class SettlementMetadataError(ValueError):
"""Payment.extra is missing the bitSpire stamp or required fields.
Raised by `parse_settlement`. Caller records the settlement as
'rejected' with the exception message in `error_message`. Operator
investigates the ATM that issued the invoice — a bitSpire ATM that
landed on a spirekeeper-managed wallet without stamping the
canonical fields is a real upstream bug (lamassu-next side), not a
graceful-degradation case. Pre-v2 reverse-derivation from the
wire amount + a machine-level fallback rate is no longer supported:
the wire-format contract (lamassu-next#44) is that the ATM always
stamps `principal_sats` and `fee_sats` explicitly.
"""
def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
"""Assert that the originating Nostr signer pubkey matches the machine.
Reads `extra["nostr_sender_pubkey"]` — populated by LNbits'
nostr-transport dispatcher from the signature-verified kind-21000
event that triggered invoice creation (aiolabs/lnbits PR #4, S5/G5).
Normalises both sides to lowercase hex via
`lnbits.utils.nostr.normalize_public_key` (the UI lets operators
enter either hex or `npub1...` bech32 for `machine.machine_npub`).
Raises `SettlementAttributionError` if the stamp is missing,
unparseable, or doesn't match. In v2 every bitSpire ATM creates
invoices via nostr-transport, so a settlement landing on a machine
wallet without the stamp means the invoice was issued by some other
path (HTTP API, manual UI, a different extension) — always wrong
for a `dca_machines` wallet.
"""
sender_pubkey = _coerce_str(extra.get("nostr_sender_pubkey"))
if not sender_pubkey:
raise SettlementAttributionError(
"missing nostr_sender_pubkey on Payment.extra — invoice was not "
"issued through the nostr-transport path"
)
if not machine.machine_npub:
# Unpaired machine (machine_npub None — nullable since #29/m011). It has
# no identity to attribute a settlement to; reject cleanly rather than
# let normalize_public_key(None) raise an uncaught AttributeError.
raise SettlementAttributionError(
f"machine {machine.id} is unpaired (no machine_npub); "
"a settlement cannot be attributed to it"
)
from lnbits.utils.nostr import normalize_public_key
try:
expected = normalize_public_key(machine.machine_npub).lower()
actual = normalize_public_key(sender_pubkey).lower()
except (ValueError, AssertionError) as exc:
raise SettlementAttributionError(f"unparseable pubkey: {exc}") from exc
if expected != actual:
raise SettlementAttributionError(
f"signer {actual[:12]}... does not match "
f"machine identity {expected[:12]}..."
)
def _assert_sat_invariants(
*,
tx_type: str,
wire_sats: int,
principal_sats: int,
fee_sats: int,
fee_fraction: Optional[float] = None,
) -> None:
"""Enforce the cross-codebase canonical sat-amount invariants on the
parsed settlement values BEFORE building the `CreateDcaSettlementData`.
Range invariants (all cases):
- wire_sats, principal_sats, fee_sats are all non-negative integers.
- fee_fraction (if provided) is in [0, 1].
Sum invariants (direction-specific):
- cash_out: wire_sats == principal_sats + fee_sats
- cash_in: wire_sats == principal_sats - fee_sats
AND fee_sats <= principal_sats
(commission cannot exceed the principal in a cash-in;
a customer can't owe negative sats)
The fee_fraction × principal_sats sanity check (≈ fee_sats ±1) is
intentionally NOT enforced here — fee_fraction is informational on
Payment.extra; the absolute fee_sats stamp is the audit anchor and
the source of truth. The two can drift by a few sats due to upstream
rounding without indicating corruption. If we ever observe drift
>1% of fee_sats we'll add the check.
Raises SettlementInvariantError with a precise message on any breach.
Reference: `reference_sat_amount_vocabulary.md`.
"""
# Range checks
if wire_sats < 0:
raise SettlementInvariantError(f"wire_sats must be >= 0, got {wire_sats}")
if principal_sats < 0:
raise SettlementInvariantError(
f"principal_sats must be >= 0, got {principal_sats}"
)
if fee_sats < 0:
raise SettlementInvariantError(f"fee_sats must be >= 0, got {fee_sats}")
if fee_fraction is not None and not (0.0 <= fee_fraction <= 1.0):
raise SettlementInvariantError(
f"fee_fraction must be in [0, 1], got {fee_fraction} "
f"(if you see a value >1 the upstream may be stamping percentage "
f"instead of fraction — check lamassu-next#? rename status)"
)
# Sum invariants per direction
if tx_type == "cash_out":
expected_wire = principal_sats + fee_sats
if wire_sats != expected_wire:
raise SettlementInvariantError(
f"cash-out wire_sats invariant violated: "
f"wire_sats={wire_sats} != principal_sats({principal_sats}) "
f"+ fee_sats({fee_sats}) = {expected_wire}"
)
elif tx_type == "cash_in":
if fee_sats > principal_sats:
raise SettlementInvariantError(
f"cash-in fee_sats({fee_sats}) cannot exceed "
f"principal_sats({principal_sats}) — commission > principal "
f"would mean a customer owes negative sats"
)
expected_wire = principal_sats - fee_sats
if wire_sats != expected_wire:
raise SettlementInvariantError(
f"cash-in wire_sats invariant violated: "
f"wire_sats={wire_sats} != principal_sats({principal_sats}) "
f"- fee_sats({fee_sats}) = {expected_wire}"
)
else:
raise SettlementInvariantError(
f"unknown tx_type={tx_type!r}; expected 'cash_out' or 'cash_in'"
)
def parse_settlement(
machine: Machine,
payment_hash: str,
wire_sats: int,
extra: dict,
super_config: SuperConfig,
) -> CreateDcaSettlementData:
"""Build a CreateDcaSettlementData for an inbound payment landing on
`machine`'s wallet.
Splits the fee on a principal-based, direction-aware model
(aiolabs/satmachineadmin#37,#38):
platform_fee_sats = round(principal_sats * super_cash_{type}_fee_fraction)
operator_fee_sats = round(principal_sats * operator_cash_{type}_fee_fraction)
where the directional super fraction comes from `super_config` and
the operator fraction comes from `machine`. The bitspire-reported
`fee_sats` field is preserved on the settlement as the customer's
actual paid total, but is NOT used as input to the split.
Requires bitSpire's canonical Payment.extra stamp (source="bitspire"
plus the absolute sat amounts) per aiolabs/lamassu-next#44. Raises
`SettlementMetadataError` on missing/partial stamp — caller records
the settlement as 'rejected' for upstream investigation. Raises
`SettlementInvariantError` if the stamped values violate the
canonical sat-amount invariants (range + sum, see
`_assert_sat_invariants`) or `tx_type` is unknown.
"""
if not is_bitspire_payment(extra):
raise SettlementMetadataError(
f"Payment.extra missing `source: \"bitspire\"` marker on machine "
f"{machine.machine_npub[:12]}... — invoice did not come through "
f"a bitSpire ATM, or the ATM firmware is older than "
f"aiolabs/lamassu-next#44 and didn't stamp the canonical fields"
)
principal_sats = _coerce_int(extra.get("principal_sats"))
fee_sats = _coerce_int(extra.get("fee_sats"))
if principal_sats is None or fee_sats is None:
raise SettlementMetadataError(
f"Payment.extra has source=bitspire but is missing required "
f"fields principal_sats={extra.get('principal_sats')!r} or "
f"fee_sats={extra.get('fee_sats')!r}; the wire-format contract "
f"(lamassu-next#44) requires both. Investigate the ATM "
f"firmware on machine {machine.machine_npub[:12]}..."
)
tx_type = _coerce_str(extra.get("type")) or "cash_out"
if tx_type == "cash_in":
super_frac = float(super_config.super_cash_in_fee_fraction)
operator_frac = float(machine.operator_cash_in_fee_fraction)
elif tx_type == "cash_out":
super_frac = float(super_config.super_cash_out_fee_fraction)
operator_frac = float(machine.operator_cash_out_fee_fraction)
else:
raise SettlementInvariantError(
f"unknown tx_type={tx_type!r}; expected 'cash_in' or 'cash_out'"
)
platform_fee_sats, operator_fee_sats = split_principal_based(
principal_sats, super_frac, operator_frac
)
# Phase-1 observability per aiolabs/satmachineadmin#38 + coord-log
# §2026-06-01T07:00Z (option A locked): compare bitspire's reported
# fee_sats against spirekeeper's recompute, log on out-of-
# tolerance drift, record the delta unconditionally for triage.
# Phase 2 (settlement-reject) lands after observability data.
fee_mismatch_sats = fee_sats - (platform_fee_sats + operator_fee_sats)
tolerance = max(1, int(principal_sats * 0.001))
if abs(fee_mismatch_sats) > tolerance:
logger.warning(
f"bitspire fee mismatch on payment {payment_hash[:12]}...: "
f"bitspire_fee_sats={fee_sats} expected={platform_fee_sats + operator_fee_sats} "
f"delta={fee_mismatch_sats} tolerance={tolerance} "
f"principal={principal_sats} super_frac={super_frac:.4f} "
f"operator_frac={operator_frac:.4f} tx_type={tx_type} "
f"machine={machine.machine_npub[:12]}... — "
"Phase 1 observability only, no behavior change. Pre-Layer-3 "
"(lamassu-next#57) the ATM still hardcodes fee fractions, so "
"large deltas here are expected until that ships."
)
exchange_rate = _coerce_float(extra.get("exchange_rate"))
if exchange_rate is None or exchange_rate <= 0:
# Without exchange rate we can't compute fiat. Use 1.0 as a stand-in
# and let the operator correct via manual reconciliation.
exchange_rate = 1.0
# `fiat_amount` is sourced directly from bitSpire's bill validator /
# dispenser ledger (lamassu-next@8318489). It's the cash that
# physically entered (cash-in) or exited (cash-out) the machine —
# canonical, not derived. We never recompute it from sats × rate
# downstream: the relationship isn't load-bearing (commission lives
# in BTC today, but the cash side has its own ground truth).
fiat_amount = _coerce_float(extra.get("fiat_amount")) or 0.0
fiat_code = _coerce_str(extra.get("currency")) or machine.fiat_code
data = CreateDcaSettlementData(
machine_id=machine.id,
payment_hash=payment_hash,
bitspire_event_id=None,
bitspire_txid=_coerce_str(extra.get("txid")),
wire_sats=wire_sats,
fiat_amount=fiat_amount,
fiat_code=fiat_code,
exchange_rate=exchange_rate,
principal_sats=principal_sats,
fee_sats=fee_sats,
platform_fee_sats=platform_fee_sats,
operator_fee_sats=operator_fee_sats,
fee_mismatch_sats=fee_mismatch_sats,
tx_type=tx_type,
bills_json=_json_dumps(extra.get("bills")),
cassettes_json=_json_dumps(extra.get("cassettes")),
)
# Enforce the cross-codebase canonical sat-amount invariants on the
# values bitSpire stamped (post-rename: `fee_fraction` is preferred;
# the old `fee_percent` field is deliberately NOT read here because
# of the 100× misinterpretation risk during the rename window — the
# absolute `fee_sats` stamp is the audit anchor and the sum
# invariants below catch any garbage at the wire).
_assert_sat_invariants(
tx_type=data.tx_type,
wire_sats=data.wire_sats,
principal_sats=data.principal_sats,
fee_sats=data.fee_sats,
fee_fraction=_coerce_float(extra.get("fee_fraction")),
)
return data