diff --git a/bitspire.py b/bitspire.py index 59192b2..e40c230 100644 --- a/bitspire.py +++ b/bitspire.py @@ -17,10 +17,7 @@ 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 +from .models import CreateDcaSettlementData, Machine # 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 @@ -222,30 +219,23 @@ def parse_settlement( payment_hash: str, wire_sats: int, extra: dict, - super_config: SuperConfig, + super_fee_fraction: float, ) -> 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. + `_assert_sat_invariants`). """ + if not (0.0 <= super_fee_fraction <= 1.0): + raise SettlementInvariantError( + f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}" + ) if not is_bitspire_payment(extra): raise SettlementMetadataError( f"Payment.extra missing `source: \"bitspire\"` marker on machine " @@ -263,39 +253,8 @@ def parse_settlement( 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." - ) + platform_fee_sats = round(fee_sats * super_fee_fraction) + operator_fee_sats = fee_sats - platform_fee_sats exchange_rate = _coerce_float(extra.get("exchange_rate")) if exchange_rate is None or exchange_rate <= 0: # Without exchange rate we can't compute fiat. Use 1.0 as a stand-in @@ -309,6 +268,7 @@ def parse_settlement( # 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 + tx_type = _coerce_str(extra.get("type")) or "cash_out" data = CreateDcaSettlementData( machine_id=machine.id, payment_hash=payment_hash, @@ -322,7 +282,6 @@ def parse_settlement( 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")), diff --git a/calculations.py b/calculations.py index c76600b..5bfadab 100644 --- a/calculations.py +++ b/calculations.py @@ -106,49 +106,36 @@ def calculate_distribution( return distributions -def split_principal_based( - principal_sats: int, - super_frac: float, - operator_frac: float, +def split_two_stage_commission( + fee_sats: int, super_fee_fraction: 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`. + """Stage-1 of the v2 commission split: super takes `super_fee_fraction` + of the total fee; the remainder is what the operator's own ruleset + acts on. - 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 (platform_fee_sats, operator_fee_sats). Platform is rounded; + operator absorbs the rounding remainder so platform_fee + operator_fee + == fee_sats exactly. 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). + >>> split_two_stage_commission(100, 0.30) + (30, 70) + >>> split_two_stage_commission(7965, 0.30) + (2390, 5575) + >>> split_two_stage_commission(100, 0.0) + (0, 100) + >>> split_two_stage_commission(100, 1.0) + (100, 0) """ - 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: + if not (0.0 <= super_fee_fraction <= 1.0): + raise ValueError( + f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}" + ) + if fee_sats <= 0: return 0, 0 - platform = max(0, round(principal_sats * super_frac)) - operator = max(0, round(principal_sats * operator_frac)) + platform = round(fee_sats * super_fee_fraction) + platform = max(0, min(platform, fee_sats)) + operator = fee_sats - platform return platform, operator diff --git a/cassette_transport.py b/cassette_transport.py index 7d98906..1725dde 100644 --- a/cassette_transport.py +++ b/cassette_transport.py @@ -41,61 +41,57 @@ centralises the canonicalisation via lnbits.utils.nostr.normalize_public_key. from __future__ import annotations import json +import time +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 lnbits.utils.nostr import normalize_public_key +from loguru import logger 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", -] +from .nip44 import decrypt_from as _nip44_local_decrypt +from .nip44 import encrypt_for as _nip44_local_encrypt +_KIND_NIP78 = 30078 _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 +# Errors # ============================================================================= -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. +class CassetteTransportError(Exception): + """Generic transport-layer 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 OperatorIdentityMissing(CassetteTransportError): + """Operator account has no Nostr pubkey on file, or no signer is + available (pre-bunker rollout — operator hasn't onboarded via + Nostr-login).""" + + +class SignerUnavailable(CassetteTransportError): + """Resolved signer can't sign server-side (client-side-only signer, + or transient bunker unreachability post-lnbits#18). Publish skipped.""" + + +class RelayUnavailable(CassetteTransportError): + """nostrclient extension isn't installed or its relay manager isn't + reachable. Treated as soft-fail; publish skipped + logged.""" class CassetteEventDecodeError(CassetteTransportError): @@ -145,11 +141,139 @@ def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]: return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines] +# ============================================================================= +# Sign-as-operator — hybrid path (resolve_signer post #17, prvkey fallback) +# ============================================================================= + + +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 cassette " + "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]}...: " f"{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 | None: + """Sign `event` using the operator's signer (LocalSigner or + RemoteBunkerSigner). Mutates `event` to add `created_at` (now), + `pubkey`, `id`, and `sig`. + + Raises typed CassetteTransportError subclasses on hard failure + (the publish endpoint maps these to HTTP statuses); never returns + None on the publish path. + """ + _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: + return await signer.sign_event(event) + except SignerUnavailableError as exc: + raise SignerUnavailable( + f"signer unavailable for operator {operator_user_id[:8]}...: " f"{exc}" + ) from exc + + +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 + ("LocalSigner does not implement nip44_encrypt") 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 config 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 + + # ============================================================================= # Publish — operator → ATM (the satmachineadmin API path) # ============================================================================= +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; cassette config " + "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) + + async def publish_to_atm( machine: Machine, payload: PublishCassettesPayload, @@ -159,20 +283,63 @@ async def publish_to_atm( 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. + audit). Raises CassetteTransportError subclasses on hard failures: + - OperatorIdentityMissing → 400: operator hasn't onboarded + - SignerUnavailable → 503: signer offline / client-side-only / bunker + timeout at the encrypt or sign step + - RelayUnavailable → 503: nostrclient not installed + - CassetteTransportError → 500: anything else """ 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())})" - ), + + # Single fetch + resolve — same signer is used for both encrypt and sign. + account, signer = await _resolve_operator_signer(operator_user_id) + + # NIP-44 v2 encrypt the wire payload. Bunker round-trip on + # RemoteBunkerSigner; direct prvkey on LocalSigner (transitional). + plaintext = json.dumps(payload.to_wire_dict(), separators=(",", ":")) + try: + content = await _nip44_encrypt_via_signer( + account, signer, plaintext, atm_pubkey_hex + ) + except NsecBunkerTimeoutError as exc: + raise SignerUnavailable( + f"bunker unreachable while encrypting cassette config 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): " + f"{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", _config_d_tag(atm_pubkey_hex)], + ["p", atm_pubkey_hex], + ], + "content": content, + # created_at is set inside _sign_as_operator before signing. + } + signed = await _sign_as_operator(operator_user_id, event) + if signed is None: + raise CassetteTransportError( + "sign_as_operator returned None unexpectedly — soft-fail path " + "shouldn't be reachable on a publish-initiated flow" + ) + + await _publish_signed_event(signed) + logger.info( + f"satmachineadmin: published kind-30078 cassette config to ATM " + f"{atm_pubkey_hex[:12]}... (event_id={signed['id'][:12]}..., " + f"machine_id={machine.id}, positions={sorted(payload.positions.keys())})" ) return signed @@ -217,7 +384,7 @@ async def decrypt_and_parse_state_event( ) try: - plaintext = await nip44_decrypt_via_signer( + plaintext = await _nip44_decrypt_via_signer( account, signer, content, sender_pubkey ) except NsecBunkerTimeoutError as exc: diff --git a/crud.py b/crud.py index 444a984..1144b0c 100644 --- a/crud.py +++ b/crud.py @@ -80,13 +80,9 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach """ 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) + fiat_code, is_active, 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) + :location, :fiat_code, :is_active, :created_at, :updated_at) """, { "id": machine_id, @@ -97,8 +93,6 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach "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, }, @@ -601,13 +595,13 @@ async def create_settlement_idempotent( 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, + fee_sats, platform_fee_sats, operator_fee_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, + :platform_fee_sats, :operator_fee_sats, :tx_type, :bills_json, :cassettes_json, :status, :error_message, :created_at) """, @@ -625,7 +619,6 @@ async def create_settlement_idempotent( "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, diff --git a/docs/security-pathway-v1.md b/docs/security-pathway-v1.md index 2b7cc8b..f7696d8 100644 --- a/docs/security-pathway-v1.md +++ b/docs/security-pathway-v1.md @@ -256,7 +256,7 @@ What we **do not** adopt and why (from the NIP survey): │ Lightning settles ▼ ┌─────────────────────────────────────────────────────────────────────────────────────────────┐ -│ Settlement attestation (NIP-57-style receipt — see kind-rotation note in §6 / S3 row) │ +│ Settlement attestation (NIP-57-style receipt — kind:9735 or our own kind:21001) │ │ │ │ LNbits publishes (signed by the LNbits server key): │ │ { kind: 9735, │ @@ -303,7 +303,7 @@ None of those need to change. The new layers slot in *above* them. | **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. | +| **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 | Decide kind: `9735` (semantic abuse for non‑zap) vs. our own kind in `21001`/`21002` range. | | **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. | diff --git a/fee_transport.py b/fee_transport.py deleted file mode 100644 index 1dc0ec7..0000000 --- a/fee_transport.py +++ /dev/null @@ -1,151 +0,0 @@ -""" -Fee-config Nostr transport — operator → ATM kind-30078 publish. - -Layer 2 of the operator-configurable fee architecture -(aiolabs/satmachineadmin#37 parent, #39 this layer). Pairs with the -bitspire consumer at `aiolabs/lamassu-next#57`. - -Wire format locked at coord-log §2026-06-01T14:25Z: - - kind = 30078 (NIP-78, replaceable) - tags = [ - ["d", "bitspire-fees:"], - ["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 97e4a68..5e0b3ec 100644 --- a/migrations.py +++ b/migrations.py @@ -682,11 +682,9 @@ async def m009_split_fee_fractions_by_direction(db): 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). + sweep). The existing `super_config.super_fee_fraction` column is + NOT dropped here — deprecated, removed in a follow-up release after + callers migrate to the directional fields. """ additions = [ ("super_config", "super_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), @@ -706,32 +704,17 @@ async def m009_split_fee_fractions_by_direction(db): 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" - ) + # Backfill super-config directional fractions from the legacy singleton + # so the live deployment's super_fee_fraction setting carries forward. + # Guarded WHERE clause: only fire when both new fields are still at + # their DEFAULT 0 (i.e., this is a first migrate-up, not a repeat). + 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 + """ + ) diff --git a/models.py b/models.py index f7f84e4..1ec2ec4 100644 --- a/models.py +++ b/models.py @@ -449,6 +449,10 @@ class TelemetrySnapshot(BaseModel): class SuperConfig(BaseModel): id: str + # Deprecated singleton fee fraction — retained for one release while + # callers migrate to the per-direction fields below. The new math + # (bitspire.py:parse_settlement) only reads the directional fields. + super_fee_fraction: float super_cash_in_fee_fraction: float = 0.0 super_cash_out_fee_fraction: float = 0.0 super_fee_wallet_id: str | None @@ -456,11 +460,15 @@ class SuperConfig(BaseModel): class UpdateSuperConfigData(BaseModel): + # Deprecated; setting either directional field is the supported path. + # Writes here continue to apply for one release for migration safety. + super_fee_fraction: float | None = None 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_fee_fraction", "super_cash_in_fee_fraction", "super_cash_out_fee_fraction", ) @@ -721,107 +729,3 @@ class PublishCassettesPayload(BaseModel): 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/nostr_publish.py b/nostr_publish.py deleted file mode 100644 index b36b39c..0000000 --- a/nostr_publish.py +++ /dev/null @@ -1,295 +0,0 @@ -""" -Shared kind-30078 (NIP-78 addressable app data) Nostr publish primitives. - -Extracted from cassette_transport.py once the second consumer landed -(fee_transport.py for aiolabs/satmachineadmin#39). Both modules -share the operator-signer resolution, NIP-44 v2 encrypt/decrypt path, -event signing, and nostrclient-relay publish path; only the d-tag -prefix + payload model differ per document type. - -Architecture: - - fee_transport / cassette_transport - │ - ▼ - publish_encrypted_kind_30078 ← high-level wrapper (build event + sign + publish) - │ - ├── resolve_operator_signer - ├── nip44_encrypt_via_signer - ├── sign_as_operator - └── publish_signed_event - -`resolve_operator_signer` and the NIP-44 helpers honor the -transitional LocalSigner → RemoteBunkerSigner cascade (lnbits#17/#18): -the bunker is the endgame for every operator account on this instance, -but pre-migration LocalSigner accounts still work via direct-prvkey -NIP-44 v2 from our hand-rolled `nip44` module. - -This module is intentionally domain-agnostic — it knows nothing about -cassettes, fees, or any specific d-tag prefix. The caller supplies -the recipient pubkey, the d-tag, and the payload dict. -""" - -from __future__ import annotations - -import json -import time -from typing import Any - -from lnbits.core.crud.users import get_account -from lnbits.core.services.nip46_bunker_client import ( - NsecBunkerRpcError, - NsecBunkerTimeoutError, -) -from lnbits.core.signers import resolve_signer -from lnbits.core.signers.base import ( - NostrSigner, - SignerError, - SignerUnavailableError, -) -from loguru import logger - -from .nip44 import decrypt_from as _nip44_local_decrypt -from .nip44 import encrypt_for as _nip44_local_encrypt - -KIND_NIP78 = 30078 - - -# ============================================================================= -# Errors — typed so API endpoints can map to specific HTTP statuses -# ============================================================================= - - -class NostrPublishError(Exception): - """Base class for kind-30078 publish errors. Sub-modules - (cassette_transport, fee_transport) typically subclass further - for domain-specific 'this couldn't be applied' errors that have - no analog in the transport layer.""" - - -class OperatorIdentityMissing(NostrPublishError): - """Operator account has no Nostr pubkey on file, or no signer is - available (pre-bunker onboarding — operator hasn't logged in via - Nostr-login flow).""" - - -class SignerUnavailable(NostrPublishError): - """Resolved signer can't sign server-side (client-side-only signer, - or transient bunker unreachability post-lnbits#18). Publish skipped - or soft-failed by the caller.""" - - -class RelayUnavailable(NostrPublishError): - """nostrclient extension isn't installed or its relay manager isn't - reachable. Treated as soft-fail by callers; publish skipped + logged.""" - - -# ============================================================================= -# Operator signer resolution + NIP-44 v2 encrypt/decrypt -# ============================================================================= - - -async def resolve_operator_signer(operator_user_id: str): - """Fetch the operator's account + resolve to a NostrSigner. - - Single source of truth for "give me the signer for this operator, - or raise an operator-facing error if we can't." Returns - `(account, signer)` so callers that need both (publish path needs - `account.pubkey` for the event author and the signer for both - encrypt + sign) don't double-fetch. - - Raises: - - OperatorIdentityMissing — no account, or no pubkey on file - - SignerUnavailable — signer resolve failed, or signer can't sign - server-side (ClientSideOnly) - """ - account = await get_account(operator_user_id) - if account is None or not account.pubkey: - raise OperatorIdentityMissing( - f"operator {operator_user_id[:8]}... has no Nostr pubkey on file. " - "Onboard via the LNbits Nostr-login flow to publish operator " - "config to your ATMs." - ) - try: - signer = resolve_signer(account) - except SignerError as exc: - raise SignerUnavailable( - f"signer resolve failed for operator {operator_user_id[:8]}...: {exc}" - ) from exc - if not signer.can_sign(): - raise SignerUnavailable( - f"operator {operator_user_id[:8]}... has a client-side-only " - "signer; server can't sign or NIP-44-encrypt on their behalf. " - "Operator must hold their nsec via a NIP-46 bunker (lnbits#18) " - "or migrate to a server-signing account." - ) - return account, signer - - -async def sign_as_operator(operator_user_id: str, event: dict) -> dict: - """Sign `event` using the operator's signer (LocalSigner or - RemoteBunkerSigner). Mutates `event` to add `created_at` (now), - `pubkey`, `id`, and `sig`. Returns the signed event. - - Raises typed NostrPublishError subclasses on hard failure (caller - maps to HTTP status / decides soft-fail). - """ - _account, signer = await resolve_operator_signer(operator_user_id) - # created_at is part of the BIP-340 event-id hash; set before signing. - event["created_at"] = int(time.time()) - try: - signed = await signer.sign_event(event) - except SignerUnavailableError as exc: - raise SignerUnavailable( - f"signer unavailable for operator {operator_user_id[:8]}...: {exc}" - ) from exc - if signed is None: - raise NostrPublishError( - f"signer returned None for operator {operator_user_id[:8]}... " - "— shouldn't be reachable on a server-signing path" - ) - return signed - - -async def nip44_encrypt_via_signer( - account, signer: NostrSigner, plaintext: str, peer_pubkey_hex: str -) -> str: - """NIP-44 v2 encrypt via the signer abstraction, with a transitional - fallback to direct-prvkey for LocalSigner accounts. - - The bunker (RemoteBunkerSigner) implements `nip44_encrypt` natively — - the operator's nsec never leaves the bunker process. LocalSigner's - `nip44_encrypt` stub explicitly raises SignerUnavailableError per - the post-PR-#38 ABC — the spec is "migrate to bunker." For the - transitional window where some operators are still on LocalSigner + - their `account.prvkey` is intact, we catch that signal and use our - hand-rolled NIP-44 v2 impl against the stored prvkey. Same wire - output either way. - - Removed once every operator account on this instance is bunker- - backed (S7 fully landed). At that point this helper collapses to - `return await signer.nip44_encrypt(plaintext, peer_pubkey_hex)`. - """ - try: - return await signer.nip44_encrypt(plaintext, peer_pubkey_hex) - except SignerUnavailableError: - if account.signer_type == "LocalSigner" and account.prvkey: - return _nip44_local_encrypt(plaintext, account.prvkey, peer_pubkey_hex) - # ClientSideOnly, or RemoteBunkerSigner with bunker comms failure - # at encrypt time — re-raise without wrapping; caller maps it. - raise - - -async def nip44_decrypt_via_signer( - account, signer: NostrSigner, ciphertext: str, peer_pubkey_hex: str -) -> str: - """Decrypt mirror of `nip44_encrypt_via_signer`. Same LocalSigner - transitional fallback.""" - try: - return await signer.nip44_decrypt(ciphertext, peer_pubkey_hex) - except SignerUnavailableError: - if account.signer_type == "LocalSigner" and account.prvkey: - return _nip44_local_decrypt(ciphertext, account.prvkey, peer_pubkey_hex) - raise - - -# ============================================================================= -# Relay publish -# ============================================================================= - - -async def publish_signed_event(signed_event: dict) -> None: - """Send a signed Nostr event to all relays via the nostrclient - extension's singleton RelayManager. - - Lazy import + typed-error so the API can surface "your LNbits doesn't - have nostrclient installed" as a 503 rather than a 500. Pattern - matches the cross-extension import guards in - `lnbits.core.services.users` (nostrmarket / nostrrelay). - """ - try: - from nostrclient.router import ( # type: ignore[import-not-found] - nostr_client, - ) - except ImportError as exc: - raise RelayUnavailable( - "nostrclient extension is not installed; kind-30078 publish " - "requires it. Install + activate the nostrclient extension on " - "this LNbits instance." - ) from exc - msg = json.dumps(["EVENT", signed_event]) - nostr_client.relay_manager.publish_message(msg) - - -# ============================================================================= -# High-level: build + encrypt + sign + publish in one call -# ============================================================================= - - -async def publish_encrypted_kind_30078( - *, - operator_user_id: str, - recipient_pubkey_hex: str, - d_tag: str, - payload: dict[str, Any], - log_context: str = "", -) -> dict: - """Build, NIP-44-v2-encrypt, sign-as-operator, and publish a - kind-30078 event addressed to `recipient_pubkey_hex` under `d_tag`. - - Centralised so cassette_transport + fee_transport (+ any future - operator-pushed document type) share the same wire-format guarantees. - - Returns the signed event dict on success. Raises typed - NostrPublishError subclasses on hard failure: - - OperatorIdentityMissing → 400: operator hasn't onboarded - - SignerUnavailable → 503: signer offline / client-side-only / - bunker timeout at encrypt or sign step - - RelayUnavailable → 503: nostrclient not installed - - NostrPublishError → 500: anything else - - `log_context` is a short string prefixed to the success log line for - triage ("cassette", "fee", etc.). - """ - account, signer = await resolve_operator_signer(operator_user_id) - - plaintext = json.dumps(payload, separators=(",", ":")) - try: - content = await nip44_encrypt_via_signer( - account, signer, plaintext, recipient_pubkey_hex - ) - except NsecBunkerTimeoutError as exc: - raise SignerUnavailable( - f"bunker unreachable while encrypting kind-30078 ({d_tag}) for " - f"operator {operator_user_id[:8]}...: {exc}" - ) from exc - except NsecBunkerRpcError as exc: - raise SignerUnavailable( - f"bunker rejected nip44_encrypt for operator " - f"{operator_user_id[:8]}... (policy / MAC / config issue): {exc}" - ) from exc - except SignerUnavailableError as exc: - raise SignerUnavailable( - f"signer cannot nip44-encrypt for operator " - f"{operator_user_id[:8]}...: {exc}" - ) from exc - - event: dict = { - "kind": KIND_NIP78, - "tags": [ - ["d", d_tag], - ["p", recipient_pubkey_hex], - ], - "content": content, - # created_at is set inside sign_as_operator before signing. - } - signed = await sign_as_operator(operator_user_id, event) - - await publish_signed_event(signed) - prefix = f"{log_context}: " if log_context else "" - logger.info( - f"satmachineadmin: {prefix}published kind-30078 to ATM " - f"{recipient_pubkey_hex[:12]}... d-tag={d_tag} " - f"event_id={signed['id'][:12]}..." - ) - return signed diff --git a/static/js/index.js b/static/js/index.js index 4da7ee6..96b98ef 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -100,11 +100,7 @@ window.app = Vue.createApp({ superFeeDialog: { show: false, saving: false, - data: { - super_cash_in_fee_fraction: 0, - super_cash_out_fee_fraction: 0, - super_fee_wallet_id: '' - } + data: {super_fee_fraction: 0, super_fee_wallet_id: ''} }, // UI configuration ----------------------------------------------- @@ -270,17 +266,6 @@ window.app = Vue.createApp({ }, computed: { - 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) - ) - }, walletOptions() { // g.user is sometimes null on initial mount in LNbits 1.4 — guard it. const wallets = this.g?.user?.wallets || [] @@ -564,10 +549,7 @@ window.app = Vue.createApp({ // ----------------------------------------------------------------- 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_fraction: this.superConfig?.super_fee_fraction ?? 0, super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '' } this.superFeeDialog.show = true @@ -580,8 +562,7 @@ window.app = Vue.createApp({ 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_fraction: Number(d.super_fee_fraction), super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null } ) @@ -724,9 +705,7 @@ window.app = Vue.createApp({ 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 + is_active: machine.is_active } this.editMachineDialog.show = true }, @@ -744,9 +723,7 @@ window.app = Vue.createApp({ 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 + is_active: d.is_active } ) const idx = this.machines.findIndex(m => m.id === data.id) @@ -1498,9 +1475,7 @@ window.app = Vue.createApp({ wallet_id: null, name: '', location: '', - fiat_code: 'GTQ', - operator_cash_in_fee_fraction: 0, - operator_cash_out_fee_fraction: 0 + fiat_code: 'GTQ' } }, @@ -1510,9 +1485,7 @@ window.app = Vue.createApp({ 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 + fiat_code: (d.fiat_code || 'GTQ').trim() } }, diff --git a/tasks.py b/tasks.py index 314c244..7f2a276 100644 --- a/tasks.py +++ b/tasks.py @@ -125,14 +125,14 @@ async def _handle_payment(payment: Payment) -> None: # 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 + super_fee_fraction = float(super_config.super_fee_fraction) if super_config else 0.0 try: data = parse_settlement( machine=machine, payment_hash=payment.payment_hash, wire_sats=payment.sat, extra=extra, - super_config=super_config, + super_fee_fraction=super_fee_fraction, ) except (SettlementMetadataError, SettlementInvariantError) as exc: await _record_rejected(payment, machine, exc) @@ -408,9 +408,9 @@ async def _handle_cassette_state_event( CassetteEventDecodeError, CassetteEventTransientError, CassetteTransportError, + _resolve_operator_signer, decrypt_and_parse_state_event, ) - from .nostr_publish import resolve_operator_signer event_raw = event_message.event if isinstance(event_raw, str): @@ -444,7 +444,7 @@ async def _handle_cassette_state_event( return try: - account, signer = await resolve_operator_signer(machine.operator_user_id) + account, signer = await _resolve_operator_signer(machine.operator_user_id) except CassetteTransportError as exc: # OperatorIdentityMissing / SignerUnavailable — log + skip. logger.warning( diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index ffdb730..8b3ddf3 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -31,19 +31,17 @@ + :class="superConfig.super_fee_fraction > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'"> 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. + ${ (superConfig.super_fee_fraction * 100).toFixed(2) }% + of each transaction's commission. - Operator's per-machine fee rides on top of these. + Your remainder splits per the rules below.