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:
Padreug 2026-05-15 22:39:30 +02:00
commit 9414a18f82
8 changed files with 301 additions and 75 deletions

View file

@ -65,6 +65,52 @@ def is_bitspire_payment(extra: dict) -> bool:
return isinstance(extra, dict) and extra.get("source") == BITSPIRE_SOURCE 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( def parse_settlement(
machine: Machine, machine: Machine,
payment_hash: str, payment_hash: str,

70
crud.py
View file

@ -69,9 +69,7 @@ async def update_super_config(data: UpdateSuperConfigData) -> Optional[SuperConf
# ============================================================================= # =============================================================================
async def create_machine( async def create_machine(operator_user_id: str, data: CreateMachineData) -> Machine:
operator_user_id: str, data: CreateMachineData
) -> Machine:
machine_id = urlsafe_short_hash() machine_id = urlsafe_short_hash()
now = datetime.now() now = datetime.now()
await db.execute( await db.execute(
@ -143,9 +141,7 @@ async def get_machines_for_operator(operator_user_id: str) -> List[Machine]:
) )
async def update_machine( async def update_machine(machine_id: str, data: UpdateMachineData) -> Optional[Machine]:
machine_id: str, data: UpdateMachineData
) -> Optional[Machine]:
update_data = {k: v for k, v in data.dict().items() if v is not None} update_data = {k: v for k, v in data.dict().items() if v is not None}
if not update_data: if not update_data:
return await get_machine(machine_id) return await get_machine(machine_id)
@ -308,9 +304,7 @@ async def delete_dca_client(client_id: str) -> None:
# ============================================================================= # =============================================================================
async def create_deposit( async def create_deposit(creator_user_id: str, data: CreateDepositData) -> DcaDeposit:
creator_user_id: str, data: CreateDepositData
) -> DcaDeposit:
deposit_id = urlsafe_short_hash() deposit_id = urlsafe_short_hash()
await db.execute( await db.execute(
""" """
@ -422,11 +416,24 @@ async def delete_deposit(deposit_id: str) -> None:
async def create_settlement_idempotent( async def create_settlement_idempotent(
data: CreateDcaSettlementData, data: CreateDcaSettlementData,
initial_status: str,
error_message: Optional[str] = None,
) -> Optional[DcaSettlement]: ) -> Optional[DcaSettlement]:
"""Insert a settlement keyed by payment_hash. Returns the inserted row on """Insert a settlement keyed by payment_hash.
first sight; returns the existing row if the payment_hash was already seen
(subscription replay, dispatcher double-fire). The UNIQUE constraint on Returns the inserted row on first sight; returns the existing row
payment_hash is the source of truth.""" 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) existing = await get_settlement_by_payment_hash(data.payment_hash)
if existing is not None: if existing is not None:
return existing return existing
@ -438,12 +445,13 @@ async def create_settlement_idempotent(
gross_sats, fiat_amount, fiat_code, exchange_rate, net_sats, gross_sats, fiat_amount, fiat_code, exchange_rate, net_sats,
commission_sats, platform_fee_sats, operator_fee_sats, commission_sats, platform_fee_sats, operator_fee_sats,
used_fallback_split, tx_type, bills_json, cassettes_json, 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, VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id,
:bitspire_txid, :gross_sats, :fiat_amount, :fiat_code, :bitspire_txid, :gross_sats, :fiat_amount, :fiat_code,
:exchange_rate, :net_sats, :commission_sats, :exchange_rate, :net_sats, :commission_sats,
:platform_fee_sats, :operator_fee_sats, :used_fallback_split, :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, "id": settlement_id,
@ -463,7 +471,8 @@ async def create_settlement_idempotent(
"tx_type": data.tx_type, "tx_type": data.tx_type,
"bills_json": data.bills_json, "bills_json": data.bills_json,
"cassettes_json": data.cassettes_json, "cassettes_json": data.cassettes_json,
"status": "pending", "status": initial_status,
"error_message": error_message,
"created_at": datetime.now(), "created_at": datetime.now(),
}, },
) )
@ -511,18 +520,34 @@ async def get_stuck_settlements_for_operator(
) -> dict: ) -> dict:
"""Operator worklist of settlements that didn't process cleanly. """Operator worklist of settlements that didn't process cleanly.
Returns a dict with three keyed lists: Returns a dict with four keyed lists:
- 'errored': any status='errored' for this operator (no age filter - 'rejected': any status='rejected' (Nostr attribution cross-check
operators always want to see these) failed signer didn't match the machine identity). Distinct
- 'stuck_pending': status='pending' AND older than threshold (listener from 'errored' because retry is wrong: the row was misrouted,
crashed before invoking process_settlement) 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 - 'stuck_processing': status='processing' AND older than threshold
(processor crashed mid-flight; processing_claim is set but no (processor crashed mid-flight; processing_claim is set but no
completion landed) completion landed).
""" """
from datetime import timedelta from datetime import timedelta
threshold_at = datetime.now() - timedelta(minutes=threshold_minutes) 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( errored = await db.fetchall(
""" """
SELECT s.* SELECT s.*
@ -561,6 +586,7 @@ async def get_stuck_settlements_for_operator(
DcaSettlement, DcaSettlement,
) )
return { return {
"rejected": rejected,
"errored": errored, "errored": errored,
"stuck_pending": stuck_pending, "stuck_pending": stuck_pending,
"stuck_processing": stuck_processing, "stuck_processing": stuck_processing,

View file

@ -220,7 +220,16 @@ class DcaSettlement(BaseModel):
tx_type: str tx_type: str
bills_json: Optional[str] bills_json: Optional[str]
cassettes_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] error_message: Optional[str]
processed_at: Optional[datetime] processed_at: Optional[datetime]
created_at: datetime created_at: datetime
@ -433,21 +442,27 @@ class PartialDispenseData(BaseModel):
class StuckSettlementsResponse(BaseModel): class StuckSettlementsResponse(BaseModel):
"""Operator worklist surfacing settlements that didn't process cleanly. """Operator worklist surfacing settlements that didn't process cleanly.
Three categories, segregated so the UI can render them with appropriate Four categories, segregated so the UI can render them with the
affordances (retry / investigate / force-error): 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. error. Operator retry endpoint handles these directly.
- stuck_pending: landed but never picked up by the processor (listener - stuck_pending: landed but never picked up by the processor
crashed before invoking process_settlement, or the claim was lost). (listener crashed before invoking process_settlement, or the
Older than `threshold_minutes`. claim was lost). Older than `threshold_minutes`.
- stuck_processing: claim was taken but no completion in - 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. Operator can force-recover via POST .../force-reset.
""" """
threshold_minutes: int threshold_minutes: int
errored: list # list[DcaSettlement] rejected: list # list[DcaSettlement]
errored: list
stuck_pending: list stuck_pending: list
stuck_processing: list stuck_processing: list

View file

@ -33,7 +33,8 @@ const SETTLEMENT_STATUS_COLOR = {
processed: 'green', processed: 'green',
partial: 'orange', partial: 'orange',
refunded: 'purple', refunded: 'purple',
errored: 'red' errored: 'red',
rejected: 'deep-orange'
} }
window.app = Vue.createApp({ window.app = Vue.createApp({
@ -69,6 +70,7 @@ window.app = Vue.createApp({
// Worklist (P9g) // Worklist (P9g)
worklist: { worklist: {
rejected: [],
errored: [], errored: [],
stuck_pending: [], stuck_pending: [],
stuck_processing: [], stuck_processing: [],
@ -262,6 +264,13 @@ window.app = Vue.createApp({
}, },
worklistBuckets() { worklistBuckets() {
return [ return [
{
key: 'rejected',
label: 'Rejected — Nostr attribution failed; investigate machine',
icon: 'gpp_bad',
color: 'deep-orange',
rows: this.worklist.rejected
},
{ {
key: 'errored', key: 'errored',
label: 'Errored — needs retry', label: 'Errored — needs retry',
@ -443,6 +452,7 @@ window.app = Vue.createApp({
try { try {
const {data} = await LNbits.api.request('GET', STUCK_PATH) const {data} = await LNbits.api.request('GET', STUCK_PATH)
this.worklistCount = this.worklistCount =
(data?.rejected?.length || 0) +
(data?.errored?.length || 0) + (data?.errored?.length || 0) +
(data?.stuck_pending?.length || 0) + (data?.stuck_pending?.length || 0) +
(data?.stuck_processing?.length || 0) (data?.stuck_processing?.length || 0)
@ -457,10 +467,12 @@ window.app = Vue.createApp({
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', `${STUCK_PATH}?threshold_minutes=${this.worklistThreshold}` 'GET', `${STUCK_PATH}?threshold_minutes=${this.worklistThreshold}`
) )
this.worklist.rejected = data?.rejected || []
this.worklist.errored = data?.errored || [] this.worklist.errored = data?.errored || []
this.worklist.stuck_pending = data?.stuck_pending || [] this.worklist.stuck_pending = data?.stuck_pending || []
this.worklist.stuck_processing = data?.stuck_processing || [] this.worklist.stuck_processing = data?.stuck_processing || []
this.worklist.totalCount = this.worklist.totalCount =
this.worklist.rejected.length +
this.worklist.errored.length + this.worklist.errored.length +
this.worklist.stuck_pending.length + this.worklist.stuck_pending.length +
this.worklist.stuck_processing.length this.worklist.stuck_processing.length

View file

@ -18,7 +18,11 @@ from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from loguru import logger from loguru import logger
from .bitspire import parse_settlement from .bitspire import (
SettlementAttributionError,
assert_nostr_attribution,
parse_settlement,
)
from .crud import ( from .crud import (
create_settlement_idempotent, create_settlement_idempotent,
get_active_machine_by_wallet_id, get_active_machine_by_wallet_id,
@ -59,16 +63,50 @@ async def _handle_payment(payment: Payment) -> None:
machine = await get_active_machine_by_wallet_id(payment.wallet_id) machine = await get_active_machine_by_wallet_id(payment.wallet_id)
if machine is None: if machine is None:
return return
extra = payment.extra or {}
super_config = await get_super_config() super_config = await get_super_config()
super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0 super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
data, used_fallback = parse_settlement( data, used_fallback = parse_settlement(
machine=machine, machine=machine,
payment_hash=payment.payment_hash, payment_hash=payment.payment_hash,
gross_sats=payment.sat, gross_sats=payment.sat,
extra=payment.extra or {}, extra=extra,
super_fee_pct=super_fee_pct, super_fee_pct=super_fee_pct,
) )
settlement = await create_settlement_idempotent(data) # Stamp the originating Nostr event id (the kind-21000 create_invoice
# RPC) onto the row for post-hoc forensics — pairs with the
# assert_nostr_attribution check below so an auditor can trace
# settlement -> RPC event -> signing key without trusting our DB.
nostr_event_id = extra.get("nostr_event_id")
if isinstance(nostr_event_id, str) and nostr_event_id:
data.bitspire_event_id = nostr_event_id
# Cross-check the signature-verified signer pubkey (stamped by
# LNbits' nostr-transport dispatcher onto Payment.extra) against
# the machine identity. Routing today is wallet_id-only with no
# cryptographic binding — this restores end-to-end attribution
# between "the npub that asked LNbits for the invoice" and "the
# machine we're crediting" (aiolabs/satmachineadmin#19, G5).
try:
assert_nostr_attribution(machine, extra)
except SettlementAttributionError as exc:
rejected = await create_settlement_idempotent(
data, initial_status="rejected", error_message=str(exc)
)
if rejected is None:
logger.error(
f"satmachineadmin: failed to insert rejected settlement for "
f"payment_hash={payment.payment_hash[:12]}..."
)
return
logger.error(
f"satmachineadmin: rejected settlement {rejected.id} "
f"(machine={machine.machine_npub[:12]}..., "
f"payment_hash={payment.payment_hash[:12]}...): {exc}"
)
return
settlement = await create_settlement_idempotent(data, initial_status="pending")
if settlement is None: if settlement is None:
logger.error( logger.error(
f"satmachineadmin: failed to insert settlement for " f"satmachineadmin: failed to insert settlement for "
@ -93,5 +131,3 @@ async def _handle_payment(payment: Payment) -> None:
task = asyncio.create_task(process_settlement(settlement.id)) task = asyncio.create_task(process_settlement(settlement.id))
_inflight_distributions.add(task) _inflight_distributions.add(task)
task.add_done_callback(_inflight_distributions.discard) task.add_done_callback(_inflight_distributions.discard)

View file

@ -653,7 +653,7 @@
@click="confirmRetryFromWorklist(props.row)"> @click="confirmRetryFromWorklist(props.row)">
<q-tooltip>Retry distribution</q-tooltip> <q-tooltip>Retry distribution</q-tooltip>
</q-btn> </q-btn>
<q-btn v-if="bucket.key !== 'errored'" <q-btn v-if="bucket.key === 'stuck_pending' || bucket.key === 'stuck_processing'"
flat dense size="sm" icon="local_fire_department" flat dense size="sm" icon="local_fire_department"
color="red" color="red"
@click="confirmForceResetFromWorklist(props.row)"> @click="confirmForceResetFromWorklist(props.row)">

View 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)

View file

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