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
70
crud.py
70
crud.py
|
|
@ -69,9 +69,7 @@ async def update_super_config(data: UpdateSuperConfigData) -> Optional[SuperConf
|
|||
# =============================================================================
|
||||
|
||||
|
||||
async def create_machine(
|
||||
operator_user_id: str, data: CreateMachineData
|
||||
) -> Machine:
|
||||
async def create_machine(operator_user_id: str, data: CreateMachineData) -> Machine:
|
||||
machine_id = urlsafe_short_hash()
|
||||
now = datetime.now()
|
||||
await db.execute(
|
||||
|
|
@ -143,9 +141,7 @@ async def get_machines_for_operator(operator_user_id: str) -> List[Machine]:
|
|||
)
|
||||
|
||||
|
||||
async def update_machine(
|
||||
machine_id: str, data: UpdateMachineData
|
||||
) -> Optional[Machine]:
|
||||
async def update_machine(machine_id: str, data: UpdateMachineData) -> Optional[Machine]:
|
||||
update_data = {k: v for k, v in data.dict().items() if v is not None}
|
||||
if not update_data:
|
||||
return await get_machine(machine_id)
|
||||
|
|
@ -308,9 +304,7 @@ async def delete_dca_client(client_id: str) -> None:
|
|||
# =============================================================================
|
||||
|
||||
|
||||
async def create_deposit(
|
||||
creator_user_id: str, data: CreateDepositData
|
||||
) -> DcaDeposit:
|
||||
async def create_deposit(creator_user_id: str, data: CreateDepositData) -> DcaDeposit:
|
||||
deposit_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
|
|
@ -422,11 +416,24 @@ async def delete_deposit(deposit_id: str) -> None:
|
|||
|
||||
async def create_settlement_idempotent(
|
||||
data: CreateDcaSettlementData,
|
||||
initial_status: str,
|
||||
error_message: Optional[str] = None,
|
||||
) -> Optional[DcaSettlement]:
|
||||
"""Insert a settlement keyed by payment_hash. Returns the inserted row on
|
||||
first sight; returns the existing row if the payment_hash was already seen
|
||||
(subscription replay, dispatcher double-fire). The UNIQUE constraint on
|
||||
payment_hash is the source of truth."""
|
||||
"""Insert a settlement keyed by payment_hash.
|
||||
|
||||
Returns the inserted row on first sight; returns the existing row
|
||||
if the payment_hash was already seen (subscription replay,
|
||||
dispatcher double-fire). The UNIQUE constraint on payment_hash is
|
||||
the source of truth.
|
||||
|
||||
`initial_status` is the row's status at insert time. Normal
|
||||
settlements arrive as 'pending' and the distribution processor
|
||||
transitions them through 'processing' → 'processed' / 'errored'.
|
||||
A row that fails the Nostr attribution cross-check (bitspire.
|
||||
assert_nostr_attribution) is inserted directly as 'rejected' with
|
||||
the failure reason in `error_message` — never goes near the
|
||||
distribution path.
|
||||
"""
|
||||
existing = await get_settlement_by_payment_hash(data.payment_hash)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
|
@ -438,12 +445,13 @@ async def create_settlement_idempotent(
|
|||
gross_sats, fiat_amount, fiat_code, exchange_rate, net_sats,
|
||||
commission_sats, platform_fee_sats, operator_fee_sats,
|
||||
used_fallback_split, tx_type, bills_json, cassettes_json,
|
||||
status, created_at)
|
||||
status, error_message, created_at)
|
||||
VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id,
|
||||
:bitspire_txid, :gross_sats, :fiat_amount, :fiat_code,
|
||||
:exchange_rate, :net_sats, :commission_sats,
|
||||
:platform_fee_sats, :operator_fee_sats, :used_fallback_split,
|
||||
:tx_type, :bills_json, :cassettes_json, :status, :created_at)
|
||||
:tx_type, :bills_json, :cassettes_json, :status,
|
||||
:error_message, :created_at)
|
||||
""",
|
||||
{
|
||||
"id": settlement_id,
|
||||
|
|
@ -463,7 +471,8 @@ async def create_settlement_idempotent(
|
|||
"tx_type": data.tx_type,
|
||||
"bills_json": data.bills_json,
|
||||
"cassettes_json": data.cassettes_json,
|
||||
"status": "pending",
|
||||
"status": initial_status,
|
||||
"error_message": error_message,
|
||||
"created_at": datetime.now(),
|
||||
},
|
||||
)
|
||||
|
|
@ -511,18 +520,34 @@ async def get_stuck_settlements_for_operator(
|
|||
) -> dict:
|
||||
"""Operator worklist of settlements that didn't process cleanly.
|
||||
|
||||
Returns a dict with three keyed lists:
|
||||
- 'errored': any status='errored' for this operator (no age filter —
|
||||
operators always want to see these)
|
||||
- 'stuck_pending': status='pending' AND older than threshold (listener
|
||||
crashed before invoking process_settlement)
|
||||
Returns a dict with four keyed lists:
|
||||
- 'rejected': any status='rejected' (Nostr attribution cross-check
|
||||
failed — signer didn't match the machine identity). Distinct
|
||||
from 'errored' because retry is wrong: the row was misrouted,
|
||||
not operationally failed. Operator must investigate the machine.
|
||||
- 'errored': any status='errored' (distribution failed for an
|
||||
operational reason — wallet error, network, downstream payment).
|
||||
Operator retries from this bucket.
|
||||
- 'stuck_pending': status='pending' AND older than threshold
|
||||
(listener crashed before invoking process_settlement).
|
||||
- 'stuck_processing': status='processing' AND older than threshold
|
||||
(processor crashed mid-flight; processing_claim is set but no
|
||||
completion landed)
|
||||
completion landed).
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
threshold_at = datetime.now() - timedelta(minutes=threshold_minutes)
|
||||
rejected = await db.fetchall(
|
||||
"""
|
||||
SELECT s.*
|
||||
FROM satoshimachine.dca_settlements s
|
||||
JOIN satoshimachine.dca_machines m ON m.id = s.machine_id
|
||||
WHERE m.operator_user_id = :uid AND s.status = 'rejected'
|
||||
ORDER BY s.created_at DESC
|
||||
""",
|
||||
{"uid": operator_user_id},
|
||||
DcaSettlement,
|
||||
)
|
||||
errored = await db.fetchall(
|
||||
"""
|
||||
SELECT s.*
|
||||
|
|
@ -561,6 +586,7 @@ async def get_stuck_settlements_for_operator(
|
|||
DcaSettlement,
|
||||
)
|
||||
return {
|
||||
"rejected": rejected,
|
||||
"errored": errored,
|
||||
"stuck_pending": stuck_pending,
|
||||
"stuck_processing": stuck_processing,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue