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:
parent
47916bdddd
commit
9414a18f82
8 changed files with 301 additions and 75 deletions
46
tasks.py
46
tasks.py
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue