spirekeeper/bitspire.py
Padreug a059e3f596 refactor: rename extension identity to spirekeeper
Fork of satmachineadmin's v2-bitspire line into its own repo. Renames
both identifiers so this extension is fully independent of the original
satmachineadmin install (which remains in service):

  - extension id   satmachineadmin -> spirekeeper
    (router prefix, static path/static_url_for, module symbols, task
     names, templates dir, config/manifest paths)
  - database name  satoshimachine  -> spirekeeper
    (Database(ext_spirekeeper), all schema-qualified table refs)

Also resets versioning to 0.1.0, sets the display name + manifest to
spirekeeper/aiolabs, and fixes the placeholder pyproject description.
Historical aiolabs/satmachineadmin#N issue references in comments are
left pointing at the original repo where those issues live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:30:05 +02:00

343 lines
14 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"
)
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