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
112
tests/test_nostr_attribution.py
Normal file
112
tests/test_nostr_attribution.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""
|
||||
Tests for `bitspire.assert_nostr_attribution` — the S5 consumer-side
|
||||
cross-check that pairs the signature-verified signer pubkey LNbits
|
||||
stamps onto Payment.extra (post aiolabs/lnbits PR #4) with the machine
|
||||
record we're about to credit.
|
||||
|
||||
In v2 every bitSpire ATM creates invoices via nostr-transport, so any
|
||||
inbound payment landing on a `dca_machines` wallet must carry
|
||||
`extra["nostr_sender_pubkey"]` and that pubkey must canonicalise to
|
||||
the same hex as `machine.machine_npub`. Anything else raises
|
||||
`SettlementAttributionError` and the listener records the row with
|
||||
`status='rejected'` instead of distributing.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from ..bitspire import SettlementAttributionError, assert_nostr_attribution
|
||||
from ..models import Machine
|
||||
|
||||
# A real Nostr pubkey pair (hex + canonical bech32). Throwaway fixture —
|
||||
# never used to sign anything live.
|
||||
_PUBKEY_HEX = "82341f882b6eabcbd6b1c2da5cd14df14b8e91dd0e6da41a72b78ad8f3a7d3b9"
|
||||
_PUBKEY_NPUB = "npub1sg6plzptd64uh443ctd9e52d799caywapek6gxnjk79d3ua86wuszhap5a"
|
||||
_OTHER_HEX = "deadbeef" * 8
|
||||
|
||||
|
||||
def _machine(npub: str) -> Machine:
|
||||
now = datetime.now(timezone.utc)
|
||||
return Machine(
|
||||
id="m1",
|
||||
operator_user_id="op1",
|
||||
machine_npub=npub,
|
||||
wallet_id="w1",
|
||||
name="sintra-1",
|
||||
location=None,
|
||||
fiat_code="EUR",
|
||||
is_active=True,
|
||||
fallback_commission_pct=0.05,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def test_returns_silently_when_sender_hex_matches_machine_hex():
|
||||
assert_nostr_attribution(
|
||||
_machine(_PUBKEY_HEX),
|
||||
{"source": "bitspire", "nostr_sender_pubkey": _PUBKEY_HEX},
|
||||
)
|
||||
|
||||
|
||||
def test_returns_silently_when_sender_hex_matches_machine_bech32():
|
||||
"""Operator entered npub1... in the UI; LNbits stamps hex. Both must
|
||||
normalise to the same canonical hex before comparison."""
|
||||
assert_nostr_attribution(
|
||||
_machine(_PUBKEY_NPUB),
|
||||
{"source": "bitspire", "nostr_sender_pubkey": _PUBKEY_HEX},
|
||||
)
|
||||
|
||||
|
||||
def test_returns_silently_under_case_variance():
|
||||
assert_nostr_attribution(
|
||||
_machine(_PUBKEY_HEX.upper()),
|
||||
{"nostr_sender_pubkey": _PUBKEY_HEX.lower()},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra",
|
||||
[
|
||||
{},
|
||||
{"source": "bitspire"},
|
||||
{"nostr_sender_pubkey": ""},
|
||||
{"nostr_sender_pubkey": None},
|
||||
],
|
||||
)
|
||||
def test_raises_when_attribution_absent(extra):
|
||||
"""Every cash-out invoice goes through nostr-transport in v2; a
|
||||
settlement reaching a machine wallet without `nostr_sender_pubkey`
|
||||
means it was issued by some other path (HTTP API, manual UI, a
|
||||
different extension). Always wrong for a `dca_machines` wallet."""
|
||||
with pytest.raises(SettlementAttributionError) as exc:
|
||||
assert_nostr_attribution(_machine(_PUBKEY_HEX), extra)
|
||||
assert "missing nostr_sender_pubkey" in str(exc.value)
|
||||
|
||||
|
||||
def test_raises_when_sender_differs_from_machine():
|
||||
with pytest.raises(SettlementAttributionError) as exc:
|
||||
assert_nostr_attribution(
|
||||
_machine(_PUBKEY_HEX),
|
||||
{"nostr_sender_pubkey": _OTHER_HEX},
|
||||
)
|
||||
assert "does not match" in str(exc.value)
|
||||
|
||||
|
||||
def test_raises_when_sender_pubkey_unparseable():
|
||||
with pytest.raises(SettlementAttributionError) as exc:
|
||||
assert_nostr_attribution(
|
||||
_machine(_PUBKEY_HEX),
|
||||
{"nostr_sender_pubkey": "not-a-real-pubkey"},
|
||||
)
|
||||
assert "unparseable pubkey" in str(exc.value)
|
||||
|
||||
|
||||
def test_raises_when_machine_npub_unparseable():
|
||||
with pytest.raises(SettlementAttributionError) as exc:
|
||||
assert_nostr_attribution(
|
||||
_machine("not-a-real-pubkey"),
|
||||
{"nostr_sender_pubkey": _PUBKEY_HEX},
|
||||
)
|
||||
assert "unparseable pubkey" in str(exc.value)
|
||||
Loading…
Add table
Add a link
Reference in a new issue