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
bitspire.py
46
bitspire.py
|
|
@ -65,6 +65,52 @@ def is_bitspire_payment(extra: dict) -> bool:
|
|||
return isinstance(extra, dict) and extra.get("source") == BITSPIRE_SOURCE
|
||||
|
||||
|
||||
class SettlementAttributionError(ValueError):
|
||||
"""The signer of the kind-21000 invoice doesn't match the machine identity.
|
||||
|
||||
Raised by `assert_nostr_attribution`. The caller records the
|
||||
settlement with `status='rejected'` and the exception message in
|
||||
`error_message`, then skips distribution.
|
||||
"""
|
||||
|
||||
|
||||
def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
|
||||
"""Assert that the originating Nostr signer pubkey matches the machine.
|
||||
|
||||
Reads `extra["nostr_sender_pubkey"]` — populated by LNbits'
|
||||
nostr-transport dispatcher from the signature-verified kind-21000
|
||||
event that triggered invoice creation (aiolabs/lnbits PR #4, S5/G5).
|
||||
Normalises both sides to lowercase hex via
|
||||
`lnbits.utils.nostr.normalize_public_key` (the UI lets operators
|
||||
enter either hex or `npub1...` bech32 for `machine.machine_npub`).
|
||||
|
||||
Raises `SettlementAttributionError` if the stamp is missing,
|
||||
unparseable, or doesn't match. In v2 every bitSpire ATM creates
|
||||
invoices via nostr-transport, so a settlement landing on a machine
|
||||
wallet without the stamp means the invoice was issued by some other
|
||||
path (HTTP API, manual UI, a different extension) — always wrong
|
||||
for a `dca_machines` wallet.
|
||||
"""
|
||||
sender_pubkey = _coerce_str(extra.get("nostr_sender_pubkey"))
|
||||
if not sender_pubkey:
|
||||
raise SettlementAttributionError(
|
||||
"missing nostr_sender_pubkey on Payment.extra — invoice was not "
|
||||
"issued through the nostr-transport path"
|
||||
)
|
||||
from lnbits.utils.nostr import normalize_public_key
|
||||
|
||||
try:
|
||||
expected = normalize_public_key(machine.machine_npub).lower()
|
||||
actual = normalize_public_key(sender_pubkey).lower()
|
||||
except (ValueError, AssertionError) as exc:
|
||||
raise SettlementAttributionError(f"unparseable pubkey: {exc}") from exc
|
||||
if expected != actual:
|
||||
raise SettlementAttributionError(
|
||||
f"signer {actual[:12]}... does not match "
|
||||
f"machine identity {expected[:12]}..."
|
||||
)
|
||||
|
||||
|
||||
def parse_settlement(
|
||||
machine: Machine,
|
||||
payment_hash: str,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue