feat(v2): reject settlements that fail nostr attribution cross-check (S5 G5)

When LNbits' nostr-transport stamps `nostr_sender_pubkey` and
`nostr_event_id` onto Payment.extra (post aiolabs/lnbits PR #4), the
listener now cross-checks the signer against the resolved machine's
`machine_npub` before any distribution. Mismatch / absence / unparseable
pubkey → settlement is recorded with `status='rejected'` and the
reason in `error_message`, distribution is skipped.

Wire shape:

  bitspire.SettlementAttributionError + assert_nostr_attribution()
    Raises on absence, mismatch, or unparseable pubkey on either side.
    Normalises both `machine.machine_npub` (operator UI accepts hex
    or `npub1...`) and the stamped sender through
    `lnbits.utils.nostr.normalize_public_key` so the comparison is
    canonical-hex on both sides.

  tasks._handle_payment
    parse_settlement -> stamp nostr_event_id onto bitspire_event_id ->
    try assert_nostr_attribution: on failure, insert row with
    initial_status='rejected' + error_message, return without
    spawning process_settlement.

  crud.create_settlement_idempotent
    Now takes `initial_status` (required) and `error_message`.
    Normal path passes 'pending'; rejected path passes 'rejected'
    with the reason. Single-statement insert — no two-step pending->
    errored dance.

  crud.get_stuck_settlements_for_operator
    New `rejected` bucket alongside `errored` / `stuck_pending` /
    `stuck_processing`. Distinct because retry is wrong for these:
    the row was misrouted, not operationally failed.

  models.DcaSettlement.status enum extended with 'rejected'.
    Worklist response model carries the new bucket; API + UI plumbed
    end-to-end.

  static/js/index.js + templates/satmachineadmin/index.html
    New 'rejected' worklist bucket (deep-orange, gpp_bad icon).
    Force-reset button now scoped to stuck_pending / stuck_processing
    only — was 'not errored' which would have shown on rejected too.

10 unit tests in tests/test_nostr_attribution.py cover hex<->hex,
hex<->bech32, case-insensitivity, every absent variant, mismatch,
and unparseable on either side. All pass.

Closes the consumer-side of aiolabs/satmachineadmin#19 (G5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-15 22:39:30 +02:00
commit 9414a18f82
8 changed files with 301 additions and 75 deletions

View file

@ -18,7 +18,11 @@ from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from loguru import logger
from .bitspire import parse_settlement
from .bitspire import (
SettlementAttributionError,
assert_nostr_attribution,
parse_settlement,
)
from .crud import (
create_settlement_idempotent,
get_active_machine_by_wallet_id,
@ -59,16 +63,50 @@ async def _handle_payment(payment: Payment) -> None:
machine = await get_active_machine_by_wallet_id(payment.wallet_id)
if machine is None:
return
extra = payment.extra or {}
super_config = await get_super_config()
super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
data, used_fallback = parse_settlement(
machine=machine,
payment_hash=payment.payment_hash,
gross_sats=payment.sat,
extra=payment.extra or {},
extra=extra,
super_fee_pct=super_fee_pct,
)
settlement = await create_settlement_idempotent(data)
# Stamp the originating Nostr event id (the kind-21000 create_invoice
# RPC) onto the row for post-hoc forensics — pairs with the
# assert_nostr_attribution check below so an auditor can trace
# settlement -> RPC event -> signing key without trusting our DB.
nostr_event_id = extra.get("nostr_event_id")
if isinstance(nostr_event_id, str) and nostr_event_id:
data.bitspire_event_id = nostr_event_id
# Cross-check the signature-verified signer pubkey (stamped by
# LNbits' nostr-transport dispatcher onto Payment.extra) against
# the machine identity. Routing today is wallet_id-only with no
# cryptographic binding — this restores end-to-end attribution
# between "the npub that asked LNbits for the invoice" and "the
# machine we're crediting" (aiolabs/satmachineadmin#19, G5).
try:
assert_nostr_attribution(machine, extra)
except SettlementAttributionError as exc:
rejected = await create_settlement_idempotent(
data, initial_status="rejected", error_message=str(exc)
)
if rejected is None:
logger.error(
f"satmachineadmin: failed to insert rejected settlement for "
f"payment_hash={payment.payment_hash[:12]}..."
)
return
logger.error(
f"satmachineadmin: rejected settlement {rejected.id} "
f"(machine={machine.machine_npub[:12]}..., "
f"payment_hash={payment.payment_hash[:12]}...): {exc}"
)
return
settlement = await create_settlement_idempotent(data, initial_status="pending")
if settlement is None:
logger.error(
f"satmachineadmin: failed to insert settlement for "
@ -93,5 +131,3 @@ async def _handle_payment(payment: Payment) -> None:
task = asyncio.create_task(process_settlement(settlement.id))
_inflight_distributions.add(task)
task.add_done_callback(_inflight_distributions.discard)