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
46
bitspire.py
46
bitspire.py
|
|
@ -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
70
crud.py
|
|
@ -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,
|
||||||
|
|
|
||||||
33
models.py
33
models.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
46
tasks.py
46
tasks.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)">
|
||||||
|
|
|
||||||
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)
|
||||||
53
views_api.py
53
views_api.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue