Compare commits

..

No commits in common. "v2-bitspire" and "main" have entirely different histories.

39 changed files with 4152 additions and 13252 deletions

10
.gitignore vendored
View file

@ -2,13 +2,3 @@ __pycache__
node_modules
.mypy_cache
.venv
# LNbits runtime data — auth keys, dev DB files, etc.
data/
*.sqlite3
*.sqlite3-journal
# uv lockfile — pyproject.toml still uses [tool.poetry] syntax, so uv lock
# produces a header-only file that pins nothing. Ignore until the
# PEP 621 migration lands (aiolabs/satmachineadmin#28).
uv.lock

View file

@ -219,38 +219,6 @@ commission_amount = 266800 - 258835 = 7,965 sats (to commission wallet)
- Input sanitization and type validation
- Audit logging for all administrative actions
### No-collision invariant — operator account pubkey ≠ ATM npub
`dca_machines.machine_npub` and `accounts.pubkey` MUST NEVER hold the
same value across the LNbits instance. Enforced by
`views_api._assert_no_pubkey_collision` at machine-creation time
(rejects with HTTP 400) and by the matching SQL check operators can run
on existing installs:
```sql
SELECT a.id, a.username, a.pubkey, m.id, m.machine_npub
FROM accounts a
JOIN ext_satoshimachine.dca_machines m
ON LOWER(a.pubkey) = LOWER(m.machine_npub);
```
**Why this matters**: when the two values match, lnbits' nostr-transport
`auth.py:resolve_nostr_auth` routes inbound kind-21000 RPCs from the
ATM directly to that operator's wallet *by collision* — it works by
coincidence, breaks silently the moment the operator's pubkey rotates
(then `auto-account-from-npub` fires for the orphaned ATM npub, and the
invoice lands on a fresh auto-account wallet instead). Reproduced on
2026-05-30 against Greg's Sintra (silent cash-out drop). The proper
architectural routing fix is `aiolabs/satmachineadmin#20` (path B /
S6); the collision guard prevents the broken state from being entered
in the first place.
When provisioning a new ATM via `lamassu-next deploy/nixos/provision-atm.sh`,
**leave `ATM_PRIVATE_KEY` unset** so the script generates a fresh ATM
keypair (distinct from any operator's nsec). See
`aiolabs/satmachineadmin#32` for design rationale + the (eventual)
reverse-direction guard on account creation in lnbits proper.
## Development Workflow
### Adding New Features

View file

@ -5,17 +5,17 @@ from lnbits.tasks import create_permanent_unique_task
from loguru import logger
from .crud import db
from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits
from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices
from .tasks import wait_for_paid_invoices, hourly_transaction_polling
from .views import satmachineadmin_generic_router
from .views_api import satmachineadmin_api_router
logger.info("satmachineadmin v2 loaded")
satmachineadmin_ext: APIRouter = APIRouter(
prefix="/satmachineadmin", tags=["DCA Admin"]
logger.debug(
"This logged message is from satmachineadmin/__init__.py, you can debug in your "
"extension using 'import logger from loguru' and 'logger.debug(<thing-to-log>)'."
)
satmachineadmin_ext: APIRouter = APIRouter(prefix="/satmachineadmin", tags=["DCA Admin"])
satmachineadmin_ext.include_router(satmachineadmin_generic_router)
satmachineadmin_ext.include_router(satmachineadmin_api_router)
@ -38,31 +38,19 @@ def satmachineadmin_stop():
def satmachineadmin_start():
# bitSpire invoice listener — replaces the v1 SSH/PostgreSQL poller.
invoice_task = create_permanent_unique_task(
"ext_satmachineadmin", wait_for_paid_invoices
)
# Start invoice listener task
invoice_task = create_permanent_unique_task("ext_satmachineadmin", wait_for_paid_invoices)
scheduled_tasks.append(invoice_task)
# Cassette bootstrap consumer (#29 v1) — subscribes to
# bitspire-cassettes-state events from each active ATM and upserts
# cassette_configs on receipt. Soft-fails if nostrclient isn't
# installed (logs + backs off, never crashes).
cassette_task = create_permanent_unique_task(
"ext_satmachineadmin_cassette_bootstrap", wait_for_cassette_state_events
)
scheduled_tasks.append(cassette_task)
# Path-B wallet-routing hook (#20 / coord-log 2026-05-31T15:25Z):
# register our ATM-roster resolver with lnbits' nostr-transport so
# inbound kind-21000 from a known ATM npub routes to the operator's
# wallet, not an auto-created machine wallet. Soft-fails on lnbits
# versions that don't yet expose `register_roster_resolver`.
register_roster_with_lnbits()
# Start hourly transaction polling task
polling_task = create_permanent_unique_task("ext_satmachineadmin_polling", hourly_transaction_polling)
scheduled_tasks.append(polling_task)
__all__ = [
"db",
"satmachineadmin_ext",
"satmachineadmin_start",
"satmachineadmin_static_files",
"satmachineadmin_start",
"satmachineadmin_stop",
]

View file

@ -1,343 +0,0 @@
# 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 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.
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 satmachineadmin'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

View file

@ -3,25 +3,50 @@ Pure calculation functions for DCA transaction processing.
These functions have no external dependencies (no lnbits, no database)
and can be easily tested in isolation.
What's intentionally NOT here (deleted 2026-05-26):
- `calculate_commission` (back-derive principal+fee from a gross-with-
commission-baked-in wire amount). Lamassu-era reverse-derivation;
obsolete since bitSpire stamps `principal_sats` AND `fee_sats`
directly on Payment.extra per aiolabs/lamassu-next#44.
- `calculate_exchange_rate` (principal / fiat_amount). bitSpire stamps
`exchange_rate` directly on Payment.extra too. Not used in production.
"""
from typing import Dict, Tuple
# Per-direction fee cap (super + operator) for any single direction.
# Locked at 15% per coord-log §2026-06-01T07:22Z (bitspire) — defense in
# depth: producer (this side) refuses to publish/persist > cap; consumer
# (bitspire) refuses to apply > cap. See aiolabs/satmachineadmin#37,#38
# and aiolabs/lamassu-next#57.
MAX_FEE_FRACTION_PER_DIRECTION = 0.15
def calculate_commission(
crypto_atoms: int,
commission_percentage: float,
discount: float = 0.0
) -> Tuple[int, int, float]:
"""
Calculate commission split from a Lamassu transaction.
The crypto_atoms from Lamassu already includes the commission baked in.
This function extracts the base amount (for DCA distribution) and
commission amount (for commission wallet).
Formula:
effective_commission = commission_percentage * (100 - discount) / 100
base_amount = round(crypto_atoms / (1 + effective_commission))
commission_amount = crypto_atoms - base_amount
Args:
crypto_atoms: Total sats from Lamassu (includes commission)
commission_percentage: Commission rate as decimal (e.g., 0.03 for 3%)
discount: Discount percentage on commission (e.g., 10.0 for 10% off)
Returns:
Tuple of (base_amount_sats, commission_amount_sats, effective_commission_rate)
Example:
>>> calculate_commission(266800, 0.03, 0.0)
(259029, 7771, 0.03)
"""
if commission_percentage > 0:
effective_commission = commission_percentage * (100 - discount) / 100
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
commission_amount_sats = crypto_atoms - base_crypto_atoms
else:
effective_commission = 0.0
base_crypto_atoms = crypto_atoms
commission_amount_sats = 0
return base_crypto_atoms, commission_amount_sats, effective_commission
def calculate_distribution(
@ -106,91 +131,17 @@ def calculate_distribution(
return distributions
def split_principal_based(
principal_sats: int,
super_frac: float,
operator_frac: float,
) -> Tuple[int, int]:
"""Compute platform + operator fee shares as independent fractions of
`principal_sats`. Both shares are derived from the customer's
principal (the canonical source of truth), NOT back-derived from
`fee_sats`.
Returns (platform_fee_sats, operator_fee_sats). Both are rounded
independently; rounding remainders do NOT compound the customer
pays whatever bitspire collected, and any drift between (super +
operator) and the bitspire-reported `fee_sats` surfaces via
`dca_settlements.fee_mismatch_sats`.
Examples:
>>> split_principal_based(100_000, 0.03, 0.05)
(3000, 5000)
>>> split_principal_based(266_800, 0.03, 0.0)
(8004, 0)
>>> split_principal_based(100_000, 0.0, 0.0)
(0, 0)
>>> split_principal_based(100_000, 0.15, 0.0)
(15000, 0)
The pre-#38 bug this corrects: the old math interpreted the super
fee as `fraction_of_fee` rather than `fraction_of_principal`. On a
100_000-sat principal with an 8% total bitspire fee (= 8_000 sats
fee_sats) and super_fraction=0.03, the bug paid the super
`round(8_000 * 0.03) = 240` sats ~13× below the intended
`100_000 * 0.03 = 3_000` sats per-settlement. Repeated on every
cash-out since the bitspire wire-shape landed. See
aiolabs/satmachineadmin#37 (parent) + #38 (this layer).
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float:
"""
if not (0.0 <= super_frac <= 1.0):
raise ValueError(f"super_frac must be in [0, 1], got {super_frac}")
if not (0.0 <= operator_frac <= 1.0):
raise ValueError(f"operator_frac must be in [0, 1], got {operator_frac}")
if principal_sats <= 0:
return 0, 0
platform = max(0, round(principal_sats * super_frac))
operator = max(0, round(principal_sats * operator_frac))
return platform, operator
Calculate exchange rate in sats per fiat unit.
Args:
base_crypto_atoms: Base amount in sats (after commission)
fiat_amount: Fiat amount dispensed
def allocate_operator_split_legs(
operator_fee_sats: int, leg_fractions: list
) -> list:
"""Stage-2 of the v2 commission split: the operator's remainder is sliced
across N leg wallets per `leg_fractions` (each in [0, 1], sum should
equal 1.0).
The last leg absorbs the rounding remainder so the sum of allocations
exactly equals operator_fee_sats (assuming fractions sum to ~1.0).
Returns a list of integer sat amounts in the same order as leg_fractions.
Examples:
>>> allocate_operator_split_legs(70, [0.5, 0.3, 0.2])
[35, 21, 14]
>>> allocate_operator_split_legs(5575, [0.5, 0.3, 0.2])
[2787, 1672, 1116]
>>> allocate_operator_split_legs(100, [1.0])
[100]
>>> allocate_operator_split_legs(0, [0.5, 0.5])
[0, 0]
Returns:
Exchange rate as sats per fiat unit
"""
if not leg_fractions:
return []
if operator_fee_sats <= 0:
return [0] * len(leg_fractions)
for f in leg_fractions:
if not (0.0 <= float(f) <= 1.0):
raise ValueError(
f"every leg fraction must be in [0, 1], got {f}"
)
allocations: list = []
remaining = operator_fee_sats
for idx, fraction in enumerate(leg_fractions):
if idx == len(leg_fractions) - 1:
allocations.append(remaining)
else:
amount = round(operator_fee_sats * float(fraction))
allocations.append(amount)
remaining -= amount
return allocations
if fiat_amount <= 0:
return 0.0
return base_crypto_atoms / fiat_amount

View file

@ -1,257 +0,0 @@
"""
Cassette-config Nostr transport operator ATM kind-30078 publish + consume.
Per the locked design at aiolabs/satmachineadmin#29 (paired with
lamassu-next#56) and the dcd0874 privacy-by-default pivot, the operator
publishes position-keyed cassette config to a target ATM via:
kind = 30078 (NIP-78, replaceable)
tags = [
["d", "bitspire-cassettes:<atm_pubkey_hex>"],
["p", "<atm_pubkey_hex>"]
]
content = NIP-44 v2 encrypted JSON of PublishCassettesPayload.to_wire_dict()
pubkey = operator pubkey
sig = operator signature
The ATM-side consumer (lamassu-next#56) subscribes by the d-tag + its own
npub, decrypts, validates, applies, hot-reloads HAL.
Reverse direction (ATM operator, v1 = one-shot bootstrap on first boot,
v2 = continuous reverse channel for reconciliation):
kind = 30078
tags = [
["d", "bitspire-cassettes-state:<atm_pubkey_hex>"],
["p", "<operator_pubkey_hex>"]
]
content = NIP-44 v2 encrypted JSON, same PublishCassettesPayload shape
pubkey = ATM pubkey
This module owns the wire-format side of both directions. The consumer
task (tasks.py) calls `decrypt_and_parse_state_event` per incoming event;
the API endpoint (views_api.py) calls `publish_to_atm` per operator submit.
The `<m>` placeholder semantics (load-bearing per the 2026-05-30T11:50Z
coord-log entry): always the ATM's hex pubkey, NEVER satmachineadmin's
internal dca_machines.id UUID. Helper `_atm_hex_pubkey(machine)`
centralises the canonicalisation via lnbits.utils.nostr.normalize_public_key.
"""
from __future__ import annotations
import json
from lnbits.core.services.nip46_bunker_client import (
NsecBunkerRpcError,
NsecBunkerTimeoutError,
)
from lnbits.core.signers.base import (
NostrSigner,
SignerUnavailableError,
)
from lnbits.utils.nostr import normalize_public_key
from .models import Machine, PublishCassettesPayload
from .nip44 import Nip44Error
from .nostr_publish import (
NostrPublishError,
OperatorIdentityMissing, # re-export for callers that catch this
RelayUnavailable, # re-export
SignerUnavailable, # re-export
nip44_decrypt_via_signer,
publish_encrypted_kind_30078,
)
# Re-exported so external callers (views_api etc.) can keep importing
# from cassette_transport without breakage. Same for the public
# constants below.
__all__ = [
"CassetteTransportError",
"CassetteEventDecodeError",
"CassetteEventTransientError",
"OperatorIdentityMissing",
"SignerUnavailable",
"RelayUnavailable",
"build_state_d_tags_for_machines",
"decrypt_and_parse_state_event",
"publish_to_atm",
]
_D_TAG_CONFIG_PREFIX = "bitspire-cassettes:" # operator → ATM
_D_TAG_STATE_PREFIX = "bitspire-cassettes-state:" # ATM → operator
# =============================================================================
# Errors — cassette-specific subclasses of the generic NostrPublishError
# =============================================================================
class CassetteTransportError(NostrPublishError):
"""Generic cassette-transport error. Subclasses distinguish failure
modes so the API can surface meaningful HTTP statuses + the consumer
task can log + skip without crashing.
Bridges back-compat with pre-extraction callers that catch this
class now equivalent to NostrPublishError plus the two consumer-
side decode/transient distinctions below.
"""
class CassetteEventDecodeError(CassetteTransportError):
"""Inbound state event failed validation: bad signature, NIP-44 v2
decrypt failure, or payload didn't conform to PublishCassettesPayload.
Terminal caller should log + skip, advancing past the event."""
class CassetteEventTransientError(CassetteTransportError):
"""Inbound state event couldn't be decrypted because the signer
component (typically the bunker) is transiently unavailable. Caller
should NOT advance past the event; retry on next tick.
Distinct from CassetteEventDecodeError so the consumer task can
differentiate "MAC failed, give up" from "bunker is partitioned, try
again in a few seconds" — surfaced by lnbits at coord-log
2026-05-31T07:10Z as the load-bearing distinction post-PR-#38."""
# =============================================================================
# Helpers — canonical pubkey + d-tag construction
# =============================================================================
def _atm_hex_pubkey(machine: Machine) -> str:
"""Canonicalise machine.machine_npub (hex OR npub bech32 — operator
enters either in the UI) to lowercase hex. ALL d-tag substitutions
use this value; using the internal machine.id UUID would silently
no-op the wire-level filter (per coord-log 11:50Z load-bearing nudge).
"""
return normalize_public_key(machine.machine_npub).lower()
def _config_d_tag(atm_pubkey_hex: str) -> str:
"""d-tag for operator → ATM publish. ATM subscribes by this tag."""
return f"{_D_TAG_CONFIG_PREFIX}{atm_pubkey_hex}"
def _state_d_tag(atm_pubkey_hex: str) -> str:
"""d-tag for ATM → operator publish (bootstrap in v1, continuous v2)."""
return f"{_D_TAG_STATE_PREFIX}{atm_pubkey_hex}"
def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
"""Bootstrap-consumer subscription filter helper: returns the full
`#d=[...]` list for all known ATMs an operator subscribes to."""
return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines]
# =============================================================================
# Publish — operator → ATM (the satmachineadmin API path)
# =============================================================================
async def publish_to_atm(
machine: Machine,
payload: PublishCassettesPayload,
operator_user_id: str,
) -> dict:
"""Build, encrypt, sign, and publish a kind-30078 cassette config event
from the operator to the target ATM.
Returns the signed event dict on success (caller may log event.id for
audit). Raises NostrPublishError subclasses (re-exported here as
CassetteTransportError, OperatorIdentityMissing, SignerUnavailable,
RelayUnavailable) on hard failures.
"""
atm_pubkey_hex = _atm_hex_pubkey(machine)
signed = await publish_encrypted_kind_30078(
operator_user_id=operator_user_id,
recipient_pubkey_hex=atm_pubkey_hex,
d_tag=_config_d_tag(atm_pubkey_hex),
payload=payload.to_wire_dict(),
log_context=(
f"cassette config (machine={machine.id}, "
f"positions={sorted(payload.positions.keys())})"
),
)
return signed
# =============================================================================
# Consume — ATM → operator (the bootstrap consumer task)
# =============================================================================
async def decrypt_and_parse_state_event(
event: dict, account, signer: NostrSigner
) -> PublishCassettesPayload:
"""Decrypt + parse an inbound `bitspire-cassettes-state:<atm_pubkey_hex>`
event the ATM published toward the operator.
Caller is responsible for:
- filtering on `kind=30078` and the expected `#d` tag list
- verifying the event signature (lnbits.utils.nostr.verify_event)
- confirming `event["pubkey"]` matches a known ATM (= machine.machine_npub
canonicalised) the consumer task does this before calling here
- resolving the operator's account + signer via
`_resolve_operator_signer(...)` and passing them in
This function does:
- NIP-44 v2 decrypt of event["content"] via `signer.nip44_decrypt`
(bunker round-trip on RemoteBunkerSigner; direct prvkey on the
transitional LocalSigner path)
- JSON parse + PublishCassettesPayload validation
Error mapping:
- CassetteEventTransientError on NsecBunkerTimeoutError caller
should NOT advance state_event_id; retry on next consumer tick
- CassetteEventDecodeError on anything else (bunker RPC reject,
signer unavailable, MAC failure, JSON parse, payload shape)
terminal; caller logs + skips
"""
sender_pubkey = event.get("pubkey")
content = event.get("content")
if not isinstance(sender_pubkey, str) or not isinstance(content, str):
raise CassetteEventDecodeError(
"event missing required pubkey or content fields"
)
try:
plaintext = await nip44_decrypt_via_signer(
account, signer, content, sender_pubkey
)
except NsecBunkerTimeoutError as exc:
raise CassetteEventTransientError(
f"bunker unreachable while decrypting cassette state event: {exc}"
) from exc
except NsecBunkerRpcError as exc:
raise CassetteEventDecodeError(
f"bunker rejected nip44_decrypt (policy / MAC / config): {exc}"
) from exc
except SignerUnavailableError as exc:
raise CassetteEventDecodeError(f"signer cannot nip44-decrypt: {exc}") from exc
except Nip44Error as exc:
# Hand-rolled LocalSigner fallback path (transitional) — MAC fail
# / version mismatch / length issue.
raise CassetteEventDecodeError(
f"NIP-44 v2 decrypt failed (LocalSigner fallback path): {exc}"
) from exc
except ValueError as exc:
# coincurve raises ValueError on a malformed pubkey hex (only
# reachable via the LocalSigner fallback path; the bunker handles
# pubkey validation server-side).
raise CassetteEventDecodeError(f"sender pubkey is malformed: {exc}") from exc
try:
raw = json.loads(plaintext)
except json.JSONDecodeError as exc:
raise CassetteEventDecodeError(
f"decrypted content isn't valid JSON: {exc}"
) from exc
try:
return PublishCassettesPayload(**raw)
except Exception as exc:
raise CassetteEventDecodeError(
f"payload didn't validate as PublishCassettesPayload: {exc}"
) from exc

1706
crud.py

File diff suppressed because it is too large Load diff

View file

@ -1,874 +0,0 @@
# Satoshi Machine v2 — settlement distribution (P2).
#
# Picks up a dca_settlements row with status='pending' and pays out the
# three leg groups via LNbits internal transfers (create_invoice +
# pay_invoice on the same instance auto-detect internal). All legs land
# in dca_payments with the appropriate leg_type discriminator and inherit
# the Payment.tag "satmachine:{machine_npub}" so LNbits payment-history
# filters work natively.
#
# Leg order:
# 1. super_fee — platform_fee_sats → super_fee_wallet_id (if set)
# 2. operator_split — operator_fee_sats split per operator's rules
# 3. dca — principal_sats distributed proportionally to active LPs,
# each leg capped at the LP's remaining fiat balance
# (preserves the v1 sync-mismatch fix from PR #2)
#
# Atomicity: LN payments cannot be rolled back. We attempt each leg, record
# success/failure per dca_payments row, and mark the settlement 'processed'
# only when every leg completed. Any failure marks 'errored' with a message
# but leaves the successful legs in place. Sats that don't get paid out
# (failed legs, no LP coverage, missing super wallet) remain in the
# machine's wallet — visible to the operator on the dashboard.
from __future__ import annotations
from datetime import datetime, timezone
from typing import List, Optional
from lnbits.core.services import create_invoice, pay_invoice
from lnbits.core.services.lnurl import get_pr_from_lnurl
from lnurl import LnAddress
from loguru import logger
from .calculations import (
allocate_operator_split_legs,
calculate_distribution,
)
from .crud import (
apply_partial_dispense,
claim_settlement_for_processing,
count_completed_legs_for_settlement,
create_dca_payment,
get_client_balance_summary,
get_dca_lp,
get_effective_commission_splits,
get_flow_mode_clients_for_machine,
get_machine,
get_settlement,
get_super_config,
mark_settlement_status,
update_payment_status,
void_open_legs_for_settlement,
)
from .models import (
CreateDcaPaymentData,
DcaClient,
DcaLpPreferences,
DcaPayment,
DcaSettlement,
Machine,
PartialDispenseData,
SettleBalanceData,
SuperConfig,
)
PAYMENT_TAG_PREFIX = "satmachine"
def _payment_tag(machine: Machine) -> str:
return f"{PAYMENT_TAG_PREFIX}:{machine.machine_npub}"
async def _record_skipped_leg(
settlement: DcaSettlement,
machine: Machine,
leg_type: str,
amount_sats: int,
reason: str,
client_id: str | None = None,
) -> None:
"""Audit row for sats intentionally left in the machine wallet.
Distinct from 'failed' (which means pay_invoice errored). 'skipped' means
we never attempted the pay by design, because some prerequisite was
missing (super wallet not configured, no operator ruleset, no exchange
rate, no eligible LPs). Operator sees these in payment history and on
the settlement detail blob; the audit trail explains where un-paid
sats are sitting.
"""
if amount_sats <= 0:
return
leg = await create_dca_payment(
CreateDcaPaymentData(
settlement_id=settlement.id,
client_id=client_id,
machine_id=machine.id,
operator_user_id=machine.operator_user_id,
leg_type=leg_type,
destination_wallet_id=None,
destination_ln_address=None,
amount_sats=amount_sats,
amount_fiat=None,
exchange_rate=None,
transaction_time=datetime.now(timezone.utc),
external_payment_hash=None,
)
)
await update_payment_status(leg.id, "skipped", None, reason[:512])
logger.info(
f"distribution: skipped {leg_type} leg " f"({amount_sats} sats) — {reason}"
)
def _resolve_partial_dispense_wire(
settlement: DcaSettlement, data: PartialDispenseData
) -> int:
if data.dispensed_sats is not None:
new_wire = int(data.dispensed_sats)
elif data.dispensed_fraction is not None:
new_wire = round(settlement.wire_sats * float(data.dispensed_fraction))
else:
raise ValueError("provide one of dispensed_sats or dispensed_fraction")
if new_wire < 0:
raise ValueError("partial dispense cannot be negative")
if new_wire > settlement.wire_sats:
raise ValueError(
f"partial dispense ({new_wire} sats) cannot exceed the original "
f"wire amount ({settlement.wire_sats} sats)"
)
return new_wire
def _build_partial_dispense_memo(
settlement: DcaSettlement,
data: PartialDispenseData,
*,
new_wire: int,
new_principal: int,
new_fee: int,
new_platform: int,
new_operator: int,
) -> str:
reason = (data.notes or "").strip() or "(no reason given)"
if data.dispensed_sats is not None:
adjust = f"dispensed_sats={data.dispensed_sats}"
else:
adjust = f"dispensed_fraction={data.dispensed_fraction}"
ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
return (
f"[{ts}] partial dispense applied — {adjust}. "
f"Original wire={settlement.wire_sats} "
f"principal={settlement.principal_sats} "
f"fee={settlement.fee_sats} "
f"(super_fee={settlement.platform_fee_sats} "
f"operator_fee={settlement.operator_fee_sats}). "
f"New wire={new_wire} principal={new_principal} "
f"fee={new_fee} "
f"(super_fee={new_platform} operator_fee={new_operator}). "
f"Reason: {reason}"
)
async def settle_lp_balance(
client: DcaClient, machine: Machine, data: SettleBalanceData
) -> DcaPayment:
"""Operator UX action — closes satmachineadmin#4.
Settle an LP's remaining fiat balance from the operator's chosen funding
wallet at the rate the operator specified. Records a leg_type='settlement'
row that counts against the LP's balance summary (so a subsequent
get_client_balance_summary reflects the new zero/reduced balance).
Caller is responsible for verifying the operator owns both the client's
machine and the funding wallet (API endpoint does this). The amount_fiat
is capped at the LP's remaining balance — operators cannot accidentally
over-pay via this path.
The destination wallet is the LP's own `dca_lp.dca_wallet_id` — the
operator can't redirect this; if the LP hasn't onboarded yet there's
no destination and we refuse.
"""
prefs = await get_dca_lp(client.user_id)
if prefs is None:
raise ValueError(
f"client {client.id} (user {client.user_id[:8]}...) has not "
f"onboarded via satmachineclient — no DCA wallet configured"
)
summary = await get_client_balance_summary(client.id)
if summary is None:
raise ValueError(f"client {client.id} balance not available")
remaining = float(summary.remaining_balance)
if remaining <= 0:
raise ValueError(f"client {client.id} has no remaining balance to settle")
# Resolve fiat amount: explicit if given (capped at remaining), else full.
requested = float(data.amount_fiat) if data.amount_fiat is not None else remaining
amount_fiat = round(min(requested, remaining), 2)
if amount_fiat <= 0:
raise ValueError("computed settlement amount is zero")
exchange_rate = float(data.exchange_rate)
amount_sats = round(amount_fiat * exchange_rate)
if amount_sats <= 0:
raise ValueError(
f"computed sat amount is zero (amount_fiat={amount_fiat}, "
f"exchange_rate={exchange_rate})"
)
reason = (data.notes or "").strip() or "(no reason given)"
memo = (
f"satmachine balance settle — {amount_fiat:.2f} "
f"{machine.fiat_code} @ {exchange_rate:g} sat/{machine.fiat_code} "
f"= {amount_sats} sats. Reason: {reason}"
)
leg_row = await create_dca_payment(
CreateDcaPaymentData(
settlement_id=None,
client_id=client.id,
machine_id=machine.id,
operator_user_id=machine.operator_user_id,
leg_type="settlement",
destination_wallet_id=prefs.dca_wallet_id,
destination_ln_address=None,
amount_sats=amount_sats,
amount_fiat=amount_fiat,
exchange_rate=exchange_rate,
transaction_time=datetime.now(timezone.utc),
external_payment_hash=None,
)
)
extra = {
"satmachine_leg": "settlement",
"satmachine_client_id": client.id,
"satmachine_machine_npub": machine.machine_npub,
"satmachine_exchange_rate": exchange_rate,
}
try:
new_invoice = await create_invoice(
wallet_id=prefs.dca_wallet_id,
amount=float(amount_sats),
internal=True,
memo=memo,
extra=extra,
)
if not new_invoice or not new_invoice.bolt11:
await update_payment_status(
leg_row.id, "failed", None, "create_invoice returned empty"
)
raise ValueError("create_invoice returned empty")
paid = await pay_invoice(
wallet_id=data.funding_wallet_id,
payment_request=new_invoice.bolt11,
description=memo,
tag=_payment_tag(machine),
extra=extra,
)
completed = await update_payment_status(
leg_row.id, "completed", paid.payment_hash, None
)
return completed if completed is not None else leg_row
except Exception as exc:
logger.error(
f"distribution: balance-settle failed for client {client.id} "
f"({amount_sats} sats from wallet {data.funding_wallet_id}): {exc}"
)
await update_payment_status(leg_row.id, "failed", None, str(exc)[:512])
raise
async def apply_partial_dispense_and_redistribute(
settlement_id: str, data: PartialDispenseData
) -> DcaSettlement:
"""Operator UX action — closes satmachineadmin#3.
When a bitSpire dispense fails mid-transaction (e.g., dispenser jam after
6 of 10 bills), the operator confirms the actual amount dispensed and we
re-allocate the split against that partial wire amount. Sat amounts scale
linearly, preserving the original commission ratio exactly. The two-stage
super/operator split also scales by the *original* platform_fee_sats /
fee_sats ratio rather than re-reading current super_fee_fraction
this honors the "absolute fields are the source of truth" invariant
even when super has changed the global rate since the settlement landed
(closes #11 H6).
Hard guard: refuses if any dca_payments leg has already completed.
Lightning payments can't be clawed back, so we won't try.
Side effects:
- Voids pending/failed legs (status 'voided').
- Overwrites the settlement's monetary fields with the new totals.
- Appends a timestamped memo to settlement.notes capturing the
original values + operator's reason.
- Resets settlement.status to 'pending' and triggers process_settlement.
"""
settlement = await get_settlement(settlement_id)
if settlement is None:
raise ValueError(f"settlement {settlement_id} not found")
if settlement.wire_sats <= 0:
raise ValueError("cannot partial-dispense a zero-wire settlement")
completed = await count_completed_legs_for_settlement(settlement_id)
if completed > 0:
raise ValueError(
f"cannot partial-dispense: {completed} leg(s) already completed "
"(Lightning payments can't be clawed back)"
)
new_wire = _resolve_partial_dispense_wire(settlement, data)
# Linear scale preserves the original commission ratio exactly.
scale = new_wire / settlement.wire_sats
new_fee = round(settlement.fee_sats * scale)
new_principal = new_wire - new_fee
new_fiat = round(float(settlement.fiat_amount) * scale, 2)
# Re-derive the stage-1 split from the ORIGINAL ratio stored on this
# settlement row — NOT the current super_fee_fraction. The contract was
# locked at landing; super raising or lowering the global rate after
# the fact must not retroactively change this transaction's share.
# Operator absorbs the rounding remainder so platform + operator
# == new_fee exactly.
if settlement.fee_sats > 0:
ratio = settlement.platform_fee_sats / settlement.fee_sats
else:
ratio = 0.0
new_platform = round(new_fee * ratio)
new_platform = max(0, min(new_platform, new_fee))
new_operator = new_fee - new_platform
memo = _build_partial_dispense_memo(
settlement,
data,
new_wire=new_wire,
new_principal=new_principal,
new_fee=new_fee,
new_platform=new_platform,
new_operator=new_operator,
)
await void_open_legs_for_settlement(settlement_id)
updated = await apply_partial_dispense(
settlement_id,
new_wire_sats=new_wire,
new_principal_sats=new_principal,
new_fee_sats=new_fee,
new_platform_fee_sats=new_platform,
new_operator_fee_sats=new_operator,
new_fiat_amount=new_fiat,
appended_note=memo,
)
if updated is None:
raise ValueError(f"settlement {settlement_id} disappeared mid-update")
logger.info(
f"distribution: partial-dispense applied to settlement "
f"{settlement_id} — re-running distribution"
)
await process_settlement(settlement_id)
after = await get_settlement(settlement_id)
return after if after is not None else updated
async def process_settlement(settlement_id: str) -> None:
"""Process a pending settlement end-to-end.
Concurrency-safe: an optimistic-lock claim flips the settlement to
'processing' atomically and tags it with a per-invocation token.
Concurrent invocations on the same id can't both win — losers see the
claim mismatch on read-back and return without writing any legs.
Retries land via reset_settlement_for_retry which voids failed legs
and flips 'errored' back to 'pending'."""
settlement = await claim_settlement_for_processing(settlement_id)
if settlement is None:
# Either already claimed by a concurrent invocation, or not in a
# 'pending' state. Either way, nothing to do here.
logger.debug(
f"distribution: skip {settlement_id} — not claimable (already "
"processing or not pending)"
)
return
machine = await get_machine(settlement.machine_id)
if machine is None:
logger.error(
f"distribution: settlement {settlement_id} references missing "
f"machine {settlement.machine_id}"
)
await mark_settlement_status(settlement_id, "errored", "machine missing")
return
super_config = await get_super_config()
errors: List[str] = []
try:
await _pay_super_fee(settlement, machine, super_config, errors)
await _pay_operator_splits(settlement, machine, errors)
# DCA distribution: applies to cash_out (LPs share the principal
# the customer paid into BTC). Does NOT apply to cash_in — that
# flow is liquidity coming IN to the operator's wallet, not
# going OUT to LPs. Skip with an audit row so the operator
# dashboard surfaces "DCA intentionally skipped for cash_in
# settlement" rather than displaying a phantom missing leg.
# See aiolabs/satmachineadmin#22 (S8 — wire cash-in path).
if settlement.tx_type == "cash_out":
await _pay_dca_distributions(settlement, machine, errors)
else:
await _record_skipped_leg(
settlement,
machine,
leg_type="dca",
amount_sats=settlement.principal_sats,
reason=(
f"DCA distribution does not apply to tx_type="
f"{settlement.tx_type!r}; principal stays in the "
"operator's wallet as liquidity received from the "
"cash-in customer."
),
)
except Exception as exc: # last-resort guard
logger.exception("distribution: unexpected error processing settlement")
errors.append(f"unexpected: {exc}")
if errors:
await mark_settlement_status(settlement_id, "errored", "; ".join(errors)[:512])
else:
await mark_settlement_status(settlement_id, "processed", None)
# =============================================================================
# Leg 1 — super fee
# =============================================================================
async def _pay_super_fee(
settlement: DcaSettlement,
machine: Machine,
super_config: SuperConfig | None,
errors: List[str],
) -> None:
if settlement.platform_fee_sats <= 0:
return
if super_config is None or not super_config.super_fee_wallet_id:
# Super has configured a fee but not a destination wallet — leave
# the sats in the machine wallet and record a skipped audit row.
# The super needs to configure their wallet before they can collect.
await _record_skipped_leg(
settlement,
machine,
leg_type="super_fee",
amount_sats=settlement.platform_fee_sats,
reason="super_fee_wallet_id not configured by LNbits super",
)
return
await _pay_internal(
settlement=settlement,
machine=machine,
leg_type="super_fee",
client_id=None,
destination_wallet_id=super_config.super_fee_wallet_id,
amount_sats=settlement.platform_fee_sats,
memo=f"satmachine super fee — {machine.name or machine.machine_npub[:12]}",
errors=errors,
)
# =============================================================================
# Leg 2 — operator commission splits
# =============================================================================
async def _pay_operator_splits(
settlement: DcaSettlement,
machine: Machine,
errors: List[str],
) -> None:
if settlement.operator_fee_sats <= 0:
return
splits = await get_effective_commission_splits(machine.operator_user_id, machine.id)
if not splits:
await _record_skipped_leg(
settlement,
machine,
leg_type="operator_split",
amount_sats=settlement.operator_fee_sats,
reason=(
"operator has no commission_splits ruleset for this machine "
"(neither per-machine override nor operator default)"
),
)
return
# Pure allocator handles the rounding rule (last leg absorbs remainder).
leg_amounts = allocate_operator_split_legs(
settlement.operator_fee_sats,
[float(leg.fraction) for leg in splits],
)
for idx, (leg, amount) in enumerate(zip(splits, leg_amounts, strict=True)):
if amount <= 0:
continue
label = leg.label or f"split-{idx + 1}"
memo = (
f"satmachine operator split — "
f"{machine.name or machine.machine_npub[:12]} ({label})"
)
await _pay_split_leg(
settlement=settlement,
machine=machine,
target=leg.target,
amount_sats=amount,
memo=memo,
errors=errors,
)
# =============================================================================
# Leg 3 — DCA distribution to active LPs
# =============================================================================
async def _pay_dca_distributions(
settlement: DcaSettlement,
machine: Machine,
errors: List[str],
) -> None:
if settlement.principal_sats <= 0:
return
if settlement.exchange_rate <= 0:
# Fallback path with no exchange rate (bitSpire Payment.extra absent).
# Without a rate we can't compute fiat balances → can't compute
# proportional shares → leave principal_sats in the machine wallet
# for manual reconciliation. Audit row makes the strand visible.
await _record_skipped_leg(
settlement,
machine,
leg_type="dca",
amount_sats=settlement.principal_sats,
reason=(
"no exchange_rate on settlement (bitSpire fallback path; "
"see aiolabs/lamassu-next#44)"
),
)
return
clients = await get_flow_mode_clients_for_machine(machine.id)
if not clients:
await _record_skipped_leg(
settlement,
machine,
leg_type="dca",
amount_sats=settlement.principal_sats,
reason="no active flow-mode LPs registered at this machine",
)
return
# Build {client_id: remaining_fiat_balance} for proportional allocation.
client_balances: dict[str, float] = {}
for client in clients:
summary = await get_client_balance_summary(client.id)
if summary is None or summary.remaining_balance <= 0:
continue
client_balances[client.id] = summary.remaining_balance
if not client_balances:
await _record_skipped_leg(
settlement,
machine,
leg_type="dca",
amount_sats=settlement.principal_sats,
reason=(
"no LP has remaining-fiat-balance > 0 — all confirmed deposits "
"already paid out"
),
)
return
# Compute proportional sat allocations, then cap each at the client's
# remaining-fiat-balance-in-sats (the v1 sync-mismatch safeguard).
raw_allocations = calculate_distribution(
base_amount_sats=settlement.principal_sats,
client_balances=client_balances,
)
capped_allocations: dict[str, int] = {}
for client_id, raw_sats in raw_allocations.items():
remaining_fiat = client_balances[client_id]
cap_sats = int(remaining_fiat * float(settlement.exchange_rate))
capped_allocations[client_id] = min(raw_sats, cap_sats)
client_by_id = {c.id: c for c in clients}
for client_id, amount_sats in capped_allocations.items():
await _pay_one_dca_leg(
settlement, machine, client_by_id[client_id], amount_sats, errors
)
async def _pay_one_dca_leg(
settlement: DcaSettlement,
machine: Machine,
client: DcaClient,
amount_sats: int,
errors: List[str],
) -> None:
"""Pay a single DCA leg + best-effort autoforward.
Reads the LP's destination wallet + autoforward config from `dca_lp`.
Callers reach this through `get_flow_mode_clients_for_machine` which
INNER JOINs on `dca_lp`, so a `prefs is None` here would indicate a
race (LP deleted their dca_lp row between query and pay) we
defensively skip.
"""
if amount_sats <= 0:
return
prefs = await get_dca_lp(client.user_id)
if prefs is None:
errors.append(f"client {client.id}: dca_lp row disappeared mid-distribution")
return
amount_fiat = round(amount_sats / float(settlement.exchange_rate), 2)
memo = f"DCA: {amount_sats} sats • {amount_fiat:.2f} {settlement.fiat_code}"
dca_leg = await _pay_internal(
settlement=settlement,
machine=machine,
leg_type="dca",
client_id=client.id,
destination_wallet_id=prefs.dca_wallet_id,
amount_sats=amount_sats,
amount_fiat=amount_fiat,
exchange_rate=float(settlement.exchange_rate),
memo=memo,
errors=errors,
)
# Best-effort auto-forward to LP's external LN address (closes
# satmachineadmin#8). Skip if the DCA leg failed (nothing to forward).
# If autoforward fails, sats stay in the LP's LNbits wallet — the
# explicit safety constraint.
if (
dca_leg is not None
and dca_leg.status == "completed"
and prefs.autoforward_enabled
and prefs.autoforward_ln_address
):
await _attempt_autoforward(client, prefs, machine, settlement, amount_sats)
# =============================================================================
# Internal transfer helper
# =============================================================================
async def _attempt_autoforward(
client: DcaClient,
prefs: DcaLpPreferences,
machine: Machine,
settlement: DcaSettlement,
amount_sats: int,
) -> None:
"""LP auto-forward (best-effort) — closes satmachineadmin#8.
Resolves the LP's configured LN address, requests a bolt11 invoice for
the DCA leg's sat amount, and pays it from the LP's LNbits wallet. Each
attempt records a dca_payments row with leg_type='autoforward' for
audit, regardless of outcome.
Safety: on any failure (malformed address, LNURL resolution fail,
payment timeout, etc.) we log a warning and leave the sats in the LP's
LNbits wallet. The LP can move them manually via the LNbits UI. We
never re-raise; failed forwarding must not block subsequent legs.
"""
address = prefs.autoforward_ln_address
if not address:
return
leg = await create_dca_payment(
CreateDcaPaymentData(
settlement_id=settlement.id,
client_id=client.id,
machine_id=machine.id,
operator_user_id=machine.operator_user_id,
leg_type="autoforward",
destination_wallet_id=None,
destination_ln_address=address,
amount_sats=amount_sats,
amount_fiat=None,
exchange_rate=None,
transaction_time=datetime.now(timezone.utc),
external_payment_hash=None,
)
)
try:
lnaddr = LnAddress(address)
bolt11 = await get_pr_from_lnurl(
lnurl=lnaddr,
amount_msat=amount_sats * 1000,
comment=f"satmachine autoforward — {machine.machine_npub[:12]}",
)
paid = await pay_invoice(
wallet_id=prefs.dca_wallet_id,
payment_request=bolt11,
description=f"satmachine autoforward → {address}",
tag=_payment_tag(machine),
extra={
"satmachine_leg": "autoforward",
"satmachine_settlement_id": settlement.id,
"satmachine_machine_npub": machine.machine_npub,
"satmachine_destination": address,
},
)
await update_payment_status(leg.id, "completed", paid.payment_hash, None)
logger.info(
f"distribution: autoforward {amount_sats} sats from client "
f"{client.id}{address} OK"
)
except Exception as exc:
logger.warning(
f"distribution: autoforward FAILED for client {client.id} "
f"{address}: {exc}. Sats stay in LP's LNbits wallet."
)
await update_payment_status(leg.id, "failed", None, str(exc)[:512])
async def _pay_split_leg(
*,
settlement: DcaSettlement,
machine: Machine,
target: str,
amount_sats: int,
memo: str,
errors: List[str],
) -> Optional[DcaPayment]:
"""Pay a commission-split leg to an arbitrary target.
`target` accepts (splitpayments pattern):
- Lightning address (user@domain) resolved via LNURL-pay
- LNURL string (LNURL...) resolved via LNURL-pay
- LNbits wallet invoice key resolved via get_wallet_for_key,
then internal create_invoice + pay
- LNbits wallet id direct internal create_invoice + pay
Records a dca_payments row regardless of outcome (success 'completed',
failure 'failed'); operator sees the row in audit either way.
"""
target = (target or "").strip()
# External target: Lightning address or LNURL.
if "@" in target or target.upper().startswith("LNURL"):
leg_row = await create_dca_payment(
CreateDcaPaymentData(
settlement_id=settlement.id,
client_id=None,
machine_id=machine.id,
operator_user_id=machine.operator_user_id,
leg_type="operator_split",
destination_wallet_id=None,
destination_ln_address=target,
amount_sats=amount_sats,
amount_fiat=None,
exchange_rate=None,
transaction_time=datetime.now(timezone.utc),
external_payment_hash=None,
)
)
extra = {
"satmachine_leg": "operator_split",
"satmachine_settlement_id": settlement.id,
"satmachine_machine_npub": machine.machine_npub,
"satmachine_destination": target,
}
try:
ln_target = LnAddress(target) if "@" in target else target
bolt11 = await get_pr_from_lnurl(
lnurl=ln_target,
amount_msat=amount_sats * 1000,
comment=memo,
)
paid = await pay_invoice(
wallet_id=machine.wallet_id,
payment_request=bolt11,
description=memo,
tag=_payment_tag(machine),
extra=extra,
)
await update_payment_status(
leg_row.id, "completed", paid.payment_hash, None
)
return leg_row
except Exception as exc:
logger.error(
f"distribution: operator_split (LNURL/LN-addr) FAILED "
f"target={target} settlement={settlement.id}: {exc}"
)
await update_payment_status(leg_row.id, "failed", None, str(exc)[:512])
errors.append(f"operator_split→{target}: {exc}")
return leg_row
# Internal LNbits target: try as invoice key first, fall back to wallet id.
resolved_wallet_id = target
try:
from lnbits.core.crud.wallets import get_wallet_for_key
wallet = await get_wallet_for_key(target)
if wallet is not None:
resolved_wallet_id = wallet.id
except Exception:
# If get_wallet_for_key isn't importable in this LNbits version, just
# treat target as a wallet id directly.
pass
return await _pay_internal(
settlement=settlement,
machine=machine,
leg_type="operator_split",
client_id=None,
destination_wallet_id=resolved_wallet_id,
amount_sats=amount_sats,
memo=memo,
errors=errors,
)
async def _pay_internal(
*,
settlement: DcaSettlement,
machine: Machine,
leg_type: str,
client_id: str | None,
destination_wallet_id: str,
amount_sats: int,
memo: str,
errors: List[str],
amount_fiat: float | None = None,
exchange_rate: float | None = None,
) -> DcaPayment | None:
"""Create an invoice on the destination wallet, pay it from the machine
wallet, and record the leg in dca_payments. Returns the dca_payments row
on success (including the failed case the row stays for audit)."""
tag = _payment_tag(machine)
leg_row = await create_dca_payment(
CreateDcaPaymentData(
settlement_id=settlement.id,
client_id=client_id,
machine_id=machine.id,
operator_user_id=machine.operator_user_id,
leg_type=leg_type,
destination_wallet_id=destination_wallet_id,
destination_ln_address=None,
amount_sats=amount_sats,
amount_fiat=amount_fiat,
exchange_rate=exchange_rate,
transaction_time=datetime.now(),
external_payment_hash=None,
)
)
extra = {
"satmachine_leg": leg_type,
"satmachine_settlement_id": settlement.id,
"satmachine_machine_npub": machine.machine_npub,
}
try:
new_invoice = await create_invoice(
wallet_id=destination_wallet_id,
amount=float(amount_sats),
internal=True,
memo=memo,
extra=extra,
)
if not new_invoice or not new_invoice.bolt11:
await update_payment_status(
leg_row.id, "failed", None, "create_invoice returned empty"
)
errors.append(f"{leg_type}: create_invoice empty")
return leg_row
paid = await pay_invoice(
wallet_id=machine.wallet_id,
payment_request=new_invoice.bolt11,
description=memo,
tag=tag,
extra=extra,
)
await update_payment_status(leg_row.id, "completed", paid.payment_hash, None)
return leg_row
except Exception as exc:
logger.error(
f"distribution: {leg_type} leg failed "
f"(settlement={settlement.id} amount={amount_sats}): {exc}"
)
await update_payment_status(leg_row.id, "failed", None, str(exc)[:512])
errors.append(f"{leg_type}: {exc}")
return leg_row

View file

@ -1,403 +0,0 @@
# bitSpire ↔ LNbits Security Pathway — State of the Union & Design Proposal
**Audience:** an operator, a junior dev, an auditor, the customer who walks up to the ATM.
**Goal:** explain — without handwaving — how money moves between a bitSpire ATM and the operator's LNbits wallet, what guarantees today's code provides, where the gaps are, and a concrete multilayered fix that capitalises on Nostr instead of bolting on TLSstyle fingerprints.
---
## 0 · Why this document exists
Today the satoshimachine code lives at `~/dev/shared/extensions/satmachineadmin` on branch `v2-bitspire`. v2 swapped the legacy Lamassu SSH/PostgreSQL polling model for a Nostrnative one: bitSpire publishes invoices over kind21000 NIP44 v2 events, LNbits pays them, and our extension hooks the resulting `Payment` object.
The hard truth: the *settlement* itself uses Lightning (so it can't be forged once a preimage lands), but everything *around* the settlement — who the ATM is, what operator it belongs to, what the principal/commission split was, and what fiat was dispensed — currently rides on **mutable, unauthenticated metadata** (`Payment.extra`) plus a **stopgap that has the ATM hold the operator's own Nostr private key**. The latter means physical possession of the ATM = total compromise of the operator's LNbits account.
Two realworld incidents during dev surfaced this:
1. A stale `sintra` machine with placeholder npub `npub1111…` was created under a `test` user. A real cashin landed on it because routing is *purely by `wallet_id`*, not by signed identity. We deleted the stale row, but the lesson is structural: there is no endtoend identity proof.
2. The provisioning script (`/home/padreug/dev/shocknet/lamassu-next/deploy/nixos/provision-atm.sh`) writes `VITE_ATM_PRIVATE_KEY` straight into `/var/lib/bitspire/.env`. Today we set that to the operator's own privkey ("Option 1 stopgap"). Anyone with physical/root access to the ATM can sign as the operator on any relay.
Lamassu's old answer here was TLS cert pinning. We have a richer toolbox — Nostr — and have so far used roughly one knob (NIP44 encryption) of it.
---
## 1 · Glossary (juniordev friendly)
| Term | Plain English |
|---|---|
| **bitSpire ATM** | The cash machine. Cousin of the old Lamassu hardware. Identifies itself with a Nostr keypair (`npub`/`nsec`). |
| **LNbits** | The Lightning wallet server we selfhost. The ATM is a "client" of LNbits over Nostr. |
| **Operator** | The human/business that owns one or more ATMs. Has an LNbits user account. |
| **Super** | The LNbits instance admin. Takes a platform fee from each operator. |
| **LP (Liquidity Provider)** | A customer who deposits fiat into the ATM business; receives BTC prorata via DCA. |
| **npub / nsec** | Nostr public / private key, bech32encoded. `npub` is shareable; `nsec` is the secret. |
| **Relay** | A Nostr pub/sub server. Carries encrypted RPC events between ATM and LNbits. |
| **NIPXX** | Nostr Implementation Possibility — a numbered protocol extension spec at `~/dev/nostr-protocol/nips/`. |
| **kind21000** | The event kind bitSpire/LNbits use for encrypted RPC (set by lamassunext's nostrtransport). |
| **NIP44 v2** | Authenticated encryption for Nostr DMs/RPC (ChaCha20 + HMACSHA256, MAC verified before signature). |
| **Payment.extra** | A freeform JSON dict LNbits stores alongside a payment. **Mutable. Unsigned.** |
| **Preimage** | The 32byte secret revealed when a Lightning invoice is paid. Unforgeable proof of payment. |
| **Settlement** | One bitSpire cashin or cashout, landed as a `dca_settlements` row in our DB. |
---
## 2 · Today's pathway — what the bytes actually do
### 2.1 Cashout, end to end (the only flow currently wired)
```
┌────────────────────┐ kind-21000 NIP-44 v2 RPC over relay ┌──────────────────────┐
│ bitSpire ATM │ ───────────────────────────────────────────▶ │ LNbits │
│ signs with │ │ nostr-transport │
│ VITE_ATM_PRIVATE │ {method: "create_invoice", amount, memo} │ handler │
│ _KEY (currently │ │ (auto-creates an │
│ the OPERATOR's │ ◀─────────────────────────────────────────── │ Account from npub) │
│ nsec — stopgap) │ {payment_request: "lnbc...", payment_hash} │ │
└──────────┬─────────┘ └──────────┬───────────┘
│ │
│ Customer scans QR, pays with their wallet on the Lightning network │
│ │
▼ ▼
Customer wallet ──── BOLT11 invoice settles ──────────────────▶ LNbits Payment row
is_in=True, success=True
wallet_id=auto-created
Payment.extra={source:"bitspire",
net_sats, fee_sats,
machine_npub, ...}
register_invoice_listener fires
satmachineadmin/tasks.py:_handle_payment
┌─────────────────────────────────┴────────────────────────────┐
▼ ▼
get_active_machine_by_wallet_id(payment.wallet_id) parse_settlement(Payment.extra)
── routing decision lives HERE ── ── trust boundary lives HERE ──
(machine ↔ wallet is 1:1 in DB) (we trust Payment.extra wholesale)
│ │
└──────────────────┬──────────────────────────────────────────┘
create_settlement_idempotent
(UNIQUE on payment_hash)
asyncio.create_task(process_settlement)
┌───────────────────────────────────────┼───────────────────────────────────────┐
▼ ▼ ▼
_pay_super_fee _pay_operator_splits _pay_dca_distributions
(platform_fee_sats → (operator_fee_sats → (net_sats → LPs pro-rata,
super_fee_wallet_id) N legs per ruleset) capped at remaining_fiat * rate)
```
### 2.2 What signs *what* today
| Hop | Signed? | By whom? | Verified? |
|---|---|---|---|
| ATM → relay (kind21000 event) | Yes (NIP01 Schnorr sig) | ATM's keypair (= operator's keypair today) | Yes — relays drop unsigned events |
| RPC payload | Yes (NIP44 v2 MAC + outer sig) | Same key | Yes — handler verifies MAC before decrypt |
| LNbits payment ↔ ATM identity | **No** | — | **No** — the link is the autocreated Account's wallet_id, set at first contact |
| Payment.extra contents | **No** | — | **No** — anyone with the wallet admin key can mutate |
| Settlement row in our DB | No (DB row, not an event) | — | n/a — operator trusts their own DB |
| Lightning settlement | Yes (cryptographically, via preimage) | The HTLC chain | Yes — preimage hashes to `payment_hash` |
The Lightning settlement (the actual money) **is** cryptographically sound. Everything *attributing* that settlement to a particular machine, operator, fiat amount, and commission rate is not.
### 2.3 Routing decision today (the loadbearing line)
```python
# tasks.py:59
machine = await get_active_machine_by_wallet_id(payment.wallet_id)
```
That's it. One DB lookup. The `wallet_id` was minted by LNbits' nostrtransport when it autocreated an Account from the ATM's npub *on first contact*. From that moment on, "which machine?" is purely a join on `dca_machines.wallet_id → wallets.id`. If you can land a payment on that wallet — by any means — it counts as that machine's settlement.
### 2.4 The Option 1 stopgap (what's in `provision-atm.sh` today)
```bash
VITE_ATM_PRIVATE_KEY=$(openssl rand -hex 32)
# or, in practice: VITE_ATM_PRIVATE_KEY=<operator's own nsec>
```
The operator's Nostr private key — the one tied to their LNbits Account — is *physically present on the ATM filesystem* (`/var/lib/bitspire/.env`). Threat: cleaner steals the ATM, dumps the disk, signs `kind:1`/`kind:4`/`kind:21000` events impersonating the operator on every relay, draining their wallets via crafted RPC. There is no second factor, no scoping, no revocation.
---
## 3 · Threat model
Who might try to break this, and how:
| # | Adversary | Capability | What they want | Today's defence |
|---|---|---|---|---|
| T1 | Random Lightning user | Pay any LNbits invoice they have a bolt11 for | Free fiat / cashout without authorising | Bolt11 is singleuse; preimage settles only once |
| T2 | Curious LP | Has wallet admin key for their own LP wallet | See other LPs' balances | Operatorscoped CRUD; `_machine_owned_by` checks |
| T3 | Rogue operator | Owns their LNbits user; controls their own machines | Forge settlements to inflate volume / dodge super fee | **None** — operator can mutate Payment.extra |
| T4 | Compromised relay operator | Sees encrypted kind21000 events | Censor, replay, reorder | NIP44 protects content; **no replay window**; relay can drop but not forge |
| T5 | Thief with physical access to ATM | Can dump `/var/lib/bitspire/.env`, root the box | Drain operator wallet, sign as operator on Nostr | **None** — operator's nsec is on disk |
| T6 | Insider at the LNbits host | Has DB access to LNbits | Mutate Payment.extra retroactively | **None**`extra` is plain JSON, no audit log |
| T7 | Attacker who knows operator's npub | Public knowledge | Spam fake kind21000 from a key they generated | Autoaccountfromnpub means they get a *different* wallet — but nothing stops them creating noise |
| T8 | Insider at the super (LNbits admin) | Owns the LNbits node | Skim more than super_fee_pct | Operators must trust their host (this is fundamental — pick a host you trust, or selfhost) |
| T9 | Customer at the ATM | Walks up, scans QR | Pay an invoice attributed to a *different* operator's machine | wallet_id routing prevents crossoperator landing **only if** the invoice was generated for that wallet — confirmed by the stalesintra incident: routing is walletlevel, not signed |
T3, T5, T6 are the ones that keep the hardware honest. T3 + T6 are *the* reason `platform_fee_sats` and `operator_fee_sats` are stored as **absolute BIGINTs** (not derived from a mutable pct) — that defends the audit trail, but doesn't defend the initial write.
---
## 4 · Audit findings — current state inventory
Pulled from the two recent codelevel audits of `~/dev/shared/extensions/satmachineadmin` (operatorscoping inventory) and `~/dev/lnbits/nostr-transport` (transport primitives).
### 4.1 What's already strong
- **Operator scoping is consistent.** All 33 routes filter by `current_user.id`; `_machine_owned_by` and `_client_owned_by` return 404 (not 403) on crossoperator probes so attackers can't enumerate other operators' resources.
- **Settlement idempotency.** `dca_settlements.payment_hash` is `UNIQUE`. A replayed Nostr event / dispatcher doublefire cannot cause a double payout.
- **Optimisticlock claim pattern.** `claim_settlement_for_processing` prevents two concurrent `process_settlement` calls from racing the same row.
- **Settlement legs are typed and tagged.** `dca_payments.leg_type` ∈ {`dca`, `super_fee`, `commission_split`, `settlement`}; `Payment.tag = "satmachine:{npub}"` flows through LNbits' native payment filter UI.
- **Absolutesats fee storage.** `platform_fee_sats` and `operator_fee_sats` are BIGINT columns, not derived from a mutable pct. This is the "Stripe Connect application_fee_amount" pattern and makes audits possible even if the commission rate later changes.
- **Appendonly `notes` on settlements.** Partialdispense recomputes prepend a timestamped memo; operator notes are timestamped + authortagged. Tamperevident at the row level.
- **NIP44 v2 is correctly used in nostrtransport.** MAC verified before decrypt, outer Schnorr sig verified before MAC. (See `~/dev/lnbits/nostr-transport/lnbits/core/services/nostr_transport/*`.)
### 4.2 What's weak — confirmed gaps
| ID | Gap | Where | Why it matters |
|---|---|---|---|
| **G1** | **Routing is by `wallet_id` only.** The ATM's signed identity is never reverified at settlement land time. | `tasks.py:59` `get_active_machine_by_wallet_id(payment.wallet_id)` | Once a wallet exists, anything paying it counts. No defence against T3, T7. |
| **G2** | **Payment.extra is unauthenticated.** We read `source`, `net_sats`, `fee_sats`, `machine_npub`, `exchange_rate` directly. Anyone with the wallet's admin key can mutate it. | `bitspire.py:103-135` | T3 / T6: forge favourable splits, dodge super fee, dispute history. |
| **G3** | **ATM private key sits on disk as the operator's nsec.** | `provision-atm.sh:99` writes `VITE_ATM_PRIVATE_KEY` | T5: physical compromise = total operator compromise on every relay. |
| **G4** | **No replay window on RPC events.** | nostrtransport handler accepts events up to 10min old | T4: a relay can stash and replay a "create invoice" RPC. NIP44 doesn't prevent replay; only NIP40 expiration tags + nonce tracking do. |
| **G5** | **`sender_pubkey` is not persisted onto `Payment.extra` by the dispatcher.** | LNbits `nostr_transport/auth.py:148-183` | We can't tell, after the fact, which Nostr identity actually triggered a payment. |
| **G6** | **`Account.prvkey` is nullable but in practice populated serverside.** | LNbits Account schema | An autocreated account holds a key it generated. Anyone with DB access can read it. (T6.) |
| **G7** | **No signedrequest primitive.** Nothing in nostrtransport requires a separate, scoped attestation on a payment — just the outer event sig. | nostrtransport | We can't bind "this is a real bitSpire settlement for machine X" cryptographically. |
| **G8** | **No rate limiting at the relay layer.** | — | T7 can spam our autoaccountfromnpub endpoint. |
| **G9** | **No ACL on which npubs may autocreate accounts.** | nostrtransport | First contact wins. Combined with G3 + a realworld incident, this lets a stale/test machine accept real funds. |
| **G10** | **Cashin path is not wired.** `_handle_payment` filters `is_in=True only`; cashin is *outbound* (LNbits pays an LNURLwithdraw the customer scanned at the ATM). | `tasks.py:57` | Today we'd never know a cashin happened. (Out of scope for this doc but flagged.) |
### 4.3 What's *not* protected by encryption (clarification)
NIP44 v2 makes the *transport* confidential and integritychecked. It does **not**:
- Prove the sender is authorised to act for any party other than themselves (G1, G3).
- Prevent replay of an old, legitimatelysigned event (G4).
- Bind a Lightning settlement to a particular kind21000 RPC after the fact (G7).
- Audit who mutated `Payment.extra` after settlement landed (G2, G6).
Treat NIP44 as TLS, not as authn/authz. We need additional NIPs for the rest.
---
## 5 · Design proposal — layered defence using what Nostr already offers
The trust model we want, in one sentence:
> **A settlement is genuine if (a) the operator delegated the ATM to act on their behalf, with a scoped, timebound, revocable token, and (b) the ATM published a signed attestation referencing the Lightning preimage, and (c) the relay/Payment.extra metadata is treated as a hint, never as truth.**
That's four primitives, each already specified in Nostr:
| Layer | NIP | What it gives us |
|---|---|---|
| Identity & delegation | **NIP26** (`~/dev/nostr-protocol/nips/26.md`) | Operator never gives their nsec to the ATM. Issues a kindbound, timebound `delegation` tag instead. |
| Settlement attestation | **NIP57style receipt** (`nips/57.md`) | ATM publishes a signed receipt event linking machine npub + Lightning preimage + amount/fiat. Receipt is the ground truth, not Payment.extra. |
| Replay protection | **NIP40** (`nips/40.md`) | Every RPC carries `["expiration", now+5m]`. Relays drop expired events; handler refuses them. |
| Permachine config | **NIP78** (`nips/78.md`) | `kind:30078` with `d="bitspire-config:<machine_id>"` is the operatorsigned source of truth for permachine policy (max withdrawal, allowed relays, fee schedule). ATM fetches on boot; LNbits crosschecks. |
| Future: bunker | **NIP46** (`nips/46.md`) | Operator's nsec stays on a phone (Amber) or HSM. ATM gets an ephemeral session key + remote signer. Endstate goal. |
What we **do not** adopt and why (from the NIP survey):
- **NIP42 relay auth.** Authenticates the connection to the relay; doesn't authorise the RPC payload. Useful for relay hygiene, but a red herring for our trust boundary.
- **NIP59 gift wrap.** Hides metadata from relays but breaks the very auditability we want from NIP57style receipts. Only useful if anonymity matters more than audit.
- **NIP32 labels.** Soft moderation signal, not enforcement. Fine as observability; useless as an access gate.
### 5.1 The new pathway (endstate)
```
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ OPERATOR (cold key on phone / Amber / Bunker — never on the ATM) │
│ │
│ 1. Generates delegation token (NIP-26): │
│ conditions = "kind=21000&created_at>T0&created_at<T0+90d" │
│ token = sign(operator_nsec, conditions || atm_pubkey) │
│ │
│ 2. Publishes per-machine config (NIP-78): │
│ kind:30078, d="bitspire-config:<machine_id>", content=signed JSON │
│ { allowed_relays, max_withdrawal_fiat, allowed_kinds, fee_schedule, ... } │
└──────────────────────┬──────────────────────────────────────────────────────────────────────┘
│ seed-URL pairing (one-shot, out-of-band)
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ bitSpire ATM (holds its OWN ephemeral keypair — not the operator's) │
│ │
│ Boot: │
│ • Loads delegation token from sealed config │
│ • Fetches NIP-78 per-machine config; verifies operator's sig │
│ │
│ Each RPC (e.g. create_invoice): │
│ • Builds kind-21000 event signed with ATM's OWN key │
│ • Includes delegation tag (NIP-26) proving operator authorised this kind, this window │
│ • Includes ["expiration", now+5min] (NIP-40) │
│ • NIP-44 v2 encrypts content to LNbits server pubkey │
└──────────────────────┬──────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ LNbits nostr-transport handler │
│ │
│ On inbound kind-21000: │
│ • Verify outer Schnorr sig (NIP-01) │
│ • Verify NIP-44 MAC, decrypt │
│ • Check ["expiration"]: reject if past (NIP-40) │
│ • Check delegation tag (NIP-26): │
│ - sig over conditions valid under claimed operator pubkey? │
│ - conditions match this event's kind + created_at? │
│ - operator pubkey ∈ LNbits user roster? │
│ • Check NIP-78 config: is ATM pubkey listed in operator's fleet for this machine? │
│ • Persist sender_pubkey + operator_pubkey on Payment.extra (signed by LNbits │
│ server key when the row is written, so it's tamper-evident in our DB) │
│ • Generate invoice │
└──────────────────────┬──────────────────────────────────────────────────────────────────────┘
│ Lightning settles
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ Settlement attestation (NIP-57-style receipt — see kind-rotation note in §6 / S3 row) │
│ │
│ LNbits publishes (signed by the LNbits server key): │
│ { kind: 9735, │
│ tags: [ │
│ ["e", <kind-21000 RPC event id>], // links back to the request │
│ ["p", <operator_pubkey>], │
│ ["P", <atm_pubkey>], │
│ ["bolt11", <invoice>], │
│ ["preimage", <32-byte hex>], │
│ ["amount", "<msat>"], │
│ ["fiat", "EUR:20.00"] │
│ ], │
│ content: "" } │
│ │
│ Operator audits: fetch all kind:9735 with #p=<my npub>; verify preimage hashes to │
│ payment_hash on every dca_settlements row. Mismatch = forged settlement. │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
```
### 5.2 Why each layer matters (juniordev framing)
- **Delegation (NIP26) closes G3.** The ATM doesn't *have* the operator's secret. It has a permission slip. Steal the ATM and you steal a permission slip that (a) only works for kind21000, (b) expires in 90 days, (c) you can't use to sign on `kind:1` or DM the operator's contacts, and (d) the operator can shorten by issuing a new one with an earlier cutoff. This is the same shape as an SSH certificate vs. an SSH key.
- **Receipts (NIP57 pattern) close G2 + G7.** The ground truth becomes a signed event referencing the preimage. Payment.extra remains as a hint (fast UI rendering), but disputes resolve against the receipt. If LNbits' DB is tampered with, the receipt on the relay is still there.
- **Expiration (NIP40) closes G4.** A 5minute window means a captured RPC can't be replayed at 3 a.m. when no human is watching the ATM.
- **NIP78 closes G1 + G9.** The operator's signed config says "machine_id 42 has fleet member npub_abc and may withdraw up to EUR 500." The handler crosschecks. Stale `npub1111…` rows can't accept real settlements because they're not in any operator's fleet.
- **NIP46 bunker (future) closes G5 + G6 properly.** The operator's nsec never touches LNbits' disk or the ATM's disk. It lives on the operator's phone or HSM and signs over an authenticated channel.
### 5.3 What we keep from today
- Absolutesats fee storage (already auditgrade).
- Operator scoping + 404not403 ownership pattern.
- Settlement idempotency on `payment_hash`.
- Optimisticlock claim for distribution.
- `dca_payments.leg_type` discriminator + LNbits `Payment.tag` for native filter UI.
None of those need to change. The new layers slot in *above* them.
---
## 6 · Phased roadmap
| Phase | Scope | Closes | Effort | Blocker |
|---|---|---|---|---|
| **S0 — SeedURL pairing + ATM keypair separation** | Provisioning script generates a fresh `nsec` for the ATM (already does — we just stop overwriting it with the operator's). Operator pastes a oneshot QR/seed URL containing `{atm_npub, operator_npub, relay_list, signed_delegation_token}` at ATM first boot. | G3 (most of it), G9 | 1 week | None — purely on our side. Use existing NIP26 spec. |
| **S1 — NIP40 expiration on all kind21000** | Every RPC carries `["expiration", now+5min]`. Handler refuses pastexpiration. ATM clock check on boot (warn if drift > 60s). | G4 | 12 days | Relay must support NIP40 (most do). |
| **S2 — NIP26 delegation enforcement in nostrtransport** | Handler parses `delegation` tag, validates sig over conditions, checks conditions match the event, looks up operator pubkey in roster. Reject events without a valid delegation. | G3 (rest), G7 (partially) | 12 weeks | LNbits PR upstream (or vendored fork on `aiolabs/lnbits` branch `nostr-transport-nip26`). |
| **S3 — NIP57style settlement receipts** | After LNbits internal payment legs complete, publish a signed receipt event per settlement (and per leg if we want leglevel audit). ATM subscribes; operator dashboard renders receipts sidebyside with `dca_settlements`. | G2, G7 | 12 weeks | **Kind allocation — DO NOT USE `kind:21001`.** That kind is claimed by CLINK (Offers) — collision caught during the 20260602 CLINK primer review. Rotation off 21001 is tracked at `aiolabs/satmachineadmin#44`; target is the aiolabs reserved band **`2200022099`** per the workspace rule in `~/dev/CLAUDE.md` (§ "Nostr kind allocations — avoid the CLINK band"). The earlier 21001 lock across `aiolabs/lnbits#22`, `aiolabs/satmachineadmin#17`, and the satmachine ATM is **SUPERSEDED** — pick the new kind before any of those land. Reusing `kind:9735` (zap receipt) is also off the table: NIP57 semantics don't apply to bitSpire cashout settlements. |
| **S4 — NIP78 permachine config + fleet roster** | Operator publishes `kind:30078` config + `kind:30000` fleet list. Handler crosschecks ATM npub ∈ fleet; reads maxwithdraw/fee policy from config. | G1, G9 | 1 week | Define config schema; backwardscompat path for preNIP78 machines. |
| **S5 — `sender_pubkey` persistence + signed metadata in Payment.extra** | When the dispatcher writes a Payment row, it stamps `Payment.extra.sender_pubkey`, `delegation_root`, and an HMAC over the key fields keyed by the LNbits server's own secret. Mutation postwrite breaks the HMAC. | G2 (DBside), G5, G6 | 35 days | LNbits PR — fairly localised. |
| **S6 — Rate limiting + rostergated autoaccount** | Autoaccountfromnpub only fires if the npub appears in some operator's NIP78 fleet OR if an explicit "open enrollment" flag is set. Relay/handlerlevel rate limit per pubkey. | G8, G9 | 1 week | LNbits PR. |
| **S7 — NIP46 bunker option** | Operator can pair satmachineadmin with a Bunker (Amber, Nunchuk Custody, etc.). Operator's nsec leaves LNbits' DB; LNbits stores only the bunker connection. | G6, partial G5 | 46 weeks | Largest. Defer until S0S5 land. |
| **S8 — Cashin path** | Wire `is_out=True` cashin handling: LNURLwithdraw with expiration matching the kind21000 invoice TTL, attestation receipt on settle, refund queue for stale links. | G10 | 2 weeks | Out of scope for this security doc but tracked here for completeness. |
Recommended sequencing for the *next sprint*: **S0 + S1 + S5**. They give us the biggest security delta with no upstream LNbits dependency for S0/S1 and a small, wellscoped LNbits patch for S5. S2/S3/S4 are the proper Nostrnative layer and should land in the sprint after.
---
## 7 · Operator & customer trust narrative
What we can say honestly to an operator after S0S5:
> "Your private key never goes on the ATM. The ATM has its own identity. You issue a permission slip — scoped to one kind of message, valid for 90 days, revocable from your phone. Every settlement publishes a public, signed receipt that anyone can verify against the Lightning preimage. If our database is ever tampered with, the receipts on the public relay are still there and still match. The platform fee and your fee are stored as absolute satoshi amounts — even if the rate changes tomorrow, last quarter's audit is exact."
And to a customer at the ATM:
> "This ATM identifies itself by a public key printed on the side of the unit. The receipt event the network publishes after your transaction will reference that same key and the Lightning payment preimage — two pieces of cryptographic evidence that no one can forge after the fact."
Compare to the Lamassu era: "the ATM has a TLS cert; if its fingerprint matches what the operator pinned, the connection is trustworthy." Same instinct, narrower surface. Nostr lets us extend that to *every settlement* without reinventing the wheel.
---
## 8 · Auditfriendliness checklist (opensource readiness)
Things a future auditor — or our opensource reviewers — will look for. Where we already pass, marked ✓; where we plan to pass after this work, marked →.
| Check | Status | Where |
|---|---|---|
| All moneymoving code paths have idempotency keys | ✓ | `dca_settlements.payment_hash UNIQUE` |
| All operator data scoped at the API boundary | ✓ | `_machine_owned_by` / `_client_owned_by` in `views_api.py` |
| No 403/404 enumeration oracle | ✓ | 404 on crossoperator probes |
| Fee storage is absolute (not derived from mutable %) | ✓ | `platform_fee_sats`, `operator_fee_sats` BIGINT |
| Audit trail is appendonly on settlements | ✓ | `dca_settlements.notes` prepended, never edited |
| Partialdispense recompute preserves original ratio | ✓ | `apply_partial_dispense_and_redistribute` (H6 fix) |
| Concurrent settlement processing is racefree | ✓ | `claim_settlement_for_processing` |
| Every settlement has a signed, public attestation | → | S3 (NIP57 receipts) |
| Operator's private key is not present on the ATM | → | S0 + S2 (NIP26 delegation) |
| RPC events cannot be replayed > 5 min later | → | S1 (NIP40 expiration) |
| Payment.extra mutation is detectable | → | S5 (serversigned HMAC) |
| Stale machine rows cannot accept real funds | → | S4 (NIP78 fleet roster crosscheck) |
| Autoaccountfromnpub is gated | → | S6 (roster + rate limit) |
| Key custody can be moved off LNbits' DB | → | S7 (NIP46 bunker) |
The state we want the opensource release to be in for v2.0 final: all ✓.
---
## 9 · Critical files (current code) and reference points
For an auditor or new contributor doing a walkthrough:
| File | Role | Note |
|---|---|---|
| `~/dev/shared/extensions/satmachineadmin/tasks.py` | LNbits invoice listener. Entry point for all settlements today. | `_handle_payment:56-95` — loadbearing routing. |
| `~/dev/shared/extensions/satmachineadmin/bitspire.py` | Parses Payment.extra. The trust boundary. | `parse_settlement:68-92` — happy vs fallback path. |
| `~/dev/shared/extensions/satmachineadmin/distribution.py` | Threeleg distribution chain. | `process_settlement` — uses claim pattern. |
| `~/dev/shared/extensions/satmachineadmin/crud.py` | Operatorscoped DB layer. | `claim_settlement_for_processing`, `_machine_owned_by`. |
| `~/dev/shared/extensions/satmachineadmin/views_api.py` | 33 routes, all `check_user_exists` except superconfig PUT. | `_assert_wallet_owned_by` is the walletIDOR fix. |
| `~/dev/shared/extensions/satmachineadmin/migrations.py` | Schema. | `dca_settlements` is the audit row; `dca_payments` is the leg row. |
| `~/dev/shocknet/lamassu-next/deploy/nixos/provision-atm.sh` | Where keys land on the ATM today. | `:81-99``VITE_ATM_PRIVATE_KEY` and the Option1 stopgap. |
| `~/dev/lnbits/nostr-transport/lnbits/core/services/nostr_transport/` | LNbits transport handler (upstream we depend on). | NIP44 v2 crypto here; G5/G6/G7 fixes will live here. |
| `~/dev/nostr-protocol/nips/26.md` | Delegation. | Source for S2. |
| `~/dev/nostr-protocol/nips/40.md` | Expiration. | Source for S1. |
| `~/dev/nostr-protocol/nips/44.md` | Authenticated encryption v2. | Already in use; spec reference for review. |
| `~/dev/nostr-protocol/nips/46.md` | Bunker / Nostr Connect. | Source for S7. |
| `~/dev/nostr-protocol/nips/57.md` | Lightning zaps & signed receipts. | Pattern source for S3. |
| `~/dev/nostr-protocol/nips/78.md` | Appspecific replaceable events. | Source for S4. |
Existing Forgejo issues this report supersedes/consolidates: `aiolabs/satmachineadmin#9` (v2 epic), `#11` (security audit findings), `#12` (ATM pairing + bunker deepdive), `aiolabs/lamassu-next#44` (Payment.extra split). This document is the design that closes the securityrelevant subset of those.
---
## 10 · Verification
How we'd test the proposed design endtoend, once S0S5 land:
1. **Negative test for G3:** Provision an ATM with seedURL pairing. Confirm `/var/lib/bitspire/.env` contains only the ATM's own nsec and a delegation token. Attempt to sign a nonkind21000 event with the ATM's key + delegation → handler rejects.
2. **Negative test for G4:** Record a kind21000 RPC. Wait 6 minutes. Replay it on the relay → handler refuses (expired).
3. **Negative test for G1/G9:** Create a stale machine row with placeholder npub. Send a real payment to its wallet → handler rejects because the npub isn't in the operator's NIP78 fleet list.
4. **Positive test for S3:** Run a full cashout. Confirm a `kind:9735`shaped receipt is published referencing the kind21000 RPC event id + preimage. Verify the preimage hashes to the `payment_hash` on the `dca_settlements` row.
5. **Positive test for S5:** After settlement, mutate `Payment.extra` directly in the LNbits DB. Confirm the HMAC check fails on the next read; operator dashboard flags the row as "tampered."
6. **Revocation test for S2:** Operator issues a new delegation with `created_at<` cutoff set to "now". ATM's next RPC (using old delegation) is rejected. ATM repairs with the new token; works again.
7. **Multioperator isolation:** Two operators on the same LNbits instance, each with one ATM. Confirm Operator A's NIP78 fleet doesn't list Operator B's ATM npub; LNbits crosschecks correctly.
8. **Endtoend smoke:** Real bitSpire on `~/dev/shocknet/lamassu-next/` (dev branch, `bun dev`) against the local LNbits stack (`~/dev/local/docker/regtest/docker-compose.dev.yml`, `LNBITS_SRC=~/dev/lnbits/nostr-transport`). One cashout → settlement lands → receipt published → operator dashboard reconciles all three artefacts.
---
## 11 · After this plan exits
Once approved:
1. The PDF for printing will be generated postplanmode (requires shell exec). Recommended path: render the markdown via `pandoc` to `~/dev/shared/extensions/satmachineadmin/docs/security-pathway-v1.pdf`; the markdown source will live at `~/dev/shared/extensions/satmachineadmin/docs/security-pathway-v1.md` so future contributors edit it inrepo.
2. Open Forgejo epics on `aiolabs/satmachineadmin` linking back to existing `#9/#11/#12` and adding a new one for "Security pathway hardening (S0S7)."
3. Open a tracking issue on `aiolabs/lnbits` against the `nostr-transport` branch for the LNbitsside primitives (S2, S5, S6).
4. Sequence sprint: **S0 + S1 + S5 first** (highest ratio of security delta to upstream coupling). S2/S3/S4 in the following sprint.

Binary file not shown.

View file

@ -1,114 +0,0 @@
@page {
margin: 14mm 12mm 14mm 12mm;
}
html {
font-size: 10.5pt;
}
body {
font-family: "DejaVu Sans", "Helvetica", sans-serif;
line-height: 1.45;
color: #1a1a1a;
}
h1 { font-size: 18pt; margin-top: 1.4em; }
h2 { font-size: 15pt; margin-top: 1.2em; border-bottom: 1px solid #ccc; padding-bottom: 0.2em; }
h3 { font-size: 12.5pt; margin-top: 1em; }
h4 { font-size: 11pt; }
/* Code blocks the big offender. ASCII diagrams are ~100 chars wide;
shrink hard and don't allow horizontal overflow. */
pre {
font-family: "DejaVu Sans Mono", monospace;
font-size: 6.8pt;
line-height: 1.15;
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 4px;
padding: 0.6em 0.7em;
white-space: pre;
overflow: hidden;
page-break-inside: avoid;
}
pre code {
white-space: pre;
word-wrap: normal;
background: transparent;
padding: 0;
font-size: inherit;
}
/* Inline code */
code {
font-family: "DejaVu Sans Mono", monospace;
font-size: 9pt;
background: #f0f2f5;
padding: 0.05em 0.3em;
border-radius: 3px;
word-break: break-word;
}
/* Tables — keep within page width by fixed layout + wrapping cells. */
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 8.5pt;
margin: 0.8em 0;
page-break-inside: avoid;
}
th, td {
border: 1px solid #c0c6cf;
padding: 4px 6px;
vertical-align: top;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: normal;
hyphens: auto;
}
th {
background: #eef2f6;
text-align: left;
font-weight: 600;
}
tr:nth-child(even) td {
background: #fafbfc;
}
/* Make code inside table cells smaller still */
td code, th code {
font-size: 7.8pt;
background: transparent;
padding: 0;
}
/* Blockquotes for the trust narrative pull-quotes */
blockquote {
border-left: 4px solid #888;
margin: 0.8em 0;
padding: 0.3em 0.9em;
color: #444;
background: #f6f8fa;
font-size: 10pt;
}
hr {
border: 0;
border-top: 1px solid #c0c6cf;
margin: 1.4em 0;
}
a { color: #0858a8; text-decoration: none; }
ul, ol { padding-left: 1.4em; }
li { margin: 0.15em 0; }
/* TOC styling */
#TOC ul { list-style: none; padding-left: 1em; }
#TOC > ul { padding-left: 0; }
#TOC a { color: #1a1a1a; }

View file

@ -1,151 +0,0 @@
"""
Fee-config Nostr transport operator ATM kind-30078 publish.
Layer 2 of the operator-configurable fee architecture
(aiolabs/satmachineadmin#37 parent, #39 this layer). Pairs with the
bitspire consumer at `aiolabs/lamassu-next#57`.
Wire format locked at coord-log §2026-06-01T14:25Z:
kind = 30078 (NIP-78, replaceable)
tags = [
["d", "bitspire-fees:<atm_pubkey_hex>"],
["p", "<atm_pubkey_hex>"],
]
content = NIP-44 v2 encrypted JSON of FeeConfigPayload.to_wire_dict()
pubkey = operator pubkey
sig = operator signature
Producer-side invariants (enforced via FeeConfigPayload validators):
- cash_*_fee_fraction 0.15 (cap, mirrored on bitspire consumer)
- |total - components sum| < 1e-6 (consistency assert)
- schema_version integer 1
Soft-fail discipline (matches `cassette_transport.publish_to_atm`):
relay/signer/bunker hiccups log + return None rather than raising,
so a fee-config trigger from a CRUD endpoint can't break the
underlying machine create/update on a transient transport failure.
Hard-raises on configuration errors (cap exceeded, operator has no
pubkey) since those indicate a bug, not a transient.
"""
from __future__ import annotations
from loguru import logger
from .models import FeeConfigPayload, FeePayloadComponents, Machine, SuperConfig
from .nostr_publish import (
NostrPublishError,
OperatorIdentityMissing,
RelayUnavailable,
SignerUnavailable,
publish_encrypted_kind_30078,
)
_D_TAG_FEES_PREFIX = "bitspire-fees:"
def _atm_hex_pubkey(machine: Machine) -> str:
"""Canonicalise machine.machine_npub to lowercase hex. Used for both
the d-tag suffix and the NIP-44 v2 recipient pubkey. Same shape as
cassette_transport's local helper — kept module-local since it's a
one-liner over `normalize_public_key` and inlining would invert the
abstraction direction (transport-module-knows-about-Machine is
correct; nostr_publish doesn't know about Machine)."""
from lnbits.utils.nostr import normalize_public_key
return normalize_public_key(machine.machine_npub).lower()
def _fees_d_tag(atm_pubkey_hex: str) -> str:
return f"{_D_TAG_FEES_PREFIX}{atm_pubkey_hex}"
def build_fee_payload(
super_config: SuperConfig, machine: Machine
) -> FeeConfigPayload:
"""Compose a FeeConfigPayload from current super-config + per-machine
fractions. FeeConfigPayload's validators enforce the cap +
consistency invariants this function constructs and validates
in one step; a returned payload is wire-shippable.
Raises ValueError (via Pydantic) if any directional total exceeds
the 0.15 cap. That's a hard error because the upstream API layer
(views_api._assert_machine_fee_cap_safe + _assert_super_config_cap_safe)
should have rejected the create/update that produced this state.
If we reach here with a cap-violating state, something bypassed the
API guards and we'd rather refuse-to-publish than ship a malformed
event.
"""
super_in = float(super_config.super_cash_in_fee_fraction)
super_out = float(super_config.super_cash_out_fee_fraction)
op_in = float(machine.operator_cash_in_fee_fraction)
op_out = float(machine.operator_cash_out_fee_fraction)
return FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=round(super_in + op_in, 4),
cash_out_fee_fraction=round(super_out + op_out, 4),
components=FeePayloadComponents(
super_cash_in=super_in,
super_cash_out=super_out,
operator_cash_in=op_in,
operator_cash_out=op_out,
),
)
async def publish_fee_config(
machine: Machine,
super_config: SuperConfig,
operator_user_id: str,
) -> dict | None:
"""Build, validate, encrypt, sign, publish the fee-config event for
`machine` to the ATM at `machine.machine_npub`.
Returns the signed event dict on success (caller may log event.id
for audit). Returns None on soft-fail (transport-layer errors:
relay unreachable, signer offline, bunker timeout) these are
transient and the caller's underlying CRUD operation should succeed
independent of publish success. Logs WARNING on soft-fail.
Raises on hard configuration error:
- OperatorIdentityMissing operator has no Nostr pubkey on file
(caller's API layer should refuse the operation before we get
here, but we propagate as HTTP 400 if it slips through)
- ValueError (from FeeConfigPayload validators) cap violation
or sum/components mismatch, indicates an API-guard bypass
"""
payload = build_fee_payload(super_config, machine)
atm_pubkey_hex = _atm_hex_pubkey(machine)
try:
signed = await publish_encrypted_kind_30078(
operator_user_id=operator_user_id,
recipient_pubkey_hex=atm_pubkey_hex,
d_tag=_fees_d_tag(atm_pubkey_hex),
payload=payload.to_wire_dict(),
log_context=(
f"fee config (machine={machine.id}, "
f"cash_in={payload.cash_in_fee_fraction}, "
f"cash_out={payload.cash_out_fee_fraction})"
),
)
except (SignerUnavailable, RelayUnavailable) as exc:
logger.warning(
f"satmachineadmin: fee-config publish soft-fail for machine "
f"{machine.id} ({machine.name or machine.machine_npub[:12]}): "
f"{type(exc).__name__}: {exc}. Underlying CRUD operation "
"succeeded; operator can re-trigger publish via the next "
"machine edit or super-config save."
)
return None
except NostrPublishError as exc:
# Truly unexpected transport error — log + soft-fail. We still
# don't break the caller's CRUD path; a future publish attempt
# (next machine edit / next super edit) will retry.
logger.warning(
f"satmachineadmin: fee-config publish unexpected transport "
f"error for machine {machine.id}: {type(exc).__name__}: {exc}"
)
return None
return signed

View file

@ -1,737 +1,172 @@
# Satoshi Machine v2 — single squashed migration.
#
# History note: m001-m004 were the legacy Lamassu schema; m005-m007 staged
# the v2 redesign (initial schema → payment_hash idempotency fix → notes
# column → concurrency claim + wallet UNIQUE index). Collapsed back into a
# single m001 during the v2-bitspire development branch since no production
# data was affected and the staged sequence had a SQLite CREATE-INDEX
# syntax bug. The pre-collapse history is preserved in git on commits
# prior to the collapse.
#
# Installs upgrading from the v1 Lamassu schema must uninstall + reinstall
# the extension to reset the LNbits dbversions tracker. The DROP TABLE
# IF EXISTS at the top of m001 also cleans the v1 tables if they happen
# to survive a partial wipe.
# DCA Admin Extension Database Migrations
# Creates all necessary tables for Dollar Cost Averaging administration
# with Lamassu ATM integration
async def m001_satmachine_v2_initial(db):
"""Single-shot v2 schema for the Satoshi Machine admin extension.
Drops every legacy Lamassu table (lamassu_config, lamassu_transactions,
plus the singular-config v1 dca_clients/deposits/payments) and creates
the v2 multi-tenant schema:
- super_config: singleton platform-fee config (super only)
- dca_machines: per-operator multi-machine registry by npub
- dca_clients: LP registrations scoped per (machine, user)
- dca_deposits: fiat the operator records against an LP
- dca_settlements: bitSpire kind-21000 idempotency table
- dca_commission_splits: operator's remainder-distribution rules
- dca_payments: leg-typed distribution audit trail
- dca_telemetry: sparse kind-30078/30079 snapshots per machine
CRITICAL design choices (preserved from the staged migrations):
* payment_hash is the UNIQUE idempotency key on dca_settlements
(LN payment_hash is globally unique and always present at the
Payment layer fix for the original "use bitspire_event_id"
false start).
* platform_fee_sats + operator_fee_sats stored as absolute BIGINT
(not derived percentages). The contract is locked at landing time;
post-v1 customer-discount engine writes here without a migration.
* dca_machines.wallet_id UNIQUE defence-in-depth against the
wallet-IDOR funds-theft vector (the API layer also checks
wallet ownership; the index is the second line of defence).
* processing_claim on dca_settlements optimistic-lock token for
concurrent process_settlement invocations.
* notes on dca_settlements append-only audit memo for partial-
dispense recompute + operator-authored notes (see
aiolabs/satmachineadmin#10 for the future structured audit table).
async def m001_initial_dca_schema(db):
"""
# 1. Drop legacy v1 tables. IF EXISTS handles both fresh-install
# paths (no-op) and migration from a v1 schema (cleans up).
for table in (
"lamassu_transactions",
"lamassu_config",
"dca_payments",
"dca_deposits",
"dca_clients",
):
await db.execute(f"DROP TABLE IF EXISTS satoshimachine.{table}")
# 2. super_config — singleton (id='default') with platform-fee config.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.super_config (
id TEXT PRIMARY KEY,
super_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
super_fee_wallet_id TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
existing = await db.fetchone(
"SELECT id FROM satoshimachine.super_config WHERE id = 'default'"
)
if not existing:
Create complete DCA admin schema from scratch.
"""
# DCA Clients table
await db.execute(
"INSERT INTO satoshimachine.super_config (id, super_fee_fraction) "
"VALUES ('default', 0.0000)"
)
# 3. dca_machines — one row per bitSpire ATM, owned by exactly one
# operator. wallet_id UNIQUE prevents the IDOR funds-theft vector.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_machines (
id TEXT PRIMARY KEY,
operator_user_id TEXT NOT NULL,
machine_npub TEXT NOT NULL UNIQUE,
wallet_id TEXT NOT NULL,
name TEXT,
location TEXT,
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS dca_machines_operator_idx "
"ON dca_machines (operator_user_id)"
)
await db.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS dca_machines_wallet_id_uq "
"ON dca_machines (wallet_id)"
)
# 4. dca_clients — per-(machine, LP) registrations. Pure machine
# enrolment record: no wallet, no mode, no autoforward — those are
# LP-controlled at the user level via dca_lp (see below). Operator
# just decides "this LP is enrolled at my machine"; everything
# delivery-related is the LP's own preference.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_clients (
id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL,
f"""
CREATE TABLE satoshimachine.dca_clients (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
wallet_id TEXT NOT NULL,
username TEXT,
dca_mode TEXT NOT NULL DEFAULT 'flow',
fixed_mode_daily_limit INTEGER,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
await db.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS dca_clients_machine_user_uq "
"ON dca_clients (machine_id, user_id)"
)
await db.execute(
"CREATE INDEX IF NOT EXISTS dca_clients_user_idx ON dca_clients (user_id)"
"""
)
# 4a. dca_lp — LP-level (per-user) DCA preferences. ONE row per LNbits
# user that has onboarded as a Liquidity Provider, regardless of
# how many machines they're enrolled at. Owned by the LP (writes
# come from the satmachineclient extension under the LP's session),
# read by satmachineadmin during distribution to resolve "where do
# DCA payouts for this LP go?"
#
# Gating: satmachineadmin refuses to create deposits for an LP who
# doesn't have a dca_lp row yet. The LP must onboard via
# satmachineclient first (which auto-creates the row with their
# default LNbits wallet on first dashboard visit). Forces every
# LP through a "yes, I am here and this is where I want my sats"
# gesture before any fiat starts accumulating against them.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_lp (
user_id TEXT PRIMARY KEY,
dca_wallet_id TEXT NOT NULL,
default_dca_mode TEXT NOT NULL DEFAULT 'flow',
fixed_mode_daily_limit DECIMAL(10,2),
autoforward_ln_address TEXT,
autoforward_enabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
# 5. dca_deposits — fiat the operator (or super) records against an LP
# at a machine. creator_user_id preserves audit trail.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_deposits (
id TEXT PRIMARY KEY,
# DCA Deposits table
await db.execute(
f"""
CREATE TABLE satoshimachine.dca_deposits (
id TEXT PRIMARY KEY NOT NULL,
client_id TEXT NOT NULL,
machine_id TEXT NOT NULL,
creator_user_id TEXT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
amount INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'GTQ',
status TEXT NOT NULL DEFAULT 'pending',
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
confirmed_at TIMESTAMP
);
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS dca_deposits_client_idx "
"ON dca_deposits (client_id, created_at DESC)"
"""
)
# 6. dca_settlements — idempotency table for bitSpire-driven settlements.
# payment_hash UNIQUE handles subscription replays + dispatcher
# double-fires. processing_claim is the optimistic-lock token
# written by claim_settlement_for_processing. notes is the
# append-only audit memo for partial-dispense + operator notes.
#
# platform_fee_sats and operator_fee_sats are absolute BIGINT,
# NOT derived fractions — when the v2 customer-discount engine
# ships, these two columns are the audit-grade record of who
# forgave what per transaction. Do not collapse them into a single
# fee_fraction. See plan section "Customer discounts" and #10.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements (
id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL,
payment_hash TEXT NOT NULL UNIQUE,
bitspire_event_id TEXT,
bitspire_txid TEXT,
wire_sats BIGINT NOT NULL,
fiat_amount DECIMAL(10,2) NOT NULL,
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
# DCA Payments table
await db.execute(
f"""
CREATE TABLE satoshimachine.dca_payments (
id TEXT PRIMARY KEY NOT NULL,
client_id TEXT NOT NULL,
amount_sats INTEGER NOT NULL,
amount_fiat INTEGER NOT NULL,
exchange_rate REAL NOT NULL,
principal_sats BIGINT NOT NULL,
fee_sats BIGINT NOT NULL,
platform_fee_sats BIGINT NOT NULL,
operator_fee_sats BIGINT NOT NULL,
tx_type TEXT NOT NULL,
bills_json TEXT,
cassettes_json TEXT,
transaction_type TEXT NOT NULL,
lamassu_transaction_id TEXT,
payment_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending',
error_message TEXT,
processed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
notes TEXT,
processing_claim TEXT
);
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS dca_settlements_machine_idx "
"ON dca_settlements (machine_id, created_at DESC)"
)
# 7. dca_commission_splits — operator's rules for distributing the
# *remainder* (fee_sats - platform_fee_sats). One row per
# leg. machine_id=NULL = operator default; non-null = per-machine
# override. Sum(fraction) per (operator, machine) must equal 1.0 —
# enforced at write-time in crud.py.
#
# `target` accepts any of (splitpayments-style):
# - LNbits wallet id (UUID-shaped)
# - LNbits wallet invoice key (resolved via get_wallet_for_key)
# - Lightning address (user@domain)
# - LNURL string (bech32 LNURL...)
# Resolution lives in distribution._pay_one_split_leg.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_commission_splits (
id TEXT PRIMARY KEY,
machine_id TEXT,
operator_user_id TEXT NOT NULL,
target TEXT NOT NULL,
label TEXT,
fraction DECIMAL(10,4) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS dca_commission_splits_lookup_idx "
"ON dca_commission_splits (operator_user_id, machine_id)"
)
# 8. dca_payments — every leg of every distribution. leg_type
# discriminator: dca | super_fee | operator_split | settlement |
# autoforward | refund. status enum: pending | completed | failed |
# voided | skipped | refunded.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_payments (
id TEXT PRIMARY KEY,
settlement_id TEXT,
client_id TEXT,
machine_id TEXT NOT NULL,
operator_user_id TEXT NOT NULL,
leg_type TEXT NOT NULL,
destination_wallet_id TEXT,
destination_ln_address TEXT,
amount_sats BIGINT NOT NULL,
amount_fiat DECIMAL(10,2),
exchange_rate REAL,
transaction_time TIMESTAMP NOT NULL,
external_payment_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending',
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
await db.execute(
"CREATE INDEX IF NOT EXISTS dca_payments_client_idx "
"ON dca_payments (client_id, created_at DESC)"
)
await db.execute(
"CREATE INDEX IF NOT EXISTS dca_payments_settlement_idx "
"ON dca_payments (settlement_id)"
)
await db.execute(
"CREATE INDEX IF NOT EXISTS dca_payments_operator_idx "
"ON dca_payments (operator_user_id, leg_type)"
)
# 9. dca_telemetry — latest replaceable kind-30078 (public availability
# beacon) and kind-30079 (operator-only fleet telemetry) snapshots
# per machine. The beacon today (lamassu-next/dev @ 2b712af) ships
# only cash_in/cash_out/cash_level/fiat/model — post-#43 fields
# (name, location, geo, fees, limits, denominations, version) are
# nullable until that upstream issue lands. Ingest opportunistically.
await db.execute("""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_telemetry (
machine_id TEXT PRIMARY KEY,
beacon_cash_in BOOLEAN,
beacon_cash_out BOOLEAN,
beacon_cash_level TEXT,
beacon_fiat TEXT,
beacon_model TEXT,
beacon_name TEXT,
beacon_location TEXT,
beacon_geo TEXT,
beacon_fees_json TEXT,
beacon_limits_json TEXT,
beacon_denominations_json TEXT,
beacon_version TEXT,
beacon_received_at TIMESTAMP,
telemetry_json TEXT,
telemetry_received_at TIMESTAMP
);
""")
async def m002_rename_commission_split_wallet_id_to_target(db):
"""One-off correction for installs whose `dca_commission_splits` table
pre-exists from an earlier partial v2 migration run (where the column
was named `wallet_id`). The collapsed m001 uses `CREATE TABLE IF NOT
EXISTS`, which is a no-op when the table already exists so the
schema drift survives the documented uninstall + reinstall workflow
because LNbits' uninstall wipes the dbversions tracker but NOT the
satoshimachine.sqlite3 file on disk.
Idempotent: probes for the `wallet_id` column via a SELECT. If the
probe succeeds the column still exists and we RENAME it; otherwise
the rename is already done (or the table was fresh) and we no-op.
Fresh installs from m001 onward already have `target` directly for
them this migration is a no-op.
"""
try:
await db.fetchone(
"SELECT wallet_id FROM satoshimachine.dca_commission_splits LIMIT 1"
)
except Exception:
# wallet_id column doesn't exist; either m001 produced the correct
# schema on a fresh install or the rename already landed.
return
# Lamassu Configuration table
await db.execute(
"ALTER TABLE satoshimachine.dca_commission_splits "
"RENAME COLUMN wallet_id TO target"
)
async def m003_rename_settlements_net_sats_to_principal_sats(db):
"""Rename `dca_settlements.net_sats` → `principal_sats` for clarity.
"Net" in financial accounting is overloaded (net of what?). In the
bitSpire/DCA context this column is specifically the principal the
operator distributes to LPs (gross commission), not a generic
"net" amount. Renaming locally before any bitSpire firmware locks
the wire-level name; lamassu-next#44 should adopt the same name.
Idempotent: probes for the old `net_sats` column. If present, rename.
"""
try:
await db.fetchone("SELECT net_sats FROM satoshimachine.dca_settlements LIMIT 1")
except Exception:
return
await db.execute(
"ALTER TABLE satoshimachine.dca_settlements "
"RENAME COLUMN net_sats TO principal_sats"
)
async def m004_introduce_dca_lp_table(db):
"""Hoist LP-level state (wallet, mode, autoforward) out of dca_clients
into a per-user dca_lp table. dca_clients becomes a pure (machine, LP)
enrolment record; everything delivery-related becomes the LP's own
preference, owned and written by satmachineclient.
Why: the per-row state on dca_clients was a denormalised duplicate of
user-level intent ("which wallet should my DCA land in?" + "should it
forward to my LN address?" — same answer regardless of which machine
paid). Today's update_lp_autoforward already does a multi-row UPDATE
to keep the rows in sync a smell of state belonging one level up.
Fresh installs from m001 onward land on the new schema directly.
Existing installs (pre-m004 test data) get migrated here:
1. Create dca_lp table (no-op if already present from m001 path).
2. Backfill dca_lp from existing dca_clients rows, picking the
most-recently-updated row per user_id when an LP is enrolled at
multiple machines.
3. Drop the moved columns from dca_clients.
Idempotent: probes for the legacy `dca_clients.wallet_id` column. If
absent the install already on the new shape; no-op.
"""
try:
await db.fetchone("SELECT wallet_id FROM satoshimachine.dca_clients LIMIT 1")
except Exception:
return
# Step 1: create dca_lp if it doesn't exist yet. m001 on a fresh install
# already created it; on a pre-m004 install we're creating it here.
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.dca_lp (
user_id TEXT PRIMARY KEY,
dca_wallet_id TEXT NOT NULL,
default_dca_mode TEXT NOT NULL DEFAULT 'flow',
fixed_mode_daily_limit DECIMAL(10,2),
autoforward_ln_address TEXT,
autoforward_enabled BOOLEAN NOT NULL DEFAULT false,
f"""
CREATE TABLE satoshimachine.lamassu_config (
id TEXT PRIMARY KEY NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 5432,
database_name TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
source_wallet_id TEXT,
commission_wallet_id TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
test_connection_last TIMESTAMP,
test_connection_success BOOLEAN,
last_poll_time TIMESTAMP,
last_successful_poll TIMESTAMP,
use_ssh_tunnel BOOLEAN NOT NULL DEFAULT false,
ssh_host TEXT,
ssh_port INTEGER NOT NULL DEFAULT 22,
ssh_username TEXT,
ssh_password TEXT,
ssh_private_key TEXT,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
# Step 2: backfill dca_lp from dca_clients. Pick the latest row per
# user (by updated_at, falling back to created_at) when the LP is
# enrolled at multiple machines — that row reflects their most
# recent intent. ROW_NUMBER() OVER (...) requires SQLite 3.25+ (2018).
await db.execute("""
INSERT OR IGNORE INTO satoshimachine.dca_lp
(user_id, dca_wallet_id, default_dca_mode, fixed_mode_daily_limit,
autoforward_ln_address, autoforward_enabled,
created_at, updated_at)
SELECT user_id, wallet_id, dca_mode, fixed_mode_daily_limit,
autoforward_ln_address, autoforward_enabled,
created_at, updated_at
FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY updated_at DESC, created_at DESC
) AS rn
FROM satoshimachine.dca_clients
) ranked
WHERE rn = 1
""")
# Step 3: drop the moved columns from dca_clients. ALTER TABLE DROP
# COLUMN needs SQLite 3.35+ (2021). One column per ALTER (SQLite
# doesn't support multi-column DROP).
for col in (
"wallet_id",
"dca_mode",
"fixed_mode_daily_limit",
"autoforward_ln_address",
"autoforward_enabled",
):
await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}")
async def m006_rename_to_canonical_sat_vocabulary(db):
"""Adopt the cross-codebase canonical sat-amount vocabulary AND drop
the now-obsolete Lamassu-era fallback columns, per the decision at
memory `reference_sat_amount_vocabulary.md` (2026-05-26):
Renames:
- 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
Drops (Lamassu-era reverse-derivation is obsolete since bitSpire
stamps both `principal_sats` AND `fee_sats` directly on
Payment.extra per lamassu-next#44 — there's nothing to back-derive):
- dca_machines.fallback_commission_pct (was the rate used by the
deleted `_parse_fallback` path)
- dca_settlements.used_fallback_split (was the per-row marker for
that path)
Same canonical applies on the lamassu-next + atm-tui side; the
rename is coordinated via `~/dev/coordination/log.md` (2026-05-26).
Each step is idempotent probe for the OLD column; rename/drop only
if present; otherwise no-op (covers fresh installs where m001
already laid down the canonical schema).
Why a single migration: all driven by the same decision and any
external code wants to see the whole rename + cleanup land at once.
"""
renames = [
("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"),
]
for table, old_col, new_col in renames:
try:
await db.fetchone(f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1")
except Exception:
# old column doesn't exist; either rename already landed or
# m001 produced the canonical schema directly on fresh install.
continue
)
# Lamassu Transactions table (for audit trail)
await db.execute(
f"ALTER TABLE satoshimachine.{table} "
f"RENAME COLUMN {old_col} TO {new_col}"
)
# Drop the Lamassu-era fallback columns. Same idempotency pattern.
# Try both old (_pct) and new (_fraction) names for the dca_machines
# column since an install could be at either rename state.
drops = [
("dca_machines", "fallback_commission_pct"),
("dca_machines", "fallback_commission_fraction"),
("dca_settlements", "used_fallback_split"),
]
for table, col in drops:
try:
await db.fetchone(f"SELECT {col} FROM satoshimachine.{table} LIMIT 1")
except Exception:
# column doesn't exist; either already dropped or never present.
continue
await db.execute(f"ALTER TABLE satoshimachine.{table} DROP COLUMN {col}")
async def m005_lock_deposit_currency_to_machine_fiat_code(db):
"""Rewrite every `dca_deposits.currency` row to match its joined
`dca_machines.fiat_code`.
Today each machine handles exactly one currency (operator-set on
`dca_machines.fiat_code`); a deposit's currency is fully determined
by the machine it's recorded against. The deposit dialog was
historically a freeform text input, which let an operator typo a
currency code (e.g., a "15 USD" row landed against an EUR Sintra
during 2026-05-16 testing that mismatch silently inflated the LP's
nominal balance because the balance summary is currency-blind).
`aiolabs/satmachineadmin#26` locks the input side; this migration
fixes any rows already on disk. Idempotent: on a fresh install with
no mismatches it's a no-op UPDATE.
"""
await db.execute("""
UPDATE satoshimachine.dca_deposits AS d
SET currency = (
SELECT m.fiat_code
FROM satoshimachine.dca_machines m
WHERE m.id = d.machine_id
)
WHERE EXISTS (
SELECT 1
FROM satoshimachine.dca_machines m
WHERE m.id = d.machine_id
AND m.fiat_code IS NOT NULL
AND m.fiat_code != d.currency
)
""")
async def m007_add_cassette_configs(db):
"""Add cassette_configs table for operator-driven ATM cassette inventory.
Tracks per-machine cassette state (denomination, count, position) editable
via the satmachineadmin dashboard and published to the ATM as encrypted
kind-30078 events. See aiolabs/satmachineadmin#29 + lamassu-next#56.
Schema choice: PK (machine_id, denomination) mirrors the ATM-side
denomination-as-key invariant in
bitspire/atm-tui/src/db.zig:31 and
lamassu-next/apps/machine/electron/state-store.ts:54
(the cassettes table PK is denomination; HAL inventory map keys on
denomination; dispense lookup is cassetteDenominations.indexOf
duplicates collapse silently). Position is operator-assignable display
order, not the addressable unit.
Reserved nullable columns (state_count, state_at, state_event_id) hold
the latest bitspire-cassettes-state:<atm_pubkey_hex> event the ATM
publishes (one-shot bootstrap in v1; continuous in v2). v1 UI doesn't
render them; v2 reconciliation UI consumes them without a migration.
"""
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.cassette_configs (
machine_id TEXT NOT NULL,
denomination INTEGER NOT NULL,
count INTEGER NOT NULL,
position INTEGER NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_by TEXT,
state_count INTEGER,
state_at TIMESTAMP,
state_event_id TEXT,
PRIMARY KEY (machine_id, denomination)
f"""
CREATE TABLE satoshimachine.lamassu_transactions (
id TEXT PRIMARY KEY NOT NULL,
lamassu_transaction_id TEXT NOT NULL UNIQUE,
fiat_amount INTEGER NOT NULL,
crypto_amount INTEGER NOT NULL,
commission_percentage REAL NOT NULL,
discount REAL NOT NULL DEFAULT 0.0,
effective_commission REAL NOT NULL,
commission_amount_sats INTEGER NOT NULL,
base_amount_sats INTEGER NOT NULL,
exchange_rate REAL NOT NULL,
crypto_code TEXT NOT NULL DEFAULT 'BTC',
fiat_code TEXT NOT NULL DEFAULT 'GTQ',
device_id TEXT,
transaction_time TIMESTAMP NOT NULL,
processed_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
clients_count INTEGER NOT NULL DEFAULT 0,
distributions_total_sats INTEGER NOT NULL DEFAULT 0
);
""")
async def m008_flip_cassette_configs_pk_to_position(db):
"""Flip cassette_configs PK from (machine_id, denomination) to
(machine_id, position). The denomination-keyed shape from m007 was
wrong: real machines have N cartridges of the same denomination
(cash-out throughput requires multiple bays for one denom), and the
operator needs to swap cartridge denominations during refill ($20
bay becomes $50 bay) without a re-provisioning event.
Coordinated v1.1 fix with the ATM side per the 2026-05-30T18:30Z +
18:45Z log entries:
- Wire shape flips from {denominations: {<d>: {position, count}}}
to {positions: {<p>: {denomination, count}}}
- Position becomes the fixed row identity (hardware bay number);
denomination + count are operator-editable per row
- NO unique constraint on denomination (multiple same-denom cassettes
are operationally valid)
Also adds `state_denomination` nullable column reserved for v2
reverse-channel reconciliation (operator-believed denomination per
slot vs ATM-reported denomination diff highlighting in v2 UI).
SQLite doesn't support ALTER PRIMARY KEY directly; the migration
does the standard create-copy-drop-rename dance. Idempotent via the
column-probe trick used elsewhere in this file.
"""
try:
# Probe: does the old PK shape still exist? If state_denomination
# column already exists, m008 already ran — no-op.
await db.fetchone(
"SELECT state_denomination FROM satoshimachine.cassette_configs " "LIMIT 1"
)
return
except Exception:
pass
await db.execute(f"""
CREATE TABLE IF NOT EXISTS satoshimachine.cassette_configs_new (
machine_id TEXT NOT NULL,
position INTEGER NOT NULL,
denomination INTEGER NOT NULL,
count INTEGER NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_by TEXT,
state_denomination INTEGER,
state_count INTEGER,
state_at TIMESTAMP,
state_event_id TEXT,
PRIMARY KEY (machine_id, position)
);
""")
# Backfill from the old table — column-by-column copy. In the v1
# m007 schema the row's `denomination` was simultaneously the
# operator-believed denomination AND the ATM-reported denomination
# (because the only write path was the bootstrap consumer copying
# from the ATM's state.db). So state_denomination at migration time
# = current denomination as a best-guess baseline; the next bootstrap
# event re-populates the state_* columns authoritatively.
await db.execute("""
INSERT INTO satoshimachine.cassette_configs_new
(machine_id, position, denomination, count,
updated_at, updated_by,
state_denomination, state_count, state_at, state_event_id)
SELECT machine_id, position, denomination, count,
updated_at, updated_by,
denomination, state_count, state_at, state_event_id
FROM satoshimachine.cassette_configs
""")
await db.execute("DROP TABLE satoshimachine.cassette_configs")
await db.execute(
"ALTER TABLE satoshimachine.cassette_configs_new " "RENAME TO cassette_configs"
)
async def m009_split_fee_fractions_by_direction(db):
"""Split the singleton `super_fee_fraction` into per-direction fields
and add matching per-machine operator fee fractions. Adds the
`fee_mismatch_sats` audit column on settlements.
Architectural intent (per aiolabs/satmachineadmin#37):
- Super (lnbits administrator) sets X_in% and X_out% applies
across every machine on the lnbits instance, calculated against
principal.
- Operator (per-machine) sets Y_in% and Y_out% sits on top of
super, calculated against principal.
- Total fee charged customer = (X+Y)% of principal per direction.
- Distribution: super gets X% of principal; operator gets Y%
(distributed through commission legs as today).
Fixes the load-bearing bug where the old `super_fee_fraction` was
interpreted as fraction-of-fee, under-paying the super by ~13× per
cashout. The post-migration split math (bitspire.py:parse_settlement
+ calculations.py:split_principal_based) is principal-based.
Schema delta:
- super_config gains super_cash_in_fee_fraction +
super_cash_out_fee_fraction (both backfilled
from the existing super_fee_fraction so live
config preserves intent across migrate-up).
- dca_machines gains operator_cash_in_fee_fraction +
operator_cash_out_fee_fraction (default 0;
operators set via the new UI surface).
- dca_settlements gains fee_mismatch_sats BIGINT NULL records
bitspire-reported fee minus expected per
satmachineadmin's principal-based recompute.
Phase 1 observability: log + record, never
reject (per coord-log §2026-06-01T07:00Z
lnbits advisory; option A locked).
Idempotency via column-probe pattern (same shape as m006's rename
sweep). The deprecated `super_config.super_fee_fraction` singleton
is backfilled into the new directional fields, then dropped in the
same migration strict-from-the-start per workspace CLAUDE.md
"Backwards-compatibility on pre-public-launch code" (v2-bitspire
hasn't shipped to public users).
"""
additions = [
("super_config", "super_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"),
("super_config", "super_cash_out_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"),
("dca_machines", "operator_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"),
("dca_machines", "operator_cash_out_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"),
("dca_settlements", "fee_mismatch_sats", "BIGINT"),
]
for table, col, coltype in additions:
try:
await db.fetchone(f"SELECT {col} FROM satoshimachine.{table} LIMIT 1")
# column already present — migration partially-ran previously, skip
continue
except Exception:
pass
await db.execute(
f"ALTER TABLE satoshimachine.{table} ADD COLUMN {col} {coltype}"
)
# Backfill + drop the legacy singleton, gated on the column still
# existing. Once dropped, a re-run of this migration skips both
# steps cleanly.
try:
await db.fetchone(
"SELECT super_fee_fraction FROM satoshimachine.super_config LIMIT 1"
)
legacy_present = True
except Exception:
legacy_present = False
if legacy_present:
# Carry the live deployment's super_fee_fraction setting forward
# into both directional fields, but only when the operator hasn't
# already explicitly set per-direction values (i.e., both are
# still at DEFAULT 0).
await db.execute(
"""
UPDATE satoshimachine.super_config
SET super_cash_in_fee_fraction = super_fee_fraction,
super_cash_out_fee_fraction = super_fee_fraction
WHERE super_cash_in_fee_fraction = 0
AND super_cash_out_fee_fraction = 0
AND super_fee_fraction > 0
"""
)
async def m002_add_transaction_time_to_dca_payments(db):
"""
Add transaction_time field to dca_payments table to store original ATM transaction time
"""
await db.execute(
"ALTER TABLE satoshimachine.super_config DROP COLUMN super_fee_fraction"
"""
ALTER TABLE satoshimachine.dca_payments
ADD COLUMN transaction_time TIMESTAMP
"""
)
async def m003_add_max_daily_limit_config(db):
"""
Add max_daily_limit_gtq field to lamassu_config table for admin-configurable client limits
"""
await db.execute(
"""
ALTER TABLE satoshimachine.lamassu_config
ADD COLUMN max_daily_limit_gtq INTEGER NOT NULL DEFAULT 2000
"""
)
async def m004_convert_to_gtq_storage(db):
"""
Convert centavo storage to GTQ storage by changing data types and converting existing data.
Handles both SQLite (data conversion only) and PostgreSQL (data + schema changes).
"""
# Detect database type
db_type = str(type(db)).lower()
is_postgres = 'postgres' in db_type or 'asyncpg' in db_type
if is_postgres:
# PostgreSQL: Need to change column types first, then convert data
# Change column types to DECIMAL(10,2)
await db.execute("ALTER TABLE satoshimachine.dca_deposits ALTER COLUMN amount TYPE DECIMAL(10,2)")
await db.execute("ALTER TABLE satoshimachine.dca_payments ALTER COLUMN amount_fiat TYPE DECIMAL(10,2)")
await db.execute("ALTER TABLE satoshimachine.lamassu_transactions ALTER COLUMN fiat_amount TYPE DECIMAL(10,2)")
await db.execute("ALTER TABLE satoshimachine.dca_clients ALTER COLUMN fixed_mode_daily_limit TYPE DECIMAL(10,2)")
await db.execute("ALTER TABLE satoshimachine.lamassu_config ALTER COLUMN max_daily_limit_gtq TYPE DECIMAL(10,2)")
# Convert data from centavos to GTQ
await db.execute("UPDATE satoshimachine.dca_deposits SET amount = amount / 100.0 WHERE currency = 'GTQ'")
await db.execute("UPDATE satoshimachine.dca_payments SET amount_fiat = amount_fiat / 100.0")
await db.execute("UPDATE satoshimachine.lamassu_transactions SET fiat_amount = fiat_amount / 100.0")
await db.execute("UPDATE satoshimachine.dca_clients SET fixed_mode_daily_limit = fixed_mode_daily_limit / 100.0 WHERE fixed_mode_daily_limit IS NOT NULL")
await db.execute("UPDATE satoshimachine.lamassu_config SET max_daily_limit_gtq = max_daily_limit_gtq / 100.0 WHERE max_daily_limit_gtq > 1000")
else:
# SQLite: Data conversion only (dynamic typing handles the rest)
await db.execute("UPDATE satoshimachine.dca_deposits SET amount = CAST(amount AS REAL) / 100.0 WHERE currency = 'GTQ'")
await db.execute("UPDATE satoshimachine.dca_payments SET amount_fiat = CAST(amount_fiat AS REAL) / 100.0")
await db.execute("UPDATE satoshimachine.lamassu_transactions SET fiat_amount = CAST(fiat_amount AS REAL) / 100.0")
await db.execute("UPDATE satoshimachine.dca_clients SET fixed_mode_daily_limit = CAST(fixed_mode_daily_limit AS REAL) / 100.0 WHERE fixed_mode_daily_limit IS NOT NULL")
await db.execute("UPDATE satoshimachine.lamassu_config SET max_daily_limit_gtq = CAST(max_daily_limit_gtq AS REAL) / 100.0 WHERE max_daily_limit_gtq > 1000")

940
models.py

File diff suppressed because it is too large Load diff

294
nip44.py
View file

@ -1,294 +0,0 @@
"""
NIP-44 v2 versioned encrypted payloads (https://github.com/nostr-protocol/nips/blob/master/44.md).
Hand-rolled because lnbits historically shipped only NIP-04 (AES-CBC) in
`lnbits.utils.nostr.encrypt_content`, and the locked design at
aiolabs/satmachineadmin#29 (paired with lamassu-next#56) wires cassette config
over kind-30078 with NIP-44 v2 encrypted content.
## Runtime status (post lnbits PR #38, 2026-05-31)
**Runtime usage has migrated to the signer abstraction** via
`signer.nip44_encrypt` / `signer.nip44_decrypt` on `lnbits.core.signers.base.
NostrSigner`. For RemoteBunkerSigner-backed accounts the bunker performs the
crypto and the operator's nsec never leaves the bunker process; for the
transitional LocalSigner path `cassette_transport._nip44_*_via_signer` falls
back to the helpers in this module against the stored `account.prvkey`.
This module's runtime export footprint is therefore:
- `encrypt_for` / `decrypt_from` called by the LocalSigner fallback in
`cassette_transport` until every operator on the instance is bunker-backed
(S7 / aiolabs/satmachineadmin#21). Then those calls disappear too.
- Everything else (encrypt_with_conversation_key, decrypt_with_conversation_key,
get_conversation_key, padding helpers, error classes) is **test-only**:
referenced by `tests/test_nip44_v2.py` to validate the wire format against
the canonical paulmillr/nip44 reference vectors and the bitspire cross-test
fixture posted to the coordination log.
Don't add new runtime call sites here. The signer abstraction is the path.
Two safety nets keep the impl honest:
1. tests/test_nip44_v2.py runs reference vectors + round-trip + tamper-detection.
2. bitspire posts a sample event encrypted on their nostr-tools side to the
coord log; test_decrypts_bitspire_sample_event cross-checks our impl
against theirs by decrypting that event with a known privkey.
Wire format (per spec):
payload = base64( 0x02 || nonce (32B) || ciphertext (var) || mac (32B) )
Key derivation:
conversation_key = HKDF-extract(salt=b"nip44-v2", IKM=ecdh_shared_x) # 32B PRK, stable per pair
per-message:
nonce = csprng(32 bytes)
temp = HKDF-expand(PRK=conversation_key, info=nonce, L=76)
chacha_key = temp[0:32]
chacha_nonce = temp[32:44]
hmac_key = temp[44:76]
Padding scheme (NIP-44 v2 length-prefixed, variable-chunk):
padded = uint16_be(len(plaintext)) || plaintext || zeros
such that 2 + padded_data_len matches a fixed step.
"""
from __future__ import annotations
import base64
import hashlib
import hmac as hmac_stdlib
import os
import struct
import coincurve
from cryptography.hazmat.primitives import hashes, hmac
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
# Spec constants.
_VERSION = 0x02
_HKDF_SALT = b"nip44-v2"
_MIN_PLAINTEXT_LEN = 1
_MAX_PLAINTEXT_LEN = 65535
_NONCE_LEN = 32
_MAC_LEN = 32
_MIN_PAYLOAD_LEN = (
1 + _NONCE_LEN + (2 + 32) + _MAC_LEN
) # version + nonce + min padded + mac
_MAX_PAYLOAD_LEN = 1 + _NONCE_LEN + (2 + 65536) + _MAC_LEN
class Nip44Error(Exception):
"""Generic NIP-44 v2 envelope error. Subclasses distinguish failure modes."""
class Nip44VersionError(Nip44Error):
"""First payload byte was not 0x02. Could be a NIP-04 envelope, a v1 NIP-44, or garbage."""
class Nip44MacError(Nip44Error):
"""HMAC verification failed — payload was tampered, wrong conversation key, or corrupted in transit."""
class Nip44LengthError(Nip44Error):
"""Plaintext or payload length outside the spec-allowed range, or padding header lies."""
# =============================================================================
# Padding (NIP-44 v2)
# =============================================================================
def _calc_padded_len(plaintext_len: int) -> int:
"""Per NIP-44 v2 padding scheme:
if L <= 32: padded_len = 32
else: chunk = max(32, next_power_2(L-1) // 8); padded_len = chunk * ((L-1) // chunk + 1)
"""
if plaintext_len <= 32:
return 32
next_power = 1 << (plaintext_len - 1).bit_length()
chunk = max(32, next_power // 8)
return chunk * ((plaintext_len - 1) // chunk + 1)
def _pad(plaintext: bytes) -> bytes:
"""Prefix uint16_be length + plaintext + zero-fill to the NIP-44 v2 boundary."""
n = len(plaintext)
if n < _MIN_PLAINTEXT_LEN or n > _MAX_PLAINTEXT_LEN:
raise Nip44LengthError(
f"plaintext length {n} outside [{_MIN_PLAINTEXT_LEN}, {_MAX_PLAINTEXT_LEN}]"
)
padded_data_len = _calc_padded_len(n)
zeros = b"\x00" * (padded_data_len - n)
return struct.pack(">H", n) + plaintext + zeros
def _unpad(padded: bytes) -> bytes:
"""Strip the uint16_be length prefix and zero padding. Validates that the
declared length is consistent with the padded payload (rejects a forged
length prefix that would slice past the buffer or imply a different
padded_data_len than what we received)."""
if len(padded) < 2:
raise Nip44LengthError("padded payload too short to hold length prefix")
declared_len = struct.unpack(">H", padded[0:2])[0]
if declared_len < _MIN_PLAINTEXT_LEN or declared_len > _MAX_PLAINTEXT_LEN:
raise Nip44LengthError(f"declared plaintext length {declared_len} out of range")
if len(padded) != 2 + _calc_padded_len(declared_len):
raise Nip44LengthError(
f"padded buffer length {len(padded)} doesn't match the calculated padding "
f"for declared length {declared_len}"
)
return padded[2 : 2 + declared_len]
# =============================================================================
# Conversation + message-key derivation
# =============================================================================
def get_conversation_key(privkey_hex: str, pubkey_hex: str) -> bytes:
"""Derive the per-pair stable conversation key (PRK) used for all messages
between sender (privkey) and recipient (pubkey).
Steps:
shared_x = ECDH(privkey, pubkey).x # 32 bytes, x-coordinate
prk = HKDF-extract(salt=b"nip44-v2", IKM=shared_x)
coincurve's `.multiply(secret).format(compressed=True)[1:]` strips the
leading 0x02/0x03 parity byte to return the raw x-coord same trick
`lnbits.utils.nostr.encrypt_content` uses for NIP-04.
"""
sender = coincurve.PrivateKey(bytes.fromhex(privkey_hex))
recipient_pub = coincurve.PublicKey(b"\x02" + bytes.fromhex(pubkey_hex))
shared_x = recipient_pub.multiply(sender.secret).format(compressed=True)[1:]
# HKDF-extract is HMAC-SHA256(key=salt, msg=ikm) per RFC 5869.
return hmac_stdlib.new(_HKDF_SALT, shared_x, hashlib.sha256).digest()
def _derive_message_keys(
conversation_key: bytes, nonce: bytes
) -> tuple[bytes, bytes, bytes]:
"""Per-message key expansion: HKDF-expand(PRK=conversation_key, info=nonce, L=76).
Returns (chacha_key 32B, chacha_nonce 12B, hmac_key 32B)."""
hkdf = HKDFExpand(algorithm=hashes.SHA256(), length=76, info=nonce)
okm = hkdf.derive(conversation_key)
return okm[0:32], okm[32:44], okm[44:76]
def _hmac_aad(hmac_key: bytes, nonce: bytes, ciphertext: bytes) -> bytes:
"""HMAC-SHA256(key=hmac_key, msg=nonce || ciphertext). Returns 32-byte MAC."""
h = hmac.HMAC(hmac_key, hashes.SHA256())
h.update(nonce)
h.update(ciphertext)
return h.finalize()
def _chacha20(key: bytes, nonce: bytes, data: bytes) -> bytes:
"""ChaCha20 stream cipher (symmetric: encrypt == decrypt). Used both directions.
The `cryptography` lib's `algorithms.ChaCha20(key, nonce)` expects a
16-byte nonce arg: a 4-byte little-endian initial counter prefix +
12-byte actual nonce. NIP-44 v2 starts the counter at 0 and uses the
HKDF-derived 12-byte chacha_nonce, so we prefix four zero bytes here.
"""
if len(nonce) != 12:
raise Nip44LengthError(
f"chacha_nonce must be 12 bytes (NIP-44 v2), got {len(nonce)}"
)
cipher = Cipher(algorithms.ChaCha20(key, b"\x00\x00\x00\x00" + nonce), mode=None)
return cipher.encryptor().update(data)
# =============================================================================
# Public API — low-level (nonce-controllable for testability)
# =============================================================================
def encrypt_with_conversation_key(
plaintext: str,
conversation_key: bytes,
*,
nonce: bytes | None = None,
) -> str:
"""Encrypt `plaintext` under a precomputed `conversation_key` (32B PRK).
`nonce` is 32 random bytes when omitted (the production path). Tests pass
it explicitly to assert pinned reference vectors.
Returns the base64-encoded payload string suitable as a Nostr event's
`content` field for kind-30078 (and any other kind that uses NIP-44 v2).
"""
if nonce is None:
nonce = os.urandom(_NONCE_LEN)
elif len(nonce) != _NONCE_LEN:
raise Nip44LengthError(f"nonce must be exactly {_NONCE_LEN} bytes")
padded = _pad(plaintext.encode("utf-8"))
chacha_key, chacha_nonce, hmac_key = _derive_message_keys(conversation_key, nonce)
ciphertext = _chacha20(chacha_key, chacha_nonce, padded)
mac = _hmac_aad(hmac_key, nonce, ciphertext)
return base64.b64encode(bytes([_VERSION]) + nonce + ciphertext + mac).decode(
"ascii"
)
def decrypt_with_conversation_key(payload_b64: str, conversation_key: bytes) -> str:
"""Decrypt a NIP-44 v2 payload using a precomputed `conversation_key`.
Raises:
Nip44VersionError payload's first byte isn't 0x02
Nip44LengthError payload too short / too long / declared length lies
Nip44MacError HMAC verification failed (tamper, wrong key, corruption)
"""
try:
raw = base64.b64decode(payload_b64, validate=True)
except (
Exception
) as exc:
raise Nip44LengthError(f"payload is not valid base64: {exc}") from exc
if len(raw) < _MIN_PAYLOAD_LEN or len(raw) > _MAX_PAYLOAD_LEN:
raise Nip44LengthError(f"payload length {len(raw)} outside valid range")
if raw[0] != _VERSION:
raise Nip44VersionError(f"unsupported NIP-44 version: 0x{raw[0]:02x}")
nonce = raw[1 : 1 + _NONCE_LEN]
mac_received = raw[-_MAC_LEN:]
ciphertext = raw[1 + _NONCE_LEN : -_MAC_LEN]
chacha_key, chacha_nonce, hmac_key = _derive_message_keys(conversation_key, nonce)
mac_expected = _hmac_aad(hmac_key, nonce, ciphertext)
# constant-time compare to avoid timing-leak in MAC verification
if not hmac_stdlib.compare_digest(mac_received, mac_expected):
raise Nip44MacError("HMAC verification failed")
padded = _chacha20(chacha_key, chacha_nonce, ciphertext)
plaintext_bytes = _unpad(padded)
return plaintext_bytes.decode("utf-8")
# =============================================================================
# Public API — high-level (pair-keyed, the call shape app code reaches for)
# =============================================================================
def encrypt_for(
plaintext: str,
sender_privkey_hex: str,
recipient_pubkey_hex: str,
*,
nonce: bytes | None = None,
) -> str:
"""Encrypt `plaintext` from the sender (holding the privkey) to the recipient
(identified by pubkey). The recipient can decrypt with `decrypt_from(
payload, recipient_privkey_hex, sender_pubkey_hex)` symmetric on the
conversation key, which is the same derived value from either side."""
conversation_key = get_conversation_key(sender_privkey_hex, recipient_pubkey_hex)
return encrypt_with_conversation_key(plaintext, conversation_key, nonce=nonce)
def decrypt_from(
payload_b64: str, recipient_privkey_hex: str, sender_pubkey_hex: str
) -> str:
"""Decrypt a payload that the recipient (holding the privkey) received from
the sender (identified by pubkey)."""
conversation_key = get_conversation_key(recipient_privkey_hex, sender_pubkey_hex)
return decrypt_with_conversation_key(payload_b64, conversation_key)

View file

@ -1,295 +0,0 @@
"""
Shared kind-30078 (NIP-78 addressable app data) Nostr publish primitives.
Extracted from cassette_transport.py once the second consumer landed
(fee_transport.py for aiolabs/satmachineadmin#39). Both modules
share the operator-signer resolution, NIP-44 v2 encrypt/decrypt path,
event signing, and nostrclient-relay publish path; only the d-tag
prefix + payload model differ per document type.
Architecture:
fee_transport / cassette_transport
publish_encrypted_kind_30078 high-level wrapper (build event + sign + publish)
resolve_operator_signer
nip44_encrypt_via_signer
sign_as_operator
publish_signed_event
`resolve_operator_signer` and the NIP-44 helpers honor the
transitional LocalSigner RemoteBunkerSigner cascade (lnbits#17/#18):
the bunker is the endgame for every operator account on this instance,
but pre-migration LocalSigner accounts still work via direct-prvkey
NIP-44 v2 from our hand-rolled `nip44` module.
This module is intentionally domain-agnostic it knows nothing about
cassettes, fees, or any specific d-tag prefix. The caller supplies
the recipient pubkey, the d-tag, and the payload dict.
"""
from __future__ import annotations
import json
import time
from typing import Any
from lnbits.core.crud.users import get_account
from lnbits.core.services.nip46_bunker_client import (
NsecBunkerRpcError,
NsecBunkerTimeoutError,
)
from lnbits.core.signers import resolve_signer
from lnbits.core.signers.base import (
NostrSigner,
SignerError,
SignerUnavailableError,
)
from loguru import logger
from .nip44 import decrypt_from as _nip44_local_decrypt
from .nip44 import encrypt_for as _nip44_local_encrypt
KIND_NIP78 = 30078
# =============================================================================
# Errors — typed so API endpoints can map to specific HTTP statuses
# =============================================================================
class NostrPublishError(Exception):
"""Base class for kind-30078 publish errors. Sub-modules
(cassette_transport, fee_transport) typically subclass further
for domain-specific 'this couldn't be applied' errors that have
no analog in the transport layer."""
class OperatorIdentityMissing(NostrPublishError):
"""Operator account has no Nostr pubkey on file, or no signer is
available (pre-bunker onboarding operator hasn't logged in via
Nostr-login flow)."""
class SignerUnavailable(NostrPublishError):
"""Resolved signer can't sign server-side (client-side-only signer,
or transient bunker unreachability post-lnbits#18). Publish skipped
or soft-failed by the caller."""
class RelayUnavailable(NostrPublishError):
"""nostrclient extension isn't installed or its relay manager isn't
reachable. Treated as soft-fail by callers; publish skipped + logged."""
# =============================================================================
# Operator signer resolution + NIP-44 v2 encrypt/decrypt
# =============================================================================
async def resolve_operator_signer(operator_user_id: str):
"""Fetch the operator's account + resolve to a NostrSigner.
Single source of truth for "give me the signer for this operator,
or raise an operator-facing error if we can't." Returns
`(account, signer)` so callers that need both (publish path needs
`account.pubkey` for the event author and the signer for both
encrypt + sign) don't double-fetch.
Raises:
- OperatorIdentityMissing no account, or no pubkey on file
- SignerUnavailable signer resolve failed, or signer can't sign
server-side (ClientSideOnly)
"""
account = await get_account(operator_user_id)
if account is None or not account.pubkey:
raise OperatorIdentityMissing(
f"operator {operator_user_id[:8]}... has no Nostr pubkey on file. "
"Onboard via the LNbits Nostr-login flow to publish operator "
"config to your ATMs."
)
try:
signer = resolve_signer(account)
except SignerError as exc:
raise SignerUnavailable(
f"signer resolve failed for operator {operator_user_id[:8]}...: {exc}"
) from exc
if not signer.can_sign():
raise SignerUnavailable(
f"operator {operator_user_id[:8]}... has a client-side-only "
"signer; server can't sign or NIP-44-encrypt on their behalf. "
"Operator must hold their nsec via a NIP-46 bunker (lnbits#18) "
"or migrate to a server-signing account."
)
return account, signer
async def sign_as_operator(operator_user_id: str, event: dict) -> dict:
"""Sign `event` using the operator's signer (LocalSigner or
RemoteBunkerSigner). Mutates `event` to add `created_at` (now),
`pubkey`, `id`, and `sig`. Returns the signed event.
Raises typed NostrPublishError subclasses on hard failure (caller
maps to HTTP status / decides soft-fail).
"""
_account, signer = await resolve_operator_signer(operator_user_id)
# created_at is part of the BIP-340 event-id hash; set before signing.
event["created_at"] = int(time.time())
try:
signed = await signer.sign_event(event)
except SignerUnavailableError as exc:
raise SignerUnavailable(
f"signer unavailable for operator {operator_user_id[:8]}...: {exc}"
) from exc
if signed is None:
raise NostrPublishError(
f"signer returned None for operator {operator_user_id[:8]}... "
"— shouldn't be reachable on a server-signing path"
)
return signed
async def nip44_encrypt_via_signer(
account, signer: NostrSigner, plaintext: str, peer_pubkey_hex: str
) -> str:
"""NIP-44 v2 encrypt via the signer abstraction, with a transitional
fallback to direct-prvkey for LocalSigner accounts.
The bunker (RemoteBunkerSigner) implements `nip44_encrypt` natively
the operator's nsec never leaves the bunker process. LocalSigner's
`nip44_encrypt` stub explicitly raises SignerUnavailableError per
the post-PR-#38 ABC — the spec is "migrate to bunker." For the
transitional window where some operators are still on LocalSigner +
their `account.prvkey` is intact, we catch that signal and use our
hand-rolled NIP-44 v2 impl against the stored prvkey. Same wire
output either way.
Removed once every operator account on this instance is bunker-
backed (S7 fully landed). At that point this helper collapses to
`return await signer.nip44_encrypt(plaintext, peer_pubkey_hex)`.
"""
try:
return await signer.nip44_encrypt(plaintext, peer_pubkey_hex)
except SignerUnavailableError:
if account.signer_type == "LocalSigner" and account.prvkey:
return _nip44_local_encrypt(plaintext, account.prvkey, peer_pubkey_hex)
# ClientSideOnly, or RemoteBunkerSigner with bunker comms failure
# at encrypt time — re-raise without wrapping; caller maps it.
raise
async def nip44_decrypt_via_signer(
account, signer: NostrSigner, ciphertext: str, peer_pubkey_hex: str
) -> str:
"""Decrypt mirror of `nip44_encrypt_via_signer`. Same LocalSigner
transitional fallback."""
try:
return await signer.nip44_decrypt(ciphertext, peer_pubkey_hex)
except SignerUnavailableError:
if account.signer_type == "LocalSigner" and account.prvkey:
return _nip44_local_decrypt(ciphertext, account.prvkey, peer_pubkey_hex)
raise
# =============================================================================
# Relay publish
# =============================================================================
async def publish_signed_event(signed_event: dict) -> None:
"""Send a signed Nostr event to all relays via the nostrclient
extension's singleton RelayManager.
Lazy import + typed-error so the API can surface "your LNbits doesn't
have nostrclient installed" as a 503 rather than a 500. Pattern
matches the cross-extension import guards in
`lnbits.core.services.users` (nostrmarket / nostrrelay).
"""
try:
from nostrclient.router import ( # type: ignore[import-not-found]
nostr_client,
)
except ImportError as exc:
raise RelayUnavailable(
"nostrclient extension is not installed; kind-30078 publish "
"requires it. Install + activate the nostrclient extension on "
"this LNbits instance."
) from exc
msg = json.dumps(["EVENT", signed_event])
nostr_client.relay_manager.publish_message(msg)
# =============================================================================
# High-level: build + encrypt + sign + publish in one call
# =============================================================================
async def publish_encrypted_kind_30078(
*,
operator_user_id: str,
recipient_pubkey_hex: str,
d_tag: str,
payload: dict[str, Any],
log_context: str = "",
) -> dict:
"""Build, NIP-44-v2-encrypt, sign-as-operator, and publish a
kind-30078 event addressed to `recipient_pubkey_hex` under `d_tag`.
Centralised so cassette_transport + fee_transport (+ any future
operator-pushed document type) share the same wire-format guarantees.
Returns the signed event dict on success. Raises typed
NostrPublishError subclasses on hard failure:
- OperatorIdentityMissing 400: operator hasn't onboarded
- SignerUnavailable 503: signer offline / client-side-only /
bunker timeout at encrypt or sign step
- RelayUnavailable 503: nostrclient not installed
- NostrPublishError 500: anything else
`log_context` is a short string prefixed to the success log line for
triage ("cassette", "fee", etc.).
"""
account, signer = await resolve_operator_signer(operator_user_id)
plaintext = json.dumps(payload, separators=(",", ":"))
try:
content = await nip44_encrypt_via_signer(
account, signer, plaintext, recipient_pubkey_hex
)
except NsecBunkerTimeoutError as exc:
raise SignerUnavailable(
f"bunker unreachable while encrypting kind-30078 ({d_tag}) for "
f"operator {operator_user_id[:8]}...: {exc}"
) from exc
except NsecBunkerRpcError as exc:
raise SignerUnavailable(
f"bunker rejected nip44_encrypt for operator "
f"{operator_user_id[:8]}... (policy / MAC / config issue): {exc}"
) from exc
except SignerUnavailableError as exc:
raise SignerUnavailable(
f"signer cannot nip44-encrypt for operator "
f"{operator_user_id[:8]}...: {exc}"
) from exc
event: dict = {
"kind": KIND_NIP78,
"tags": [
["d", d_tag],
["p", recipient_pubkey_hex],
],
"content": content,
# created_at is set inside sign_as_operator before signing.
}
signed = await sign_as_operator(operator_user_id, event)
await publish_signed_event(signed)
prefix = f"{log_context}: " if log_context else ""
logger.info(
f"satmachineadmin: {prefix}published kind-30078 to ATM "
f"{recipient_pubkey_hex[:12]}... d-tag={d_tag} "
f"event_id={signed['id'][:12]}..."
)
return signed

View file

@ -1,143 +0,0 @@
"""
Roster-resolver hook for the path-B wallet-routing fix
(aiolabs/satmachineadmin#20 / lnbits-side issue forthcoming per
coord-log 2026-05-31T15:25Z).
Exposes a `resolve(sender_pubkey_hex)` function that, given an inbound
NIP-46 sender pubkey, looks it up against `dca_machines.machine_npub`
and returns a `RouteHit(operator_user_id, wallet_id, source_extension)`
on a match.
The hook is registered with lnbits' `nostr_transport` at extension-init
time via `register_with_lnbits()`. Until the lnbits side ships
`lnbits.core.services.nostr_transport.register_roster_resolver`, the
registration call lazily-imports + soft-fails so satmachineadmin keeps
loading cleanly on any lnbits version.
When the lnbits implementation lands + the satmachine instance has
`NOSTR_TRANSPORT_ROSTER_REQUIRED=true` set, inbound kind-21000
RPCs from a registered ATM npub will route directly to the operator's
wallet (delivering the "cash-out sats go to the operator's wallet, not
an auto-created machine wallet" outcome). Unregistered npubs get
rejected with the fail-closed posture user chose at coord-log
2026-05-31T14:38Z.
Field-shape contract for `RouteHit` is FROZEN per coord-log
2026-05-31T15:25Z lnbits ack: `(operator_user_id, wallet_id,
source_extension)`. Don't add fields here without a coord-log round —
the shape is a multi-extension API contract.
"""
from __future__ import annotations
from dataclasses import dataclass
from lnbits.utils.nostr import normalize_public_key
from loguru import logger
from .crud import get_machine_by_atm_pubkey_hex
_SOURCE_EXTENSION = "satmachineadmin"
@dataclass(frozen=True)
class RouteHit:
"""A positive answer from a roster resolver: route the resulting
invoice to (operator_user_id, wallet_id). `source_extension`
identifies which roster matched used by lnbits for loud-reject
logging when the failure-mode posture rejects.
Local definition mirrors the agreed lnbits-side shape per coord-log
2026-05-31T15:25Z. When lnbits' canonical class is importable,
`register_with_lnbits` prefers it over this local one but the
local stays as a fallback so this module imports cleanly on pre-
landing lnbits versions + drives the unit tests.
"""
operator_user_id: str
wallet_id: str
source_extension: str = _SOURCE_EXTENSION
async def resolve(sender_pubkey_hex: str) -> RouteHit | None:
"""Roster lookup: given a sender pubkey from an inbound nostr-
transport RPC, return a RouteHit if it's a registered ATM, None
otherwise.
Canonicalises the input first (sender pubkeys arrive lowercase-hex
from `Payment.extra.nostr_sender_pubkey` per lnbits PR #4, but
upstream is paranoid normalise just in case).
Raises on a malformed pubkey input lnbits' fail-closed posture
(option b at coord-log 2026-05-31T14:38Z, ack'd at 15:15Z item 2
sub-case "resolver raises an exception → reject + ERROR log")
means this surfaces as a rejection, not a silent fall-through.
Same handling as any other unrecoverable resolver error.
"""
canonical = normalize_public_key(sender_pubkey_hex).lower()
machine = await get_machine_by_atm_pubkey_hex(canonical)
if machine is None:
return None
return _build_route_hit(
operator_user_id=machine.operator_user_id,
wallet_id=machine.wallet_id,
)
def _build_route_hit(operator_user_id: str, wallet_id: str):
"""Construct a RouteHit using lnbits' canonical class if importable,
otherwise the local fallback. Centralised so a future lnbits-side
shape evolution only touches this helper."""
try:
from lnbits.core.services.nostr_transport import ( # type: ignore[attr-defined]
RouteHit as _LnbitsRouteHit,
)
except ImportError:
return RouteHit(
operator_user_id=operator_user_id,
wallet_id=wallet_id,
source_extension=_SOURCE_EXTENSION,
)
return _LnbitsRouteHit(
operator_user_id=operator_user_id,
wallet_id=wallet_id,
source_extension=_SOURCE_EXTENSION,
)
def register_with_lnbits() -> bool:
"""Register `resolve` with lnbits' nostr-transport roster registry.
Returns True if the registration landed (lnbits surface available
+ call succeeded), False if soft-failed because lnbits hasn't
shipped `register_roster_resolver` yet that's the expected
state until the path-B lnbits PR lands. Either way satmachineadmin
boots cleanly; only the routing-via-roster behavior is gated on
the lnbits side being present.
Called once from `satmachineadmin_start()`. Idempotent on the
lnbits side per their 15:15Z spec ("re-registration on extension
reload replaces cleanly").
"""
try:
from lnbits.core.services.nostr_transport import ( # type: ignore[attr-defined]
register_roster_resolver,
)
except ImportError:
logger.info(
"satmachineadmin: nostr-transport roster-resolver hook not "
"available on this lnbits version (pre-path-B); ATM-npub "
"routing falls through to lnbits' default auto-account-from-"
"npub behaviour. See aiolabs/satmachineadmin#20 / coord-log "
"2026-05-31T15:25Z for the path-B handoff."
)
return False
register_roster_resolver(_SOURCE_EXTENSION, resolve)
logger.info(
f"satmachineadmin: registered '{_SOURCE_EXTENSION}' roster "
"resolver with lnbits nostr-transport — inbound kind-21000 "
"from a registered ATM npub will route to the operator's wallet "
"directly. (Behavior gated server-side by "
"NOSTR_TRANSPORT_ROSTER_REQUIRED.)"
)
return True

File diff suppressed because it is too large Load diff

507
tasks.py
View file

@ -1,490 +1,53 @@
# Satoshi Machine v2 — invoice listener (P1 + fix bundle 2).
#
# Subscribes to LNbits' invoice dispatcher (register_invoice_listener), then
# for each successful inbound payment:
# 1. Checks if wallet_id belongs to an active dca_machines row. If not, skip.
# 2. Verifies the originating Nostr signer matches the machine identity
# (assert_nostr_attribution; uses Payment.extra.nostr_sender_pubkey
# stamped by lnbits nostr-transport dispatcher).
# 3. Parses Payment.extra for bitSpire's canonical split stamp per
# aiolabs/lamassu-next#44 (`source: "bitspire"`, principal_sats,
# fee_sats, exchange_rate). Raises if the stamp is missing or
# garbage (no more Lamassu-era reverse-derivation fallback).
# 4. Computes the two-stage split (super_fee first, operator remainder).
# 5. Inserts a dca_settlements row idempotently (keyed by payment_hash).
# 6. Spawns the distribution processor on a background task so the
# LNbits invoice queue (which serves ALL extensions on the node)
# keeps draining while we move sats. Concurrency is safe because
# process_settlement now uses an optimistic-lock claim (fix bundle 1).
#
# Rejection paths (settlement still recorded with status='rejected' for
# operator forensics, but distribution is skipped):
# - SettlementAttributionError: signer mismatch (G5).
# - SettlementMetadataError: Payment.extra missing bitSpire stamp.
# - SettlementInvariantError: stamped values violate the canonical
# sat-amount invariants (range/sum).
import asyncio
from datetime import datetime
from lnbits.core.models import Payment
from lnbits.core.services import websocket_updater
from lnbits.tasks import register_invoice_listener
from loguru import logger
from .bitspire import (
SettlementAttributionError,
SettlementInvariantError,
SettlementMetadataError,
assert_nostr_attribution,
parse_settlement,
)
from .crud import (
create_settlement_idempotent,
get_active_machine_by_wallet_id,
get_super_config,
)
from .distribution import process_settlement
from .models import CreateDcaSettlementData, Machine
from .transaction_processor import poll_lamassu_transactions
LISTENER_NAME = "ext_satmachineadmin"
#######################################
########## RUN YOUR TASKS HERE ########
#######################################
# Holds strong refs to in-flight distribution tasks so Python's GC doesn't
# collect them mid-flight (asyncio.create_task only weakly references its
# task once awaiters drop). Tasks self-clean by removing themselves on
# completion via the done_callback below.
_inflight_distributions: set = set()
# The usual task is to listen to invoices related to this extension
async def wait_for_paid_invoices() -> None:
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, LISTENER_NAME)
logger.info(
"satmachineadmin v2: invoice listener registered as "
f"`{LISTENER_NAME}` — waiting for bitSpire settlements."
)
async def wait_for_paid_invoices():
"""Invoice listener for DCA-related payments"""
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, "ext_satmachineadmin")
while True:
payment: Payment = await invoice_queue.get()
try:
await _handle_payment(payment)
except Exception as exc: # listener must never die
logger.error(
f"satmachineadmin: error handling payment "
f"{payment.payment_hash[:12]}...: {exc}"
)
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def _handle_payment(payment: Payment) -> None:
if not payment.success:
return
machine = await get_active_machine_by_wallet_id(payment.wallet_id)
if machine is None:
return
extra = payment.extra or {}
async def hourly_transaction_polling():
"""Background task that polls Lamassu database every hour for new transactions"""
logger.info("Starting hourly Lamassu transaction polling task")
# Two axes, deliberately named in pairs to avoid the inversion trap
# documented at `~/.claude/projects/.../memory/feedback_naming_business_vs_protocol.md`:
#
# - is_lightning_inbound / is_lightning_outbound: PROTOCOL direction
# at the operator's wallet. `payment.is_in` from LNbits.
# - tx_type ∈ {"cash_out", "cash_in"}: BUSINESS direction at the ATM.
# Sourced from Payment.extra (canonical, stamped by bitSpire).
#
# Canonical mapping:
# cash_out ↔ is_lightning_inbound (customer pays ATM's invoice in BTC,
# operator wallet receives sats)
# cash_in ↔ is_lightning_outbound (customer redeems ATM's LNURL-
# withdraw, operator wallet sends sats)
#
# Process BOTH directions; reject mismatches at the discriminator gate.
is_lightning_inbound = payment.is_in
is_lightning_outbound = not payment.is_in
# Outbound payments from the operator's wallet need an extra
# discriminator before we touch them. An operator may legitimately
# send sats for non-ATM reasons (manual send, different extension,
# etc.). Without `source=bitspire` on Payment.extra we can't tell
# the operator paying their landlord from a cash-in settlement —
# skip silently. (For cash-out / inbound payments we already gate
# on machine-owned wallet via `get_active_machine_by_wallet_id`.)
if is_lightning_outbound and extra.get("source") != "bitspire":
return
# 1) Attribution FIRST — uses only `extra.nostr_sender_pubkey` (no parse
# needed). If this fails, every subsequent field on `extra` is
# attacker-controlled and untrustworthy — record a minimal rejected
# row with placeholder zeros (don't display unverified split numbers
# in the operator dashboard).
try:
assert_nostr_attribution(machine, extra)
except SettlementAttributionError as exc:
await _record_rejected(payment, machine, exc)
return
# 2) Parse + invariants. parse_settlement enforces the canonical
# sat-amount invariants on the bitSpire-stamped numbers (range +
# direction-specific sum). Raises SettlementMetadataError if the
# stamp is missing, SettlementInvariantError on any range/sum
# breach.
super_config = await get_super_config()
assert super_config is not None # m001 inserts the default singleton
try:
data = parse_settlement(
machine=machine,
payment_hash=payment.payment_hash,
wire_sats=payment.sat,
extra=extra,
super_config=super_config,
)
except (SettlementMetadataError, SettlementInvariantError) as exc:
await _record_rejected(payment, machine, exc)
return
# Cross-axis sanity: protocol direction must agree with business
# direction per the canonical mapping above. A mismatch means
# something upstream is confused — refuse to process. Concrete
# symptom this catches: an attacker (or a buggy extension) stamps
# `source=bitspire, type=cash_out` on an outbound payment from the
# operator's wallet to attempt a fake "we just received sats" row.
expected_inbound = data.tx_type == "cash_out"
if is_lightning_inbound != expected_inbound:
await _record_rejected(
payment,
machine,
SettlementInvariantError(
f"direction mismatch: payment.is_in={is_lightning_inbound} "
f"but tx_type={data.tx_type!r}. Expected cash_out ↔ inbound, "
"cash_in ↔ outbound."
),
)
return
del is_lightning_outbound # only used for the discriminator above
# Stamp the originating Nostr event id (the kind-21000 create_invoice
# RPC) onto the row for post-hoc forensics — an auditor can trace
# settlement → RPC event → signing key without trusting our DB.
nostr_event_id = extra.get("nostr_event_id")
if isinstance(nostr_event_id, str) and nostr_event_id:
data.bitspire_event_id = nostr_event_id
# 3) Insert + distribute.
settlement = await create_settlement_idempotent(data, initial_status="pending")
if settlement is None:
logger.error(
f"satmachineadmin: failed to insert settlement for "
f"payment_hash={payment.payment_hash[:12]}..."
)
return
logger.info(
f"satmachineadmin: landed settlement {settlement.id} for "
f"machine={machine.machine_npub[:12]}... "
f"wire={data.wire_sats}sats principal={data.principal_sats}sats "
f"fee={data.fee_sats}sats "
f"(super_fee={data.platform_fee_sats} "
f"operator_fee={data.operator_fee_sats})"
)
# Spawn distribution on a background task so the LNbits invoice queue
# (shared across all extensions) keeps draining while we move sats.
# Concurrency-safe: process_settlement uses claim_settlement_for_processing
# so a listener re-fire can't double-process. Listener latency is now
# bounded by the create_settlement_idempotent insert, not by the N+M
# internal pay_invoice round-trips of a full distribution.
task = asyncio.create_task(process_settlement(settlement.id))
_inflight_distributions.add(task)
task.add_done_callback(_inflight_distributions.discard)
async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -> None:
"""Insert a minimal `dca_settlements` row with `status='rejected'` and
the exception message for operator forensics.
Used for every rejection path (attribution / metadata / invariant).
The split fields are zero placeholders we deliberately do NOT
display attacker-supplied numbers in the operator dashboard. The
wire amount (`payment.sat`) is the only value LNbits authenticated;
everything else from Payment.extra is untrusted in this branch.
"""
data = CreateDcaSettlementData(
machine_id=machine.id,
payment_hash=payment.payment_hash,
wire_sats=payment.sat,
fiat_amount=0.0,
fiat_code=machine.fiat_code,
exchange_rate=0.0,
principal_sats=0,
fee_sats=0,
platform_fee_sats=0,
operator_fee_sats=0,
# tx_type is unknown for rejection paths; default to cash_out
# (the only direction currently wired). When S8 lands the
# listener will branch on tx_type from extra, and this default
# gets revisited.
tx_type="cash_out",
)
rejected = await create_settlement_idempotent(
data, initial_status="rejected", error_message=str(exc)
)
if rejected is None:
logger.error(
f"satmachineadmin: failed to insert rejected settlement for "
f"payment_hash={payment.payment_hash[:12]}..."
)
return
logger.error(
f"satmachineadmin: rejected settlement {rejected.id} "
f"(machine={machine.machine_npub[:12]}..., "
f"payment_hash={payment.payment_hash[:12]}...): {exc}"
)
# =============================================================================
# Cassette bootstrap consumer (#29 v1)
# =============================================================================
# Subscribes to kind-30078 bitspire-cassettes-state:<atm_pubkey_hex> events
# published by each active machine's ATM on first boot (lamassu-next#56's
# bootstrap publish path). Decrypts the NIP-44 v2 content with the operator's
# privkey + ATM sender pubkey, validates as PublishCassettesPayload, and
# upserts cassette_configs via apply_bootstrap_state.
#
# v1 = one-shot per machine (ATM's meta.bootstrapPublishedAt makes the
# publish idempotent on ATM-side restart; satmachineadmin's apply_bootstrap_
# state dedups on state_event_id for relay re-delivery).
#
# v2 (separate issue) = continuous reverse-channel consumer with a
# last_state_created_at watermark for reconciliation UI.
#
# Implementation: polls nostrclient.router.NostrRouter.received_subscription_
# events keyed by our subscription_id. nostrclient's NostrRouter design is
# per-WebSocket-client; the singleton dict it drains into is the only
# server-side hook to consume events without standing up an in-process
# websocket. The relay manager is the same singleton publish_to_atm uses,
# so add_subscription registers a filter against the same relay pool.
CASSETTE_BOOTSTRAP_SUB_ID = "satmachineadmin-cassette-bootstrap"
_CASSETTE_POLL_INTERVAL_S = 2.0
_CASSETTE_BACKOFF_S = 30.0 # when nostrclient isn't installed yet
async def wait_for_cassette_state_events() -> None:
"""Long-running task: subscribe to bitspire-cassettes-state events from
every active machine's ATM and upsert cassette_configs on receipt.
Pattern mirrors wait_for_paid_invoices (try/except wraps each event,
never lets the loop die). Re-derives the subscription filter on each
tick from the current active-machines list newly-added machines
start receiving bootstrap events without an LNbits restart.
Soft-fail surfaces:
- nostrclient not installed log + sleep _CASSETTE_BACKOFF_S
between retries (operator may install it later)
- inbound event fails sig-verify / decrypt / parse log + skip
the event, continue the loop
- apply_bootstrap_state errors log + skip
"""
logger.info(
"satmachineadmin v2: cassette bootstrap consumer starting "
f"(sub_id={CASSETTE_BOOTSTRAP_SUB_ID})"
)
current_filter_key: str | None = None
while True:
try:
current_filter_key = await _cassette_consumer_tick(current_filter_key)
await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S)
except _NostrclientUnavailable:
logger.warning(
"satmachineadmin: nostrclient extension not installed; "
f"cassette bootstrap consumer sleeping {_CASSETTE_BACKOFF_S}s "
"before retry. Install + activate nostrclient on this "
"LNbits instance."
)
current_filter_key = None
await asyncio.sleep(_CASSETTE_BACKOFF_S)
except Exception as exc: # listener must never die
logger.error(
f"satmachineadmin: cassette consumer loop error (continuing): " f"{exc}"
)
await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S)
logger.info(f"Running Lamassu transaction poll at {datetime.now()}")
await poll_lamassu_transactions()
logger.info("Completed Lamassu transaction poll, sleeping for 1 hour")
# Sleep for 1 hour (3600 seconds)
await asyncio.sleep(3600)
except Exception as e:
logger.error(f"Error in hourly polling task: {e}")
# Sleep for 5 minutes before retrying on error
await asyncio.sleep(300)
class _NostrclientUnavailable(Exception):
"""Internal sentinel — nostrclient extension import failed. Caller
sleeps a backoff then retries; the operator may install nostrclient
at any time."""
async def _cassette_consumer_tick(current_filter_key: str | None) -> str:
"""Single iteration of the bootstrap-consumer loop. Returns the filter
key used this tick so the caller can detect filter-set changes.
Raises _NostrclientUnavailable if nostrclient can't be imported (the
outer loop backs off + retries).
"""
try:
from nostrclient.router import ( # type: ignore[import-not-found]
NostrRouter,
nostr_client,
)
except ImportError as exc:
raise _NostrclientUnavailable() from exc
from .cassette_transport import build_state_d_tags_for_machines
from .crud import (
apply_bootstrap_state,
get_machine_by_atm_pubkey_hex,
list_all_active_machines,
)
machines = await list_all_active_machines()
d_tags = build_state_d_tags_for_machines(machines)
filter_key = ",".join(sorted(d_tags))
if filter_key != current_filter_key:
if d_tags:
filters = [{"kinds": [30078], "#d": d_tags}]
# nostrclient's add_subscription is typed as list[str] but the
# actual relay protocol accepts list[Filter-dict] — type ignore
# the upstream typing mismatch.
nostr_client.relay_manager.add_subscription(
CASSETTE_BOOTSTRAP_SUB_ID, filters # type: ignore[arg-type]
)
logger.info(
"satmachineadmin: (re)registered cassette bootstrap "
f"subscription with {len(d_tags)} d-tag(s)"
)
else:
nostr_client.relay_manager.close_subscription(CASSETTE_BOOTSTRAP_SUB_ID)
logger.info(
"satmachineadmin: no active machines; closed cassette "
"bootstrap subscription"
)
inbound = NostrRouter.received_subscription_events.get(CASSETTE_BOOTSTRAP_SUB_ID)
if inbound:
while inbound:
event_message = inbound.pop(0)
try:
await _handle_cassette_state_event(
event_message,
get_machine_by_atm_pubkey_hex,
apply_bootstrap_state,
)
except Exception as exc:
logger.warning(
f"satmachineadmin: cassette state event handler "
f"failed (skipping): {exc}"
)
return filter_key
async def _handle_cassette_state_event(
event_message,
get_machine_by_atm_pubkey_hex,
apply_bootstrap_state,
) -> None:
"""Verify signature, resolve the operator's signer, decrypt via the
signer abstraction (bunker round-trip for RemoteBunkerSigner; direct
prvkey on the LocalSigner transitional fallback inside the transport
helper), parse, upsert.
Each step logs at WARNING (not ERROR) so a noisy attacker can't fill
the logs this is data on a public relay, garbage is expected.
Two skip outcomes:
- Terminal (CassetteEventDecodeError / SignerUnavailable /
OperatorIdentityMissing / etc.): log + return. `apply_bootstrap_
state` is never called `state_event_id` is not advanced
same event would re-process on next poll cycle but the consumer's
WARN log surfaces the underlying issue immediately.
- Transient (CassetteEventTransientError): log at INFO (less noisy)
+ return. Same retry-via-no-advance semantics, just less
alarming in the operator log feed.
"""
import json as _json
from datetime import datetime as _datetime
from datetime import timezone as _timezone
from lnbits.utils.nostr import verify_event
from .cassette_transport import (
CassetteEventDecodeError,
CassetteEventTransientError,
CassetteTransportError,
decrypt_and_parse_state_event,
)
from .nostr_publish import resolve_operator_signer
event_raw = event_message.event
if isinstance(event_raw, str):
event_obj = _json.loads(event_raw)
elif isinstance(event_raw, dict):
event_obj = event_raw
else:
logger.warning(
f"satmachineadmin: cassette event of unexpected type "
f"{type(event_raw).__name__}; skipping"
)
return
if not verify_event(event_obj):
logger.warning(
f"satmachineadmin: cassette state event sig verify failed "
f"(id={event_obj.get('id', '?')[:12]}...)"
)
return
sender_pubkey = event_obj.get("pubkey", "")
machine = await get_machine_by_atm_pubkey_hex(sender_pubkey)
if machine is None:
# Unknown sender — could be relay noise or an attacker. Don't
# treat as our problem.
logger.warning(
f"satmachineadmin: cassette state event from unknown ATM "
f"pubkey {sender_pubkey[:12]}... (not in dca_machines); "
"skipping"
)
return
try:
account, signer = await resolve_operator_signer(machine.operator_user_id)
except CassetteTransportError as exc:
# OperatorIdentityMissing / SignerUnavailable — log + skip.
logger.warning(
f"satmachineadmin: can't resolve signer for operator "
f"{machine.operator_user_id[:8]}... (machine {machine.id}): "
f"{exc}"
)
return
try:
payload = await decrypt_and_parse_state_event(event_obj, account, signer)
except CassetteEventTransientError as exc:
logger.info(
f"satmachineadmin: cassette state event for machine {machine.id} "
f"hit a transient signer error (will retry next poll): {exc}"
)
return
except CassetteEventDecodeError as exc:
logger.warning(
f"satmachineadmin: cassette state event decode failed for "
f"machine {machine.id} (id={event_obj.get('id', '?')[:12]}...): "
f"{exc}"
)
return
event_id = event_obj.get("id", "")
created_at_unix = event_obj.get("created_at", 0)
event_created_at = _datetime.fromtimestamp(int(created_at_unix), tz=_timezone.utc)
applied = await apply_bootstrap_state(
machine.id, event_id, event_created_at, payload
)
if applied:
logger.info(
f"satmachineadmin: applied bootstrap state event {event_id[:12]}... "
f"to machine {machine.id} ({len(payload.positions)} cassettes)"
)
else:
# Replay: event_id already on file. Normal on relay reconnect.
logger.debug(
f"satmachineadmin: cassette state event {event_id[:12]}... "
f"already applied to machine {machine.id} (replay no-op)"
)
async def on_invoice_paid(payment: Payment) -> None:
"""Handle DCA-related invoice payments"""
# DCA payments are handled internally by the transaction processor
# This function can be extended if needed for additional payment processing
if payment.extra.get("tag") in ["dca_distribution", "dca_commission"]:
logger.info(f"DCA payment processed: {payment.checking_id} - {payment.amount} sats")
# Could add websocket notifications here if needed
pass

File diff suppressed because it is too large Load diff

View file

@ -1,32 +0,0 @@
"""
Pytest configuration for the satmachineadmin extension test suite.
Provides a `loguru_capture` fixture for tests that need to verify
loguru WARN/ERROR side-effects. Loguru attaches its default sink to
sys.stderr at import time, before pytest's `capsys` wraps stderr, so
neither `caplog` (stdlib logging only) nor `capsys` reliably sees
loguru output. The fixture adds a list-sink for the test's duration
and removes it on teardown.
"""
from typing import Generator, List
import pytest
from loguru import logger
@pytest.fixture
def loguru_capture() -> Generator[List[str], None, None]:
"""Capture loguru log records into a list for the test's duration.
Usage:
def test_warns_on_X(loguru_capture):
do_thing_that_warns()
assert any("expected message" in msg for msg in loguru_capture)
"""
captured: List[str] = []
handler_id = logger.add(
captured.append, level="WARNING", format="{level} {message}"
)
yield captured
logger.remove(handler_id)

View file

@ -1,19 +1,114 @@
"""
Tests for DCA transaction calculations.
Tests for DCA transaction calculations using empirical data.
Covers the pure-function helpers that survive the 2026-05-26 cleanup:
- calculate_distribution (proportional split across LPs by balance)
The previous test surface for `calculate_commission` and
`calculate_exchange_rate` was deleted alongside those functions the
Lamassu-era reverse-derivation is obsolete now that bitSpire stamps
`principal_sats` and `fee_sats` directly on Payment.extra.
Two-stage commission split tests live in `test_two_stage_split.py`.
These tests verify commission and distribution calculations against
real Lamassu transaction data to ensure the math is correct.
"""
import pytest
from decimal import Decimal
from typing import Dict, List, Tuple
# Import from the parent package (following lnurlp pattern)
from ..calculations import calculate_distribution
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
# =============================================================================
# COMMISSION CALCULATION TESTS
# =============================================================================
class TestCommissionCalculation:
"""Tests for commission calculation logic."""
# Empirical test cases: (crypto_atoms, commission%, discount%, expected_base, expected_commission)
# Formula: base = round(crypto_atoms / (1 + effective_commission))
# Where: effective_commission = commission_percentage * (100 - discount) / 100
EMPIRICAL_COMMISSION_CASES = [
# =============================================================
# REAL LAMASSU TRANSACTIONS (extracted from production database)
# =============================================================
# 8.75% commission, no discount - small transaction
# 15600 / 1.0875 = 14344.827... → 14345
(15600, 0.0875, 0.0, 14345, 1255),
# 8.75% commission, no discount - large transaction
# 309200 / 1.0875 = 284322.298... → 284322
(309200, 0.0875, 0.0, 284322, 24878),
# 5.5% commission, no discount
# 309500 / 1.055 = 293364.928... → 293365
(309500, 0.055, 0.0, 293365, 16135),
# 5.5% commission with 100% discount (no commission charged)
# effective = 0.055 * (100-100)/100 = 0
(292400, 0.055, 100.0, 292400, 0),
# 5.5% commission with 90% discount
# effective = 0.055 * (100-90)/100 = 0.0055
# 115000 / 1.0055 = 114370.96... → 114371
(115000, 0.055, 90.0, 114371, 629),
# 5.5% commission, no discount - 1300 GTQ transaction
# 205600 / 1.055 = 194881.516... → 194882
# Note: This tx showed 0.01 GTQ rounding discrepancy in per-client fiat
(205600, 0.055, 0.0, 194882, 10718),
# =============================================================
# SYNTHETIC TEST CASES (edge cases)
# =============================================================
# Zero commission - all goes to base
(100000, 0.0, 0.0, 100000, 0),
# Small amount edge case (1 sat minimum)
(100, 0.03, 0.0, 97, 3),
]
@pytest.mark.parametrize(
"crypto_atoms,commission_pct,discount,expected_base,expected_commission",
EMPIRICAL_COMMISSION_CASES,
ids=[
"lamassu_8.75pct_small",
"lamassu_8.75pct_large",
"lamassu_5.5pct_no_discount",
"lamassu_5.5pct_100pct_discount",
"lamassu_5.5pct_90pct_discount",
"lamassu_5.5pct_1300gtq",
"zero_commission",
"small_amount_100sats",
]
)
def test_commission_calculation(
self,
crypto_atoms: int,
commission_pct: float,
discount: float,
expected_base: int,
expected_commission: int
):
"""Test commission calculation against empirical data."""
base, commission, _ = calculate_commission(crypto_atoms, commission_pct, discount)
assert base == expected_base, f"Base amount mismatch: got {base}, expected {expected_base}"
assert commission == expected_commission, f"Commission mismatch: got {commission}, expected {expected_commission}"
# Invariant: base + commission must equal total
assert base + commission == crypto_atoms, "Base + commission must equal total crypto_atoms"
def test_commission_invariant_always_sums_to_total(self):
"""Commission + base must always equal the original amount."""
test_values = [1, 100, 1000, 10000, 100000, 266800, 1000000]
commission_rates = [0.0, 0.01, 0.03, 0.05, 0.10]
discounts = [0.0, 10.0, 25.0, 50.0]
for crypto_atoms in test_values:
for comm_rate in commission_rates:
for discount in discounts:
base, commission, _ = calculate_commission(crypto_atoms, comm_rate, discount)
assert base + commission == crypto_atoms, \
f"Invariant failed: {base} + {commission} != {crypto_atoms} " \
f"(rate={comm_rate}, discount={discount})"
# =============================================================================
@ -62,6 +157,7 @@ class TestDistributionCalculation:
def test_distribution_invariant_sums_to_total(self):
"""Total distributed sats must always equal base amount."""
# Test with various client configurations
test_cases = [
{"a": 100.0},
{"a": 100.0, "b": 100.0},
@ -119,6 +215,156 @@ class TestDistributionCalculation:
assert distributions == {}
def test_fiat_round_trip_invariant(self):
"""
Verify that distributed sats convert back to original fiat amount.
The sum of each client's fiat equivalent should equal the original
fiat amount (within rounding tolerance).
"""
# Use real Lamassu transaction data
test_cases = [
# (crypto_atoms, fiat_amount, commission_pct, discount, client_balances)
(309200, 2000.0, 0.0875, 0.0, {"a": 1000.0, "b": 1000.0}),
(309500, 2000.0, 0.055, 0.0, {"a": 500.0, "b": 1000.0, "c": 500.0}),
(292400, 2000.0, 0.055, 100.0, {"a": 800.0, "b": 1200.0}),
(115000, 800.0, 0.055, 90.0, {"a": 400.0, "b": 400.0}),
# Transaction that showed 0.01 GTQ rounding discrepancy with 4 clients
(205600, 1300.0, 0.055, 0.0, {"a": 1.0, "b": 986.0, "c": 14.0, "d": 4.0}),
]
for crypto_atoms, fiat_amount, comm_pct, discount, client_balances in test_cases:
# Calculate commission and base amount
base_sats, _, _ = calculate_commission(crypto_atoms, comm_pct, discount)
# Calculate exchange rate
exchange_rate = calculate_exchange_rate(base_sats, fiat_amount)
# Distribute sats to clients
distributions = calculate_distribution(base_sats, client_balances)
# Convert each client's sats back to fiat
total_fiat_distributed = sum(
sats / exchange_rate for sats in distributions.values()
)
# Should equal original fiat amount (within small rounding tolerance)
assert abs(total_fiat_distributed - fiat_amount) < 0.01, \
f"Fiat round-trip failed: {total_fiat_distributed:.2f} != {fiat_amount:.2f} " \
f"(crypto={crypto_atoms}, comm={comm_pct}, discount={discount})"
# =============================================================================
# EMPIRICAL END-TO-END TESTS
# =============================================================================
class TestEmpiricalTransactions:
"""
End-to-end tests using real Lamassu transaction data.
Add your empirical test cases here! Each case should include:
- Transaction details (crypto_atoms, fiat, commission, discount)
- Client balances at time of transaction
- Expected distribution outcome
"""
# TODO: Add your empirical data here
# Example structure:
EMPIRICAL_SCENARIOS = [
{
"name": "real_tx_266800sats_two_equal_clients",
"transaction": {
"crypto_atoms": 266800,
"fiat_amount": 2000,
"commission_percentage": 0.03,
"discount": 0.0,
},
"client_balances": {
"client_a": 1000.00, # 50% of total
"client_b": 1000.00, # 50% of total
},
# 266800 / 1.03 = 259029
"expected_base_sats": 259029,
"expected_commission_sats": 7771,
"expected_distributions": {
# 259029 / 2 = 129514.5 → both get 129514 or 129515
# With banker's rounding: 129514.5 → 129514 (even)
# Remainder of 1 sat goes to first client by fractional sort
"client_a": 129515,
"client_b": 129514,
},
},
# Add more scenarios from your real data!
]
@pytest.mark.parametrize(
"scenario",
EMPIRICAL_SCENARIOS,
ids=[s["name"] for s in EMPIRICAL_SCENARIOS]
)
def test_empirical_scenario(self, scenario):
"""Test full transaction flow against empirical data."""
tx = scenario["transaction"]
# Calculate commission
base, commission, _ = calculate_commission(
tx["crypto_atoms"],
tx["commission_percentage"],
tx["discount"]
)
assert base == scenario["expected_base_sats"], \
f"Base amount mismatch in {scenario['name']}"
assert commission == scenario["expected_commission_sats"], \
f"Commission mismatch in {scenario['name']}"
# Calculate distribution
distributions = calculate_distribution(
base,
scenario["client_balances"]
)
# Verify each client's allocation
for client_id, expected_sats in scenario["expected_distributions"].items():
actual_sats = distributions.get(client_id, 0)
assert actual_sats == expected_sats, \
f"Distribution mismatch for {client_id} in {scenario['name']}: " \
f"got {actual_sats}, expected {expected_sats}"
# Verify total distribution equals base
assert sum(distributions.values()) == base, \
f"Total distribution doesn't match base in {scenario['name']}"
# =============================================================================
# EDGE CASE TESTS
# =============================================================================
class TestEdgeCases:
"""Tests for edge cases and boundary conditions."""
def test_minimum_amount_1_sat(self):
"""Test with minimum possible amount (1 sat)."""
base, commission, _ = calculate_commission(1, 0.03, 0.0)
# With 3% commission on 1 sat, base rounds to 1, commission to 0
assert base + commission == 1
def test_large_transaction(self):
"""Test with large transaction (100 BTC worth of sats)."""
crypto_atoms = 10_000_000_000 # 100 BTC in sats
base, commission, _ = calculate_commission(crypto_atoms, 0.03, 0.0)
assert base + commission == crypto_atoms
assert commission > 0
def test_100_percent_discount(self):
"""100% discount should result in zero commission."""
base, commission, effective = calculate_commission(100000, 0.03, 100.0)
assert effective == 0.0
assert commission == 0
assert base == 100000
def test_many_clients_distribution(self):
"""Test distribution with many clients."""
# 10 clients with varying balances

View file

@ -1,220 +0,0 @@
"""
Tests for the v1.1 cassette-config layer (aiolabs/satmachineadmin#29).
Covers the pure pieces that don't need a live DB:
- Pydantic validator behaviour on PublishCassettesPayload + the row /
upsert models (position key coercion, integer ranges, multiple-same-
denomination payloads, wire-format round-trip)
- _should_apply_bootstrap_state dedup helper (extracted from
apply_bootstrap_state so the relay-re-delivery decision is testable
without a database round-trip)
DB-touching tests (apply_bootstrap_state actually upserting, list-by-
machine ordering, etc.) follow the project convention from
test_deposit_currency.py: "Layer 2 is an endpoint-level behaviour better
covered by an integration test against a running LNbits; tracked in #26
as a follow-up." Smoke-tested manually via the dev container.
Wire shape pivot from m007 m008 is the v1.1 coordination point per
coord-log 2026-05-30T18:30Z + 18:45Z: position is the row identity,
denomination + count are operator-editable per row, multiple same-denom
cassettes are valid.
"""
import pytest
from ..crud import _should_apply_bootstrap_state
from ..models import (
CassettePayloadRow,
PublishCassettesPayload,
UpsertCassetteConfigData,
)
# =============================================================================
# PublishCassettesPayload — wire-shape validators
# =============================================================================
class TestPublishCassettesPayload:
"""The kind-30078 content payload, bidirectional (operator→ATM and
ATMoperator share the shape). String JSON keys must coerce to int;
per-row int constraints enforced; multiple same-denom rows are valid."""
def test_happy_path_coerces_string_keys_to_int(self):
p = PublishCassettesPayload(
positions={
"1": {"denomination": 20, "count": 49},
"2": {"denomination": 50, "count": 100},
}
)
assert set(p.positions.keys()) == {1, 2}
assert p.positions[1].denomination == 20
assert p.positions[1].count == 49
assert p.positions[2].denomination == 50
assert p.positions[2].count == 100
def test_wire_dict_round_trip_restringifies_keys(self):
"""to_wire_dict() must restringify position keys so the resulting
JSON is parseable by clients (including the ATM-side nostr-tools
NIP-44 v2 consumer per the byte-compat cross-test)."""
original = PublishCassettesPayload(
positions={
"1": {"denomination": 20, "count": 49},
"2": {"denomination": 50, "count": 100},
}
)
wire = original.to_wire_dict()
assert wire == {
"positions": {
"1": {"denomination": 20, "count": 49},
"2": {"denomination": 50, "count": 100},
}
}
# And the wire form round-trips back through the parser cleanly.
reparsed = PublishCassettesPayload(**wire)
assert reparsed.positions == original.positions
def test_accepts_multiple_same_denomination_cassettes(self):
"""v1.1 operational case: real machines have N cassettes loaded
with the same denomination for cash-out throughput. The wire shape
must accept this, and we explicitly do NOT validate uniqueness on
denomination. Coord-log 2026-05-30T18:45Z bitspire response."""
p = PublishCassettesPayload(
positions={
"1": {"denomination": 20, "count": 100},
"2": {"denomination": 20, "count": 100},
"3": {"denomination": 50, "count": 50},
"4": {"denomination": 100, "count": 25},
}
)
assert len(p.positions) == 4
denoms = [row.denomination for row in p.positions.values()]
assert denoms.count(20) == 2 # two $20 cassettes
assert sorted(denoms) == [20, 20, 50, 100]
def test_rejects_non_int_position_key(self):
with pytest.raises(ValueError) as exc:
PublishCassettesPayload(positions={"abc": {"denomination": 20, "count": 1}})
assert "is not an int" in str(exc.value)
def test_rejects_non_positive_position(self):
with pytest.raises(ValueError) as exc:
PublishCassettesPayload(positions={"0": {"denomination": 20, "count": 1}})
assert "position must be > 0" in str(exc.value)
def test_rejects_negative_position(self):
with pytest.raises(ValueError) as exc:
PublishCassettesPayload(positions={"-1": {"denomination": 20, "count": 1}})
assert "position must be > 0" in str(exc.value)
def test_rejects_negative_count(self):
with pytest.raises(ValueError):
PublishCassettesPayload(positions={"1": {"denomination": 20, "count": -1}})
def test_rejects_zero_denomination(self):
with pytest.raises(ValueError):
PublishCassettesPayload(positions={"1": {"denomination": 0, "count": 49}})
def test_rejects_negative_denomination(self):
with pytest.raises(ValueError):
PublishCassettesPayload(positions={"1": {"denomination": -20, "count": 49}})
def test_allows_zero_count(self):
"""An empty cassette is a legal state — operator must be able to
record `count=0` after a dispatcher pulled the cassette mid-day."""
p = PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 0}})
assert p.positions[1].count == 0
# =============================================================================
# CassettePayloadRow — per-row int constraints
# =============================================================================
class TestCassettePayloadRow:
def test_happy_path(self):
row = CassettePayloadRow(denomination=20, count=49)
assert row.denomination == 20
assert row.count == 49
@pytest.mark.parametrize("bad_denom", [0, -1, -100])
def test_rejects_non_positive_denomination(self, bad_denom):
with pytest.raises(ValueError):
CassettePayloadRow(denomination=bad_denom, count=1)
def test_rejects_negative_count(self):
with pytest.raises(ValueError):
CassettePayloadRow(denomination=20, count=-1)
# =============================================================================
# UpsertCassetteConfigData — operator-edit form
# =============================================================================
class TestUpsertCassetteConfigData:
"""Operator-driven row edit. Both fields optional; same int constraints
as the wire-format row but applied independently per-edit. Position is
NOT editable it's the row's identity (the hardware bay number)."""
def test_partial_update_count_only(self):
d = UpsertCassetteConfigData(count=80)
assert d.count == 80
assert d.denomination is None
def test_partial_update_denomination_only(self):
"""v1.1 operational case: operator records a cartridge swap at
refill slot 1 was $20, dispatcher replaced with $50."""
d = UpsertCassetteConfigData(denomination=50)
assert d.denomination == 50
assert d.count is None
def test_empty_update_is_legal(self):
"""An empty UpsertCassetteConfigData parses fine; the CRUD short-
circuits a no-op on empty payload (no SQL emitted)."""
d = UpsertCassetteConfigData()
assert d.count is None
assert d.denomination is None
def test_rejects_negative_count(self):
with pytest.raises(ValueError):
UpsertCassetteConfigData(count=-1)
def test_rejects_non_positive_denomination(self):
with pytest.raises(ValueError):
UpsertCassetteConfigData(denomination=0)
# =============================================================================
# _should_apply_bootstrap_state — relay re-delivery dedup
# =============================================================================
class TestShouldApplyBootstrapState:
"""Pure-function dedup gate extracted from apply_bootstrap_state so the
decision is testable without a DB. Logic: apply if-and-only-if the
existing row's state_event_id differs from the incoming event_id.
In v1.1 the ATM publishes the bootstrap event exactly once per machine,
so this is sufficient for replay protection. v2 will need a
`last_state_created_at` watermark in addition (per bitspire's
`meta.lastKnownConfigCreatedAt` on the ATM side) flagged in #29's
v2 forward-look section.
"""
def test_applies_when_no_existing_row(self):
assert _should_apply_bootstrap_state(None, "new-event-id") is True
def test_applies_when_existing_event_id_differs(self):
assert _should_apply_bootstrap_state("old-event-id", "new-event-id") is True
def test_skips_when_existing_event_id_matches(self):
"""The same bootstrap event re-delivered after a relay reconnect
or satmachineadmin restart should no-op, not re-upsert the same
rows (which would clobber any operator edits since)."""
assert _should_apply_bootstrap_state("same-event", "same-event") is False
def test_applies_when_existing_is_empty_string_and_incoming_is_id(self):
"""Defensive — a sentinel empty-string existing_state_event_id
shouldn't block a real incoming event from applying."""
assert _should_apply_bootstrap_state("", "real-event-id") is True

View file

@ -1,485 +0,0 @@
"""
Tests for the cassette bootstrap consumer's transport-decrypt path
(`cassette_transport.decrypt_and_parse_state_event`) and d-tag construction.
Post-PR-#38 migration (2026-05-31): the function takes an Account +
NostrSigner instead of a raw privkey, and is async. Tests use:
- `_FakeBunkerSigner` implements async `nip44_decrypt/encrypt` against
the hand-rolled `nip44` impl so tests don't need a live bunker.
Exercises the "happy" RemoteBunkerSigner path.
- `_FakeLocalSignerStub` raises `SignerUnavailableError` from
`nip44_decrypt`, mimicking the post-#38 `LocalSigner` stub. Combined
with an Account that has `signer_type="LocalSigner"` + `prvkey`,
exercises the transitional fallback path in
`_nip44_decrypt_via_signer`.
- `_FakeRaisingSigner` raises an arbitrary exception, used to
exercise the `NsecBunkerTimeoutError` `CassetteEventTransientError`
and `NsecBunkerRpcError` `CassetteEventDecodeError` mappings.
Coroutines are driven via `asyncio.run` so no pytest-asyncio config is
required. Matches the existing project test pattern (test_init.py
demonstrates the project lacks an asyncio plugin in CI; using asyncio.run
inside the test body sidesteps that without changing project config).
Full handler tests (the dispatch through verify_event
get_machine_by_atm_pubkey_hex apply_bootstrap_state) need a live LNbits
DB; smoke-tested manually via the dev container per the project
convention (see test_deposit_currency.py rationale).
"""
import asyncio
import json
from types import SimpleNamespace
import coincurve
import pytest
from lnbits.core.services.nip46_bunker_client import (
NsecBunkerRpcError,
NsecBunkerTimeoutError,
)
from lnbits.core.signers.base import SignerUnavailableError
from ..cassette_transport import (
CassetteEventDecodeError,
CassetteEventTransientError,
_atm_hex_pubkey,
_config_d_tag,
_state_d_tag,
build_state_d_tags_for_machines,
decrypt_and_parse_state_event,
)
from ..models import Machine, PublishCassettesPayload
from ..nip44 import (
decrypt_from as _nip44_decrypt,
)
from ..nip44 import (
encrypt_with_conversation_key,
get_conversation_key,
)
# Canonical keys (integer 1 + integer 2, the paulmillr/nip44 reference pair).
_OP_SEC = "00" * 31 + "01"
_ATM_SEC = "00" * 31 + "02"
def _pub_hex(sec_hex: str) -> str:
return (
coincurve.PrivateKey(bytes.fromhex(sec_hex))
.public_key.format(compressed=True)[1:]
.hex()
)
_OP_PUB = _pub_hex(_OP_SEC)
_ATM_PUB = _pub_hex(_ATM_SEC)
# =============================================================================
# Fake signers + account-shaped helper
# =============================================================================
class _FakeBunkerSigner:
"""Test double for RemoteBunkerSigner — implements async nip44_*
against the hand-rolled `nip44` impl. Used to exercise the
"signer.nip44_decrypt returns successfully" path without standing up
a live bunker process."""
def __init__(self, privkey_hex: str):
self._privkey_hex = privkey_hex
@property
def pubkey(self) -> str:
return _pub_hex(self._privkey_hex)
def can_sign(self) -> bool:
return True
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
ck = get_conversation_key(self._privkey_hex, peer_pubkey_hex)
return encrypt_with_conversation_key(plaintext, ck)
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
return _nip44_decrypt(ciphertext, self._privkey_hex, peer_pubkey_hex)
class _FakeLocalSignerStub:
"""Test double for the post-#38 LocalSigner stub — its nip44_* always
raises SignerUnavailableError. Combined with an Account that has
`signer_type='LocalSigner'` + `prvkey` populated, exercises the
transitional fallback in `_nip44_decrypt_via_signer` (which catches
the SignerUnavailableError and falls back to direct-prvkey via the
hand-rolled impl)."""
def can_sign(self) -> bool:
return True
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
raise SignerUnavailableError("LocalSigner does not implement nip44_encrypt")
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
raise SignerUnavailableError("LocalSigner does not implement nip44_decrypt")
class _FakeRaisingSigner:
"""Test double that raises a configurable exception on nip44_decrypt.
Used to validate the bunker-error-mapping branches in
decrypt_and_parse_state_event."""
def __init__(self, exc):
self._exc = exc
def can_sign(self) -> bool:
return True
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
raise self._exc
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
raise self._exc
def _fake_account(
signer_type: str = "RemoteBunkerSigner",
prvkey: str | None = None,
):
"""Account-shaped duck-typed object. decrypt_and_parse_state_event +
_nip44_decrypt_via_signer only read `.signer_type` and `.prvkey`; the
rest is irrelevant."""
return SimpleNamespace(
id="test-operator",
pubkey=_OP_PUB,
prvkey=prvkey,
signer_type=signer_type,
signer_config=None,
)
def _make_state_event(
payload: PublishCassettesPayload,
*,
atm_sec: str = _ATM_SEC,
op_pub: str = _OP_PUB,
atm_pub: str = _ATM_PUB,
event_id: str = "fake-event-id",
created_at: int = 1234567890,
) -> dict:
"""Build a state event the way bitspire's ATM publisher would. Skips
the sig-verify step (handler-level concern); the transport-decrypt
path doesn't depend on sig validity, only on conversation-key match."""
plaintext = json.dumps(payload.to_wire_dict(), separators=(",", ":"))
ck = get_conversation_key(atm_sec, op_pub)
content = encrypt_with_conversation_key(plaintext, ck)
return {
"kind": 30078,
"pubkey": atm_pub,
"content": content,
"tags": [
["d", f"bitspire-cassettes-state:{atm_pub}"],
["p", op_pub],
],
"created_at": created_at,
"id": event_id,
}
# =============================================================================
# decrypt_and_parse_state_event — RemoteBunkerSigner happy path
# =============================================================================
class TestDecryptViaBunkerSigner:
"""The expected production path post-#38: operator account is bunker-
backed, signer.nip44_decrypt routes through the bunker (mocked here
via _FakeBunkerSigner), and the wire payload round-trips cleanly."""
def test_happy_path_recovers_positions_keyed_payload(self):
payload = PublishCassettesPayload(
positions={
"1": {"denomination": 20, "count": 49},
"2": {"denomination": 50, "count": 100},
}
)
event = _make_state_event(payload)
account = _fake_account(signer_type="RemoteBunkerSigner")
signer = _FakeBunkerSigner(_OP_SEC)
recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
assert sorted(recovered.positions.keys()) == [1, 2]
assert recovered.positions[1].denomination == 20
assert recovered.positions[1].count == 49
assert recovered.positions[2].denomination == 50
assert recovered.positions[2].count == 100
def test_round_trips_multiple_same_denomination(self):
"""v1.1 operational case (coord-log 2026-05-30T18:45Z) — multiple
bays carrying the same denomination."""
payload = PublishCassettesPayload(
positions={
"1": {"denomination": 20, "count": 100},
"2": {"denomination": 20, "count": 100},
"3": {"denomination": 20, "count": 100},
"4": {"denomination": 20, "count": 100},
}
)
event = _make_state_event(payload)
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
assert len(recovered.positions) == 4
for pos in (1, 2, 3, 4):
assert recovered.positions[pos].denomination == 20
assert recovered.positions[pos].count == 100
# =============================================================================
# decrypt_and_parse_state_event — LocalSigner transitional fallback
# =============================================================================
class TestDecryptViaLocalSignerFallback:
"""When the operator account is still on LocalSigner (pre-bunker
migration), the LocalSigner stub raises SignerUnavailableError from
nip44_decrypt. `_nip44_decrypt_via_signer` catches that and falls
back to the hand-rolled impl using `account.prvkey`. Same wire
output; transitional until S7 retires LocalSigner accounts entirely."""
def test_localsigner_with_prvkey_decrypts_via_fallback(self):
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account(signer_type="LocalSigner", prvkey=_OP_SEC)
signer = _FakeLocalSignerStub()
recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
assert recovered.positions[1].denomination == 20
assert recovered.positions[1].count == 49
def test_localsigner_without_prvkey_raises_decode_error(self):
"""A LocalSigner account whose prvkey field is None (impossible
in practice LocalSigner requires prvkey at provision time, but
defensive in case the row is corrupt) should surface as a
decode error, not silently succeed."""
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account(signer_type="LocalSigner", prvkey=None)
signer = _FakeLocalSignerStub()
with pytest.raises(CassetteEventDecodeError):
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_clientonlysigner_raises_decode_error(self):
"""ClientSideOnlySigner has no server-side decrypt path at all;
falling back to direct-prvkey is also impossible (no prvkey).
Surface as a decode error so the consumer logs + skips."""
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account(signer_type="ClientSideOnlySigner", prvkey=None)
signer = _FakeLocalSignerStub() # behaves the same way: raises
with pytest.raises(CassetteEventDecodeError):
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
# =============================================================================
# decrypt_and_parse_state_event — bunker error mapping
# =============================================================================
class TestBunkerErrorMapping:
"""The post-#38 error hierarchy splits transient (bunker partitioned)
from terminal (bunker policy reject, MAC failure). Consumer behaves
differently transient retries, terminal logs + skips. Validate the
mapping from NsecBunker* exceptions to our CassetteEvent* types."""
def test_timeout_maps_to_transient_error(self):
"""Bunker unreachable → NsecBunkerTimeoutError → caller-visible
CassetteEventTransientError. Consumer treats this as retry-
eligible (don't advance state_event_id)."""
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account()
signer = _FakeRaisingSigner(NsecBunkerTimeoutError("bunker unreachable"))
with pytest.raises(CassetteEventTransientError):
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_rpc_reject_maps_to_decode_error(self):
"""Bunker rejected the RPC (policy / MAC / config) →
NsecBunkerRpcError caller-visible CassetteEventDecodeError.
Terminal retrying won't help."""
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account()
signer = _FakeRaisingSigner(
NsecBunkerRpcError("bunker policy reject: kind 30078 not authorised")
)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
# =============================================================================
# decrypt_and_parse_state_event — payload + envelope validation
# =============================================================================
class TestPayloadValidation:
"""Errors that originate at the parse layer (post-decrypt), not the
signer. Same set as pre-migration covered through the bunker-signer
path since LocalSigner is going away."""
def test_tampered_content_rejected(self):
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
event["content"] = event["content"][:-2] + "AA"
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_wrong_signer_privkey_rejected(self):
"""Wrong privkey on the signer → wrong conversation key → MAC
verification fails inside nip44_decrypt surfaces as decode
error (via the hand-rolled Nip44Error since this is the fake
bunker signer; in production the bunker would raise
NsecBunkerRpcError which also maps to CassetteEventDecodeError)."""
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account()
wrong_sec = "00" * 31 + "03"
signer = _FakeBunkerSigner(wrong_sec)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_missing_content_rejected(self):
event = _make_state_event(
PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 49}})
)
del event["content"]
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_missing_pubkey_rejected(self):
event = _make_state_event(
PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 49}})
)
del event["pubkey"]
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_decrypted_garbage_json_rejected(self):
"""If plaintext decrypts cleanly but isn't valid JSON, surface
as decode error (not crash the consumer loop)."""
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
event = {
"kind": 30078,
"pubkey": _ATM_PUB,
"content": encrypt_with_conversation_key("definitely not json", ck),
"tags": [],
"created_at": 0,
"id": "x",
}
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
def test_decrypted_wrong_shape_rejected(self):
"""Well-formed JSON but missing 'positions' → payload-shape
validation failure."""
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
event = {
"kind": 30078,
"pubkey": _ATM_PUB,
"content": encrypt_with_conversation_key('{"wrong_field": 42}', ck),
"tags": [],
"created_at": 0,
"id": "x",
}
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(decrypt_and_parse_state_event(event, account, signer))
# =============================================================================
# d-tag construction — unchanged by the migration, kept as regression guard
# =============================================================================
class TestDTagConstruction:
"""The `<m>` placeholder in d-tags = ATM hex pubkey (load-bearing per
coord-log 2026-05-30T11:50Z). These tests pin the canonical
substitution so a refactor can't silently break wire compatibility."""
def _machine(self, npub: str, id_: str = "m1") -> Machine:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
return Machine(
id=id_,
operator_user_id="op1",
machine_npub=npub,
wallet_id="w1",
name=None,
location=None,
fiat_code="EUR",
is_active=True,
created_at=now,
updated_at=now,
)
def test_atm_hex_pubkey_from_hex_storage(self):
assert _atm_hex_pubkey(self._machine(_ATM_PUB)) == _ATM_PUB
def test_atm_hex_pubkey_lowercases_uppercase_hex(self):
assert _atm_hex_pubkey(self._machine(_ATM_PUB.upper())) == _ATM_PUB
def test_atm_hex_pubkey_canonicalises_bech32_to_hex(self):
from lnbits.utils.nostr import hex_to_npub
npub_bech32 = hex_to_npub(_ATM_PUB)
assert _atm_hex_pubkey(self._machine(npub_bech32)) == _ATM_PUB
def test_config_d_tag_uses_hex_pubkey_not_id(self):
"""REGRESSION GUARD: d-tag must contain the ATM hex pubkey, NOT
the internal machine UUID."""
m = self._machine(_ATM_PUB, id_="some-uuid-not-the-pubkey")
d_tag = _config_d_tag(_atm_hex_pubkey(m))
assert d_tag == f"bitspire-cassettes:{_ATM_PUB}"
assert "some-uuid" not in d_tag
def test_state_d_tag_uses_hex_pubkey_not_id(self):
m = self._machine(_ATM_PUB, id_="some-uuid-not-the-pubkey")
d_tag = _state_d_tag(_atm_hex_pubkey(m))
assert d_tag == f"bitspire-cassettes-state:{_ATM_PUB}"
assert "some-uuid" not in d_tag
def test_build_state_d_tags_for_machines(self):
atm2_pub = _pub_hex("00" * 31 + "03")
machines = [
self._machine(_ATM_PUB, id_="m1"),
self._machine(atm2_pub, id_="m2"),
]
tags = build_state_d_tags_for_machines(machines)
assert tags == [
f"bitspire-cassettes-state:{_ATM_PUB}",
f"bitspire-cassettes-state:{atm2_pub}",
]

View file

@ -1,124 +0,0 @@
"""
Tests for `views_api._assert_no_pubkey_collision` (aiolabs/satmachineadmin#32).
Defends against the silent-drop failure mode reproduced on 2026-05-30T21:33Z:
Greg's operator account pubkey had been seeded identical to the Sintra ATM's
machine_npub, which masked the routing problem until Greg's pubkey rotated
during the bunker migration then `auto-account-from-npub` fired for the
orphaned ATM npub and the cash-out invoice silently landed on a fresh
auto-account wallet.
The guard refuses to register a machine whose npub matches any LNbits
operator account's `accounts.pubkey`, so this state cannot be entered
through the satmachineadmin UI in the first place.
Monkeypatches `views_api.get_account_by_pubkey` to avoid needing a live
LNbits DB; this matches the assertion-style of tests/test_nostr_attribution
(both isolate the assertion function for unit-testability).
"""
import asyncio
from types import SimpleNamespace
import pytest
from .. import views_api
from ..views_api import _assert_no_pubkey_collision
# Canonical x-only pubkey for the integer 1 secret (matches NIP-44 reference vector).
_PUBKEY_HEX = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
# Bech32 form of the same pubkey — operators may enter either form in the UI.
_PUBKEY_NPUB = "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"
def _fake_account(pubkey: str = _PUBKEY_HEX):
"""Account-shaped duck-typed object. _assert_no_pubkey_collision only
cares whether get_account_by_pubkey returns non-None; the returned
shape doesn't matter beyond that."""
return SimpleNamespace(id="op1", username="alice", pubkey=pubkey)
def _patch_lookup(monkeypatch, return_value):
"""Replace `views_api.get_account_by_pubkey` with an async stub that
captures the canonical-hex argument the guard normalised to and
returns the configured value."""
captured = {}
async def fake_lookup(pubkey: str):
captured["called_with"] = pubkey
return return_value
monkeypatch.setattr(views_api, "get_account_by_pubkey", fake_lookup)
return captured
class TestCollisionDetected:
"""Positive cases: machine_npub collides with an operator account's
pubkey. Each form (hex / bech32 / uppercase) must normalise to the
same canonical lookup + raise the same 400."""
def test_collision_with_hex_input_raises(self, monkeypatch):
_patch_lookup(monkeypatch, return_value=_fake_account())
with pytest.raises(Exception) as exc:
asyncio.run(_assert_no_pubkey_collision(_PUBKEY_HEX))
assert exc.value.status_code == 400
assert "collides with an existing LNbits operator account" in exc.value.detail
assert "aiolabs/satmachineadmin#32" in exc.value.detail
def test_collision_with_bech32_input_raises(self, monkeypatch):
"""Operator may enter `npub1...` in the UI; the guard must
canonicalise to hex BEFORE the lookup, otherwise a colliding
npub-form input would silently miss the hex-stored
accounts.pubkey row."""
captured = _patch_lookup(monkeypatch, return_value=_fake_account())
with pytest.raises(Exception) as exc:
asyncio.run(_assert_no_pubkey_collision(_PUBKEY_NPUB))
assert exc.value.status_code == 400
# The bech32 input must be canonicalised to lowercase hex before the lookup.
assert captured["called_with"] == _PUBKEY_HEX
def test_collision_with_uppercase_hex_input_raises(self, monkeypatch):
"""Hex inputs from manual entry / paste can land uppercase; the
guard's `normalize_public_key().lower()` should bring it to the
canonical lowercase hex that get_account_by_pubkey itself also
lowercases internally."""
captured = _patch_lookup(monkeypatch, return_value=_fake_account())
with pytest.raises(Exception) as exc:
asyncio.run(_assert_no_pubkey_collision(_PUBKEY_HEX.upper()))
assert exc.value.status_code == 400
assert captured["called_with"] == _PUBKEY_HEX
class TestNoCollision:
"""Negative cases: machine_npub does not match any account → guard
returns silently, machine creation can proceed."""
def test_no_collision_returns_silently(self, monkeypatch):
_patch_lookup(monkeypatch, return_value=None)
# Should NOT raise.
asyncio.run(_assert_no_pubkey_collision(_PUBKEY_HEX))
def test_no_collision_bech32_form_returns_silently(self, monkeypatch):
captured = _patch_lookup(monkeypatch, return_value=None)
asyncio.run(_assert_no_pubkey_collision(_PUBKEY_NPUB))
# The lookup still gets called with the canonicalised hex form.
assert captured["called_with"] == _PUBKEY_HEX
class TestErrorMessage:
"""The 400 detail must be operator-actionable: explains the failure,
points at the issue, and gives the remediation path."""
def test_error_includes_truncated_pubkey(self, monkeypatch):
_patch_lookup(monkeypatch, return_value=_fake_account())
with pytest.raises(Exception) as exc:
asyncio.run(_assert_no_pubkey_collision(_PUBKEY_HEX))
# First 12 chars of the canonical lowercase hex, followed by an ellipsis.
assert _PUBKEY_HEX[:12] in exc.value.detail
def test_error_includes_remediation_hint(self, monkeypatch):
_patch_lookup(monkeypatch, return_value=_fake_account())
with pytest.raises(Exception) as exc:
asyncio.run(_assert_no_pubkey_collision(_PUBKEY_HEX))
assert "lamassu-next" in exc.value.detail
assert "ATM_PRIVATE_KEY" in exc.value.detail

View file

@ -1,56 +0,0 @@
"""
Locks in the contract from `aiolabs/satmachineadmin#26`: a deposit's
currency is bound to its machine's `fiat_code`, never operator-chooseable.
The mechanism is two-layered:
1. `CreateDepositData` / `UpdateDepositData` Pydantic models don't
accept a `currency` field any value a client submits is dropped
at validation, before reaching the handler.
2. The `api_create_deposit` endpoint resolves the machine's
`fiat_code` server-side and passes it to `create_deposit(
..., currency=...)`.
This test covers layer 1 (the model contract). Layer 2 is an
endpoint-level behaviour better covered by an integration test against
a running LNbits; tracked in #26 as a follow-up.
"""
from ..models import CreateDepositData, UpdateDepositData
def test_create_deposit_data_has_no_currency_field():
"""A client posting `{currency: "USD"}` against an EUR machine must
have that field silently dropped by validation there's no public
way to inject the wrong currency through this endpoint."""
fields = CreateDepositData.__fields__
assert "currency" not in fields, (
f"CreateDepositData must not expose a `currency` field "
f"(found {list(fields)})"
)
def test_update_deposit_data_has_no_currency_field():
"""Same protection on the edit path: a pending deposit can have
its amount / notes edited, but never its currency that's bound
to the machine."""
fields = UpdateDepositData.__fields__
assert "currency" not in fields, (
f"UpdateDepositData must not expose a `currency` field "
f"(found {list(fields)})"
)
def test_create_deposit_data_drops_unknown_currency_silently():
"""Pydantic's default `Config` ignores unknown fields, so a stray
`currency` on the request body parses cleanly without leaving
a trace on the resulting model. Belt-and-braces locks in the
"input has no way to influence the currency" guarantee."""
data = CreateDepositData(
client_id="c1",
machine_id="m1",
amount=20.0,
currency="USD", # ignored — field doesn't exist on the model
)
assert not hasattr(data, "currency")
assert data.amount == 20.0
assert data.machine_id == "m1"

View file

@ -1,208 +0,0 @@
"""
Tests for `views_api._assert_machine_fee_cap_safe` and
`_assert_super_config_cap_safe` (aiolabs/satmachineadmin#38, Layer 1).
Per-direction cap is locked at 15% (super + operator) per coord-log
§2026-06-01T07:22Z. Both helpers enforce the same cap from the
opposite direction:
- machine_fee_cap_safe runs at machine create/update; pairs candidate
operator fractions against the current super-config
- super_config_cap_safe runs at super-config update; pairs candidate
super fractions against every active machine's operator fractions
and names the first offender so the super-admin can fix the
triggering machine
Tests monkeypatch the CRUD lookups directly same shape as
test_collision_guard.py so the validators are unit-testable without
a live LNbits DB.
"""
import asyncio
from types import SimpleNamespace
import pytest
from .. import views_api
from ..views_api import (
_assert_machine_fee_cap_safe,
_assert_super_config_cap_safe,
)
def _super_config(in_frac: float = 0.0, out_frac: float = 0.0):
"""Duck-typed super-config row carrying just the two directional fields
the cap helpers read."""
return SimpleNamespace(
super_cash_in_fee_fraction=in_frac,
super_cash_out_fee_fraction=out_frac,
)
def _machine(
machine_id: str,
op_in: float,
op_out: float,
name: str | None = None,
npub: str = "a" * 64,
):
return SimpleNamespace(
id=machine_id,
operator_cash_in_fee_fraction=op_in,
operator_cash_out_fee_fraction=op_out,
name=name,
machine_npub=npub,
)
def _patch_super(monkeypatch, value):
async def fake_get():
return value
monkeypatch.setattr(views_api, "get_super_config", fake_get)
def _patch_machines(monkeypatch, machines: list):
async def fake_list():
return machines
monkeypatch.setattr(views_api, "list_all_active_machines", fake_list)
# ---------------------------------------------------------------------------
# _assert_machine_fee_cap_safe — candidate operator fractions vs current super
# ---------------------------------------------------------------------------
class TestMachineFeeCapSafe:
def test_cash_in_cap_exceeded_raises(self, monkeypatch):
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.05))
# 0.10 + 0.06 = 0.16 > 0.15 → reject
with pytest.raises(Exception) as exc:
asyncio.run(_assert_machine_fee_cap_safe(0.06, 0.05))
assert exc.value.status_code == 400
assert "cash-in fee cap exceeded" in exc.value.detail
def test_cash_out_cap_exceeded_raises(self, monkeypatch):
_patch_super(monkeypatch, _super_config(in_frac=0.05, out_frac=0.10))
# 0.10 + 0.06 = 0.16 > 0.15 → reject
with pytest.raises(Exception) as exc:
asyncio.run(_assert_machine_fee_cap_safe(0.05, 0.06))
assert exc.value.status_code == 400
assert "cash-out fee cap exceeded" in exc.value.detail
def test_at_exact_cap_passes(self, monkeypatch):
"""The cap check is `>`, not `>=` — operators may set exactly
15% on either direction without rejection."""
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.10))
asyncio.run(_assert_machine_fee_cap_safe(0.05, 0.05))
def test_no_super_config_treats_super_as_zero(self, monkeypatch):
"""Uninitialised instance (super_config = None) → only operator
counts. Cap then degenerates to a pure operator-fee check."""
_patch_super(monkeypatch, None)
# 0.14 alone is under cap → pass
asyncio.run(_assert_machine_fee_cap_safe(0.14, 0.14))
# 0.16 alone exceeds cap → reject
with pytest.raises(Exception) as exc:
asyncio.run(_assert_machine_fee_cap_safe(0.16, 0.05))
assert exc.value.status_code == 400
def test_well_under_cap_passes_silently(self, monkeypatch):
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
# Should not raise.
asyncio.run(_assert_machine_fee_cap_safe(0.0333, 0.0777))
def test_zero_operator_under_zero_super_passes(self, monkeypatch):
"""Free-charge ATM corner case — operator deliberately sets 0
on both directions, super is 0 on both. Cap of 0 0.15."""
_patch_super(monkeypatch, _super_config(in_frac=0.0, out_frac=0.0))
asyncio.run(_assert_machine_fee_cap_safe(0.0, 0.0))
def test_error_detail_includes_cap_value(self, monkeypatch):
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.0))
with pytest.raises(Exception) as exc:
asyncio.run(_assert_machine_fee_cap_safe(0.10, 0.0))
# 0.10 + 0.10 = 0.20 > 0.15
assert "0.15" in exc.value.detail
# ---------------------------------------------------------------------------
# _assert_super_config_cap_safe — candidate super fractions vs all machines
# ---------------------------------------------------------------------------
class TestSuperConfigCapSafe:
def test_offending_machine_raises_and_is_named(self, monkeypatch):
"""When a super-fee bump pushes one machine over the cap, the
rejection names that machine so the super-admin knows which
operator's per-machine config blocks the change."""
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
_patch_machines(
monkeypatch,
[
_machine("m1", op_in=0.01, op_out=0.02, name="Cafe A"),
_machine("m2", op_in=0.10, op_out=0.02, name="Greedy ATM"),
],
)
# New super_in = 0.06. m2 has op_in 0.10 → 0.16 > cap.
with pytest.raises(Exception) as exc:
asyncio.run(_assert_super_config_cap_safe(0.06, None))
assert exc.value.status_code == 400
assert "Greedy ATM" in exc.value.detail or "m2" in exc.value.detail
def test_all_machines_under_cap_passes(self, monkeypatch):
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
_patch_machines(
monkeypatch,
[
_machine("m1", op_in=0.05, op_out=0.05, name="Cafe A"),
_machine("m2", op_in=0.03, op_out=0.03, name="Cafe B"),
],
)
# Bump super to 0.08/0.08 → max total = 0.13 + 0.13 = both under cap.
asyncio.run(_assert_super_config_cap_safe(0.08, 0.08))
def test_none_direction_pulls_current_value(self, monkeypatch):
"""Caller passes new_super_in=None → check uses current super_in
value. Confirms partial-update semantics caller can change
cash-out alone without retransmitting cash-in."""
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.03))
_patch_machines(monkeypatch, [_machine("m1", op_in=0.06, op_out=0.0)])
# Skipping in (None) but op_in=0.06 + current super_in=0.10 = 0.16 > cap.
with pytest.raises(Exception) as exc:
asyncio.run(_assert_super_config_cap_safe(None, 0.05))
assert exc.value.status_code == 400
def test_no_machines_passes(self, monkeypatch):
"""Cap check across an empty fleet is vacuously safe."""
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.10))
_patch_machines(monkeypatch, [])
asyncio.run(_assert_super_config_cap_safe(0.12, 0.12))
def test_no_super_config_with_machines_uses_zero(self, monkeypatch):
"""Uninitialised super + new fractions → cap check still runs
against the candidate new values + each machine's operator
fractions."""
_patch_super(monkeypatch, None)
_patch_machines(
monkeypatch,
[_machine("m1", op_in=0.10, op_out=0.0, name="Cafe A")],
)
# 0.06 + 0.10 = 0.16 > cap.
with pytest.raises(Exception) as exc:
asyncio.run(_assert_super_config_cap_safe(0.06, 0.0))
assert exc.value.status_code == 400
def test_uses_machine_id_when_name_missing(self, monkeypatch):
"""Machines without a `name` set fall back to the id (or npub
prefix) for the error message operator-actionable in either
case."""
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
_patch_machines(
monkeypatch,
[_machine("unnamed-machine-id", op_in=0.10, op_out=0.0, name=None)],
)
with pytest.raises(Exception) as exc:
asyncio.run(_assert_super_config_cap_safe(0.06, None))
assert "unnamed-machine-id" in exc.value.detail

View file

@ -1,179 +0,0 @@
"""
Tests for `dca_settlements.fee_mismatch_sats` Phase-1 observability
(aiolabs/satmachineadmin#38, coord-log §2026-06-01T07:00Z — option A
locked: always record, no enforce_fee_match gate).
Each settlement records:
fee_mismatch_sats = bitspire_fee_sats - (platform_fee_sats + operator_fee_sats)
Positive = bitspire over-reported (claimed more fee than satmachineadmin
recomputed against principal). Negative = bitspire under-reported.
Zero = exact match.
Tolerance for the WARN log is `max(1, int(principal_sats * 0.001))`
1-sat floor, 0.1% relative ceiling. Sub-tolerance drift records the
delta silently; over-tolerance drift logs a WARNING. The delta is
recorded unconditionally regardless of tolerance sub-tolerance data
is still useful triage data once aggregated.
Phase 2 (settlement-reject on out-of-tolerance) is a follow-up; this
layer is observability-only.
"""
from datetime import datetime
from ..bitspire import parse_settlement
from ..models import Machine, SuperConfig
_NOW = datetime(2026, 6, 1, 12, 0, 0)
def _machine(op_out: float = 0.0) -> Machine:
return Machine(
id="m1",
operator_user_id="op1",
machine_npub="a" * 64,
wallet_id="w1",
name="Test",
location=None,
fiat_code="EUR",
is_active=True,
operator_cash_in_fee_fraction=0.0,
operator_cash_out_fee_fraction=op_out,
created_at=_NOW,
updated_at=_NOW,
)
def _super_config(out_frac: float = 0.0) -> SuperConfig:
return SuperConfig(
id="default",
super_cash_in_fee_fraction=0.0,
super_cash_out_fee_fraction=out_frac,
super_fee_wallet_id="super-wallet",
updated_at=_NOW,
)
def _bitspire_extra(principal_sats: int, fee_sats: int) -> dict:
return {
"source": "bitspire",
"type": "cash_out",
"principal_sats": principal_sats,
"fee_sats": fee_sats,
"fee_fraction": fee_sats / principal_sats if principal_sats else 0.0,
"exchange_rate": 0.00001,
"fiat_amount": 100.0,
"currency": "EUR",
"txid": "fake-txid",
"nostr_sender_pubkey": "a" * 64,
}
def _parse(machine, super_cfg, principal_sats, fee_sats):
"""Helper: build extra + invoke parse_settlement with cash-out wire
invariant (wire = principal + fee)."""
extra = _bitspire_extra(principal_sats, fee_sats)
return parse_settlement(
machine=machine,
payment_hash="ph_test",
wire_sats=principal_sats + fee_sats,
extra=extra,
super_config=super_cfg,
)
class TestFeeMismatchSatsRecording:
def test_zero_mismatch_when_bitspire_matches_recompute(self):
"""super=3%, operator=5%, total=8%. Bitspire reports
principal=100_000 fee=8_000 100_000 * 0.08 = 8_000 mismatch=0."""
machine = _machine(op_out=0.05)
super_cfg = _super_config(out_frac=0.03)
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=8_000)
assert data.platform_fee_sats == 3_000
assert data.operator_fee_sats == 5_000
assert data.fee_mismatch_sats == 0
def test_positive_mismatch_when_bitspire_over_reports(self):
"""super=3%, operator=5% → expected=8_000. Bitspire claims 9_000.
Delta = +1_000 (over-reported)."""
machine = _machine(op_out=0.05)
super_cfg = _super_config(out_frac=0.03)
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=9_000)
assert data.fee_mismatch_sats == 1_000
def test_negative_mismatch_when_bitspire_under_reports(self):
"""super=3%, operator=5% → expected=8_000. Bitspire claims 7_000.
Delta = -1_000 (under-reported)."""
machine = _machine(op_out=0.05)
super_cfg = _super_config(out_frac=0.03)
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=7_000)
assert data.fee_mismatch_sats == -1_000
def test_pre_layer3_records_large_delta(self):
"""Real-world Phase-1 scenario before Layer 3 (lamassu-next#57)
ships: ATM hardcodes 7.77% cash-out; operator configures 5%
operator + 3% super = 8% total. Bitspire reports
100_000 * 0.0777 = 7_770 sats; satmachineadmin recomputes 8_000.
Delta is large and visible for triage; behavior unchanged."""
machine = _machine(op_out=0.05)
super_cfg = _super_config(out_frac=0.03)
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=7_770)
# Expected = 3_000 + 5_000 = 8_000; bitspire claims 7_770.
assert data.fee_mismatch_sats == -230
class TestFeeMismatchWarningLogging:
"""Tolerance = max(1, int(principal_sats * 0.001)).
For principal=100_000 tolerance=100. For principal=500 tolerance=1.
Uses the `loguru_capture` fixture (defined in conftest.py) to read
the WARN log line pytest's `caplog` only sees stdlib logging,
and `capsys` misses loguru's pre-bound stderr sink.
"""
def test_within_tolerance_does_not_warn(self, loguru_capture):
"""1-sat delta at principal=100_000 → tolerance=100 → no warn."""
machine = _machine(op_out=0.05)
super_cfg = _super_config(out_frac=0.03)
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=8_001)
assert data.fee_mismatch_sats == 1
# Still recorded — the delta is small, the WARN is suppressed.
assert not any("fee mismatch" in m.lower() for m in loguru_capture)
def test_outside_tolerance_logs_warning(self, loguru_capture):
"""101-sat delta at principal=100_000 → tolerance=100 → warns."""
machine = _machine(op_out=0.05)
super_cfg = _super_config(out_frac=0.03)
# bitspire claims 8_101 (= expected 8_000 + 101 over)
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=8_101)
assert data.fee_mismatch_sats == 101
assert any("fee mismatch" in m.lower() for m in loguru_capture)
def test_warning_includes_diagnostic_fields(self, loguru_capture):
"""WARN log line must carry the fields a triage-time operator
needs: bitspire's claim, the expected total, the delta, the
principal, both fractions, and tx_type."""
machine = _machine(op_out=0.05)
super_cfg = _super_config(out_frac=0.03)
_parse(machine, super_cfg, principal_sats=100_000, fee_sats=9_000)
log_text = "".join(loguru_capture)
assert "bitspire_fee_sats=9000" in log_text
assert "expected=8000" in log_text
assert "delta=1000" in log_text
assert "principal=100000" in log_text
assert "tx_type=cash_out" in log_text
def test_one_sat_floor_warns_on_tiny_principal(self, loguru_capture):
"""At principal=500, tolerance=max(1, 0.5)=1. A 2-sat delta
triggers the warning the floor exists so tiny-principal
settlements don't go un-policed."""
machine = _machine(op_out=0.05)
super_cfg = _super_config(out_frac=0.03)
# principal=500 → expected fee = 500 * 0.08 = 40 sats.
# Bitspire claims 42 → delta=2. Tolerance=max(1, 0)=1. Warns.
data = _parse(machine, super_cfg, principal_sats=500, fee_sats=42)
assert data.fee_mismatch_sats == 2
assert any("fee mismatch" in m.lower() for m in loguru_capture)

View file

@ -1,391 +0,0 @@
"""
Tests for the three views_api trigger points that publish fee config
to ATMs via fee_transport (aiolabs/satmachineadmin#39 Layer 2):
1. api_create_machine publish always after create (so ATM unblocks
past `awaiting-fees` maintenance, even with default 0/0 operator
fees that produce a super-only payload)
2. api_update_machine publish only when either operator fee fraction
changes (skip on name/location/wallet_id/is_active-only edits)
3. api_update_super_config publish to every active machine when
either super fraction changes, signed by each machine's operator
Tests monkeypatch `views_api.publish_fee_config` with a recording stub
to verify the trigger fired (or not) and what arguments it received.
The publisher itself is exercised by test_fee_transport.py these
tests are about the wiring.
"""
import asyncio
from datetime import datetime
from .. import views_api
from ..models import CreateMachineData, Machine, SuperConfig, UpdateMachineData
_NOW = datetime(2026, 6, 1, 12, 0, 0)
_ATM_PUBKEY_HEX = (
"522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
)
_OP_USER_ID = "ac35c9fc842f40f0a0e9809347cd24d1"
def _machine(
machine_id: str = "m1",
npub: str = _ATM_PUBKEY_HEX,
op_in: float = 0.0,
op_out: float = 0.0,
operator_user_id: str = _OP_USER_ID,
) -> Machine:
return Machine(
id=machine_id,
operator_user_id=operator_user_id,
machine_npub=npub,
wallet_id="w1",
name=f"machine-{machine_id}",
location=None,
fiat_code="EUR",
is_active=True,
operator_cash_in_fee_fraction=op_in,
operator_cash_out_fee_fraction=op_out,
created_at=_NOW,
updated_at=_NOW,
)
def _super(in_frac: float = 0.03, out_frac: float = 0.03) -> SuperConfig:
return SuperConfig(
id="default",
super_cash_in_fee_fraction=in_frac,
super_cash_out_fee_fraction=out_frac,
super_fee_wallet_id="super-wallet",
updated_at=_NOW,
)
class _PublishRecorder:
"""Records every (machine.id, super_in, super_out, operator) tuple
publish_fee_config was called with. Drop-in stub for monkeypatching
`views_api.publish_fee_config`."""
def __init__(self):
self.calls: list[tuple[str, float, float, float, float, str]] = []
async def __call__(self, machine, super_config, operator_user_id):
self.calls.append(
(
machine.id,
float(super_config.super_cash_in_fee_fraction),
float(super_config.super_cash_out_fee_fraction),
float(machine.operator_cash_in_fee_fraction),
float(machine.operator_cash_out_fee_fraction),
operator_user_id,
)
)
return {"id": f"evt_{machine.id}", "kind": 30078}
# ---------------------------------------------------------------------------
# Trigger 1: api_create_machine
# ---------------------------------------------------------------------------
class TestCreateMachineTrigger:
def test_publishes_on_create_with_default_operator_fees(self, monkeypatch):
"""Default 0/0 operator fees — payload carries super-only totals.
Publish fires anyway so the ATM gets initial config and can
boot past maintenance."""
recorder = _PublishRecorder()
machine = _machine(op_in=0.0, op_out=0.0)
async def fake_assert_wallet(*args, **kwargs):
return None
async def fake_assert_collision(*args, **kwargs):
return None
async def fake_assert_fee_cap(*args, **kwargs):
return None
async def fake_create_machine(user_id, data):
return machine
async def fake_get_super():
return _super()
monkeypatch.setattr(views_api, "_assert_wallet_owned_by", fake_assert_wallet)
monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_assert_collision)
monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", fake_assert_fee_cap)
monkeypatch.setattr(views_api, "create_machine", fake_create_machine)
monkeypatch.setattr(views_api, "get_super_config", fake_get_super)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
# Build a CreateMachineData + fake User and invoke the endpoint.
from types import SimpleNamespace
data = CreateMachineData(
machine_npub=_ATM_PUBKEY_HEX,
wallet_id="w1",
name="sintra",
)
user = SimpleNamespace(id=_OP_USER_ID)
result = asyncio.run(views_api.api_create_machine(data=data, user=user))
assert result is machine
assert len(recorder.calls) == 1
assert recorder.calls[0] == ("m1", 0.03, 0.03, 0.0, 0.0, _OP_USER_ID)
def test_publishes_on_create_with_nonzero_operator_fees(self, monkeypatch):
recorder = _PublishRecorder()
machine = _machine(op_in=0.05, op_out=0.05)
async def passthrough(*args, **kwargs):
return None
async def fake_create_machine(user_id, data):
return machine
async def fake_get_super():
return _super(0.03, 0.03)
monkeypatch.setattr(views_api, "_assert_wallet_owned_by", passthrough)
monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", passthrough)
monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", passthrough)
monkeypatch.setattr(views_api, "create_machine", fake_create_machine)
monkeypatch.setattr(views_api, "get_super_config", fake_get_super)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
from types import SimpleNamespace
data = CreateMachineData(
machine_npub=_ATM_PUBKEY_HEX,
wallet_id="w1",
operator_cash_in_fee_fraction=0.05,
operator_cash_out_fee_fraction=0.05,
)
user = SimpleNamespace(id=_OP_USER_ID)
asyncio.run(views_api.api_create_machine(data=data, user=user))
assert recorder.calls == [("m1", 0.03, 0.03, 0.05, 0.05, _OP_USER_ID)]
def test_no_super_config_skips_publish(self, monkeypatch):
"""If the super-config singleton is missing (impossible in
practice since m001 inserts it), skip the publish rather than
crash the create. Machine still created."""
recorder = _PublishRecorder()
machine = _machine()
async def passthrough(*args, **kwargs):
return None
async def fake_create_machine(user_id, data):
return machine
async def fake_get_super():
return None
monkeypatch.setattr(views_api, "_assert_wallet_owned_by", passthrough)
monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", passthrough)
monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", passthrough)
monkeypatch.setattr(views_api, "create_machine", fake_create_machine)
monkeypatch.setattr(views_api, "get_super_config", fake_get_super)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
from types import SimpleNamespace
data = CreateMachineData(machine_npub=_ATM_PUBKEY_HEX, wallet_id="w1")
user = SimpleNamespace(id=_OP_USER_ID)
result = asyncio.run(views_api.api_create_machine(data=data, user=user))
assert result is machine
assert recorder.calls == []
# ---------------------------------------------------------------------------
# Trigger 2: api_update_machine
# ---------------------------------------------------------------------------
def _wire_update_machine_patches(
monkeypatch, existing_machine, updated_machine, recorder
):
"""Common setup for api_update_machine tests."""
async def passthrough(*args, **kwargs):
return None
async def fake_get_machine(machine_id):
return existing_machine
async def fake_update_machine(machine_id, data):
return updated_machine
async def fake_get_super():
return _super(0.03, 0.03)
monkeypatch.setattr(views_api, "_assert_wallet_owned_by", passthrough)
monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", passthrough)
monkeypatch.setattr(views_api, "get_machine", fake_get_machine)
monkeypatch.setattr(views_api, "update_machine", fake_update_machine)
monkeypatch.setattr(views_api, "get_super_config", fake_get_super)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
class TestUpdateMachineTrigger:
def test_publishes_when_operator_cash_in_changes(self, monkeypatch):
recorder = _PublishRecorder()
existing = _machine(op_in=0.05, op_out=0.05)
updated = _machine(op_in=0.07, op_out=0.05)
_wire_update_machine_patches(monkeypatch, existing, updated, recorder)
from types import SimpleNamespace
data = UpdateMachineData(operator_cash_in_fee_fraction=0.07)
user = SimpleNamespace(id=_OP_USER_ID)
asyncio.run(
views_api.api_update_machine(machine_id="m1", data=data, user=user)
)
assert len(recorder.calls) == 1
assert recorder.calls[0] == ("m1", 0.03, 0.03, 0.07, 0.05, _OP_USER_ID)
def test_publishes_when_operator_cash_out_changes(self, monkeypatch):
recorder = _PublishRecorder()
existing = _machine(op_in=0.05, op_out=0.05)
updated = _machine(op_in=0.05, op_out=0.08)
_wire_update_machine_patches(monkeypatch, existing, updated, recorder)
from types import SimpleNamespace
data = UpdateMachineData(operator_cash_out_fee_fraction=0.08)
user = SimpleNamespace(id=_OP_USER_ID)
asyncio.run(
views_api.api_update_machine(machine_id="m1", data=data, user=user)
)
assert len(recorder.calls) == 1
assert recorder.calls[0] == ("m1", 0.03, 0.03, 0.05, 0.08, _OP_USER_ID)
def test_no_publish_when_only_name_changes(self, monkeypatch):
"""Name / location / fiat_code / is_active / wallet_id changes
don't affect the fee model the ATM enforces — skip the
republish to avoid relay churn."""
recorder = _PublishRecorder()
existing = _machine()
updated = _machine() # same fees
_wire_update_machine_patches(monkeypatch, existing, updated, recorder)
from types import SimpleNamespace
data = UpdateMachineData(name="new name")
user = SimpleNamespace(id=_OP_USER_ID)
asyncio.run(
views_api.api_update_machine(machine_id="m1", data=data, user=user)
)
assert recorder.calls == []
def test_no_publish_when_only_is_active_changes(self, monkeypatch):
recorder = _PublishRecorder()
_wire_update_machine_patches(monkeypatch, _machine(), _machine(), recorder)
from types import SimpleNamespace
data = UpdateMachineData(is_active=False)
user = SimpleNamespace(id=_OP_USER_ID)
asyncio.run(
views_api.api_update_machine(machine_id="m1", data=data, user=user)
)
assert recorder.calls == []
# ---------------------------------------------------------------------------
# Trigger 3: api_update_super_config
# ---------------------------------------------------------------------------
class TestSuperConfigUpdateTrigger:
def test_publishes_to_every_active_machine_on_super_fraction_change(
self, monkeypatch
):
"""A super-fee change ripples to every active machine since each
machine's total = super + machine.operator. Republish per-machine
with that machine's operator as the signer (machines owned by
different operators sign with different keys)."""
recorder = _PublishRecorder()
new_super = _super(in_frac=0.04, out_frac=0.04)
machines = [
_machine(machine_id="m1", operator_user_id="op_A"),
_machine(machine_id="m2", operator_user_id="op_B", op_in=0.05, op_out=0.07),
_machine(machine_id="m3", operator_user_id="op_A", op_in=0.02, op_out=0.02),
]
async def fake_assert_cap(*args, **kwargs):
return None
async def fake_update_super(data):
return new_super
async def fake_list_active():
return machines
monkeypatch.setattr(
views_api, "_assert_super_config_cap_safe", fake_assert_cap
)
monkeypatch.setattr(views_api, "update_super_config", fake_update_super)
monkeypatch.setattr(
views_api, "list_all_active_machines", fake_list_active
)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
from types import SimpleNamespace
from ..models import UpdateSuperConfigData
data = UpdateSuperConfigData(super_cash_in_fee_fraction=0.04)
user = SimpleNamespace(id="super_admin")
asyncio.run(views_api.api_update_super_config(data=data, _user=user))
assert len(recorder.calls) == 3
# Verify each call carries the NEW super fractions + that
# machine's operator + own fees
assert recorder.calls[0] == ("m1", 0.04, 0.04, 0.0, 0.0, "op_A")
assert recorder.calls[1] == ("m2", 0.04, 0.04, 0.05, 0.07, "op_B")
assert recorder.calls[2] == ("m3", 0.04, 0.04, 0.02, 0.02, "op_A")
def test_no_publish_when_only_wallet_id_changes(self, monkeypatch):
"""Changing super_fee_wallet_id without touching either fraction
doesn't affect any ATM's fee model skip the fleet-wide
republish."""
recorder = _PublishRecorder()
new_super = _super(in_frac=0.03, out_frac=0.03)
async def fake_assert_cap(*args, **kwargs):
return None
async def fake_update_super(data):
return new_super
async def fake_list_active():
raise AssertionError(
"list_all_active_machines should not be called when "
"no fraction changed"
)
monkeypatch.setattr(
views_api, "_assert_super_config_cap_safe", fake_assert_cap
)
monkeypatch.setattr(views_api, "update_super_config", fake_update_super)
monkeypatch.setattr(
views_api, "list_all_active_machines", fake_list_active
)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
from types import SimpleNamespace
from ..models import UpdateSuperConfigData
data = UpdateSuperConfigData(super_fee_wallet_id="new-wallet")
user = SimpleNamespace(id="super_admin")
asyncio.run(views_api.api_update_super_config(data=data, _user=user))
assert recorder.calls == []

View file

@ -1,325 +0,0 @@
"""
Tests for `fee_transport.py` and `models.FeeConfigPayload`
Layer 2 of the operator-configurable fee architecture
(aiolabs/satmachineadmin#39).
Three concerns covered:
1. FeeConfigPayload validators enforce the locked wire-format
invariants (cap 0.15 per direction, components sum matches totals,
schema_version 1).
2. `build_fee_payload(super_config, machine)` composes a payload
from current DB rows. Wraps construction + validation in one call.
3. `publish_fee_config(machine, super_config, operator_user_id)`
soft-fail discipline: transport errors log + return None, hard
errors (cap-violating state) propagate.
"""
from datetime import datetime
import pytest
from .. import fee_transport
from ..fee_transport import build_fee_payload, publish_fee_config
from ..models import FeeConfigPayload, FeePayloadComponents, Machine, SuperConfig
from ..nostr_publish import (
OperatorIdentityMissing,
RelayUnavailable,
SignerUnavailable,
)
_NOW = datetime(2026, 6, 1, 12, 0, 0)
_ATM_PUBKEY_HEX = (
"522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
)
def _machine(op_in: float = 0.05, op_out: float = 0.05) -> Machine:
return Machine(
id="m1",
operator_user_id="op1",
machine_npub=_ATM_PUBKEY_HEX,
wallet_id="w1",
name="sintra",
location=None,
fiat_code="EUR",
is_active=True,
operator_cash_in_fee_fraction=op_in,
operator_cash_out_fee_fraction=op_out,
created_at=_NOW,
updated_at=_NOW,
)
def _super(in_frac: float = 0.03, out_frac: float = 0.03) -> SuperConfig:
return SuperConfig(
id="default",
super_cash_in_fee_fraction=in_frac,
super_cash_out_fee_fraction=out_frac,
super_fee_wallet_id="super-wallet",
updated_at=_NOW,
)
# ---------------------------------------------------------------------------
# FeeConfigPayload — wire-format validators
# ---------------------------------------------------------------------------
class TestFeeConfigPayloadValidators:
def _components(
self, s_in: float = 0.03, s_out: float = 0.03, o_in: float = 0.05, o_out: float = 0.05
) -> FeePayloadComponents:
return FeePayloadComponents(
super_cash_in=s_in,
super_cash_out=s_out,
operator_cash_in=o_in,
operator_cash_out=o_out,
)
def test_well_formed_payload_accepts(self):
payload = FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.08,
cash_out_fee_fraction=0.08,
components=self._components(),
)
assert payload.schema_version == 1
assert payload.cash_in_fee_fraction == 0.08
assert payload.cash_out_fee_fraction == 0.08
def test_to_wire_dict_round_trips(self):
original = FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.08,
cash_out_fee_fraction=0.1077,
components=self._components(o_out=0.0777),
)
wire = original.to_wire_dict()
rebuilt = FeeConfigPayload(**wire)
assert rebuilt.cash_in_fee_fraction == 0.08
assert rebuilt.cash_out_fee_fraction == 0.1077
assert rebuilt.components.operator_cash_out == 0.0777
def test_cap_violation_cash_in_rejects(self):
# cap is 0.15 per direction.
with pytest.raises(ValueError, match="fee fraction must be in"):
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.16,
cash_out_fee_fraction=0.08,
components=self._components(s_in=0.10, o_in=0.06),
)
def test_cap_violation_cash_out_rejects(self):
with pytest.raises(ValueError, match="fee fraction must be in"):
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.08,
cash_out_fee_fraction=0.20,
components=self._components(s_out=0.10, o_out=0.10),
)
def test_exact_cap_accepted(self):
"""0.15 exactly is the upper bound — must accept."""
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.15,
cash_out_fee_fraction=0.15,
components=self._components(s_in=0.10, s_out=0.10, o_in=0.05, o_out=0.05),
)
def test_inconsistent_total_vs_components_rejects_cash_in(self):
"""sum(super_cash_in + operator_cash_in) must equal
cash_in_fee_fraction within 1e-6."""
with pytest.raises(ValueError, match="cash_in_fee_fraction"):
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.09, # claims 9%
cash_out_fee_fraction=0.08,
components=self._components(), # actually 0.03 + 0.05 = 0.08
)
def test_inconsistent_total_vs_components_rejects_cash_out(self):
with pytest.raises(ValueError, match="cash_out_fee_fraction"):
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.08,
cash_out_fee_fraction=0.10, # claims 10%
components=self._components(), # actually 0.08
)
def test_schema_version_zero_rejects(self):
with pytest.raises(ValueError, match="schema_version must be"):
FeeConfigPayload(
schema_version=0,
cash_in_fee_fraction=0.08,
cash_out_fee_fraction=0.08,
components=self._components(),
)
def test_zero_fractions_accepted(self):
"""Free-charge ATM — both super + operator at 0 → totals 0."""
FeeConfigPayload(
schema_version=1,
cash_in_fee_fraction=0.0,
cash_out_fee_fraction=0.0,
components=self._components(s_in=0.0, s_out=0.0, o_in=0.0, o_out=0.0),
)
# ---------------------------------------------------------------------------
# build_fee_payload — composition from SuperConfig + Machine
# ---------------------------------------------------------------------------
class TestBuildFeePayload:
def test_basic_composition(self):
payload = build_fee_payload(_super(0.03, 0.03), _machine(0.05, 0.05))
assert payload.cash_in_fee_fraction == 0.08
assert payload.cash_out_fee_fraction == 0.08
assert payload.components.super_cash_in == 0.03
assert payload.components.operator_cash_in == 0.05
def test_different_directions(self):
"""Cash-in and cash-out can differ — payload preserves both."""
payload = build_fee_payload(_super(0.03, 0.05), _machine(0.0333, 0.0777))
assert payload.cash_in_fee_fraction == 0.0633
assert payload.cash_out_fee_fraction == 0.1277
def test_super_only_no_operator(self):
"""Pre-Layer-2 default — machine has 0/0 operator fees; payload
carries super-only totals. This is the 'publish on machine create'
path's expected shape."""
payload = build_fee_payload(_super(0.03, 0.03), _machine(0.0, 0.0))
assert payload.cash_in_fee_fraction == 0.03
assert payload.cash_out_fee_fraction == 0.03
def test_cap_violation_at_build_time_raises(self):
"""If the API guards were bypassed and the DB has a cap-violating
state, build_fee_payload refuses rather than ship a bad payload."""
with pytest.raises(ValueError, match="fee fraction must be in"):
build_fee_payload(_super(0.10, 0.03), _machine(0.10, 0.0))
# 0.10 + 0.10 = 0.20 > 0.15
# ---------------------------------------------------------------------------
# publish_fee_config — soft-fail discipline
# ---------------------------------------------------------------------------
class TestPublishFeeConfigSoftFail:
def test_relay_unavailable_returns_none_logs_warning(
self, monkeypatch, loguru_capture
):
async def fake_publish(**kwargs):
raise RelayUnavailable("nostrclient extension is not installed")
monkeypatch.setattr(
fee_transport, "publish_encrypted_kind_30078", fake_publish
)
import asyncio
result = asyncio.run(publish_fee_config(_machine(), _super(), "op1"))
assert result is None
assert any(
"soft-fail" in m and "RelayUnavailable" in m for m in loguru_capture
)
def test_signer_unavailable_returns_none_logs_warning(
self, monkeypatch, loguru_capture
):
async def fake_publish(**kwargs):
raise SignerUnavailable("bunker unreachable")
monkeypatch.setattr(
fee_transport, "publish_encrypted_kind_30078", fake_publish
)
import asyncio
result = asyncio.run(publish_fee_config(_machine(), _super(), "op1"))
assert result is None
assert any(
"soft-fail" in m and "SignerUnavailable" in m for m in loguru_capture
)
def test_operator_identity_missing_returns_none_logs_warning(
self, monkeypatch, loguru_capture
):
"""OperatorIdentityMissing is a NostrPublishError but not a
transport one currently soft-fails at the same layer. The
caller may want to convert this to HTTP 400 in future if the
operator-facing UX needs a hard signal, but v1 keeps it soft
because a partially-onboarded operator shouldn't crash machine
create."""
async def fake_publish(**kwargs):
raise OperatorIdentityMissing("no pubkey on file")
monkeypatch.setattr(
fee_transport, "publish_encrypted_kind_30078", fake_publish
)
import asyncio
result = asyncio.run(publish_fee_config(_machine(), _super(), "op1"))
assert result is None
def test_publish_success_returns_signed_event(self, monkeypatch):
signed = {
"id": "ev1",
"kind": 30078,
"pubkey": "op_pubkey",
"content": "ciphertext",
"tags": [["d", f"bitspire-fees:{_ATM_PUBKEY_HEX}"], ["p", _ATM_PUBKEY_HEX]],
"created_at": 1780000000,
"sig": "ff" * 32,
}
captured = {}
async def fake_publish(**kwargs):
captured.update(kwargs)
return signed
monkeypatch.setattr(
fee_transport, "publish_encrypted_kind_30078", fake_publish
)
import asyncio
result = asyncio.run(publish_fee_config(_machine(), _super(), "op1"))
assert result is signed
# Verify d-tag matches the locked spec
assert captured["d_tag"] == f"bitspire-fees:{_ATM_PUBKEY_HEX}"
assert captured["recipient_pubkey_hex"] == _ATM_PUBKEY_HEX
# Payload shape carries components per the §14:25Z lock
payload = captured["payload"]
assert payload["schema_version"] == 1
assert payload["cash_in_fee_fraction"] == 0.08
assert "components" in payload
def test_cap_violation_raises_does_not_soft_fail(self, monkeypatch):
"""build_fee_payload raises ValueError at construction time on
cap-violating state. That's a hard configuration error (API
guards bypassed), not a transient transport issue, so it
propagates. publish_encrypted_kind_30078 is never reached."""
called = {"count": 0}
async def fake_publish(**kwargs):
called["count"] += 1
return {}
monkeypatch.setattr(
fee_transport, "publish_encrypted_kind_30078", fake_publish
)
import asyncio
with pytest.raises(ValueError, match="fee fraction must be in"):
asyncio.run(
publish_fee_config(
_machine(op_in=0.10, op_out=0.0),
_super(in_frac=0.10, out_frac=0.0),
"op1",
)
)
assert called["count"] == 0

11
tests/test_init.py Normal file
View file

@ -0,0 +1,11 @@
import pytest
from fastapi import APIRouter
from .. import satmachineadmin_ext
# just import router and add it to a test router
@pytest.mark.asyncio
async def test_router():
router = APIRouter()
router.include_router(satmachineadmin_ext)

View file

@ -1,390 +0,0 @@
"""
Tests for the hand-rolled NIP-44 v2 implementation in `nip44.py`.
Three layers of validation, ordered by trust:
1. Pinned reference vector from the canonical paulmillr/nip44 test suite
the conversation_key for (sec=1, sec=2) is widely-published as
c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d. If
our get_conversation_key() ever drifts from that value, the impl is
broken at the key-derivation layer.
2. Round-trip + tamper detection verifies the encrypt/decrypt loop
under random nonces, catches HMAC + version + padding tampering.
3. Cross-test (TBD) bitspire will post one sample event encrypted on
their nostr-tools side to the coord log; test_decrypts_bitspire_sample
wires it as a fixture and asserts byte-compatibility with the
nostr-tools NIP-44 v2 impl. Placeholder stub until the sample lands.
"""
import base64
import coincurve
import pytest
from ..nip44 import (
Nip44LengthError,
Nip44MacError,
Nip44VersionError,
_calc_padded_len,
decrypt_from,
decrypt_with_conversation_key,
encrypt_for,
encrypt_with_conversation_key,
get_conversation_key,
)
# Helper: derive a compressed-x-coord pubkey hex string from a secret hex.
def _pub_hex(sec_hex: str) -> str:
return (
coincurve.PrivateKey(bytes.fromhex(sec_hex))
.public_key.format(compressed=True)[1:]
.hex()
)
# Canonical test keys widely used across NIP-44 reference vectors.
_SEC_ONE = "00" * 31 + "01" # integer 1
_SEC_TWO = "00" * 31 + "02" # integer 2
_PUB_ONE = _pub_hex(_SEC_ONE)
_PUB_TWO = _pub_hex(_SEC_TWO)
# =============================================================================
# Layer 1 — pinned reference vector (paulmillr/nip44)
# =============================================================================
class TestConversationKeyReferenceVector:
"""Pinned reference vector from the canonical NIP-44 v2 test suite
(paulmillr/nip44). If get_conversation_key drifts from this value we
have a key-derivation regression fail loudly."""
REFERENCE_CK_HEX = (
"c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d"
)
def test_sec_one_pub_two(self):
ck = get_conversation_key(_SEC_ONE, _PUB_TWO)
assert ck.hex() == self.REFERENCE_CK_HEX
def test_sec_two_pub_one_is_symmetric(self):
"""Conversation key is symmetric: ck(privA, pubB) == ck(privB, pubA).
Both sides of a NIP-44 conversation derive the identical PRK; this
is what lets the recipient decrypt with their own privkey + the
sender's pubkey."""
ck_ab = get_conversation_key(_SEC_ONE, _PUB_TWO)
ck_ba = get_conversation_key(_SEC_TWO, _PUB_ONE)
assert ck_ab == ck_ba
# =============================================================================
# Layer 2 — round-trip + tamper detection
# =============================================================================
class TestRoundTrip:
"""Encrypt then decrypt under the high-level pair-keyed API."""
@pytest.mark.parametrize(
"plaintext",
[
"a", # 1 byte (minimum)
"hello, nip44 v2", # short
"x" * 32, # exactly the small-payload boundary
"x" * 33, # just over
"y" * 1000, # medium
"z" * 5000, # large
'{"denominations": {"20": {"position": 1, "count": 49}}}', # realistic
],
)
def test_round_trip_various_lengths(self, plaintext):
payload = encrypt_for(plaintext, _SEC_ONE, _PUB_TWO)
recovered = decrypt_from(payload, _SEC_TWO, _PUB_ONE)
assert recovered == plaintext
def test_payloads_are_unique_under_random_nonce(self):
"""Same plaintext + same key pair should produce different payloads
each time because the nonce is fresh CSPRNG bytes. Catches a
regression where the nonce is accidentally pinned."""
plaintext = "the same message"
p1 = encrypt_for(plaintext, _SEC_ONE, _PUB_TWO)
p2 = encrypt_for(plaintext, _SEC_ONE, _PUB_TWO)
assert p1 != p2
assert decrypt_from(p1, _SEC_TWO, _PUB_ONE) == plaintext
assert decrypt_from(p2, _SEC_TWO, _PUB_ONE) == plaintext
def test_pinned_nonce_is_deterministic(self):
"""Same plaintext + same key pair + same nonce = byte-identical
payload. Regression-locks the chacha20 + hmac chain."""
ck = get_conversation_key(_SEC_ONE, _PUB_TWO)
nonce = bytes(32) # 32 zero bytes
p1 = encrypt_with_conversation_key("a", ck, nonce=nonce)
p2 = encrypt_with_conversation_key("a", ck, nonce=nonce)
assert p1 == p2
assert decrypt_with_conversation_key(p1, ck) == "a"
class TestTamperDetection:
"""HMAC-SHA256 verification catches tampered envelopes. The cryptographic
construction depends on this if HMAC verification ever no-ops, a
relay-MITM could forge ATM state events."""
def _payload(self) -> str:
return encrypt_for("important message", _SEC_ONE, _PUB_TWO)
def test_flipped_mac_byte_rejected(self):
raw = bytearray(base64.b64decode(self._payload()))
raw[-1] ^= 0x01
tampered = base64.b64encode(bytes(raw)).decode("ascii")
with pytest.raises(Nip44MacError):
decrypt_from(tampered, _SEC_TWO, _PUB_ONE)
def test_flipped_ciphertext_byte_rejected(self):
raw = bytearray(base64.b64decode(self._payload()))
# Flip a byte in the middle of the ciphertext segment
# (version[1] + nonce[32..32] + ciphertext[33..-32] + mac[-32..])
ct_start = 1 + 32
raw[ct_start + 5] ^= 0x01
tampered = base64.b64encode(bytes(raw)).decode("ascii")
with pytest.raises(Nip44MacError):
decrypt_from(tampered, _SEC_TWO, _PUB_ONE)
def test_flipped_nonce_byte_rejected(self):
raw = bytearray(base64.b64decode(self._payload()))
# Nonce starts at byte 1 (after version)
raw[1] ^= 0x01
tampered = base64.b64encode(bytes(raw)).decode("ascii")
with pytest.raises(Nip44MacError):
decrypt_from(tampered, _SEC_TWO, _PUB_ONE)
def test_wrong_recipient_privkey_rejected(self):
"""The MAC is derived from the conversation key, so a wrong
recipient privkey produces a different conversation key
different hmac_key MAC verification fails. (Doesn't decrypt
to garbage; fails fast.)"""
sec_three = "00" * 31 + "03"
with pytest.raises(Nip44MacError):
decrypt_from(self._payload(), sec_three, _PUB_ONE)
class TestVersionRejection:
def test_v1_byte_rejected(self):
raw = bytearray(base64.b64decode(encrypt_for("x", _SEC_ONE, _PUB_TWO)))
raw[0] = 0x01
bad = base64.b64encode(bytes(raw)).decode("ascii")
with pytest.raises(Nip44VersionError):
decrypt_from(bad, _SEC_TWO, _PUB_ONE)
def test_unknown_version_byte_rejected(self):
raw = bytearray(base64.b64decode(encrypt_for("x", _SEC_ONE, _PUB_TWO)))
raw[0] = 0xFF
bad = base64.b64encode(bytes(raw)).decode("ascii")
with pytest.raises(Nip44VersionError):
decrypt_from(bad, _SEC_TWO, _PUB_ONE)
class TestLengthGuards:
def test_empty_plaintext_rejected(self):
with pytest.raises(Nip44LengthError):
encrypt_for("", _SEC_ONE, _PUB_TWO)
def test_plaintext_at_max_length_accepted(self):
plaintext = "x" * 65535
payload = encrypt_for(plaintext, _SEC_ONE, _PUB_TWO)
assert decrypt_from(payload, _SEC_TWO, _PUB_ONE) == plaintext
def test_plaintext_over_max_rejected(self):
with pytest.raises(Nip44LengthError):
encrypt_for("x" * 65536, _SEC_ONE, _PUB_TWO)
def test_invalid_base64_payload_rejected(self):
with pytest.raises(Nip44LengthError):
decrypt_from("not!!!base64@@@", _SEC_TWO, _PUB_ONE)
def test_payload_too_short_rejected(self):
# 50 bytes is well under the 99-byte minimum
too_short = base64.b64encode(b"\x02" + b"\x00" * 49).decode("ascii")
with pytest.raises(Nip44LengthError):
decrypt_from(too_short, _SEC_TWO, _PUB_ONE)
class TestPaddingFormula:
"""Spot-check the _calc_padded_len formula against hand-computed cases.
Locks in the NIP-44 v2 padding scheme so a refactor can't silently
break wire compatibility (which would only surface as cross-impl
decryption failures exactly what test_decrypts_bitspire_sample is
meant to catch end-to-end, but a unit test here is cheaper)."""
@pytest.mark.parametrize(
"plaintext_len,expected_padded",
[
(1, 32), # <= 32 → 32
(16, 32),
(32, 32),
(33, 64), # > 32 → next chunk
(64, 64),
(
65,
96,
), # chunk = 32 for L=65 (next_power(64) = 64; 64//8 = 8; max(32, 8) = 32)
(100, 128),
(128, 128),
# L=129: next_power(128) = 1<<8 = 256; chunk = max(32, 256//8) = 32;
# padded = 32 * (128//32 + 1) = 32 * 5 = 160.
(129, 160),
(256, 256), # chunk = 32 for L=256 (next_power(255)=256; max(32, 32) = 32)
(257, 320),
(
1000,
1024,
), # chunk = 128 for L=1000 (next_power(999)=1024; max(32, 128) = 128)
],
)
def test_calc_padded_len(self, plaintext_len, expected_padded):
assert _calc_padded_len(plaintext_len) == expected_padded
# =============================================================================
# Layer 3 — byte-compat cross-test against nostr-tools (bitspire's impl)
# =============================================================================
# -----------------------------------------------------------------------------
# Bitspire-side v1.1 fixture, posted to ~/dev/coordination/log.md at
# 2026-05-30T19:00Z. Positions-keyed wire shape per the v1.1 redesign
# (18:30Z + 18:45Z); intentionally includes two positions sharing
# denomination=20 to exercise the multi-same-denom round-trip on our
# decrypt + payload-validate path. Throwaway keypairs (one-shot, never
# sign anything else) — safe to embed verbatim.
# Generated by apps/machine/src/services/operator-config.ts-shape code
# path using the @bitSpire/nostr-client encryptContentV2 +
# createSignedEvent helpers (same code the production bootstrap publish
# uses). Round-tripped on bitspire side via decryptContentV2 before posting.
# -----------------------------------------------------------------------------
_BITSPIRE_FIXTURE = {
"atm_keypair": {
"privkey_hex": (
"814e6188d017102bbf301ba5b38fba95b2556dc79a60df4cd50605c4593578e6"
),
"pubkey_hex": (
"217bdc9a65b571c4d9b59da6227a7aa6ca5bbfd5280af791417c57a79d92852b"
),
},
"operator_keypair": {
"privkey_hex": (
"cca7dd9fe4874f6b9f3f3fae21648da686b7e714bfd4786e8fa8745933fd3185"
),
"pubkey_hex": (
"49bd8e615769f8b6a5aa8ce9617b919996abecf234599ba196789461cf239146"
),
},
"expected_plaintext": {
"positions": {
"1": {"denomination": 20, "count": 49},
"2": {"denomination": 20, "count": 38},
"3": {"denomination": 50, "count": 100},
},
},
"event": {
"kind": 30078,
"content": (
"AqOHsCcjN2W8L/Cx0uH+n++VA13W+wy7z1EcuuNX49sSagelX2lI0HEKyd+ActOc"
"iaPsHrp9ecJTkEZOD86ioldbLbEVColJwK4g1uVZSbpDeqRe+97woxVDqPnzj507"
"tFaVLF/dRmda+oKHUzkVPhE4PHQJzp9Fqji38J3nU6N68qo7KOt3qg1nSy5eDfAu"
"zt7djRBx63+/veub0rWTMMQLBgci8+Ms6Y+Zb1mki3L6NWuIR0Or+8DhcD+ZJiOu"
"WTcx"
),
"tags": [
[
"d",
"bitspire-cassettes-state:"
"217bdc9a65b571c4d9b59da6227a7aa6ca5bbfd5280af791417c57a79d92852b",
],
[
"p",
"49bd8e615769f8b6a5aa8ce9617b919996abecf234599ba196789461cf239146",
],
],
"created_at": 1780173222,
"pubkey": ("217bdc9a65b571c4d9b59da6227a7aa6ca5bbfd5280af791417c57a79d92852b"),
"id": ("72c09f333386dd4ad6125f8c69823824eea50d8091b694458bcd60701517eece"),
"sig": (
"07ecafacf0169f074e564a999ee1c31446930b43391d007c4a1f9ef7ad890d6c"
"2aa6e3ecc5318edeb5748fbd64c7ca33407099a97154e2ff7e0c626e48d71925"
),
},
}
class TestBitspireCrossTest:
"""Byte-compat cross-test between our hand-rolled NIP-44 v2 (`nip44.py`)
and the nostr-tools NIP-44 v2 impl that bitspire uses on the ATM side
(via @bitSpire/nostr-client). If these tests pass, the wire format
agrees across both implementations and the joint round-trip (operator
publish ATM apply / ATM bootstrap operator consume) is byte-safe.
If any fail, the spec ambiguity surfaces before sintra ships."""
def test_decrypts_bitspire_sample_event(self):
"""The load-bearing assertion: our `decrypt_from` recovers the
expected `{"positions": {...}}` plaintext from bitspire's encrypted
event content. v1.1 fixture intentionally exercises the multi-same-
denomination round-trip (positions 1 + 2 both hold $20)."""
import json
event = _BITSPIRE_FIXTURE["event"]
operator_privkey = _BITSPIRE_FIXTURE["operator_keypair"]["privkey_hex"]
from ..nip44 import decrypt_from
plaintext = decrypt_from(
event["content"],
operator_privkey,
event["pubkey"],
)
payload = json.loads(plaintext)
assert payload == _BITSPIRE_FIXTURE["expected_plaintext"]
# v1.1 invariant: two positions can carry the same denomination.
# Pin it explicitly so a future "fix" that re-introduces denom-
# uniqueness validation surfaces here instead of as a runtime
# rejection on real machines.
assert payload["positions"]["1"]["denomination"] == 20
assert payload["positions"]["2"]["denomination"] == 20
assert payload["positions"]["1"]["count"] != payload["positions"]["2"]["count"]
def test_signature_verifies_via_lnbits_helper(self):
"""Optional extra per bitspire's 13:15Z note (3). The consumer
path runs verify_event before NIP-44 decrypt locking the sig-
algorithm agreement here means both sides hash the event id the
same way + Schnorr-verify under the same x-only public-key
convention."""
from lnbits.utils.nostr import verify_event
assert verify_event(_BITSPIRE_FIXTURE["event"]) is True
def test_encrypt_round_trip_via_our_impl_decrypts_with_their_keys(self):
"""Optional extra per bitspire's 13:15Z note (3). Encrypt the
expected plaintext using OUR impl with the ATM keypair as
sender + operator pubkey as recipient. The resulting ciphertext
won't be byte-identical to the fixture (NIP-44 v2 nonces are
random) but it MUST decrypt back to the same plaintext when
passed to our decrypt path. Locks the encrypt direction too,
not just decrypt."""
import json
from ..nip44 import decrypt_from, encrypt_for
plaintext = json.dumps(
_BITSPIRE_FIXTURE["expected_plaintext"], separators=(",", ":")
)
atm_sec = _BITSPIRE_FIXTURE["atm_keypair"]["privkey_hex"]
atm_pub = _BITSPIRE_FIXTURE["atm_keypair"]["pubkey_hex"]
op_sec = _BITSPIRE_FIXTURE["operator_keypair"]["privkey_hex"]
op_pub = _BITSPIRE_FIXTURE["operator_keypair"]["pubkey_hex"]
our_ciphertext = encrypt_for(plaintext, atm_sec, op_pub)
recovered = decrypt_from(our_ciphertext, op_sec, atm_pub)
assert json.loads(recovered) == _BITSPIRE_FIXTURE["expected_plaintext"]
# The two ciphertexts SHOULD differ (random nonce per encrypt)
assert our_ciphertext != _BITSPIRE_FIXTURE["event"]["content"]

View file

@ -1,111 +0,0 @@
"""
Tests for `bitspire.assert_nostr_attribution` the S5 consumer-side
cross-check that pairs the signature-verified signer pubkey LNbits
stamps onto Payment.extra (post aiolabs/lnbits PR #4) with the machine
record we're about to credit.
In v2 every bitSpire ATM creates invoices via nostr-transport, so any
inbound payment landing on a `dca_machines` wallet must carry
`extra["nostr_sender_pubkey"]` and that pubkey must canonicalise to
the same hex as `machine.machine_npub`. Anything else raises
`SettlementAttributionError` and the listener records the row with
`status='rejected'` instead of distributing.
"""
from datetime import datetime, timezone
import pytest
from ..bitspire import SettlementAttributionError, assert_nostr_attribution
from ..models import Machine
# A real Nostr pubkey pair (hex + canonical bech32). Throwaway fixture —
# never used to sign anything live.
_PUBKEY_HEX = "82341f882b6eabcbd6b1c2da5cd14df14b8e91dd0e6da41a72b78ad8f3a7d3b9"
_PUBKEY_NPUB = "npub1sg6plzptd64uh443ctd9e52d799caywapek6gxnjk79d3ua86wuszhap5a"
_OTHER_HEX = "deadbeef" * 8
def _machine(npub: str) -> Machine:
now = datetime.now(timezone.utc)
return Machine(
id="m1",
operator_user_id="op1",
machine_npub=npub,
wallet_id="w1",
name="sintra-1",
location=None,
fiat_code="EUR",
is_active=True,
created_at=now,
updated_at=now,
)
def test_returns_silently_when_sender_hex_matches_machine_hex():
assert_nostr_attribution(
_machine(_PUBKEY_HEX),
{"source": "bitspire", "nostr_sender_pubkey": _PUBKEY_HEX},
)
def test_returns_silently_when_sender_hex_matches_machine_bech32():
"""Operator entered npub1... in the UI; LNbits stamps hex. Both must
normalise to the same canonical hex before comparison."""
assert_nostr_attribution(
_machine(_PUBKEY_NPUB),
{"source": "bitspire", "nostr_sender_pubkey": _PUBKEY_HEX},
)
def test_returns_silently_under_case_variance():
assert_nostr_attribution(
_machine(_PUBKEY_HEX.upper()),
{"nostr_sender_pubkey": _PUBKEY_HEX.lower()},
)
@pytest.mark.parametrize(
"extra",
[
{},
{"source": "bitspire"},
{"nostr_sender_pubkey": ""},
{"nostr_sender_pubkey": None},
],
)
def test_raises_when_attribution_absent(extra):
"""Every cash-out invoice goes through nostr-transport in v2; a
settlement reaching a machine wallet without `nostr_sender_pubkey`
means it was issued by some other path (HTTP API, manual UI, a
different extension). Always wrong for a `dca_machines` wallet."""
with pytest.raises(SettlementAttributionError) as exc:
assert_nostr_attribution(_machine(_PUBKEY_HEX), extra)
assert "missing nostr_sender_pubkey" in str(exc.value)
def test_raises_when_sender_differs_from_machine():
with pytest.raises(SettlementAttributionError) as exc:
assert_nostr_attribution(
_machine(_PUBKEY_HEX),
{"nostr_sender_pubkey": _OTHER_HEX},
)
assert "does not match" in str(exc.value)
def test_raises_when_sender_pubkey_unparseable():
with pytest.raises(SettlementAttributionError) as exc:
assert_nostr_attribution(
_machine(_PUBKEY_HEX),
{"nostr_sender_pubkey": "not-a-real-pubkey"},
)
assert "unparseable pubkey" in str(exc.value)
def test_raises_when_machine_npub_unparseable():
with pytest.raises(SettlementAttributionError) as exc:
assert_nostr_attribution(
_machine("not-a-real-pubkey"),
{"nostr_sender_pubkey": _PUBKEY_HEX},
)
assert "unparseable pubkey" in str(exc.value)

View file

@ -1,150 +0,0 @@
"""
Tests for `allocate_operator_split_legs` (operator's commission-leg
distribution) and the partial-dispense ratio math in
`apply_partial_dispense_and_redistribute`.
Both are split-arithmetic concerns that survive the post-#38
principal-based-math refactor:
- `allocate_operator_split_legs` slices the operator's share across
their commission legs by their per-leg fractions. Function-level,
no fee-model coupling.
- Partial-dispense ratio math (in distribution.py) preserves the
ORIGINAL platform/operator ratio recorded against a settlement at
land time when an operator partial-dispenses post-hoc. The ratio
comes from the absolute platform_fee_sats / fee_sats recorded on
the settlement row, NOT the current super-config fractions the
contract is locked at landing.
Pre-#38 tests for `split_two_stage_commission` lived here; that
function was removed when the principal-based math landed
(aiolabs/satmachineadmin#38).
"""
import pytest
from ..calculations import allocate_operator_split_legs
class TestAllocateOperatorSplitLegs:
"""Operator's remaining share split into commission legs by fraction."""
def test_plan_example_50_30_20_on_70(self):
amounts = allocate_operator_split_legs(70, [0.5, 0.3, 0.2])
assert amounts == [35, 21, 14]
def test_realistic_50_30_20_on_5575(self):
amounts = allocate_operator_split_legs(5575, [0.5, 0.3, 0.2])
# Plan-scale: 5575 * (0.5, 0.3, 0.2) = (2787.5, 1672.5, 1115)
# Last leg absorbs rounding remainders so sum == 5575 exactly.
assert sum(amounts) == 5575
assert amounts[0] == round(5575 * 0.5)
assert amounts[1] == round(5575 * 0.3)
# Last leg absorbs the remainder.
assert amounts[2] == 5575 - amounts[0] - amounts[1]
def test_single_leg_full_remainder(self):
amounts = allocate_operator_split_legs(7965, [1.0])
assert amounts == [7965]
def test_zero_operator_fee_zeros_all_legs(self):
amounts = allocate_operator_split_legs(0, [0.5, 0.3, 0.2])
assert amounts == [0, 0, 0]
def test_empty_legs_list_returns_empty(self):
amounts = allocate_operator_split_legs(100, [])
assert amounts == []
def test_last_leg_absorbs_rounding_remainder(self):
# 100 sats split [1/3, 1/3, 1/3] — last leg absorbs the +1 remainder.
amounts = allocate_operator_split_legs(100, [1 / 3, 1 / 3, 1 / 3])
assert sum(amounts) == 100
assert amounts[0] == round(100 / 3) # 33
assert amounts[1] == round(100 / 3) # 33
# Last leg absorbs the rounding (34, not 33) so total == 100.
assert amounts[2] == 100 - amounts[0] - amounts[1]
@pytest.mark.parametrize(
"operator_fee,fractions",
[
(1, [0.5, 0.5]),
(7, [0.5, 0.3, 0.2]),
(100, [0.5, 0.5]),
(5575, [0.5, 0.3, 0.2]),
(1_000_000, [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]),
],
)
def test_invariant_sum_equals_operator_fee(self, operator_fee, fractions):
amounts = allocate_operator_split_legs(operator_fee, fractions)
assert sum(amounts) == operator_fee
assert all(a >= 0 for a in amounts)
class TestPartialDispenseSplitRatio:
"""Partial-dispense recompute (closes #11 H6) must preserve the
ORIGINAL platform/operator ratio recorded on the settlement row at
land time. Super raising or lowering a global rate post-hoc must
NOT retroactively change an existing settlement's share split.
The math is inlined in `apply_partial_dispense_and_redistribute`
(distribution.py) rather than in a standalone function. These tests
mirror the inline math so a future refactor doesn't silently change
the invariant.
"""
def _recompute(self, original_fee, original_platform_fee, new_fee):
"""Mirror of the ratio math in apply_partial_dispense_and_redistribute."""
if original_fee > 0:
ratio = original_platform_fee / original_fee
else:
ratio = 0.0
new_platform = round(new_fee * ratio)
new_platform = max(0, min(new_platform, new_fee))
new_operator = new_fee - new_platform
return new_platform, new_operator
def test_30pct_lands_then_partial(self):
# Landed at platform ratio 30/100 = 0.30; new fee = 50.
# Original ratio preserved → new_platform = round(50 * 0.30) = 15.
new_platform, new_operator = self._recompute(100, 30, 50)
assert new_platform == 15
assert new_operator == 35
assert new_platform + new_operator == 50
def test_super_changed_rate_doesnt_affect_existing_settlement(self):
# Landed with platform=2390, fee=7965 (ratio ≈ 0.30). Super then
# bumps the global rate to 50%. Operator partial-dispenses to
# 50% gross → new_fee = round(7965 * 0.5) = 3982. The 30% ratio
# at land time MUST persist regardless of the new super rate.
new_platform, new_operator = self._recompute(7965, 2390, 3982)
# Expected with original ratio: round(3982 * 0.30006...) = 1195
# With (broken) current rate of 50%: would be 1991 — much higher.
assert 1190 <= new_platform <= 1200
assert new_platform + new_operator == 3982
# Original platform share was ~30%; preserved within rounding.
assert abs(new_platform / 3982 - 2390 / 7965) < 0.001
def test_zero_original_fee_yields_zero_platform(self):
new_platform, new_operator = self._recompute(0, 0, 0)
assert new_platform == 0
assert new_operator == 0
def test_invariant_sum_equals_new_fee(self):
# Random-ish parameter sweep over realistic values.
cases = [
(100, 30, 50),
(100, 0, 50), # original platform_fee was 0
(100, 100, 50), # original platform_fee was full fee
(7965, 2390, 3982),
(7965, 7965, 3982),
(1_000_000, 333_333, 250_000),
]
for orig_comm, orig_plat, new_comm in cases:
new_platform, new_operator = self._recompute(
orig_comm, orig_plat, new_comm
)
assert new_platform + new_operator == new_comm, (
f"sum invariant violated: {orig_comm=} {orig_plat=} "
f"{new_comm=}{new_platform=} {new_operator=}"
)
assert 0 <= new_platform <= new_comm

View file

@ -1,270 +0,0 @@
"""
Tests for the post-#38 principal-based fee split:
- `calculations.split_principal_based(principal_sats, super_frac,
operator_frac)` pure-function math
- `bitspire.parse_settlement` directional dispatch by tx_type
("cash_in" super_cash_in + operator_cash_in;
"cash_out" super_cash_out + operator_cash_out)
The bug this layer closes: pre-#38 math interpreted super_fee_fraction
as fraction-of-fee instead of fraction-of-principal, under-paying the
super by ~13× per cashout. Tests below pin the new math to the
intended fraction-of-principal model and verify the per-direction
routing through parse_settlement.
Fee mismatch recording (`fee_mismatch_sats` column, Phase 1
observability per coord-log §2026-06-01T07:00Z) lands in the next
commit; those tests live in `test_fee_mismatch_recording.py`.
"""
from datetime import datetime
import pytest
from ..bitspire import SettlementInvariantError, parse_settlement
from ..calculations import split_principal_based
from ..models import Machine, SuperConfig
# ---------------------------------------------------------------------------
# split_principal_based — pure-function math
# ---------------------------------------------------------------------------
class TestSplitPrincipalBased:
def test_super_fraction_only(self):
"""Operator at 0% — super takes exactly super_frac of principal,
operator gets 0."""
platform, operator = split_principal_based(100_000, 0.03, 0.0)
assert platform == 3_000
assert operator == 0
def test_operator_fraction_only(self):
"""Super at 0% — operator takes exactly operator_frac of
principal, platform gets 0."""
platform, operator = split_principal_based(100_000, 0.0, 0.05)
assert platform == 0
assert operator == 5_000
def test_both_fractions(self):
"""Both shares independently computed against principal — total
is super + operator, not anchored to any fee_sats input."""
platform, operator = split_principal_based(100_000, 0.03, 0.05)
assert platform == 3_000
assert operator == 5_000
def test_zero_principal_yields_zero_shares(self):
platform, operator = split_principal_based(0, 0.03, 0.05)
assert platform == 0
assert operator == 0
def test_negative_principal_yields_zero_shares(self):
"""Defensive: negative principal can't happen in production but
the function should not produce negative outputs if it ever does."""
platform, operator = split_principal_based(-100, 0.03, 0.05)
assert platform == 0
assert operator == 0
def test_rounding_does_not_compound(self):
"""The two shares round independently — there is no carryover.
On a 1_000_000-sat principal with super=0.0333, operator=0.0777,
each share rounds against principal individually."""
platform, operator = split_principal_based(1_000_000, 0.0333, 0.0777)
assert platform == round(1_000_000 * 0.0333) # 33_300
assert operator == round(1_000_000 * 0.0777) # 77_700
def test_super_frac_out_of_range_raises(self):
with pytest.raises(ValueError, match="super_frac"):
split_principal_based(100_000, 1.5, 0.0)
with pytest.raises(ValueError, match="super_frac"):
split_principal_based(100_000, -0.1, 0.0)
def test_operator_frac_out_of_range_raises(self):
with pytest.raises(ValueError, match="operator_frac"):
split_principal_based(100_000, 0.0, 1.5)
with pytest.raises(ValueError, match="operator_frac"):
split_principal_based(100_000, 0.0, -0.1)
def test_super_under_payment_bug_regression(self):
"""Direct regression test for the bug this layer closes.
Pre-#38 math (deleted): `round(fee_sats * super_fraction)` with
fee_sats=8_000 (= 8% of 100_000 principal) and super_fraction=0.03
produced platform_fee_sats=240 ~13× below intent.
Post-#38 math: split_principal_based(100_000, 0.03, 0.05) gives
platform=3_000, which IS the intended 3% of principal."""
platform, operator = split_principal_based(100_000, 0.03, 0.05)
# Post-#38: super gets intended 3% of principal (3_000 sats)
# Pre-#38 would have produced ~240 sats from round(8000 * 0.03).
assert platform == 3_000
# ---------------------------------------------------------------------------
# parse_settlement — directional dispatch via tx_type
# ---------------------------------------------------------------------------
def _bitspire_extra(
*,
tx_type: str = "cash_out",
principal_sats: int = 100_000,
fee_sats: int = 8_000,
exchange_rate: float = 0.00001,
fiat_amount: float = 100.0,
currency: str = "EUR",
nostr_sender_pubkey: str = "a" * 64,
extra_overrides: dict | None = None,
):
"""Canonical bitspire-stamped Payment.extra dict for tests. Mirrors
the shape required by `is_bitspire_payment` + the canonical sat-
amount invariants in `_assert_sat_invariants`."""
base = {
"source": "bitspire",
"type": tx_type,
"principal_sats": principal_sats,
"fee_sats": fee_sats,
"fee_fraction": fee_sats / principal_sats if principal_sats else 0.0,
"exchange_rate": exchange_rate,
"fiat_amount": fiat_amount,
"currency": currency,
"txid": "fake-txid",
"nostr_sender_pubkey": nostr_sender_pubkey,
}
if extra_overrides:
base.update(extra_overrides)
return base
_NOW = datetime(2026, 6, 1, 12, 0, 0)
def _machine(
machine_id: str = "m1",
machine_npub: str = "a" * 64,
op_in: float = 0.0,
op_out: float = 0.0,
fiat_code: str = "EUR",
) -> Machine:
return Machine(
id=machine_id,
operator_user_id="op1",
machine_npub=machine_npub,
wallet_id="w1",
name="Test",
location=None,
fiat_code=fiat_code,
is_active=True,
operator_cash_in_fee_fraction=op_in,
operator_cash_out_fee_fraction=op_out,
created_at=_NOW,
updated_at=_NOW,
)
def _super_config(in_frac: float = 0.0, out_frac: float = 0.0) -> SuperConfig:
return SuperConfig(
id="default",
super_cash_in_fee_fraction=in_frac,
super_cash_out_fee_fraction=out_frac,
super_fee_wallet_id="super-wallet",
updated_at=_NOW,
)
class TestParseSettlementDirectional:
def test_cash_out_uses_cash_out_fractions(self):
"""tx_type='cash_out' must route to super_cash_out +
operator_cash_out fractions."""
machine = _machine(op_in=0.10, op_out=0.05)
super_cfg = _super_config(in_frac=0.10, out_frac=0.03)
extra = _bitspire_extra(tx_type="cash_out", principal_sats=100_000)
data = parse_settlement(
machine=machine,
payment_hash="ph1",
wire_sats=108_000,
extra=extra,
super_config=super_cfg,
)
# super_cash_out=0.03, operator_cash_out=0.05 against 100_000 principal
assert data.platform_fee_sats == 3_000
assert data.operator_fee_sats == 5_000
assert data.tx_type == "cash_out"
def test_cash_in_uses_cash_in_fractions(self):
"""tx_type='cash_in' must route to super_cash_in +
operator_cash_in fractions (not cash_out)."""
machine = _machine(op_in=0.04, op_out=0.10)
super_cfg = _super_config(in_frac=0.02, out_frac=0.10)
extra = _bitspire_extra(tx_type="cash_in", principal_sats=100_000)
# cash-in wire invariant: wire = principal - fee
data = parse_settlement(
machine=machine,
payment_hash="ph2",
wire_sats=92_000,
extra=extra,
super_config=super_cfg,
)
# super_cash_in=0.02, operator_cash_in=0.04 against 100_000 principal
assert data.platform_fee_sats == 2_000
assert data.operator_fee_sats == 4_000
assert data.tx_type == "cash_in"
def test_unknown_tx_type_raises(self):
machine = _machine()
super_cfg = _super_config()
extra = _bitspire_extra(
tx_type="cash_out",
extra_overrides={"type": "withdrawal"}, # not a known direction
)
with pytest.raises(SettlementInvariantError, match="unknown tx_type"):
parse_settlement(
machine=machine,
payment_hash="ph3",
wire_sats=108_000,
extra=extra,
super_config=super_cfg,
)
def test_zero_fractions_zero_split(self):
"""Free-charge ATM: both super + operator at 0 → platform and
operator fees are both 0, principal is the full take."""
machine = _machine(op_in=0.0, op_out=0.0)
super_cfg = _super_config(in_frac=0.0, out_frac=0.0)
extra = _bitspire_extra(
tx_type="cash_out", principal_sats=100_000, fee_sats=0
)
data = parse_settlement(
machine=machine,
payment_hash="ph4",
wire_sats=100_000,
extra=extra,
super_config=super_cfg,
)
assert data.platform_fee_sats == 0
assert data.operator_fee_sats == 0
assert data.principal_sats == 100_000
def test_cash_in_does_not_use_cash_out_config(self):
"""Cross-direction guard: cash-in must NOT pick up cash-out's
super or operator fractions even when they're set differently.
Pin both directions concretely to prove the dispatch."""
machine = _machine(op_in=0.01, op_out=0.10)
super_cfg = _super_config(in_frac=0.01, out_frac=0.10)
extra = _bitspire_extra(tx_type="cash_in", principal_sats=100_000)
# cash-in wire invariant: wire = principal - fee
data = parse_settlement(
machine=machine,
payment_hash="ph5",
wire_sats=92_000,
extra=extra,
super_config=super_cfg,
)
# Cash-in totals = 0.01 + 0.01 = 0.02; not 0.10 + 0.10 = 0.20
assert data.platform_fee_sats == 1_000 # 100_000 * 0.01
assert data.operator_fee_sats == 1_000 # 100_000 * 0.01

View file

@ -1,163 +0,0 @@
"""
Tests for `nostr_transport_roster.resolve` the lookup function
satmachineadmin hands lnbits' nostr-transport via
`register_roster_resolver` (path-B wallet-routing fix, #20 /
coord-log 2026-05-31T15:25Z).
Verifies:
- Known ATM npub RouteHit with operator_user_id + wallet_id from
the machine row
- Unknown sender None (lnbits falls back to its other resolvers,
or fail-closed rejection per the env-gated posture)
- bech32 input is normalised to hex before lookup
- Uppercase hex input is normalised to lowercase before lookup
- Malformed input raises (fail-closed sub-case per lnbits 15:15Z ack)
`register_with_lnbits` is also smoke-tested for the soft-fail branch
that fires on lnbits versions without `register_roster_resolver`. The
positive (lnbits hook present) branch needs a live lnbits import +
will be covered once the lnbits-side PR lands.
Coroutines driven via `asyncio.run` per project convention (no pytest-
asyncio plugin in CI; see test_cassette_state_consumer.py header).
"""
import asyncio
import sys
from types import SimpleNamespace
import coincurve
import pytest
from lnbits.utils.nostr import hex_to_npub
from .. import nostr_transport_roster as roster
from ..nostr_transport_roster import register_with_lnbits, resolve
_ATM_SEC = "00" * 31 + "02"
_ATM_PUB_HEX = (
coincurve.PrivateKey(bytes.fromhex(_ATM_SEC))
.public_key.format(compressed=True)[1:]
.hex()
)
_ATM_PUB_NPUB = hex_to_npub(_ATM_PUB_HEX)
def _fake_machine(operator_user_id: str, wallet_id: str, npub_hex: str):
return SimpleNamespace(
operator_user_id=operator_user_id,
wallet_id=wallet_id,
machine_npub=npub_hex,
)
def test_resolve_known_atm_returns_route_hit(monkeypatch):
captured: dict[str, str] = {}
async def _fake_lookup(pubkey_hex: str):
captured["pubkey_hex"] = pubkey_hex
return _fake_machine(
operator_user_id="op-123",
wallet_id="wallet-abc",
npub_hex=_ATM_PUB_HEX,
)
monkeypatch.setattr(roster, "get_machine_by_atm_pubkey_hex", _fake_lookup)
result = asyncio.run(resolve(_ATM_PUB_HEX))
# `_build_route_hit` prefers lnbits' canonical `RouteHit` when
# importable + falls back to our local class otherwise; assert on
# the frozen field-shape contract (coord-log 2026-05-31T15:25Z),
# not the specific class identity, so the test passes against
# both lnbits versions.
assert result is not None
assert result.operator_user_id == "op-123"
assert result.wallet_id == "wallet-abc"
assert result.source_extension == "satmachineadmin"
assert captured["pubkey_hex"] == _ATM_PUB_HEX
def test_resolve_unknown_sender_returns_none(monkeypatch):
async def _no_match(pubkey_hex: str):
return None
monkeypatch.setattr(roster, "get_machine_by_atm_pubkey_hex", _no_match)
result = asyncio.run(resolve(_ATM_PUB_HEX))
assert result is None
def test_resolve_canonicalises_bech32_to_hex(monkeypatch):
"""Sender pubkeys arrive lowercase-hex from lnbits PR #4, but the
resolver is paranoid: a bech32 input must still hit the hex-keyed
crud lookup."""
captured: dict[str, str] = {}
async def _fake_lookup(pubkey_hex: str):
captured["pubkey_hex"] = pubkey_hex
return _fake_machine(
operator_user_id="op-bech32",
wallet_id="wallet-bech32",
npub_hex=_ATM_PUB_HEX,
)
monkeypatch.setattr(roster, "get_machine_by_atm_pubkey_hex", _fake_lookup)
result = asyncio.run(resolve(_ATM_PUB_NPUB))
assert result is not None
assert captured["pubkey_hex"] == _ATM_PUB_HEX
def test_resolve_lowercases_uppercase_hex(monkeypatch):
captured: dict[str, str] = {}
async def _fake_lookup(pubkey_hex: str):
captured["pubkey_hex"] = pubkey_hex
return None
monkeypatch.setattr(roster, "get_machine_by_atm_pubkey_hex", _fake_lookup)
asyncio.run(resolve(_ATM_PUB_HEX.upper()))
assert captured["pubkey_hex"] == _ATM_PUB_HEX
def test_resolve_raises_on_malformed_input(monkeypatch):
"""Fail-closed sub-case per lnbits 15:15Z ack item 2: resolver
raising an exception surfaces to lnbits as a reject + ERROR log,
NOT a silent fall-through to auto-account creation."""
async def _unreachable(pubkey_hex: str):
raise AssertionError("crud must not be reached for malformed input")
monkeypatch.setattr(roster, "get_machine_by_atm_pubkey_hex", _unreachable)
with pytest.raises((ValueError, AssertionError)):
asyncio.run(resolve("not-a-pubkey"))
def test_register_with_lnbits_soft_fails_without_hook(monkeypatch):
"""Until the lnbits-side path-B PR lands, the registration call
must soft-fail cleanly (returns False, no exception) so
satmachineadmin keeps booting on every lnbits version."""
real_import = (
__builtins__["__import__"]
if isinstance(__builtins__, dict)
else __builtins__.__import__
)
def _faulty_import(name, *args, **kwargs):
if name == "lnbits.core.services.nostr_transport":
raise ImportError("simulated: pre-path-B lnbits")
return real_import(name, *args, **kwargs)
monkeypatch.setattr("builtins.__import__", _faulty_import)
# Drop any cached import so the lazy `from … import …` inside
# register_with_lnbits re-triggers the import statement.
monkeypatch.delitem(
sys.modules, "lnbits.core.services.nostr_transport", raising=False
)
assert register_with_lnbits() is False

1274
transaction_processor.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,8 @@
# Satoshi Machine v2 — page route.
#
# v2 is operator-installable (any LNbits user, not super-only). The super-only
# check in v1's index() is gone. Super-only controls (platform fee config)
# move to a dedicated API endpoint protected by check_super_user in P1.
# Description: DCA Admin page endpoints.
from fastapi import APIRouter, Depends, Request
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
@ -17,9 +15,13 @@ def satmachineadmin_renderer():
return template_renderer(["satmachineadmin/templates"])
# DCA Admin page - Requires superuser access
@satmachineadmin_generic_router.get("/", response_class=HTMLResponse)
async def index(req: Request, user: User = Depends(check_user_exists)):
return satmachineadmin_renderer().TemplateResponse(
"satmachineadmin/index.html",
{"request": req, "user": user.json()},
if not user.super_user:
raise HTTPException(
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
)
return satmachineadmin_renderer().TemplateResponse(
"satmachineadmin/index.html", {"request": req, "user": user.json()}
)

File diff suppressed because it is too large Load diff