diff --git a/bitspire.py b/bitspire.py index e40c230..59192b2 100644 --- a/bitspire.py +++ b/bitspire.py @@ -17,7 +17,10 @@ from __future__ import annotations import json from typing import Any, Optional -from .models import CreateDcaSettlementData, Machine +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 @@ -219,23 +222,30 @@ def parse_settlement( payment_hash: str, wire_sats: int, extra: dict, - super_fee_fraction: float, + 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`). + `_assert_sat_invariants`) or `tx_type` is unknown. """ - 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 " @@ -253,8 +263,39 @@ def parse_settlement( f"(lamassu-next#44) requires both. Investigate the ATM " f"firmware on machine {machine.machine_npub[:12]}..." ) - platform_fee_sats = round(fee_sats * super_fee_fraction) - operator_fee_sats = fee_sats - platform_fee_sats + 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 @@ -268,7 +309,6 @@ 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, @@ -282,6 +322,7 @@ 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 5bfadab..c76600b 100644 --- a/calculations.py +++ b/calculations.py @@ -106,36 +106,49 @@ def calculate_distribution( return distributions -def split_two_stage_commission( - fee_sats: int, super_fee_fraction: float +def split_principal_based( + principal_sats: int, + super_frac: float, + operator_frac: float, ) -> Tuple[int, int]: - """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. + """Compute platform + operator fee shares as independent fractions of + `principal_sats`. Both shares are derived from the customer's + principal (the canonical source of truth), NOT back-derived from + `fee_sats`. - Returns (platform_fee_sats, operator_fee_sats). Platform is rounded; - operator absorbs the rounding remainder so platform_fee + operator_fee - == fee_sats exactly. + Returns (platform_fee_sats, operator_fee_sats). Both are rounded + independently; rounding remainders do NOT compound — the customer + pays whatever bitspire collected, and any drift between (super + + operator) and the bitspire-reported `fee_sats` surfaces via + `dca_settlements.fee_mismatch_sats`. Examples: - >>> split_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) + >>> split_principal_based(100_000, 0.03, 0.05) + (3000, 5000) + >>> split_principal_based(266_800, 0.03, 0.0) + (8004, 0) + >>> split_principal_based(100_000, 0.0, 0.0) + (0, 0) + >>> split_principal_based(100_000, 0.15, 0.0) + (15000, 0) + + The pre-#38 bug this corrects: the old math interpreted the super + fee as `fraction_of_fee` rather than `fraction_of_principal`. On a + 100_000-sat principal with an 8% total bitspire fee (= 8_000 sats + fee_sats) and super_fraction=0.03, the bug paid the super + `round(8_000 * 0.03) = 240` sats — ~13× below the intended + `100_000 * 0.03 = 3_000` sats per-settlement. Repeated on every + cash-out since the bitspire wire-shape landed. See + aiolabs/satmachineadmin#37 (parent) + #38 (this layer). """ - if not (0.0 <= super_fee_fraction <= 1.0): - raise ValueError( - f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}" - ) - if fee_sats <= 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: return 0, 0 - platform = round(fee_sats * super_fee_fraction) - platform = max(0, min(platform, fee_sats)) - operator = fee_sats - platform + platform = max(0, round(principal_sats * super_frac)) + operator = max(0, round(principal_sats * operator_frac)) return platform, operator diff --git a/cassette_transport.py b/cassette_transport.py index 1725dde..7d98906 100644 --- a/cassette_transport.py +++ b/cassette_transport.py @@ -41,57 +41,61 @@ 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 .nip44 import decrypt_from as _nip44_local_decrypt -from .nip44 import encrypt_for as _nip44_local_encrypt +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", +] -_KIND_NIP78 = 30078 _D_TAG_CONFIG_PREFIX = "bitspire-cassettes:" # operator → ATM _D_TAG_STATE_PREFIX = "bitspire-cassettes-state:" # ATM → operator # ============================================================================= -# Errors +# Errors — cassette-specific subclasses of the generic NostrPublishError # ============================================================================= -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.""" +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 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.""" + 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): @@ -141,139 +145,11 @@ 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, @@ -283,63 +159,20 @@ 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 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 + audit). Raises NostrPublishError subclasses (re-exported here as + CassetteTransportError, OperatorIdentityMissing, SignerUnavailable, + RelayUnavailable) on hard failures. """ atm_pubkey_hex = _atm_hex_pubkey(machine) - - # 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())})" + 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 @@ -384,7 +217,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 1144b0c..444a984 100644 --- a/crud.py +++ b/crud.py @@ -80,9 +80,13 @@ 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, created_at, updated_at) + 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, :created_at, :updated_at) + :location, :fiat_code, :is_active, + :operator_cash_in_fee_fraction, :operator_cash_out_fee_fraction, + :created_at, :updated_at) """, { "id": machine_id, @@ -93,6 +97,8 @@ 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, }, @@ -595,13 +601,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_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, + :platform_fee_sats, :operator_fee_sats, :fee_mismatch_sats, :tx_type, :bills_json, :cassettes_json, :status, :error_message, :created_at) """, @@ -619,6 +625,7 @@ 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 f7696d8..2b7cc8b 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 — kind:9735 or our own kind:21001) │ +│ Settlement attestation (NIP-57-style receipt — see kind-rotation note in §6 / S3 row) │ │ │ │ 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 | Decide kind: `9735` (semantic abuse for non‑zap) vs. our own kind in `21001`/`21002` range. | +| **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. | 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 5e0b3ec..97e4a68 100644 --- a/migrations.py +++ b/migrations.py @@ -682,9 +682,11 @@ 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 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. + sweep). The deprecated `super_config.super_fee_fraction` singleton + is backfilled into the new directional fields, then dropped in the + same migration — strict-from-the-start per workspace CLAUDE.md + "Backwards-compatibility on pre-public-launch code" (v2-bitspire + hasn't shipped to public users). """ additions = [ ("super_config", "super_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), @@ -704,17 +706,32 @@ async def m009_split_fee_fractions_by_direction(db): f"ALTER TABLE satoshimachine.{table} ADD COLUMN {col} {coltype}" ) - # 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 - """ - ) + # 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 1ec2ec4..f7f84e4 100644 --- a/models.py +++ b/models.py @@ -449,10 +449,6 @@ 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 @@ -460,15 +456,11 @@ 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", ) @@ -729,3 +721,107 @@ 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 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/static/js/index.js b/static/js/index.js index 96b98ef..4da7ee6 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -100,7 +100,11 @@ window.app = Vue.createApp({ superFeeDialog: { show: false, saving: false, - data: {super_fee_fraction: 0, super_fee_wallet_id: ''} + data: { + super_cash_in_fee_fraction: 0, + super_cash_out_fee_fraction: 0, + super_fee_wallet_id: '' + } }, // UI configuration ----------------------------------------------- @@ -266,6 +270,17 @@ 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 || [] @@ -549,7 +564,10 @@ window.app = Vue.createApp({ // ----------------------------------------------------------------- openSuperFeeDialog() { this.superFeeDialog.data = { - super_fee_fraction: this.superConfig?.super_fee_fraction ?? 0, + 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 @@ -562,7 +580,8 @@ window.app = Vue.createApp({ const {data} = await LNbits.api.request( 'PUT', SUPER_FEE_PATH, null, { - super_fee_fraction: Number(d.super_fee_fraction), + 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 } ) @@ -705,7 +724,9 @@ window.app = Vue.createApp({ location: machine.location || '', wallet_id: machine.wallet_id, fiat_code: machine.fiat_code, - is_active: machine.is_active + 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 }, @@ -723,7 +744,9 @@ window.app = Vue.createApp({ location: d.location, wallet_id: d.wallet_id, fiat_code: d.fiat_code, - is_active: d.is_active + 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) @@ -1475,7 +1498,9 @@ window.app = Vue.createApp({ wallet_id: null, name: '', location: '', - fiat_code: 'GTQ' + fiat_code: 'GTQ', + operator_cash_in_fee_fraction: 0, + operator_cash_out_fee_fraction: 0 } }, @@ -1485,7 +1510,9 @@ 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() + 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 } }, diff --git a/tasks.py b/tasks.py index 7f2a276..314c244 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() - super_fee_fraction = float(super_config.super_fee_fraction) if super_config else 0.0 + 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_fee_fraction=super_fee_fraction, + super_config=super_config, ) 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 8b3ddf3..ffdb730 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -31,17 +31,19 @@ + :class="superAnyFee > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'"> LNbits platform fee: - ${ (superConfig.super_fee_fraction * 100).toFixed(2) }% - of each transaction's commission. + 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. - Your remainder splits per the rules below. + Operator's per-machine fee rides on top of these.