diff --git a/.gitignore b/.gitignore index 0152b6e..4750fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,13 @@ __pycache__ node_modules .mypy_cache .venv + +# LNbits runtime data — auth keys, dev DB files, etc. +data/ +*.sqlite3 +*.sqlite3-journal + +# uv lockfile — pyproject.toml still uses [tool.poetry] syntax, so uv lock +# produces a header-only file that pins nothing. Ignore until the +# PEP 621 migration lands (aiolabs/satmachineadmin#28). +uv.lock diff --git a/CLAUDE.md b/CLAUDE.md index 9d2af64..acf23cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,6 +219,38 @@ commission_amount = 266800 - 258835 = 7,965 sats (to commission wallet) - Input sanitization and type validation - Audit logging for all administrative actions +### No-collision invariant — operator account pubkey ≠ ATM npub + +`dca_machines.machine_npub` and `accounts.pubkey` MUST NEVER hold the +same value across the LNbits instance. Enforced by +`views_api._assert_no_pubkey_collision` at machine-creation time +(rejects with HTTP 400) and by the matching SQL check operators can run +on existing installs: + +```sql +SELECT a.id, a.username, a.pubkey, m.id, m.machine_npub +FROM accounts a +JOIN ext_satoshimachine.dca_machines m + ON LOWER(a.pubkey) = LOWER(m.machine_npub); +``` + +**Why this matters**: when the two values match, lnbits' nostr-transport +`auth.py:resolve_nostr_auth` routes inbound kind-21000 RPCs from the +ATM directly to that operator's wallet *by collision* — it works by +coincidence, breaks silently the moment the operator's pubkey rotates +(then `auto-account-from-npub` fires for the orphaned ATM npub, and the +invoice lands on a fresh auto-account wallet instead). Reproduced on +2026-05-30 against Greg's Sintra (silent cash-out drop). The proper +architectural routing fix is `aiolabs/satmachineadmin#20` (path B / +S6); the collision guard prevents the broken state from being entered +in the first place. + +When provisioning a new ATM via `lamassu-next deploy/nixos/provision-atm.sh`, +**leave `ATM_PRIVATE_KEY` unset** so the script generates a fresh ATM +keypair (distinct from any operator's nsec). See +`aiolabs/satmachineadmin#32` for design rationale + the (eventual) +reverse-direction guard on account creation in lnbits proper. + ## Development Workflow ### Adding New Features diff --git a/__init__.py b/__init__.py index 8c16954..daff156 100644 --- a/__init__.py +++ b/__init__.py @@ -5,17 +5,17 @@ from lnbits.tasks import create_permanent_unique_task from loguru import logger from .crud import db -from .tasks import wait_for_paid_invoices, hourly_transaction_polling +from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits +from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices from .views import satmachineadmin_generic_router from .views_api import satmachineadmin_api_router -logger.debug( - "This logged message is from satmachineadmin/__init__.py, you can debug in your " - "extension using 'import logger from loguru' and 'logger.debug()'." +logger.info("satmachineadmin v2 loaded") + + +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_api_router) @@ -38,19 +38,31 @@ def satmachineadmin_stop(): def satmachineadmin_start(): - # Start invoice listener task - invoice_task = create_permanent_unique_task("ext_satmachineadmin", wait_for_paid_invoices) + # bitSpire invoice listener — replaces the v1 SSH/PostgreSQL poller. + invoice_task = create_permanent_unique_task( + "ext_satmachineadmin", wait_for_paid_invoices + ) scheduled_tasks.append(invoice_task) - - # Start hourly transaction polling task - polling_task = create_permanent_unique_task("ext_satmachineadmin_polling", hourly_transaction_polling) - scheduled_tasks.append(polling_task) + # Cassette bootstrap consumer (#29 v1) — subscribes to + # bitspire-cassettes-state events from each active ATM and upserts + # cassette_configs on receipt. Soft-fails if nostrclient isn't + # installed (logs + backs off, never crashes). + cassette_task = create_permanent_unique_task( + "ext_satmachineadmin_cassette_bootstrap", wait_for_cassette_state_events + ) + scheduled_tasks.append(cassette_task) + # Path-B wallet-routing hook (#20 / coord-log 2026-05-31T15:25Z): + # register our ATM-roster resolver with lnbits' nostr-transport so + # inbound kind-21000 from a known ATM npub routes to the operator's + # wallet, not an auto-created machine wallet. Soft-fails on lnbits + # versions that don't yet expose `register_roster_resolver`. + register_roster_with_lnbits() __all__ = [ "db", "satmachineadmin_ext", - "satmachineadmin_static_files", "satmachineadmin_start", + "satmachineadmin_static_files", "satmachineadmin_stop", ] diff --git a/bitspire.py b/bitspire.py new file mode 100644 index 0000000..59192b2 --- /dev/null +++ b/bitspire.py @@ -0,0 +1,343 @@ +# 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 diff --git a/calculations.py b/calculations.py index a7b3aa9..c76600b 100644 --- a/calculations.py +++ b/calculations.py @@ -3,50 +3,25 @@ Pure calculation functions for DCA transaction processing. These functions have no external dependencies (no lnbits, no database) and can be easily tested in isolation. + +What's intentionally NOT here (deleted 2026-05-26): + - `calculate_commission` (back-derive principal+fee from a gross-with- + commission-baked-in wire amount). Lamassu-era reverse-derivation; + obsolete since bitSpire stamps `principal_sats` AND `fee_sats` + directly on Payment.extra per aiolabs/lamassu-next#44. + - `calculate_exchange_rate` (principal / fiat_amount). bitSpire stamps + `exchange_rate` directly on Payment.extra too. Not used in production. """ from typing import Dict, Tuple -def calculate_commission( - crypto_atoms: int, - commission_percentage: float, - discount: float = 0.0 -) -> Tuple[int, int, float]: - """ - Calculate commission split from a Lamassu transaction. - - The crypto_atoms from Lamassu already includes the commission baked in. - This function extracts the base amount (for DCA distribution) and - commission amount (for commission wallet). - - Formula: - effective_commission = commission_percentage * (100 - discount) / 100 - base_amount = round(crypto_atoms / (1 + effective_commission)) - commission_amount = crypto_atoms - base_amount - - Args: - crypto_atoms: Total sats from Lamassu (includes commission) - commission_percentage: Commission rate as decimal (e.g., 0.03 for 3%) - discount: Discount percentage on commission (e.g., 10.0 for 10% off) - - Returns: - Tuple of (base_amount_sats, commission_amount_sats, effective_commission_rate) - - Example: - >>> calculate_commission(266800, 0.03, 0.0) - (259029, 7771, 0.03) - """ - if commission_percentage > 0: - effective_commission = commission_percentage * (100 - discount) / 100 - base_crypto_atoms = round(crypto_atoms / (1 + effective_commission)) - commission_amount_sats = crypto_atoms - base_crypto_atoms - else: - effective_commission = 0.0 - base_crypto_atoms = crypto_atoms - commission_amount_sats = 0 - - return base_crypto_atoms, commission_amount_sats, effective_commission +# Per-direction fee cap (super + operator) for any single direction. +# Locked at 15% per coord-log §2026-06-01T07:22Z (bitspire) — defense in +# depth: producer (this side) refuses to publish/persist > cap; consumer +# (bitspire) refuses to apply > cap. See aiolabs/satmachineadmin#37,#38 +# and aiolabs/lamassu-next#57. +MAX_FEE_FRACTION_PER_DIRECTION = 0.15 def calculate_distribution( @@ -131,17 +106,91 @@ def calculate_distribution( return distributions -def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float: - """ - Calculate exchange rate in sats per fiat unit. +def split_principal_based( + principal_sats: int, + super_frac: float, + operator_frac: float, +) -> Tuple[int, int]: + """Compute platform + operator fee shares as independent fractions of + `principal_sats`. Both shares are derived from the customer's + principal (the canonical source of truth), NOT back-derived from + `fee_sats`. - Args: - base_crypto_atoms: Base amount in sats (after commission) - fiat_amount: Fiat amount dispensed + 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`. - Returns: - Exchange rate as sats per fiat unit + 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 fiat_amount <= 0: - return 0.0 - return base_crypto_atoms / fiat_amount + if not (0.0 <= super_frac <= 1.0): + raise ValueError(f"super_frac must be in [0, 1], got {super_frac}") + if not (0.0 <= operator_frac <= 1.0): + raise ValueError(f"operator_frac must be in [0, 1], got {operator_frac}") + if principal_sats <= 0: + return 0, 0 + platform = max(0, round(principal_sats * super_frac)) + operator = max(0, round(principal_sats * operator_frac)) + return platform, operator + + +def allocate_operator_split_legs( + operator_fee_sats: int, leg_fractions: list +) -> list: + """Stage-2 of the v2 commission split: the operator's remainder is sliced + across N leg wallets per `leg_fractions` (each in [0, 1], sum should + equal 1.0). + + The last leg absorbs the rounding remainder so the sum of allocations + exactly equals operator_fee_sats (assuming fractions sum to ~1.0). + Returns a list of integer sat amounts in the same order as leg_fractions. + + Examples: + >>> allocate_operator_split_legs(70, [0.5, 0.3, 0.2]) + [35, 21, 14] + >>> allocate_operator_split_legs(5575, [0.5, 0.3, 0.2]) + [2787, 1672, 1116] + >>> allocate_operator_split_legs(100, [1.0]) + [100] + >>> allocate_operator_split_legs(0, [0.5, 0.5]) + [0, 0] + """ + if not leg_fractions: + return [] + if operator_fee_sats <= 0: + return [0] * len(leg_fractions) + for f in leg_fractions: + if not (0.0 <= float(f) <= 1.0): + raise ValueError( + f"every leg fraction must be in [0, 1], got {f}" + ) + allocations: list = [] + remaining = operator_fee_sats + for idx, fraction in enumerate(leg_fractions): + if idx == len(leg_fractions) - 1: + allocations.append(remaining) + else: + amount = round(operator_fee_sats * float(fraction)) + allocations.append(amount) + remaining -= amount + return allocations + + diff --git a/cassette_transport.py b/cassette_transport.py new file mode 100644 index 0000000..7d98906 --- /dev/null +++ b/cassette_transport.py @@ -0,0 +1,257 @@ +""" +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:"], + ["p", ""] + ] + 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:"], + ["p", ""] + ] + 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 `` 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:` + 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 diff --git a/crud.py b/crud.py index 326352c..444a984 100644 --- a/crud.py +++ b/crud.py @@ -1,116 +1,495 @@ -# Description: This file contains the CRUD operations for talking to the database. +# Satoshi Machine v2 — CRUD layer over the m005 schema. +# +# All operator-scoped queries take an operator_user_id and enforce isolation +# at the SQL boundary. Cross-operator LP queries (for satmachineclient) join +# through dca_machines.operator_user_id. See plan section "Identity & multi- +# machine model". -from typing import List, Optional, Union -from datetime import datetime, timezone +from datetime import datetime from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash from .models import ( - CreateDcaClientData, DcaClient, UpdateDcaClientData, - CreateDepositData, DcaDeposit, UpdateDepositData, UpdateDepositStatusData, - CreateDcaPaymentData, DcaPayment, + CassetteConfig, ClientBalanceSummary, - CreateLamassuConfigData, LamassuConfig, UpdateLamassuConfigData, - CreateLamassuTransactionData, StoredLamassuTransaction + CommissionSplit, + CommissionSplitLeg, + CreateDcaClientData, + CreateDcaPaymentData, + CreateDcaSettlementData, + CreateDepositData, + CreateMachineData, + DcaClient, + DcaDeposit, + DcaLpPreferences, + DcaPayment, + DcaSettlement, + Machine, + PublishCassettesPayload, + SuperConfig, + TelemetrySnapshot, + UpdateDcaClientData, + UpdateDepositData, + UpdateDepositStatusData, + UpdateMachineData, + UpdateSuperConfigData, + UpsertCassetteConfigData, + UpsertDcaLpData, ) db = Database("ext_satoshimachine") -# DCA Client CRUD Operations -async def create_dca_client(data: CreateDcaClientData) -> DcaClient: - client_id = urlsafe_short_hash() +# ============================================================================= +# Super config +# ============================================================================= + + +async def get_super_config() -> SuperConfig | None: + return await db.fetchone( + "SELECT * FROM satoshimachine.super_config WHERE id = :id", + {"id": "default"}, + SuperConfig, + ) + + +async def update_super_config(data: UpdateSuperConfigData) -> SuperConfig | None: + update_data = {k: v for k, v in data.dict().items() if v is not None} + if not update_data: + return await get_super_config() + update_data["updated_at"] = datetime.now() + set_clause = ", ".join(f"{k} = :{k}" for k in update_data) + update_data["id"] = "default" + await db.execute( + f"UPDATE satoshimachine.super_config SET {set_clause} WHERE id = :id", + update_data, + ) + return await get_super_config() + + +# ============================================================================= +# Machines +# ============================================================================= + + +async def create_machine(operator_user_id: str, data: CreateMachineData) -> Machine: + machine_id = urlsafe_short_hash() + now = datetime.now() await db.execute( """ - INSERT INTO satoshimachine.dca_clients - (id, user_id, wallet_id, username, dca_mode, fixed_mode_daily_limit, status, created_at, updated_at) - VALUES (:id, :user_id, :wallet_id, :username, :dca_mode, :fixed_mode_daily_limit, :status, :created_at, :updated_at) + INSERT INTO satoshimachine.dca_machines + (id, operator_user_id, machine_npub, wallet_id, name, location, + fiat_code, is_active, + operator_cash_in_fee_fraction, operator_cash_out_fee_fraction, + created_at, updated_at) + VALUES (:id, :operator_user_id, :machine_npub, :wallet_id, :name, + :location, :fiat_code, :is_active, + :operator_cash_in_fee_fraction, :operator_cash_out_fee_fraction, + :created_at, :updated_at) + """, + { + "id": machine_id, + "operator_user_id": operator_user_id, + "machine_npub": data.machine_npub, + "wallet_id": data.wallet_id, + "name": data.name, + "location": data.location, + "fiat_code": data.fiat_code, + "is_active": True, + "operator_cash_in_fee_fraction": data.operator_cash_in_fee_fraction, + "operator_cash_out_fee_fraction": data.operator_cash_out_fee_fraction, + "created_at": now, + "updated_at": now, + }, + ) + machine = await get_machine(machine_id) + assert machine is not None + return machine + + +async def get_machine(machine_id: str) -> Machine | None: + return await db.fetchone( + "SELECT * FROM satoshimachine.dca_machines WHERE id = :id", + {"id": machine_id}, + Machine, + ) + + +async def get_machine_by_npub(machine_npub: str) -> Machine | None: + return await db.fetchone( + "SELECT * FROM satoshimachine.dca_machines WHERE machine_npub = :npub", + {"npub": machine_npub}, + Machine, + ) + + +async def get_active_machine_by_wallet_id(wallet_id: str) -> Machine | None: + """Used by the invoice listener to route an incoming payment to a machine.""" + return await db.fetchone( + """ + SELECT * FROM satoshimachine.dca_machines + WHERE wallet_id = :wid AND is_active = true + LIMIT 1 + """, + {"wid": wallet_id}, + Machine, + ) + + +async def get_machines_for_operator(operator_user_id: str) -> list[Machine]: + return await db.fetchall( + """ + SELECT * FROM satoshimachine.dca_machines + WHERE operator_user_id = :uid + ORDER BY created_at DESC + """, + {"uid": operator_user_id}, + Machine, + ) + + +async def list_all_active_machines() -> list[Machine]: + """Used by the cassette bootstrap consumer task to build a single + cross-operator subscription filter. Each event's pubkey routes to + the right operator via get_machine_by_atm_pubkey_hex + the machine's + operator_user_id. + """ + return await db.fetchall( + """ + SELECT * FROM satoshimachine.dca_machines + WHERE is_active = true + ORDER BY created_at DESC + """, + {}, + Machine, + ) + + +async def get_machine_by_atm_pubkey_hex(atm_pubkey_hex: str) -> Machine | None: + """Look up an active machine by its ATM pubkey, accepting hex or bech32 + in machine_npub. Used by the cassette bootstrap consumer to route an + incoming state event to the right machine row (and therefore operator + privkey for decryption). + + O(N) over active machines — fine for small fleets. If fleet sizes + grow, normalise machine_npub-at-write to hex and add an index. + """ + from lnbits.utils.nostr import normalize_public_key + + target = atm_pubkey_hex.lower() + machines = await list_all_active_machines() + for m in machines: + try: + if normalize_public_key(m.machine_npub).lower() == target: + return m + except (ValueError, AssertionError): + continue + return None + + +async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine | None: + update_data = {k: v for k, v in data.dict().items() if v is not None} + if not update_data: + return await get_machine(machine_id) + update_data["updated_at"] = datetime.now() + set_clause = ", ".join(f"{k} = :{k}" for k in update_data) + update_data["id"] = machine_id + await db.execute( + f"UPDATE satoshimachine.dca_machines SET {set_clause} WHERE id = :id", + update_data, + ) + return await get_machine(machine_id) + + +async def delete_machine(machine_id: str) -> None: + await db.execute( + "DELETE FROM satoshimachine.dca_machines WHERE id = :id", + {"id": machine_id}, + ) + + +# ============================================================================= +# DCA Clients (LPs) +# ============================================================================= + + +async def create_dca_client(data: CreateDcaClientData) -> DcaClient: + """Operator enrols an LP at one of their machines. + + Pure (machine, LP) record. Wallet / mode / autoforward live on + dca_lp (per-user) — populated by the LP via satmachineclient. + Enrolment doesn't require the LP to be onboarded yet, but deposits + do (see `create_deposit`). + """ + client_id = urlsafe_short_hash() + now = datetime.now() + await db.execute( + """ + INSERT INTO satoshimachine.dca_clients + (id, machine_id, user_id, username, status, created_at, updated_at) + VALUES (:id, :machine_id, :user_id, :username, :status, + :created_at, :updated_at) """, { "id": client_id, + "machine_id": data.machine_id, "user_id": data.user_id, - "wallet_id": data.wallet_id, "username": data.username, - "dca_mode": data.dca_mode, - "fixed_mode_daily_limit": data.fixed_mode_daily_limit, "status": "active", - "created_at": datetime.now(), - "updated_at": datetime.now() - } + "created_at": now, + "updated_at": now, + }, ) - return await get_dca_client(client_id) + client = await get_dca_client(client_id) + assert client is not None + return client -async def get_dca_client(client_id: str) -> Optional[DcaClient]: +# Shared SELECT fragment: client columns plus the LP-onboarded flag +# computed via LEFT JOIN on dca_lp. Returned as `lp_onboarded` (boolean +# 0/1 in SQLite, which Pydantic coerces to bool on the DcaClient model). +_CLIENT_SELECT = """ + c.id, c.machine_id, c.user_id, c.username, c.status, + c.created_at, c.updated_at, + (lp.user_id IS NOT NULL) AS lp_onboarded +""" +_CLIENT_FROM = ( + "satoshimachine.dca_clients c " + "LEFT JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id" +) + + +async def get_dca_client(client_id: str) -> DcaClient | None: return await db.fetchone( - "SELECT * FROM satoshimachine.dca_clients WHERE id = :id", + f"SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} WHERE c.id = :id", {"id": client_id}, DcaClient, ) -async def get_dca_clients() -> List[DcaClient]: - return await db.fetchall( - "SELECT * FROM satoshimachine.dca_clients ORDER BY created_at DESC", - model=DcaClient, +async def get_dca_client_for_machine_user( + machine_id: str, user_id: str +) -> DcaClient | None: + return await db.fetchone( + f""" + SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} + WHERE c.machine_id = :machine_id AND c.user_id = :user_id + """, + {"machine_id": machine_id, "user_id": user_id}, + DcaClient, ) -async def get_dca_client_by_user(user_id: str) -> Optional[DcaClient]: - return await db.fetchone( - "SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id", +async def get_dca_clients_for_machine(machine_id: str) -> list[DcaClient]: + return await db.fetchall( + f""" + SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} + WHERE c.machine_id = :machine_id + ORDER BY c.created_at DESC + """, + {"machine_id": machine_id}, + DcaClient, + ) + + +async def get_dca_clients_for_operator(operator_user_id: str) -> list[DcaClient]: + """All clients across every machine this operator owns.""" + return await db.fetchall( + f""" + SELECT {_CLIENT_SELECT} + FROM {_CLIENT_FROM} + JOIN satoshimachine.dca_machines m ON m.id = c.machine_id + WHERE m.operator_user_id = :uid + ORDER BY c.created_at DESC + """, + {"uid": operator_user_id}, + DcaClient, + ) + + +async def get_dca_clients_for_user(user_id: str) -> list[DcaClient]: + """LP cross-operator view — every machine this LP is registered at.""" + return await db.fetchall( + f""" + SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} + WHERE c.user_id = :user_id + ORDER BY c.created_at DESC + """, {"user_id": user_id}, DcaClient, ) -async def update_dca_client(client_id: str, data: UpdateDcaClientData) -> Optional[DcaClient]: +async def get_flow_mode_clients_for_machine(machine_id: str) -> list[DcaClient]: + """Active LPs enrolled at this machine whose per-user `dca_lp` row + has `default_dca_mode = 'flow'`. Used by the distribution algorithm. + + An LP enrolment without a matching `dca_lp` row (i.e., the LP hasn't + onboarded via satmachineclient yet) is filtered out by the INNER + JOIN — there's no destination wallet to pay to. + """ + return await db.fetchall( + """ + SELECT c.* + FROM satoshimachine.dca_clients c + JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id + WHERE c.machine_id = :machine_id + AND lp.default_dca_mode = 'flow' + AND c.status = 'active' + ORDER BY c.created_at ASC + """, + {"machine_id": machine_id}, + DcaClient, + ) + + +# ============================================================================= +# DCA LP preferences (per-user) — wallet + mode + autoforward +# ============================================================================= + + +async def get_dca_lp(user_id: str) -> DcaLpPreferences | None: + """Return the LP's preferences row, or None if they haven't onboarded + via satmachineclient yet.""" + return await db.fetchone( + "SELECT * FROM satoshimachine.dca_lp WHERE user_id = :uid", + {"uid": user_id}, + DcaLpPreferences, + ) + + +async def lp_is_onboarded(user_id: str) -> bool: + """Cheap existence check used by the deposit-creation gate.""" + row = await db.fetchone( + "SELECT user_id FROM satoshimachine.dca_lp WHERE user_id = :uid", + {"uid": user_id}, + ) + return row is not None + + +async def upsert_dca_lp( + user_id: str, + data: UpsertDcaLpData, + *, + fallback_wallet_id: str | None = None, +) -> DcaLpPreferences: + """Create or update the LP's preferences row. + + First call (no row yet): `data.dca_wallet_id` must be set OR + `fallback_wallet_id` must be provided (satmachineclient passes the + LP's default LNbits wallet here when auto-seeding on first dashboard + visit). Subsequent calls update only the fields in `data` that are + non-None. + """ + existing = await get_dca_lp(user_id) + now = datetime.now() + if existing is None: + wallet_id = data.dca_wallet_id or fallback_wallet_id + if not wallet_id: + raise ValueError( + "first upsert requires dca_wallet_id (or fallback_wallet_id)" + ) + await db.execute( + """ + INSERT 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) + VALUES (:uid, :wallet, :mode, :limit, :ln_addr, :auto, + :now, :now) + """, + { + "uid": user_id, + "wallet": wallet_id, + "mode": data.default_dca_mode or "flow", + "limit": data.fixed_mode_daily_limit, + "ln_addr": data.autoforward_ln_address, + "auto": data.autoforward_enabled or False, + "now": now, + }, + ) + else: + update_data: dict = {k: v for k, v in data.dict().items() if v is not None} + if not update_data: + return existing + update_data["updated_at"] = now + set_clause = ", ".join(f"{k} = :{k}" for k in update_data) + update_data["uid"] = user_id + await db.execute( + f"UPDATE satoshimachine.dca_lp SET {set_clause} WHERE user_id = :uid", + update_data, + ) + refreshed = await get_dca_lp(user_id) + assert refreshed is not None + return refreshed + + +async def update_dca_client( + client_id: str, data: UpdateDcaClientData +) -> DcaClient | None: update_data = {k: v for k, v in data.dict().items() if v is not None} if not update_data: return await get_dca_client(client_id) - update_data["updated_at"] = datetime.now() - set_clause = ", ".join([f"{k} = :{k}" for k in update_data.keys()]) + set_clause = ", ".join(f"{k} = :{k}" for k in update_data) update_data["id"] = client_id - await db.execute( f"UPDATE satoshimachine.dca_clients SET {set_clause} WHERE id = :id", - update_data + update_data, ) return await get_dca_client(client_id) async def delete_dca_client(client_id: str) -> None: await db.execute( - "DELETE FROM satoshimachine.dca_clients WHERE id = :id", - {"id": client_id} + "DELETE FROM satoshimachine.dca_clients WHERE id = :id", + {"id": client_id}, ) -# DCA Deposit CRUD Operations -async def create_deposit(data: CreateDepositData) -> DcaDeposit: +# ============================================================================= +# Deposits +# ============================================================================= + + +async def create_deposit( + creator_user_id: str, data: CreateDepositData, *, currency: str +) -> DcaDeposit: + """Insert a deposit row. + + `currency` is passed explicitly by the caller (the API endpoint + resolves it from the target machine's `fiat_code`) rather than + coming off the request body — the operator doesn't get to choose + it (`aiolabs/satmachineadmin#26`). + """ deposit_id = urlsafe_short_hash() await db.execute( """ - INSERT INTO satoshimachine.dca_deposits - (id, client_id, amount, currency, status, notes, created_at) - VALUES (:id, :client_id, :amount, :currency, :status, :notes, :created_at) + INSERT INTO satoshimachine.dca_deposits + (id, client_id, machine_id, creator_user_id, amount, currency, + status, notes, created_at) + VALUES (:id, :client_id, :machine_id, :creator_user_id, :amount, + :currency, :status, :notes, :created_at) """, { "id": deposit_id, "client_id": data.client_id, + "machine_id": data.machine_id, + "creator_user_id": creator_user_id, "amount": data.amount, - "currency": data.currency, + "currency": currency, "status": "pending", "notes": data.notes, - "created_at": datetime.now() - } + "created_at": datetime.now(), + }, ) - return await get_deposit(deposit_id) + deposit = await get_deposit(deposit_id) + assert deposit is not None + return deposit -async def get_deposit(deposit_id: str) -> Optional[DcaDeposit]: +async def get_deposit(deposit_id: str) -> DcaDeposit | None: return await db.fetchone( "SELECT * FROM satoshimachine.dca_deposits WHERE id = :id", {"id": deposit_id}, @@ -118,52 +497,65 @@ async def get_deposit(deposit_id: str) -> Optional[DcaDeposit]: ) -async def get_deposits_by_client(client_id: str) -> List[DcaDeposit]: +async def get_deposits_for_client(client_id: str) -> list[DcaDeposit]: return await db.fetchall( - "SELECT * FROM satoshimachine.dca_deposits WHERE client_id = :client_id ORDER BY created_at DESC", + """ + SELECT * FROM satoshimachine.dca_deposits + WHERE client_id = :client_id + ORDER BY created_at DESC + """, {"client_id": client_id}, DcaDeposit, ) -async def get_all_deposits() -> List[DcaDeposit]: +async def get_deposits_for_operator(operator_user_id: str) -> list[DcaDeposit]: return await db.fetchall( - "SELECT * FROM satoshimachine.dca_deposits ORDER BY created_at DESC", - model=DcaDeposit, + """ + SELECT d.* + FROM satoshimachine.dca_deposits d + JOIN satoshimachine.dca_machines m ON m.id = d.machine_id + WHERE m.operator_user_id = :uid + ORDER BY d.created_at DESC + """, + {"uid": operator_user_id}, + DcaDeposit, ) -async def update_deposit_status(deposit_id: str, data: UpdateDepositStatusData) -> Optional[DcaDeposit]: - update_data = { - "status": data.status, - "notes": data.notes - } - - if data.status == "confirmed": - update_data["confirmed_at"] = datetime.now() - - set_clause = ", ".join([f"{k} = :{k}" for k, v in update_data.items() if v is not None]) - filtered_data = {k: v for k, v in update_data.items() if v is not None} - filtered_data["id"] = deposit_id - +async def update_deposit( + deposit_id: str, data: UpdateDepositData +) -> DcaDeposit | None: + update_data = {k: v for k, v in data.dict().items() if v is not None} + if not update_data: + return await get_deposit(deposit_id) + set_clause = ", ".join(f"{k} = :{k}" for k in update_data) + update_data["id"] = deposit_id await db.execute( f"UPDATE satoshimachine.dca_deposits SET {set_clause} WHERE id = :id", - filtered_data + update_data, ) return await get_deposit(deposit_id) -async def update_deposit(deposit_id: str, data: UpdateDepositData) -> Optional[DcaDeposit]: - update_data = {k: v for k, v in data.dict().items() if v is not None} - if not update_data: - return await get_deposit(deposit_id) - - set_clause = ", ".join([f"{k} = :{k}" for k in update_data.keys()]) - update_data["id"] = deposit_id - +async def update_deposit_status( + deposit_id: str, data: UpdateDepositStatusData +) -> DcaDeposit | None: + payload = { + "id": deposit_id, + "status": data.status, + "notes": data.notes, + "confirmed_at": datetime.now() if data.status == "confirmed" else None, + } await db.execute( - f"UPDATE satoshimachine.dca_deposits SET {set_clause} WHERE id = :id", - update_data + """ + UPDATE satoshimachine.dca_deposits + SET status = :status, + notes = COALESCE(:notes, notes), + confirmed_at = COALESCE(:confirmed_at, confirmed_at) + WHERE id = :id + """, + payload, ) return await get_deposit(deposit_id) @@ -171,39 +563,564 @@ async def update_deposit(deposit_id: str, data: UpdateDepositData) -> Optional[D async def delete_deposit(deposit_id: str) -> None: await db.execute( "DELETE FROM satoshimachine.dca_deposits WHERE id = :id", - {"id": deposit_id} + {"id": deposit_id}, ) -# DCA Payment CRUD Operations +# ============================================================================= +# Settlements (bitSpire kind-21000 events) +# ============================================================================= + + +async def create_settlement_idempotent( + data: CreateDcaSettlementData, + initial_status: str, + error_message: str | None = None, +) -> DcaSettlement | None: + """Insert a settlement keyed by payment_hash. + + Returns the inserted row on first sight; returns the existing row + if the payment_hash was already seen (subscription replay, + dispatcher double-fire). The UNIQUE constraint on payment_hash is + the source of truth. + + `initial_status` is the row's status at insert time. Normal + settlements arrive as 'pending' and the distribution processor + transitions them through 'processing' → 'processed' / 'errored'. + A row that fails the Nostr attribution cross-check (bitspire. + assert_nostr_attribution) is inserted directly as 'rejected' with + the failure reason in `error_message` — never goes near the + distribution path. + """ + existing = await get_settlement_by_payment_hash(data.payment_hash) + if existing is not None: + return existing + settlement_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO satoshimachine.dca_settlements + (id, machine_id, payment_hash, bitspire_event_id, bitspire_txid, + wire_sats, fiat_amount, fiat_code, exchange_rate, principal_sats, + fee_sats, platform_fee_sats, operator_fee_sats, fee_mismatch_sats, + tx_type, bills_json, cassettes_json, + status, error_message, created_at) + VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id, + :bitspire_txid, :wire_sats, :fiat_amount, :fiat_code, + :exchange_rate, :principal_sats, :fee_sats, + :platform_fee_sats, :operator_fee_sats, :fee_mismatch_sats, + :tx_type, :bills_json, :cassettes_json, :status, + :error_message, :created_at) + """, + { + "id": settlement_id, + "machine_id": data.machine_id, + "payment_hash": data.payment_hash, + "bitspire_event_id": data.bitspire_event_id, + "bitspire_txid": data.bitspire_txid, + "wire_sats": data.wire_sats, + "fiat_amount": data.fiat_amount, + "fiat_code": data.fiat_code, + "exchange_rate": data.exchange_rate, + "principal_sats": data.principal_sats, + "fee_sats": data.fee_sats, + "platform_fee_sats": data.platform_fee_sats, + "operator_fee_sats": data.operator_fee_sats, + "fee_mismatch_sats": data.fee_mismatch_sats, + "tx_type": data.tx_type, + "bills_json": data.bills_json, + "cassettes_json": data.cassettes_json, + "status": initial_status, + "error_message": error_message, + "created_at": datetime.now(), + }, + ) + return await get_settlement(settlement_id) + + +async def get_settlement(settlement_id: str) -> DcaSettlement | None: + return await db.fetchone( + "SELECT * FROM satoshimachine.dca_settlements WHERE id = :id", + {"id": settlement_id}, + DcaSettlement, + ) + + +async def get_settlement_by_payment_hash( + payment_hash: str, +) -> DcaSettlement | None: + return await db.fetchone( + """ + SELECT * FROM satoshimachine.dca_settlements + WHERE payment_hash = :hash + """, + {"hash": payment_hash}, + DcaSettlement, + ) + + +async def get_settlements_for_machine( + machine_id: str, limit: int = 100 +) -> list[DcaSettlement]: + return await db.fetchall( + """ + SELECT * FROM satoshimachine.dca_settlements + WHERE machine_id = :machine_id + ORDER BY created_at DESC + LIMIT :lim + """, + {"machine_id": machine_id, "lim": limit}, + DcaSettlement, + ) + + +async def get_stuck_settlements_for_operator( + operator_user_id: str, threshold_minutes: int = 30 +) -> dict: + """Operator worklist of settlements that didn't process cleanly. + + Returns a dict with four keyed lists: + - 'rejected': any status='rejected' (Nostr attribution cross-check + failed — signer didn't match the machine identity). Distinct + from 'errored' because retry is wrong: the row was misrouted, + not operationally failed. Operator must investigate the machine. + - 'errored': any status='errored' (distribution failed for an + operational reason — wallet error, network, downstream payment). + Operator retries from this bucket. + - 'stuck_pending': status='pending' AND older than threshold + (listener crashed before invoking process_settlement). + - 'stuck_processing': status='processing' AND older than threshold + (processor crashed mid-flight; processing_claim is set but no + completion landed). + """ + from datetime import timedelta + + threshold_at = datetime.now() - timedelta(minutes=threshold_minutes) + rejected = await db.fetchall( + """ + SELECT s.* + FROM satoshimachine.dca_settlements s + JOIN satoshimachine.dca_machines m ON m.id = s.machine_id + WHERE m.operator_user_id = :uid AND s.status = 'rejected' + ORDER BY s.created_at DESC + """, + {"uid": operator_user_id}, + DcaSettlement, + ) + errored = await db.fetchall( + """ + SELECT s.* + FROM satoshimachine.dca_settlements s + JOIN satoshimachine.dca_machines m ON m.id = s.machine_id + WHERE m.operator_user_id = :uid AND s.status = 'errored' + ORDER BY s.created_at DESC + """, + {"uid": operator_user_id}, + DcaSettlement, + ) + stuck_pending = await db.fetchall( + """ + SELECT s.* + FROM satoshimachine.dca_settlements s + JOIN satoshimachine.dca_machines m ON m.id = s.machine_id + WHERE m.operator_user_id = :uid + AND s.status = 'pending' + AND s.created_at < :threshold + ORDER BY s.created_at ASC + """, + {"uid": operator_user_id, "threshold": threshold_at}, + DcaSettlement, + ) + stuck_processing = await db.fetchall( + """ + SELECT s.* + FROM satoshimachine.dca_settlements s + JOIN satoshimachine.dca_machines m ON m.id = s.machine_id + WHERE m.operator_user_id = :uid + AND s.status = 'processing' + AND s.created_at < :threshold + ORDER BY s.created_at ASC + """, + {"uid": operator_user_id, "threshold": threshold_at}, + DcaSettlement, + ) + return { + "rejected": rejected, + "errored": errored, + "stuck_pending": stuck_pending, + "stuck_processing": stuck_processing, + } + + +async def force_reset_stuck_settlement( + settlement_id: str, +) -> DcaSettlement | None: + """Operator escape hatch for genuinely stuck settlements (processor + crashed mid-flight, etc.). Flips 'pending'/'processing' → 'errored' so + the existing retry endpoint can take over. Clears processing_claim. + + Caller is responsible for verifying the settlement is *actually* stuck + (e.g., via threshold check on created_at). This function trusts the + decision.""" + await db.execute( + """ + UPDATE satoshimachine.dca_settlements + SET status = 'errored', + processing_claim = NULL, + error_message = 'force-reset by operator (was stuck)' + WHERE id = :id AND status IN ('pending', 'processing') + """, + {"id": settlement_id}, + ) + return await get_settlement(settlement_id) + + +async def get_settlements_for_operator( + operator_user_id: str, limit: int = 200 +) -> list[DcaSettlement]: + return await db.fetchall( + """ + SELECT s.* + FROM satoshimachine.dca_settlements s + JOIN satoshimachine.dca_machines m ON m.id = s.machine_id + WHERE m.operator_user_id = :uid + ORDER BY s.created_at DESC + LIMIT :lim + """, + {"uid": operator_user_id, "lim": limit}, + DcaSettlement, + ) + + +async def mark_settlement_status( + settlement_id: str, + status: str, + error_message: str | None = None, +) -> DcaSettlement | None: + """Status: 'pending' | 'processing' | 'processed' | 'partial' | + 'refunded' | 'errored'. Clears processing_claim on terminal states so a + fresh claim attempt won't see a stale token.""" + await db.execute( + """ + UPDATE satoshimachine.dca_settlements + SET status = :status, + error_message = :err, + processed_at = CASE + WHEN :status IN ('processed', 'partial', 'refunded') + THEN :now ELSE processed_at + END, + processing_claim = CASE + WHEN :status = 'processing' THEN processing_claim + ELSE NULL + END + WHERE id = :id + """, + { + "id": settlement_id, + "status": status, + "err": error_message, + "now": datetime.now(), + }, + ) + return await get_settlement(settlement_id) + + +async def claim_settlement_for_processing( + settlement_id: str, +) -> DcaSettlement | None: + """Optimistic-lock claim: atomically flip a settlement to 'processing' + and tag it with a per-invocation token. Returns the claimed row on + success; None if another caller already won the claim or the settlement + is not in a claimable state ('pending'). + + Pattern is portable across SQLite + PostgreSQL (doesn't rely on + UPDATE ... RETURNING). Two concurrent invocations may both run the + UPDATE, but only one row matches the WHERE clause; the loser's UPDATE + is a no-op against status='processing'. The read-back check on the + token disambiguates.""" + token = urlsafe_short_hash() + await db.execute( + """ + UPDATE satoshimachine.dca_settlements + SET status = 'processing', processing_claim = :token + WHERE id = :id AND status = 'pending' + """, + {"id": settlement_id, "token": token}, + ) + after = await get_settlement(settlement_id) + if after is None: + return None + if after.processing_claim != token: + return None + return after + + +async def reset_settlement_for_retry( + settlement_id: str, +) -> DcaSettlement | None: + """Operator retry path. Flips 'errored' → 'pending' and voids any + 'failed' legs so process_settlement re-runs them fresh. Completed legs + are left in place — we never re-pay sats that already moved.""" + await db.execute( + """ + UPDATE satoshimachine.dca_payments + SET status = 'voided' + WHERE settlement_id = :sid AND status = 'failed' + """, + {"sid": settlement_id}, + ) + await db.execute( + """ + UPDATE satoshimachine.dca_settlements + SET status = 'pending', + error_message = NULL, + processing_claim = NULL, + processed_at = NULL + WHERE id = :id AND status = 'errored' + """, + {"id": settlement_id}, + ) + return await get_settlement(settlement_id) + + +async def apply_partial_dispense( + settlement_id: str, + *, + new_wire_sats: int, + new_principal_sats: int, + new_fee_sats: int, + new_platform_fee_sats: int, + new_operator_fee_sats: int, + new_fiat_amount: float, + appended_note: str, +) -> DcaSettlement | None: + """Overwrite the monetary fields on a settlement (partial-dispense + recompute) and prepend `appended_note` to the notes column. + + Notes are append-only: new lines go at the top (newest first) so the + settlement detail view shows the most recent adjustment first without + needing to scroll. Resets status to 'pending' so process_settlement + can re-distribute via the existing idempotent path.""" + await db.execute( + """ + UPDATE satoshimachine.dca_settlements + SET wire_sats = :gross, + principal_sats = :principal, + fee_sats = :commission, + platform_fee_sats = :platform, + operator_fee_sats = :operator, + fiat_amount = :fiat, + status = 'pending', + error_message = NULL, + processed_at = NULL, + notes = CASE + WHEN notes IS NULL OR notes = '' THEN :note + ELSE :note || char(10) || char(10) || notes + END + WHERE id = :id + """, + { + "id": settlement_id, + "gross": new_wire_sats, + "principal": new_principal_sats, + "commission": new_fee_sats, + "platform": new_platform_fee_sats, + "operator": new_operator_fee_sats, + "fiat": new_fiat_amount, + "note": appended_note, + }, + ) + return await get_settlement(settlement_id) + + +async def count_completed_legs_for_settlement(settlement_id: str) -> int: + """Used by partial-dispense to refuse adjustments after any leg has + successfully moved sats (Lightning payments can't be clawed back).""" + row = await db.fetchone( + """ + SELECT COUNT(*) AS n FROM satoshimachine.dca_payments + WHERE settlement_id = :sid AND status = 'completed' + """, + {"sid": settlement_id}, + ) + return int(row["n"]) if row else 0 + + +async def append_settlement_note( + settlement_id: str, note: str, author_user_id: str +) -> DcaSettlement | None: + """Prepend an operator-authored note to settlement.notes. Each entry is + timestamped (UTC) and tagged with the author's user id so the trail + is accountable. Append-only: existing entries are never edited.""" + from datetime import timezone + + ts = datetime.now(timezone.utc).isoformat(timespec="seconds") + formatted = f"[{ts} by {author_user_id}] {note}" + await db.execute( + """ + UPDATE satoshimachine.dca_settlements + SET notes = CASE + WHEN notes IS NULL OR notes = '' THEN :note + ELSE :note || char(10) || char(10) || notes + END + WHERE id = :id + """, + {"id": settlement_id, "note": formatted}, + ) + return await get_settlement(settlement_id) + + +async def void_open_legs_for_settlement(settlement_id: str) -> None: + """Marks open legs as 'voided' before re-running distribution on a + partial-dispense recompute. Preserves the rows for audit but stops + them from being interpreted as live. Includes 'skipped' so that audit + rows from a prior attempt don't double-count once the new attempt + writes its own (possibly different) skipped reasons.""" + await db.execute( + """ + UPDATE satoshimachine.dca_payments + SET status = 'voided' + WHERE settlement_id = :sid + AND status IN ('pending', 'failed', 'skipped') + """, + {"sid": settlement_id}, + ) + + +# ============================================================================= +# Commission splits — operator's remainder-distribution rules. +# ============================================================================= + + +async def get_commission_splits( + operator_user_id: str, machine_id: str | None = None +) -> list[CommissionSplit]: + """Returns the rule set for the given scope. + + Precedence (caller's responsibility): try per-machine override first; + if empty, fall back to operator default (machine_id IS NULL). + """ + if machine_id is None: + return await db.fetchall( + """ + SELECT * FROM satoshimachine.dca_commission_splits + WHERE operator_user_id = :uid AND machine_id IS NULL + ORDER BY sort_order ASC + """, + {"uid": operator_user_id}, + CommissionSplit, + ) + return await db.fetchall( + """ + SELECT * FROM satoshimachine.dca_commission_splits + WHERE operator_user_id = :uid AND machine_id = :mid + ORDER BY sort_order ASC + """, + {"uid": operator_user_id, "mid": machine_id}, + CommissionSplit, + ) + + +async def get_effective_commission_splits( + operator_user_id: str, machine_id: str +) -> list[CommissionSplit]: + """Per-machine override if set, otherwise operator's default ruleset.""" + overrides = await get_commission_splits(operator_user_id, machine_id) + if overrides: + return overrides + return await get_commission_splits(operator_user_id, None) + + +async def replace_commission_splits( + operator_user_id: str, + machine_id: str | None, + legs: list[CommissionSplitLeg], +) -> list[CommissionSplit]: + """Atomic replace for the (operator, machine) scope. Caller should have + already validated legs sum to 1.0 via the Pydantic model.""" + if machine_id is None: + await db.execute( + """ + DELETE FROM satoshimachine.dca_commission_splits + WHERE operator_user_id = :uid AND machine_id IS NULL + """, + {"uid": operator_user_id}, + ) + else: + await db.execute( + """ + DELETE FROM satoshimachine.dca_commission_splits + WHERE operator_user_id = :uid AND machine_id = :mid + """, + {"uid": operator_user_id, "mid": machine_id}, + ) + now = datetime.now() + for leg in legs: + await db.execute( + """ + INSERT INTO satoshimachine.dca_commission_splits + (id, machine_id, operator_user_id, target, label, fraction, + sort_order, created_at) + VALUES (:id, :machine_id, :uid, :target, :label, :fraction, + :sort_order, :created_at) + """, + { + "id": urlsafe_short_hash(), + "machine_id": machine_id, + "uid": operator_user_id, + "target": leg.target, + "label": leg.label, + "fraction": leg.fraction, + "sort_order": leg.sort_order, + "created_at": now, + }, + ) + return await get_commission_splits(operator_user_id, machine_id) + + +# ============================================================================= +# Payments — distribution legs. +# ============================================================================= + + async def create_dca_payment(data: CreateDcaPaymentData) -> DcaPayment: payment_id = urlsafe_short_hash() await db.execute( """ - INSERT INTO satoshimachine.dca_payments - (id, client_id, amount_sats, amount_fiat, exchange_rate, transaction_type, - lamassu_transaction_id, payment_hash, status, created_at, transaction_time) - VALUES (:id, :client_id, :amount_sats, :amount_fiat, :exchange_rate, :transaction_type, - :lamassu_transaction_id, :payment_hash, :status, :created_at, :transaction_time) + INSERT INTO satoshimachine.dca_payments + (id, settlement_id, client_id, machine_id, operator_user_id, + leg_type, destination_wallet_id, destination_ln_address, + amount_sats, amount_fiat, exchange_rate, transaction_time, + external_payment_hash, status, created_at) + VALUES (:id, :settlement_id, :client_id, :machine_id, + :operator_user_id, :leg_type, :destination_wallet_id, + :destination_ln_address, :amount_sats, :amount_fiat, + :exchange_rate, :transaction_time, :external_payment_hash, + :status, :created_at) """, { "id": payment_id, + "settlement_id": data.settlement_id, "client_id": data.client_id, + "machine_id": data.machine_id, + "operator_user_id": data.operator_user_id, + "leg_type": data.leg_type, + "destination_wallet_id": data.destination_wallet_id, + "destination_ln_address": data.destination_ln_address, "amount_sats": data.amount_sats, "amount_fiat": data.amount_fiat, "exchange_rate": data.exchange_rate, - "transaction_type": data.transaction_type, - "lamassu_transaction_id": data.lamassu_transaction_id, - "payment_hash": data.payment_hash, + "transaction_time": data.transaction_time, + "external_payment_hash": data.external_payment_hash, "status": "pending", "created_at": datetime.now(), - "transaction_time": data.transaction_time - } + }, ) - return await get_dca_payment(payment_id) + payment = await get_dca_payment(payment_id) + assert payment is not None + return payment -async def get_dca_payment(payment_id: str) -> Optional[DcaPayment]: +async def get_dca_payment(payment_id: str) -> DcaPayment | None: return await db.fetchone( "SELECT * FROM satoshimachine.dca_payments WHERE id = :id", {"id": payment_id}, @@ -211,327 +1128,410 @@ async def get_dca_payment(payment_id: str) -> Optional[DcaPayment]: ) -async def get_payments_by_client(client_id: str) -> List[DcaPayment]: +async def get_payments_for_settlement(settlement_id: str) -> list[DcaPayment]: return await db.fetchall( - "SELECT * FROM satoshimachine.dca_payments WHERE client_id = :client_id ORDER BY created_at DESC", - {"client_id": client_id}, + """ + SELECT * FROM satoshimachine.dca_payments + WHERE settlement_id = :sid + ORDER BY created_at ASC + """, + {"sid": settlement_id}, DcaPayment, ) -async def get_all_payments() -> List[DcaPayment]: +async def get_payments_for_client(client_id: str) -> list[DcaPayment]: return await db.fetchall( - "SELECT * FROM satoshimachine.dca_payments ORDER BY created_at DESC", - model=DcaPayment, + """ + SELECT * FROM satoshimachine.dca_payments + WHERE client_id = :cid + ORDER BY created_at DESC + """, + {"cid": client_id}, + DcaPayment, ) -async def update_dca_payment_status(payment_id: str, status: str) -> None: - """Update the status of a DCA payment""" +async def get_payments_for_operator( + operator_user_id: str, leg_type: str | None = None, limit: int = 200 +) -> list[DcaPayment]: + if leg_type is None: + return await db.fetchall( + """ + SELECT * FROM satoshimachine.dca_payments + WHERE operator_user_id = :uid + ORDER BY created_at DESC + LIMIT :lim + """, + {"uid": operator_user_id, "lim": limit}, + DcaPayment, + ) + return await db.fetchall( + """ + SELECT * FROM satoshimachine.dca_payments + WHERE operator_user_id = :uid AND leg_type = :leg + ORDER BY created_at DESC + LIMIT :lim + """, + {"uid": operator_user_id, "leg": leg_type, "lim": limit}, + DcaPayment, + ) + + +async def update_payment_status( + payment_id: str, + status: str, + external_payment_hash: str | None = None, + error_message: str | None = None, +) -> DcaPayment | None: await db.execute( - "UPDATE satoshimachine.dca_payments SET status = :status WHERE id = :id", - {"status": status, "id": payment_id} - ) - - -async def get_payments_by_lamassu_transaction(lamassu_transaction_id: str) -> List[DcaPayment]: - return await db.fetchall( - "SELECT * FROM satoshimachine.dca_payments WHERE lamassu_transaction_id = :transaction_id", - {"transaction_id": lamassu_transaction_id}, - DcaPayment, - ) - - -# Balance and Summary Operations -async def get_client_balance_summary(client_id: str, as_of_time: Optional[datetime] = None) -> ClientBalanceSummary: - """Get client balance summary, optionally as of a specific point in time""" - - # Build time filter for temporal accuracy - time_filter = "" - params = {"client_id": client_id} - - if as_of_time is not None: - time_filter = "AND confirmed_at <= :as_of_time" - params["as_of_time"] = as_of_time - - # Get total confirmed deposits (only those confirmed before the cutoff time) - total_deposits_result = await db.fetchone( - f""" - SELECT COALESCE(SUM(amount), 0) as total, currency - FROM satoshimachine.dca_deposits - WHERE client_id = :client_id AND status = 'confirmed' {time_filter} - GROUP BY currency + """ + UPDATE satoshimachine.dca_payments + SET status = :status, + external_payment_hash = COALESCE(:hash, external_payment_hash), + error_message = :err + WHERE id = :id """, - params + { + "id": payment_id, + "status": status, + "hash": external_payment_hash, + "err": error_message, + }, ) - - # Get total payments made (only those with ATM transaction time before the cutoff) - # Use transaction_time instead of created_at for temporal accuracy - payment_time_filter = "" - if as_of_time is not None: - payment_time_filter = "AND transaction_time <= :as_of_time" - - total_payments_result = await db.fetchone( - f""" - SELECT COALESCE(SUM(amount_fiat), 0) as total - FROM satoshimachine.dca_payments - WHERE client_id = :client_id AND status = 'confirmed' {payment_time_filter} + return await get_dca_payment(payment_id) + + +# ============================================================================= +# Balance summaries +# ============================================================================= + + +async def get_client_balance_summary( + client_id: str, +) -> ClientBalanceSummary | None: + """Per-client (and per-machine, since clients are per-machine in v2) summary. + + DCA legs only — settlement/autoforward/super_fee/operator_split legs are + not credited against an LP's balance. + """ + client = await get_dca_client(client_id) + if client is None: + return None + deposits_row = await db.fetchone( + """ + SELECT COALESCE(SUM(amount), 0) AS total + FROM satoshimachine.dca_deposits + WHERE client_id = :cid AND status = 'confirmed' """, - params + {"cid": client_id}, ) - - total_deposits = total_deposits_result["total"] if total_deposits_result else 0 - total_payments = total_payments_result["total"] if total_payments_result else 0 - currency = total_deposits_result["currency"] if total_deposits_result else "GTQ" - - # Log temporal filtering if as_of_time was used - if as_of_time is not None: - from loguru import logger - # Verify timezone consistency for temporal filtering - tz_info = "UTC" if as_of_time.tzinfo == timezone.utc else f"TZ: {as_of_time.tzinfo}" - logger.info(f"Client {client_id[:8]}... balance as of {as_of_time} ({tz_info}): deposits.confirmed_at <= cutoff, payments.transaction_time <= cutoff → {total_deposits - total_payments:.2f} GTQ remaining") - + # Both DCA legs (auto, from bitSpire settlements) and balance-settle legs + # (operator-initiated under #4) reduce the LP's remaining fiat balance. + payments_row = await db.fetchone( + """ + SELECT COALESCE(SUM(amount_fiat), 0) AS total + FROM satoshimachine.dca_payments + WHERE client_id = :cid + AND leg_type IN ('dca', 'settlement') + AND status = 'completed' + """, + {"cid": client_id}, + ) + total_deposits = float(deposits_row["total"]) if deposits_row else 0.0 + total_payments = float(payments_row["total"]) if payments_row else 0.0 + # fiat code: take it from the machine (clients inherit their machine's fiat) + machine = await get_machine(client.machine_id) + currency = machine.fiat_code if machine else "GTQ" return ClientBalanceSummary( client_id=client_id, - total_deposits=total_deposits, - total_payments=total_payments, - remaining_balance=total_deposits - total_payments, - currency=currency + machine_id=client.machine_id, + total_deposits=round(total_deposits, 2), + total_payments=round(total_payments, 2), + remaining_balance=round(total_deposits - total_payments, 2), + currency=currency, ) -async def get_flow_mode_clients() -> List[DcaClient]: - return await db.fetchall( - "SELECT * FROM satoshimachine.dca_clients WHERE dca_mode = 'flow' AND status = 'active'", - model=DcaClient, - ) +# ============================================================================= +# Telemetry — sparse beacon (kind-30078) and fleet snapshot (kind-30079) state. +# ============================================================================= -async def get_fixed_mode_clients() -> List[DcaClient]: - return await db.fetchall( - "SELECT * FROM satoshimachine.dca_clients WHERE dca_mode = 'fixed' AND status = 'active'", - model=DcaClient, - ) - - -# Lamassu Configuration CRUD Operations -async def create_lamassu_config(data: CreateLamassuConfigData) -> LamassuConfig: - config_id = urlsafe_short_hash() - - # Deactivate any existing configs first (only one active config allowed) - await db.execute( - "UPDATE satoshimachine.lamassu_config SET is_active = false, updated_at = :updated_at", - {"updated_at": datetime.now()} - ) - - await db.execute( - """ - INSERT INTO satoshimachine.lamassu_config - (id, host, port, database_name, username, password, source_wallet_id, commission_wallet_id, is_active, created_at, updated_at, - use_ssh_tunnel, ssh_host, ssh_port, ssh_username, ssh_password, ssh_private_key, max_daily_limit_gtq) - VALUES (:id, :host, :port, :database_name, :username, :password, :source_wallet_id, :commission_wallet_id, :is_active, :created_at, :updated_at, - :use_ssh_tunnel, :ssh_host, :ssh_port, :ssh_username, :ssh_password, :ssh_private_key, :max_daily_limit_gtq) - """, - { - "id": config_id, - "host": data.host, - "port": data.port, - "database_name": data.database_name, - "username": data.username, - "password": data.password, - "source_wallet_id": data.source_wallet_id, - "commission_wallet_id": data.commission_wallet_id, - "is_active": True, - "created_at": datetime.now(), - "updated_at": datetime.now(), - "use_ssh_tunnel": data.use_ssh_tunnel, - "ssh_host": data.ssh_host, - "ssh_port": data.ssh_port, - "ssh_username": data.ssh_username, - "ssh_password": data.ssh_password, - "ssh_private_key": data.ssh_private_key, - "max_daily_limit_gtq": data.max_daily_limit_gtq - } - ) - return await get_lamassu_config(config_id) - - -async def get_lamassu_config(config_id: str) -> Optional[LamassuConfig]: +async def get_telemetry(machine_id: str) -> TelemetrySnapshot | None: return await db.fetchone( - "SELECT * FROM satoshimachine.lamassu_config WHERE id = :id", - {"id": config_id}, - LamassuConfig, + "SELECT * FROM satoshimachine.dca_telemetry WHERE machine_id = :mid", + {"mid": machine_id}, + TelemetrySnapshot, ) -async def get_active_lamassu_config() -> Optional[LamassuConfig]: +async def upsert_beacon_snapshot( + machine_id: str, + *, + cash_in: bool | None = None, + cash_out: bool | None = None, + cash_level: str | None = None, + fiat: str | None = None, + model: str | None = None, + name: str | None = None, + location: str | None = None, + geo: str | None = None, + fees_json: str | None = None, + limits_json: str | None = None, + denominations_json: str | None = None, + version: str | None = None, +) -> TelemetrySnapshot | None: + """Upsert kind-30078 beacon fields. All fields are nullable because today's + upstream payload only carries cash_in/cash_out/cash_level/fiat/model (see + lamassu-next#43 — the enrichment is not yet shipped).""" + existing = await get_telemetry(machine_id) + now = datetime.now() + if existing is None: + await db.execute( + """ + INSERT INTO satoshimachine.dca_telemetry + (machine_id, beacon_cash_in, beacon_cash_out, beacon_cash_level, + beacon_fiat, beacon_model, beacon_name, beacon_location, + beacon_geo, beacon_fees_json, beacon_limits_json, + beacon_denominations_json, beacon_version, beacon_received_at) + VALUES (:mid, :cash_in, :cash_out, :cash_level, :fiat, :model, + :name, :location, :geo, :fees, :limits, :denoms, + :version, :now) + """, + { + "mid": machine_id, + "cash_in": cash_in, + "cash_out": cash_out, + "cash_level": cash_level, + "fiat": fiat, + "model": model, + "name": name, + "location": location, + "geo": geo, + "fees": fees_json, + "limits": limits_json, + "denoms": denominations_json, + "version": version, + "now": now, + }, + ) + else: + await db.execute( + """ + UPDATE satoshimachine.dca_telemetry SET + beacon_cash_in = COALESCE(:cash_in, beacon_cash_in), + beacon_cash_out = COALESCE(:cash_out, beacon_cash_out), + beacon_cash_level = COALESCE(:cash_level, beacon_cash_level), + beacon_fiat = COALESCE(:fiat, beacon_fiat), + beacon_model = COALESCE(:model, beacon_model), + beacon_name = COALESCE(:name, beacon_name), + beacon_location = COALESCE(:location, beacon_location), + beacon_geo = COALESCE(:geo, beacon_geo), + beacon_fees_json = COALESCE(:fees, beacon_fees_json), + beacon_limits_json = COALESCE(:limits, beacon_limits_json), + beacon_denominations_json = + COALESCE(:denoms, beacon_denominations_json), + beacon_version = COALESCE(:version, beacon_version), + beacon_received_at = :now + WHERE machine_id = :mid + """, + { + "mid": machine_id, + "cash_in": cash_in, + "cash_out": cash_out, + "cash_level": cash_level, + "fiat": fiat, + "model": model, + "name": name, + "location": location, + "geo": geo, + "fees": fees_json, + "limits": limits_json, + "denoms": denominations_json, + "version": version, + "now": now, + }, + ) + return await get_telemetry(machine_id) + + +async def upsert_fleet_snapshot( + machine_id: str, telemetry_json: str +) -> TelemetrySnapshot | None: + """Upsert kind-30079 operator-only telemetry. Awaits lamassu-next#42 to + produce a real schema; we store the raw JSON blob until then.""" + existing = await get_telemetry(machine_id) + now = datetime.now() + if existing is None: + await db.execute( + """ + INSERT INTO satoshimachine.dca_telemetry + (machine_id, telemetry_json, telemetry_received_at) + VALUES (:mid, :json, :now) + """, + {"mid": machine_id, "json": telemetry_json, "now": now}, + ) + else: + await db.execute( + """ + UPDATE satoshimachine.dca_telemetry + SET telemetry_json = :json, telemetry_received_at = :now + WHERE machine_id = :mid + """, + {"mid": machine_id, "json": telemetry_json, "now": now}, + ) + return await get_telemetry(machine_id) + + +# ============================================================================= +# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1). +# ============================================================================= +# Row lifecycle per #29: +# - First population for a (machine_id, position) pair → apply_bootstrap_state +# (consumer reading the ATM's one-shot bitspire-cassettes-state event) +# - Operator edit of denomination or count → update_cassette_config +# (refuses to create new rows; the slot count is hardware-determined) +# - Row creation/deletion for a new position → admin only, via ATM +# re-provisioning + new bootstrap event (not exposed in v1 here) + + +def _should_apply_bootstrap_state( + existing_state_event_id: str | None, incoming_event_id: str +) -> bool: + """Pure-function dedup gate for apply_bootstrap_state. + + Returns False if any existing row for this machine already references + the incoming event_id (relay re-delivery after restart). True otherwise. + + Extracted as a pure function so the dedup decision is unit-testable + without a database round-trip. The actual idempotency check in + apply_bootstrap_state fetches one existing row and passes its + state_event_id here. + """ + return existing_state_event_id != incoming_event_id + + +async def get_cassette_config( + machine_id: str, position: int +) -> CassetteConfig | None: return await db.fetchone( - "SELECT * FROM satoshimachine.lamassu_config WHERE is_active = true ORDER BY created_at DESC LIMIT 1", - model=LamassuConfig, + "SELECT * FROM satoshimachine.cassette_configs " + "WHERE machine_id = :mid AND position = :pos", + {"mid": machine_id, "pos": position}, + CassetteConfig, ) -async def get_all_lamassu_configs() -> List[LamassuConfig]: +async def list_cassette_configs_for_machine( + machine_id: str, +) -> list[CassetteConfig]: return await db.fetchall( - "SELECT * FROM satoshimachine.lamassu_config ORDER BY created_at DESC", - model=LamassuConfig, + "SELECT * FROM satoshimachine.cassette_configs " + "WHERE machine_id = :mid ORDER BY position", + {"mid": machine_id}, + CassetteConfig, ) -async def update_lamassu_config(config_id: str, data: UpdateLamassuConfigData) -> Optional[LamassuConfig]: - update_data = {k: v for k, v in data.dict().items() if v is not None} +async def update_cassette_config( + machine_id: str, + position: int, + data: UpsertCassetteConfigData, + *, + updated_by: str | None = None, +) -> CassetteConfig | None: + """Operator-driven row update: change denomination and/or count for a + single cassette slot. Refuses to create new rows — those only land via + apply_bootstrap_state() consuming an ATM bootstrap event (per #29 row + lifecycle: hardware-determined slot count, not operator-creatable). + Returns None if the (machine_id, position) row doesn't exist. + """ + existing = await get_cassette_config(machine_id, position) + if existing is None: + return None + update_data: dict = {k: v for k, v in data.dict().items() if v is not None} if not update_data: - return await get_lamassu_config(config_id) - + return existing update_data["updated_at"] = datetime.now() - set_clause = ", ".join([f"{k} = :{k}" for k in update_data.keys()]) - update_data["id"] = config_id - + update_data["updated_by"] = updated_by + set_clause = ", ".join(f"{k} = :{k}" for k in update_data) + update_data["mid"] = machine_id + update_data["pos"] = position await db.execute( - f"UPDATE satoshimachine.lamassu_config SET {set_clause} WHERE id = :id", - update_data + f"UPDATE satoshimachine.cassette_configs SET {set_clause} " + "WHERE machine_id = :mid AND position = :pos", + update_data, ) - return await get_lamassu_config(config_id) + return await get_cassette_config(machine_id, position) -async def update_config_test_result(config_id: str, success: bool) -> None: - utc_now = datetime.now(timezone.utc) - await db.execute( - """ - UPDATE satoshimachine.lamassu_config - SET test_connection_last = :test_time, test_connection_success = :success, updated_at = :updated_at - WHERE id = :id - """, - { - "id": config_id, - "test_time": utc_now, - "success": success, - "updated_at": utc_now - } +async def apply_bootstrap_state( + machine_id: str, + event_id: str, + event_created_at: datetime, + payload: PublishCassettesPayload, +) -> bool: + """Consume an ATM-published kind-30078 bitspire-cassettes-state: event + and upsert one cassette_configs row per position in the payload. + + Returns True if the upsert ran; False if any existing row for this + machine already references this event_id (idempotent on relay + re-delivery / restart). + + Populates both the operator-believed columns (denomination, count, + updated_at, updated_by='atm-bootstrap') AND the v2 reverse-channel + columns (state_denomination, state_count, state_at, state_event_id) + so the operator's initial view matches the ATM's reported state. v2 + reconciliation UI will diverge them when continuous reverse-channel + events land + the operator subsequently edits. + """ + existing_first: dict | None = await db.fetchone( + "SELECT state_event_id FROM satoshimachine.cassette_configs " + "WHERE machine_id = :mid LIMIT 1", + {"mid": machine_id}, ) + existing_event_id: str | None = None + if existing_first is not None: + existing_event_id = ( + existing_first.get("state_event_id") + if isinstance(existing_first, dict) + else getattr(existing_first, "state_event_id", None) + ) + if not _should_apply_bootstrap_state(existing_event_id, event_id): + return False - -async def delete_lamassu_config(config_id: str) -> None: - await db.execute( - "DELETE FROM satoshimachine.lamassu_config WHERE id = :id", - {"id": config_id} - ) - - -async def update_poll_start_time(config_id: str) -> None: - """Update the last poll start time""" - utc_now = datetime.now(timezone.utc) - await db.execute( - """ - UPDATE satoshimachine.lamassu_config - SET last_poll_time = :poll_time, updated_at = :updated_at - WHERE id = :id - """, - { - "id": config_id, - "poll_time": utc_now, - "updated_at": utc_now - } - ) - - -async def update_poll_success_time(config_id: str) -> None: - """Update the last successful poll time""" - utc_now = datetime.now(timezone.utc) - await db.execute( - """ - UPDATE satoshimachine.lamassu_config - SET last_successful_poll = :poll_time, updated_at = :updated_at - WHERE id = :id - """, - { - "id": config_id, - "poll_time": utc_now, - "updated_at": utc_now - } - ) - - -# Lamassu Transaction Storage CRUD Operations -async def create_lamassu_transaction(data: CreateLamassuTransactionData) -> StoredLamassuTransaction: - """Store a processed Lamassu transaction""" - transaction_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO satoshimachine.lamassu_transactions - (id, lamassu_transaction_id, fiat_amount, crypto_amount, commission_percentage, - discount, effective_commission, commission_amount_sats, base_amount_sats, - exchange_rate, crypto_code, fiat_code, device_id, transaction_time, processed_at, - clients_count, distributions_total_sats) - VALUES (:id, :lamassu_transaction_id, :fiat_amount, :crypto_amount, :commission_percentage, - :discount, :effective_commission, :commission_amount_sats, :base_amount_sats, - :exchange_rate, :crypto_code, :fiat_code, :device_id, :transaction_time, :processed_at, - :clients_count, :distributions_total_sats) - """, - { - "id": transaction_id, - "lamassu_transaction_id": data.lamassu_transaction_id, - "fiat_amount": data.fiat_amount, - "crypto_amount": data.crypto_amount, - "commission_percentage": data.commission_percentage, - "discount": data.discount, - "effective_commission": data.effective_commission, - "commission_amount_sats": data.commission_amount_sats, - "base_amount_sats": data.base_amount_sats, - "exchange_rate": data.exchange_rate, - "crypto_code": data.crypto_code, - "fiat_code": data.fiat_code, - "device_id": data.device_id, - "transaction_time": data.transaction_time, - "processed_at": datetime.now(), - "clients_count": 0, # Will be updated after distributions - "distributions_total_sats": 0 # Will be updated after distributions - } - ) - return await get_lamassu_transaction(transaction_id) - - -async def get_lamassu_transaction(transaction_id: str) -> Optional[StoredLamassuTransaction]: - """Get a stored Lamassu transaction by ID""" - return await db.fetchone( - "SELECT * FROM satoshimachine.lamassu_transactions WHERE id = :id", - {"id": transaction_id}, - StoredLamassuTransaction, - ) - - -async def get_lamassu_transaction_by_lamassu_id(lamassu_transaction_id: str) -> Optional[StoredLamassuTransaction]: - """Get a stored Lamassu transaction by Lamassu transaction ID""" - return await db.fetchone( - "SELECT * FROM satoshimachine.lamassu_transactions WHERE lamassu_transaction_id = :lamassu_id", - {"lamassu_id": lamassu_transaction_id}, - StoredLamassuTransaction, - ) - - -async def get_all_lamassu_transactions() -> List[StoredLamassuTransaction]: - """Get all stored Lamassu transactions""" - return await db.fetchall( - "SELECT * FROM satoshimachine.lamassu_transactions ORDER BY transaction_time DESC", - model=StoredLamassuTransaction, - ) - - -async def update_lamassu_transaction_distribution_stats( - transaction_id: str, - clients_count: int, - distributions_total_sats: int -) -> None: - """Update distribution statistics for a Lamassu transaction""" - await db.execute( - """ - UPDATE satoshimachine.lamassu_transactions - SET clients_count = :clients_count, distributions_total_sats = :distributions_total_sats - WHERE id = :id - """, - { - "clients_count": clients_count, - "distributions_total_sats": distributions_total_sats, - "id": transaction_id - } - ) + now = datetime.now() + for pos, row in payload.positions.items(): + await db.execute( + """ + INSERT INTO satoshimachine.cassette_configs + (machine_id, position, denomination, count, updated_at, + updated_by, state_denomination, state_count, state_at, + state_event_id) + VALUES (:mid, :pos, :denom, :count, :now, :by, + :state_denom, :state_count, :state_at, :event_id) + ON CONFLICT (machine_id, position) DO UPDATE SET + denomination = excluded.denomination, + count = excluded.count, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by, + state_denomination = excluded.state_denomination, + state_count = excluded.state_count, + state_at = excluded.state_at, + state_event_id = excluded.state_event_id + """, + { + "mid": machine_id, + "pos": pos, + "denom": row.denomination, + "count": row.count, + "now": now, + "by": "atm-bootstrap", + "state_denom": row.denomination, + "state_count": row.count, + "state_at": event_created_at, + "event_id": event_id, + }, + ) + return True diff --git a/distribution.py b/distribution.py new file mode 100644 index 0000000..b4dfbeb --- /dev/null +++ b/distribution.py @@ -0,0 +1,874 @@ +# 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 diff --git a/docs/security-pathway-v1.md b/docs/security-pathway-v1.md new file mode 100644 index 0000000..2b7cc8b --- /dev/null +++ b/docs/security-pathway-v1.md @@ -0,0 +1,403 @@ +# 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= +``` + +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:"` 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], // links back to the request │ +│ ["p", ], │ +│ ["P", ], │ +│ ["bolt11", ], │ +│ ["preimage", <32-byte hex>], │ +│ ["amount", ""], │ +│ ["fiat", "EUR:20.00"] │ +│ ], │ +│ content: "" } │ +│ │ +│ Operator audits: fetch all kind:9735 with #p=; 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. diff --git a/docs/security-pathway-v1.pdf b/docs/security-pathway-v1.pdf new file mode 100644 index 0000000..a5b77e9 Binary files /dev/null and b/docs/security-pathway-v1.pdf differ diff --git a/docs/security-pathway.css b/docs/security-pathway.css new file mode 100644 index 0000000..c6b1916 --- /dev/null +++ b/docs/security-pathway.css @@ -0,0 +1,114 @@ +@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; } diff --git a/fee_transport.py b/fee_transport.py new file mode 100644 index 0000000..1dc0ec7 --- /dev/null +++ b/fee_transport.py @@ -0,0 +1,151 @@ +""" +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:"], + ["p", ""], + ] + 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 diff --git a/migrations.py b/migrations.py index c8147f5..97e4a68 100644 --- a/migrations.py +++ b/migrations.py @@ -1,172 +1,737 @@ -# DCA Admin Extension Database Migrations -# Creates all necessary tables for Dollar Cost Averaging administration -# with Lamassu ATM integration +# Satoshi Machine v2 — single squashed migration. +# +# History note: m001-m004 were the legacy Lamassu schema; m005-m007 staged +# the v2 redesign (initial schema → payment_hash idempotency fix → notes +# column → concurrency claim + wallet UNIQUE index). Collapsed back into a +# single m001 during the v2-bitspire development branch since no production +# data was affected and the staged sequence had a SQLite CREATE-INDEX +# syntax bug. The pre-collapse history is preserved in git on commits +# prior to the collapse. +# +# Installs upgrading from the v1 Lamassu schema must uninstall + reinstall +# the extension to reset the LNbits dbversions tracker. The DROP TABLE +# IF EXISTS at the top of m001 also cleans the v1 tables if they happen +# to survive a partial wipe. -async def m001_initial_dca_schema(db): +async def m001_satmachine_v2_initial(db): + """Single-shot v2 schema for the Satoshi Machine admin extension. + + Drops every legacy Lamassu table (lamassu_config, lamassu_transactions, + plus the singular-config v1 dca_clients/deposits/payments) and creates + the v2 multi-tenant schema: + + - super_config: singleton platform-fee config (super only) + - dca_machines: per-operator multi-machine registry by npub + - dca_clients: LP registrations scoped per (machine, user) + - dca_deposits: fiat the operator records against an LP + - dca_settlements: bitSpire kind-21000 idempotency table + - dca_commission_splits: operator's remainder-distribution rules + - dca_payments: leg-typed distribution audit trail + - dca_telemetry: sparse kind-30078/30079 snapshots per machine + + CRITICAL design choices (preserved from the staged migrations): + * payment_hash is the UNIQUE idempotency key on dca_settlements + (LN payment_hash is globally unique and always present at the + Payment layer — fix for the original "use bitspire_event_id" + false start). + * platform_fee_sats + operator_fee_sats stored as absolute BIGINT + (not derived percentages). The contract is locked at landing time; + post-v1 customer-discount engine writes here without a migration. + * dca_machines.wallet_id UNIQUE — defence-in-depth against the + wallet-IDOR funds-theft vector (the API layer also checks + wallet ownership; the index is the second line of defence). + * processing_claim on dca_settlements — optimistic-lock token for + concurrent process_settlement invocations. + * notes on dca_settlements — append-only audit memo for partial- + dispense recompute + operator-authored notes (see + aiolabs/satmachineadmin#10 for the future structured audit table). """ - Create complete DCA admin schema from scratch. - """ - # DCA Clients table - await db.execute( - f""" - CREATE TABLE satoshimachine.dca_clients ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL, + # 1. Drop legacy v1 tables. IF EXISTS handles both fresh-install + # paths (no-op) and migration from a v1 schema (cleans up). + for table in ( + "lamassu_transactions", + "lamassu_config", + "dca_payments", + "dca_deposits", + "dca_clients", + ): + await db.execute(f"DROP TABLE IF EXISTS satoshimachine.{table}") + + # 2. super_config — singleton (id='default') with platform-fee config. + await db.execute(f""" + CREATE TABLE IF NOT EXISTS satoshimachine.super_config ( + id TEXT PRIMARY KEY, + super_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000, + super_fee_wallet_id TEXT, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """) + existing = await db.fetchone( + "SELECT id FROM satoshimachine.super_config WHERE id = 'default'" + ) + if not existing: + await db.execute( + "INSERT INTO satoshimachine.super_config (id, super_fee_fraction) " + "VALUES ('default', 0.0000)" + ) + + # 3. dca_machines — one row per bitSpire ATM, owned by exactly one + # operator. wallet_id UNIQUE prevents the IDOR funds-theft vector. + await db.execute(f""" + CREATE TABLE IF NOT EXISTS satoshimachine.dca_machines ( + id TEXT PRIMARY KEY, + operator_user_id TEXT NOT NULL, + machine_npub TEXT NOT NULL UNIQUE, wallet_id TEXT NOT NULL, + name TEXT, + location TEXT, + fiat_code TEXT NOT NULL DEFAULT 'GTQ', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """) + await db.execute( + "CREATE INDEX IF NOT EXISTS dca_machines_operator_idx " + "ON dca_machines (operator_user_id)" + ) + await db.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS dca_machines_wallet_id_uq " + "ON dca_machines (wallet_id)" + ) + + # 4. dca_clients — per-(machine, LP) registrations. Pure machine + # enrolment record: no wallet, no mode, no autoforward — those are + # LP-controlled at the user level via dca_lp (see below). Operator + # just decides "this LP is enrolled at my machine"; everything + # delivery-related is the LP's own preference. + await db.execute(f""" + CREATE TABLE IF NOT EXISTS satoshimachine.dca_clients ( + id TEXT PRIMARY KEY, + machine_id TEXT NOT NULL, + user_id TEXT NOT NULL, username TEXT, - dca_mode TEXT NOT NULL DEFAULT 'flow', - fixed_mode_daily_limit INTEGER, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); - """ + """) + await db.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS dca_clients_machine_user_uq " + "ON dca_clients (machine_id, user_id)" + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS dca_clients_user_idx ON dca_clients (user_id)" ) - # DCA Deposits table - await db.execute( - f""" - CREATE TABLE satoshimachine.dca_deposits ( - id TEXT PRIMARY KEY NOT NULL, + # 4a. dca_lp — LP-level (per-user) DCA preferences. ONE row per LNbits + # user that has onboarded as a Liquidity Provider, regardless of + # how many machines they're enrolled at. Owned by the LP (writes + # come from the satmachineclient extension under the LP's session), + # read by satmachineadmin during distribution to resolve "where do + # DCA payouts for this LP go?" + # + # Gating: satmachineadmin refuses to create deposits for an LP who + # doesn't have a dca_lp row yet. The LP must onboard via + # satmachineclient first (which auto-creates the row with their + # default LNbits wallet on first dashboard visit). Forces every + # LP through a "yes, I am here and this is where I want my sats" + # gesture before any fiat starts accumulating against them. + await db.execute(f""" + CREATE TABLE IF NOT EXISTS satoshimachine.dca_lp ( + user_id TEXT PRIMARY KEY, + dca_wallet_id TEXT NOT NULL, + default_dca_mode TEXT NOT NULL DEFAULT 'flow', + fixed_mode_daily_limit DECIMAL(10,2), + autoforward_ln_address TEXT, + autoforward_enabled BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """) + + # 5. dca_deposits — fiat the operator (or super) records against an LP + # at a machine. creator_user_id preserves audit trail. + await db.execute(f""" + CREATE TABLE IF NOT EXISTS satoshimachine.dca_deposits ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL, - amount INTEGER NOT NULL, + machine_id TEXT NOT NULL, + creator_user_id TEXT NOT NULL, + amount DECIMAL(10,2) NOT NULL, currency TEXT NOT NULL DEFAULT 'GTQ', status TEXT NOT NULL DEFAULT 'pending', notes TEXT, created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, confirmed_at TIMESTAMP ); - """ + """) + await db.execute( + "CREATE INDEX IF NOT EXISTS dca_deposits_client_idx " + "ON dca_deposits (client_id, created_at DESC)" ) - # DCA Payments table - await db.execute( - f""" - CREATE TABLE satoshimachine.dca_payments ( - id TEXT PRIMARY KEY NOT NULL, - client_id TEXT NOT NULL, - amount_sats INTEGER NOT NULL, - amount_fiat INTEGER NOT NULL, + # 6. dca_settlements — idempotency table for bitSpire-driven settlements. + # payment_hash UNIQUE handles subscription replays + dispatcher + # double-fires. processing_claim is the optimistic-lock token + # written by claim_settlement_for_processing. notes is the + # append-only audit memo for partial-dispense + operator notes. + # + # platform_fee_sats and operator_fee_sats are absolute BIGINT, + # NOT derived fractions — when the v2 customer-discount engine + # ships, these two columns are the audit-grade record of who + # forgave what per transaction. Do not collapse them into a single + # fee_fraction. See plan section "Customer discounts" and #10. + await db.execute(f""" + CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements ( + id TEXT PRIMARY KEY, + machine_id TEXT NOT NULL, + payment_hash TEXT NOT NULL UNIQUE, + bitspire_event_id TEXT, + bitspire_txid TEXT, + wire_sats BIGINT NOT NULL, + fiat_amount DECIMAL(10,2) NOT NULL, + fiat_code TEXT NOT NULL DEFAULT 'GTQ', exchange_rate REAL NOT NULL, - transaction_type TEXT NOT NULL, - lamassu_transaction_id TEXT, - payment_hash TEXT, + principal_sats BIGINT NOT NULL, + fee_sats BIGINT NOT NULL, + platform_fee_sats BIGINT NOT NULL, + operator_fee_sats BIGINT NOT NULL, + tx_type TEXT NOT NULL, + bills_json TEXT, + cassettes_json TEXT, status TEXT NOT NULL DEFAULT 'pending', + error_message TEXT, + processed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + notes TEXT, + processing_claim TEXT + ); + """) + await db.execute( + "CREATE INDEX IF NOT EXISTS dca_settlements_machine_idx " + "ON dca_settlements (machine_id, created_at DESC)" + ) + + # 7. dca_commission_splits — operator's rules for distributing the + # *remainder* (fee_sats - platform_fee_sats). One row per + # leg. machine_id=NULL = operator default; non-null = per-machine + # override. Sum(fraction) per (operator, machine) must equal 1.0 — + # enforced at write-time in crud.py. + # + # `target` accepts any of (splitpayments-style): + # - LNbits wallet id (UUID-shaped) + # - LNbits wallet invoice key (resolved via get_wallet_for_key) + # - Lightning address (user@domain) + # - LNURL string (bech32 LNURL...) + # Resolution lives in distribution._pay_one_split_leg. + await db.execute(f""" + CREATE TABLE IF NOT EXISTS satoshimachine.dca_commission_splits ( + id TEXT PRIMARY KEY, + machine_id TEXT, + operator_user_id TEXT NOT NULL, + target TEXT NOT NULL, + label TEXT, + fraction DECIMAL(10,4) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); - """ + """) + await db.execute( + "CREATE INDEX IF NOT EXISTS dca_commission_splits_lookup_idx " + "ON dca_commission_splits (operator_user_id, machine_id)" ) - # Lamassu Configuration table + # 8. dca_payments — every leg of every distribution. leg_type + # discriminator: dca | super_fee | operator_split | settlement | + # autoforward | refund. status enum: pending | completed | failed | + # voided | skipped | refunded. + await db.execute(f""" + CREATE TABLE IF NOT EXISTS satoshimachine.dca_payments ( + id TEXT PRIMARY KEY, + settlement_id TEXT, + client_id TEXT, + machine_id TEXT NOT NULL, + operator_user_id TEXT NOT NULL, + leg_type TEXT NOT NULL, + destination_wallet_id TEXT, + destination_ln_address TEXT, + amount_sats BIGINT NOT NULL, + amount_fiat DECIMAL(10,2), + exchange_rate REAL, + transaction_time TIMESTAMP NOT NULL, + external_payment_hash TEXT, + status TEXT NOT NULL DEFAULT 'pending', + error_message TEXT, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """) await db.execute( - f""" - CREATE TABLE satoshimachine.lamassu_config ( - id TEXT PRIMARY KEY NOT NULL, - host TEXT NOT NULL, - port INTEGER NOT NULL DEFAULT 5432, - database_name TEXT NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL, - source_wallet_id TEXT, - commission_wallet_id TEXT, - is_active BOOLEAN NOT NULL DEFAULT true, - test_connection_last TIMESTAMP, - test_connection_success BOOLEAN, - last_poll_time TIMESTAMP, - last_successful_poll TIMESTAMP, - use_ssh_tunnel BOOLEAN NOT NULL DEFAULT false, - ssh_host TEXT, - ssh_port INTEGER NOT NULL DEFAULT 22, - ssh_username TEXT, - ssh_password TEXT, - ssh_private_key TEXT, + "CREATE INDEX IF NOT EXISTS dca_payments_client_idx " + "ON dca_payments (client_id, created_at DESC)" + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS dca_payments_settlement_idx " + "ON dca_payments (settlement_id)" + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS dca_payments_operator_idx " + "ON dca_payments (operator_user_id, leg_type)" + ) + + # 9. dca_telemetry — latest replaceable kind-30078 (public availability + # beacon) and kind-30079 (operator-only fleet telemetry) snapshots + # per machine. The beacon today (lamassu-next/dev @ 2b712af) ships + # only cash_in/cash_out/cash_level/fiat/model — post-#43 fields + # (name, location, geo, fees, limits, denominations, version) are + # nullable until that upstream issue lands. Ingest opportunistically. + await db.execute(""" + CREATE TABLE IF NOT EXISTS satoshimachine.dca_telemetry ( + machine_id TEXT PRIMARY KEY, + beacon_cash_in BOOLEAN, + beacon_cash_out BOOLEAN, + beacon_cash_level TEXT, + beacon_fiat TEXT, + beacon_model TEXT, + beacon_name TEXT, + beacon_location TEXT, + beacon_geo TEXT, + beacon_fees_json TEXT, + beacon_limits_json TEXT, + beacon_denominations_json TEXT, + beacon_version TEXT, + beacon_received_at TIMESTAMP, + telemetry_json TEXT, + telemetry_received_at TIMESTAMP + ); + """) + + +async def m002_rename_commission_split_wallet_id_to_target(db): + """One-off correction for installs whose `dca_commission_splits` table + pre-exists from an earlier partial v2 migration run (where the column + was named `wallet_id`). The collapsed m001 uses `CREATE TABLE IF NOT + EXISTS`, which is a no-op when the table already exists — so the + schema drift survives the documented uninstall + reinstall workflow + because LNbits' uninstall wipes the dbversions tracker but NOT the + satoshimachine.sqlite3 file on disk. + + Idempotent: probes for the `wallet_id` column via a SELECT. If the + probe succeeds the column still exists and we RENAME it; otherwise + the rename is already done (or the table was fresh) and we no-op. + + Fresh installs from m001 onward already have `target` directly — for + them this migration is a no-op. + """ + try: + await db.fetchone( + "SELECT wallet_id FROM satoshimachine.dca_commission_splits LIMIT 1" + ) + except Exception: + # wallet_id column doesn't exist; either m001 produced the correct + # schema on a fresh install or the rename already landed. + return + 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}, updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); - """ - ) + """) - # Lamassu Transactions table (for audit trail) - await db.execute( - 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 + # Step 2: backfill dca_lp from dca_clients. Pick the latest row per + # user (by updated_at, falling back to created_at) when the LP is + # enrolled at multiple machines — that row reflects their most + # recent intent. ROW_NUMBER() OVER (...) requires SQLite 3.25+ (2018). + await db.execute(""" + INSERT OR IGNORE INTO satoshimachine.dca_lp + (user_id, dca_wallet_id, default_dca_mode, fixed_mode_daily_limit, + autoforward_ln_address, autoforward_enabled, + created_at, updated_at) + SELECT user_id, wallet_id, dca_mode, fixed_mode_daily_limit, + autoforward_ln_address, autoforward_enabled, + created_at, updated_at + FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY updated_at DESC, created_at DESC + ) AS rn + FROM satoshimachine.dca_clients + ) ranked + WHERE rn = 1 + """) + + # Step 3: drop the moved columns from dca_clients. ALTER TABLE DROP + # COLUMN needs SQLite 3.35+ (2021). One column per ALTER (SQLite + # doesn't support multi-column DROP). + for col in ( + "wallet_id", + "dca_mode", + "fixed_mode_daily_limit", + "autoforward_ln_address", + "autoforward_enabled", + ): + await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}") + + +async def m006_rename_to_canonical_sat_vocabulary(db): + """Adopt the cross-codebase canonical sat-amount vocabulary AND drop + the now-obsolete Lamassu-era fallback columns, per the decision at + memory `reference_sat_amount_vocabulary.md` (2026-05-26): + + Renames: + - dca_settlements.gross_sats → wire_sats + - dca_settlements.commission_sats → fee_sats + - super_config.super_fee_pct → super_fee_fraction + - dca_commission_splits.pct → fraction + + Drops (Lamassu-era reverse-derivation is obsolete since bitSpire + stamps both `principal_sats` AND `fee_sats` directly on + Payment.extra per lamassu-next#44 — there's nothing to back-derive): + - dca_machines.fallback_commission_pct (was the rate used by the + deleted `_parse_fallback` path) + - dca_settlements.used_fallback_split (was the per-row marker for + that path) + + Same canonical applies on the lamassu-next + atm-tui side; the + rename is coordinated via `~/dev/coordination/log.md` (2026-05-26). + + Each step is idempotent — probe for the OLD column; rename/drop only + if present; otherwise no-op (covers fresh installs where m001 + already laid down the canonical schema). + + Why a single migration: all driven by the same decision and any + external code wants to see the whole rename + cleanup land at once. + """ + renames = [ + ("dca_settlements", "gross_sats", "wire_sats"), + ("dca_settlements", "commission_sats", "fee_sats"), + ("super_config", "super_fee_pct", "super_fee_fraction"), + ("dca_commission_splits", "pct", "fraction"), + ] + for table, old_col, new_col in renames: + try: + await db.fetchone(f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1") + except Exception: + # old column doesn't exist; either rename already landed or + # m001 produced the canonical schema directly on fresh install. + continue + 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: 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 m002_add_transaction_time_to_dca_payments(db): - """ - Add transaction_time field to dca_payments table to store original ATM transaction time +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: {: {position, count}}} + to {positions: {

: {denomination, count}}} + - Position becomes the fixed row identity (hardware bay number); + denomination + count are operator-editable per row + - NO unique constraint on denomination (multiple same-denom cassettes + are operationally valid) + + Also adds `state_denomination` nullable column reserved for v2 + reverse-channel reconciliation (operator-believed denomination per + slot vs ATM-reported denomination — diff highlighting in v2 UI). + + SQLite doesn't support ALTER PRIMARY KEY directly; the migration + does the standard create-copy-drop-rename dance. Idempotent via the + column-probe trick used elsewhere in this file. """ + try: + # Probe: does the old PK shape still exist? If state_denomination + # column already exists, m008 already ran — no-op. + await db.fetchone( + "SELECT state_denomination FROM satoshimachine.cassette_configs " "LIMIT 1" + ) + return + except Exception: + pass + + await db.execute(f""" + CREATE TABLE IF NOT EXISTS satoshimachine.cassette_configs_new ( + machine_id TEXT NOT NULL, + position INTEGER NOT NULL, + denomination INTEGER NOT NULL, + count INTEGER NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_by TEXT, + state_denomination INTEGER, + state_count INTEGER, + state_at TIMESTAMP, + state_event_id TEXT, + PRIMARY KEY (machine_id, position) + ); + """) + + # Backfill from the old table — column-by-column copy. In the v1 + # m007 schema the row's `denomination` was simultaneously the + # operator-believed denomination AND the ATM-reported denomination + # (because the only write path was the bootstrap consumer copying + # from the ATM's state.db). So state_denomination at migration time + # = current denomination as a best-guess baseline; the next bootstrap + # event re-populates the state_* columns authoritatively. + await db.execute(""" + INSERT INTO satoshimachine.cassette_configs_new + (machine_id, position, denomination, count, + updated_at, updated_by, + state_denomination, state_count, state_at, state_event_id) + SELECT machine_id, position, denomination, count, + updated_at, updated_by, + denomination, state_count, state_at, state_event_id + FROM satoshimachine.cassette_configs + """) + + await db.execute("DROP TABLE satoshimachine.cassette_configs") await db.execute( - """ - ALTER TABLE satoshimachine.dca_payments - ADD COLUMN transaction_time TIMESTAMP - """ + "ALTER TABLE satoshimachine.cassette_configs_new " "RENAME TO cassette_configs" ) -async def m003_add_max_daily_limit_config(db): - """ - Add max_daily_limit_gtq field to lamassu_config table for admin-configurable client limits - """ - await db.execute( - """ - ALTER TABLE satoshimachine.lamassu_config - ADD COLUMN max_daily_limit_gtq INTEGER NOT NULL DEFAULT 2000 - """ - ) +async def m009_split_fee_fractions_by_direction(db): + """Split the singleton `super_fee_fraction` into per-direction fields + and add matching per-machine operator fee fractions. Adds the + `fee_mismatch_sats` audit column on settlements. + Architectural intent (per aiolabs/satmachineadmin#37): + - Super (lnbits administrator) sets X_in% and X_out% — applies + across every machine on the lnbits instance, calculated against + principal. + - Operator (per-machine) sets Y_in% and Y_out% — sits on top of + super, calculated against principal. + - Total fee charged customer = (X+Y)% of principal per direction. + - Distribution: super gets X% of principal; operator gets Y% + (distributed through commission legs as today). -async def m004_convert_to_gtq_storage(db): + 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). """ - Convert centavo storage to GTQ storage by changing data types and converting existing data. - Handles both SQLite (data conversion only) and PostgreSQL (data + schema changes). - """ - # Detect database type - db_type = str(type(db)).lower() - is_postgres = 'postgres' in db_type or 'asyncpg' in db_type - - if is_postgres: - # PostgreSQL: Need to change column types first, then convert data - - # Change column types to DECIMAL(10,2) - await db.execute("ALTER TABLE satoshimachine.dca_deposits ALTER COLUMN amount TYPE DECIMAL(10,2)") - await db.execute("ALTER TABLE satoshimachine.dca_payments ALTER COLUMN amount_fiat TYPE DECIMAL(10,2)") - await db.execute("ALTER TABLE satoshimachine.lamassu_transactions ALTER COLUMN fiat_amount TYPE DECIMAL(10,2)") - await db.execute("ALTER TABLE satoshimachine.dca_clients ALTER COLUMN fixed_mode_daily_limit TYPE DECIMAL(10,2)") - await db.execute("ALTER TABLE satoshimachine.lamassu_config ALTER COLUMN max_daily_limit_gtq TYPE DECIMAL(10,2)") - - # Convert data from centavos to GTQ - await db.execute("UPDATE satoshimachine.dca_deposits SET amount = amount / 100.0 WHERE currency = 'GTQ'") - await db.execute("UPDATE satoshimachine.dca_payments SET amount_fiat = amount_fiat / 100.0") - await db.execute("UPDATE satoshimachine.lamassu_transactions SET fiat_amount = fiat_amount / 100.0") - await db.execute("UPDATE satoshimachine.dca_clients SET fixed_mode_daily_limit = fixed_mode_daily_limit / 100.0 WHERE fixed_mode_daily_limit IS NOT NULL") - await db.execute("UPDATE satoshimachine.lamassu_config SET max_daily_limit_gtq = max_daily_limit_gtq / 100.0 WHERE max_daily_limit_gtq > 1000") - - else: - # SQLite: Data conversion only (dynamic typing handles the rest) - await db.execute("UPDATE satoshimachine.dca_deposits SET amount = CAST(amount AS REAL) / 100.0 WHERE currency = 'GTQ'") - await db.execute("UPDATE satoshimachine.dca_payments SET amount_fiat = CAST(amount_fiat AS REAL) / 100.0") - await db.execute("UPDATE satoshimachine.lamassu_transactions SET fiat_amount = CAST(fiat_amount AS REAL) / 100.0") - await db.execute("UPDATE satoshimachine.dca_clients SET fixed_mode_daily_limit = CAST(fixed_mode_daily_limit AS REAL) / 100.0 WHERE fixed_mode_daily_limit IS NOT NULL") - await db.execute("UPDATE satoshimachine.lamassu_config SET max_daily_limit_gtq = CAST(max_daily_limit_gtq AS REAL) / 100.0 WHERE max_daily_limit_gtq > 1000") \ No newline at end of file + additions = [ + ("super_config", "super_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("super_config", "super_cash_out_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("dca_machines", "operator_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("dca_machines", "operator_cash_out_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("dca_settlements", "fee_mismatch_sats", "BIGINT"), + ] + for table, col, coltype in additions: + try: + await db.fetchone(f"SELECT {col} FROM satoshimachine.{table} LIMIT 1") + # column already present — migration partially-ran previously, skip + continue + except Exception: + pass + await db.execute( + f"ALTER TABLE satoshimachine.{table} ADD COLUMN {col} {coltype}" + ) + + # Backfill + drop the legacy singleton, gated on the column still + # existing. Once dropped, a re-run of this migration skips both + # steps cleanly. + try: + await db.fetchone( + "SELECT super_fee_fraction FROM satoshimachine.super_config LIMIT 1" + ) + legacy_present = True + except Exception: + legacy_present = False + + if legacy_present: + # Carry the live deployment's super_fee_fraction setting forward + # into both directional fields, but only when the operator hasn't + # already explicitly set per-direction values (i.e., both are + # still at DEFAULT 0). + await db.execute( + """ + UPDATE satoshimachine.super_config + SET super_cash_in_fee_fraction = super_fee_fraction, + super_cash_out_fee_fraction = super_fee_fraction + WHERE super_cash_in_fee_fraction = 0 + AND super_cash_out_fee_fraction = 0 + AND super_fee_fraction > 0 + """ + ) + await db.execute( + "ALTER TABLE satoshimachine.super_config DROP COLUMN super_fee_fraction" + ) diff --git a/models.py b/models.py index 2fb60a6..f7f84e4 100644 --- a/models.py +++ b/models.py @@ -1,49 +1,191 @@ -# Description: Pydantic data models dictate what is passed between frontend and backend. +# Satoshi Machine v2 — Pydantic data models. +# +# The v2 schema replaces the Lamassu-era single-config, super-only data model +# with a per-operator multi-machine layout that ingests bitSpire settlements +# over Nostr kind-21000. See migrations.py::m005_satmachine_v2_overhaul and +# the plan at ~/.claude/plans/snug-gliding-shamir.md. from datetime import datetime -from typing import Optional from pydantic import BaseModel, validator +# ============================================================================= +# Machines — one row per bitSpire ATM, owned by exactly one operator. +# ============================================================================= -# DCA Client Models -class CreateDcaClientData(BaseModel): - user_id: str + +class CreateMachineData(BaseModel): + """Operator adds a machine to their fleet by Nostr npub. + + `wallet_id` is the LNbits wallet that will receive bitSpire settlements + for this machine. The same operator can own multiple machines; each + machine gets its own wallet so per-machine accounting via Payment.tag + (set to "satmachine:{machine_npub}") works natively. + + `operator_cash_*_fee_fraction` is the per-machine operator fee charged on + top of the platform-wide super fee. Both fractions sit on top of the + super's per-direction fractions and are calculated against principal, + not against any fee total. See aiolabs/satmachineadmin#37 / #38. + """ + + machine_npub: str wallet_id: str - username: str - dca_mode: str = "flow" # 'flow' or 'fixed' - fixed_mode_daily_limit: Optional[float] = None + name: str | None = None + location: str | None = None + fiat_code: str = "GTQ" + operator_cash_in_fee_fraction: float = 0.0 + operator_cash_out_fee_fraction: float = 0.0 + + @validator("operator_cash_in_fee_fraction", "operator_cash_out_fee_fraction") + def _operator_fee_in_unit_range(cls, v): + if v is None: + return 0.0 + if v < 0 or v > 1: + raise ValueError("operator fee fraction must be between 0 and 1") + return round(float(v), 4) -class DcaClient(BaseModel): +class Machine(BaseModel): id: str - user_id: str + operator_user_id: str + machine_npub: str wallet_id: str - username: Optional[str] - dca_mode: str - fixed_mode_daily_limit: Optional[int] - status: str + name: str | None + location: str | None + fiat_code: str + is_active: bool + operator_cash_in_fee_fraction: float = 0.0 + operator_cash_out_fee_fraction: float = 0.0 created_at: datetime updated_at: datetime +class UpdateMachineData(BaseModel): + name: str | None = None + location: str | None = None + fiat_code: str | None = None + is_active: bool | None = None + wallet_id: str | None = None + operator_cash_in_fee_fraction: float | None = None + operator_cash_out_fee_fraction: float | None = None + + @validator("operator_cash_in_fee_fraction", "operator_cash_out_fee_fraction") + def _operator_fee_in_unit_range(cls, v): + if v is None: + return v + if v < 0 or v > 1: + raise ValueError("operator fee fraction must be between 0 and 1") + return round(float(v), 4) + + +# ============================================================================= +# DCA Clients — LP registrations, scoped per (machine, user). +# ============================================================================= + + +class CreateDcaClientData(BaseModel): + """Operator enrols an LP at one of their machines. + + Pure (machine, LP) tuple — no wallet, no mode, no autoforward. Those + live on the per-user `dca_lp` row, written by the LP themselves via + satmachineclient. An LP must have onboarded (have a `dca_lp` row) + before deposits can be recorded against this enrolment; enrolment + itself works either way. + """ + + machine_id: str + user_id: str + username: str | None = None + + +class DcaClient(BaseModel): + id: str + machine_id: str + user_id: str + username: str | None + status: str + created_at: datetime + updated_at: datetime + # Computed at SELECT time via LEFT JOIN on dca_lp. Lets the operator + # UI render "pending onboarding" badges and disable deposit creation + # without a second round-trip per row. + lp_onboarded: bool = False + + class UpdateDcaClientData(BaseModel): - username: Optional[str] = None - dca_mode: Optional[str] = None - fixed_mode_daily_limit: Optional[float] = None - status: Optional[str] = None + """Operator-side updates to an enrolment. The operator can only edit + fields that aren't LP-controlled (username display, status). Wallet + / mode / autoforward changes go through satmachineclient against + `dca_lp` instead.""" + + username: str | None = None + status: str | None = None -# Deposit Models (Now storing GTQ directly) -class CreateDepositData(BaseModel): +class DcaLpPreferences(BaseModel): + """Per-user DCA preferences, owned by the LP. + + Created on first satmachineclient dashboard access (the extension + auto-seeds `dca_wallet_id` with the LP's first/default LNbits wallet + — they can change it from the dashboard). All distribution decisions + (where do the sats go, do we forward to an LN address, what's the + default mode) read from here, joined onto `dca_clients` by user_id. + """ + + user_id: str + dca_wallet_id: str + default_dca_mode: str # 'flow' | 'fixed' + fixed_mode_daily_limit: float | None + autoforward_ln_address: str | None + autoforward_enabled: bool + created_at: datetime + updated_at: datetime + + +class UpsertDcaLpData(BaseModel): + """satmachineclient writes this on first onboarding / when the LP + edits their preferences. All fields optional on update — pass only + the ones being changed.""" + + dca_wallet_id: str | None = None + default_dca_mode: str | None = None + fixed_mode_daily_limit: float | None = None + autoforward_ln_address: str | None = None + autoforward_enabled: bool | None = None + + +class ClientBalanceSummary(BaseModel): client_id: str - amount: float # Amount in GTQ (e.g., 150.75) - currency: str = "GTQ" - notes: Optional[str] = None - - @validator('amount') - def round_amount_to_cents(cls, v): - """Ensure amount is rounded to 2 decimal places for DECIMAL(10,2) storage""" + machine_id: str + total_deposits: float # confirmed deposits in fiat + total_payments: float # DCA fiat-equivalent distributed + remaining_balance: float # deposits - payments + currency: str + + +# ============================================================================= +# Deposits — fiat the operator (or super) records against an LP at a machine. +# ============================================================================= + + +class CreateDepositData(BaseModel): + """Operator records a fiat deposit against an LP enrolment. + + `currency` is server-set from the target machine's `fiat_code` at + write time — the API ignores any value the client submits. Each + machine currently handles exactly one currency (`dca_machines. + fiat_code`); allowing the operator to pick a different one at + deposit time would either be a typo or a future multi-currency + feature that doesn't exist yet (`aiolabs/satmachineadmin#26`). + """ + + client_id: str + machine_id: str + amount: float + notes: str | None = None + + @validator("amount") + def round_amount(cls, v): if v is not None: return round(float(v), 2) return v @@ -52,192 +194,634 @@ class CreateDepositData(BaseModel): class DcaDeposit(BaseModel): id: str client_id: str - amount: float # Amount in GTQ (e.g., 150.75) + machine_id: str + creator_user_id: str + amount: float currency: str - status: str # 'pending' or 'confirmed' - notes: Optional[str] + status: str # 'pending' | 'confirmed' | 'rejected' + notes: str | None created_at: datetime - confirmed_at: Optional[datetime] + confirmed_at: datetime | None class UpdateDepositData(BaseModel): - amount: Optional[float] = None - currency: Optional[str] = None - notes: Optional[str] = None + """Operator edits on a pending deposit. `currency` removed — see + `CreateDepositData`; the currency is bound to the machine and not + editable after the row lands.""" - @validator('amount') - def round_amount_to_cents(cls, v): + amount: float | None = None + notes: str | None = None + + @validator("amount") + def round_amount(cls, v): if v is not None: return round(float(v), 2) return v class UpdateDepositStatusData(BaseModel): - status: str - notes: Optional[str] = None + status: str # 'pending' | 'confirmed' | 'rejected' + notes: str | None = None -# Payment Models -class CreateDcaPaymentData(BaseModel): - client_id: str - amount_sats: int - amount_fiat: float # Amount in GTQ (e.g., 150.75) +# ============================================================================= +# Settlements — one per bitSpire kind-21000 event. +# ============================================================================= +# platform_fee_sats and operator_fee_sats are absolute audit-grade values. +# Today they equal the contractual split; tomorrow (post-v1 promo engine) +# they record who-forgave-what. DO NOT collapse them into a single fraction. +# See plan section "Customer discounts & promotions (post-v1)". + + +class CreateDcaSettlementData(BaseModel): + machine_id: str + payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table) + bitspire_event_id: str | None = None # reserved for direct-Nostr ingestion + bitspire_txid: str | None = None + wire_sats: int + fiat_amount: float + fiat_code: str = "GTQ" exchange_rate: float - transaction_type: str # 'flow', 'fixed', 'manual', 'commission' - lamassu_transaction_id: Optional[str] = None - payment_hash: Optional[str] = None - transaction_time: Optional[datetime] = None # Original ATM transaction time + principal_sats: int + fee_sats: int + platform_fee_sats: int + operator_fee_sats: int + tx_type: str # 'cash_out' | 'cash_in' + # Phase-1 observability column (aiolabs/satmachineadmin#38). + # `bitspire_fee_sats - (platform_fee_sats + operator_fee_sats)` — + # positive means bitspire over-reported, negative means under-reported. + # Recorded unconditionally; WARN-logged when |delta| > tolerance. NULL + # only on pre-#38 rows. + fee_mismatch_sats: int | None = None + bills_json: str | None = None + cassettes_json: str | None = None + + +class DcaSettlement(BaseModel): + id: str + machine_id: str + payment_hash: str + bitspire_event_id: str | None + bitspire_txid: str | None + wire_sats: int + fiat_amount: float + fiat_code: str + exchange_rate: float + principal_sats: int + fee_sats: int + platform_fee_sats: int + operator_fee_sats: int + tx_type: str + fee_mismatch_sats: int | None = None + bills_json: str | None + cassettes_json: str | None + # 'pending' (default at insert) + # 'processing' (claim taken by distribution processor) + # 'processed' (all legs paid) + # 'partial' (operator marked partial-dispense after the fact) + # 'refunded' (operator-initiated refund) + # 'errored' (operational distribution failure — retry path applies) + # 'rejected' (Nostr attribution cross-check failed at land time; + # never went near distribution. error_message holds the + # reason. Retry is wrong — investigate the machine.) + status: str + error_message: str | None + processed_at: datetime | None + created_at: datetime + # Append-only audit memo. Populated when an operator triggers an in-place + # adjustment (partial-dispense, manual reconciliation override). Each + # entry timestamped + records original values so the overwrite is + # auditable from the settlement detail view alone. Never edited in place. + notes: str | None = None + # Optimistic-lock claim token written when status flips to 'processing'. + # Two concurrent process_settlement invocations can't both win the claim + # (only one matching read-back). Cleared back to NULL when the leg- + # writing pass completes (status='processed' or 'errored'). + processing_claim: str | None = None + + +# ============================================================================= +# Commission splits — operator-defined remainder allocation per machine. +# ============================================================================= +# machine_id=NULL means operator's default; non-null means per-machine override. +# Sum of fraction across rows for a (operator_user_id, machine_id) scope must +# be 1.0, enforced at write-time in crud.py. + + +class CommissionSplitLeg(BaseModel): + """Single leg of an operator's commission-split rule set. + + `target` accepts any of (splitpayments pattern): + - LNbits wallet id + - LNbits wallet invoice key (resolved server-side via get_wallet_for_key) + - Lightning address (user@domain) + - LNURL string (bech32 LNURL...) + """ + + target: str + label: str | None = None + fraction: float + sort_order: int = 0 + + @validator("target") + def non_empty_target(cls, v): + v = (v or "").strip() + if not v: + raise ValueError("target cannot be empty") + return v + + @validator("fraction") + def fraction_in_unit_range(cls, v): + if v < 0 or v > 1: + raise ValueError("fraction must be between 0 and 1") + return round(float(v), 4) + + +class CommissionSplit(BaseModel): + id: str + machine_id: str | None # None = operator's default ruleset + operator_user_id: str + target: str + label: str | None + fraction: float + sort_order: int + created_at: datetime + + +class SetCommissionSplitsData(BaseModel): + """Replaces the entire ruleset for a given scope. + + `machine_id=None` writes the operator's default ruleset (applies to any + machine without an explicit override). Otherwise scoped per machine. + """ + + machine_id: str | None = None + legs: list[CommissionSplitLeg] + + @validator("legs") + def legs_sum_to_one(cls, v): + total = round(sum(leg.fraction for leg in v), 4) + if abs(total - 1.0) > 0.0001: + raise ValueError(f"split fractions must sum to 1.0 (got {total})") + return v + + +# ============================================================================= +# Payments — every distribution leg (DCA / super_fee / split / settle / etc.) +# ============================================================================= + + +class CreateDcaPaymentData(BaseModel): + settlement_id: str | None = None + client_id: str | None = None + machine_id: str + operator_user_id: str + leg_type: str + # 'dca' | 'super_fee' | 'operator_split' | 'settlement' | 'autoforward' | 'refund' + destination_wallet_id: str | None = None + destination_ln_address: str | None = None + amount_sats: int + amount_fiat: float | None = None + exchange_rate: float | None = None + transaction_time: datetime + external_payment_hash: str | None = None class DcaPayment(BaseModel): id: str - client_id: str + settlement_id: str | None + client_id: str | None + machine_id: str + operator_user_id: str + leg_type: str + destination_wallet_id: str | None + destination_ln_address: str | None amount_sats: int - amount_fiat: float # Amount in GTQ (e.g., 150.75) - exchange_rate: float - transaction_type: str - lamassu_transaction_id: Optional[str] - payment_hash: Optional[str] - status: str # 'pending', 'confirmed', 'failed' - created_at: datetime - transaction_time: Optional[datetime] = None # Original ATM transaction time - - -# Client Balance Summary (Now storing GTQ directly) -class ClientBalanceSummary(BaseModel): - client_id: str - total_deposits: float # Total confirmed deposits in GTQ - total_payments: float # Total payments made in GTQ - remaining_balance: float # Available balance for DCA in GTQ - currency: str - - -# Transaction Processing Models -class LamassuTransaction(BaseModel): - transaction_id: str - amount_fiat: float # Amount in GTQ (e.g., 150.75) - amount_crypto: int - exchange_rate: float - transaction_type: str # 'cash_in' or 'cash_out' + amount_fiat: float | None + exchange_rate: float | None + transaction_time: datetime + external_payment_hash: str | None status: str - timestamp: datetime + # Leg status enum: + # 'pending' — row written, payment not yet attempted + # 'completed' — pay_invoice succeeded; sats moved + # 'failed' — pay_invoice errored; sats stayed at source + # 'voided' — superseded (e.g. partial-dispense recompute voided + # the previous pending/failed leg) + # 'skipped' — intentionally not paid (no super wallet configured, + # no commission ruleset, no exchange rate, no LPs) + # 'refunded' — reserved for future refund flows + error_message: str | None + created_at: datetime -# Lamassu Transaction Storage Models -class CreateLamassuTransactionData(BaseModel): - lamassu_transaction_id: str - fiat_amount: float # Amount in GTQ (e.g., 150.75) - crypto_amount: int - commission_percentage: float - discount: float = 0.0 - effective_commission: float - commission_amount_sats: int - base_amount_sats: int - exchange_rate: float - crypto_code: str = "BTC" - fiat_code: str = "GTQ" - device_id: Optional[str] = None - transaction_time: datetime +# ============================================================================= +# Telemetry — sparse beacon (kind-30078) + fleet snapshot (kind-30079) state. +# ============================================================================= -class StoredLamassuTransaction(BaseModel): +class TelemetrySnapshot(BaseModel): + machine_id: str + # Beacon (kind-30078) — all fields are nullable because the upstream payload + # is sparse today. As lamassu-next#43 lands, the post-#43 columns fill in. + beacon_cash_in: bool | None = None + beacon_cash_out: bool | None = None + beacon_cash_level: str | None = None + beacon_fiat: str | None = None + beacon_model: str | None = None + beacon_name: str | None = None + beacon_location: str | None = None + beacon_geo: str | None = None + beacon_fees_json: str | None = None + beacon_limits_json: str | None = None + beacon_denominations_json: str | None = None + beacon_version: str | None = None + beacon_received_at: datetime | None = None + # Fleet telemetry (kind-30079) — operator-only, awaits lamassu-next#42. + telemetry_json: str | None = None + telemetry_received_at: datetime | None = None + + +# ============================================================================= +# Super config — singleton row with the platform fee. +# ============================================================================= + + +class SuperConfig(BaseModel): id: str - lamassu_transaction_id: str - fiat_amount: float # Amount in GTQ (e.g., 150.75) - crypto_amount: int - commission_percentage: float - discount: float - effective_commission: float - commission_amount_sats: int - base_amount_sats: int - exchange_rate: float - crypto_code: str - fiat_code: str - device_id: Optional[str] - transaction_time: datetime - processed_at: datetime - clients_count: int # Number of clients who received distributions - distributions_total_sats: int # Total sats distributed to clients + super_cash_in_fee_fraction: float = 0.0 + super_cash_out_fee_fraction: float = 0.0 + super_fee_wallet_id: str | None + updated_at: datetime -# Lamassu Configuration Models -class CreateLamassuConfigData(BaseModel): - host: str - port: int = 5432 - database_name: str - username: str - password: str - # Source wallet for DCA distributions - source_wallet_id: Optional[str] = None - # Commission wallet for storing commission earnings - commission_wallet_id: Optional[str] = None - # SSH Tunnel settings - use_ssh_tunnel: bool = False - ssh_host: Optional[str] = None - ssh_port: int = 22 - ssh_username: Optional[str] = None - ssh_password: Optional[str] = None - ssh_private_key: Optional[str] = None # Path to private key file or key content - # DCA Client Limits - max_daily_limit_gtq: float = 2000.0 # Maximum daily limit for Fixed mode clients - - @validator('max_daily_limit_gtq') - def round_max_daily_limit(cls, v): - """Ensure max daily limit is rounded to 2 decimal places""" - if v is not None: - return round(float(v), 2) +class UpdateSuperConfigData(BaseModel): + super_cash_in_fee_fraction: float | None = None + super_cash_out_fee_fraction: float | None = None + super_fee_wallet_id: str | None = None + + @validator( + "super_cash_in_fee_fraction", + "super_cash_out_fee_fraction", + ) + def _fee_in_unit_range(cls, v): + if v is None: + return v + if v < 0 or v > 1: + raise ValueError("super fee fraction must be between 0 and 1") + return round(float(v), 4) + + +# ============================================================================= +# Operator UX action carriers — partial-tx and balance-settlement features. +# ============================================================================= + + +class PartialDispenseData(BaseModel): + """Resolves satmachineadmin#3 — operator confirms actual bills dispensed + when bitSpire reports an error mid-dispense. + + Either `dispensed_fraction` (0..1) for ratio-based recompute, or + `dispensed_sats` for explicit recompute. Exactly one must be set. + """ + + settlement_id: str + dispensed_fraction: float | None = None + dispensed_sats: int | None = None + notes: str | None = None + + @validator("dispensed_fraction") + def fraction_in_unit_range(cls, v): + if v is None: + return v + if v < 0 or v > 1: + raise ValueError("dispensed_fraction must be between 0 and 1") return v -class LamassuConfig(BaseModel): - id: str - host: str - port: int - database_name: str - username: str - password: str - is_active: bool - test_connection_last: Optional[datetime] - test_connection_success: Optional[bool] - created_at: datetime +class StuckSettlementsResponse(BaseModel): + """Operator worklist surfacing settlements that didn't process cleanly. + + Four categories, segregated so the UI can render them with the + right affordances (investigate / retry / force-error): + + - rejected: Nostr attribution cross-check failed at land time — + the kind-21000 invoice signer didn't match the machine identity. + Distribution never ran. Retry is *wrong* for these: the row was + misrouted, not operationally failed. Operator investigates the + machine. + - errored: distribution ran and one or more legs reported a payment + error. Operator retry endpoint handles these directly. + - stuck_pending: landed but never picked up by the processor + (listener crashed before invoking process_settlement, or the + claim was lost). Older than `threshold_minutes`. + - stuck_processing: claim was taken but no completion in + `threshold_minutes`. Processor likely crashed mid-flight. + Operator can force-recover via POST .../force-reset. + """ + + threshold_minutes: int + rejected: list # list[DcaSettlement] + errored: list + stuck_pending: list + stuck_processing: list + + +class AppendSettlementNoteData(BaseModel): + """Operator-authored free-form note on a settlement. + + Notes are prepended (newest first) to the settlement's `notes` column, + with a UTC timestamp and the author's user id so each entry is + accountable. Useful for cash-drawer reconciliation context, off-the- + record refund records, or any narrative an operator wants to attach + for future reference. + """ + + note: str + + @validator("note") + def non_empty(cls, v): + v = v.strip() if isinstance(v, str) else v + if not v: + raise ValueError("note cannot be empty") + if len(v) > 2000: + raise ValueError("note too long (max 2000 chars)") + return v + + +class SettleBalanceData(BaseModel): + """Resolves satmachineadmin#4 — operator settles small remaining LP balance + from their own wallet at a specified exchange rate. + + Use case: an LP has a small remaining fiat balance (e.g. 47 GTQ) that + keeps shrinking proportionally on each new transaction (Zeno's paradox). + Operator hits 'Settle', specifies the exchange rate they're willing to + honor, and the system pays out the remaining balance in sats from the + operator's wallet. The LP's balance goes to zero; settlement legs count + against the LP's balance summary alongside DCA legs. + """ + + funding_wallet_id: str + # The exchange rate the operator is settling at (sats per 1 fiat unit). + # Operator picks the rate so they can use exchange spot, a market + # midpoint, or a favorable rate as a gesture. Required and explicit so + # there's no ambiguity about what rate was used. + exchange_rate: float + # If None, settle the LP's full remaining balance. Else partial. + amount_fiat: float | None = None + notes: str | None = None + + @validator("exchange_rate") + def positive_rate(cls, v): + if v is None or v <= 0: + raise ValueError("exchange_rate must be > 0 (sats per fiat unit)") + return float(v) + + @validator("amount_fiat") + def round_amount(cls, v): + if v is None: + return v + if v <= 0: + raise ValueError("amount_fiat must be > 0 if specified") + return round(float(v), 2) + + +# ============================================================================= +# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1). +# ============================================================================= +# Schema is position-keyed per the coordinated v1.1 redesign at coord-log +# 2026-05-30T18:30Z + 18:45Z. The earlier denomination-keyed shape (m007) +# was wrong: real machines have N cassettes of the same denomination for +# cash-out throughput, and operators need to swap cartridge denominations +# during refill ($20 bay becomes a $50 bay) without re-provisioning. +# +# Wire shape: +# {"positions": {"": {"denomination": N, "count": M}}} +# +# Editable surface per row: +# - denomination: yes (operator swaps cartridges during refill) +# - count: yes (refill / decrement) +# Read-only per row: +# - position: hardware bay number; the slot count is fixed by the +# dispenser model (e.g., Tejo has 4 positions). +# +# No "denomination must be unique within payload" constraint: multiple +# same-denom cassettes are operationally valid. The ATM HAL distributes +# a dispense request greedy across all positions matching the requested +# denomination (lamassu-next#56 v1.1 HAL refactor). +# +# state_* columns are reserved nullable for the v2 reverse-channel +# reconciliation consumer (bitspire-cassettes-state:). +# v1 populates them on bootstrap-event receipt but the UI doesn't render +# reconciliation. state_denomination (added in m008) lets v2 highlight +# operator-believed-vs-ATM-reported denomination drift per slot. + + +class CassetteConfig(BaseModel): + machine_id: str + position: int + denomination: int + count: int updated_at: datetime - # Source wallet for DCA distributions - source_wallet_id: Optional[str] = None - # Commission wallet for storing commission earnings - commission_wallet_id: Optional[str] = None - # SSH Tunnel settings - use_ssh_tunnel: bool = False - ssh_host: Optional[str] = None - ssh_port: int = 22 - ssh_username: Optional[str] = None - ssh_password: Optional[str] = None - ssh_private_key: Optional[str] = None - # Poll tracking - last_poll_time: Optional[datetime] = None - last_successful_poll: Optional[datetime] = None - # DCA Client Limits - max_daily_limit_gtq: float = 2000.0 # Maximum daily limit for Fixed mode clients + updated_by: str | None + state_denomination: int | None + state_count: int | None + state_at: datetime | None + state_event_id: str | None -class UpdateLamassuConfigData(BaseModel): - host: Optional[str] = None - port: Optional[int] = None - database_name: Optional[str] = None - username: Optional[str] = None - password: Optional[str] = None - is_active: Optional[bool] = None - # Source wallet for DCA distributions - source_wallet_id: Optional[str] = None - # Commission wallet for storing commission earnings - commission_wallet_id: Optional[str] = None - # SSH Tunnel settings - use_ssh_tunnel: Optional[bool] = None - ssh_host: Optional[str] = None - ssh_port: Optional[int] = None - ssh_username: Optional[str] = None - ssh_password: Optional[str] = None - ssh_private_key: Optional[str] = None - # DCA Client Limits - max_daily_limit_gtq: Optional[int] = None +class UpsertCassetteConfigData(BaseModel): + """Operator edits a single cassette row's denomination or count from + the dashboard. Both fields optional; pass only those changed. + Position is not edited — it's the row's identity (hardware bay).""" + + denomination: int | None = None + count: int | None = None + + @validator("denomination") + def denomination_positive(cls, v): + if v is None: + return v + if v <= 0: + raise ValueError("denomination must be > 0") + return v + + @validator("count") + def count_non_negative(cls, v): + if v is None: + return v + if v < 0: + raise ValueError("count must be >= 0") + return v +class CassettePayloadRow(BaseModel): + """One position's payload values in the wire-format + `{"positions": {"": {"denomination", "count"}}}`.""" + + denomination: int + count: int + + @validator("denomination") + def denomination_positive(cls, v): + if v <= 0: + raise ValueError("denomination must be > 0") + return v + + @validator("count") + def count_non_negative(cls, v): + if v < 0: + raise ValueError("count must be >= 0") + return v + + +class PublishCassettesPayload(BaseModel): + """The decrypted JSON content of a kind-30078 cassette event, both + directions: + - operator → ATM (d-tag `bitspire-cassettes:`) + - ATM → operator (d-tag `bitspire-cassettes-state:`) + + Wire shape: `{"positions": {"": {"denomination", "count"}}}`. + JSON object keys are always strings; the validator coerces back to + int on parse. The position key set MUST match what the receiver + already has (slot count is hardware-fixed; no add/remove from this + payload). + + No denomination-unique constraint: multiple same-denom cassettes are + operationally valid (cash-out throughput on a popular denom). + """ + + positions: dict[int, CassettePayloadRow] + + @validator("positions", pre=True) + def coerce_string_keys_to_int(cls, v): + if not isinstance(v, dict): + raise ValueError("positions must be a dict") + out = {} + for k, val in v.items(): + try: + key_int = int(k) + except (TypeError, ValueError) as exc: + raise ValueError(f"position key {k!r} is not an int") from exc + if key_int <= 0: + raise ValueError(f"position must be > 0 (got {key_int})") + out[key_int] = val + return out + + def to_wire_dict(self) -> dict: + """Serialise back to the wire format with string keys for JSON + object compatibility. Used by the publisher to build the kind-30078 + event content before NIP-44 v2 encryption.""" + return { + "positions": { + str(pos): { + "denomination": row.denomination, + "count": row.count, + } + for pos, row in self.positions.items() + } + } + + +# ============================================================================= +# Fee-config Nostr payload — operator → ATM (aiolabs/satmachineadmin#39) +# ============================================================================= +# Locked wire format per coord-log §2026-06-01T14:25Z: +# { +# "schema_version": 1, +# "cash_in_fee_fraction": super_cash_in + operator_cash_in, +# "cash_out_fee_fraction": super_cash_out + operator_cash_out, +# "components": { +# "super_cash_in": float, +# "super_cash_out": float, +# "operator_cash_in": float, +# "operator_cash_out": float +# } +# } +# +# Producer invariants (refuse-to-publish if violated): +# - cash_*_fee_fraction ≤ 0.15 (cap, defense in depth — bitspire +# consumer enforces the same) +# - |cash_in_fee_fraction - (super_cash_in + operator_cash_in)| < 1e-6 +# - |cash_out_fee_fraction - (super_cash_out + operator_cash_out)| < 1e-6 +# - All six fractions in [0.0, 0.15] +# - schema_version is integer ≥ 1 +# v1 consumers ignore unknown top-level keys per the locked spec. + + +class FeePayloadComponents(BaseModel): + """The producer-mandatory `components` sub-object that splits the + summed `cash_*_fee_fraction` totals back into their super + operator + halves. Audit + future-promo substrate; consumer-optional in v1.""" + + super_cash_in: float + super_cash_out: float + operator_cash_in: float + operator_cash_out: float + + +class FeeConfigPayload(BaseModel): + """The decrypted JSON content of a kind-30078 fee-config event + (operator → ATM, d-tag `bitspire-fees:`). + + Built from a Machine row + the SuperConfig singleton via + `fee_transport.build_fee_payload`. Validates the cap + + sum-vs-components consistency at construction time so any caller + that holds a FeeConfigPayload instance has a wire-shippable payload. + """ + + schema_version: int = 1 + cash_in_fee_fraction: float + cash_out_fee_fraction: float + components: FeePayloadComponents + + @validator("schema_version") + def _schema_version_at_least_v1(cls, v): + if v < 1: + raise ValueError(f"schema_version must be >= 1, got {v}") + return v + + @validator("cash_in_fee_fraction", "cash_out_fee_fraction") + def _total_in_unit_range(cls, v): + # Imported here rather than at module top to avoid a circular + # import (calculations imports nothing from models, but keep the + # dependency direction explicit at the call site). + from .calculations import MAX_FEE_FRACTION_PER_DIRECTION + + if v < 0 or v > MAX_FEE_FRACTION_PER_DIRECTION: + raise ValueError( + f"fee fraction must be in [0, {MAX_FEE_FRACTION_PER_DIRECTION}], " + f"got {v}" + ) + return round(float(v), 4) + + @validator("components", always=True) + def _components_sum_matches_totals(cls, v, values): + sum_in = round(v.super_cash_in + v.operator_cash_in, 4) + sum_out = round(v.super_cash_out + v.operator_cash_out, 4) + total_in = values.get("cash_in_fee_fraction") + total_out = values.get("cash_out_fee_fraction") + if total_in is not None and abs(total_in - sum_in) > 1e-6: + raise ValueError( + f"cash_in_fee_fraction={total_in} doesn't match components " + f"sum super({v.super_cash_in}) + operator({v.operator_cash_in}) = {sum_in}" + ) + if total_out is not None and abs(total_out - sum_out) > 1e-6: + raise ValueError( + f"cash_out_fee_fraction={total_out} doesn't match components " + f"sum super({v.super_cash_out}) + operator({v.operator_cash_out}) = {sum_out}" + ) + return v + + def to_wire_dict(self) -> dict: + return { + "schema_version": self.schema_version, + "cash_in_fee_fraction": self.cash_in_fee_fraction, + "cash_out_fee_fraction": self.cash_out_fee_fraction, + "components": { + "super_cash_in": self.components.super_cash_in, + "super_cash_out": self.components.super_cash_out, + "operator_cash_in": self.components.operator_cash_in, + "operator_cash_out": self.components.operator_cash_out, + }, + } diff --git a/nip44.py b/nip44.py new file mode 100644 index 0000000..109860d --- /dev/null +++ b/nip44.py @@ -0,0 +1,294 @@ +""" +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) diff --git a/nostr_publish.py b/nostr_publish.py new file mode 100644 index 0000000..b36b39c --- /dev/null +++ b/nostr_publish.py @@ -0,0 +1,295 @@ +""" +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 diff --git a/nostr_transport_roster.py b/nostr_transport_roster.py new file mode 100644 index 0000000..6603e50 --- /dev/null +++ b/nostr_transport_roster.py @@ -0,0 +1,143 @@ +""" +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 diff --git a/static/js/index.js b/static/js/index.js index 50b0fce..4da7ee6 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,773 +1,1524 @@ +// Satoshi Machine v2 — operator dashboard (P9a foundation). +// +// Vue 3 + Quasar UMD app. Talks to the v2 satmachineadmin REST surface +// (machines / clients / deposits / settlements / commission-splits / +// super-config). All endpoints are operator-scoped via the LNbits session. +// +// LNbits UMD/Quasar conventions in play: +// - Vue delimiters are `${ ... }` because Jinja owns `{{ }}` in the +// template file. Use v-text / :attr binding rather than mustache. +// - For per-element typography overrides, prefer :style — Quasar's +// .text-grey-* / .text-caption utilities collide with LNbits' theme. +// - For pale backgrounds (bg-*-1), pair with explicit dark text class +// so dark-mode users don't get unreadable white-on-cream. + +const API = '/satmachineadmin/api/v1/dca' +const SUPER_FEE_PATH = `${API}/super-config` +const MACHINES_PATH = `${API}/machines` +const SETTLEMENTS_PATH = `${API}/settlements` +const STUCK_PATH = `${API}/settlements/stuck` +const CLIENTS_PATH = `${API}/clients` +const DEPOSITS_PATH = `${API}/deposits` +const COMMISSION_SPLITS_PATH = `${API}/commission-splits` + +const DEPOSIT_STATUS_COLOR = { + pending: 'orange', + confirmed: 'green', + rejected: 'red' +} + +const SETTLEMENT_STATUS_COLOR = { + pending: 'grey', + processing: 'blue', + processed: 'green', + partial: 'orange', + refunded: 'purple', + errored: 'red', + rejected: 'deep-orange' +} + window.app = Vue.createApp({ el: '#vue', mixins: [windowMixin], delimiters: ['${', '}'], - data: function () { - return { - // DCA Admin Data - dcaClients: [], - deposits: [], - lamassuTransactions: [], - // Table configurations - clientsTable: { - columns: [ - { name: 'username', align: 'left', label: 'Username', field: 'username' }, - { name: 'user_id', align: 'left', label: 'User ID', field: 'user_id' }, - { name: 'wallet_id', align: 'left', label: 'Wallet ID', field: 'wallet_id' }, - { name: 'dca_mode', align: 'left', label: 'DCA Mode', field: 'dca_mode' }, - { name: 'remaining_balance', align: 'right', label: 'Remaining Balance', field: 'remaining_balance' }, - { name: 'fixed_mode_daily_limit', align: 'left', label: 'Daily Limit', field: 'fixed_mode_daily_limit' }, - { name: 'status', align: 'left', label: 'Status', field: 'status' } - ], - pagination: { - rowsPerPage: 10 - } + data() { + return { + activeTab: 'fleet', + refreshing: false, + + // Server state --------------------------------------------------- + superConfig: null, + machines: [], + clients: [], + clientBalances: {}, // {client_id: ClientBalanceSummary} + deposits: [], + worklistCount: 0, + + depositsFilter: { + status: null, + client_id: null }, - depositsTable: { - columns: [ - { name: 'client_id', align: 'left', label: 'Client', field: 'client_id' }, - { name: 'amount', align: 'left', label: 'Amount', field: 'amount' }, - { name: 'currency', align: 'left', label: 'Currency', field: 'currency' }, - { name: 'status', align: 'left', label: 'Status', field: 'status' }, - { name: 'created_at', align: 'left', label: 'Created', field: 'created_at' }, - { name: 'notes', align: 'left', label: 'Notes', field: 'notes' } - ], - pagination: { - rowsPerPage: 10 - } + + // Commission splits editor (P9e) -- null scope = default ruleset. + commissionScope: null, + commissionLegs: [], + commissionSaving: false, + // Preview shows how an example commission-sats input would split + // across the current legs (purely visual; doesn't hit the server). + commissionPreviewInput: 1000, + + // Worklist (P9g) + worklist: { + rejected: [], + errored: [], + stuck_pending: [], + stuck_processing: [], + totalCount: 0 }, - lamassuTransactionsTable: { + worklistLoading: false, + worklistThreshold: 30, + worklistTable: { columns: [ - { name: 'lamassu_transaction_id', align: 'left', label: 'Transaction ID', field: 'lamassu_transaction_id' }, - { name: 'transaction_time', align: 'left', label: 'Time', field: 'transaction_time' }, - { name: 'fiat_amount', align: 'right', label: 'Fiat Amount', field: 'fiat_amount' }, - { name: 'crypto_amount', align: 'right', label: 'Total Sats', field: 'crypto_amount' }, - { name: 'commission_amount_sats', align: 'right', label: 'Commission', field: 'commission_amount_sats' }, - { name: 'base_amount_sats', align: 'right', label: 'Base Amount', field: 'base_amount_sats' }, - { name: 'distributions_total_sats', align: 'right', label: 'Distributed', field: 'distributions_total_sats' }, - { name: 'clients_count', align: 'center', label: 'Clients', field: 'clients_count' } - ], - pagination: { - rowsPerPage: 10 - } - }, - distributionDetailsTable: { - columns: [ - { name: 'client_username', align: 'left', label: 'Client', field: 'client_username' }, - { name: 'amount_sats', align: 'right', label: 'Amount (sats)', field: 'amount_sats' }, - { name: 'amount_fiat', align: 'right', label: 'Amount (fiat)', field: 'amount_fiat' }, - { name: 'status', align: 'center', label: 'Status', field: 'status' }, - { name: 'created_at', align: 'left', label: 'Created', field: 'created_at' } + {name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'}, + {name: 'created_at', label: 'Created', field: 'created_at', align: 'left'}, + {name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'}, + { + name: 'error_message', + label: 'Error', + field: 'error_message', + align: 'left' + }, + {name: 'actions', label: '', field: 'id', align: 'right'} ] }, - // Dialog states - depositFormDialog: { + // Reports + reportsBusy: false, + + // Super-fee edit dialog (super-only) + superFeeDialog: { show: false, + saving: false, data: { - currency: 'GTQ' + super_cash_in_fee_fraction: 0, + super_cash_out_fee_fraction: 0, + super_fee_wallet_id: '' } }, - clientDetailsDialog: { - show: false, - data: null, - balance: null - }, - distributionDialog: { - show: false, - transaction: null, - distributions: [] + + // UI configuration ----------------------------------------------- + machinesTable: { + columns: [ + {name: 'status', label: '', field: 'is_active', align: 'center'}, + {name: 'name', label: 'Name / Location', field: 'name', align: 'left'}, + {name: 'machine_npub', label: 'npub', field: 'machine_npub', align: 'left'}, + {name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'}, + {name: 'fiat_code', label: 'Fiat', field: 'fiat_code', align: 'left'}, + {name: 'actions', label: 'Actions', field: 'id', align: 'right'} + ], + pagination: {rowsPerPage: 10, sortBy: 'name'} }, - // Quick deposit form - quickDepositForm: { - selectedClient: null, - amount: null, + depositsTable: { + columns: [ + {name: 'status', label: 'Status', field: 'status', align: 'left'}, + {name: 'client', label: 'LP / Machine', field: 'client_id', align: 'left'}, + {name: 'amount', label: 'Amount', field: 'amount', align: 'right'}, + {name: 'created_at', label: 'Created', field: 'created_at', align: 'left'}, + { + name: 'confirmed_at', + label: 'Confirmed', + field: 'confirmed_at', + align: 'left' + }, + {name: 'notes', label: 'Notes', field: 'notes', align: 'left'}, + {name: 'actions', label: '', field: 'id', align: 'right'} + ], + pagination: {rowsPerPage: 25, sortBy: 'created_at', descending: true} + }, + + clientsTable: { + // Wallet / mode / autoforward dropped — those are LP-controlled + // via satmachineclient, not the operator's concern. `onboarded` + // surfaces the dca_lp existence flag (lp_onboarded) so operators + // can see at a glance which LPs still need to register before + // deposits can be recorded against them. + columns: [ + {name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'}, + {name: 'username', label: 'LP', field: 'username', align: 'left'}, + {name: 'onboarded', label: 'Onboarded', field: 'lp_onboarded', align: 'center'}, + { + name: 'remaining_balance', + label: 'Balance', + field: 'remaining_balance', + align: 'right' + }, + {name: 'status', label: 'Status', field: 'status', align: 'left'}, + {name: 'actions', label: '', field: 'id', align: 'right'} + ], + pagination: {rowsPerPage: 25} + }, + + settlementsTable: { + columns: [ + {name: 'status', label: 'Status', field: 'status', align: 'left'}, + {name: 'tx_type', label: 'Direction', field: 'tx_type', align: 'left'}, + {name: 'created_at', label: 'Time', field: 'created_at', align: 'left'}, + {name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'}, + {name: 'principal_sats', label: 'Principal (→ LPs)', field: 'principal_sats', align: 'right'}, + { + name: 'fee_sats', + label: 'Fee', + field: 'fee_sats', + align: 'right' + }, + {name: 'fiat_amount', label: 'Fiat', field: 'fiat_amount', align: 'right'}, + {name: 'payment_hash', label: 'Hash', field: 'payment_hash', align: 'left'}, + {name: 'actions', label: '', field: 'id', align: 'right'} + ], + pagination: {rowsPerPage: 25, sortBy: 'created_at', descending: true} + }, + + // Dialog state --------------------------------------------------- + addMachineDialog: { + show: false, + saving: false, + data: this._emptyMachineForm() + }, + editMachineDialog: { + show: false, + saving: false, + data: {} + }, + machineDetail: { + show: false, + loading: false, + machine: null, + settlements: [], + // Cassettes sub-tab state (#29 v1) — see openCassettePublishConfirm / + // submitCassettePublish methods + the cassettes panel in + // templates/satmachineadmin/index.html. + activeTab: 'settlements', + cassetteEdits: [], // editable working copy of cassette_configs rows + cassettesPristine: [], // last-known-clean snapshot for revert + cassettesLoading: false, + cassettesPublishing: false, + cassettesDirty: false, + cassettesError: null + }, + cassettesTable: { + columns: [ + {name: 'position', label: 'Bay', field: 'position', align: 'right'}, + {name: 'denomination', label: 'Denomination', field: 'denomination', align: 'right'}, + {name: 'count', label: 'Count', field: 'count', align: 'right'}, + {name: 'state', label: 'ATM-reported', field: 'state_denomination', align: 'right'}, + {name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'} + ], + pagination: {rowsPerPage: 0} // hide pagination — cassette count is small + }, + cassettePublishConfirm: { + show: false + }, + partialDispenseDialog: { + show: false, + saving: false, + settlement: null, + mode: 'fraction', + dispensed_fraction: null, + dispensed_sats: null, + notes: '' + }, + noteDialog: { + show: false, + saving: false, + settlement: null, + note: '' + }, + clientDialog: { + show: false, + saving: false, + mode: 'add', // 'add' | 'edit' + data: this._emptyClientForm() + }, + depositDialog: { + show: false, + saving: false, + mode: 'add', + data: this._emptyDepositForm() + }, + rejectDepositDialog: { + show: false, + saving: false, + deposit: null, notes: '' }, - // Polling status - lastPollTime: null, - testingConnection: false, - runningManualPoll: false, - runningTestTransaction: false, - processingSpecificTransaction: false, - lamassuConfig: null, - - // Manual transaction processing - manualTransactionDialog: { - show: false, - transactionId: '' - }, - - // Config dialog - configDialog: { + settleBalanceDialog: { show: false, + saving: false, + client: null, + balance: null, data: { - host: '', - port: 5432, - database_name: '', - username: '', - password: '', - selectedWallet: null, - selectedCommissionWallet: null, - // DCA Client Limits - max_daily_limit_gtq: 2000, - // SSH Tunnel settings - use_ssh_tunnel: false, - ssh_host: '', - ssh_port: 22, - ssh_username: '', - ssh_password: '', - ssh_private_key: '' - } - }, - - // Options - currencyOptions: [ - { label: 'GTQ', value: 'GTQ' }, - { label: 'USD', value: 'USD' } - ] - } - }, - - /////////////////////////////////////////////////// - ////////////////METHODS FUNCTIONS////////////////// - /////////////////////////////////////////////////// - - methods: { - // Utility Methods - formatCurrency(amount) { - if (!amount) return 'Q 0.00'; - - // Amount is now stored as GTQ directly in database - return new Intl.NumberFormat('es-GT', { - style: 'currency', - currency: 'GTQ', - }).format(amount); - }, - - formatDate(dateString) { - if (!dateString) return '' - return new Date(dateString).toLocaleDateString() - }, - - formatDateTime(dateString) { - if (!dateString) return '' - const date = new Date(dateString) - return date.toLocaleDateString() + ' ' + date.toLocaleTimeString('en-US', { hour12: false }) - }, - - formatSats(amount) { - if (!amount) return '0 sats' - return new Intl.NumberFormat('en-US').format(amount) + ' sats' - }, - - getClientUsername(clientId) { - const client = this.dcaClients.find(c => c.id === clientId) - return client ? (client.username || client.user_id.substring(0, 8) + '...') : clientId - }, - - - // Configuration Methods - async getLamassuConfig() { - try { - const { data } = await LNbits.api.request( - 'GET', - '/satmachineadmin/api/v1/dca/config', - null - ) - this.lamassuConfig = data - - // When opening config dialog, populate the selected wallets if they exist - if (data && data.source_wallet_id && this.g.user?.wallets) { - const wallet = this.g.user.wallets.find(w => w.id === data.source_wallet_id) - if (wallet) { - this.configDialog.data.selectedWallet = wallet - } - } - if (data && data.commission_wallet_id && this.g.user?.wallets) { - const commissionWallet = this.g.user.wallets.find(w => w.id === data.commission_wallet_id) - if (commissionWallet) { - this.configDialog.data.selectedCommissionWallet = commissionWallet - } - } - - // Populate other configuration fields - if (data) { - this.configDialog.data.max_daily_limit_gtq = data.max_daily_limit_gtq || 2000 - } - } catch (error) { - // It's OK if no config exists yet - this.lamassuConfig = null - } - }, - - async saveConfiguration() { - try { - const data = { - host: this.configDialog.data.host, - port: this.configDialog.data.port, - database_name: this.configDialog.data.database_name, - username: this.configDialog.data.username, - password: this.configDialog.data.password, - source_wallet_id: this.configDialog.data.selectedWallet?.id, - commission_wallet_id: this.configDialog.data.selectedCommissionWallet?.id, - // SSH Tunnel settings - max_daily_limit_gtq: this.configDialog.data.max_daily_limit_gtq, - use_ssh_tunnel: this.configDialog.data.use_ssh_tunnel, - ssh_host: this.configDialog.data.ssh_host, - ssh_port: this.configDialog.data.ssh_port, - ssh_username: this.configDialog.data.ssh_username, - ssh_password: this.configDialog.data.ssh_password, - ssh_private_key: this.configDialog.data.ssh_private_key - } - - const { data: config } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/config', - null, - data - ) - - this.lamassuConfig = config - this.closeConfigDialog() - - this.$q.notify({ - type: 'positive', - message: 'Database configuration saved successfully', - timeout: 5000 - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - closeConfigDialog() { - this.configDialog.show = false - this.configDialog.data = { - host: '', - port: 5432, - database_name: '', - username: '', - password: '', - selectedWallet: null, - selectedCommissionWallet: null, - // DCA Client Limits - max_daily_limit_gtq: 2000, - // SSH Tunnel settings - use_ssh_tunnel: false, - ssh_host: '', - ssh_port: 22, - ssh_username: '', - ssh_password: '', - ssh_private_key: '' - } - }, - - // DCA Client Methods - async getDcaClients() { - try { - const { data } = await LNbits.api.request( - 'GET', - '/satmachineadmin/api/v1/dca/clients', - null - ) - - // Fetch balance data for each client - const clientsWithBalances = await Promise.all( - data.map(async (client) => { - try { - const { data: balance } = await LNbits.api.request( - 'GET', - `/satmachineadmin/api/v1/dca/clients/${client.id}/balance`, - null - ) - return { - ...client, - remaining_balance: balance.remaining_balance - } - } catch (error) { - console.error(`Error fetching balance for client ${client.id}:`, error) - return { - ...client, - remaining_balance: 0 - } - } - }) - ) - - this.dcaClients = clientsWithBalances - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - - // Quick Deposit Methods - async sendQuickDeposit() { - try { - const data = { - client_id: this.quickDepositForm.selectedClient?.value, - amount: this.quickDepositForm.amount, // Send GTQ directly - now stored as GTQ - currency: 'GTQ', - notes: this.quickDepositForm.notes - } - - const { data: newDeposit } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/deposits', - null, - data - ) - - this.deposits.unshift(newDeposit) - - // Reset form - this.quickDepositForm = { - selectedClient: null, - amount: null, + funding_wallet_id: null, + exchange_rate: null, + amount_fiat: null, notes: '' } - - this.$q.notify({ - type: 'positive', - message: 'Deposit created successfully', - timeout: 5000 - }) - } catch (error) { - LNbits.utils.notifyApiError(error) } - }, - - async viewClientDetails(client) { - try { - const { data: balance } = await LNbits.api.request( - 'GET', - `/satmachineadmin/api/v1/dca/clients/${client.id}/balance`, - null - ) - this.clientDetailsDialog.data = client - this.clientDetailsDialog.balance = balance - this.clientDetailsDialog.show = true - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - // Deposit Methods - async getDeposits() { - try { - const { data } = await LNbits.api.request( - 'GET', - '/satmachineadmin/api/v1/dca/deposits', - null - ) - this.deposits = data - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - addDepositDialog(client) { - this.depositFormDialog.data = { - client_id: client.id, - client_name: client.username || `${client.user_id.substring(0, 8)}...`, - currency: 'GTQ' - } - this.depositFormDialog.show = true - }, - - async sendDepositData() { - try { - const data = { - client_id: this.depositFormDialog.data.client_id, - amount: this.depositFormDialog.data.amount, // Send GTQ directly - now stored as GTQ - currency: this.depositFormDialog.data.currency, - notes: this.depositFormDialog.data.notes - } - - if (this.depositFormDialog.data.id) { - // Update existing pending deposit - const { data: updatedDeposit } = await LNbits.api.request( - 'PUT', - `/satmachineadmin/api/v1/dca/deposits/${this.depositFormDialog.data.id}`, - null, - { amount: data.amount, currency: data.currency, notes: data.notes } - ) - const index = this.deposits.findIndex(d => d.id === updatedDeposit.id) - if (index !== -1) { - this.deposits.splice(index, 1, updatedDeposit) - } - } else { - // Create new deposit - const { data: newDeposit } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/deposits', - null, - data - ) - this.deposits.unshift(newDeposit) - } - - this.closeDepositFormDialog() - this.$q.notify({ - type: 'positive', - message: this.depositFormDialog.data.id ? 'Deposit updated successfully' : 'Deposit created successfully', - timeout: 5000 - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - closeDepositFormDialog() { - this.depositFormDialog.show = false - this.depositFormDialog.data = { - currency: 'GTQ' - } - }, - - async confirmDeposit(deposit) { - try { - await LNbits.utils - .confirmDialog('Confirm that this deposit has been physically placed in the ATM machine?') - .onOk(async () => { - const { data: updatedDeposit } = await LNbits.api.request( - 'PUT', - `/satmachineadmin/api/v1/dca/deposits/${deposit.id}/status`, - null, - { status: 'confirmed', notes: 'Confirmed by admin - money placed in machine' } - ) - const index = this.deposits.findIndex(d => d.id === deposit.id) - if (index !== -1) { - this.deposits.splice(index, 1, updatedDeposit) - } - this.$q.notify({ - type: 'positive', - message: 'Deposit confirmed! DCA is now active for this client.', - timeout: 5000 - }) - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - editDeposit(deposit) { - this.depositFormDialog.data = { ...deposit } - this.depositFormDialog.show = true - }, - - async deleteDeposit(deposit) { - try { - await LNbits.utils - .confirmDialog('Are you sure you want to delete this pending deposit?') - .onOk(async () => { - await LNbits.api.request( - 'DELETE', - `/satmachineadmin/api/v1/dca/deposits/${deposit.id}`, - null - ) - this.deposits = this.deposits.filter(d => d.id !== deposit.id) - this.$q.notify({ - type: 'positive', - message: 'Deposit deleted successfully', - timeout: 5000 - }) - }) - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - // Export Methods - async exportClientsCSV() { - await LNbits.utils.exportCSV(this.clientsTable.columns, this.dcaClients) - }, - - async exportDepositsCSV() { - await LNbits.utils.exportCSV(this.depositsTable.columns, this.deposits) - }, - - async exportLamassuTransactionsCSV() { - await LNbits.utils.exportCSV(this.lamassuTransactionsTable.columns, this.lamassuTransactions) - }, - - // Polling Methods - async testDatabaseConnection() { - this.testingConnection = true - try { - const { data } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/test-connection', - null - ) - - // Show detailed results in a dialog - const stepsList = data.steps ? data.steps.join('\n') : 'No detailed steps available' - - let dialogContent = `Connection Test Results

` - - if (data.ssh_tunnel_used) { - dialogContent += `SSH Tunnel: ${data.ssh_tunnel_success ? '✅ Success' : '❌ Failed'}
` - } - - dialogContent += `Database: ${data.database_connection_success ? '✅ Success' : '❌ Failed'}

` - dialogContent += `Detailed Steps:
` - dialogContent += stepsList.replace(/\n/g, '
') - - this.$q.dialog({ - title: data.success ? 'Connection Test Passed' : 'Connection Test Failed', - message: dialogContent, - html: true, - ok: { - color: data.success ? 'positive' : 'negative', - label: 'Close' - } - }) - - // Also show a brief notification - this.$q.notify({ - type: data.success ? 'positive' : 'negative', - message: data.message, - timeout: 3000 - }) - - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.testingConnection = false - } - }, - - async manualPoll() { - this.runningManualPoll = true - try { - const { data } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/manual-poll', - null - ) - - this.lastPollTime = new Date().toLocaleString() - this.$q.notify({ - type: 'positive', - message: `Manual poll completed. Found ${data.transactions_processed} new transactions.`, - timeout: 5000 - }) - - // Refresh data - await this.getDcaClients() // Refresh to show updated balances - await this.getDeposits() - await this.getLamassuTransactions() - await this.getLamassuConfig() - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.runningManualPoll = false - } - }, - - async testTransaction() { - this.runningTestTransaction = true - try { - const { data } = await LNbits.api.request( - 'POST', - '/satmachineadmin/api/v1/dca/test-transaction', - null - ) - - // Show detailed results in a dialog - const details = data.transaction_details - - let dialogContent = `Test Transaction Results

` - dialogContent += `Transaction ID: ${details.transaction_id}
` - dialogContent += `Total Amount: ${details.total_amount_sats} sats
` - dialogContent += `Base Amount: ${details.base_amount_sats} sats
` - dialogContent += `Commission: ${details.commission_amount_sats} sats (${details.commission_percentage}%)
` - if (details.discount > 0) { - dialogContent += `Discount: ${details.discount}%
` - dialogContent += `Effective Commission: ${details.effective_commission}%
` - } - dialogContent += `
Check your wallets to see the distributions!` - - this.$q.dialog({ - title: 'Test Transaction Completed', - message: dialogContent, - html: true, - ok: { - color: 'positive', - label: 'Great!' - } - }) - - // Also show a brief notification - this.$q.notify({ - type: 'positive', - message: `Test transaction processed: ${details.total_amount_sats} sats distributed`, - timeout: 5000 - }) - - // Refresh data - await this.getDcaClients() // Refresh to show updated balances - await this.getDeposits() - await this.getLamassuTransactions() - await this.getLamassuConfig() - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.runningTestTransaction = false - } - }, - - openManualTransactionDialog() { - this.manualTransactionDialog.transactionId = '' - this.manualTransactionDialog.show = true - }, - - async processSpecificTransaction() { - if (!this.manualTransactionDialog.transactionId) { - this.$q.notify({ - type: 'warning', - message: 'Please enter a transaction ID', - timeout: 3000 - }) - return - } - - this.processingSpecificTransaction = true - try { - const { data } = await LNbits.api.request( - 'POST', - `/satmachineadmin/api/v1/dca/process-transaction/${this.manualTransactionDialog.transactionId}`, - null - ) - - if (data.already_processed) { - this.$q.notify({ - type: 'warning', - message: `Transaction already processed with ${data.payment_count} distributions`, - timeout: 5000 - }) - this.manualTransactionDialog.show = false - return - } - - // Show detailed results - const details = data.transaction_details - let dialogContent = `Manual Transaction Processing Results

` - dialogContent += `Transaction ID: ${details.transaction_id}
` - dialogContent += `Status: ${details.status}
` - dialogContent += `Dispense: ${details.dispense ? 'Yes' : 'No'}
` - dialogContent += `Dispense Confirmed: ${details.dispense_confirmed ? 'Yes' : 'No'}
` - dialogContent += `Crypto Amount: ${details.crypto_amount} sats
` - dialogContent += `Fiat Amount: ${details.fiat_amount}
` - dialogContent += `
Transaction processed successfully!` - - this.$q.dialog({ - title: 'Transaction Processed', - message: dialogContent, - html: true, - ok: { - color: 'positive', - label: 'Great!' - } - }) - - this.$q.notify({ - type: 'positive', - message: `Transaction ${details.transaction_id} processed successfully`, - timeout: 5000 - }) - - // Close dialog and refresh data - this.manualTransactionDialog.show = false - await this.getDcaClients() - await this.getDeposits() - await this.getLamassuTransactions() - await this.getLamassuConfig() - - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.processingSpecificTransaction = false - } - }, - - // Lamassu Transaction Methods - async getLamassuTransactions() { - try { - const { data } = await LNbits.api.request( - 'GET', - '/satmachineadmin/api/v1/dca/transactions', - null - ) - this.lamassuTransactions = data - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - async viewTransactionDistributions(transaction) { - try { - const { data: distributions } = await LNbits.api.request( - 'GET', - `/satmachineadmin/api/v1/dca/transactions/${transaction.id}/distributions`, - null - ) - - this.distributionDialog.transaction = transaction - this.distributionDialog.distributions = distributions - this.distributionDialog.show = true - } catch (error) { - LNbits.utils.notifyApiError(error) - } - }, - - }, - /////////////////////////////////////////////////// - //////LIFECYCLE FUNCTIONS RUNNING ON PAGE LOAD///// - /////////////////////////////////////////////////// - async created() { - // Load DCA admin data - await Promise.all([ - this.getLamassuConfig(), - this.getDcaClients(), - this.getDeposits(), - this.getLamassuTransactions() - ]) + } }, computed: { - isConfigFormValid() { - const data = this.configDialog.data - - // Basic database fields are required - const basicValid = data.host && data.database_name && data.username && data.selectedWallet - - // If SSH tunnel is enabled, validate SSH fields - if (data.use_ssh_tunnel) { - const sshValid = data.ssh_host && data.ssh_username && - (data.ssh_password || data.ssh_private_key) - return basicValid && sshValid - } - - return basicValid + superAnyFee() { + // Banner styling key — true when either directional super fee is + // non-zero, so the banner reads as "active platform fee" instead + // of the muted grey "free instance" state. + const c = this.superConfig + if (!c) return 0 + return ( + Number(c.super_cash_in_fee_fraction || 0) + + Number(c.super_cash_out_fee_fraction || 0) + ) }, - - clientOptions() { - return this.dcaClients.map(client => ({ - label: `${client.username || client.user_id.substring(0, 8) + '...'} (${client.dca_mode})`, - value: client.id + walletOptions() { + // g.user is sometimes null on initial mount in LNbits 1.4 — guard it. + const wallets = this.g?.user?.wallets || [] + return wallets.map(w => ({label: w.name, value: w.id})) + }, + machineOptions() { + return this.machines.map(m => ({ + label: m.name || this.shortNpub(m.machine_npub), + value: m.id })) }, + depositClientOptions() { + // Annotate each LP option with onboarding state so the operator + // sees at-pick time which LPs can accept deposits. We don't hide + // un-onboarded LPs — the operator might want to know they exist + // and chase them — but submission is gated below by + // `selectedDepositClient.lp_onboarded`. + return this.clients.map(c => ({ + label: + `${c.username || this.shortId(c.user_id)} @ ` + + `${this.machineNameById(c.machine_id)}` + + (c.lp_onboarded ? '' : ' — pending onboarding'), + value: c.id, + disable: !c.lp_onboarded + })) + }, + selectedDepositClient() { + const id = this.depositDialog.data.client_id + return id ? this.clients.find(c => c.id === id) : null + }, + depositMachineFiatCode() { + // Currency the deposit will land in — bound to the machine the + // selected LP is enrolled at. Resolved entirely client-side from + // already-loaded data, but the server has the final say (#26). + const c = this.selectedDepositClient + if (!c) return null + const m = this.machines.find(m => m.id === c.machine_id) + return m ? m.fiat_code : null + }, + worklistBuckets() { + return [ + { + key: 'rejected', + label: 'Rejected — Nostr attribution failed; investigate machine', + icon: 'gpp_bad', + color: 'deep-orange', + rows: this.worklist.rejected + }, + { + key: 'errored', + label: 'Errored — needs retry', + icon: 'error', + color: 'red', + rows: this.worklist.errored + }, + { + key: 'stuck_pending', + label: 'Stuck pending — listener crashed before processing?', + icon: 'hourglass_top', + color: 'orange', + rows: this.worklist.stuck_pending + }, + { + key: 'stuck_processing', + label: 'Stuck processing — processor crashed mid-flight?', + icon: 'sync_problem', + color: 'purple', + rows: this.worklist.stuck_processing + } + ] + }, - totalDcaBalance() { - return this.deposits - .filter(d => d.status === 'confirmed') - .reduce((total, deposit) => total + deposit.amount, 0) + commissionScopeOptions() { + const opts = [{label: 'Default ruleset (operator-wide)', value: null}] + for (const m of this.machines) { + opts.push({ + label: `Override: ${m.name || this.shortNpub(m.machine_npub)}`, + value: m.id + }) + } + return opts + }, + commissionSum() { + return this.commissionLegs.reduce( + (acc, leg) => acc + (Number(leg.fraction) || 0), 0 + ) + }, + commissionSumValid() { + // Allow ZERO legs (empty ruleset = no rules; valid). Else must sum to 1. + if (!this.commissionLegs.length) return true + return Math.abs(this.commissionSum - 1.0) < 0.0001 + }, + commissionPreview() { + if (!this.commissionLegs.length) return null + // Last-leg-absorbs-rounding mirrors calculations.allocate_operator_split_legs. + const total = this.commissionPreviewInput + let remaining = total + const out = [] + this.commissionLegs.forEach((leg, idx) => { + let sats + if (idx === this.commissionLegs.length - 1) { + sats = remaining + } else { + sats = Math.round(total * (Number(leg.fraction) || 0)) + remaining -= sats + } + out.push({label: leg.label, sats}) + }) + return out + }, + filteredDeposits() { + let rows = this.deposits + if (this.depositsFilter.status) { + rows = rows.filter(d => d.status === this.depositsFilter.status) + } + if (this.depositsFilter.client_id) { + rows = rows.filter(d => d.client_id === this.depositsFilter.client_id) + } + return rows + }, + machinesById() { + const map = {} + for (const m of this.machines) map[m.id] = m + return map + } + }, + + async created() { + await this.refreshAll() + await this.loadCommissionSplits() + await this.loadWorklist() + }, + + methods: { + // ----------------------------------------------------------------- + // Loaders + // ----------------------------------------------------------------- + async refreshAll() { + this.refreshing = true + try { + await Promise.all([ + this.loadSuperConfig(), + this.loadMachines(), + this.loadClients(), + this.loadDeposits(), + this.loadWorklistCount() + ]) + } finally { + this.refreshing = false + } + }, + + async loadDeposits() { + try { + const {data} = await LNbits.api.request('GET', DEPOSITS_PATH) + this.deposits = data || [] + } catch (e) { + this.deposits = [] + this._notifyError(e, 'Failed to load deposits') + } + }, + + depositStatusColor(status) { + return DEPOSIT_STATUS_COLOR[status] || 'grey' + }, + + clientUsernameById(clientId) { + const c = this.clients.find(x => x.id === clientId) + if (!c) return this.shortId(clientId) + return c.username || this.shortId(c.user_id) + }, + + async loadClients() { + try { + const {data} = await LNbits.api.request('GET', CLIENTS_PATH) + this.clients = data || [] + // N+1 acceptable for fleet sizes ~50; review #11 captures the + // single-grouped-JOIN follow-up (M3). + await Promise.all( + this.clients.map(c => this._loadClientBalance(c.id)) + ) + } catch (e) { + this.clients = [] + this._notifyError(e, 'Failed to load LPs') + } + }, + + async _loadClientBalance(clientId) { + try { + const {data} = await LNbits.api.request( + 'GET', `${CLIENTS_PATH}/${clientId}/balance` + ) + this.clientBalances[clientId] = data + } catch (e) { + delete this.clientBalances[clientId] + } + }, + + machineNameById(machineId) { + const m = this.machinesById[machineId] + if (!m) return this.shortId(machineId) + return m.name || this.shortNpub(m.machine_npub) + }, + + async loadSuperConfig() { + try { + const {data} = await LNbits.api.request('GET', SUPER_FEE_PATH) + this.superConfig = data + } catch (e) { + this.superConfig = null + } + }, + + async loadMachines() { + try { + const {data} = await LNbits.api.request('GET', MACHINES_PATH) + this.machines = data || [] + } catch (e) { + this.machines = [] + this._notifyError(e, 'Failed to load machines') + } + }, + + async loadWorklistCount() { + // Light read for the tab badge — Worklist tab fetches the full + // payload via loadWorklist when opened. + try { + const {data} = await LNbits.api.request('GET', STUCK_PATH) + this.worklistCount = + (data?.rejected?.length || 0) + + (data?.errored?.length || 0) + + (data?.stuck_pending?.length || 0) + + (data?.stuck_processing?.length || 0) + } catch (e) { + this.worklistCount = 0 + } + }, + + async loadWorklist() { + this.worklistLoading = true + try { + const {data} = await LNbits.api.request( + 'GET', `${STUCK_PATH}?threshold_minutes=${this.worklistThreshold}` + ) + this.worklist.rejected = data?.rejected || [] + this.worklist.errored = data?.errored || [] + this.worklist.stuck_pending = data?.stuck_pending || [] + this.worklist.stuck_processing = data?.stuck_processing || [] + this.worklist.totalCount = + this.worklist.rejected.length + + this.worklist.errored.length + + this.worklist.stuck_pending.length + + this.worklist.stuck_processing.length + this.worklistCount = this.worklist.totalCount + } catch (e) { + this._notifyError(e, 'Failed to load worklist') + } finally { + this.worklistLoading = false + } + }, + + async viewMachineFromWorklist(settlement) { + const machine = this.machinesById[settlement.machine_id] + if (!machine) return + await this.viewMachine(machine) + }, + + confirmRetryFromWorklist(settlement) { + this.confirmRetrySettlement(settlement) + // Drop from worklist on success (optimistic; reload covers re-eval). + setTimeout(() => this.loadWorklist(), 500) + }, + + confirmForceResetFromWorklist(settlement) { + this.confirmForceReset(settlement) + setTimeout(() => this.loadWorklist(), 500) + }, + + // ----------------------------------------------------------------- + // Super-fee edit (P9f — super-only) + // ----------------------------------------------------------------- + openSuperFeeDialog() { + this.superFeeDialog.data = { + super_cash_in_fee_fraction: + this.superConfig?.super_cash_in_fee_fraction ?? 0, + super_cash_out_fee_fraction: + this.superConfig?.super_cash_out_fee_fraction ?? 0, + super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '' + } + this.superFeeDialog.show = true + }, + + async submitSuperFee() { + const d = this.superFeeDialog.data + this.superFeeDialog.saving = true + try { + const {data} = await LNbits.api.request( + 'PUT', SUPER_FEE_PATH, null, + { + super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction), + super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction), + super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null + } + ) + this.superConfig = data + this.superFeeDialog.show = false + Quasar.Notify.create({type: 'positive', message: 'Platform fee updated'}) + } catch (e) { + this._notifyError(e, 'Save failed') + } finally { + this.superFeeDialog.saving = false + } + }, + + // ----------------------------------------------------------------- + // Reports / CSV exports (P9g) + // ----------------------------------------------------------------- + downloadMachinesCsv() { + this._downloadCsv( + 'machines.csv', + ['id', 'machine_npub', 'wallet_id', 'name', 'location', 'fiat_code', + 'is_active', 'created_at'], + this.machines + ) + }, + + downloadClientsCsv() { + const rows = this.clients.map(c => { + const bal = this.clientBalances[c.id] || {} + return { + ...c, + machine_name: this.machineNameById(c.machine_id), + remaining_balance: bal.remaining_balance ?? '', + total_deposits: bal.total_deposits ?? '', + total_payments: bal.total_payments ?? '', + balance_currency: bal.currency ?? '' + } + }) + this._downloadCsv( + 'clients.csv', + ['id', 'machine_id', 'machine_name', 'user_id', + 'username', 'lp_onboarded', 'status', + 'total_deposits', 'total_payments', + 'remaining_balance', 'balance_currency', 'created_at'], + rows + ) + }, + + downloadDepositsCsv() { + this._downloadCsv( + 'deposits.csv', + ['id', 'client_id', 'machine_id', 'creator_user_id', 'amount', + 'currency', 'status', 'notes', 'created_at', 'confirmed_at'], + this.deposits + ) + }, + + async downloadPaymentsCsv() { + // Payments are not pre-loaded; fetch on demand. + this.reportsBusy = true + try { + const {data} = await LNbits.api.request('GET', `${API}/payments`) + this._downloadCsv( + 'payments.csv', + ['id', 'settlement_id', 'client_id', 'machine_id', 'leg_type', + 'destination_wallet_id', 'destination_ln_address', 'amount_sats', + 'amount_fiat', 'exchange_rate', 'status', 'external_payment_hash', + 'transaction_time', 'created_at', 'error_message'], + data || [] + ) + } catch (e) { + this._notifyError(e, 'Failed to fetch payments') + } finally { + this.reportsBusy = false + } + }, + + _downloadCsv(filename, columns, rows) { + const escape = v => { + if (v == null) return '' + const s = String(v) + if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"' + return s + } + const header = columns.join(',') + const body = rows.map( + row => columns.map(col => escape(row[col])).join(',') + ).join('\n') + const csv = header + '\n' + body + const blob = new Blob([csv], {type: 'text/csv;charset=utf-8'}) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + }, + + // ----------------------------------------------------------------- + // Add machine + // ----------------------------------------------------------------- + openAddMachineDialog() { + this.addMachineDialog.data = this._emptyMachineForm() + this.addMachineDialog.show = true + }, + + async submitAddMachine() { + const body = this._cleanMachineForm(this.addMachineDialog.data) + if (!body.machine_npub || !body.wallet_id) { + Quasar.Notify.create({ + type: 'negative', + message: 'machine_npub and wallet_id are required' + }) + return + } + this.addMachineDialog.saving = true + try { + const {data} = await LNbits.api.request('POST', MACHINES_PATH, null, body) + this.machines.unshift(data) + this.addMachineDialog.show = false + Quasar.Notify.create({ + type: 'positive', + message: `Machine ${data.name || data.machine_npub.slice(0, 12)} added` + }) + } catch (e) { + this._notifyError(e, 'Failed to add machine') + } finally { + this.addMachineDialog.saving = false + } + }, + + // ----------------------------------------------------------------- + // Edit / delete machine + // ----------------------------------------------------------------- + openEditMachineDialog(machine) { + this.editMachineDialog.data = { + id: machine.id, + name: machine.name || '', + location: machine.location || '', + wallet_id: machine.wallet_id, + fiat_code: machine.fiat_code, + is_active: machine.is_active, + operator_cash_in_fee_fraction: machine.operator_cash_in_fee_fraction ?? 0, + operator_cash_out_fee_fraction: machine.operator_cash_out_fee_fraction ?? 0 + } + this.editMachineDialog.show = true + }, + + async submitEditMachine() { + const d = this.editMachineDialog.data + this.editMachineDialog.saving = true + try { + const {data} = await LNbits.api.request( + 'PUT', + `${MACHINES_PATH}/${d.id}`, + null, + { + name: d.name, + location: d.location, + wallet_id: d.wallet_id, + fiat_code: d.fiat_code, + is_active: d.is_active, + operator_cash_in_fee_fraction: Number(d.operator_cash_in_fee_fraction) || 0, + operator_cash_out_fee_fraction: Number(d.operator_cash_out_fee_fraction) || 0 + } + ) + const idx = this.machines.findIndex(m => m.id === data.id) + if (idx >= 0) this.machines[idx] = data + this.editMachineDialog.show = false + Quasar.Notify.create({type: 'positive', message: 'Machine updated'}) + } catch (e) { + this._notifyError(e, 'Failed to update machine') + } finally { + this.editMachineDialog.saving = false + } + }, + + confirmDeleteMachine(machine) { + Quasar.Dialog.create({ + title: 'Delete machine?', + message: + `This removes ${machine.name || machine.machine_npub.slice(0, 12)}` + + ' from your fleet. Existing settlements and payment history are preserved' + + ' — only the machine row itself is removed. Continue?', + html: true, + cancel: true, + persistent: true + }).onOk(async () => { + try { + await LNbits.api.request('DELETE', `${MACHINES_PATH}/${machine.id}`) + this.machines = this.machines.filter(m => m.id !== machine.id) + Quasar.Notify.create({type: 'positive', message: 'Machine deleted'}) + } catch (e) { + this._notifyError(e, 'Failed to delete machine') + } + }) + }, + + // ----------------------------------------------------------------- + // Machine detail dialog (P9b) + // ----------------------------------------------------------------- + async viewMachine(machine) { + this.machineDetail.machine = machine + this.machineDetail.settlements = [] + this.machineDetail.cassetteEdits = [] + this.machineDetail.cassettesPristine = [] + this.machineDetail.cassettesDirty = false + this.machineDetail.cassettesError = null + this.machineDetail.activeTab = 'settlements' + this.machineDetail.show = true + await this.reloadMachineDetail() + }, + + async reloadMachineDetail() { + if (!this.machineDetail.machine) return + this.machineDetail.loading = true + try { + const {data} = await LNbits.api.request( + 'GET', + `${MACHINES_PATH}/${this.machineDetail.machine.id}/settlements` + ) + this.machineDetail.settlements = data || [] + } catch (e) { + this._notifyError(e, 'Failed to load settlements') + } finally { + this.machineDetail.loading = false + } + // Cassettes load in parallel; UI only renders them when the tab + // is active, but pre-loading means no flicker on tab switch. + await this.loadMachineCassettes() + }, + + // ----------------------------------------------------------------- + // Cassette inventory (#29 v1) + // ----------------------------------------------------------------- + async loadMachineCassettes() { + if (!this.machineDetail.machine) return + this.machineDetail.cassettesLoading = true + this.machineDetail.cassettesError = null + try { + const {data} = await LNbits.api.request( + 'GET', + `${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes` + ) + const rows = (data || []).map(row => ({...row, _dirty: false})) + this.machineDetail.cassetteEdits = rows + this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(rows)) + this.machineDetail.cassettesDirty = false + } catch (e) { + this._notifyError(e, 'Failed to load cassettes') + } finally { + this.machineDetail.cassettesLoading = false + } + }, + + markCassetteDirty(row) { + // Find pristine match by position (the row identity) and compare; + // flip _dirty + overall dirty flag accordingly. Editable fields + // are denomination + count; position is the immutable row key. + const pristine = this.machineDetail.cassettesPristine.find( + p => p.position === row.position + ) + row._dirty = + !pristine || + Number(row.denomination) !== Number(pristine.denomination) || + Number(row.count) !== Number(pristine.count) + this.machineDetail.cassettesDirty = + this.machineDetail.cassetteEdits.some(r => r._dirty) + }, + + revertCassetteEdits() { + this.machineDetail.cassetteEdits = JSON.parse( + JSON.stringify(this.machineDetail.cassettesPristine) + ) + this.machineDetail.cassettesDirty = false + this.machineDetail.cassettesError = null + }, + + openCassettePublishConfirm() { + if (!this.machineDetail.cassettesDirty) return + this.machineDetail.cassettesError = null + this.cassettePublishConfirm.show = true + }, + + async submitCassettePublish() { + // Build the PublishCassettesPayload shape (v1.1, position-keyed): + // { positions: { "": { denomination, count }, ... } } + // The API enforces the position set matches what's stored — + // since we only edit existing rows, this should always pass. + const positions = {} + for (const row of this.machineDetail.cassetteEdits) { + positions[String(row.position)] = { + denomination: Number(row.denomination), + count: Number(row.count) + } + } + const payload = {positions} + this.machineDetail.cassettesPublishing = true + try { + const {data} = await LNbits.api.request( + 'POST', + `${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/publish`, + null, + payload + ) + const fresh = (data || []).map(r => ({...r, _dirty: false})) + this.machineDetail.cassetteEdits = fresh + this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(fresh)) + this.machineDetail.cassettesDirty = false + this.cassettePublishConfirm.show = false + Quasar.Notify.create({ + type: 'positive', + message: 'Cassette config published to ATM' + }) + } catch (e) { + const detail = + (e && e.response && e.response.data && e.response.data.detail) || + 'Publish failed' + this.machineDetail.cassettesError = detail + this._notifyError(e, 'Publish failed') + } finally { + this.machineDetail.cassettesPublishing = false + } + }, + + settlementStatusColor(status) { + return SETTLEMENT_STATUS_COLOR[status] || 'grey' + }, + + txTypeChip(txType) { + // Direction at the ATM (business semantics), not at the operator's + // wallet (Lightning protocol semantics). See the canonical mapping + // in tasks.py:_handle_payment — cash_out ↔ inbound Lightning, + // cash_in ↔ outbound Lightning. + if (txType === 'cash_in') { + return { + color: 'orange-8', + icon: 'north_east', + label: 'cash-in', + tooltip: + 'Cash-in: customer deposited fiat at the ATM, operator wallet ' + + 'sent sats (LNURL-withdraw). No DCA distribution; liquidity ' + + 'stays in the operator wallet.' + } + } + // Default to cash_out — both the only direction shipped pre-S8 and + // the safer "unknown means cash_out" fallback for legacy rows. + return { + color: 'green-8', + icon: 'south_west', + label: 'cash-out', + tooltip: + 'Cash-out: customer paid the ATM\'s invoice in BTC, operator ' + + 'wallet received sats. Principal is distributed to LPs.' + } + }, + + // ----------------------------------------------------------------- + // Settlement actions: retry, partial-dispense, force-reset, note + // ----------------------------------------------------------------- + confirmRetrySettlement(settlement) { + Quasar.Dialog.create({ + title: 'Retry distribution?', + message: + 'Voids any failed legs and re-runs the distribution chain. ' + + 'Completed legs are never re-paid.', + cancel: true, + persistent: true + }).onOk(async () => { + try { + const {data} = await LNbits.api.request( + 'POST', + `${SETTLEMENTS_PATH}/${settlement.id}/retry` + ) + this._replaceSettlement(data) + Quasar.Notify.create({ + type: 'positive', + message: `Settlement ${this.shortId(settlement.id)} re-run` + }) + } catch (e) { + this._notifyError(e, 'Retry failed') + } + }) + }, + + confirmForceReset(settlement) { + Quasar.Dialog.create({ + title: 'Force-reset stuck settlement?', + message: + `Flips status '${settlement.status}' → 'errored' so you can then ` + + 'retry. Only use if the processor truly crashed mid-flight — fresh ' + + 'settlements are refused (default 30-minute age guard).', + cancel: true, + persistent: true + }).onOk(async () => { + try { + const {data} = await LNbits.api.request( + 'POST', + `${SETTLEMENTS_PATH}/${settlement.id}/force-reset` + ) + this._replaceSettlement(data) + Quasar.Notify.create({ + type: 'warning', + message: `Settlement marked errored — hit Retry next` + }) + } catch (e) { + this._notifyError(e, 'Force-reset failed') + } + }) + }, + + openPartialDispense(settlement) { + this.partialDispenseDialog.settlement = settlement + this.partialDispenseDialog.mode = 'fraction' + this.partialDispenseDialog.dispensed_fraction = null + this.partialDispenseDialog.dispensed_sats = null + this.partialDispenseDialog.notes = '' + this.partialDispenseDialog.show = true + }, + + async submitPartialDispense() { + const d = this.partialDispenseDialog + const body = {notes: d.notes || null} + if (d.mode === 'fraction') { + body.dispensed_fraction = Number(d.dispensed_fraction) + } else { + body.dispensed_sats = Number(d.dispensed_sats) + } + d.saving = true + try { + const {data} = await LNbits.api.request( + 'POST', + `${SETTLEMENTS_PATH}/${d.settlement.id}/partial-dispense`, + null, + body + ) + this._replaceSettlement(data) + d.show = false + Quasar.Notify.create({ + type: 'positive', + message: 'Partial dispense applied; distribution re-running' + }) + } catch (e) { + this._notifyError(e, 'Partial dispense failed') + } finally { + d.saving = false + } + }, + + openSettlementNote(settlement) { + this.noteDialog.settlement = settlement + this.noteDialog.note = '' + this.noteDialog.show = true + }, + + async submitNote() { + const d = this.noteDialog + if (!d.note || !d.note.trim()) return + d.saving = true + try { + const {data} = await LNbits.api.request( + 'POST', + `${SETTLEMENTS_PATH}/${d.settlement.id}/notes`, + null, + {note: d.note.trim()} + ) + this._replaceSettlement(data) + d.show = false + Quasar.Notify.create({type: 'positive', message: 'Note added'}) + } catch (e) { + this._notifyError(e, 'Failed to add note') + } finally { + d.saving = false + } + }, + + // ----------------------------------------------------------------- + // Client (LP) management (P9c) + // ----------------------------------------------------------------- + openAddClientDialog() { + this.clientDialog.mode = 'add' + this.clientDialog.data = this._emptyClientForm() + this.clientDialog.show = true + }, + + openEditClientDialog(client) { + this.clientDialog.mode = 'edit' + this.clientDialog.data = { + id: client.id, + machine_id: client.machine_id, + user_id: client.user_id, + username: client.username || '', + status: client.status + } + this.clientDialog.show = true + }, + + async submitClient() { + const d = this.clientDialog.data + this.clientDialog.saving = true + try { + if (this.clientDialog.mode === 'add') { + const body = this._cleanClientCreate(d) + const {data} = await LNbits.api.request('POST', CLIENTS_PATH, null, body) + this.clients.unshift(data) + await this._loadClientBalance(data.id) + Quasar.Notify.create({type: 'positive', message: 'LP registered'}) + } else { + const body = this._cleanClientUpdate(d) + const {data} = await LNbits.api.request( + 'PUT', `${CLIENTS_PATH}/${d.id}`, null, body + ) + const idx = this.clients.findIndex(c => c.id === data.id) + if (idx >= 0) this.clients[idx] = data + Quasar.Notify.create({type: 'positive', message: 'LP updated'}) + } + this.clientDialog.show = false + } catch (e) { + this._notifyError(e, 'Save failed') + } finally { + this.clientDialog.saving = false + } + }, + + confirmDeleteClient(client) { + Quasar.Dialog.create({ + title: 'Delete LP?', + message: + `Remove ${client.username || this.shortId(client.user_id)} from this machine. ` + + 'Their existing deposits and payment history are preserved — only the registration row goes. Continue?', + html: true, + cancel: true, + persistent: true + }).onOk(async () => { + try { + await LNbits.api.request('DELETE', `${CLIENTS_PATH}/${client.id}`) + this.clients = this.clients.filter(c => c.id !== client.id) + delete this.clientBalances[client.id] + Quasar.Notify.create({type: 'positive', message: 'LP deleted'}) + } catch (e) { + this._notifyError(e, 'Delete failed') + } + }) + }, + + // ----------------------------------------------------------------- + // Deposits (P9d) + // ----------------------------------------------------------------- + openAddDepositDialog() { + this.depositDialog.mode = 'add' + this.depositDialog.data = this._emptyDepositForm() + this.depositDialog.show = true + }, + + openEditDepositDialog(deposit) { + this.depositDialog.mode = 'edit' + this.depositDialog.data = { + id: deposit.id, + client_id: deposit.client_id, + amount: deposit.amount, + notes: deposit.notes || '' + } + this.depositDialog.show = true + }, + + async submitDeposit() { + const d = this.depositDialog.data + this.depositDialog.saving = true + try { + if (this.depositDialog.mode === 'add') { + // machine_id is server-cross-checked but we send it explicitly. + // currency is server-resolved from the machine's fiat_code + // (#26); not in the request body. + const client = this.clients.find(c => c.id === d.client_id) + if (!client) throw new Error('client not found') + const body = { + client_id: d.client_id, + machine_id: client.machine_id, + amount: Number(d.amount), + notes: (d.notes || '').trim() || null + } + const {data} = await LNbits.api.request('POST', DEPOSITS_PATH, null, body) + this.deposits.unshift(data) + Quasar.Notify.create({type: 'positive', message: 'Deposit recorded'}) + } else { + const body = { + amount: Number(d.amount), + notes: (d.notes || '').trim() || null + } + const {data} = await LNbits.api.request( + 'PUT', `${DEPOSITS_PATH}/${d.id}`, null, body + ) + this._replaceDeposit(data) + Quasar.Notify.create({type: 'positive', message: 'Deposit updated'}) + } + this.depositDialog.show = false + } catch (e) { + this._notifyError(e, 'Save failed') + } finally { + this.depositDialog.saving = false + } + }, + + confirmDepositStatus(deposit, newStatus) { + const verb = newStatus === 'confirmed' ? 'Confirm' : 'Reject' + Quasar.Dialog.create({ + title: `${verb} deposit?`, + message: + newStatus === 'confirmed' + ? `Confirming will count this toward the LP's DCA balance.` + : `Rejecting marks it ignored; it won't affect balances.`, + cancel: true, + persistent: true + }).onOk(async () => { + try { + const {data} = await LNbits.api.request( + 'PUT', + `${DEPOSITS_PATH}/${deposit.id}/status`, + null, + {status: newStatus, notes: deposit.notes || null} + ) + this._replaceDeposit(data) + // Confirming changes the LP balance — refresh it. + if (newStatus === 'confirmed') { + await this._loadClientBalance(deposit.client_id) + } + Quasar.Notify.create({ + type: 'positive', + message: `Deposit ${newStatus}` + }) + } catch (e) { + this._notifyError(e, 'Status update failed') + } + }) + }, + + openRejectDepositDialog(deposit) { + this.rejectDepositDialog.deposit = deposit + this.rejectDepositDialog.notes = '' + this.rejectDepositDialog.show = true + }, + + async submitRejectDeposit() { + const d = this.rejectDepositDialog + d.saving = true + try { + const {data} = await LNbits.api.request( + 'PUT', + `${DEPOSITS_PATH}/${d.deposit.id}/status`, + null, + {status: 'rejected', notes: d.notes || null} + ) + this._replaceDeposit(data) + d.show = false + Quasar.Notify.create({type: 'positive', message: 'Deposit rejected'}) + } catch (e) { + this._notifyError(e, 'Reject failed') + } finally { + d.saving = false + } + }, + + confirmDeleteDeposit(deposit) { + Quasar.Dialog.create({ + title: 'Delete deposit?', + message: 'Only pending deposits can be deleted.', + cancel: true, + persistent: true + }).onOk(async () => { + try { + await LNbits.api.request('DELETE', `${DEPOSITS_PATH}/${deposit.id}`) + this.deposits = this.deposits.filter(x => x.id !== deposit.id) + Quasar.Notify.create({type: 'positive', message: 'Deposit deleted'}) + } catch (e) { + this._notifyError(e, 'Delete failed') + } + }) + }, + + _replaceDeposit(updated) { + if (!updated) return + const idx = this.deposits.findIndex(d => d.id === updated.id) + if (idx >= 0) this.deposits[idx] = updated + }, + + // ----------------------------------------------------------------- + // Commission splits editor (P9e) + // ----------------------------------------------------------------- + async loadCommissionSplits() { + const params = this.commissionScope + ? `?machine_id=${this.commissionScope}` + : '' + try { + const {data} = await LNbits.api.request( + 'GET', `${COMMISSION_SPLITS_PATH}${params}` + ) + // targetKind is a UI-only hint derived from the stored target string. + // It's not persisted server-side; the server resolves the target + // at payment time regardless. + this.commissionLegs = (data || []).map(leg => ({ + target: leg.target || '', + targetKind: this._inferTargetKind(leg.target), + label: leg.label || '', + fraction: Number(leg.fraction) || 0 + })) + } catch (e) { + this.commissionLegs = [] + this._notifyError(e, 'Failed to load commission splits') + } + }, + + _inferTargetKind(target) { + // If the value matches one of the operator's own wallet ids, render + // the row in 'wallet' mode (q-select). Otherwise treat as external + // (free-text q-input). + if (!target) return 'wallet' + const ownIds = new Set(this.walletOptions.map(w => w.value)) + return ownIds.has(target) ? 'wallet' : 'external' + }, + + addCommissionLeg() { + this.commissionLegs.push({ + target: this.walletOptions[0]?.value || '', + targetKind: 'wallet', + label: '', + fraction: 0 + }) + }, + + async saveCommissionSplits() { + if (!this.commissionSumValid) { + Quasar.Notify.create({ + type: 'negative', + message: 'Legs must sum to 100% before saving' + }) + return + } + const body = { + machine_id: this.commissionScope, + legs: this.commissionLegs.map((leg, idx) => ({ + target: (leg.target || '').toString().trim(), + label: leg.label || null, + fraction: Number(leg.fraction), + sort_order: idx + })) + } + this.commissionSaving = true + try { + await LNbits.api.request('PUT', COMMISSION_SPLITS_PATH, null, body) + await this.loadCommissionSplits() + Quasar.Notify.create({type: 'positive', message: 'Saved'}) + } catch (e) { + this._notifyError(e, 'Save failed') + } finally { + this.commissionSaving = false + } + }, + + confirmDeleteCommissionOverride() { + Quasar.Dialog.create({ + title: 'Remove per-machine override?', + message: + 'The default operator ruleset will apply to this machine again. ' + + 'No legs are deleted from your default.', + cancel: true, + persistent: true + }).onOk(async () => { + const params = `?machine_id=${this.commissionScope}` + try { + await LNbits.api.request( + 'DELETE', `${COMMISSION_SPLITS_PATH}${params}` + ) + await this.loadCommissionSplits() + Quasar.Notify.create({type: 'positive', message: 'Override removed'}) + } catch (e) { + this._notifyError(e, 'Remove failed') + } + }) + }, + + // ----------------------------------------------------------------- + // Settle balance (P3e — closes #4) + // ----------------------------------------------------------------- + async openSettleBalanceDialog(client) { + this.settleBalanceDialog.client = client + this.settleBalanceDialog.balance = this.clientBalances[client.id] || null + this.settleBalanceDialog.data = { + funding_wallet_id: null, + exchange_rate: null, + amount_fiat: null, + notes: '' + } + // Refresh balance to make sure we're showing the latest before settling. + await this._loadClientBalance(client.id) + this.settleBalanceDialog.balance = this.clientBalances[client.id] || null + this.settleBalanceDialog.show = true + }, + + async submitSettleBalance() { + const d = this.settleBalanceDialog + const body = { + funding_wallet_id: d.data.funding_wallet_id, + exchange_rate: Number(d.data.exchange_rate), + amount_fiat: d.data.amount_fiat ? Number(d.data.amount_fiat) : null, + notes: d.data.notes || null + } + d.saving = true + try { + await LNbits.api.request( + 'POST', + `${CLIENTS_PATH}/${d.client.id}/settle`, + null, + body + ) + // Refresh this client's balance so the table reflects the new remaining. + await this._loadClientBalance(d.client.id) + d.show = false + Quasar.Notify.create({type: 'positive', message: 'Balance settled'}) + } catch (e) { + this._notifyError(e, 'Settle failed') + } finally { + d.saving = false + } + }, + + _replaceSettlement(updated) { + if (!updated) return + const idx = this.machineDetail.settlements.findIndex( + s => s.id === updated.id + ) + if (idx >= 0) this.machineDetail.settlements[idx] = updated + }, + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + shortNpub(npub) { + if (!npub) return '' + if (npub.length <= 16) return npub + return npub.slice(0, 8) + '…' + npub.slice(-6) + }, + + shortId(id) { + if (!id) return '' + return id.length <= 12 ? id : id.slice(0, 8) + '…' + }, + + formatSats(n) { + if (n == null) return '—' + return Number(n).toLocaleString() + }, + + formatFiat(amount, code) { + if (amount == null) return '—' + return `${Number(amount).toFixed(2)} ${code || ''}`.trim() + }, + + formatTime(ts) { + if (!ts) return '' + const d = new Date(ts) + if (isNaN(d.getTime())) return String(ts) + return d.toLocaleString() + }, + + copy(text) { + if (!text) return + Quasar.copyToClipboard(text).then(() => { + Quasar.Notify.create({type: 'info', message: 'Copied', timeout: 800}) + }) + }, + + _emptyDepositForm() { + // currency is server-resolved from the selected client's machine + // fiat_code (see #26); not stored on the form, just displayed in + // the dialog via depositMachineFiatCode() computed. + return { + client_id: null, + amount: null, + notes: '' + } + }, + + _emptyClientForm() { + // Operator-side LP enrolment is just (machine, user, optional + // display name). Wallet / mode / autoforward are LP-controlled + // via satmachineclient — operator can't pick or change them. + return { + machine_id: null, + user_id: '', + username: '', + status: 'active' + } + }, + + _cleanClientCreate(d) { + return { + machine_id: d.machine_id, + user_id: (d.user_id || '').trim(), + username: (d.username || '').trim() || null + } + }, + + _cleanClientUpdate(d) { + return { + username: (d.username || '').trim() || null, + status: d.status + } + }, + + _emptyMachineForm() { + return { + machine_npub: '', + wallet_id: null, + name: '', + location: '', + fiat_code: 'GTQ', + operator_cash_in_fee_fraction: 0, + operator_cash_out_fee_fraction: 0 + } + }, + + _cleanMachineForm(d) { + return { + machine_npub: (d.machine_npub || '').trim(), + wallet_id: d.wallet_id, + name: (d.name || '').trim() || null, + location: (d.location || '').trim() || null, + fiat_code: (d.fiat_code || 'GTQ').trim(), + operator_cash_in_fee_fraction: Number(d.operator_cash_in_fee_fraction) || 0, + operator_cash_out_fee_fraction: Number(d.operator_cash_out_fee_fraction) || 0 + } + }, + + _notifyError(err, fallback) { + const msg = err?.response?.data?.detail || err?.message || fallback + Quasar.Notify.create({type: 'negative', message: msg, timeout: 5000}) } } }) diff --git a/tasks.py b/tasks.py index 0ed9efe..314c244 100644 --- a/tasks.py +++ b/tasks.py @@ -1,53 +1,490 @@ +# Satoshi Machine v2 — invoice listener (P1 + fix bundle 2). +# +# Subscribes to LNbits' invoice dispatcher (register_invoice_listener), then +# for each successful inbound payment: +# 1. Checks if wallet_id belongs to an active dca_machines row. If not, skip. +# 2. Verifies the originating Nostr signer matches the machine identity +# (assert_nostr_attribution; uses Payment.extra.nostr_sender_pubkey +# stamped by lnbits nostr-transport dispatcher). +# 3. Parses Payment.extra for bitSpire's canonical split stamp per +# aiolabs/lamassu-next#44 (`source: "bitspire"`, principal_sats, +# fee_sats, exchange_rate). Raises if the stamp is missing or +# garbage (no more Lamassu-era reverse-derivation fallback). +# 4. Computes the two-stage split (super_fee first, operator remainder). +# 5. Inserts a dca_settlements row idempotently (keyed by payment_hash). +# 6. Spawns the distribution processor on a background task so the +# LNbits invoice queue (which serves ALL extensions on the node) +# keeps draining while we move sats. Concurrency is safe because +# process_settlement now uses an optimistic-lock claim (fix bundle 1). +# +# Rejection paths (settlement still recorded with status='rejected' for +# operator forensics, but distribution is skipped): +# - SettlementAttributionError: signer mismatch (G5). +# - SettlementMetadataError: Payment.extra missing bitSpire stamp. +# - SettlementInvariantError: stamped values violate the canonical +# sat-amount invariants (range/sum). + import asyncio -from datetime import datetime from lnbits.core.models import Payment -from lnbits.core.services import websocket_updater from lnbits.tasks import register_invoice_listener from loguru import logger -from .transaction_processor import poll_lamassu_transactions +from .bitspire import ( + SettlementAttributionError, + SettlementInvariantError, + SettlementMetadataError, + assert_nostr_attribution, + parse_settlement, +) +from .crud import ( + create_settlement_idempotent, + get_active_machine_by_wallet_id, + get_super_config, +) +from .distribution import process_settlement +from .models import CreateDcaSettlementData, Machine -####################################### -########## RUN YOUR TASKS HERE ######## -####################################### +LISTENER_NAME = "ext_satmachineadmin" -# The usual task is to listen to invoices related to this extension +# Holds strong refs to in-flight distribution tasks so Python's GC doesn't +# collect them mid-flight (asyncio.create_task only weakly references its +# task once awaiters drop). Tasks self-clean by removing themselves on +# completion via the done_callback below. +_inflight_distributions: set = set() -async def wait_for_paid_invoices(): - """Invoice listener for DCA-related payments""" - invoice_queue = asyncio.Queue() - register_invoice_listener(invoice_queue, "ext_satmachineadmin") +async def wait_for_paid_invoices() -> None: + invoice_queue: asyncio.Queue = asyncio.Queue() + register_invoice_listener(invoice_queue, LISTENER_NAME) + logger.info( + "satmachineadmin v2: invoice listener registered as " + f"`{LISTENER_NAME}` — waiting for bitSpire settlements." + ) while True: - payment = await invoice_queue.get() - await on_invoice_paid(payment) + payment: Payment = await invoice_queue.get() + try: + await _handle_payment(payment) + except Exception as exc: # listener must never die + logger.error( + f"satmachineadmin: error handling payment " + f"{payment.payment_hash[:12]}...: {exc}" + ) -async def hourly_transaction_polling(): - """Background task that polls Lamassu database every hour for new transactions""" - logger.info("Starting hourly Lamassu transaction polling task") - +async def _handle_payment(payment: Payment) -> None: + if not payment.success: + return + machine = await get_active_machine_by_wallet_id(payment.wallet_id) + if machine is None: + return + extra = payment.extra or {} + + # 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: events +# published by each active machine's ATM on first boot (lamassu-next#56's +# bootstrap publish path). Decrypts the NIP-44 v2 content with the operator's +# privkey + ATM sender pubkey, validates as PublishCassettesPayload, and +# upserts cassette_configs via apply_bootstrap_state. +# +# v1 = one-shot per machine (ATM's meta.bootstrapPublishedAt makes the +# publish idempotent on ATM-side restart; satmachineadmin's apply_bootstrap_ +# state dedups on state_event_id for relay re-delivery). +# +# v2 (separate issue) = continuous reverse-channel consumer with a +# last_state_created_at watermark for reconciliation UI. +# +# Implementation: polls nostrclient.router.NostrRouter.received_subscription_ +# events keyed by our subscription_id. nostrclient's NostrRouter design is +# per-WebSocket-client; the singleton dict it drains into is the only +# server-side hook to consume events without standing up an in-process +# websocket. The relay manager is the same singleton publish_to_atm uses, +# so add_subscription registers a filter against the same relay pool. + +CASSETTE_BOOTSTRAP_SUB_ID = "satmachineadmin-cassette-bootstrap" +_CASSETTE_POLL_INTERVAL_S = 2.0 +_CASSETTE_BACKOFF_S = 30.0 # when nostrclient isn't installed yet + + +async def wait_for_cassette_state_events() -> None: + """Long-running task: subscribe to bitspire-cassettes-state events from + every active machine's ATM and upsert cassette_configs on receipt. + + Pattern mirrors wait_for_paid_invoices (try/except wraps each event, + never lets the loop die). Re-derives the subscription filter on each + tick from the current active-machines list — newly-added machines + start receiving bootstrap events without an LNbits restart. + + Soft-fail surfaces: + - nostrclient not installed → log + sleep _CASSETTE_BACKOFF_S + between retries (operator may install it later) + - inbound event fails sig-verify / decrypt / parse → log + skip + the event, continue the loop + - apply_bootstrap_state errors → log + skip + """ + logger.info( + "satmachineadmin v2: cassette bootstrap consumer starting " + f"(sub_id={CASSETTE_BOOTSTRAP_SUB_ID})" + ) + current_filter_key: str | None = None while True: try: - logger.info(f"Running Lamassu transaction poll at {datetime.now()}") - await poll_lamassu_transactions() - logger.info("Completed Lamassu transaction poll, sleeping for 1 hour") - - # Sleep for 1 hour (3600 seconds) - await asyncio.sleep(3600) - - except Exception as e: - logger.error(f"Error in hourly polling task: {e}") - # Sleep for 5 minutes before retrying on error - await asyncio.sleep(300) + current_filter_key = await _cassette_consumer_tick(current_filter_key) + await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S) + except _NostrclientUnavailable: + logger.warning( + "satmachineadmin: nostrclient extension not installed; " + f"cassette bootstrap consumer sleeping {_CASSETTE_BACKOFF_S}s " + "before retry. Install + activate nostrclient on this " + "LNbits instance." + ) + current_filter_key = None + await asyncio.sleep(_CASSETTE_BACKOFF_S) + except Exception as exc: # listener must never die + logger.error( + f"satmachineadmin: cassette consumer loop error (continuing): " f"{exc}" + ) + await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S) -async def on_invoice_paid(payment: Payment) -> None: - """Handle DCA-related invoice payments""" - # DCA payments are handled internally by the transaction processor - # This function can be extended if needed for additional payment processing - if payment.extra.get("tag") in ["dca_distribution", "dca_commission"]: - logger.info(f"DCA payment processed: {payment.checking_id} - {payment.amount} sats") - # Could add websocket notifications here if needed - pass +class _NostrclientUnavailable(Exception): + """Internal sentinel — nostrclient extension import failed. Caller + sleeps a backoff then retries; the operator may install nostrclient + at any time.""" + + +async def _cassette_consumer_tick(current_filter_key: str | None) -> str: + """Single iteration of the bootstrap-consumer loop. Returns the filter + key used this tick so the caller can detect filter-set changes. + + Raises _NostrclientUnavailable if nostrclient can't be imported (the + outer loop backs off + retries). + """ + try: + from nostrclient.router import ( # type: ignore[import-not-found] + NostrRouter, + nostr_client, + ) + except ImportError as exc: + raise _NostrclientUnavailable() from exc + + from .cassette_transport import build_state_d_tags_for_machines + from .crud import ( + apply_bootstrap_state, + get_machine_by_atm_pubkey_hex, + list_all_active_machines, + ) + + machines = await list_all_active_machines() + d_tags = build_state_d_tags_for_machines(machines) + filter_key = ",".join(sorted(d_tags)) + + if filter_key != current_filter_key: + if d_tags: + filters = [{"kinds": [30078], "#d": d_tags}] + # nostrclient's add_subscription is typed as list[str] but the + # actual relay protocol accepts list[Filter-dict] — type ignore + # the upstream typing mismatch. + nostr_client.relay_manager.add_subscription( + CASSETTE_BOOTSTRAP_SUB_ID, filters # type: ignore[arg-type] + ) + logger.info( + "satmachineadmin: (re)registered cassette bootstrap " + f"subscription with {len(d_tags)} d-tag(s)" + ) + else: + nostr_client.relay_manager.close_subscription(CASSETTE_BOOTSTRAP_SUB_ID) + logger.info( + "satmachineadmin: no active machines; closed cassette " + "bootstrap subscription" + ) + + inbound = NostrRouter.received_subscription_events.get(CASSETTE_BOOTSTRAP_SUB_ID) + if inbound: + while inbound: + event_message = inbound.pop(0) + try: + await _handle_cassette_state_event( + event_message, + get_machine_by_atm_pubkey_hex, + apply_bootstrap_state, + ) + except Exception as exc: + logger.warning( + f"satmachineadmin: cassette state event handler " + f"failed (skipping): {exc}" + ) + + return filter_key + + +async def _handle_cassette_state_event( + event_message, + get_machine_by_atm_pubkey_hex, + apply_bootstrap_state, +) -> None: + """Verify signature, resolve the operator's signer, decrypt via the + signer abstraction (bunker round-trip for RemoteBunkerSigner; direct + prvkey on the LocalSigner transitional fallback inside the transport + helper), parse, upsert. + + Each step logs at WARNING (not ERROR) so a noisy attacker can't fill + the logs — this is data on a public relay, garbage is expected. + + Two skip outcomes: + - Terminal (CassetteEventDecodeError / SignerUnavailable / + OperatorIdentityMissing / etc.): log + return. `apply_bootstrap_ + state` is never called → `state_event_id` is not advanced → + same event would re-process on next poll cycle but the consumer's + WARN log surfaces the underlying issue immediately. + - Transient (CassetteEventTransientError): log at INFO (less noisy) + + return. Same retry-via-no-advance semantics, just less + alarming in the operator log feed. + """ + import json as _json + from datetime import datetime as _datetime + from datetime import timezone as _timezone + + from lnbits.utils.nostr import verify_event + + from .cassette_transport import ( + CassetteEventDecodeError, + CassetteEventTransientError, + CassetteTransportError, + decrypt_and_parse_state_event, + ) + from .nostr_publish import resolve_operator_signer + + event_raw = event_message.event + if isinstance(event_raw, str): + event_obj = _json.loads(event_raw) + elif isinstance(event_raw, dict): + event_obj = event_raw + else: + logger.warning( + f"satmachineadmin: cassette event of unexpected type " + f"{type(event_raw).__name__}; skipping" + ) + return + + if not verify_event(event_obj): + logger.warning( + f"satmachineadmin: cassette state event sig verify failed " + f"(id={event_obj.get('id', '?')[:12]}...)" + ) + return + + sender_pubkey = event_obj.get("pubkey", "") + machine = await get_machine_by_atm_pubkey_hex(sender_pubkey) + if machine is None: + # Unknown sender — could be relay noise or an attacker. Don't + # treat as our problem. + logger.warning( + f"satmachineadmin: cassette state event from unknown ATM " + f"pubkey {sender_pubkey[:12]}... (not in dca_machines); " + "skipping" + ) + return + + try: + account, signer = await resolve_operator_signer(machine.operator_user_id) + except CassetteTransportError as exc: + # OperatorIdentityMissing / SignerUnavailable — log + skip. + logger.warning( + f"satmachineadmin: can't resolve signer for operator " + f"{machine.operator_user_id[:8]}... (machine {machine.id}): " + f"{exc}" + ) + return + + try: + payload = await decrypt_and_parse_state_event(event_obj, account, signer) + except CassetteEventTransientError as exc: + logger.info( + f"satmachineadmin: cassette state event for machine {machine.id} " + f"hit a transient signer error (will retry next poll): {exc}" + ) + return + except CassetteEventDecodeError as exc: + logger.warning( + f"satmachineadmin: cassette state event decode failed for " + f"machine {machine.id} (id={event_obj.get('id', '?')[:12]}...): " + f"{exc}" + ) + return + + event_id = event_obj.get("id", "") + created_at_unix = event_obj.get("created_at", 0) + event_created_at = _datetime.fromtimestamp(int(created_at_unix), tz=_timezone.utc) + + applied = await apply_bootstrap_state( + machine.id, event_id, event_created_at, payload + ) + if applied: + logger.info( + f"satmachineadmin: applied bootstrap state event {event_id[:12]}... " + f"to machine {machine.id} ({len(payload.positions)} cassettes)" + ) + else: + # Replay: event_id already on file. Normal on relay reconnect. + logger.debug( + f"satmachineadmin: cassette state event {event_id[:12]}... " + f"already applied to machine {machine.id} (replay no-op)" + ) diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 9c0f3a9..ffdb730 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -1,851 +1,1537 @@ - - - +{% extends "base.html" %} +{% from "macros.jinja" import window_vars with context %} -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} {% block page %} +{% block scripts %} + {{ window_vars(user) }} + +{% endblock %} + +{% block page %}

-
- - - -
-
-
DCA Deposit Management
-

Manage fiat deposits for existing DCA clients

-
-
-
-
+
- - - -
-
-
Registered DCA Clients
-

Clients registered via the DCA client extension

-
-
- Export to CSV -
-
- - - -
-
+ +
+
+

Satoshi Machine — Operator

+

+ Manage your bitSpire fleet, liquidity providers, and commission distribution. +

+
+
+ + Refresh all data + +
+
- - - -
Quick Add Deposit
-

Add a new deposit for an existing client

- -
- - - No DCA clients registered yet. Clients must first install and configure the DCA client extension. - -
- - -
+ + + + + LNbits platform fee: + cash-in ${ (superConfig.super_cash_in_fee_fraction * 100).toFixed(2) }% + · + cash-out ${ (superConfig.super_cash_out_fee_fraction * 100).toFixed(2) }% + of each transaction's principal. + + + Operator's per-machine fee rides on top of these. + + + + + + + + + + + + + ${ worklistCount } + + + + + + + + + + + + +
- -
-
- +
Your machines
+

+ Each ATM is paired with one dedicated wallet. Inbound payments to + that wallet trigger automatic distribution. +

Add Deposit + color="primary" icon="add" + label="Add machine" + @click="openAddMachineDialog">
-
-
- -
-
- - -
- - - -
-
-
Recent Deposits
-
-
- Export to CSV -
-
- - - -
-
- - - - -
-
-
Processed Lamassu Transactions
-

ATM transactions processed through DCA distribution

-
-
- Export to CSV -
-
- - - -
-
- -
- -
- - -
- {{SITE_TITLE}} DCA Admin Extension -
-

- Dollar Cost Averaging administration for Lamassu ATM integration.
- Manage client deposits and DCA distribution settings. -

-
- - - - - -
-
Active Clients:
-
${ dcaClients.filter(c => c.status === 'active').length }
-
-
-
Pending Deposits:
-
${ deposits.filter(d => d.status === 'pending').length }
-
-
-
Total DCA Balance:
-
${ formatCurrency(totalDcaBalance) }
-
-
-
- - - -
-

Database: ${ lamassuConfig.host }:${ lamassuConfig.port }/${ lamassuConfig.database_name }

-

Status: - Connected - Failed - Not tested -

-

Last Poll: ${ lamassuConfig.last_poll_time ? formatDateTime(lamassuConfig.last_poll_time) : 'Not yet run' }

-

Last Success: ${ lamassuConfig.last_successful_poll ? formatDateTime(lamassuConfig.last_successful_poll) : 'Never' }

-
-
-

Status: Not configured

-
- -
- - Configure Database - - - Test Connection - - - Manual Poll - - - Process specific transaction by ID (bypasses dispense checks) - Manual TX - -
-
-
- - {% include "satmachineadmin/_api_docs.html" %} -
-
-
-
- - - - - - - - - -
- Deposit for: ${ depositFormDialog.data.client_name } -
- - - -
- Update Deposit - Create Deposit - Cancel -
-
-
-
- - - - - - - -
Client Details
-
- - - - Username - ${ clientDetailsDialog.data.username } - - - - - User ID - ${ clientDetailsDialog.data.user_id } - - - - - Wallet ID - ${ clientDetailsDialog.data.wallet_id } - - - - - DCA Mode - ${ clientDetailsDialog.data.dca_mode } - - - - - Daily Limit - ${ formatCurrency(clientDetailsDialog.data.fixed_mode_daily_limit) } - - - - - Balance Summary - - Deposits: ${ formatCurrency(clientDetailsDialog.balance.total_deposits) } | - Payments: ${ formatCurrency(clientDetailsDialog.balance.total_payments) } | - Remaining: ${ formatCurrency(clientDetailsDialog.balance.remaining_balance) } - - - - -
-
- Close -
-
-
- - - - - - - -
Lamassu Database Configuration
- - - - - - - - - - - - - -
DCA Source Wallet
- - - - - - - -
DCA Client Limits
- - - - - -
SSH Tunnel (Recommended)
- -
- - Use SSH Tunnel -
- -
- - - - - - - - - - - + - SSH tunneling keeps your database secure by avoiding direct internet exposure. - The database connection will be routed through the SSH server. + You haven't registered any machines yet. Click Add machine to + register a bitSpire ATM by its Nostr npub. -
- - - - This configuration will be securely stored and used for hourly polling. - Only read access to the Lamassu database is required. - - -
- Save Configuration - Cancel -
-
-
-
- - - + + + + - - -
Transaction Distribution Details
+ + + + +
+
+
Liquidity providers
+

+ LPs receive proportional DCA distributions from your machines. + Balances reflect deposits less the sats they've been paid. +

+
+
+ +
+
-
- - - - Lamassu Transaction ID - ${ distributionDialog.transaction.lamassu_transaction_id } - - - - - Transaction Time - ${ formatDateTime(distributionDialog.transaction.transaction_time) } - - - - - Total Amount - - ${ formatCurrency(distributionDialog.transaction.fiat_amount) } - (${ formatSats(distributionDialog.transaction.crypto_amount) }) - - - - - - Commission - - ${ (distributionDialog.transaction.commission_percentage * 100).toFixed(1) }% - - (with ${ distributionDialog.transaction.discount }% discount = ${ (distributionDialog.transaction.effective_commission * 100).toFixed(1) }% effective) + + + Register at least one machine before adding LPs — an LP is scoped + to a specific machine. + + + + + No LPs yet. Use Register LP to add one at any of your machines. + + + + + + + +
+
+
Deposits
+

+ Record fiat handed in by LPs. Confirmed deposits increase the + LP's balance and feed proportional DCA distribution. +

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + Register at least one LP before recording deposits. + + + + + No deposits yet. Use Record deposit to log a new one. + + + + No deposits match the current filters. + + + + + +
+ +
+
+
Commission splits
+

+ After the LNbits platform fee is taken, the remainder is + distributed across the wallets you configure here. Per-machine + overrides take precedence over your default rules. +

+
+
+ +
+
+ +
+ + Default ruleset — applies to every machine without an + explicit override. + + + Per-machine override for + . + Empty/cleared rows fall back to the default. - = ${ formatSats(distributionDialog.transaction.commission_amount_sats) } - - - - - - Available for Distribution - ${ formatSats(distributionDialog.transaction.base_amount_sats) } - - - - - Total Distributed - ${ formatSats(distributionDialog.transaction.distributions_total_sats) } to ${ distributionDialog.transaction.clients_count } clients - - - -
- - - -
Client Distributions
- - - - +
+
-
- Close -
+ + +
+
+ Legs + + Sum: + + + + Must sum to 100% before saving + + +
+
+ +
+
+ + + + + No default rules. Without a default, all operator + commission stays in the machine wallet (audit visible). + + + No override for this machine. The default ruleset applies. + + + +
+
+
+ +
+
+ + Remove leg + +
+
+
+
+ + +
+
+ +
+
+ + + +
+
+
+ + + + Preview against + + sats operator commission → + + : + + + +
+ + + + + + +
+
+ +
+
+
Worklist
+

+ Settlements that didn't process cleanly. Errored ones need + retry; stuck ones may need force-reset (processor crashed + mid-flight). +

+
+
+ + +
+
+ + + + All clear — no errored or stuck settlements. + + +
+
+ + + +
+ + + +
+
+ + +
+
+
Reports
+

+ Client-side CSV exports of the data currently loaded in the + dashboard. For larger date ranges or server-side filters, + use the LNbits API directly. +

+
+
+
+
+ + +
Machines
+
+ rows +
+
+ + + +
+
+
+ + +
Clients (LPs)
+
+ rows, balances included +
+
+ + + +
+
+
+ + +
Deposits
+
+ rows +
+
+ + + +
+
+
+ + +
Payments (legs)
+
+ Distribution audit (dca / super_fee / operator_split / etc) +
+
+ + + +
+
+
+
+ + - - - - + + + + + + +
Add bitSpire machine
+ + +
+ +

+ Register an ATM by its Nostr public key. Choose the LNbits wallet that + will receive cash-out payments from this machine — settlements there + trigger the automatic distribution chain. +

- - -
Process Specific Transaction
+ - - -
- Use with caution: This bypasses all dispense status checks and will process the transaction even if dispense_confirmed is false. Only use this for manually settled transactions. -
-
+ - - - - + -
- This will: -
    -
  • Fetch the transaction from Lamassu regardless of dispense status
  • -
  • Process it through the normal DCA distribution flow
  • -
  • Credit the source wallet and distribute to clients
  • -
  • Send commission to the commission wallet (if configured)
  • -
-
+ -
+ + + + + + + + - Process Transaction - - - Cancel - -
-
-
-
+ color="primary" label="Add machine" + :loading="addMachineDialog.saving" + @click="submitAddMachine"> + +
+
+ + + + + + +
+ + + + Reload + + + Close + +
+ + +
+
+
npub
+ +
+
+
Wallet
+ +
+
+
Location
+ +
+
+ + + + + + + + + + + + +
+
+
Settlements
+

+ Every bitSpire transaction lands here. Click a row's menu for + retry / partial-dispense / notes. +

+
+
+ + + No settlements yet. They'll appear when bitSpire pays this machine's + wallet. + + + + + + +
+ + +
+
+
Cassettes
+

+ Per-cassette count and physical bay position. Denomination + set is hardware-determined (re-provision via atm-tui to + change). "Publish to ATM" encrypts + signs + sends the new + config to the machine via Nostr. +

+
+
+ + Discard unsaved edits + + +
+
+ + + + + + + + + Waiting for the ATM's bootstrap state event. Power on the ATM + and confirm it has reached the configured relay; cassette + rows will auto-populate on receipt. + + + + + + +
+
+
+
+
+ + + + + + + +
Publish cassette config to ATM
+ + +
+ + + + This publish will overwrite the ATM's currently-tracked + counts. If the ATM has dispensed cash since your last + refill or count baseline, those decrements will be lost. + Publish only after a physical refill (a known total), not to + "tweak" counts mid-day. v2 reconciliation will replace this + modal with reconciled state display. + +

Sending to ATM:

+ + + + + + + + + + + · count + + + + + +
+ + + + +
+
+ + + + + + + +
Apply partial dispense
+ + +
+ + + + Original gross: + . + Provide what was actually dispensed. Sat amounts will scale linearly, + the commission split will recompute, and distribution will re-run. + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + +
Add note to settlement
+ + +
+ +

+ Notes are append-only and timestamped. Use for reconciliation context, + off-LN refund records, dispute narrative, etc. +

+ +
+ + + + +
+
+ + + + + + + +
Platform fee (super-only)
+ + +
+ +

+ Charged on every transaction's principal across the LNbits + instance. Independent per direction. Each direction's total + (super + operator) is capped at 15%. Operators see these as a + read-only banner. Wallet ID is where the collected fee lands; + typically a wallet you (the super) own. +

+ + + +
+ + + + +
+
+ + + + + + + +
+ + +
+ + + + + + This LP hasn't onboarded via satmachineclient yet, so + their DCA wallet isn't configured. Ask them to open the + satmachineclient extension once and the deposit will be + accepted next time. + + + + + + + + + + + + +
+
+ + + + + + + +
Reject deposit
+ + +
+ +

+ The deposit will be marked rejected and won't count toward the LP's + balance. Optional reason for the audit trail. +

+ +
+ + + + +
+
+ + + + + + + +
+ + +
+ +

+ Enrol an LP at one of your machines. Wallet, DCA mode, and + autoforward are configured by the LP themselves via the + satmachineclient extension — you can't set them here. + Deposits are refused until the LP has registered. +

+ + + + + + + + +
+ + + + +
+
+ + + + + + + +
Settle LP balance
+ + +
+ + + + Pay the LP's remaining fiat balance in sats from your wallet at the + rate you choose. Useful to zero out small balances that would + otherwise shrink forever via proportional shares. + + +
+
Remaining balance
+
+
+ + + + + + + + +
+ + + + +
+
+ + + + + + + +
Edit machine
+ + +
+ + + + + + + + + + + + + +
+
+ +
{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..39fe975 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +""" +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) diff --git a/tests/test_calculations.py b/tests/test_calculations.py index 04262a0..b6010d7 100644 --- a/tests/test_calculations.py +++ b/tests/test_calculations.py @@ -1,114 +1,19 @@ """ -Tests for DCA transaction calculations using empirical data. +Tests for DCA transaction calculations. -These tests verify commission and distribution calculations against -real Lamassu transaction data to ensure the math is correct. +Covers the pure-function helpers that survive the 2026-05-26 cleanup: + - calculate_distribution (proportional split across LPs by balance) + +The previous test surface for `calculate_commission` and +`calculate_exchange_rate` was deleted alongside those functions — the +Lamassu-era reverse-derivation is obsolete now that bitSpire stamps +`principal_sats` and `fee_sats` directly on Payment.extra. + +Two-stage commission split tests live in `test_two_stage_split.py`. """ -import pytest -from decimal import Decimal -from typing import Dict, List, Tuple - # Import from the parent package (following lnurlp pattern) -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})" +from ..calculations import calculate_distribution # ============================================================================= @@ -157,7 +62,6 @@ class TestDistributionCalculation: def test_distribution_invariant_sums_to_total(self): """Total distributed sats must always equal base amount.""" - # Test with various client configurations test_cases = [ {"a": 100.0}, {"a": 100.0, "b": 100.0}, @@ -215,156 +119,6 @@ class TestDistributionCalculation: assert distributions == {} - def test_fiat_round_trip_invariant(self): - """ - Verify that distributed sats convert back to original fiat amount. - - The sum of each client's fiat equivalent should equal the original - fiat amount (within rounding tolerance). - """ - # Use real Lamassu transaction data - test_cases = [ - # (crypto_atoms, fiat_amount, commission_pct, discount, client_balances) - (309200, 2000.0, 0.0875, 0.0, {"a": 1000.0, "b": 1000.0}), - (309500, 2000.0, 0.055, 0.0, {"a": 500.0, "b": 1000.0, "c": 500.0}), - (292400, 2000.0, 0.055, 100.0, {"a": 800.0, "b": 1200.0}), - (115000, 800.0, 0.055, 90.0, {"a": 400.0, "b": 400.0}), - # Transaction that showed 0.01 GTQ rounding discrepancy with 4 clients - (205600, 1300.0, 0.055, 0.0, {"a": 1.0, "b": 986.0, "c": 14.0, "d": 4.0}), - ] - - for crypto_atoms, fiat_amount, comm_pct, discount, client_balances in test_cases: - # Calculate commission and base amount - base_sats, _, _ = calculate_commission(crypto_atoms, comm_pct, discount) - - # Calculate exchange rate - exchange_rate = calculate_exchange_rate(base_sats, fiat_amount) - - # Distribute sats to clients - distributions = calculate_distribution(base_sats, client_balances) - - # Convert each client's sats back to fiat - total_fiat_distributed = sum( - sats / exchange_rate for sats in distributions.values() - ) - - # Should equal original fiat amount (within small rounding tolerance) - assert abs(total_fiat_distributed - fiat_amount) < 0.01, \ - f"Fiat round-trip failed: {total_fiat_distributed:.2f} != {fiat_amount:.2f} " \ - f"(crypto={crypto_atoms}, comm={comm_pct}, discount={discount})" - - -# ============================================================================= -# EMPIRICAL END-TO-END TESTS -# ============================================================================= - -class TestEmpiricalTransactions: - """ - End-to-end tests using real Lamassu transaction data. - - Add your empirical test cases here! Each case should include: - - Transaction details (crypto_atoms, fiat, commission, discount) - - Client balances at time of transaction - - Expected distribution outcome - """ - - # TODO: Add your empirical data here - # Example structure: - EMPIRICAL_SCENARIOS = [ - { - "name": "real_tx_266800sats_two_equal_clients", - "transaction": { - "crypto_atoms": 266800, - "fiat_amount": 2000, - "commission_percentage": 0.03, - "discount": 0.0, - }, - "client_balances": { - "client_a": 1000.00, # 50% of total - "client_b": 1000.00, # 50% of total - }, - # 266800 / 1.03 = 259029 - "expected_base_sats": 259029, - "expected_commission_sats": 7771, - "expected_distributions": { - # 259029 / 2 = 129514.5 → both get 129514 or 129515 - # With banker's rounding: 129514.5 → 129514 (even) - # Remainder of 1 sat goes to first client by fractional sort - "client_a": 129515, - "client_b": 129514, - }, - }, - # Add more scenarios from your real data! - ] - - @pytest.mark.parametrize( - "scenario", - EMPIRICAL_SCENARIOS, - ids=[s["name"] for s in EMPIRICAL_SCENARIOS] - ) - def test_empirical_scenario(self, scenario): - """Test full transaction flow against empirical data.""" - tx = scenario["transaction"] - - # Calculate commission - base, commission, _ = calculate_commission( - tx["crypto_atoms"], - tx["commission_percentage"], - tx["discount"] - ) - - assert base == scenario["expected_base_sats"], \ - f"Base amount mismatch in {scenario['name']}" - assert commission == scenario["expected_commission_sats"], \ - f"Commission mismatch in {scenario['name']}" - - # Calculate distribution - distributions = calculate_distribution( - base, - scenario["client_balances"] - ) - - # Verify each client's allocation - for client_id, expected_sats in scenario["expected_distributions"].items(): - actual_sats = distributions.get(client_id, 0) - assert actual_sats == expected_sats, \ - f"Distribution mismatch for {client_id} in {scenario['name']}: " \ - f"got {actual_sats}, expected {expected_sats}" - - # Verify total distribution equals base - assert sum(distributions.values()) == base, \ - f"Total distribution doesn't match base in {scenario['name']}" - - -# ============================================================================= -# EDGE CASE TESTS -# ============================================================================= - -class TestEdgeCases: - """Tests for edge cases and boundary conditions.""" - - def test_minimum_amount_1_sat(self): - """Test with minimum possible amount (1 sat).""" - base, commission, _ = calculate_commission(1, 0.03, 0.0) - # With 3% commission on 1 sat, base rounds to 1, commission to 0 - assert base + commission == 1 - - def test_large_transaction(self): - """Test with large transaction (100 BTC worth of sats).""" - crypto_atoms = 10_000_000_000 # 100 BTC in sats - base, commission, _ = calculate_commission(crypto_atoms, 0.03, 0.0) - - assert base + commission == crypto_atoms - assert commission > 0 - - def test_100_percent_discount(self): - """100% discount should result in zero commission.""" - base, commission, effective = calculate_commission(100000, 0.03, 100.0) - - assert effective == 0.0 - assert commission == 0 - assert base == 100000 - def test_many_clients_distribution(self): """Test distribution with many clients.""" # 10 clients with varying balances diff --git a/tests/test_cassette_configs.py b/tests/test_cassette_configs.py new file mode 100644 index 0000000..08a7e00 --- /dev/null +++ b/tests/test_cassette_configs.py @@ -0,0 +1,220 @@ +""" +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 diff --git a/tests/test_cassette_state_consumer.py b/tests/test_cassette_state_consumer.py new file mode 100644 index 0000000..a0840bc --- /dev/null +++ b/tests/test_cassette_state_consumer.py @@ -0,0 +1,485 @@ +""" +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 `` 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}", + ] diff --git a/tests/test_collision_guard.py b/tests/test_collision_guard.py new file mode 100644 index 0000000..0f1a236 --- /dev/null +++ b/tests/test_collision_guard.py @@ -0,0 +1,124 @@ +""" +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 diff --git a/tests/test_deposit_currency.py b/tests/test_deposit_currency.py new file mode 100644 index 0000000..fd0be00 --- /dev/null +++ b/tests/test_deposit_currency.py @@ -0,0 +1,56 @@ +""" +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" diff --git a/tests/test_fee_cap_validation.py b/tests/test_fee_cap_validation.py new file mode 100644 index 0000000..25b4927 --- /dev/null +++ b/tests/test_fee_cap_validation.py @@ -0,0 +1,208 @@ +""" +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 diff --git a/tests/test_fee_mismatch_recording.py b/tests/test_fee_mismatch_recording.py new file mode 100644 index 0000000..7e5bb80 --- /dev/null +++ b/tests/test_fee_mismatch_recording.py @@ -0,0 +1,179 @@ +""" +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) diff --git a/tests/test_fee_publish_triggers.py b/tests/test_fee_publish_triggers.py new file mode 100644 index 0000000..650bd73 --- /dev/null +++ b/tests/test_fee_publish_triggers.py @@ -0,0 +1,391 @@ +""" +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 == [] diff --git a/tests/test_fee_transport.py b/tests/test_fee_transport.py new file mode 100644 index 0000000..58bbf03 --- /dev/null +++ b/tests/test_fee_transport.py @@ -0,0 +1,325 @@ +""" +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 diff --git a/tests/test_init.py b/tests/test_init.py deleted file mode 100644 index e2fc116..0000000 --- a/tests/test_init.py +++ /dev/null @@ -1,11 +0,0 @@ -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) diff --git a/tests/test_nip44_v2.py b/tests/test_nip44_v2.py new file mode 100644 index 0000000..31f996b --- /dev/null +++ b/tests/test_nip44_v2.py @@ -0,0 +1,390 @@ +""" +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"] diff --git a/tests/test_nostr_attribution.py b/tests/test_nostr_attribution.py new file mode 100644 index 0000000..34eec29 --- /dev/null +++ b/tests/test_nostr_attribution.py @@ -0,0 +1,111 @@ +""" +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) diff --git a/tests/test_operator_split_legs.py b/tests/test_operator_split_legs.py new file mode 100644 index 0000000..1e0bbca --- /dev/null +++ b/tests/test_operator_split_legs.py @@ -0,0 +1,150 @@ +""" +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 diff --git a/tests/test_principal_based_fees.py b/tests/test_principal_based_fees.py new file mode 100644 index 0000000..ebdd040 --- /dev/null +++ b/tests/test_principal_based_fees.py @@ -0,0 +1,270 @@ +""" +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 diff --git a/tests/test_roster_resolver.py b/tests/test_roster_resolver.py new file mode 100644 index 0000000..70eb2d3 --- /dev/null +++ b/tests/test_roster_resolver.py @@ -0,0 +1,163 @@ +""" +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 diff --git a/transaction_processor.py b/transaction_processor.py deleted file mode 100644 index 661e5ab..0000000 --- a/transaction_processor.py +++ /dev/null @@ -1,1274 +0,0 @@ -# Transaction processing and polling service for Lamassu ATM integration - -import asyncio -import asyncpg -from datetime import datetime, timedelta, timezone -from typing import List, Optional, Dict, Any -from loguru import logger -import socket -import threading -import time - -try: - import asyncssh - SSH_AVAILABLE = True -except ImportError: - try: - # Fallback to subprocess-based SSH tunnel - import subprocess - SSH_AVAILABLE = True - except ImportError: - SSH_AVAILABLE = False - logger.warning("SSH tunnel support not available") - -from lnbits.core.services import create_invoice, pay_invoice -from lnbits.core.crud.wallets import get_wallet -from lnbits.core.services import update_wallet_balance -from lnbits.settings import settings - -from .calculations import calculate_commission, calculate_distribution, calculate_exchange_rate -from .crud import ( - get_flow_mode_clients, - get_payments_by_lamassu_transaction, - create_dca_payment, - get_client_balance_summary, - get_active_lamassu_config, - update_config_test_result, - update_poll_start_time, - update_poll_success_time, - update_dca_payment_status, - create_lamassu_transaction, - update_lamassu_transaction_distribution_stats -) -from .models import CreateDcaPaymentData, LamassuTransaction, DcaClient, CreateLamassuTransactionData - - -class LamassuTransactionProcessor: - """Handles polling Lamassu database and processing transactions for DCA distribution""" - - def __init__(self): - self.last_check_time = None - self.processed_transaction_ids = set() - self.ssh_process = None - self.ssh_key_path = None - self.ssh_config_path = None - - async def get_db_config(self) -> Optional[Dict[str, Any]]: - """Get database configuration from the database""" - try: - config = await get_active_lamassu_config() - if not config: - logger.error("No active Lamassu database configuration found") - return None - - return { - "host": config.host, - "port": config.port, - "database": config.database_name, - "user": config.username, - "password": config.password, - "config_id": config.id, - "use_ssh_tunnel": config.use_ssh_tunnel, - "ssh_host": config.ssh_host, - "ssh_port": config.ssh_port, - "ssh_username": config.ssh_username, - "ssh_password": config.ssh_password, - "ssh_private_key": config.ssh_private_key - } - except Exception as e: - logger.error(f"Error getting database configuration: {e}") - return None - - def setup_ssh_tunnel(self, db_config: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Setup SSH tunnel if required and return modified connection config""" - if not db_config.get("use_ssh_tunnel"): - return db_config - - if not SSH_AVAILABLE: - logger.error("SSH tunnel requested but SSH libraries not available") - return None - - try: - # Close existing tunnel if any - self.close_ssh_tunnel() - - # Use subprocess-based SSH tunnel as fallback - return self._setup_subprocess_ssh_tunnel(db_config) - - except Exception as e: - logger.error(f"Failed to setup SSH tunnel: {e}") - self.close_ssh_tunnel() - return None - - def _setup_subprocess_ssh_tunnel(self, db_config: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Setup SSH tunnel using subprocess (compatible with all environments)""" - import subprocess - import socket - - # Find an available local port - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(('', 0)) - local_port = s.getsockname()[1] - - # Build SSH command - ssh_cmd = [ - "ssh", - "-N", # Don't execute remote command - "-L", f"{local_port}:{db_config['host']}:{db_config['port']}", - f"{db_config['ssh_username']}@{db_config['ssh_host']}", - "-p", str(db_config['ssh_port']), - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-o", "ConnectTimeout=10", - "-o", "ServerAliveInterval=60" - ] - - # Add authentication method - if db_config.get("ssh_password"): - # Check if sshpass is available for password authentication - try: - import subprocess - subprocess.run(["which", "sshpass"], check=True, capture_output=True) - ssh_cmd = ["sshpass", "-p", db_config["ssh_password"]] + ssh_cmd - except subprocess.CalledProcessError: - logger.error("Password authentication requires 'sshpass' tool which is not installed. Please use SSH key authentication instead.") - return None - elif db_config.get("ssh_private_key"): - # Write private key and SSH config to temporary files - import tempfile - import os - key_fd, key_path = tempfile.mkstemp(suffix='.pem') - config_fd, config_path = tempfile.mkstemp(suffix='.ssh_config') - try: - # Prepare key content with proper line endings and final newline - key_data = db_config["ssh_private_key"] - key_data = key_data.replace('\r\n', '\n').replace('\r', '\n') # Normalize line endings - if not key_data.endswith('\n'): - key_data += '\n' # Ensure newline at end of file - - with os.fdopen(key_fd, 'w', encoding='utf-8') as f: - f.write(key_data) - - os.chmod(key_path, 0o600) - - # Create temporary SSH config file with strict settings - ssh_config = f"""Host {db_config['ssh_host']} - HostName {db_config['ssh_host']} - Port {db_config['ssh_port']} - User {db_config['ssh_username']} - IdentityFile {key_path} - IdentitiesOnly yes - PasswordAuthentication no - PubkeyAuthentication yes - PreferredAuthentications publickey - NumberOfPasswordPrompts 0 - IdentityAgent none - ControlMaster no - ControlPath none - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - ConnectTimeout 10 - ServerAliveInterval 60 -""" - - with os.fdopen(config_fd, 'w', encoding='utf-8') as f: - f.write(ssh_config) - - os.chmod(config_path, 0o600) - - # Use the custom config file - ssh_cmd.extend([ - "-F", config_path, - db_config['ssh_host'] - ]) - print(ssh_cmd) - - self.ssh_key_path = key_path # Store for cleanup - self.ssh_config_path = config_path # Store for cleanup - except Exception as e: - os.unlink(key_path) - if 'config_path' in locals(): - os.unlink(config_path) - raise e - else: - logger.error("SSH tunnel requires either private key or password") - return None - - # Start SSH tunnel process - try: - self.ssh_process = subprocess.Popen( - ssh_cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL - ) - - # Wait a moment for tunnel to establish - import time - time.sleep(2) - - # Check if process is still running - if self.ssh_process.poll() is not None: - raise Exception("SSH tunnel process terminated immediately") - - logger.info(f"SSH tunnel established: localhost:{local_port} -> {db_config['ssh_host']}:{db_config['ssh_port']} -> {db_config['host']}:{db_config['port']}") - - # Return modified config to connect through tunnel - tunnel_config = db_config.copy() - tunnel_config["host"] = "127.0.0.1" - tunnel_config["port"] = local_port - - return tunnel_config - - except FileNotFoundError: - logger.error("SSH command not found. SSH tunneling requires ssh (and sshpass for password auth) to be installed on the system.") - return None - except Exception as e: - logger.error(f"Failed to establish SSH tunnel: {e}") - return None - - def close_ssh_tunnel(self): - """Close SSH tunnel if active""" - # Close subprocess-based tunnel - if hasattr(self, 'ssh_process') and self.ssh_process: - try: - self.ssh_process.terminate() - self.ssh_process.wait(timeout=5) - logger.info("SSH tunnel process closed") - except Exception as e: - logger.warning(f"Error closing SSH tunnel process: {e}") - try: - self.ssh_process.kill() - except: - pass - finally: - self.ssh_process = None - - # Clean up temporary key file if exists - if hasattr(self, 'ssh_key_path') and self.ssh_key_path: - try: - import os - os.unlink(self.ssh_key_path) - logger.info("SSH key file cleaned up") - except Exception as e: - logger.warning(f"Error cleaning up SSH key file: {e}") - finally: - self.ssh_key_path = None - - # Clean up temporary SSH config file if exists - if hasattr(self, 'ssh_config_path') and self.ssh_config_path: - try: - import os - os.unlink(self.ssh_config_path) - logger.info("SSH config file cleaned up") - except Exception as e: - logger.warning(f"Error cleaning up SSH config file: {e}") - finally: - self.ssh_config_path = None - - async def test_connection_detailed(self) -> Dict[str, Any]: - """Test connection with detailed step-by-step reporting""" - result = { - "success": False, - "message": "", - "steps": [], - "ssh_tunnel_used": False, - "ssh_tunnel_success": False, - "database_connection_success": False, - "config_id": None - } - - try: - # Step 1: Get configuration - result["steps"].append("Retrieving database configuration...") - db_config = await self.get_db_config() - if not db_config: - result["message"] = "No active Lamassu database configuration found" - result["steps"].append("❌ No configuration found") - return result - - result["config_id"] = db_config["config_id"] - result["steps"].append("✅ Configuration retrieved") - - # Step 2: SSH Tunnel setup (if required) - if db_config.get("use_ssh_tunnel"): - result["ssh_tunnel_used"] = True - result["steps"].append("Setting up SSH tunnel...") - - if not SSH_AVAILABLE: - result["message"] = "SSH tunnel required but SSH support not available" - result["steps"].append("❌ SSH support missing (requires ssh command line tool)") - return result - - connection_config = self.setup_ssh_tunnel(db_config) - if not connection_config: - result["message"] = "Failed to establish SSH tunnel" - result["steps"].append("❌ SSH tunnel failed - check SSH credentials and server accessibility") - return result - - result["ssh_tunnel_success"] = True - result["steps"].append(f"✅ SSH tunnel established to {db_config['ssh_host']}:{db_config['ssh_port']}") - else: - connection_config = db_config - result["steps"].append("ℹ️ Direct database connection (no SSH tunnel)") - - # Step 3: Test SSH-based database query - result["steps"].append("Testing database query via SSH...") - test_query = "SELECT 1 as test" - test_results = await self.execute_ssh_query(db_config, test_query) - - if not test_results: - result["message"] = "SSH connection succeeded but database query failed" - result["steps"].append("❌ Database query test failed") - return result - - result["database_connection_success"] = True - result["steps"].append("✅ Database query test successful") - - # Step 4: Test actual table access and check timezone - result["steps"].append("Testing access to cash_out_txs table...") - table_query = "SELECT COUNT(*) FROM cash_out_txs" - table_results = await self.execute_ssh_query(db_config, table_query) - - if not table_results: - result["message"] = "Connected but cash_out_txs table not accessible" - result["steps"].append("❌ Table access failed") - return result - - count = table_results[0].get('count', 0) - result["steps"].append(f"✅ Table access successful (found {count} transactions)") - - # Step 5: Check database timezone - result["steps"].append("Checking database timezone...") - timezone_query = "SELECT NOW() as db_time, EXTRACT(timezone FROM NOW()) as timezone_offset" - timezone_results = await self.execute_ssh_query(db_config, timezone_query) - - if timezone_results: - db_time = timezone_results[0].get('db_time', 'unknown') - timezone_offset = timezone_results[0].get('timezone_offset', 'unknown') - result["steps"].append(f"✅ Database time: {db_time} (offset: {timezone_offset})") - else: - result["steps"].append("⚠️ Could not determine database timezone") - - result["success"] = True - result["message"] = "All connection tests passed successfully" - - except Exception as e: - error_msg = str(e) - if "cash_out_txs" in error_msg: - result["message"] = "Connected to database but cash_out_txs table not found" - result["steps"].append("❌ Lamassu transaction table missing") - elif "ssh" in error_msg.lower() or "connection" in error_msg.lower(): - result["message"] = f"SSH connection error: {error_msg}" - result["steps"].append(f"❌ SSH error: {error_msg}") - elif "permission denied" in error_msg.lower() or "authentication" in error_msg.lower(): - result["message"] = f"SSH authentication failed: {error_msg}" - result["steps"].append(f"❌ SSH authentication error: {error_msg}") - else: - result["message"] = f"Connection test failed: {error_msg}" - result["steps"].append(f"❌ Unexpected error: {error_msg}") - - # Update test result in database - if result["config_id"]: - try: - await update_config_test_result(result["config_id"], result["success"]) - except Exception as e: - logger.warning(f"Could not update config test result: {e}") - - return result - - async def connect_to_lamassu_db(self) -> Optional[Dict[str, Any]]: - """Get database configuration (returns config dict instead of connection)""" - try: - db_config = await self.get_db_config() - if not db_config: - return None - - # Update test result on successful config retrieval - try: - await update_config_test_result(db_config["config_id"], True) - except Exception as e: - logger.warning(f"Could not update config test result: {e}") - - return db_config - except Exception as e: - logger.error(f"Failed to get database configuration: {e}") - return None - - async def execute_ssh_query(self, db_config: Dict[str, Any], query: str) -> List[Dict[str, Any]]: - """Execute a query via SSH connection""" - import subprocess - import json - import asyncio - - try: - # Build SSH command to execute the query - ssh_cmd = [ - "ssh", - f"{db_config['ssh_username']}@{db_config['ssh_host']}", - "-p", str(db_config['ssh_port']), - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR" - ] - - # Add key authentication if provided - if db_config.get("ssh_private_key"): - import tempfile - import os - key_fd, key_path = tempfile.mkstemp(suffix='.pem') - config_fd, config_path = tempfile.mkstemp(suffix='.ssh_config') - try: - # Prepare key content with proper line endings and final newline - key_data = db_config["ssh_private_key"] - key_data = key_data.replace('\r\n', '\n').replace('\r', '\n') # Normalize line endings - if not key_data.endswith('\n'): - key_data += '\n' # Ensure newline at end of file - - with os.fdopen(key_fd, 'w', encoding='utf-8') as f: - f.write(key_data) - os.chmod(key_path, 0o600) - - # Create temporary SSH config file with strict settings - ssh_config = f"""Host {db_config['ssh_host']} - HostName {db_config['ssh_host']} - Port {db_config['ssh_port']} - User {db_config['ssh_username']} - IdentityFile {key_path} - IdentitiesOnly yes - PasswordAuthentication no - PubkeyAuthentication yes - PreferredAuthentications publickey - NumberOfPasswordPrompts 0 - IdentityAgent none - ControlMaster no - ControlPath none - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - ConnectTimeout 10 - ServerAliveInterval 60 -""" - - with os.fdopen(config_fd, 'w', encoding='utf-8') as f: - f.write(ssh_config) - os.chmod(config_path, 0o600) - - # Use the custom config file - ssh_cmd = [ - "ssh", - "-F", config_path, - db_config['ssh_host'] - ] - - # Build the psql command to return JSON - psql_cmd = f"psql {db_config['database']} -t -c \"COPY ({query}) TO STDOUT WITH CSV HEADER\"" - ssh_cmd.append(psql_cmd) - - # Execute the command - process = await asyncio.create_subprocess_exec( - *ssh_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - - stdout, stderr = await process.communicate() - - if process.returncode != 0: - logger.error(f"SSH query failed: {stderr.decode()}") - return [] - - # Parse CSV output - import csv - import io - - csv_data = stdout.decode() - if not csv_data.strip(): - return [] - - reader = csv.DictReader(io.StringIO(csv_data)) - results = [] - for row in reader: - # Convert string values to appropriate types - processed_row = {} - for key, value in row.items(): - # Handle None/empty values consistently at data ingestion boundary - if value == '' or value is None: - if key in ['fiat_amount', 'crypto_amount']: - processed_row[key] = 0 # Default numeric fields to 0 - elif key in ['commission_percentage', 'discount']: - processed_row[key] = 0.0 # Default percentage fields to 0.0 - else: - processed_row[key] = None # Keep None for non-numeric fields - elif key in ['transaction_id', 'device_id', 'crypto_code', 'fiat_code']: - processed_row[key] = str(value) - elif key in ['fiat_amount', 'crypto_amount']: - try: - processed_row[key] = int(float(value)) - except (ValueError, TypeError): - processed_row[key] = 0 # Fallback to 0 for invalid values - elif key in ['commission_percentage', 'discount']: - try: - processed_row[key] = float(value) - except (ValueError, TypeError): - processed_row[key] = 0.0 # Fallback to 0.0 for invalid values - elif key == 'transaction_time': - from datetime import datetime - # Parse PostgreSQL timestamp format and ensure it's in UTC for consistency - # Handle formats like: '2025-07-04 23:12:42.627+00' (PostgreSQL format) - timestamp_str = value - - # Fix PostgreSQL timezone format: +00 -> +00:00 - if timestamp_str.endswith('+00'): - timestamp_str = timestamp_str + ':00' - elif timestamp_str.endswith('Z'): - timestamp_str = timestamp_str.replace('Z', '+00:00') - - try: - dt = datetime.fromisoformat(timestamp_str) - except ValueError as e: - logger.error(f"Failed to parse timestamp '{value}': {e}") - # Fallback to current time with warning - dt = datetime.now(timezone.utc) - logger.warning(f"Using current UTC time as fallback for invalid timestamp: {dt}") - - # Convert to UTC if not already - if dt.tzinfo is None: - # Assume UTC if no timezone info - dt = dt.replace(tzinfo=timezone.utc) - elif dt.tzinfo != timezone.utc: - # Convert to UTC - dt = dt.astimezone(timezone.utc) - processed_row[key] = dt - else: - processed_row[key] = value - results.append(processed_row) - - return results - - finally: - os.unlink(key_path) - if 'config_path' in locals(): - os.unlink(config_path) - - else: - logger.error("SSH private key required for database queries") - return [] - - except Exception as e: - logger.error(f"Error executing SSH query: {e}") - return [] - - async def fetch_transaction_by_id(self, db_config: Dict[str, Any], transaction_id: str) -> Optional[Dict[str, Any]]: - """Fetch a specific transaction by ID from Lamassu database, bypassing all status filters""" - try: - logger.info(f"Fetching transaction {transaction_id} from Lamassu database (bypass all filters)") - - # Query for specific transaction ID without any status/dispense filters - lamassu_query = f""" - SELECT - co.id as transaction_id, - co.fiat as fiat_amount, - co.crypto_atoms as crypto_amount, - co.confirmed_at as transaction_time, - co.device_id, - co.status, - co.commission_percentage, - co.discount, - co.crypto_code, - co.fiat_code, - co.dispense, - co.dispense_confirmed - FROM cash_out_txs co - WHERE co.id = '{transaction_id}' - """ - - results = await self.execute_ssh_query(db_config, lamassu_query) - - if not results: - logger.warning(f"Transaction {transaction_id} not found in Lamassu database") - return None - - transaction = results[0] - logger.info(f"Found transaction {transaction_id}: status={transaction.get('status')}, dispense={transaction.get('dispense')}, dispense_confirmed={transaction.get('dispense_confirmed')}") - return transaction - - except Exception as e: - logger.error(f"Error fetching transaction {transaction_id} from Lamassu database: {e}") - return None - - async def fetch_new_transactions(self, db_config: Dict[str, Any]) -> List[Dict[str, Any]]: - """Fetch new successful transactions from Lamassu database since last poll""" - try: - # Determine the time threshold based on last successful poll - config = await get_active_lamassu_config() - if config and config.last_successful_poll: - # Use last successful poll time - time_threshold = config.last_successful_poll - logger.info(f"Checking for transactions since last successful poll: {time_threshold}") - else: - # Fallback to last 24 hours for first run or if no previous poll - time_threshold = datetime.now(timezone.utc) - timedelta(hours=24) - logger.info(f"No previous poll found, checking last 24 hours since: {time_threshold}") - - # Convert to UTC if not already timezone-aware - if time_threshold.tzinfo is None: - time_threshold = time_threshold.replace(tzinfo=timezone.utc) - elif time_threshold.tzinfo != timezone.utc: - time_threshold = time_threshold.astimezone(timezone.utc) - - # Format as UTC for database query - time_threshold_str = time_threshold.strftime('%Y-%m-%d %H:%M:%S UTC') - - # First, get all transactions since the threshold from Lamassu database - # Filter out unconfirmed dispenses - # TODO: review - lamassu_query = f""" - SELECT - co.id as transaction_id, - co.fiat as fiat_amount, - co.crypto_atoms as crypto_amount, - co.confirmed_at as transaction_time, - co.device_id, - co.status, - co.commission_percentage, - co.discount, - co.crypto_code, - co.fiat_code - FROM cash_out_txs co - WHERE co.confirmed_at > '{time_threshold_str}' - AND co.status IN ('confirmed', 'authorized') - AND co.dispense = 't' - AND co.dispense_confirmed = 't' - ORDER BY co.confirmed_at ASC - """ - - all_transactions = await self.execute_ssh_query(db_config, lamassu_query) - - # Then filter out already processed transactions using our local database - from .crud import get_all_payments - processed_payments = await get_all_payments() - processed_transaction_ids = { - payment.lamassu_transaction_id - for payment in processed_payments - if payment.lamassu_transaction_id - } - - # Filter out already processed transactions - new_transactions = [ - tx for tx in all_transactions - if tx['transaction_id'] not in processed_transaction_ids - ] - - logger.info(f"Found {len(all_transactions)} total transactions since {time_threshold}, {len(new_transactions)} are new") - return new_transactions - - except Exception as e: - logger.error(f"Error fetching transactions from Lamassu database: {e}") - return [] - - async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> tuple[Dict[str, Any], int]: - """Calculate how much each Flow Mode client should receive. - - Returns: - tuple: (distributions dict, orphan_sats int) - - distributions: {client_id: {fiat_amount, sats_amount, exchange_rate}} - - orphan_sats: sats that couldn't be distributed due to sync mismatch - """ - try: - # Get all active Flow Mode clients - flow_clients = await get_flow_mode_clients() - - if not flow_clients: - logger.info("No Flow Mode clients found - skipping distribution") - return {}, 0 - - # Extract transaction details - guaranteed clean from data ingestion - crypto_atoms = transaction.get("crypto_amount", 0) # Total sats with commission baked in - fiat_amount = transaction.get("fiat_amount", 0) # Actual fiat dispensed (principal only) - commission_percentage = transaction.get("commission_percentage", 0.0) # Already stored as decimal (e.g., 0.045) - discount = transaction.get("discount", 0.0) # Discount percentage - transaction_time = transaction.get("transaction_time") # ATM transaction timestamp for temporal accuracy - - # Normalize transaction_time to UTC if present - if transaction_time is not None: - if transaction_time.tzinfo is None: - # Assume UTC if no timezone info - transaction_time = transaction_time.replace(tzinfo=timezone.utc) - logger.warning("Transaction time was timezone-naive, assuming UTC") - elif transaction_time.tzinfo != timezone.utc: - # Convert to UTC - original_tz = transaction_time.tzinfo - transaction_time = transaction_time.astimezone(timezone.utc) - logger.info(f"Converted transaction time from {original_tz} to UTC") - - # Validate required fields - if crypto_atoms is None: - logger.error(f"Missing crypto_amount in transaction: {transaction}") - return {}, 0 - if fiat_amount is None: - logger.error(f"Missing fiat_amount in transaction: {transaction}") - return {}, 0 - if commission_percentage is None: - logger.warning(f"Missing commission_percentage in transaction: {transaction}, defaulting to 0") - commission_percentage = 0.0 - if discount is None: - logger.info(f"Missing discount in transaction: {transaction}, defaulting to 0") - discount = 0.0 - if transaction_time is None: - logger.warning(f"Missing transaction_time in transaction: {transaction}") - # Could use current time as fallback, but this indicates a data issue - # transaction_time = datetime.now(timezone.utc) - - # Calculate commission split using the extracted pure function - base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission( - crypto_atoms, commission_percentage, discount - ) - - # Calculate exchange rate based on base amounts - exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount) - - logger.info(f"Transaction - Total crypto: {crypto_atoms} sats") - logger.info(f"Commission: {commission_percentage*100:.1f}% - {discount:.1f}% discount = {effective_commission*100:.1f}% effective ({commission_amount_sats} sats)") - logger.info(f"Base for DCA: {base_crypto_atoms} sats, Fiat dispensed: {fiat_amount}, Exchange rate: {exchange_rate:.2f} sats/fiat_unit") - if transaction_time: - logger.info(f"Calculating balances as of transaction time: {transaction_time}") - else: - logger.warning("No transaction time available - using current balances (may be inaccurate)") - - # Get balance summaries for all clients to calculate proportions - client_balances = {} - total_confirmed_deposits = 0 - - for client in flow_clients: - # Get balance as of the transaction time for temporal accuracy - balance = await get_client_balance_summary(client.id, as_of_time=transaction_time) - # Only include clients with positive remaining balance - # NOTE: This works for fiat amounts that use cents - if balance.remaining_balance >= 0.01: - client_balances[client.id] = balance.remaining_balance - total_confirmed_deposits += balance.remaining_balance - logger.debug(f"Client {client.id[:8]}... included with balance: {balance.remaining_balance:.2f} GTQ") - else: - logger.info(f"Client {client.id[:8]}... excluded - zero/negative balance: {balance.remaining_balance:.2f} GTQ") - - if total_confirmed_deposits == 0: - logger.info("No clients with remaining DCA balance - skipping distribution") - return {}, 0 - - # Detect sync mismatch: more money in ATM than tracked client balances - sync_mismatch = total_confirmed_deposits < fiat_amount - if sync_mismatch: - orphan_fiat = fiat_amount - total_confirmed_deposits - logger.warning( - f"Sync mismatch detected: tracked balances ({total_confirmed_deposits:.2f} GTQ) " - f"< transaction ({fiat_amount} GTQ). Orphan amount: {orphan_fiat:.2f} GTQ" - ) - - # Calculate distribution amounts - distributions = {} - - if sync_mismatch: - # SYNC MISMATCH MODE: Cap each client's allocation to their remaining fiat balance - # Each client gets sats equivalent to their full remaining balance - for client_id, client_balance in client_balances.items(): - # Calculate sats equivalent to this client's remaining fiat balance - client_sats_amount = round(client_balance * exchange_rate) - proportion = client_balance / total_confirmed_deposits - - # Calculate equivalent fiat value in GTQ for tracking purposes - client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.0 - - distributions[client_id] = { - "fiat_amount": client_fiat_amount, - "sats_amount": client_sats_amount, - "exchange_rate": exchange_rate - } - - logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount:.2f} GTQ, {proportion:.2%} share)") - - # Calculate orphan sats (difference between base amount and distributed) - total_distributed = sum(dist["sats_amount"] for dist in distributions.values()) - orphan_sats = base_crypto_atoms - total_distributed - logger.info( - f"Sync mismatch distribution: {total_distributed} sats to clients, " - f"{orphan_sats} sats orphaned (staying in source wallet)" - ) - else: - # NORMAL MODE: Proportional distribution based on transaction amount - sat_allocations = calculate_distribution(base_crypto_atoms, client_balances) - - if not sat_allocations: - logger.info("No allocations calculated - skipping distribution") - return {}, 0 - - # Build final distributions dict with additional tracking fields - for client_id, client_sats_amount in sat_allocations.items(): - # Calculate proportion for logging - proportion = client_balances[client_id] / total_confirmed_deposits - - # Calculate equivalent fiat value in GTQ for tracking purposes - client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.0 - - distributions[client_id] = { - "fiat_amount": client_fiat_amount, - "sats_amount": client_sats_amount, - "exchange_rate": exchange_rate - } - - logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount:.2f} GTQ, {proportion:.2%} share)") - - # Verification: ensure total distribution equals base amount - total_distributed = sum(dist["sats_amount"] for dist in distributions.values()) - if total_distributed != base_crypto_atoms: - logger.error(f"Distribution mismatch! Expected: {base_crypto_atoms} sats, Distributed: {total_distributed} sats") - raise ValueError(f"Satoshi distribution calculation error: {base_crypto_atoms} != {total_distributed}") - orphan_sats = 0 - - # Safety check: Re-verify all clients still have positive balances before finalizing distributions - # This prevents race conditions where balances changed during calculation - final_distributions = {} - for client_id, distribution in distributions.items(): - # Re-check current balance (without temporal filtering to get most recent state) - current_balance = await get_client_balance_summary(client_id) - if current_balance.remaining_balance > 0: - final_distributions[client_id] = distribution - logger.info(f"Client {client_id[:8]}... final balance check: {current_balance.remaining_balance:.2f} GTQ - APPROVED for {distribution['sats_amount']} sats") - else: - logger.warning(f"Client {client_id[:8]}... final balance check: {current_balance.remaining_balance:.2f} GTQ - REJECTED (negative balance)") - - if len(final_distributions) != len(distributions): - logger.warning(f"Rejected {len(distributions) - len(final_distributions)} clients due to negative balances during final check") - - # Recalculate proportions if some clients were rejected - if len(final_distributions) == 0: - logger.info("All clients rejected due to negative balances - no distributions") - return {}, orphan_sats - - # For simplicity, we'll still return the original distributions but log the warning - # In a production system, you might want to recalculate the entire distribution - logger.warning("Proceeding with original distribution despite balance warnings - manual review recommended") - - logger.info(f"Distribution verified: {total_distributed} sats distributed across {len(distributions)} clients (clients with positive allocations only)") - return distributions, orphan_sats - - except Exception as e: - logger.error(f"Error calculating distribution amounts: {e}") - return {}, 0 - - async def distribute_to_clients(self, transaction: Dict[str, Any], distributions: Dict[str, Dict[str, int]]) -> None: - """Send Bitcoin payments to DCA clients""" - try: - transaction_id = transaction["transaction_id"] - transaction_time = transaction.get("transaction_time") - - # Normalize transaction_time to UTC if present - if transaction_time is not None: - if transaction_time.tzinfo is None: - transaction_time = transaction_time.replace(tzinfo=timezone.utc) - elif transaction_time.tzinfo != timezone.utc: - transaction_time = transaction_time.astimezone(timezone.utc) - - for client_id, distribution in distributions.items(): - try: - # Get client info - flow_clients = await get_flow_mode_clients() - client = next((c for c in flow_clients if c.id == client_id), None) - - if not client: - logger.error(f"Client {client_id} not found") - continue - - # Final safety check: Verify client still has positive balance before payment - current_balance = await get_client_balance_summary(client_id) - if current_balance.remaining_balance <= 0: - logger.error(f"CRITICAL: Client {client_id[:8]}... has negative balance ({current_balance.remaining_balance:.2f} GTQ) - REFUSING payment of {distribution['sats_amount']} sats") - continue - - # Verify balance is sufficient for this distribution (round to 2 decimal places to match DECIMAL(10,2) precision) - fiat_equivalent = distribution["fiat_amount"] # Amount in GTQ - # Round both values to 2 decimal places to match database precision and avoid floating point comparison issues - balance_rounded = round(current_balance.remaining_balance, 2) - amount_rounded = round(fiat_equivalent, 2) - if balance_rounded < amount_rounded: - logger.error(f"CRITICAL: Client {client_id[:8]}... insufficient balance ({balance_rounded:.2f} < {amount_rounded:.2f} GTQ) - REFUSING payment") - continue - - logger.info(f"Client {client_id[:8]}... pre-payment balance check: {current_balance.remaining_balance:.2f} GTQ - SUFFICIENT for {fiat_equivalent:.2f} GTQ payment") - - # Create DCA payment record - payment_data = CreateDcaPaymentData( - client_id=client_id, - amount_sats=distribution["sats_amount"], - amount_fiat=distribution["fiat_amount"], # Amount in GTQ - exchange_rate=distribution["exchange_rate"], - transaction_type="flow", - lamassu_transaction_id=transaction_id, - transaction_time=transaction_time # Normalized UTC timestamp - ) - - # Record the payment in our database - dca_payment = await create_dca_payment(payment_data) - - # Send Bitcoin to client's wallet - success = await self.send_dca_payment(client, distribution, transaction_id) - if success: - # Update payment status to confirmed after successful payment - await self.update_payment_status(dca_payment.id, "confirmed") - logger.info(f"DCA payment sent to client {client_id[:8]}...: {distribution['sats_amount']} sats") - else: - # Update payment status to failed if payment failed - await self.update_payment_status(dca_payment.id, "failed") - logger.error(f"Failed to send DCA payment to client {client_id[:8]}...") - - except Exception as e: - logger.error(f"Error processing distribution for client {client_id}: {e}") - continue - - except Exception as e: - logger.error(f"Error distributing to clients: {e}") - - async def send_dca_payment(self, client: DcaClient, distribution: Dict[str, Any], lamassu_transaction_id: str) -> bool: - """Send Bitcoin payment to a DCA client's wallet""" - try: - # For now, we only support wallet_id payments (internal LNBits transfers) - target_wallet_id = client.wallet_id - amount_sats = distribution["sats_amount"] - amount_msat = amount_sats * 1000 # Convert sats to millisats - - # Validate the target wallet exists - target_wallet = await get_wallet(target_wallet_id) - if not target_wallet: - logger.error(f"Target wallet {target_wallet_id} not found for client {client.username or client.user_id}") - return False - - # Create descriptive memo with DCA metrics - fiat_amount_gtq = distribution.get("fiat_amount", 0.0) - exchange_rate = distribution.get("exchange_rate", 0) - - # Calculate cost basis (fiat per BTC) - if exchange_rate > 0: - # exchange_rate is sats per fiat unit, so convert to fiat per BTC - cost_basis_per_btc = 100_000_000 / exchange_rate # 100M sats = 1 BTC - memo = f"DCA: {amount_sats:,} sats • {fiat_amount_gtq:.2f} GTQ • Cost basis: {cost_basis_per_btc:,.2f} GTQ/BTC" - else: - memo = f"DCA: {amount_sats:,} sats • {fiat_amount_gtq:.2f} GTQ" - - # Create invoice in target wallet - extra={ - "tag": "dca_distribution", - "client_id": client.id, - "lamassu_transaction_id": lamassu_transaction_id, - "distribution_amount": amount_sats - } - new_payment = await create_invoice( - wallet_id=target_wallet.id, - amount=float(amount_sats), # LNBits create_invoice expects float - internal=True, # Internal transfer within LNBits - memo=memo, - extra=extra - ) - - if not new_payment: - logger.error(f"Failed to create invoice for client {client.username or client.user_id}") - return False - - # Pay the invoice from the DCA admin wallet (this extension's wallet) - # Get the admin wallet that manages DCA funds - admin_config = await get_active_lamassu_config() - if not admin_config: - logger.error("No active Lamassu config found - cannot determine source wallet") - return False - - if not admin_config.source_wallet_id: - logger.warning("DCA source wallet not configured - payment creation successful but not sent") - logger.info(f"Created invoice for {amount_sats} sats to client {client.username or client.user_id}") - logger.info(f"Invoice: {new_payment.bolt11}") - return True - - # Pay the invoice from the configured source wallet - try: - await pay_invoice( - payment_request=new_payment.bolt11, - wallet_id=admin_config.source_wallet_id, - description=memo, - extra=extra - ) - logger.info(f"DCA payment completed: {amount_sats} sats sent to {client.username or client.user_id}") - return True - except Exception as e: - logger.error(f"Failed to pay invoice for client {client.username or client.user_id}: {e}") - return False - - except Exception as e: - logger.error(f"Error sending DCA payment to client {client.username or client.user_id}: {e}") - return False - - async def credit_source_wallet(self, transaction: Dict[str, Any]) -> bool: - """Credit the source wallet with the full crypto_atoms amount from Lamassu transaction""" - try: - # Get the configuration to find source wallet - admin_config = await get_active_lamassu_config() - if not admin_config or not admin_config.source_wallet_id: - logger.error("No source wallet configured - cannot credit wallet") - return False - - crypto_atoms = transaction["crypto_amount"] # Full amount including commission - transaction_id = transaction["transaction_id"] - - # Get the source wallet object - source_wallet = await get_wallet(admin_config.source_wallet_id) - if not source_wallet: - logger.error(f"Source wallet {admin_config.source_wallet_id} not found") - return False - - # Credit the source wallet with the full crypto_atoms amount - await update_wallet_balance( - wallet=source_wallet, - amount=crypto_atoms # Function expects sats, not millisats - ) - - logger.info(f"Credited source wallet with {crypto_atoms} sats from transaction {transaction_id}") - return True - - except Exception as e: - logger.error(f"Error crediting source wallet for transaction {transaction.get('transaction_id', 'unknown')}: {e}") - return False - - async def update_payment_status(self, payment_id: str, status: str) -> None: - """Update the status of a DCA payment""" - try: - await update_dca_payment_status(payment_id, status) - logger.info(f"Updated payment {payment_id[:8]}... status to {status}") - except Exception as e: - logger.error(f"Error updating payment status for {payment_id}: {e}") - - async def store_lamassu_transaction(self, transaction: Dict[str, Any]) -> Optional[str]: - """Store the Lamassu transaction in our database for audit and UI""" - try: - # Extract transaction data - guaranteed clean from data ingestion boundary - crypto_atoms = transaction.get("crypto_amount", 0) - fiat_amount = transaction.get("fiat_amount", 0) - commission_percentage = transaction.get("commission_percentage", 0.0) - discount = transaction.get("discount", 0.0) - transaction_time = transaction.get("transaction_time") - - # Normalize transaction_time to UTC if present - if transaction_time is not None: - if transaction_time.tzinfo is None: - transaction_time = transaction_time.replace(tzinfo=timezone.utc) - elif transaction_time.tzinfo != timezone.utc: - transaction_time = transaction_time.astimezone(timezone.utc) - - # Calculate commission metrics using the extracted pure function - base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission( - crypto_atoms, commission_percentage, discount - ) - - # Calculate exchange rate - exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount) - - # Create transaction data with GTQ amounts - transaction_data = CreateLamassuTransactionData( - lamassu_transaction_id=transaction["transaction_id"], - fiat_amount=round(fiat_amount, 2), # Store GTQ with 2 decimal places - crypto_amount=crypto_atoms, - commission_percentage=commission_percentage, - discount=discount, - effective_commission=effective_commission, - commission_amount_sats=commission_amount_sats, - base_amount_sats=base_crypto_atoms, - exchange_rate=exchange_rate, - crypto_code=transaction.get("crypto_code", "BTC"), - fiat_code=transaction.get("fiat_code", "GTQ"), - device_id=transaction.get("device_id"), - transaction_time=transaction_time # Normalized UTC timestamp - ) - - # Store in database - stored_transaction = await create_lamassu_transaction(transaction_data) - logger.info(f"Stored Lamassu transaction {transaction['transaction_id']} in database") - return stored_transaction.id - - except Exception as e: - logger.error(f"Error storing Lamassu transaction {transaction.get('transaction_id', 'unknown')}: {e}") - return None - - async def send_commission_payment(self, transaction: Dict[str, Any], commission_amount_sats: int) -> bool: - """Send commission to the configured commission wallet""" - try: - # Get the configuration to find commission wallet - admin_config = await get_active_lamassu_config() - if not admin_config or not admin_config.commission_wallet_id: - logger.info("No commission wallet configured - commission remains in source wallet") - return True # Not an error, just no transfer needed - - if not admin_config.source_wallet_id: - logger.error("No source wallet configured - cannot send commission") - return False - - transaction_id = transaction["transaction_id"] - - # Create invoice in commission wallet with DCA metrics - fiat_amount = transaction.get("fiat_amount", 0) - commission_percentage = transaction.get("commission_percentage", 0) * 100 # Convert to percentage - discount = transaction.get("discount", 0.0) # Discount percentage - - # Calculate effective commission for display - if commission_percentage > 0: - effective_commission_percentage = commission_percentage * (100 - discount) / 100 - else: - effective_commission_percentage = 0.0 - - # Create detailed memo showing discount if applied - if discount > 0: - commission_memo = f"DCA Commission: {commission_amount_sats:,} sats • {commission_percentage:.1f}% - {discount:.1f}% discount = {effective_commission_percentage:.1f}% effective • {fiat_amount:,} GTQ transaction" - else: - commission_memo = f"DCA Commission: {commission_amount_sats:,} sats • {commission_percentage:.1f}% • {fiat_amount:,} GTQ transaction" - - commission_payment = await create_invoice( - wallet_id=admin_config.commission_wallet_id, - amount=float(commission_amount_sats), # LNbits create_invoice expects float - internal=True, - memo=commission_memo, - extra={ - "tag": "dca_commission", - "lamassu_transaction_id": transaction_id, - "commission_amount": commission_amount_sats - } - ) - - if not commission_payment: - logger.error(f"Failed to create commission invoice for transaction {transaction_id}") - return False - - # Pay the commission invoice from source wallet - await pay_invoice( - payment_request=commission_payment.bolt11, - wallet_id=admin_config.source_wallet_id, - description=commission_memo, - extra={ - "tag": "dca_commission_payment", - "lamassu_transaction_id": transaction_id - } - ) - - logger.info(f"Commission payment completed: {commission_amount_sats} sats sent to commission wallet for transaction {transaction_id}") - return True - - except Exception as e: - logger.error(f"Error sending commission payment for transaction {transaction.get('transaction_id', 'unknown')}: {e}") - return False - - async def process_transaction(self, transaction: Dict[str, Any]) -> None: - """Process a single transaction - calculate and distribute DCA payments""" - try: - transaction_id = transaction["transaction_id"] - - # Check if transaction already processed - existing_payments = await get_payments_by_lamassu_transaction(transaction_id) - if existing_payments: - logger.info(f"Transaction {transaction_id} already processed - skipping") - return - - logger.info(f"Processing new transaction: {transaction_id}") - - # First, credit the source wallet with the full transaction amount - credit_success = await self.credit_source_wallet(transaction) - if not credit_success: - logger.error(f"Failed to credit source wallet for transaction {transaction_id} - skipping distribution") - return - - # Store the transaction in our database for audit and UI - stored_transaction = await self.store_lamassu_transaction(transaction) - - # Calculate distribution amounts - distributions, orphan_sats = await self.calculate_distribution_amounts(transaction) - - if not distributions: - if orphan_sats > 0: - logger.warning( - f"No client distributions for transaction {transaction_id}, " - f"but {orphan_sats} orphan sats remain in source wallet" - ) - else: - logger.info(f"No distributions calculated for transaction {transaction_id}") - return - - # Calculate commission amount for sending to commission wallet - crypto_atoms = transaction.get("crypto_amount", 0) - commission_percentage = transaction.get("commission_percentage", 0.0) - discount = transaction.get("discount", 0.0) - - # Calculate commission amount using the extracted pure function - _, commission_amount_sats, _ = calculate_commission( - crypto_atoms, commission_percentage, discount - ) - - # Distribute to clients - await self.distribute_to_clients(transaction, distributions) - - # Send commission to commission wallet (if configured) - if commission_amount_sats > 0: - await self.send_commission_payment(transaction, commission_amount_sats) - - # Update distribution statistics in stored transaction - if stored_transaction: - clients_count = len(distributions) - distributions_total_sats = sum(dist["sats_amount"] for dist in distributions.values()) - await update_lamassu_transaction_distribution_stats( - stored_transaction, - clients_count, - distributions_total_sats - ) - - logger.info(f"Successfully processed transaction {transaction_id}") - - except Exception as e: - logger.error(f"Error processing transaction {transaction.get('transaction_id', 'unknown')}: {e}") - - async def poll_and_process(self) -> None: - """Main polling function - checks for new transactions and processes them""" - config_id = None - try: - logger.info("Starting Lamassu transaction polling...") - - # Get database configuration - db_config = await self.connect_to_lamassu_db() - if not db_config: - logger.error("Could not get Lamassu database configuration - skipping this poll") - return - - config_id = db_config["config_id"] - - # Record poll start time - await update_poll_start_time(config_id) - logger.info("Poll start time recorded") - - # Fetch new transactions via SSH - new_transactions = await self.fetch_new_transactions(db_config) - - # Process each transaction - transactions_processed = 0 - for transaction in new_transactions: - await self.process_transaction(transaction) - transactions_processed += 1 - - # Record successful poll completion - await update_poll_success_time(config_id) - logger.info(f"Completed processing {transactions_processed} transactions. Poll success time recorded.") - - except Exception as e: - logger.error(f"Error in polling cycle: {e}") - # Don't update success time on error, but poll start time remains as attempted - - -# Global processor instance -transaction_processor = LamassuTransactionProcessor() - - -async def poll_lamassu_transactions() -> None: - """Entry point for the polling task""" - await transaction_processor.poll_and_process() diff --git a/views.py b/views.py index 6532836..1061e9f 100644 --- a/views.py +++ b/views.py @@ -1,8 +1,10 @@ -# Description: DCA Admin page endpoints. +# Satoshi Machine v2 — page route. +# +# v2 is operator-installable (any LNbits user, not super-only). The super-only +# check in v1's index() is gone. Super-only controls (platform fee config) +# move to a dedicated API endpoint protected by check_super_user in P1. -from http import HTTPStatus - -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse from lnbits.core.models import User from lnbits.decorators import check_user_exists @@ -15,13 +17,9 @@ def satmachineadmin_renderer(): return template_renderer(["satmachineadmin/templates"]) -# DCA Admin page - Requires superuser access @satmachineadmin_generic_router.get("/", response_class=HTMLResponse) async def index(req: Request, user: User = Depends(check_user_exists)): - if not user.super_user: - raise HTTPException( - HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges." - ) return satmachineadmin_renderer().TemplateResponse( - "satmachineadmin/index.html", {"request": req, "user": user.json()} + "satmachineadmin/index.html", + {"request": req, "user": user.json()}, ) diff --git a/views_api.py b/views_api.py index eb38da8..3c4c6cc 100644 --- a/views_api.py +++ b/views_api.py @@ -1,570 +1,1099 @@ -# Description: This file contains the extensions API endpoints. +# Satoshi Machine v2 — operator API surface (P1b). +# +# All endpoints are operator-scoped via check_user_exists. Every query +# filters by the authenticated user's id so two operators on the same +# LNbits instance can never see each other's machines, settlements, or +# clients. The super-only platform-fee write endpoint lands in P2. from http import HTTPStatus -from typing import Optional -from fastapi import APIRouter, Depends, Request -from lnbits.core.crud import get_user -from lnbits.core.models import User, WalletTypeInfo -from lnbits.core.services import create_invoice -from lnbits.decorators import check_super_user -from starlette.exceptions import HTTPException +from fastapi import APIRouter, Depends, HTTPException +from lnbits.core.crud import get_wallet +from lnbits.core.crud.users import get_account_by_pubkey +from lnbits.core.models import User +from lnbits.decorators import check_super_user, check_user_exists +from lnbits.utils.nostr import normalize_public_key +from .calculations import MAX_FEE_FRACTION_PER_DIRECTION +from .cassette_transport import ( + CassetteTransportError, + OperatorIdentityMissing, + RelayUnavailable, + SignerUnavailable, + publish_to_atm, +) +from .fee_transport import publish_fee_config from .crud import ( - # DCA CRUD operations - get_dca_clients, - get_dca_client, - update_dca_client, - delete_dca_client, + append_settlement_note, + count_completed_legs_for_settlement, + create_dca_client, create_deposit, - get_all_deposits, + create_machine, + delete_dca_client, + delete_deposit, + delete_machine, + force_reset_stuck_settlement, + get_client_balance_summary, + get_commission_splits, + get_dca_client, + get_dca_clients_for_machine, + get_dca_clients_for_operator, get_deposit, + get_deposits_for_client, + get_deposits_for_operator, + get_effective_commission_splits, + get_machine, + get_machines_for_operator, + get_payments_for_operator, + get_settlement, + get_settlements_for_machine, + get_settlements_for_operator, + get_stuck_settlements_for_operator, + get_super_config, + list_all_active_machines, + list_cassette_configs_for_machine, + lp_is_onboarded, + replace_commission_splits, + reset_settlement_for_retry, + update_cassette_config, + update_dca_client, update_deposit, update_deposit_status, - delete_deposit, - get_client_balance_summary, - # Lamassu config CRUD operations - create_lamassu_config, - get_lamassu_config, - get_active_lamassu_config, - get_all_lamassu_configs, - update_lamassu_config, - update_config_test_result, - delete_lamassu_config, - # Lamassu transaction CRUD operations - get_all_lamassu_transactions, - get_lamassu_transaction, + update_machine, + update_super_config, +) +from .distribution import ( + apply_partial_dispense_and_redistribute, + process_settlement, + settle_lp_balance, ) from .models import ( - # DCA models - DcaClient, - UpdateDcaClientData, + AppendSettlementNoteData, + CassetteConfig, + ClientBalanceSummary, + CommissionSplit, + CreateDcaClientData, CreateDepositData, + CreateMachineData, + DcaClient, DcaDeposit, + DcaPayment, + DcaSettlement, + Machine, + PartialDispenseData, + PublishCassettesPayload, + SetCommissionSplitsData, + SettleBalanceData, + StuckSettlementsResponse, + SuperConfig, + UpdateDcaClientData, UpdateDepositData, UpdateDepositStatusData, - ClientBalanceSummary, - CreateLamassuConfigData, - LamassuConfig, - UpdateLamassuConfigData, - StoredLamassuTransaction, + UpdateMachineData, + UpdateSuperConfigData, + UpsertCassetteConfigData, ) satmachineadmin_api_router = APIRouter() -################################################### -################ DCA API ENDPOINTS ################ -################################################### - -# DCA Client Endpoints - - -@satmachineadmin_api_router.get("/api/v1/dca/clients") -async def api_get_dca_clients( - wallet: WalletTypeInfo = Depends(check_super_user), -) -> list[DcaClient]: - """Get all DCA clients""" - return await get_dca_clients() - - -@satmachineadmin_api_router.get("/api/v1/dca/clients/{client_id}") -async def api_get_dca_client( - client_id: str, - wallet: WalletTypeInfo = Depends(check_super_user), -) -> DcaClient: - """Get a specific DCA client""" - client = await get_dca_client(client_id) - if not client: +async def _assert_wallet_owned_by(wallet_id: str, user_id: str) -> None: + """Defence-in-depth: refuse to bind any DB row to a wallet the caller + doesn't own. Used on every endpoint that accepts a wallet_id from the + request body. The DB-side UNIQUE on dca_machines.wallet_id (m007) is a + second line of defence; this check is the primary gate.""" + wallet = await get_wallet(wallet_id) + if wallet is None or wallet.user != user_id: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found." - ) - return client - - -# Note: Client creation/update/delete will be handled by the DCA client extension -# Admin extension only reads existing clients and manages their deposits - - -@satmachineadmin_api_router.get("/api/v1/dca/clients/{client_id}/balance") -async def api_get_client_balance( - client_id: str, - wallet: WalletTypeInfo = Depends(check_super_user), -) -> ClientBalanceSummary: - """Get client balance summary""" - client = await get_dca_client(client_id) - if not client: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found." - ) - - return await get_client_balance_summary(client_id) - - -# DCA Deposit Endpoints - - -@satmachineadmin_api_router.get("/api/v1/dca/deposits") -async def api_get_deposits( - wallet: WalletTypeInfo = Depends(check_super_user), -) -> list[DcaDeposit]: - """Get all deposits""" - return await get_all_deposits() - - -@satmachineadmin_api_router.get("/api/v1/dca/deposits/{deposit_id}") -async def api_get_deposit( - deposit_id: str, - wallet: WalletTypeInfo = Depends(check_super_user), -) -> DcaDeposit: - """Get a specific deposit""" - deposit = await get_deposit(deposit_id) - if not deposit: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Deposit not found." - ) - return deposit - - -@satmachineadmin_api_router.post("/api/v1/dca/deposits", status_code=HTTPStatus.CREATED) -async def api_create_deposit( - data: CreateDepositData, - user: User = Depends(check_super_user), -) -> DcaDeposit: - """Create a new deposit""" - # Verify client exists - client = await get_dca_client(data.client_id) - if not client: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found." - ) - - return await create_deposit(data) - - -@satmachineadmin_api_router.put("/api/v1/dca/deposits/{deposit_id}/status") -async def api_update_deposit_status( - deposit_id: str, - data: UpdateDepositStatusData, - user: User = Depends(check_super_user), -) -> DcaDeposit: - """Update deposit status (e.g., confirm deposit)""" - deposit = await get_deposit(deposit_id) - if not deposit: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Deposit not found." - ) - - updated_deposit = await update_deposit_status(deposit_id, data) - if not updated_deposit: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to update deposit.", - ) - return updated_deposit - - -@satmachineadmin_api_router.put("/api/v1/dca/deposits/{deposit_id}") -async def api_update_deposit( - deposit_id: str, - data: UpdateDepositData, - user: User = Depends(check_super_user), -) -> DcaDeposit: - """Update deposit fields (amount, currency, notes). Only pending deposits can be edited.""" - deposit = await get_deposit(deposit_id) - if not deposit: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Deposit not found." - ) - - if deposit.status != "pending": - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Only pending deposits can be edited.", - ) - - updated_deposit = await update_deposit(deposit_id, data) - if not updated_deposit: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to update deposit.", - ) - return updated_deposit - - -@satmachineadmin_api_router.delete("/api/v1/dca/deposits/{deposit_id}") -async def api_delete_deposit( - deposit_id: str, - user: User = Depends(check_super_user), -): - """Delete a deposit. Only pending deposits (not yet inserted into the machine) can be deleted.""" - deposit = await get_deposit(deposit_id) - if not deposit: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Deposit not found." - ) - - if deposit.status != "pending": - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Only pending deposits can be deleted. Confirmed deposits have already been inserted into the machine.", - ) - - await delete_deposit(deposit_id) - return {"message": "Deposit deleted successfully"} - - -# Transaction Polling Endpoints - - -@satmachineadmin_api_router.post("/api/v1/dca/test-connection") -async def api_test_database_connection( - user: User = Depends(check_super_user), -): - """Test connection to Lamassu database with detailed reporting""" - try: - from .transaction_processor import transaction_processor - - # Use the detailed test method - result = await transaction_processor.test_connection_detailed() - return result - - except Exception as e: - return { - "success": False, - "message": f"Test connection error: {str(e)}", - "steps": [f"❌ Unexpected error: {str(e)}"], - "ssh_tunnel_used": False, - "ssh_tunnel_success": False, - "database_connection_success": False, - } - - -@satmachineadmin_api_router.post("/api/v1/dca/manual-poll") -async def api_manual_poll( - user: User = Depends(check_super_user), -): - """Manually trigger a poll of the Lamassu database""" - try: - from .transaction_processor import transaction_processor - from .crud import update_poll_start_time, update_poll_success_time - - # Get database configuration - db_config = await transaction_processor.connect_to_lamassu_db() - if not db_config: - raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail="Could not get Lamassu database configuration", - ) - - config_id = db_config["config_id"] - - # Record manual poll start time - await update_poll_start_time(config_id) - - # Fetch and process transactions via SSH - new_transactions = await transaction_processor.fetch_new_transactions(db_config) - - transactions_processed = 0 - for transaction in new_transactions: - await transaction_processor.process_transaction(transaction) - transactions_processed += 1 - - # Record successful manual poll completion - await update_poll_success_time(config_id) - - return { - "success": True, - "transactions_processed": transactions_processed, - "message": f"Processed {transactions_processed} new transactions since last poll", - } - - except Exception as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Error during manual poll: {str(e)}", + HTTPStatus.BAD_REQUEST, + "wallet_id is not owned by the authenticated operator", ) -@satmachineadmin_api_router.post("/api/v1/dca/process-transaction/{transaction_id}") -async def api_process_specific_transaction( - transaction_id: str, - user: User = Depends(check_super_user), -): +async def _assert_no_pubkey_collision(machine_npub: str) -> None: + """Defence-in-depth: refuse to register a machine whose npub matches + any LNbits operator account's pubkey. + + Such a collision causes lnbits' nostr-transport `auth.py:resolve_ + nostr_auth` to route inbound kind-21000 RPCs from the ATM directly + to that operator's wallet — works by coincidence, but breaks silently + the moment the operator's pubkey rotates (because the auto-account- + from-npub flow then fires for the ATM's now-orphaned npub, and the + invoice lands on a fresh auto-account wallet instead). Reproducer: + Greg's Sintra silent-drop on 2026-05-30T21:33Z. See + aiolabs/satmachineadmin#32 for the failure mode + this guard's + design rationale. + + Path B (`#20` roster-lookup) is the architectural fix at the + routing layer; this guard prevents new operators from inadvertently + setting up the collision in the first place. Two layers of defence. + + Idempotent on the same caller re-attempting machine creation with + the same npub (the second attempt hits the dca_machines.machine_npub + UNIQUE on m001, not this guard — they only collide with operator- + account pubkeys, not other machine npubs). """ - Manually process a specific Lamassu transaction by ID, bypassing all status filters. + canonical = normalize_public_key(machine_npub).lower() + matching = await get_account_by_pubkey(canonical) + if matching is not None: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + f"machine_npub {canonical[:12]}... collides with an " + f"existing LNbits operator account's pubkey. Registering " + "an ATM under this npub would silently route invoices via " + "a pubkey-collision dependency that breaks on operator " + "pubkey rotation. Use a fresh ATM keypair: lamassu-next " + "`provision-atm` regenerates one with `ATM_PRIVATE_KEY` " + "unset. See aiolabs/satmachineadmin#32." + ), + ) - This endpoint is useful for processing transactions that were manually settled - or had dispense issues but need to be included in DCA distribution. + +async def _assert_machine_fee_cap_safe( + operator_in: float, + operator_out: float, +) -> None: + """Reject create/update if (super_X + operator_X) > 0.15 for either + direction. Locked at 15% per coord-log §2026-06-01T07:22Z; defense in + depth — the bitspire consumer enforces the same cap on the wire-format + side (aiolabs/lamassu-next#57). + + Fetches the current super-config singleton to pair against the + candidate per-machine fractions. NULL super-config (uninitialised + instance) treats super contribution as 0 — the cap then degenerates + to a pure operator-fee check. """ - try: - from .transaction_processor import transaction_processor - from .crud import get_payments_by_lamassu_transaction - - # Get database configuration - db_config = await transaction_processor.connect_to_lamassu_db() - if not db_config: - raise HTTPException( - status_code=HTTPStatus.SERVICE_UNAVAILABLE, - detail="Could not get Lamassu database configuration", - ) - - # Check if transaction was already processed - existing_payments = await get_payments_by_lamassu_transaction(transaction_id) - if existing_payments: - return { - "success": False, - "already_processed": True, - "message": f"Transaction {transaction_id} was already processed with {len(existing_payments)} distributions", - "payment_count": len(existing_payments), - } - - # Fetch the specific transaction from Lamassu (bypassing all filters) - transaction = await transaction_processor.fetch_transaction_by_id(db_config, transaction_id) - - if not transaction: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Transaction {transaction_id} not found in Lamassu database", - ) - - # Process the transaction through normal DCA flow - await transaction_processor.process_transaction(transaction) - - return { - "success": True, - "message": f"Transaction {transaction_id} processed successfully", - "transaction_details": { - "transaction_id": transaction_id, - "status": transaction.get("status"), - "dispense": transaction.get("dispense"), - "dispense_confirmed": transaction.get("dispense_confirmed"), - "crypto_amount": transaction.get("crypto_amount"), - "fiat_amount": transaction.get("fiat_amount"), - }, - } - - except HTTPException: - raise - except Exception as e: + super_config = await get_super_config() + super_in = ( + float(super_config.super_cash_in_fee_fraction) if super_config else 0.0 + ) + super_out = ( + float(super_config.super_cash_out_fee_fraction) if super_config else 0.0 + ) + # Fields are stored as DECIMAL(10,4) and Pydantic validators round to + # 4 decimals on the way in, so the source-of-truth precision is 1e-4. + # Round the float-arithmetic sum to that precision before comparison so + # `0.10 + 0.05 = 0.15000000000000002` (IEEE 754) doesn't trip the cap. + total_in = round(super_in + operator_in, 4) + total_out = round(super_out + operator_out, 4) + if total_in > MAX_FEE_FRACTION_PER_DIRECTION: raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Error processing transaction {transaction_id}: {str(e)}", + HTTPStatus.BAD_REQUEST, + ( + f"cash-in fee cap exceeded: super {super_in:.4f} + operator " + f"{operator_in:.4f} = {total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" + ), + ) + if total_out > MAX_FEE_FRACTION_PER_DIRECTION: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + f"cash-out fee cap exceeded: super {super_out:.4f} + operator " + f"{operator_out:.4f} = {total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" + ), ) -# COMMENTED OUT FOR PRODUCTION - Test transaction endpoint disabled -# Uncomment only for development/debugging purposes -# -# @satmachineadmin_api_router.post("/api/v1/dca/test-transaction") -# async def api_test_transaction( -# user: User = Depends(check_super_user), -# crypto_atoms: int = 103, -# commission_percentage: float = 0.03, -# discount: float = 0.0, -# ) -> dict: -# """Test transaction processing with simulated Lamassu transaction data""" -# try: -# from .transaction_processor import transaction_processor -# import uuid -# from datetime import datetime, timezone -# -# # Create a mock transaction that mimics Lamassu database structure -# mock_transaction = { -# "transaction_id": str(uuid.uuid4())[:8], # Short ID for testing -# "crypto_amount": crypto_atoms, # Total sats including commission -# "fiat_amount": 100, # Mock fiat amount (100 centavos = 1 GTQ) -# "commission_percentage": commission_percentage, # Already as decimal -# "discount": discount, -# "transaction_time": datetime.now(timezone.utc), -# "crypto_code": "BTC", -# "fiat_code": "GTQ", -# "device_id": "test_device", -# "status": "confirmed", -# } -# -# # Process the mock transaction through the complete DCA flow -# await transaction_processor.process_transaction(mock_transaction) -# -# # Calculate commission for response -# if commission_percentage > 0: -# effective_commission = commission_percentage * (100 - discount) / 100 -# base_crypto_atoms = int(crypto_atoms / (1 + effective_commission)) -# commission_amount_sats = crypto_atoms - base_crypto_atoms -# else: -# base_crypto_atoms = crypto_atoms -# commission_amount_sats = 0 -# -# return { -# "success": True, -# "message": "Test transaction processed successfully", -# "transaction_details": { -# "transaction_id": mock_transaction["transaction_id"], -# "total_amount_sats": crypto_atoms, -# "base_amount_sats": base_crypto_atoms, -# "commission_amount_sats": commission_amount_sats, -# "commission_percentage": commission_percentage -# * 100, # Show as percentage -# "effective_commission": effective_commission * 100 -# if commission_percentage > 0 -# else 0, -# "discount": discount, -# }, -# } -# -# except Exception as e: -# raise HTTPException( -# status_code=HTTPStatus.INTERNAL_SERVER_ERROR, -# detail=f"Error processing test transaction: {str(e)}", -# ) +async def _assert_super_config_cap_safe( + new_super_in: float | None, + new_super_out: float | None, +) -> None: + """Reject super-config update if any active machine's + (new_super + operator) > 0.15 for either direction. Same cap policy + as _assert_machine_fee_cap_safe but checked across the fleet because + a super update affects every machine. + + `None` for a direction means "no change" — pulls the current value + from super-config so the cap check still runs against the resulting + post-update state. + """ + current = await get_super_config() + effective_in = ( + float(new_super_in) + if new_super_in is not None + else (float(current.super_cash_in_fee_fraction) if current else 0.0) + ) + effective_out = ( + float(new_super_out) + if new_super_out is not None + else (float(current.super_cash_out_fee_fraction) if current else 0.0) + ) + machines = await list_all_active_machines() + for m in machines: + op_in = float(m.operator_cash_in_fee_fraction) + op_out = float(m.operator_cash_out_fee_fraction) + # Round to DECIMAL(10,4) precision — see _assert_machine_fee_cap_safe + # for the IEEE 754 motivation. + total_in = round(effective_in + op_in, 4) + total_out = round(effective_out + op_out, 4) + if total_in > MAX_FEE_FRACTION_PER_DIRECTION: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + f"super cash-in fee {effective_in:.4f} would exceed cap " + f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " + f"+ operator {op_in:.4f} = " + f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" + ), + ) + if total_out > MAX_FEE_FRACTION_PER_DIRECTION: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + f"super cash-out fee {effective_out:.4f} would exceed cap " + f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " + f"+ operator {op_out:.4f} = " + f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" + ), + ) -# Lamassu Transaction Endpoints +# ============================================================================= +# Machines +# ============================================================================= -@satmachineadmin_api_router.get("/api/v1/dca/transactions") -async def api_get_lamassu_transactions( - wallet: WalletTypeInfo = Depends(check_super_user), -) -> list[StoredLamassuTransaction]: - """Get all processed Lamassu transactions""" - return await get_all_lamassu_transactions() +@satmachineadmin_api_router.post("/api/v1/dca/machines", response_model=Machine) +async def api_create_machine( + data: CreateMachineData, user: User = Depends(check_user_exists) +) -> Machine: + await _assert_wallet_owned_by(data.wallet_id, user.id) + await _assert_no_pubkey_collision(data.machine_npub) + await _assert_machine_fee_cap_safe( + data.operator_cash_in_fee_fraction, + data.operator_cash_out_fee_fraction, + ) + machine = await create_machine(user.id, data) + # Layer 2 (#39): publish initial fee config to the ATM so it can + # unblock past its `awaiting-fees` maintenance gate. Soft-fails on + # transport errors — machine creation has already succeeded. + super_config = await get_super_config() + if super_config is not None: + await publish_fee_config(machine, super_config, user.id) + return machine -@satmachineadmin_api_router.get("/api/v1/dca/transactions/{transaction_id}") -async def api_get_lamassu_transaction( - transaction_id: str, - wallet: WalletTypeInfo = Depends(check_super_user), -) -> StoredLamassuTransaction: - """Get a specific Lamassu transaction with details""" - transaction = await get_lamassu_transaction(transaction_id) - if not transaction: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Lamassu transaction not found." - ) - return transaction +@satmachineadmin_api_router.get("/api/v1/dca/machines", response_model=list[Machine]) +async def api_list_machines( + user: User = Depends(check_user_exists), +) -> list[Machine]: + return await get_machines_for_operator(user.id) @satmachineadmin_api_router.get( - "/api/v1/dca/transactions/{transaction_id}/distributions" + "/api/v1/dca/machines/{machine_id}", response_model=Machine ) -async def api_get_transaction_distributions( - transaction_id: str, - wallet: WalletTypeInfo = Depends(check_super_user), -) -> list[dict]: - """Get distribution details for a specific Lamassu transaction""" - # Get the stored transaction - transaction = await get_lamassu_transaction(transaction_id) - if not transaction: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Lamassu transaction not found." +async def api_get_machine( + machine_id: str, user: User = Depends(check_user_exists) +) -> Machine: + machine = await get_machine(machine_id) + if machine is None or machine.operator_user_id != user.id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") + return machine + + +@satmachineadmin_api_router.put( + "/api/v1/dca/machines/{machine_id}", response_model=Machine +) +async def api_update_machine( + machine_id: str, + data: UpdateMachineData, + user: User = Depends(check_user_exists), +) -> Machine: + machine = await get_machine(machine_id) + if machine is None or machine.operator_user_id != user.id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") + if data.wallet_id is not None: + await _assert_wallet_owned_by(data.wallet_id, user.id) + # Cap check against post-update state — partial PATCH semantics: + # unset directional fields keep the machine's current value. + if ( + data.operator_cash_in_fee_fraction is not None + or data.operator_cash_out_fee_fraction is not None + ): + candidate_in = ( + data.operator_cash_in_fee_fraction + if data.operator_cash_in_fee_fraction is not None + else float(machine.operator_cash_in_fee_fraction) ) + candidate_out = ( + data.operator_cash_out_fee_fraction + if data.operator_cash_out_fee_fraction is not None + else float(machine.operator_cash_out_fee_fraction) + ) + await _assert_machine_fee_cap_safe(candidate_in, candidate_out) + updated = await update_machine(machine_id, data) + if updated is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") + # Layer 2 (#39): if either operator fee fraction changed, publish a + # fresh kind-30078 to the ATM so it picks up the new total. Skip + # otherwise — name/location/wallet_id/is_active edits don't change + # the fee model the ATM enforces. + fees_changed = ( + data.operator_cash_in_fee_fraction is not None + or data.operator_cash_out_fee_fraction is not None + ) + if fees_changed: + super_config = await get_super_config() + if super_config is not None: + await publish_fee_config(updated, super_config, user.id) + return updated - # Get all DCA payments for this Lamassu transaction - from .crud import get_payments_by_lamassu_transaction, get_dca_client - payments = await get_payments_by_lamassu_transaction( - transaction.lamassu_transaction_id +@satmachineadmin_api_router.delete( + "/api/v1/dca/machines/{machine_id}", status_code=HTTPStatus.NO_CONTENT +) +async def api_delete_machine( + machine_id: str, user: User = Depends(check_user_exists) +) -> None: + machine = await get_machine(machine_id) + if machine is None or machine.operator_user_id != user.id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") + await delete_machine(machine_id) + + +# ============================================================================= +# DCA Clients (LPs) — scoped per (machine, user). +# ============================================================================= + + +async def _machine_owned_by(machine_id: str, user_id: str) -> Machine: + """Lookup-with-ownership guard. 404 (not 403) so operators can't probe + for other operators' machines.""" + machine = await get_machine(machine_id) + if machine is None or machine.operator_user_id != user_id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") + return machine + + +async def _client_owned_by(client_id: str, user_id: str) -> DcaClient: + """Lookup-with-ownership guard for an LP record; ownership is checked + transitively via the client's machine. 404 if either doesn't match.""" + client = await get_dca_client(client_id) + if client is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found") + machine = await get_machine(client.machine_id) + if machine is None or machine.operator_user_id != user_id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found") + return client + + +@satmachineadmin_api_router.post("/api/v1/dca/clients", response_model=DcaClient) +async def api_create_client( + data: CreateDcaClientData, user: User = Depends(check_user_exists) +) -> DcaClient: + # Operator can only register LPs on machines they own. + await _machine_owned_by(data.machine_id, user.id) + return await create_dca_client(data) + + +@satmachineadmin_api_router.get("/api/v1/dca/clients", response_model=list[DcaClient]) +async def api_list_clients( + machine_id: str | None = None, + user: User = Depends(check_user_exists), +) -> list[DcaClient]: + """List the operator's LPs. Without ?machine_id, returns all LPs across + the operator's fleet. With ?machine_id, scoped to that machine (with + ownership check).""" + if machine_id is None: + return await get_dca_clients_for_operator(user.id) + await _machine_owned_by(machine_id, user.id) + return await get_dca_clients_for_machine(machine_id) + + +@satmachineadmin_api_router.get( + "/api/v1/dca/clients/{client_id}", response_model=DcaClient +) +async def api_get_client( + client_id: str, user: User = Depends(check_user_exists) +) -> DcaClient: + return await _client_owned_by(client_id, user.id) + + +@satmachineadmin_api_router.put( + "/api/v1/dca/clients/{client_id}", response_model=DcaClient +) +async def api_update_client( + client_id: str, + data: UpdateDcaClientData, + user: User = Depends(check_user_exists), +) -> DcaClient: + await _client_owned_by(client_id, user.id) + updated = await update_dca_client(client_id, data) + if updated is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found") + return updated + + +@satmachineadmin_api_router.delete( + "/api/v1/dca/clients/{client_id}", status_code=HTTPStatus.NO_CONTENT +) +async def api_delete_client( + client_id: str, user: User = Depends(check_user_exists) +) -> None: + await _client_owned_by(client_id, user.id) + await delete_dca_client(client_id) + + +@satmachineadmin_api_router.get( + "/api/v1/dca/clients/{client_id}/balance", + response_model=ClientBalanceSummary, +) +async def api_get_client_balance( + client_id: str, user: User = Depends(check_user_exists) +) -> ClientBalanceSummary: + await _client_owned_by(client_id, user.id) + summary = await get_client_balance_summary(client_id) + if summary is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found") + return summary + + +@satmachineadmin_api_router.post( + "/api/v1/dca/clients/{client_id}/settle", response_model=DcaPayment +) +async def api_settle_client_balance( + client_id: str, + data: SettleBalanceData, + user: User = Depends(check_user_exists), +) -> DcaPayment: + """Operator UX — closes satmachineadmin#4. + + Settle an LP's remaining fiat balance from the operator's chosen funding + wallet at the specified exchange rate. The amount_fiat is capped at the + LP's remaining balance; if omitted, settles the full remaining. + + Use case: avoid the Zeno's-paradox of vanishing tiny shares for small + remaining balances. Operator hits 'Settle' on the LP, gets to specify + the rate, and the system pays out the rest in sats from their wallet. + """ + client = await _client_owned_by(client_id, user.id) + machine = await _machine_owned_by(client.machine_id, user.id) + # Verify the operator owns the funding wallet. + funding_wallet = await get_wallet(data.funding_wallet_id) + if funding_wallet is None or funding_wallet.user != user.id: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "funding_wallet_id is not owned by the authenticated operator", + ) + try: + return await settle_lp_balance(client, machine, data) + except ValueError as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc + + +# ============================================================================= +# Deposits — operator records fiat handed in by an LP at a machine. +# ============================================================================= + + +async def _deposit_owned_by(deposit_id: str, user_id: str) -> DcaDeposit: + deposit = await get_deposit(deposit_id) + if deposit is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Deposit not found") + machine = await get_machine(deposit.machine_id) + if machine is None or machine.operator_user_id != user_id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Deposit not found") + return deposit + + +@satmachineadmin_api_router.post("/api/v1/dca/deposits", response_model=DcaDeposit) +async def api_create_deposit( + data: CreateDepositData, user: User = Depends(check_user_exists) +) -> DcaDeposit: + # Verify the (client_id, machine_id) pair belongs to the operator. + client = await _client_owned_by(data.client_id, user.id) + if client.machine_id != data.machine_id: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "client_id and machine_id refer to different machines", + ) + # Gate: refuse deposits for an LP who hasn't onboarded via + # satmachineclient. Without a dca_lp row we don't know where to + # send their DCA distributions, so accepting fiat against them + # would just queue up sats with nowhere to go. Forces the LP to + # actively register before any economic activity accrues. + if not await lp_is_onboarded(client.user_id): + raise HTTPException( + HTTPStatus.UNPROCESSABLE_ENTITY, + "LP has not onboarded yet — they must register via " + "satmachineclient and select a DCA wallet before deposits " + "can be recorded against them.", + ) + # Currency is bound to the machine, not operator-choosable. Resolve + # it server-side so an operator with a UI bug / curl mistake / older + # client can't poison the LP balance with the wrong unit + # (aiolabs/satmachineadmin#26). + machine = await get_machine(data.machine_id) + if machine is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") + return await create_deposit(user.id, data, currency=machine.fiat_code) + + +@satmachineadmin_api_router.get("/api/v1/dca/deposits", response_model=list[DcaDeposit]) +async def api_list_deposits( + client_id: str | None = None, + user: User = Depends(check_user_exists), +) -> list[DcaDeposit]: + """Operator's deposits across all their machines; ?client_id scopes to + a single LP (with ownership check).""" + if client_id is not None: + await _client_owned_by(client_id, user.id) + return await get_deposits_for_client(client_id) + return await get_deposits_for_operator(user.id) + + +@satmachineadmin_api_router.get( + "/api/v1/dca/deposits/{deposit_id}", response_model=DcaDeposit +) +async def api_get_deposit( + deposit_id: str, user: User = Depends(check_user_exists) +) -> DcaDeposit: + return await _deposit_owned_by(deposit_id, user.id) + + +@satmachineadmin_api_router.put( + "/api/v1/dca/deposits/{deposit_id}", response_model=DcaDeposit +) +async def api_update_deposit( + deposit_id: str, + data: UpdateDepositData, + user: User = Depends(check_user_exists), +) -> DcaDeposit: + existing = await _deposit_owned_by(deposit_id, user.id) + if existing.status != "pending": + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Only pending deposits can be edited", + ) + updated = await update_deposit(deposit_id, data) + if updated is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Deposit not found") + return updated + + +@satmachineadmin_api_router.put( + "/api/v1/dca/deposits/{deposit_id}/status", response_model=DcaDeposit +) +async def api_update_deposit_status( + deposit_id: str, + data: UpdateDepositStatusData, + user: User = Depends(check_user_exists), +) -> DcaDeposit: + await _deposit_owned_by(deposit_id, user.id) + updated = await update_deposit_status(deposit_id, data) + if updated is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Deposit not found") + return updated + + +@satmachineadmin_api_router.delete( + "/api/v1/dca/deposits/{deposit_id}", status_code=HTTPStatus.NO_CONTENT +) +async def api_delete_deposit( + deposit_id: str, user: User = Depends(check_user_exists) +) -> None: + existing = await _deposit_owned_by(deposit_id, user.id) + if existing.status != "pending": + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Only pending deposits can be deleted", + ) + await delete_deposit(deposit_id) + + +# ============================================================================= +# Settlements (read-only at this phase; landing happens in tasks.py) +# ============================================================================= + + +@satmachineadmin_api_router.get( + "/api/v1/dca/settlements", response_model=list[DcaSettlement] +) +async def api_list_settlements( + user: User = Depends(check_user_exists), +) -> list[DcaSettlement]: + return await get_settlements_for_operator(user.id) + + +@satmachineadmin_api_router.get( + "/api/v1/dca/machines/{machine_id}/settlements", + response_model=list[DcaSettlement], +) +async def api_list_settlements_for_machine( + machine_id: str, user: User = Depends(check_user_exists) +) -> list[DcaSettlement]: + machine = await get_machine(machine_id) + if machine is None or machine.operator_user_id != user.id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") + return await get_settlements_for_machine(machine_id) + + +# NOTE on route ordering: FastAPI matches in declaration order. The literal +# /settlements/stuck must be registered BEFORE /settlements/{settlement_id} +# so the literal wins. Same applies to any future literal sub-route under +# /settlements/* (don't reshuffle this section without re-confirming the +# order). + + +@satmachineadmin_api_router.get( + "/api/v1/dca/settlements/stuck", response_model=StuckSettlementsResponse +) +async def api_list_stuck_settlements( + threshold_minutes: int = 30, + user: User = Depends(check_user_exists), +) -> StuckSettlementsResponse: + """Operator worklist of settlements that didn't process cleanly. + + Returns four lists: + - rejected: Nostr attribution cross-check failed — signer didn't + match the machine identity. Investigate; do not retry. + - errored: distribution ran and failed; retry endpoint handles these + - stuck_pending: landed but never picked up by the processor + - stuck_processing: claim taken but no completion in N minutes + + `threshold_minutes` controls the age threshold for 'stuck' (default 30). + Operators can force-recover stuck-processing settlements via + POST /api/v1/dca/settlements/{id}/force-reset.""" + if threshold_minutes < 1: + raise HTTPException(HTTPStatus.BAD_REQUEST, "threshold_minutes must be >= 1") + buckets = await get_stuck_settlements_for_operator(user.id, threshold_minutes) + return StuckSettlementsResponse( + threshold_minutes=threshold_minutes, + rejected=buckets["rejected"], + errored=buckets["errored"], + stuck_pending=buckets["stuck_pending"], + stuck_processing=buckets["stuck_processing"], ) - # Enhance payments with client information - distributions = [] - for payment in payments: - client = await get_dca_client(payment.client_id) - distributions.append( - { - "payment_id": payment.id, - "client_id": payment.client_id, - "client_username": client.username if client else None, - "client_user_id": client.user_id if client else None, - "amount_sats": payment.amount_sats, - "amount_fiat": payment.amount_fiat, - "exchange_rate": payment.exchange_rate, - "status": payment.status, - "created_at": payment.created_at, - } - ) - return distributions +@satmachineadmin_api_router.get( + "/api/v1/dca/settlements/{settlement_id}", response_model=DcaSettlement +) +async def api_get_settlement( + settlement_id: str, user: User = Depends(check_user_exists) +) -> DcaSettlement: + settlement = await get_settlement(settlement_id) + if settlement is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + machine = await get_machine(settlement.machine_id) + if machine is None or machine.operator_user_id != user.id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + return settlement -# Lamassu Configuration Endpoints +@satmachineadmin_api_router.post( + "/api/v1/dca/settlements/{settlement_id}/partial-dispense", + response_model=DcaSettlement, +) +async def api_partial_dispense( + settlement_id: str, + data: PartialDispenseData, + user: User = Depends(check_user_exists), +) -> DcaSettlement: + """Operator UX — resolves satmachineadmin#3. + Recompute the split for a settlement that didn't dispense the full + amount (jam, mid-tx error). Provide one of dispensed_fraction (0..1) + or dispensed_sats. Optionally include a reason in notes. -@satmachineadmin_api_router.get("/api/v1/dca/config") -async def api_get_lamassu_config( - wallet: WalletTypeInfo = Depends(check_super_user), -) -> Optional[LamassuConfig]: - """Get active Lamassu database configuration""" - return await get_active_lamassu_config() - - -@satmachineadmin_api_router.post("/api/v1/dca/config", status_code=HTTPStatus.CREATED) -async def api_create_lamassu_config( - data: CreateLamassuConfigData, - user: User = Depends(check_super_user), -) -> LamassuConfig: - """Create/update Lamassu database configuration""" - return await create_lamassu_config(data) - - -@satmachineadmin_api_router.put("/api/v1/dca/config/{config_id}") -async def api_update_lamassu_config( - config_id: str, - data: UpdateLamassuConfigData, - user: User = Depends(check_super_user), -) -> LamassuConfig: - """Update Lamassu database configuration""" - config = await get_lamassu_config(config_id) - if not config: + Refuses when any leg has already completed — Lightning payments can't + be clawed back. Use balance settlement (P3e) for those cases. + """ + settlement = await get_settlement(settlement_id) + if settlement is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + machine = await get_machine(settlement.machine_id) + if machine is None or machine.operator_user_id != user.id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + if (data.dispensed_fraction is None) == (data.dispensed_sats is None): raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Configuration not found." + HTTPStatus.BAD_REQUEST, + "Provide exactly one of dispensed_fraction or dispensed_sats", ) - - updated_config = await update_lamassu_config(config_id, data) - if not updated_config: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Failed to update configuration.", - ) - return updated_config - - -@satmachineadmin_api_router.delete("/api/v1/dca/config/{config_id}") -async def api_delete_lamassu_config( - config_id: str, - user: User = Depends(check_super_user), -): - """Delete Lamassu database configuration""" - config = await get_lamassu_config(config_id) - if not config: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Configuration not found." - ) - - await delete_lamassu_config(config_id) - return {"message": "Configuration deleted successfully"} - - -@satmachineadmin_api_router.get("/api/v1/dca/client-limits") -async def api_get_client_limits(): - """Get client-safe configuration limits (public endpoint - no authentication)""" try: - config = await get_active_lamassu_config() - if not config: - # Return sensible defaults if no config exists - return { - "max_daily_limit_gtq": 2000, - "currency": "GTQ" - } - - # Return only client-safe configuration fields - return { - "max_daily_limit_gtq": config.max_daily_limit_gtq, - "currency": "GTQ" # Could be made configurable later - } - except Exception: - # Return defaults on any error - return { - "max_daily_limit_gtq": 2000, - "currency": "GTQ" - } + return await apply_partial_dispense_and_redistribute(settlement_id, data) + except ValueError as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc + + +@satmachineadmin_api_router.post( + "/api/v1/dca/settlements/{settlement_id}/force-reset", + response_model=DcaSettlement, +) +async def api_force_reset_settlement( + settlement_id: str, + threshold_minutes: int = 30, + user: User = Depends(check_user_exists), +) -> DcaSettlement: + """Operator escape hatch for genuinely stuck settlements (processor + crashed mid-flight, claim never released). Flips status + 'pending'/'processing' → 'errored' so the retry endpoint can take over. + + Refuses unless the settlement is older than `threshold_minutes` so an + operator can't accidentally interrupt a slow-but-running settlement. + Threshold check uses created_at as a proxy — adequate for v1 since the + processor either completes fast or it crashed.""" + from datetime import datetime, timedelta, timezone + + settlement = await get_settlement(settlement_id) + if settlement is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + machine = await get_machine(settlement.machine_id) + if machine is None or machine.operator_user_id != user.id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + if settlement.status not in ("pending", "processing"): + raise HTTPException( + HTTPStatus.BAD_REQUEST, + f"settlement status must be 'pending' or 'processing' to " + f"force-reset (currently '{settlement.status}')", + ) + # Age check — refuse if settlement is fresh (processor might still + # be running normally). Both sides made timezone-aware before compare. + created = settlement.created_at + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + age = datetime.now(timezone.utc) - created + if age < timedelta(minutes=threshold_minutes): + raise HTTPException( + HTTPStatus.BAD_REQUEST, + f"settlement is only {age.total_seconds() / 60:.1f} minutes " + f"old (threshold {threshold_minutes}m); refusing to force-reset " + "a possibly-still-running settlement", + ) + updated = await force_reset_stuck_settlement(settlement_id) + if updated is None: + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "failed to force-reset") + return updated + + +@satmachineadmin_api_router.post( + "/api/v1/dca/settlements/{settlement_id}/retry", + response_model=DcaSettlement, +) +async def api_retry_settlement( + settlement_id: str, user: User = Depends(check_user_exists) +) -> DcaSettlement: + """Operator retry path for an errored settlement. + + Voids any failed legs (completed legs are NEVER re-paid — Lightning + sats already moved) and flips status 'errored' → 'pending', then + re-invokes process_settlement. The optimistic-lock claim guards + against a concurrent listener re-fire racing this retry. + + REFUSES when any leg has already completed. Reason: process_settlement + re-creates every leg from scratch (super_fee + operator_split + dca); + if a previous attempt already completed some of them, retrying would + DOUBLE-PAY those legs. For partial-success failures, the operator + needs to either edit the commission_splits ruleset to remove the + already-paid targets before retry, or manually pay the missing legs + out-of-band. + """ + settlement = await get_settlement(settlement_id) + if settlement is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + machine = await get_machine(settlement.machine_id) + if machine is None or machine.operator_user_id != user.id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + if settlement.status != "errored": + raise HTTPException( + HTTPStatus.BAD_REQUEST, + f"settlement status must be 'errored' to retry " + f"(currently '{settlement.status}')", + ) + completed = await count_completed_legs_for_settlement(settlement_id) + if completed > 0: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + f"refusing to retry: {completed} leg(s) already completed. " + "Re-running distribution would double-pay them. Edit the " + "commission_splits ruleset to remove the already-paid targets, " + "or manually pay the missing legs.", + ) + updated = await reset_settlement_for_retry(settlement_id) + if updated is None or updated.status != "pending": + raise HTTPException( + HTTPStatus.INTERNAL_SERVER_ERROR, "failed to reset settlement" + ) + await process_settlement(settlement_id) + after = await get_settlement(settlement_id) + return after if after is not None else updated + + +@satmachineadmin_api_router.post( + "/api/v1/dca/settlements/{settlement_id}/notes", + response_model=DcaSettlement, +) +async def api_append_settlement_note( + settlement_id: str, + data: AppendSettlementNoteData, + user: User = Depends(check_user_exists), +) -> DcaSettlement: + """Operator appends a free-form note to the settlement. Useful for cash- + drawer reconciliation context, off-LN refund records, or any narrative + an operator wants to attach. Each entry is timestamped (UTC) and tagged + with the author's user id; existing entries are never modified. + + For richer queryable audit (filter by author, time range, action type), + see aiolabs/satmachineadmin (future audit-table feature).""" + settlement = await get_settlement(settlement_id) + if settlement is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + machine = await get_machine(settlement.machine_id) + if machine is None or machine.operator_user_id != user.id: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + updated = await append_settlement_note(settlement_id, data.note, user.id) + if updated is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found") + return updated + + +# ============================================================================= +# Payments (read-only — the leg-typed breakdown of distributions) +# ============================================================================= + + +@satmachineadmin_api_router.get("/api/v1/dca/payments", response_model=list[DcaPayment]) +async def api_list_payments( + leg_type: str | None = None, + user: User = Depends(check_user_exists), +) -> list[DcaPayment]: + return await get_payments_for_operator(user.id, leg_type=leg_type) + + +# ============================================================================= +# Commission splits — operator's rules for distributing the commission +# remainder (post-super-fee). Sum-to-1.0 invariant enforced at the model +# boundary by SetCommissionSplitsData. +# ============================================================================= + + +@satmachineadmin_api_router.get( + "/api/v1/dca/commission-splits", response_model=list[CommissionSplit] +) +async def api_get_commission_splits( + machine_id: str | None = None, + effective: bool = False, + user: User = Depends(check_user_exists), +) -> list[CommissionSplit]: + """No machine_id: operator's default ruleset (rows where machine_id IS NULL). + With machine_id: per-machine override only (404 the machine if not yours). + With machine_id and ?effective=true: per-machine override if set, else + operator default — what the settlement processor actually applies.""" + if machine_id is not None: + await _machine_owned_by(machine_id, user.id) + if effective: + return await get_effective_commission_splits(user.id, machine_id) + return await get_commission_splits(user.id, machine_id) + return await get_commission_splits(user.id, None) + + +@satmachineadmin_api_router.put( + "/api/v1/dca/commission-splits", response_model=list[CommissionSplit] +) +async def api_replace_commission_splits( + data: SetCommissionSplitsData, + user: User = Depends(check_user_exists), +) -> list[CommissionSplit]: + """Atomic replace for the (operator, machine) scope. If + data.machine_id is None, replaces the operator's default ruleset; + otherwise replaces the per-machine override (machine must be owned). + Sum-to-1.0 invariant enforced upstream by the Pydantic validator.""" + if data.machine_id is not None: + await _machine_owned_by(data.machine_id, user.id) + return await replace_commission_splits(user.id, data.machine_id, data.legs) + + +@satmachineadmin_api_router.delete( + "/api/v1/dca/commission-splits", + status_code=HTTPStatus.NO_CONTENT, +) +async def api_delete_commission_splits( + machine_id: str | None = None, + user: User = Depends(check_user_exists), +) -> None: + """Clear a ruleset. With machine_id: clears the per-machine override + (machine falls back to operator default). Without: clears the operator + default (any per-machine overrides keep applying).""" + if machine_id is not None: + await _machine_owned_by(machine_id, user.id) + # Atomic replace with an empty leg list — same effect as DELETE WHERE. + await replace_commission_splits(user.id, machine_id, []) + + +# ============================================================================= +# Super config — operators read; super (LNbits instance admin) writes. +# ============================================================================= + + +@satmachineadmin_api_router.get("/api/v1/dca/super-config", response_model=SuperConfig) +async def api_get_super_config( + _user: User = Depends(check_user_exists), +) -> SuperConfig: + """Returns the platform-fee config so operators can display it as a + read-only line item in their UI. The fee is set by the LNbits super + instance-wide; operators see it but can't change it.""" + config = await get_super_config() + if config is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Super config not initialised") + return config + + +@satmachineadmin_api_router.put("/api/v1/dca/super-config", response_model=SuperConfig) +async def api_update_super_config( + data: UpdateSuperConfigData, + _user: User = Depends(check_super_user), +) -> SuperConfig: + """Super-only: set the platform fee % charged on every operator's + commission, plus the destination wallet for collecting it. The fee is + enforced before the operator's own commission_splits ruleset fires + (see distribution.process_settlement).""" + await _assert_super_config_cap_safe( + data.super_cash_in_fee_fraction, + data.super_cash_out_fee_fraction, + ) + config = await update_super_config(data) + if config is None: + raise HTTPException( + HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to update super config" + ) + # Layer 2 (#39): 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. + # Soft-fails per machine independently; partial success is acceptable + # (the operator whose publish failed can re-trigger via a machine + # edit). Skip if neither directional fraction was touched in this + # update (e.g. caller only changed super_fee_wallet_id). + super_fractions_changed = ( + data.super_cash_in_fee_fraction is not None + or data.super_cash_out_fee_fraction is not None + ) + if super_fractions_changed: + for machine in await list_all_active_machines(): + await publish_fee_config(machine, config, machine.operator_user_id) + return config + + +# ============================================================================= +# Cassette configs (#29 v1.1) — per-machine ATM cassette inventory +# ============================================================================= +# v1.1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints: +# GET /machines/{id}/cassettes — list rows for the operator UI +# POST /machines/{id}/cassettes/publish — apply edits + publish kind-30078 +# +# Row creation (new (machine_id, position) pairs) is admin-only via the +# bootstrap consumer task — slot count is hardware-determined. Operator- +# side flow is edit-and-publish over the existing rows only; the editable +# fields per row are denomination and count. + + +@satmachineadmin_api_router.get( + "/api/v1/dca/machines/{machine_id}/cassettes", + response_model=list[CassetteConfig], +) +async def api_list_machine_cassettes( + machine_id: str, user: User = Depends(check_user_exists) +) -> list[CassetteConfig]: + """List the cassette config rows for one of the operator's machines, + ordered by position. Empty list = ATM hasn't yet published its + bootstrap event (or the bootstrap consumer hasn't processed it yet); + UI should show a "waiting for ATM" state.""" + await _machine_owned_by(machine_id, user.id) + return await list_cassette_configs_for_machine(machine_id) + + +@satmachineadmin_api_router.post( + "/api/v1/dca/machines/{machine_id}/cassettes/publish", + response_model=list[CassetteConfig], +) +async def api_publish_machine_cassettes( + machine_id: str, + payload: PublishCassettesPayload, + user: User = Depends(check_user_exists), +) -> list[CassetteConfig]: + """Operator submits the full per-machine cassette state for publish to + the ATM. Validates the position set matches what's currently in + cassette_configs for the machine (slot count is hardware-fixed), + upserts each row, then encrypts + signs + publishes a kind-30078 + event tagged with d=bitspire-cassettes: and + p=. + + The `` placeholder in the published d-tag is the ATM's hex pubkey + from machine.machine_npub (canonicalised via normalize_public_key), + NOT the internal dca_machines.id UUID — see #29 'machine_id semantics' + section and coord-log 2026-05-30T11:50Z load-bearing nudge. + + Returns the fresh cassette_configs rows after the upserts so the UI + can refresh its table from one round-trip. + + Errors: + 400 — payload position set doesn't match the machine's stored set + (operator publishing for a slot that doesn't exist on the + ATM; or the bootstrap hasn't landed yet so no rows exist) + 400 — operator hasn't onboarded a Nostr identity + 503 — signer offline / client-side-only, or nostrclient extension + not installed on this LNbits instance + 500 — anything else from the publish path + """ + machine = await _machine_owned_by(machine_id, user.id) + + existing = await list_cassette_configs_for_machine(machine_id) + existing_positions = {row.position for row in existing} + incoming_positions = set(payload.positions.keys()) + + if not existing: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + "No cassette_configs rows exist for this machine yet — " + "waiting for the ATM's bootstrap state event. Power on the " + "ATM and confirm it has reached the configured relay; " + "satmachineadmin will auto-populate cassette_configs on " + "receipt." + ), + ) + if existing_positions != incoming_positions: + missing = existing_positions - incoming_positions + extra = incoming_positions - existing_positions + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + "Payload position set doesn't match the machine's stored " + f"set. Missing from payload: {sorted(missing)}; extra in " + f"payload: {sorted(extra)}. Slot count is hardware-fixed " + "— re-provision the ATM via atm-tui to add/remove physical " + "bays, then re-publish." + ), + ) + + # Apply each per-row edit so the operator-believed state on + # satmachineadmin reflects the published payload, even if the ATM + # ack lands later (v2). updated_by audit-stamps the operator user id. + for pos, row in payload.positions.items(): + updated = await update_cassette_config( + machine_id, + pos, + UpsertCassetteConfigData(denomination=row.denomination, count=row.count), + updated_by=user.id, + ) + if updated is None: + # Defensive — we just validated the row exists, but a + # concurrent delete could land between. Surface as 500. + raise HTTPException( + HTTPStatus.INTERNAL_SERVER_ERROR, + f"cassette row for position {pos} disappeared mid-publish", + ) + + try: + await publish_to_atm(machine, payload, user.id) + except OperatorIdentityMissing as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc + except SignerUnavailable as exc: + raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) from exc + except RelayUnavailable as exc: + raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) from exc + except CassetteTransportError as exc: + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) from exc + + return await list_cassette_configs_for_machine(machine_id)