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>
343 lines
14 KiB
Python
343 lines
14 KiB
Python
# 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
|