From 8d19da43c24586a0672f6b069656b23862f9cd98 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 22:26:45 +0200 Subject: [PATCH] feat(notify): alert the operator over Nostr when a customer is owed cash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cash_owed / partial_pending now sends a NIP-17 gift-wrapped DM (kind 14 rumor → kind 13 seal signed through the operator's signer → kind 1059 wrap from a throwaway key) to the operator's own account pubkey, or to super_config.alerts_pubkey when set. Best-effort: a failed publish is logged and the dispense report is still acked; the worklist is the durable record. No email channel, no NIP-04. --- dispense_transport.py | 10 +++ notify.py | 202 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 notify.py diff --git a/dispense_transport.py b/dispense_transport.py index 2b13efa..80921eb 100644 --- a/dispense_transport.py +++ b/dispense_transport.py @@ -80,6 +80,14 @@ def _spawn_distribution(settlement_id: str) -> None: task.add_done_callback(_inflight.discard) +async def _alert_operator(settlement, machine: Machine, report: DispenseReportIn, status: str) -> None: + """ADR-005 §6: a human is now owed money — tell the operator over Nostr. + Best-effort; the report is acked regardless (see notify.py).""" + from .notify import notify_cash_outcome + + await notify_cash_outcome(settlement, machine, report, status) + + async def apply_report_to_settlement( settlement: DcaSettlement, report: DispenseReportIn, @@ -118,6 +126,7 @@ async def apply_report_to_settlement( f"{report.fiat_cents / 100:.2f} {report.currency}) — distributing" ) elif new_status == "partial_pending": + await _alert_operator(updated or settlement, machine, report, new_status) logger.warning( f"spirekeeper: PARTIAL dispense for settlement {settlement.id} " f"(machine={machine.id}, txid={report.txid}): " @@ -127,6 +136,7 @@ async def apply_report_to_settlement( f"{report.raw_code or ''}. Held until the operator resolves the shortfall." ) else: + await _alert_operator(updated or settlement, machine, report, new_status) logger.error( f"spirekeeper: CASH OWED — settlement {settlement.id} " f"(machine={machine.id}, txid={report.txid}): customer paid " diff --git a/notify.py b/notify.py new file mode 100644 index 0000000..f860a73 --- /dev/null +++ b/notify.py @@ -0,0 +1,202 @@ +""" +Operator alerts over Nostr (bitspire ADR-005 §6 / Future directions). + +When a cash-out settlement lands in `cash_owed` or `partial_pending` — a +customer has paid and is owed cash — the operator is told, immediately, as a +Nostr event addressed to their pubkey. Not email, not SMS (lamassu's +`notifyOperator` channel). The pieces this reuses: + + - the recipient is the operator's own LNbits-account pubkey (`account.pubkey`, + which `get_machine_config` already refuses to run without), or + `super_config.alerts_pubkey` when set — a phone identity, say; + - the sender signs and NIP-44-encrypts through the operator's signer + (bunker-backed where the operator has moved to one), so no key is at rest + here. For the default recipient this is a note to self, which every NIP-46 + client renders as a DM thread with yourself. + +Wire: NIP-17. A kind-14 rumor (unsigned) is sealed (kind 13, NIP-44 to the +recipient, signed by the sender) and gift-wrapped (kind 1059, NIP-44 to the +recipient, signed by a throwaway key, `p`-tagged to the recipient, stamped at +a random moment in the last two days). The relay sees a throwaway pubkey and +ciphertext; only the recipient can open it. NIP-04 is not used anywhere in +this stack. + +Best-effort by contract: a failed alert is logged and never fails the RPC +that triggered it — the dispense report must still be acknowledged, and the +worklist is the durable record. `operator_notified_at` is set only on a +successful publish, so a report resend cannot re-alert. +""" + +from __future__ import annotations + +import hashlib +import json +import random +import time + +from loguru import logger + +from .crud import get_super_config, mark_settlement_notified +from .models import DcaSettlement, DispenseReportIn, Machine +from .nostr_publish import ( + NostrPublishError, + nip44_encrypt_via_signer, + publish_signed_event, + resolve_operator_signer, + sign_as_operator, +) + +KIND_DM = 14 +KIND_SEAL = 13 +KIND_GIFT_WRAP = 1059 +# NIP-17: randomise outer timestamps up to two days in the past so the wrap +# does not reveal when the conversation happened. +_MAX_BACKDATE_S = 2 * 24 * 60 * 60 + + +def _canonical_id( + pubkey: str, created_at: int, kind: int, tags: list, content: str +) -> str: + from lnbits.utils.nostr import json_dumps + + return hashlib.sha256( + json_dumps([0, pubkey, created_at, kind, tags, content]).encode() + ).hexdigest() + + +def _backdated_now() -> int: + return int(time.time()) - random.randint(0, _MAX_BACKDATE_S) # noqa: S311 + + +def _wrap_with_ephemeral(plaintext: str, recipient: str, created_at: int) -> dict: + """Kind-1059 gift wrap: NIP-44-encrypt `plaintext` to `recipient` from a + freshly generated key, sign with that key, and forget it.""" + import coincurve + from lnbits.utils.nostr import generate_keypair, sign_event + + from .nip44 import encrypt_for + + priv_hex, pub_hex = generate_keypair() + wrap = { + "kind": KIND_GIFT_WRAP, + "created_at": created_at, + "tags": [["p", recipient]], + "content": encrypt_for(plaintext, priv_hex, recipient), + } + return sign_event(wrap, pub_hex, coincurve.PrivateKey(bytes.fromhex(priv_hex))) + + +async def build_gift_wrapped_dm( + operator_user_id: str, recipient_pubkey_hex: str, text: str +) -> dict: + """Build a NIP-17 gift-wrapped DM from the operator's identity to + `recipient_pubkey_hex`. Returns the signed kind-1059 event, ready to publish. + + Raises the typed NostrPublishError subclasses from nostr_publish on a hard + failure (no pubkey on file, signer unavailable, …).""" + account, signer = await resolve_operator_signer(operator_user_id) + sender = account.pubkey + recipient = recipient_pubkey_hex.lower() + + # 1. rumor — unsigned kind 14, id computed so clients can thread it + rumor_created = int(time.time()) + rumor_tags = [["p", recipient]] + rumor = { + "id": _canonical_id(sender, rumor_created, KIND_DM, rumor_tags, text), + "pubkey": sender, + "created_at": rumor_created, + "kind": KIND_DM, + "tags": rumor_tags, + "content": text, + } + + # 2. seal — NIP-44 to the recipient, signed by the sender (the bunker + # path never sees the operator's key here) + sealed_content = await nip44_encrypt_via_signer( + account, signer, json.dumps(rumor, separators=(",", ":")), recipient + ) + seal = {"kind": KIND_SEAL, "tags": [], "content": sealed_content} + seal = await sign_as_operator(operator_user_id, seal) + + # 3. gift wrap — NIP-44 from a throwaway key to the recipient, p-tagged, + # backdated, signed by that same throwaway key, which is then forgotten + return _wrap_with_ephemeral( + json.dumps(seal, separators=(",", ":")), recipient, _backdated_now() + ) + + +async def resolve_alert_recipient(operator_user_id: str) -> str: + """`super_config.alerts_pubkey` when set, else the operator's own pubkey.""" + cfg = await get_super_config() + override = (getattr(cfg, "alerts_pubkey", None) or "").strip() if cfg else "" + if override: + return override.lower() + account, _signer = await resolve_operator_signer(operator_user_id) + return account.pubkey + + +def format_cash_outcome_alert( + settlement: DcaSettlement, machine: Machine, report: DispenseReportIn, status: str +) -> str: + """The message the operator reads on their phone. Plain text, the facts + they need to act, no raw dispenser code (that is in the dashboard).""" + name = machine.name or machine.id + paid = f"{report.fiat_cents / 100:.2f} {report.currency}" + out = f"{report.dispensed_fiat_cents / 100:.2f} {report.currency}" + if status == "cash_owed": + head = f"⚠️ CASH OWED — {name}" + body = f"A customer paid {paid} and received nothing." + else: + head = f"⚠️ PARTIAL DISPENSE — {name}" + body = f"A customer paid {paid} and received {out}." + lines = [ + head, + body, + f"Cause: {report.error or 'no dispenser error reported'}" + + (f" ({report.error_code})" if report.error_code else ""), + f"Transaction: {report.txid}", + f"Settlement: {settlement.id}", + "Nothing has been distributed. Open spirekeeper → Worklist to resolve " + "(remediate at the machine, or settle off-machine).", + ] + if report.error_class == "terminal": + lines.append( + "The machine has taken cash-out out of service until you recount " + "or resume it." + ) + return "\n".join(lines) + + +async def notify_cash_outcome( + settlement: DcaSettlement, + machine: Machine, + report: DispenseReportIn, + status: str, +) -> bool: + """Alert the operator that `settlement` needs a human. Best-effort: + returns True if the DM was published, False (after logging) otherwise. + Never raises — the caller is the dispense-report RPC and must ack.""" + if getattr(settlement, "operator_notified_at", None): + return False + try: + recipient = await resolve_alert_recipient(machine.operator_user_id) + text = format_cash_outcome_alert(settlement, machine, report, status) + wrapped = await build_gift_wrapped_dm(machine.operator_user_id, recipient, text) + await publish_signed_event(wrapped) + await mark_settlement_notified(settlement.id) + logger.info( + f"spirekeeper: operator alerted ({status}) for settlement {settlement.id} " + f"→ {recipient[:12]}… (gift wrap {wrapped['id'][:12]}…)" + ) + return True + except NostrPublishError as exc: + logger.warning( + f"spirekeeper: could not alert operator for settlement {settlement.id}: {exc} " + "— the worklist still has it" + ) + except Exception as exc: # never let an alert break the RPC + logger.error( + f"spirekeeper: unexpected error alerting operator for settlement " + f"{settlement.id}: {exc!r}" + ) + return False