refactor(v2): canonical sat-amount vocabulary + delete Lamassu-era reverse-derivation
Cross-codebase decision logged at memory `reference_sat_amount_vocabulary.md`
and at `~/dev/coordination/log.md` (2026-05-26). Canonical names with
explicit units across satmachineadmin, lamassu-next, atm-tui:
- `wire_sats` — actual Lightning payment amount (direction-agnostic;
was `gross_sats`, only "gross" for cash-out)
- `principal_sats` — market-rate sats before commission (unchanged)
- `fee_sats` — commission (was `commission_sats` internally;
already the wire format)
- `fee_fraction` — commission rate as unit fraction in [0, 1]
(was `*_pct` / `fee_percent`; eliminates the
latent 100x bug from `feePercent * 100` on the
lamassu-next side)
Invariants enforced in bitspire._assert_sat_invariants on every
parsed settlement — range (all sats >= 0, 0 <= fee_fraction <= 1) +
direction-specific sum:
- cash-out: wire_sats == principal_sats + fee_sats
- cash-in: wire_sats == principal_sats - fee_sats
AND fee_sats <= principal_sats
Breaches raise SettlementInvariantError; tasks._handle_payment
records the row as `status='rejected'` with the exception message
and skips distribution. Attribution failure path symmetric.
Schema changes (m001 + m006):
- dca_settlements.gross_sats -> wire_sats
- dca_settlements.commission_sats -> fee_sats
- super_config.super_fee_pct -> super_fee_fraction
- dca_commission_splits.pct -> fraction
- dca_machines.fallback_commission_pct DROPPED (obsolete)
- dca_settlements.used_fallback_split DROPPED (obsolete)
m006 idempotently renames + drops columns on existing installs;
m001 lays down the canonical schema for fresh installs.
Obsolete code removed (Lamassu-era reverse-derivation):
- calculations.calculate_commission — back-derived principal+fee
from gross-with-commission-baked-in. v2 stamps both directly.
- calculations.calculate_exchange_rate — bitSpire stamps directly.
- bitspire._parse_fallback — sole caller of calculate_commission.
- Machine.fallback_commission_fraction — only read by _parse_fallback.
- DcaSettlement.used_fallback_split — only written by _parse_fallback.
parse_settlement now raises SettlementMetadataError if Payment.extra
lacks the bitSpire stamp or required absolute sat fields. No silent
back-derivation; upstream-bug surfacing via dashboard rejection.
Frontend (JS + Quasar templates) updated for the column renames and
the removed fallback fields. Settlements table renders "Wire" + "Fee"
columns; the "(fallback split)" warning badge is gone.
Tests:
- test_calculations.py: kept distribution tests; deleted
calculate_commission + calculate_exchange_rate tests.
- test_two_stage_split.py: renamed variables; rewrote docstring
value literals (e.g. `super_fee_fraction=0.30` not `=30%`).
- test_nostr_attribution.py: dropped fallback_commission_fraction
from machine fixture.
- 72/72 pass on regtest container.
Cross-codebase follow-ups tracked in coordination log:
- lamassu-next: rename `fee_percent` -> `fee_fraction` on
Payment.extra + state.db; drop the `* 100` at lightning.ts:780.
- atm-tui: read `fee_fraction` column in db.zig.
Memory artefacts:
- reference_sat_amount_vocabulary.md (canonical + invariants)
- feedback_pct_to_fraction_renames_need_value_sweep.md (gotcha)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6348c55e37
commit
d717a6e214
12 changed files with 530 additions and 681 deletions
256
bitspire.py
256
bitspire.py
|
|
@ -1,24 +1,22 @@
|
|||
# Satoshi Machine v2 — bitSpire payment parser.
|
||||
#
|
||||
# Translates an inbound LNbits Payment (cash-out customer paid the ATM's
|
||||
# invoice) into the principal/commission split needed by satmachineadmin.
|
||||
# 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.).
|
||||
#
|
||||
# Happy path: bitSpire populates Payment.extra with the canonical split
|
||||
# fields per aiolabs/lamassu-next#44 — we read them directly.
|
||||
#
|
||||
# Fallback path: extra is missing (older bitSpire, edge case). We back-derive
|
||||
# the split from the machine's fallback_commission_pct using the Lamassu-era
|
||||
# formula (base = total / (1 + commission)) and mark used_fallback_split=true
|
||||
# so the audit trail shows we estimated.
|
||||
# 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, Tuple
|
||||
from typing import Any, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .calculations import calculate_commission
|
||||
from .models import CreateDcaSettlementData, Machine
|
||||
|
||||
# Sentinel value bitSpire sets in Payment.extra.source so we know an inbound
|
||||
|
|
@ -74,6 +72,34 @@ class SettlementAttributionError(ValueError):
|
|||
"""
|
||||
|
||||
|
||||
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 satmachineadmin-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.
|
||||
|
||||
|
|
@ -111,49 +137,124 @@ def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
|
|||
)
|
||||
|
||||
|
||||
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,
|
||||
gross_sats: int,
|
||||
wire_sats: int,
|
||||
extra: dict,
|
||||
super_fee_pct: float,
|
||||
) -> Tuple[CreateDcaSettlementData, bool]:
|
||||
super_fee_fraction: float,
|
||||
) -> CreateDcaSettlementData:
|
||||
"""Build a CreateDcaSettlementData for an inbound payment landing on
|
||||
`machine`'s wallet.
|
||||
|
||||
Returns (data, used_fallback): when `used_fallback` is True, bitSpire
|
||||
didn't populate Payment.extra so we back-derived the split. Caller
|
||||
should log this for visibility — once aiolabs/lamassu-next#44 ships,
|
||||
fallback usage should drop to zero.
|
||||
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`).
|
||||
"""
|
||||
if is_bitspire_payment(extra):
|
||||
data = _parse_extra(machine, payment_hash, gross_sats, extra, super_fee_pct)
|
||||
return data, False
|
||||
logger.warning(
|
||||
f"satmachineadmin: settlement on machine {machine.machine_npub[:12]}... "
|
||||
f"missing bitSpire extra metadata; back-deriving via "
|
||||
f"fallback_commission_pct={machine.fallback_commission_pct}. "
|
||||
f"See aiolabs/lamassu-next#44."
|
||||
)
|
||||
return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct), True
|
||||
|
||||
|
||||
def _parse_extra(
|
||||
machine: Machine,
|
||||
payment_hash: str,
|
||||
gross_sats: int,
|
||||
extra: dict,
|
||||
super_fee_pct: float,
|
||||
) -> CreateDcaSettlementData:
|
||||
"""Happy path: bitSpire populated Payment.extra per lamassu-next#44."""
|
||||
if not (0.0 <= super_fee_fraction <= 1.0):
|
||||
raise SettlementInvariantError(
|
||||
f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}"
|
||||
)
|
||||
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:
|
||||
# Missing key fields — shouldn't happen post-#44 but defensive.
|
||||
return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct)
|
||||
commission_sats = fee_sats
|
||||
platform_fee_sats = round(commission_sats * super_fee_pct)
|
||||
operator_fee_sats = commission_sats - platform_fee_sats
|
||||
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]}..."
|
||||
)
|
||||
platform_fee_sats = round(fee_sats * super_fee_fraction)
|
||||
operator_fee_sats = fee_sats - platform_fee_sats
|
||||
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
|
||||
|
|
@ -163,66 +264,39 @@ def _parse_extra(
|
|||
# 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 is't load-bearing (commission lives
|
||||
# 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
|
||||
return CreateDcaSettlementData(
|
||||
tx_type = _coerce_str(extra.get("type")) or "cash_out"
|
||||
data = CreateDcaSettlementData(
|
||||
machine_id=machine.id,
|
||||
payment_hash=payment_hash,
|
||||
bitspire_event_id=None,
|
||||
bitspire_txid=_coerce_str(extra.get("txid")),
|
||||
gross_sats=gross_sats,
|
||||
wire_sats=wire_sats,
|
||||
fiat_amount=fiat_amount,
|
||||
fiat_code=fiat_code,
|
||||
exchange_rate=exchange_rate,
|
||||
principal_sats=principal_sats,
|
||||
commission_sats=commission_sats,
|
||||
fee_sats=fee_sats,
|
||||
platform_fee_sats=platform_fee_sats,
|
||||
operator_fee_sats=operator_fee_sats,
|
||||
used_fallback_split=False,
|
||||
tx_type=_coerce_str(extra.get("type")) or "cash_out",
|
||||
tx_type=tx_type,
|
||||
bills_json=_json_dumps(extra.get("bills")),
|
||||
cassettes_json=_json_dumps(extra.get("cassettes")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_fallback(
|
||||
machine: Machine,
|
||||
payment_hash: str,
|
||||
gross_sats: int,
|
||||
super_fee_pct: float,
|
||||
) -> CreateDcaSettlementData:
|
||||
"""Back-derive the split using the machine's fallback_commission_pct.
|
||||
|
||||
Same formula as the Lamassu integration used:
|
||||
base_amount = round(gross / (1 + commission_pct))
|
||||
commission = gross - base_amount
|
||||
"""
|
||||
principal_sats, commission_sats, _effective = calculate_commission(
|
||||
crypto_atoms=gross_sats,
|
||||
commission_percentage=machine.fallback_commission_pct,
|
||||
discount=0.0,
|
||||
)
|
||||
platform_fee_sats = round(commission_sats * super_fee_pct)
|
||||
operator_fee_sats = commission_sats - platform_fee_sats
|
||||
# No exchange rate from the wire; leave fiat_amount=0 so it's visibly
|
||||
# incomplete on the operator's reconciliation screen.
|
||||
return CreateDcaSettlementData(
|
||||
machine_id=machine.id,
|
||||
payment_hash=payment_hash,
|
||||
bitspire_event_id=None,
|
||||
bitspire_txid=None,
|
||||
gross_sats=gross_sats,
|
||||
fiat_amount=0.0,
|
||||
fiat_code=machine.fiat_code,
|
||||
exchange_rate=0.0,
|
||||
principal_sats=principal_sats,
|
||||
commission_sats=commission_sats,
|
||||
platform_fee_sats=platform_fee_sats,
|
||||
operator_fee_sats=operator_fee_sats,
|
||||
used_fallback_split=True,
|
||||
tx_type="cash_out",
|
||||
bills_json=None,
|
||||
cassettes_json=None,
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue