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
53
views_api.py
53
views_api.py
|
|
@ -105,9 +105,7 @@ async def api_create_machine(
|
|||
return await create_machine(user.id, data)
|
||||
|
||||
|
||||
@satmachineadmin_api_router.get(
|
||||
"/api/v1/dca/machines", response_model=list[Machine]
|
||||
)
|
||||
@satmachineadmin_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
|
||||
async def api_list_machines(
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> list[Machine]:
|
||||
|
|
@ -183,9 +181,7 @@ async def _client_owned_by(client_id: str, user_id: str) -> DcaClient:
|
|||
return client
|
||||
|
||||
|
||||
@satmachineadmin_api_router.post(
|
||||
"/api/v1/dca/clients", response_model=DcaClient
|
||||
)
|
||||
@satmachineadmin_api_router.post("/api/v1/dca/clients", response_model=DcaClient)
|
||||
async def api_create_client(
|
||||
data: CreateDcaClientData, user: User = Depends(check_user_exists)
|
||||
) -> DcaClient:
|
||||
|
|
@ -194,9 +190,7 @@ async def api_create_client(
|
|||
return await create_dca_client(data)
|
||||
|
||||
|
||||
@satmachineadmin_api_router.get(
|
||||
"/api/v1/dca/clients", response_model=list[DcaClient]
|
||||
)
|
||||
@satmachineadmin_api_router.get("/api/v1/dca/clients", response_model=list[DcaClient])
|
||||
async def api_list_clients(
|
||||
machine_id: str | None = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
|
|
@ -306,9 +300,7 @@ async def _deposit_owned_by(deposit_id: str, user_id: str) -> DcaDeposit:
|
|||
return deposit
|
||||
|
||||
|
||||
@satmachineadmin_api_router.post(
|
||||
"/api/v1/dca/deposits", response_model=DcaDeposit
|
||||
)
|
||||
@satmachineadmin_api_router.post("/api/v1/dca/deposits", response_model=DcaDeposit)
|
||||
async def api_create_deposit(
|
||||
data: CreateDepositData, user: User = Depends(check_user_exists)
|
||||
) -> DcaDeposit:
|
||||
|
|
@ -322,9 +314,7 @@ async def api_create_deposit(
|
|||
return await create_deposit(user.id, data)
|
||||
|
||||
|
||||
@satmachineadmin_api_router.get(
|
||||
"/api/v1/dca/deposits", response_model=list[DcaDeposit]
|
||||
)
|
||||
@satmachineadmin_api_router.get("/api/v1/dca/deposits", response_model=list[DcaDeposit])
|
||||
async def api_list_deposits(
|
||||
client_id: str | None = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
|
|
@ -439,8 +429,10 @@ async def api_list_stuck_settlements(
|
|||
) -> StuckSettlementsResponse:
|
||||
"""Operator worklist of settlements that didn't process cleanly.
|
||||
|
||||
Returns three lists:
|
||||
- errored: distribution failed; retry endpoint handles these
|
||||
Returns four lists:
|
||||
- rejected: Nostr attribution cross-check failed — signer didn't
|
||||
match the machine identity. Investigate; do not retry.
|
||||
- errored: distribution ran and failed; retry endpoint handles these
|
||||
- stuck_pending: landed but never picked up by the processor
|
||||
- stuck_processing: claim taken but no completion in N minutes
|
||||
|
||||
|
|
@ -448,12 +440,11 @@ async def api_list_stuck_settlements(
|
|||
Operators can force-recover stuck-processing settlements via
|
||||
POST /api/v1/dca/settlements/{id}/force-reset."""
|
||||
if threshold_minutes < 1:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, "threshold_minutes must be >= 1"
|
||||
)
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "threshold_minutes must be >= 1")
|
||||
buckets = await get_stuck_settlements_for_operator(user.id, threshold_minutes)
|
||||
return StuckSettlementsResponse(
|
||||
threshold_minutes=threshold_minutes,
|
||||
rejected=buckets["rejected"],
|
||||
errored=buckets["errored"],
|
||||
stuck_pending=buckets["stuck_pending"],
|
||||
stuck_processing=buckets["stuck_processing"],
|
||||
|
|
@ -556,9 +547,7 @@ async def api_force_reset_settlement(
|
|||
)
|
||||
updated = await force_reset_stuck_settlement(settlement_id)
|
||||
if updated is None:
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR, "failed to force-reset"
|
||||
)
|
||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "failed to force-reset")
|
||||
return updated
|
||||
|
||||
|
||||
|
|
@ -648,9 +637,7 @@ async def api_append_settlement_note(
|
|||
# =============================================================================
|
||||
|
||||
|
||||
@satmachineadmin_api_router.get(
|
||||
"/api/v1/dca/payments", response_model=list[DcaPayment]
|
||||
)
|
||||
@satmachineadmin_api_router.get("/api/v1/dca/payments", response_model=list[DcaPayment])
|
||||
async def api_list_payments(
|
||||
leg_type: str | None = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
|
|
@ -723,9 +710,7 @@ async def api_delete_commission_splits(
|
|||
# =============================================================================
|
||||
|
||||
|
||||
@satmachineadmin_api_router.get(
|
||||
"/api/v1/dca/super-config", response_model=SuperConfig
|
||||
)
|
||||
@satmachineadmin_api_router.get("/api/v1/dca/super-config", response_model=SuperConfig)
|
||||
async def api_get_super_config(
|
||||
_user: User = Depends(check_user_exists),
|
||||
) -> SuperConfig:
|
||||
|
|
@ -734,15 +719,11 @@ async def api_get_super_config(
|
|||
instance-wide; operators see it but can't change it."""
|
||||
config = await get_super_config()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, "Super config not initialised"
|
||||
)
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Super config not initialised")
|
||||
return config
|
||||
|
||||
|
||||
@satmachineadmin_api_router.put(
|
||||
"/api/v1/dca/super-config", response_model=SuperConfig
|
||||
)
|
||||
@satmachineadmin_api_router.put("/api/v1/dca/super-config", response_model=SuperConfig)
|
||||
async def api_update_super_config(
|
||||
data: UpdateSuperConfigData,
|
||||
_user: User = Depends(check_super_user),
|
||||
|
|
@ -757,5 +738,3 @@ async def api_update_super_config(
|
|||
HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to update super config"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue