Compare commits
No commits in common. "v2-bitspire" and "main" have entirely different histories.
v2-bitspir
...
main
39 changed files with 4152 additions and 13252 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -2,13 +2,3 @@ __pycache__
|
||||||
node_modules
|
node_modules
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
.venv
|
.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
|
|
||||||
|
|
|
||||||
32
CLAUDE.md
32
CLAUDE.md
|
|
@ -219,38 +219,6 @@ commission_amount = 266800 - 258835 = 7,965 sats (to commission wallet)
|
||||||
- Input sanitization and type validation
|
- Input sanitization and type validation
|
||||||
- Audit logging for all administrative actions
|
- 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
|
## Development Workflow
|
||||||
|
|
||||||
### Adding New Features
|
### Adding New Features
|
||||||
|
|
|
||||||
40
__init__.py
40
__init__.py
|
|
@ -5,17 +5,17 @@ from lnbits.tasks import create_permanent_unique_task
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .crud import db
|
from .crud import db
|
||||||
from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits
|
from .tasks import wait_for_paid_invoices, hourly_transaction_polling
|
||||||
from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices
|
|
||||||
from .views import satmachineadmin_generic_router
|
from .views import satmachineadmin_generic_router
|
||||||
from .views_api import satmachineadmin_api_router
|
from .views_api import satmachineadmin_api_router
|
||||||
|
|
||||||
logger.info("satmachineadmin v2 loaded")
|
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: APIRouter = APIRouter(prefix="/satmachineadmin", tags=["DCA Admin"])
|
||||||
satmachineadmin_ext.include_router(satmachineadmin_generic_router)
|
satmachineadmin_ext.include_router(satmachineadmin_generic_router)
|
||||||
satmachineadmin_ext.include_router(satmachineadmin_api_router)
|
satmachineadmin_ext.include_router(satmachineadmin_api_router)
|
||||||
|
|
||||||
|
|
@ -38,31 +38,19 @@ def satmachineadmin_stop():
|
||||||
|
|
||||||
|
|
||||||
def satmachineadmin_start():
|
def satmachineadmin_start():
|
||||||
# bitSpire invoice listener — replaces the v1 SSH/PostgreSQL poller.
|
# Start invoice listener task
|
||||||
invoice_task = create_permanent_unique_task(
|
invoice_task = create_permanent_unique_task("ext_satmachineadmin", wait_for_paid_invoices)
|
||||||
"ext_satmachineadmin", wait_for_paid_invoices
|
|
||||||
)
|
|
||||||
scheduled_tasks.append(invoice_task)
|
scheduled_tasks.append(invoice_task)
|
||||||
# Cassette bootstrap consumer (#29 v1) — subscribes to
|
|
||||||
# bitspire-cassettes-state events from each active ATM and upserts
|
# Start hourly transaction polling task
|
||||||
# cassette_configs on receipt. Soft-fails if nostrclient isn't
|
polling_task = create_permanent_unique_task("ext_satmachineadmin_polling", hourly_transaction_polling)
|
||||||
# installed (logs + backs off, never crashes).
|
scheduled_tasks.append(polling_task)
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"db",
|
"db",
|
||||||
"satmachineadmin_ext",
|
"satmachineadmin_ext",
|
||||||
"satmachineadmin_start",
|
|
||||||
"satmachineadmin_static_files",
|
"satmachineadmin_static_files",
|
||||||
|
"satmachineadmin_start",
|
||||||
"satmachineadmin_stop",
|
"satmachineadmin_stop",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
343
bitspire.py
343
bitspire.py
|
|
@ -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
|
|
||||||
147
calculations.py
147
calculations.py
|
|
@ -3,25 +3,50 @@ Pure calculation functions for DCA transaction processing.
|
||||||
|
|
||||||
These functions have no external dependencies (no lnbits, no database)
|
These functions have no external dependencies (no lnbits, no database)
|
||||||
and can be easily tested in isolation.
|
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
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
|
||||||
# Per-direction fee cap (super + operator) for any single direction.
|
def calculate_commission(
|
||||||
# Locked at 15% per coord-log §2026-06-01T07:22Z (bitspire) — defense in
|
crypto_atoms: int,
|
||||||
# depth: producer (this side) refuses to publish/persist > cap; consumer
|
commission_percentage: float,
|
||||||
# (bitspire) refuses to apply > cap. See aiolabs/satmachineadmin#37,#38
|
discount: float = 0.0
|
||||||
# and aiolabs/lamassu-next#57.
|
) -> Tuple[int, int, float]:
|
||||||
MAX_FEE_FRACTION_PER_DIRECTION = 0.15
|
"""
|
||||||
|
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(
|
def calculate_distribution(
|
||||||
|
|
@ -106,91 +131,17 @@ def calculate_distribution(
|
||||||
return distributions
|
return distributions
|
||||||
|
|
||||||
|
|
||||||
def split_principal_based(
|
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float:
|
||||||
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).
|
|
||||||
"""
|
"""
|
||||||
if not (0.0 <= super_frac <= 1.0):
|
Calculate exchange rate in sats per fiat unit.
|
||||||
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
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_crypto_atoms: Base amount in sats (after commission)
|
||||||
|
fiat_amount: Fiat amount dispensed
|
||||||
|
|
||||||
def allocate_operator_split_legs(
|
Returns:
|
||||||
operator_fee_sats: int, leg_fractions: list
|
Exchange rate as sats per fiat unit
|
||||||
) -> 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]
|
|
||||||
"""
|
"""
|
||||||
if not leg_fractions:
|
if fiat_amount <= 0:
|
||||||
return []
|
return 0.0
|
||||||
if operator_fee_sats <= 0:
|
return base_crypto_atoms / fiat_amount
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
874
distribution.py
874
distribution.py
|
|
@ -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
|
|
||||||
|
|
@ -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 hand‑waving — 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 multi‑layered fix that capitalises on Nostr instead of bolting on TLS‑style fingerprints.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0 · Why this document exists
|
|
||||||
|
|
||||||
Today the satoshi‑machine code lives at `~/dev/shared/extensions/satmachineadmin` on branch `v2-bitspire`. v2 swapped the legacy Lamassu SSH/PostgreSQL polling model for a Nostr‑native one: bitSpire publishes invoices over kind‑21000 NIP‑44 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 real‑world incidents during dev surfaced this:
|
|
||||||
|
|
||||||
1. A stale `sintra` machine with placeholder npub `npub1111…` was created under a `test` user. A real cash‑in 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 end‑to‑end 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 (NIP‑44 encryption) of it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1 · Glossary (junior‑dev 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 self‑host. 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 pro‑rata via DCA. |
|
|
||||||
| **npub / nsec** | Nostr public / private key, bech32‑encoded. `npub` is shareable; `nsec` is the secret. |
|
|
||||||
| **Relay** | A Nostr pub/sub server. Carries encrypted RPC events between ATM and LNbits. |
|
|
||||||
| **NIP‑XX** | Nostr Implementation Possibility — a numbered protocol extension spec at `~/dev/nostr-protocol/nips/`. |
|
|
||||||
| **kind‑21000** | The event kind bitSpire/LNbits use for encrypted RPC (set by lamassu‑next's nostr‑transport). |
|
|
||||||
| **NIP‑44 v2** | Authenticated encryption for Nostr DMs/RPC (ChaCha20 + HMAC‑SHA256, MAC verified before signature). |
|
|
||||||
| **Payment.extra** | A free‑form JSON dict LNbits stores alongside a payment. **Mutable. Unsigned.** |
|
|
||||||
| **Preimage** | The 32‑byte secret revealed when a Lightning invoice is paid. Unforgeable proof of payment. |
|
|
||||||
| **Settlement** | One bitSpire cash‑in or cash‑out, landed as a `dca_settlements` row in our DB. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2 · Today's pathway — what the bytes actually do
|
|
||||||
|
|
||||||
### 2.1 Cash‑out, 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 (kind‑21000 event) | Yes (NIP‑01 Schnorr sig) | ATM's keypair (= operator's keypair today) | Yes — relays drop unsigned events |
|
|
||||||
| RPC payload | Yes (NIP‑44 v2 MAC + outer sig) | Same key | Yes — handler verifies MAC before decrypt |
|
|
||||||
| LNbits payment ↔ ATM identity | **No** | — | **No** — the link is the auto‑created 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 load‑bearing 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' nostr‑transport when it auto‑created 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 / cash‑out without authorising | Bolt11 is single‑use; preimage settles only once |
|
|
||||||
| T2 | Curious LP | Has wallet admin key for their own LP wallet | See other LPs' balances | Operator‑scoped 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 kind‑21000 events | Censor, replay, reorder | NIP‑44 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 kind‑21000 from a key they generated | Auto‑account‑from‑npub 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 self‑host) |
|
|
||||||
| T9 | Customer at the ATM | Walks up, scans QR | Pay an invoice attributed to a *different* operator's machine | wallet_id routing prevents cross‑operator landing **only if** the invoice was generated for that wallet — confirmed by the stale‑sintra incident: routing is wallet‑level, 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 code‑level audits of `~/dev/shared/extensions/satmachineadmin` (operator‑scoping 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 cross‑operator probes so attackers can't enumerate other operators' resources.
|
|
||||||
- **Settlement idempotency.** `dca_settlements.payment_hash` is `UNIQUE`. A replayed Nostr event / dispatcher double‑fire cannot cause a double payout.
|
|
||||||
- **Optimistic‑lock 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.
|
|
||||||
- **Absolute‑sats 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.
|
|
||||||
- **Append‑only `notes` on settlements.** Partial‑dispense recomputes prepend a timestamped memo; operator notes are timestamped + author‑tagged. Tamper‑evident at the row level.
|
|
||||||
- **NIP‑44 v2 is correctly used in nostr‑transport.** 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 re‑verified 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.** | nostr‑transport handler accepts events up to 10min old | T4: a relay can stash and replay a "create invoice" RPC. NIP‑44 doesn't prevent replay; only NIP‑40 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 server‑side.** | LNbits Account schema | An auto‑created account holds a key it generated. Anyone with DB access can read it. (T6.) |
|
|
||||||
| **G7** | **No signed‑request primitive.** Nothing in nostr‑transport requires a separate, scoped attestation on a payment — just the outer event sig. | nostr‑transport | 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 auto‑account‑from‑npub endpoint. |
|
|
||||||
| **G9** | **No ACL on which npubs may auto‑create accounts.** | nostr‑transport | First contact wins. Combined with G3 + a real‑world incident, this lets a stale/test machine accept real funds. |
|
|
||||||
| **G10** | **Cash‑in path is not wired.** `_handle_payment` filters `is_in=True only`; cash‑in is *outbound* (LNbits pays an LNURL‑withdraw the customer scanned at the ATM). | `tasks.py:57` | Today we'd never know a cash‑in happened. (Out of scope for this doc but flagged.) |
|
|
||||||
|
|
||||||
### 4.3 What's *not* protected by encryption (clarification)
|
|
||||||
|
|
||||||
NIP‑44 v2 makes the *transport* confidential and integrity‑checked. It does **not**:
|
|
||||||
|
|
||||||
- Prove the sender is authorised to act for any party other than themselves (G1, G3).
|
|
||||||
- Prevent replay of an old, legitimately‑signed event (G4).
|
|
||||||
- Bind a Lightning settlement to a particular kind‑21000 RPC after the fact (G7).
|
|
||||||
- Audit who mutated `Payment.extra` after settlement landed (G2, G6).
|
|
||||||
|
|
||||||
Treat NIP‑44 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, time‑bound, 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 | **NIP‑26** (`~/dev/nostr-protocol/nips/26.md`) | Operator never gives their nsec to the ATM. Issues a kind‑bound, time‑bound `delegation` tag instead. |
|
|
||||||
| Settlement attestation | **NIP‑57‑style 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 | **NIP‑40** (`nips/40.md`) | Every RPC carries `["expiration", now+5m]`. Relays drop expired events; handler refuses them. |
|
|
||||||
| Per‑machine config | **NIP‑78** (`nips/78.md`) | `kind:30078` with `d="bitspire-config:<machine_id>"` is the operator‑signed source of truth for per‑machine policy (max withdrawal, allowed relays, fee schedule). ATM fetches on boot; LNbits cross‑checks. |
|
|
||||||
| Future: bunker | **NIP‑46** (`nips/46.md`) | Operator's nsec stays on a phone (Amber) or HSM. ATM gets an ephemeral session key + remote signer. End‑state goal. |
|
|
||||||
|
|
||||||
What we **do not** adopt and why (from the NIP survey):
|
|
||||||
|
|
||||||
- **NIP‑42 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.
|
|
||||||
- **NIP‑59 gift wrap.** Hides metadata from relays but breaks the very auditability we want from NIP‑57‑style receipts. Only useful if anonymity matters more than audit.
|
|
||||||
- **NIP‑32 labels.** Soft moderation signal, not enforcement. Fine as observability; useless as an access gate.
|
|
||||||
|
|
||||||
### 5.1 The new pathway (end‑state)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 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 (junior‑dev framing)
|
|
||||||
|
|
||||||
- **Delegation (NIP‑26) 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 kind‑21000, (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 (NIP‑57 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 (NIP‑40) closes G4.** A 5‑minute window means a captured RPC can't be replayed at 3 a.m. when no human is watching the ATM.
|
|
||||||
- **NIP‑78 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 cross‑checks. Stale `npub1111…` rows can't accept real settlements because they're not in any operator's fleet.
|
|
||||||
- **NIP‑46 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
|
|
||||||
|
|
||||||
- Absolute‑sats fee storage (already audit‑grade).
|
|
||||||
- Operator scoping + 404‑not‑403 ownership pattern.
|
|
||||||
- Settlement idempotency on `payment_hash`.
|
|
||||||
- Optimistic‑lock 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 — Seed‑URL 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 one‑shot 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 NIP‑26 spec. |
|
|
||||||
| **S1 — NIP‑40 expiration on all kind‑21000** | Every RPC carries `["expiration", now+5min]`. Handler refuses past‑expiration. ATM clock check on boot (warn if drift > 60s). | G4 | 1–2 days | Relay must support NIP‑40 (most do). |
|
|
||||||
| **S2 — NIP‑26 delegation enforcement in nostr‑transport** | 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) | 1–2 weeks | LNbits PR upstream (or vendored fork on `aiolabs/lnbits` branch `nostr-transport-nip26`). |
|
|
||||||
| **S3 — NIP‑57‑style settlement receipts** | After LNbits internal payment legs complete, publish a signed receipt event per settlement (and per leg if we want leg‑level audit). ATM subscribes; operator dashboard renders receipts side‑by‑side with `dca_settlements`. | G2, G7 | 1–2 weeks | **Kind allocation — DO NOT USE `kind:21001`.** That kind is claimed by CLINK (Offers) — collision caught during the 2026‑06‑02 CLINK primer review. Rotation off 21001 is tracked at `aiolabs/satmachineadmin#44`; target is the aiolabs reserved band **`22000–22099`** 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: NIP‑57 semantics don't apply to bitSpire cash‑out settlements. |
|
|
||||||
| **S4 — NIP‑78 per‑machine config + fleet roster** | Operator publishes `kind:30078` config + `kind:30000` fleet list. Handler cross‑checks ATM npub ∈ fleet; reads max‑withdraw/fee policy from config. | G1, G9 | 1 week | Define config schema; backwards‑compat path for pre‑NIP‑78 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 post‑write breaks the HMAC. | G2 (DB‑side), G5, G6 | 3–5 days | LNbits PR — fairly localised. |
|
|
||||||
| **S6 — Rate limiting + roster‑gated auto‑account** | Auto‑account‑from‑npub only fires if the npub appears in some operator's NIP‑78 fleet OR if an explicit "open enrollment" flag is set. Relay/handler‑level rate limit per pubkey. | G8, G9 | 1 week | LNbits PR. |
|
|
||||||
| **S7 — NIP‑46 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 | 4–6 weeks | Largest. Defer until S0–S5 land. |
|
|
||||||
| **S8 — Cash‑in path** | Wire `is_out=True` cash‑in handling: LNURL‑withdraw with expiration matching the kind‑21000 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, well‑scoped LNbits patch for S5. S2/S3/S4 are the proper Nostr‑native layer and should land in the sprint after.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7 · Operator & customer trust narrative
|
|
||||||
|
|
||||||
What we can say honestly to an operator after S0–S5:
|
|
||||||
|
|
||||||
> "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 re‑inventing the wheel.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8 · Audit‑friendliness checklist (open‑source readiness)
|
|
||||||
|
|
||||||
Things a future auditor — or our open‑source reviewers — will look for. Where we already pass, marked ✓; where we plan to pass after this work, marked →.
|
|
||||||
|
|
||||||
| Check | Status | Where |
|
|
||||||
|---|---|---|
|
|
||||||
| All money‑moving 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 cross‑operator probes |
|
|
||||||
| Fee storage is absolute (not derived from mutable %) | ✓ | `platform_fee_sats`, `operator_fee_sats` BIGINT |
|
|
||||||
| Audit trail is append‑only on settlements | ✓ | `dca_settlements.notes` prepended, never edited |
|
|
||||||
| Partial‑dispense recompute preserves original ratio | ✓ | `apply_partial_dispense_and_redistribute` (H6 fix) |
|
|
||||||
| Concurrent settlement processing is race‑free | ✓ | `claim_settlement_for_processing` |
|
|
||||||
| Every settlement has a signed, public attestation | → | S3 (NIP‑57 receipts) |
|
|
||||||
| Operator's private key is not present on the ATM | → | S0 + S2 (NIP‑26 delegation) |
|
|
||||||
| RPC events cannot be replayed > 5 min later | → | S1 (NIP‑40 expiration) |
|
|
||||||
| Payment.extra mutation is detectable | → | S5 (server‑signed HMAC) |
|
|
||||||
| Stale machine rows cannot accept real funds | → | S4 (NIP‑78 fleet roster cross‑check) |
|
|
||||||
| Auto‑account‑from‑npub is gated | → | S6 (roster + rate limit) |
|
|
||||||
| Key custody can be moved off LNbits' DB | → | S7 (NIP‑46 bunker) |
|
|
||||||
|
|
||||||
The state we want the open‑source 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 walk‑through:
|
|
||||||
|
|
||||||
| File | Role | Note |
|
|
||||||
|---|---|---|
|
|
||||||
| `~/dev/shared/extensions/satmachineadmin/tasks.py` | LNbits invoice listener. Entry point for all settlements today. | `_handle_payment:56-95` — load‑bearing 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` | Three‑leg distribution chain. | `process_settlement` — uses claim pattern. |
|
|
||||||
| `~/dev/shared/extensions/satmachineadmin/crud.py` | Operator‑scoped DB layer. | `claim_settlement_for_processing`, `_machine_owned_by`. |
|
|
||||||
| `~/dev/shared/extensions/satmachineadmin/views_api.py` | 33 routes, all `check_user_exists` except super‑config PUT. | `_assert_wallet_owned_by` is the wallet‑IDOR 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 Option‑1 stopgap. |
|
|
||||||
| `~/dev/lnbits/nostr-transport/lnbits/core/services/nostr_transport/` | LNbits transport handler (upstream we depend on). | NIP‑44 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` | App‑specific 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 deep‑dive), `aiolabs/lamassu-next#44` (Payment.extra split). This document is the design that closes the security‑relevant subset of those.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10 · Verification
|
|
||||||
|
|
||||||
How we'd test the proposed design end‑to‑end, once S0–S5 land:
|
|
||||||
|
|
||||||
1. **Negative test for G3:** Provision an ATM with seed‑URL pairing. Confirm `/var/lib/bitspire/.env` contains only the ATM's own nsec and a delegation token. Attempt to sign a non‑kind‑21000 event with the ATM's key + delegation → handler rejects.
|
|
||||||
2. **Negative test for G4:** Record a kind‑21000 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 NIP‑78 fleet list.
|
|
||||||
4. **Positive test for S3:** Run a full cash‑out. Confirm a `kind:9735`‑shaped receipt is published referencing the kind‑21000 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 re‑pairs with the new token; works again.
|
|
||||||
7. **Multi‑operator isolation:** Two operators on the same LNbits instance, each with one ATM. Confirm Operator A's NIP‑78 fleet doesn't list Operator B's ATM npub; LNbits cross‑checks correctly.
|
|
||||||
8. **End‑to‑end 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 cash‑out → 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 post‑plan‑mode (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 in‑repo.
|
|
||||||
2. Open Forgejo epics on `aiolabs/satmachineadmin` linking back to existing `#9/#11/#12` and adding a new one for "Security pathway hardening (S0–S7)."
|
|
||||||
3. Open a tracking issue on `aiolabs/lnbits` against the `nostr-transport` branch for the LNbits‑side 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.
|
|
@ -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; }
|
|
||||||
151
fee_transport.py
151
fee_transport.py
|
|
@ -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
|
|
||||||
831
migrations.py
831
migrations.py
|
|
@ -1,737 +1,172 @@
|
||||||
# Satoshi Machine v2 — single squashed migration.
|
# DCA Admin Extension Database Migrations
|
||||||
#
|
# Creates all necessary tables for Dollar Cost Averaging administration
|
||||||
# History note: m001-m004 were the legacy Lamassu schema; m005-m007 staged
|
# with Lamassu ATM integration
|
||||||
# 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.
|
|
||||||
|
|
||||||
|
|
||||||
async def m001_satmachine_v2_initial(db):
|
async def m001_initial_dca_schema(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).
|
|
||||||
"""
|
"""
|
||||||
# 1. Drop legacy v1 tables. IF EXISTS handles both fresh-install
|
Create complete DCA admin schema from scratch.
|
||||||
# paths (no-op) and migration from a v1 schema (cleans up).
|
"""
|
||||||
for table in (
|
# DCA Clients table
|
||||||
"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:
|
|
||||||
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(
|
await db.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS dca_machines_operator_idx "
|
f"""
|
||||||
"ON dca_machines (operator_user_id)"
|
CREATE TABLE satoshimachine.dca_clients (
|
||||||
)
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
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,
|
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
wallet_id TEXT NOT NULL,
|
||||||
username TEXT,
|
username TEXT,
|
||||||
|
dca_mode TEXT NOT NULL DEFAULT 'flow',
|
||||||
|
fixed_mode_daily_limit INTEGER,
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||||
updated_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
|
# DCA Deposits table
|
||||||
# user that has onboarded as a Liquidity Provider, regardless of
|
await db.execute(
|
||||||
# how many machines they're enrolled at. Owned by the LP (writes
|
f"""
|
||||||
# come from the satmachineclient extension under the LP's session),
|
CREATE TABLE satoshimachine.dca_deposits (
|
||||||
# read by satmachineadmin during distribution to resolve "where do
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
# 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,
|
|
||||||
client_id TEXT NOT NULL,
|
client_id TEXT NOT NULL,
|
||||||
machine_id TEXT NOT NULL,
|
amount INTEGER NOT NULL,
|
||||||
creator_user_id TEXT NOT NULL,
|
|
||||||
amount DECIMAL(10,2) NOT NULL,
|
|
||||||
currency TEXT NOT NULL DEFAULT 'GTQ',
|
currency TEXT NOT NULL DEFAULT 'GTQ',
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||||
confirmed_at TIMESTAMP
|
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.
|
# DCA Payments table
|
||||||
# payment_hash UNIQUE handles subscription replays + dispatcher
|
await db.execute(
|
||||||
# double-fires. processing_claim is the optimistic-lock token
|
f"""
|
||||||
# written by claim_settlement_for_processing. notes is the
|
CREATE TABLE satoshimachine.dca_payments (
|
||||||
# append-only audit memo for partial-dispense + operator notes.
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
#
|
client_id TEXT NOT NULL,
|
||||||
# platform_fee_sats and operator_fee_sats are absolute BIGINT,
|
amount_sats INTEGER NOT NULL,
|
||||||
# NOT derived fractions — when the v2 customer-discount engine
|
amount_fiat INTEGER NOT NULL,
|
||||||
# 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',
|
|
||||||
exchange_rate REAL NOT NULL,
|
exchange_rate REAL NOT NULL,
|
||||||
principal_sats BIGINT NOT NULL,
|
transaction_type TEXT NOT NULL,
|
||||||
fee_sats BIGINT NOT NULL,
|
lamassu_transaction_id TEXT,
|
||||||
platform_fee_sats BIGINT NOT NULL,
|
payment_hash TEXT,
|
||||||
operator_fee_sats BIGINT NOT NULL,
|
|
||||||
tx_type TEXT NOT NULL,
|
|
||||||
bills_json TEXT,
|
|
||||||
cassettes_json TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
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}
|
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
|
# Lamassu Configuration table
|
||||||
# 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(
|
await db.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS dca_payments_client_idx "
|
f"""
|
||||||
"ON dca_payments (client_id, created_at DESC)"
|
CREATE TABLE satoshimachine.lamassu_config (
|
||||||
)
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
await db.execute(
|
host TEXT NOT NULL,
|
||||||
"CREATE INDEX IF NOT EXISTS dca_payments_settlement_idx "
|
port INTEGER NOT NULL DEFAULT 5432,
|
||||||
"ON dca_payments (settlement_id)"
|
database_name TEXT NOT NULL,
|
||||||
)
|
username TEXT NOT NULL,
|
||||||
await db.execute(
|
password TEXT NOT NULL,
|
||||||
"CREATE INDEX IF NOT EXISTS dca_payments_operator_idx "
|
source_wallet_id TEXT,
|
||||||
"ON dca_payments (operator_user_id, leg_type)"
|
commission_wallet_id TEXT,
|
||||||
)
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
test_connection_last TIMESTAMP,
|
||||||
# 9. dca_telemetry — latest replaceable kind-30078 (public availability
|
test_connection_success BOOLEAN,
|
||||||
# beacon) and kind-30079 (operator-only fleet telemetry) snapshots
|
last_poll_time TIMESTAMP,
|
||||||
# per machine. The beacon today (lamassu-next/dev @ 2b712af) ships
|
last_successful_poll TIMESTAMP,
|
||||||
# only cash_in/cash_out/cash_level/fiat/model — post-#43 fields
|
use_ssh_tunnel BOOLEAN NOT NULL DEFAULT false,
|
||||||
# (name, location, geo, fees, limits, denominations, version) are
|
ssh_host TEXT,
|
||||||
# nullable until that upstream issue lands. Ingest opportunistically.
|
ssh_port INTEGER NOT NULL DEFAULT 22,
|
||||||
await db.execute("""
|
ssh_username TEXT,
|
||||||
CREATE TABLE IF NOT EXISTS satoshimachine.dca_telemetry (
|
ssh_password TEXT,
|
||||||
machine_id TEXT PRIMARY KEY,
|
ssh_private_key TEXT,
|
||||||
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
|
|
||||||
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,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||||
updated_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
|
# Lamassu Transactions table (for audit trail)
|
||||||
# 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
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
await db.execute(
|
||||||
"ALTER TABLE satoshimachine.cassette_configs_new " "RENAME TO cassette_configs"
|
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 m009_split_fee_fractions_by_direction(db):
|
async def m002_add_transaction_time_to_dca_payments(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 = [
|
Add transaction_time field to dca_payments table to store original ATM transaction time
|
||||||
("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"),
|
await db.execute(
|
||||||
("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"),
|
ALTER TABLE satoshimachine.dca_payments
|
||||||
("dca_settlements", "fee_mismatch_sats", "BIGINT"),
|
ADD COLUMN transaction_time TIMESTAMP
|
||||||
]
|
"""
|
||||||
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:
|
async def m003_add_max_daily_limit_config(db):
|
||||||
# Carry the live deployment's super_fee_fraction setting forward
|
"""
|
||||||
# into both directional fields, but only when the operator hasn't
|
Add max_daily_limit_gtq field to lamassu_config table for admin-configurable client limits
|
||||||
# already explicitly set per-direction values (i.e., both are
|
"""
|
||||||
# still at DEFAULT 0).
|
await db.execute(
|
||||||
await db.execute(
|
"""
|
||||||
"""
|
ALTER TABLE satoshimachine.lamassu_config
|
||||||
UPDATE satoshimachine.super_config
|
ADD COLUMN max_daily_limit_gtq INTEGER NOT NULL DEFAULT 2000
|
||||||
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 m004_convert_to_gtq_storage(db):
|
||||||
"""
|
"""
|
||||||
)
|
Convert centavo storage to GTQ storage by changing data types and converting existing data.
|
||||||
await db.execute(
|
Handles both SQLite (data conversion only) and PostgreSQL (data + schema changes).
|
||||||
"ALTER TABLE satoshimachine.super_config DROP COLUMN super_fee_fraction"
|
"""
|
||||||
)
|
# 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")
|
||||||
294
nip44.py
294
nip44.py
|
|
@ -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)
|
|
||||||
295
nostr_publish.py
295
nostr_publish.py
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
2229
static/js/index.js
2229
static/js/index.js
File diff suppressed because it is too large
Load diff
509
tasks.py
509
tasks.py
|
|
@ -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
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from lnbits.core.models import Payment
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.core.services import websocket_updater
|
||||||
from lnbits.tasks import register_invoice_listener
|
from lnbits.tasks import register_invoice_listener
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .bitspire import (
|
from .transaction_processor import poll_lamassu_transactions
|
||||||
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
|
|
||||||
|
|
||||||
LISTENER_NAME = "ext_satmachineadmin"
|
#######################################
|
||||||
|
########## RUN YOUR TASKS HERE ########
|
||||||
|
#######################################
|
||||||
|
|
||||||
# Holds strong refs to in-flight distribution tasks so Python's GC doesn't
|
# The usual task is to listen to invoices related to this extension
|
||||||
# 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()
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_paid_invoices() -> None:
|
async def wait_for_paid_invoices():
|
||||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
"""Invoice listener for DCA-related payments"""
|
||||||
register_invoice_listener(invoice_queue, LISTENER_NAME)
|
invoice_queue = asyncio.Queue()
|
||||||
logger.info(
|
register_invoice_listener(invoice_queue, "ext_satmachineadmin")
|
||||||
"satmachineadmin v2: invoice listener registered as "
|
|
||||||
f"`{LISTENER_NAME}` — waiting for bitSpire settlements."
|
|
||||||
)
|
|
||||||
while True:
|
while True:
|
||||||
payment: Payment = await invoice_queue.get()
|
payment = await invoice_queue.get()
|
||||||
try:
|
await on_invoice_paid(payment)
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_payment(payment: Payment) -> None:
|
async def hourly_transaction_polling():
|
||||||
if not payment.success:
|
"""Background task that polls Lamassu database every hour for new transactions"""
|
||||||
return
|
logger.info("Starting hourly Lamassu transaction polling task")
|
||||||
machine = await get_active_machine_by_wallet_id(payment.wallet_id)
|
|
||||||
if machine is None:
|
|
||||||
return
|
|
||||||
extra = payment.extra or {}
|
|
||||||
|
|
||||||
# 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:
|
while True:
|
||||||
try:
|
try:
|
||||||
current_filter_key = await _cassette_consumer_tick(current_filter_key)
|
logger.info(f"Running Lamassu transaction poll at {datetime.now()}")
|
||||||
await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S)
|
await poll_lamassu_transactions()
|
||||||
except _NostrclientUnavailable:
|
logger.info("Completed Lamassu transaction poll, sleeping for 1 hour")
|
||||||
logger.warning(
|
|
||||||
"satmachineadmin: nostrclient extension not installed; "
|
# Sleep for 1 hour (3600 seconds)
|
||||||
f"cassette bootstrap consumer sleeping {_CASSETTE_BACKOFF_S}s "
|
await asyncio.sleep(3600)
|
||||||
"before retry. Install + activate nostrclient on this "
|
|
||||||
"LNbits instance."
|
except Exception as e:
|
||||||
)
|
logger.error(f"Error in hourly polling task: {e}")
|
||||||
current_filter_key = None
|
# Sleep for 5 minutes before retrying on error
|
||||||
await asyncio.sleep(_CASSETTE_BACKOFF_S)
|
await asyncio.sleep(300)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class _NostrclientUnavailable(Exception):
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
"""Internal sentinel — nostrclient extension import failed. Caller
|
"""Handle DCA-related invoice payments"""
|
||||||
sleeps a backoff then retries; the operator may install nostrclient
|
# DCA payments are handled internally by the transaction processor
|
||||||
at any time."""
|
# 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")
|
||||||
async def _cassette_consumer_tick(current_filter_key: str | None) -> str:
|
# Could add websocket notifications here if needed
|
||||||
"""Single iteration of the bootstrap-consumer loop. Returns the filter
|
pass
|
||||||
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)"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
|
||||||
|
|
@ -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:
|
These tests verify commission and distribution calculations against
|
||||||
- calculate_distribution (proportional split across LPs by balance)
|
real Lamassu transaction data to ensure the math is correct.
|
||||||
|
|
||||||
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`.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
# Import from the parent package (following lnurlp pattern)
|
# 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):
|
def test_distribution_invariant_sums_to_total(self):
|
||||||
"""Total distributed sats must always equal base amount."""
|
"""Total distributed sats must always equal base amount."""
|
||||||
|
# Test with various client configurations
|
||||||
test_cases = [
|
test_cases = [
|
||||||
{"a": 100.0},
|
{"a": 100.0},
|
||||||
{"a": 100.0, "b": 100.0},
|
{"a": 100.0, "b": 100.0},
|
||||||
|
|
@ -119,6 +215,156 @@ class TestDistributionCalculation:
|
||||||
|
|
||||||
assert distributions == {}
|
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):
|
def test_many_clients_distribution(self):
|
||||||
"""Test distribution with many clients."""
|
"""Test distribution with many clients."""
|
||||||
# 10 clients with varying balances
|
# 10 clients with varying balances
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
ATM→operator 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
|
|
||||||
|
|
@ -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}",
|
|
||||||
]
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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 == []
|
|
||||||
|
|
@ -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
11
tests/test_init.py
Normal 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)
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
1274
transaction_processor.py
Normal file
File diff suppressed because it is too large
Load diff
18
views.py
18
views.py
|
|
@ -1,10 +1,8 @@
|
||||||
# Satoshi Machine v2 — page route.
|
# Description: DCA Admin page endpoints.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_user_exists
|
from lnbits.decorators import check_user_exists
|
||||||
|
|
@ -17,9 +15,13 @@ def satmachineadmin_renderer():
|
||||||
return template_renderer(["satmachineadmin/templates"])
|
return template_renderer(["satmachineadmin/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
# DCA Admin page - Requires superuser access
|
||||||
@satmachineadmin_generic_router.get("/", response_class=HTMLResponse)
|
@satmachineadmin_generic_router.get("/", response_class=HTMLResponse)
|
||||||
async def index(req: Request, user: User = Depends(check_user_exists)):
|
async def index(req: Request, user: User = Depends(check_user_exists)):
|
||||||
|
if not user.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
|
||||||
|
)
|
||||||
return satmachineadmin_renderer().TemplateResponse(
|
return satmachineadmin_renderer().TemplateResponse(
|
||||||
"satmachineadmin/index.html",
|
"satmachineadmin/index.html", {"request": req, "user": user.json()}
|
||||||
{"request": req, "user": user.json()},
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
1529
views_api.py
1529
views_api.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue