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
33
models.py
33
models.py
|
|
@ -220,7 +220,16 @@ class DcaSettlement(BaseModel):
|
|||
tx_type: str
|
||||
bills_json: Optional[str]
|
||||
cassettes_json: Optional[str]
|
||||
status: str # 'pending' | 'processed' | 'partial' | 'refunded' | 'errored'
|
||||
# 'pending' (default at insert)
|
||||
# 'processing' (claim taken by distribution processor)
|
||||
# 'processed' (all legs paid)
|
||||
# 'partial' (operator marked partial-dispense after the fact)
|
||||
# 'refunded' (operator-initiated refund)
|
||||
# 'errored' (operational distribution failure — retry path applies)
|
||||
# 'rejected' (Nostr attribution cross-check failed at land time;
|
||||
# never went near distribution. error_message holds the
|
||||
# reason. Retry is wrong — investigate the machine.)
|
||||
status: str
|
||||
error_message: Optional[str]
|
||||
processed_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
|
@ -433,21 +442,27 @@ class PartialDispenseData(BaseModel):
|
|||
class StuckSettlementsResponse(BaseModel):
|
||||
"""Operator worklist surfacing settlements that didn't process cleanly.
|
||||
|
||||
Three categories, segregated so the UI can render them with appropriate
|
||||
affordances (retry / investigate / force-error):
|
||||
Four categories, segregated so the UI can render them with the
|
||||
right affordances (investigate / retry / force-error):
|
||||
|
||||
- errored: distribution failed; one or more legs reported a payment
|
||||
- rejected: Nostr attribution cross-check failed at land time —
|
||||
the kind-21000 invoice signer didn't match the machine identity.
|
||||
Distribution never ran. Retry is *wrong* for these: the row was
|
||||
misrouted, not operationally failed. Operator investigates the
|
||||
machine.
|
||||
- errored: distribution ran and one or more legs reported a payment
|
||||
error. Operator retry endpoint handles these directly.
|
||||
- stuck_pending: landed but never picked up by the processor (listener
|
||||
crashed before invoking process_settlement, or the claim was lost).
|
||||
Older than `threshold_minutes`.
|
||||
- stuck_pending: landed but never picked up by the processor
|
||||
(listener crashed before invoking process_settlement, or the
|
||||
claim was lost). Older than `threshold_minutes`.
|
||||
- stuck_processing: claim was taken but no completion in
|
||||
`threshold_minutes`. The processor likely crashed mid-flight.
|
||||
`threshold_minutes`. Processor likely crashed mid-flight.
|
||||
Operator can force-recover via POST .../force-reset.
|
||||
"""
|
||||
|
||||
threshold_minutes: int
|
||||
errored: list # list[DcaSettlement]
|
||||
rejected: list # list[DcaSettlement]
|
||||
errored: list
|
||||
stuck_pending: list
|
||||
stuck_processing: list
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue