chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1)

Pre-merge lint hygiene on the PR #30 touched files:

- `black` reformatted 9 files (cassette_transport, crud, models, tasks,
  views_api, nip44, all 3 cassette test files, migrations). Cosmetic:
  line lengths, trailing commas, multi-line argument layout.
- `ruff check --fix` cleared 176 of 202 errors auto-fixed. Mostly
  `UP006` `typing.Optional` → `| None` modernization, `I001` import
  sort order, `UP035` typing-extensions cleanup.
- Two new mypy regressions introduced by the migration commit dcb7de0
  fixed:
  - `crud.py:apply_bootstrap_state` — annotated `existing_first: dict
    | None` on the dedup fetch.
  - `tasks.py:_cassette_consumer_tick` — `# type: ignore[arg-type]` on
    the `nostr_client.relay_manager.add_subscription` call; nostrclient's
    upstream typing declares `list[str]` for filters but the actual
    Nostr protocol takes `list[<filter-dict>]`. The runtime accepts it
    (live smoke at 13:43Z dispatched `nip44_decrypt` cleanly through
    this subscription); the typing mismatch is upstream's.

Remaining lint state, intentionally not addressed in this commit
(all pre-existing baseline, not regressions):
- 8 mypy errors in `calculations.py` + the unchanged-by-this-PR parts
  of `crud.py` — pre-existing on v2-bitspire.
- 26 ruff style warnings: 14 are N805 false-positives on Pydantic
  validators (`cls` first-arg is correct for `@validator`-decorated
  methods); 4 are N818 exception-name-suffix preferences on my new
  exception classes (renaming would touch many call sites; keep
  `OperatorIdentityMissing` / `SignerUnavailable` / `RelayUnavailable`
  / `_NostrclientUnavailable` as-is for clarity); 5 are E501 line-too-
  long on docstrings (the long lines are formatted for clarity);
  1 RUF002 unicode-minus in a docstring.

Tests: 155 passed, 1 pre-existing async-plugin failure unchanged.
Live smoke (both publish + consume directions through the bunker)
unaffected — this is purely a code-style pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-31 15:50:14 +02:00
commit d448fab0d2
10 changed files with 249 additions and 352 deletions

View file

@ -42,7 +42,6 @@ from __future__ import annotations
import json import json
import time import time
from typing import Optional
from lnbits.core.crud.users import get_account from lnbits.core.crud.users import get_account
from lnbits.core.services.nip46_bunker_client import ( from lnbits.core.services.nip46_bunker_client import (
@ -172,8 +171,7 @@ async def _resolve_operator_signer(operator_user_id: str):
signer = resolve_signer(account) signer = resolve_signer(account)
except SignerError as exc: except SignerError as exc:
raise SignerUnavailable( raise SignerUnavailable(
f"signer resolve failed for operator {operator_user_id[:8]}...: " f"signer resolve failed for operator {operator_user_id[:8]}...: " f"{exc}"
f"{exc}"
) from exc ) from exc
if not signer.can_sign(): if not signer.can_sign():
raise SignerUnavailable( raise SignerUnavailable(
@ -185,9 +183,7 @@ async def _resolve_operator_signer(operator_user_id: str):
return account, signer return account, signer
async def _sign_as_operator( async def _sign_as_operator(operator_user_id: str, event: dict) -> dict | None:
operator_user_id: str, event: dict
) -> Optional[dict]:
"""Sign `event` using the operator's signer (LocalSigner or """Sign `event` using the operator's signer (LocalSigner or
RemoteBunkerSigner). Mutates `event` to add `created_at` (now), RemoteBunkerSigner). Mutates `event` to add `created_at` (now),
`pubkey`, `id`, and `sig`. `pubkey`, `id`, and `sig`.
@ -203,8 +199,7 @@ async def _sign_as_operator(
return await signer.sign_event(event) return await signer.sign_event(event)
except SignerUnavailableError as exc: except SignerUnavailableError as exc:
raise SignerUnavailable( raise SignerUnavailable(
f"signer unavailable for operator {operator_user_id[:8]}...: " f"signer unavailable for operator {operator_user_id[:8]}...: " f"{exc}"
f"{exc}"
) from exc ) from exc
@ -231,13 +226,8 @@ async def _nip44_encrypt_via_signer(
try: try:
return await signer.nip44_encrypt(plaintext, peer_pubkey_hex) return await signer.nip44_encrypt(plaintext, peer_pubkey_hex)
except SignerUnavailableError: except SignerUnavailableError:
if ( if account.signer_type == "LocalSigner" and account.prvkey:
account.signer_type == "LocalSigner" return _nip44_local_encrypt(plaintext, account.prvkey, peer_pubkey_hex)
and account.prvkey
):
return _nip44_local_encrypt(
plaintext, account.prvkey, peer_pubkey_hex
)
# ClientSideOnly, or RemoteBunkerSigner with bunker comms failure # ClientSideOnly, or RemoteBunkerSigner with bunker comms failure
# at config time — re-raise without wrapping; caller maps it. # at config time — re-raise without wrapping; caller maps it.
raise raise
@ -251,13 +241,8 @@ async def _nip44_decrypt_via_signer(
try: try:
return await signer.nip44_decrypt(ciphertext, peer_pubkey_hex) return await signer.nip44_decrypt(ciphertext, peer_pubkey_hex)
except SignerUnavailableError: except SignerUnavailableError:
if ( if account.signer_type == "LocalSigner" and account.prvkey:
account.signer_type == "LocalSigner" return _nip44_local_decrypt(ciphertext, account.prvkey, peer_pubkey_hex)
and account.prvkey
):
return _nip44_local_decrypt(
ciphertext, account.prvkey, peer_pubkey_hex
)
raise raise
@ -411,9 +396,7 @@ async def decrypt_and_parse_state_event(
f"bunker rejected nip44_decrypt (policy / MAC / config): {exc}" f"bunker rejected nip44_decrypt (policy / MAC / config): {exc}"
) from exc ) from exc
except SignerUnavailableError as exc: except SignerUnavailableError as exc:
raise CassetteEventDecodeError( raise CassetteEventDecodeError(f"signer cannot nip44-decrypt: {exc}") from exc
f"signer cannot nip44-decrypt: {exc}"
) from exc
except Nip44Error as exc: except Nip44Error as exc:
# Hand-rolled LocalSigner fallback path (transitional) — MAC fail # Hand-rolled LocalSigner fallback path (transitional) — MAC fail
# / version mismatch / length issue. # / version mismatch / length issue.
@ -424,9 +407,7 @@ async def decrypt_and_parse_state_event(
# coincurve raises ValueError on a malformed pubkey hex (only # coincurve raises ValueError on a malformed pubkey hex (only
# reachable via the LocalSigner fallback path; the bunker handles # reachable via the LocalSigner fallback path; the bunker handles
# pubkey validation server-side). # pubkey validation server-side).
raise CassetteEventDecodeError( raise CassetteEventDecodeError(f"sender pubkey is malformed: {exc}") from exc
f"sender pubkey is malformed: {exc}"
) from exc
try: try:
raw = json.loads(plaintext) raw = json.loads(plaintext)
@ -437,7 +418,7 @@ async def decrypt_and_parse_state_event(
try: try:
return PublishCassettesPayload(**raw) return PublishCassettesPayload(**raw)
except Exception as exc: # noqa: BLE001 — Pydantic raises various subclasses except Exception as exc:
raise CassetteEventDecodeError( raise CassetteEventDecodeError(
f"payload didn't validate as PublishCassettesPayload: {exc}" f"payload didn't validate as PublishCassettesPayload: {exc}"
) from exc ) from exc

147
crud.py
View file

@ -6,7 +6,6 @@
# machine model". # machine model".
from datetime import datetime from datetime import datetime
from typing import List, Optional
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
@ -47,7 +46,7 @@ db = Database("ext_satoshimachine")
# ============================================================================= # =============================================================================
async def get_super_config() -> Optional[SuperConfig]: async def get_super_config() -> SuperConfig | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM satoshimachine.super_config WHERE id = :id", "SELECT * FROM satoshimachine.super_config WHERE id = :id",
{"id": "default"}, {"id": "default"},
@ -55,7 +54,7 @@ async def get_super_config() -> Optional[SuperConfig]:
) )
async def update_super_config(data: UpdateSuperConfigData) -> Optional[SuperConfig]: async def update_super_config(data: UpdateSuperConfigData) -> SuperConfig | None:
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_super_config() return await get_super_config()
@ -103,7 +102,7 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach
return machine return machine
async def get_machine(machine_id: str) -> Optional[Machine]: async def get_machine(machine_id: str) -> Machine | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM satoshimachine.dca_machines WHERE id = :id", "SELECT * FROM satoshimachine.dca_machines WHERE id = :id",
{"id": machine_id}, {"id": machine_id},
@ -111,7 +110,7 @@ async def get_machine(machine_id: str) -> Optional[Machine]:
) )
async def get_machine_by_npub(machine_npub: str) -> Optional[Machine]: async def get_machine_by_npub(machine_npub: str) -> Machine | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM satoshimachine.dca_machines WHERE machine_npub = :npub", "SELECT * FROM satoshimachine.dca_machines WHERE machine_npub = :npub",
{"npub": machine_npub}, {"npub": machine_npub},
@ -119,7 +118,7 @@ async def get_machine_by_npub(machine_npub: str) -> Optional[Machine]:
) )
async def get_active_machine_by_wallet_id(wallet_id: str) -> Optional[Machine]: async def get_active_machine_by_wallet_id(wallet_id: str) -> Machine | None:
"""Used by the invoice listener to route an incoming payment to a machine.""" """Used by the invoice listener to route an incoming payment to a machine."""
return await db.fetchone( return await db.fetchone(
""" """
@ -132,7 +131,7 @@ async def get_active_machine_by_wallet_id(wallet_id: str) -> Optional[Machine]:
) )
async def get_machines_for_operator(operator_user_id: str) -> List[Machine]: async def get_machines_for_operator(operator_user_id: str) -> list[Machine]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM satoshimachine.dca_machines SELECT * FROM satoshimachine.dca_machines
@ -144,7 +143,7 @@ async def get_machines_for_operator(operator_user_id: str) -> List[Machine]:
) )
async def list_all_active_machines() -> List[Machine]: async def list_all_active_machines() -> list[Machine]:
"""Used by the cassette bootstrap consumer task to build a single """Used by the cassette bootstrap consumer task to build a single
cross-operator subscription filter. Each event's pubkey routes to cross-operator subscription filter. Each event's pubkey routes to
the right operator via get_machine_by_atm_pubkey_hex + the machine's the right operator via get_machine_by_atm_pubkey_hex + the machine's
@ -161,7 +160,7 @@ async def list_all_active_machines() -> List[Machine]:
) )
async def get_machine_by_atm_pubkey_hex(atm_pubkey_hex: str) -> Optional[Machine]: async def get_machine_by_atm_pubkey_hex(atm_pubkey_hex: str) -> Machine | None:
"""Look up an active machine by its ATM pubkey, accepting hex or bech32 """Look up an active machine by its ATM pubkey, accepting hex or bech32
in machine_npub. Used by the cassette bootstrap consumer to route an in machine_npub. Used by the cassette bootstrap consumer to route an
incoming state event to the right machine row (and therefore operator incoming state event to the right machine row (and therefore operator
@ -183,7 +182,7 @@ async def get_machine_by_atm_pubkey_hex(atm_pubkey_hex: str) -> Optional[Machine
return None return None
async def update_machine(machine_id: str, data: UpdateMachineData) -> Optional[Machine]: async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine | None:
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)
@ -255,7 +254,7 @@ _CLIENT_FROM = (
) )
async def get_dca_client(client_id: str) -> Optional[DcaClient]: async def get_dca_client(client_id: str) -> DcaClient | None:
return await db.fetchone( return await db.fetchone(
f"SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} WHERE c.id = :id", f"SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} WHERE c.id = :id",
{"id": client_id}, {"id": client_id},
@ -265,7 +264,7 @@ async def get_dca_client(client_id: str) -> Optional[DcaClient]:
async def get_dca_client_for_machine_user( async def get_dca_client_for_machine_user(
machine_id: str, user_id: str machine_id: str, user_id: str
) -> Optional[DcaClient]: ) -> DcaClient | None:
return await db.fetchone( return await db.fetchone(
f""" f"""
SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
@ -276,7 +275,7 @@ async def get_dca_client_for_machine_user(
) )
async def get_dca_clients_for_machine(machine_id: str) -> List[DcaClient]: async def get_dca_clients_for_machine(machine_id: str) -> list[DcaClient]:
return await db.fetchall( return await db.fetchall(
f""" f"""
SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
@ -288,7 +287,7 @@ async def get_dca_clients_for_machine(machine_id: str) -> List[DcaClient]:
) )
async def get_dca_clients_for_operator(operator_user_id: str) -> List[DcaClient]: async def get_dca_clients_for_operator(operator_user_id: str) -> list[DcaClient]:
"""All clients across every machine this operator owns.""" """All clients across every machine this operator owns."""
return await db.fetchall( return await db.fetchall(
f""" f"""
@ -303,7 +302,7 @@ async def get_dca_clients_for_operator(operator_user_id: str) -> List[DcaClient]
) )
async def get_dca_clients_for_user(user_id: str) -> List[DcaClient]: async def get_dca_clients_for_user(user_id: str) -> list[DcaClient]:
"""LP cross-operator view — every machine this LP is registered at.""" """LP cross-operator view — every machine this LP is registered at."""
return await db.fetchall( return await db.fetchall(
f""" f"""
@ -316,7 +315,7 @@ async def get_dca_clients_for_user(user_id: str) -> List[DcaClient]:
) )
async def get_flow_mode_clients_for_machine(machine_id: str) -> List[DcaClient]: async def get_flow_mode_clients_for_machine(machine_id: str) -> list[DcaClient]:
"""Active LPs enrolled at this machine whose per-user `dca_lp` row """Active LPs enrolled at this machine whose per-user `dca_lp` row
has `default_dca_mode = 'flow'`. Used by the distribution algorithm. has `default_dca_mode = 'flow'`. Used by the distribution algorithm.
@ -344,7 +343,7 @@ async def get_flow_mode_clients_for_machine(machine_id: str) -> List[DcaClient]:
# ============================================================================= # =============================================================================
async def get_dca_lp(user_id: str) -> Optional[DcaLpPreferences]: async def get_dca_lp(user_id: str) -> DcaLpPreferences | None:
"""Return the LP's preferences row, or None if they haven't onboarded """Return the LP's preferences row, or None if they haven't onboarded
via satmachineclient yet.""" via satmachineclient yet."""
return await db.fetchone( return await db.fetchone(
@ -367,7 +366,7 @@ async def upsert_dca_lp(
user_id: str, user_id: str,
data: UpsertDcaLpData, data: UpsertDcaLpData,
*, *,
fallback_wallet_id: Optional[str] = None, fallback_wallet_id: str | None = None,
) -> DcaLpPreferences: ) -> DcaLpPreferences:
"""Create or update the LP's preferences row. """Create or update the LP's preferences row.
@ -422,7 +421,7 @@ async def upsert_dca_lp(
async def update_dca_client( async def update_dca_client(
client_id: str, data: UpdateDcaClientData client_id: str, data: UpdateDcaClientData
) -> Optional[DcaClient]: ) -> DcaClient | None:
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_dca_client(client_id) return await get_dca_client(client_id)
@ -484,7 +483,7 @@ async def create_deposit(
return deposit return deposit
async def get_deposit(deposit_id: str) -> Optional[DcaDeposit]: async def get_deposit(deposit_id: str) -> DcaDeposit | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM satoshimachine.dca_deposits WHERE id = :id", "SELECT * FROM satoshimachine.dca_deposits WHERE id = :id",
{"id": deposit_id}, {"id": deposit_id},
@ -492,7 +491,7 @@ async def get_deposit(deposit_id: str) -> Optional[DcaDeposit]:
) )
async def get_deposits_for_client(client_id: str) -> List[DcaDeposit]: async def get_deposits_for_client(client_id: str) -> list[DcaDeposit]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM satoshimachine.dca_deposits SELECT * FROM satoshimachine.dca_deposits
@ -504,7 +503,7 @@ async def get_deposits_for_client(client_id: str) -> List[DcaDeposit]:
) )
async def get_deposits_for_operator(operator_user_id: str) -> List[DcaDeposit]: async def get_deposits_for_operator(operator_user_id: str) -> list[DcaDeposit]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT d.* SELECT d.*
@ -520,7 +519,7 @@ async def get_deposits_for_operator(operator_user_id: str) -> List[DcaDeposit]:
async def update_deposit( async def update_deposit(
deposit_id: str, data: UpdateDepositData deposit_id: str, data: UpdateDepositData
) -> Optional[DcaDeposit]: ) -> DcaDeposit | None:
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_deposit(deposit_id) return await get_deposit(deposit_id)
@ -535,7 +534,7 @@ async def update_deposit(
async def update_deposit_status( async def update_deposit_status(
deposit_id: str, data: UpdateDepositStatusData deposit_id: str, data: UpdateDepositStatusData
) -> Optional[DcaDeposit]: ) -> DcaDeposit | None:
payload = { payload = {
"id": deposit_id, "id": deposit_id,
"status": data.status, "status": data.status,
@ -570,8 +569,8 @@ 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, initial_status: str,
error_message: Optional[str] = None, error_message: str | None = None,
) -> Optional[DcaSettlement]: ) -> DcaSettlement | None:
"""Insert a settlement keyed by payment_hash. """Insert a settlement keyed by payment_hash.
Returns the inserted row on first sight; returns the existing row Returns the inserted row on first sight; returns the existing row
@ -631,7 +630,7 @@ async def create_settlement_idempotent(
return await get_settlement(settlement_id) return await get_settlement(settlement_id)
async def get_settlement(settlement_id: str) -> Optional[DcaSettlement]: async def get_settlement(settlement_id: str) -> DcaSettlement | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM satoshimachine.dca_settlements WHERE id = :id", "SELECT * FROM satoshimachine.dca_settlements WHERE id = :id",
{"id": settlement_id}, {"id": settlement_id},
@ -641,7 +640,7 @@ async def get_settlement(settlement_id: str) -> Optional[DcaSettlement]:
async def get_settlement_by_payment_hash( async def get_settlement_by_payment_hash(
payment_hash: str, payment_hash: str,
) -> Optional[DcaSettlement]: ) -> DcaSettlement | None:
return await db.fetchone( return await db.fetchone(
""" """
SELECT * FROM satoshimachine.dca_settlements SELECT * FROM satoshimachine.dca_settlements
@ -654,7 +653,7 @@ async def get_settlement_by_payment_hash(
async def get_settlements_for_machine( async def get_settlements_for_machine(
machine_id: str, limit: int = 100 machine_id: str, limit: int = 100
) -> List[DcaSettlement]: ) -> list[DcaSettlement]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM satoshimachine.dca_settlements SELECT * FROM satoshimachine.dca_settlements
@ -747,7 +746,7 @@ async def get_stuck_settlements_for_operator(
async def force_reset_stuck_settlement( async def force_reset_stuck_settlement(
settlement_id: str, settlement_id: str,
) -> Optional[DcaSettlement]: ) -> DcaSettlement | None:
"""Operator escape hatch for genuinely stuck settlements (processor """Operator escape hatch for genuinely stuck settlements (processor
crashed mid-flight, etc.). Flips 'pending'/'processing' 'errored' so crashed mid-flight, etc.). Flips 'pending'/'processing' 'errored' so
the existing retry endpoint can take over. Clears processing_claim. the existing retry endpoint can take over. Clears processing_claim.
@ -770,7 +769,7 @@ async def force_reset_stuck_settlement(
async def get_settlements_for_operator( async def get_settlements_for_operator(
operator_user_id: str, limit: int = 200 operator_user_id: str, limit: int = 200
) -> List[DcaSettlement]: ) -> list[DcaSettlement]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT s.* SELECT s.*
@ -788,8 +787,8 @@ async def get_settlements_for_operator(
async def mark_settlement_status( async def mark_settlement_status(
settlement_id: str, settlement_id: str,
status: str, status: str,
error_message: Optional[str] = None, error_message: str | None = None,
) -> Optional[DcaSettlement]: ) -> DcaSettlement | None:
"""Status: 'pending' | 'processing' | 'processed' | 'partial' | """Status: 'pending' | 'processing' | 'processed' | 'partial' |
'refunded' | 'errored'. Clears processing_claim on terminal states so a 'refunded' | 'errored'. Clears processing_claim on terminal states so a
fresh claim attempt won't see a stale token.""" fresh claim attempt won't see a stale token."""
@ -820,7 +819,7 @@ async def mark_settlement_status(
async def claim_settlement_for_processing( async def claim_settlement_for_processing(
settlement_id: str, settlement_id: str,
) -> Optional[DcaSettlement]: ) -> DcaSettlement | None:
"""Optimistic-lock claim: atomically flip a settlement to 'processing' """Optimistic-lock claim: atomically flip a settlement to 'processing'
and tag it with a per-invocation token. Returns the claimed row on and tag it with a per-invocation token. Returns the claimed row on
success; None if another caller already won the claim or the settlement success; None if another caller already won the claim or the settlement
@ -850,7 +849,7 @@ async def claim_settlement_for_processing(
async def reset_settlement_for_retry( async def reset_settlement_for_retry(
settlement_id: str, settlement_id: str,
) -> Optional[DcaSettlement]: ) -> DcaSettlement | None:
"""Operator retry path. Flips 'errored''pending' and voids any """Operator retry path. Flips 'errored''pending' and voids any
'failed' legs so process_settlement re-runs them fresh. Completed legs 'failed' legs so process_settlement re-runs them fresh. Completed legs
are left in place we never re-pay sats that already moved.""" are left in place we never re-pay sats that already moved."""
@ -886,7 +885,7 @@ async def apply_partial_dispense(
new_operator_fee_sats: int, new_operator_fee_sats: int,
new_fiat_amount: float, new_fiat_amount: float,
appended_note: str, appended_note: str,
) -> Optional[DcaSettlement]: ) -> DcaSettlement | None:
"""Overwrite the monetary fields on a settlement (partial-dispense """Overwrite the monetary fields on a settlement (partial-dispense
recompute) and prepend `appended_note` to the notes column. recompute) and prepend `appended_note` to the notes column.
@ -941,7 +940,7 @@ async def count_completed_legs_for_settlement(settlement_id: str) -> int:
async def append_settlement_note( async def append_settlement_note(
settlement_id: str, note: str, author_user_id: str settlement_id: str, note: str, author_user_id: str
) -> Optional[DcaSettlement]: ) -> DcaSettlement | None:
"""Prepend an operator-authored note to settlement.notes. Each entry is """Prepend an operator-authored note to settlement.notes. Each entry is
timestamped (UTC) and tagged with the author's user id so the trail timestamped (UTC) and tagged with the author's user id so the trail
is accountable. Append-only: existing entries are never edited.""" is accountable. Append-only: existing entries are never edited."""
@ -986,8 +985,8 @@ async def void_open_legs_for_settlement(settlement_id: str) -> None:
async def get_commission_splits( async def get_commission_splits(
operator_user_id: str, machine_id: Optional[str] = None operator_user_id: str, machine_id: str | None = None
) -> List[CommissionSplit]: ) -> list[CommissionSplit]:
"""Returns the rule set for the given scope. """Returns the rule set for the given scope.
Precedence (caller's responsibility): try per-machine override first; Precedence (caller's responsibility): try per-machine override first;
@ -1016,7 +1015,7 @@ async def get_commission_splits(
async def get_effective_commission_splits( async def get_effective_commission_splits(
operator_user_id: str, machine_id: str operator_user_id: str, machine_id: str
) -> List[CommissionSplit]: ) -> list[CommissionSplit]:
"""Per-machine override if set, otherwise operator's default ruleset.""" """Per-machine override if set, otherwise operator's default ruleset."""
overrides = await get_commission_splits(operator_user_id, machine_id) overrides = await get_commission_splits(operator_user_id, machine_id)
if overrides: if overrides:
@ -1026,9 +1025,9 @@ async def get_effective_commission_splits(
async def replace_commission_splits( async def replace_commission_splits(
operator_user_id: str, operator_user_id: str,
machine_id: Optional[str], machine_id: str | None,
legs: List[CommissionSplitLeg], legs: list[CommissionSplitLeg],
) -> List[CommissionSplit]: ) -> list[CommissionSplit]:
"""Atomic replace for the (operator, machine) scope. Caller should have """Atomic replace for the (operator, machine) scope. Caller should have
already validated legs sum to 1.0 via the Pydantic model.""" already validated legs sum to 1.0 via the Pydantic model."""
if machine_id is None: if machine_id is None:
@ -1114,7 +1113,7 @@ async def create_dca_payment(data: CreateDcaPaymentData) -> DcaPayment:
return payment return payment
async def get_dca_payment(payment_id: str) -> Optional[DcaPayment]: async def get_dca_payment(payment_id: str) -> DcaPayment | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM satoshimachine.dca_payments WHERE id = :id", "SELECT * FROM satoshimachine.dca_payments WHERE id = :id",
{"id": payment_id}, {"id": payment_id},
@ -1122,7 +1121,7 @@ async def get_dca_payment(payment_id: str) -> Optional[DcaPayment]:
) )
async def get_payments_for_settlement(settlement_id: str) -> List[DcaPayment]: async def get_payments_for_settlement(settlement_id: str) -> list[DcaPayment]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM satoshimachine.dca_payments SELECT * FROM satoshimachine.dca_payments
@ -1134,7 +1133,7 @@ async def get_payments_for_settlement(settlement_id: str) -> List[DcaPayment]:
) )
async def get_payments_for_client(client_id: str) -> List[DcaPayment]: async def get_payments_for_client(client_id: str) -> list[DcaPayment]:
return await db.fetchall( return await db.fetchall(
""" """
SELECT * FROM satoshimachine.dca_payments SELECT * FROM satoshimachine.dca_payments
@ -1147,8 +1146,8 @@ async def get_payments_for_client(client_id: str) -> List[DcaPayment]:
async def get_payments_for_operator( async def get_payments_for_operator(
operator_user_id: str, leg_type: Optional[str] = None, limit: int = 200 operator_user_id: str, leg_type: str | None = None, limit: int = 200
) -> List[DcaPayment]: ) -> list[DcaPayment]:
if leg_type is None: if leg_type is None:
return await db.fetchall( return await db.fetchall(
""" """
@ -1175,9 +1174,9 @@ async def get_payments_for_operator(
async def update_payment_status( async def update_payment_status(
payment_id: str, payment_id: str,
status: str, status: str,
external_payment_hash: Optional[str] = None, external_payment_hash: str | None = None,
error_message: Optional[str] = None, error_message: str | None = None,
) -> Optional[DcaPayment]: ) -> DcaPayment | None:
await db.execute( await db.execute(
""" """
UPDATE satoshimachine.dca_payments UPDATE satoshimachine.dca_payments
@ -1203,7 +1202,7 @@ async def update_payment_status(
async def get_client_balance_summary( async def get_client_balance_summary(
client_id: str, client_id: str,
) -> Optional[ClientBalanceSummary]: ) -> ClientBalanceSummary | None:
"""Per-client (and per-machine, since clients are per-machine in v2) summary. """Per-client (and per-machine, since clients are per-machine in v2) summary.
DCA legs only settlement/autoforward/super_fee/operator_split legs are DCA legs only settlement/autoforward/super_fee/operator_split legs are
@ -1252,7 +1251,7 @@ async def get_client_balance_summary(
# ============================================================================= # =============================================================================
async def get_telemetry(machine_id: str) -> Optional[TelemetrySnapshot]: async def get_telemetry(machine_id: str) -> TelemetrySnapshot | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM satoshimachine.dca_telemetry WHERE machine_id = :mid", "SELECT * FROM satoshimachine.dca_telemetry WHERE machine_id = :mid",
{"mid": machine_id}, {"mid": machine_id},
@ -1263,19 +1262,19 @@ async def get_telemetry(machine_id: str) -> Optional[TelemetrySnapshot]:
async def upsert_beacon_snapshot( async def upsert_beacon_snapshot(
machine_id: str, machine_id: str,
*, *,
cash_in: Optional[bool] = None, cash_in: bool | None = None,
cash_out: Optional[bool] = None, cash_out: bool | None = None,
cash_level: Optional[str] = None, cash_level: str | None = None,
fiat: Optional[str] = None, fiat: str | None = None,
model: Optional[str] = None, model: str | None = None,
name: Optional[str] = None, name: str | None = None,
location: Optional[str] = None, location: str | None = None,
geo: Optional[str] = None, geo: str | None = None,
fees_json: Optional[str] = None, fees_json: str | None = None,
limits_json: Optional[str] = None, limits_json: str | None = None,
denominations_json: Optional[str] = None, denominations_json: str | None = None,
version: Optional[str] = None, version: str | None = None,
) -> Optional[TelemetrySnapshot]: ) -> TelemetrySnapshot | None:
"""Upsert kind-30078 beacon fields. All fields are nullable because today's """Upsert kind-30078 beacon fields. All fields are nullable because today's
upstream payload only carries cash_in/cash_out/cash_level/fiat/model (see upstream payload only carries cash_in/cash_out/cash_level/fiat/model (see
lamassu-next#43 — the enrichment is not yet shipped).""" lamassu-next#43 — the enrichment is not yet shipped)."""
@ -1352,7 +1351,7 @@ async def upsert_beacon_snapshot(
async def upsert_fleet_snapshot( async def upsert_fleet_snapshot(
machine_id: str, telemetry_json: str machine_id: str, telemetry_json: str
) -> Optional[TelemetrySnapshot]: ) -> TelemetrySnapshot | None:
"""Upsert kind-30079 operator-only telemetry. Awaits lamassu-next#42 to """Upsert kind-30079 operator-only telemetry. Awaits lamassu-next#42 to
produce a real schema; we store the raw JSON blob until then.""" produce a real schema; we store the raw JSON blob until then."""
existing = await get_telemetry(machine_id) existing = await get_telemetry(machine_id)
@ -1391,7 +1390,7 @@ async def upsert_fleet_snapshot(
def _should_apply_bootstrap_state( def _should_apply_bootstrap_state(
existing_state_event_id: Optional[str], incoming_event_id: str existing_state_event_id: str | None, incoming_event_id: str
) -> bool: ) -> bool:
"""Pure-function dedup gate for apply_bootstrap_state. """Pure-function dedup gate for apply_bootstrap_state.
@ -1408,7 +1407,7 @@ def _should_apply_bootstrap_state(
async def get_cassette_config( async def get_cassette_config(
machine_id: str, position: int machine_id: str, position: int
) -> Optional[CassetteConfig]: ) -> CassetteConfig | None:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM satoshimachine.cassette_configs " "SELECT * FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid AND position = :pos", "WHERE machine_id = :mid AND position = :pos",
@ -1419,7 +1418,7 @@ async def get_cassette_config(
async def list_cassette_configs_for_machine( async def list_cassette_configs_for_machine(
machine_id: str, machine_id: str,
) -> List[CassetteConfig]: ) -> list[CassetteConfig]:
return await db.fetchall( return await db.fetchall(
"SELECT * FROM satoshimachine.cassette_configs " "SELECT * FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid ORDER BY position", "WHERE machine_id = :mid ORDER BY position",
@ -1433,8 +1432,8 @@ async def update_cassette_config(
position: int, position: int,
data: UpsertCassetteConfigData, data: UpsertCassetteConfigData,
*, *,
updated_by: Optional[str] = None, updated_by: str | None = None,
) -> Optional[CassetteConfig]: ) -> CassetteConfig | None:
"""Operator-driven row update: change denomination and/or count for a """Operator-driven row update: change denomination and/or count for a
single cassette slot. Refuses to create new rows those only land via single cassette slot. Refuses to create new rows those only land via
apply_bootstrap_state() consuming an ATM bootstrap event (per #29 row apply_bootstrap_state() consuming an ATM bootstrap event (per #29 row
@ -1480,12 +1479,12 @@ async def apply_bootstrap_state(
reconciliation UI will diverge them when continuous reverse-channel reconciliation UI will diverge them when continuous reverse-channel
events land + the operator subsequently edits. events land + the operator subsequently edits.
""" """
existing_first = await db.fetchone( existing_first: dict | None = await db.fetchone(
"SELECT state_event_id FROM satoshimachine.cassette_configs " "SELECT state_event_id FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid LIMIT 1", "WHERE machine_id = :mid LIMIT 1",
{"mid": machine_id}, {"mid": machine_id},
) )
existing_event_id: Optional[str] = None existing_event_id: str | None = None
if existing_first is not None: if existing_first is not None:
existing_event_id = ( existing_event_id = (
existing_first.get("state_event_id") existing_first.get("state_event_id")

View file

@ -474,9 +474,7 @@ async def m006_rename_to_canonical_sat_vocabulary(db):
] ]
for table, old_col, new_col in renames: for table, old_col, new_col in renames:
try: try:
await db.fetchone( await db.fetchone(f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1")
f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1"
)
except Exception: except Exception:
# old column doesn't exist; either rename already landed or # old column doesn't exist; either rename already landed or
# m001 produced the canonical schema directly on fresh install. # m001 produced the canonical schema directly on fresh install.
@ -496,15 +494,11 @@ async def m006_rename_to_canonical_sat_vocabulary(db):
] ]
for table, col in drops: for table, col in drops:
try: try:
await db.fetchone( await db.fetchone(f"SELECT {col} FROM satoshimachine.{table} LIMIT 1")
f"SELECT {col} FROM satoshimachine.{table} LIMIT 1"
)
except Exception: except Exception:
# column doesn't exist; either already dropped or never present. # column doesn't exist; either already dropped or never present.
continue continue
await db.execute( await db.execute(f"ALTER TABLE satoshimachine.{table} DROP COLUMN {col}")
f"ALTER TABLE satoshimachine.{table} DROP COLUMN {col}"
)
async def m005_lock_deposit_currency_to_machine_fiat_code(db): async def m005_lock_deposit_currency_to_machine_fiat_code(db):
@ -606,8 +600,7 @@ async def m008_flip_cassette_configs_pk_to_position(db):
# Probe: does the old PK shape still exist? If state_denomination # Probe: does the old PK shape still exist? If state_denomination
# column already exists, m008 already ran — no-op. # column already exists, m008 already ran — no-op.
await db.fetchone( await db.fetchone(
"SELECT state_denomination FROM satoshimachine.cassette_configs " "SELECT state_denomination FROM satoshimachine.cassette_configs " "LIMIT 1"
"LIMIT 1"
) )
return return
except Exception: except Exception:
@ -649,6 +642,5 @@ async def m008_flip_cassette_configs_pk_to_position(db):
await db.execute("DROP TABLE satoshimachine.cassette_configs") await db.execute("DROP TABLE satoshimachine.cassette_configs")
await db.execute( await db.execute(
"ALTER TABLE satoshimachine.cassette_configs_new " "ALTER TABLE satoshimachine.cassette_configs_new " "RENAME TO cassette_configs"
"RENAME TO cassette_configs"
) )

179
models.py
View file

@ -6,7 +6,6 @@
# the plan at ~/.claude/plans/snug-gliding-shamir.md. # the plan at ~/.claude/plans/snug-gliding-shamir.md.
from datetime import datetime from datetime import datetime
from typing import Optional
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
@ -26,8 +25,8 @@ class CreateMachineData(BaseModel):
machine_npub: str machine_npub: str
wallet_id: str wallet_id: str
name: Optional[str] = None name: str | None = None
location: Optional[str] = None location: str | None = None
fiat_code: str = "GTQ" fiat_code: str = "GTQ"
@ -36,8 +35,8 @@ class Machine(BaseModel):
operator_user_id: str operator_user_id: str
machine_npub: str machine_npub: str
wallet_id: str wallet_id: str
name: Optional[str] name: str | None
location: Optional[str] location: str | None
fiat_code: str fiat_code: str
is_active: bool is_active: bool
created_at: datetime created_at: datetime
@ -45,11 +44,11 @@ class Machine(BaseModel):
class UpdateMachineData(BaseModel): class UpdateMachineData(BaseModel):
name: Optional[str] = None name: str | None = None
location: Optional[str] = None location: str | None = None
fiat_code: Optional[str] = None fiat_code: str | None = None
is_active: Optional[bool] = None is_active: bool | None = None
wallet_id: Optional[str] = None wallet_id: str | None = None
# ============================================================================= # =============================================================================
@ -69,14 +68,14 @@ class CreateDcaClientData(BaseModel):
machine_id: str machine_id: str
user_id: str user_id: str
username: Optional[str] = None username: str | None = None
class DcaClient(BaseModel): class DcaClient(BaseModel):
id: str id: str
machine_id: str machine_id: str
user_id: str user_id: str
username: Optional[str] username: str | None
status: str status: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@ -92,8 +91,8 @@ class UpdateDcaClientData(BaseModel):
/ mode / autoforward changes go through satmachineclient against / mode / autoforward changes go through satmachineclient against
`dca_lp` instead.""" `dca_lp` instead."""
username: Optional[str] = None username: str | None = None
status: Optional[str] = None status: str | None = None
class DcaLpPreferences(BaseModel): class DcaLpPreferences(BaseModel):
@ -109,8 +108,8 @@ class DcaLpPreferences(BaseModel):
user_id: str user_id: str
dca_wallet_id: str dca_wallet_id: str
default_dca_mode: str # 'flow' | 'fixed' default_dca_mode: str # 'flow' | 'fixed'
fixed_mode_daily_limit: Optional[float] fixed_mode_daily_limit: float | None
autoforward_ln_address: Optional[str] autoforward_ln_address: str | None
autoforward_enabled: bool autoforward_enabled: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@ -121,11 +120,11 @@ class UpsertDcaLpData(BaseModel):
edits their preferences. All fields optional on update pass only edits their preferences. All fields optional on update pass only
the ones being changed.""" the ones being changed."""
dca_wallet_id: Optional[str] = None dca_wallet_id: str | None = None
default_dca_mode: Optional[str] = None default_dca_mode: str | None = None
fixed_mode_daily_limit: Optional[float] = None fixed_mode_daily_limit: float | None = None
autoforward_ln_address: Optional[str] = None autoforward_ln_address: str | None = None
autoforward_enabled: Optional[bool] = None autoforward_enabled: bool | None = None
class ClientBalanceSummary(BaseModel): class ClientBalanceSummary(BaseModel):
@ -156,7 +155,7 @@ class CreateDepositData(BaseModel):
client_id: str client_id: str
machine_id: str machine_id: str
amount: float amount: float
notes: Optional[str] = None notes: str | None = None
@validator("amount") @validator("amount")
def round_amount(cls, v): def round_amount(cls, v):
@ -173,9 +172,9 @@ class DcaDeposit(BaseModel):
amount: float amount: float
currency: str currency: str
status: str # 'pending' | 'confirmed' | 'rejected' status: str # 'pending' | 'confirmed' | 'rejected'
notes: Optional[str] notes: str | None
created_at: datetime created_at: datetime
confirmed_at: Optional[datetime] confirmed_at: datetime | None
class UpdateDepositData(BaseModel): class UpdateDepositData(BaseModel):
@ -183,8 +182,8 @@ class UpdateDepositData(BaseModel):
`CreateDepositData`; the currency is bound to the machine and not `CreateDepositData`; the currency is bound to the machine and not
editable after the row lands.""" editable after the row lands."""
amount: Optional[float] = None amount: float | None = None
notes: Optional[str] = None notes: str | None = None
@validator("amount") @validator("amount")
def round_amount(cls, v): def round_amount(cls, v):
@ -195,7 +194,7 @@ class UpdateDepositData(BaseModel):
class UpdateDepositStatusData(BaseModel): class UpdateDepositStatusData(BaseModel):
status: str # 'pending' | 'confirmed' | 'rejected' status: str # 'pending' | 'confirmed' | 'rejected'
notes: Optional[str] = None notes: str | None = None
# ============================================================================= # =============================================================================
@ -210,8 +209,8 @@ class UpdateDepositStatusData(BaseModel):
class CreateDcaSettlementData(BaseModel): class CreateDcaSettlementData(BaseModel):
machine_id: str machine_id: str
payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table) payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table)
bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion bitspire_event_id: str | None = None # reserved for direct-Nostr ingestion
bitspire_txid: Optional[str] = None bitspire_txid: str | None = None
wire_sats: int wire_sats: int
fiat_amount: float fiat_amount: float
fiat_code: str = "GTQ" fiat_code: str = "GTQ"
@ -221,16 +220,16 @@ class CreateDcaSettlementData(BaseModel):
platform_fee_sats: int platform_fee_sats: int
operator_fee_sats: int operator_fee_sats: int
tx_type: str # 'cash_out' | 'cash_in' tx_type: str # 'cash_out' | 'cash_in'
bills_json: Optional[str] = None bills_json: str | None = None
cassettes_json: Optional[str] = None cassettes_json: str | None = None
class DcaSettlement(BaseModel): class DcaSettlement(BaseModel):
id: str id: str
machine_id: str machine_id: str
payment_hash: str payment_hash: str
bitspire_event_id: Optional[str] bitspire_event_id: str | None
bitspire_txid: Optional[str] bitspire_txid: str | None
wire_sats: int wire_sats: int
fiat_amount: float fiat_amount: float
fiat_code: str fiat_code: str
@ -240,8 +239,8 @@ class DcaSettlement(BaseModel):
platform_fee_sats: int platform_fee_sats: int
operator_fee_sats: int operator_fee_sats: int
tx_type: str tx_type: str
bills_json: Optional[str] bills_json: str | None
cassettes_json: Optional[str] cassettes_json: str | None
# 'pending' (default at insert) # 'pending' (default at insert)
# 'processing' (claim taken by distribution processor) # 'processing' (claim taken by distribution processor)
# 'processed' (all legs paid) # 'processed' (all legs paid)
@ -252,19 +251,19 @@ class DcaSettlement(BaseModel):
# never went near distribution. error_message holds the # never went near distribution. error_message holds the
# reason. Retry is wrong — investigate the machine.) # reason. Retry is wrong — investigate the machine.)
status: str status: str
error_message: Optional[str] error_message: str | None
processed_at: Optional[datetime] processed_at: datetime | None
created_at: datetime created_at: datetime
# Append-only audit memo. Populated when an operator triggers an in-place # Append-only audit memo. Populated when an operator triggers an in-place
# adjustment (partial-dispense, manual reconciliation override). Each # adjustment (partial-dispense, manual reconciliation override). Each
# entry timestamped + records original values so the overwrite is # entry timestamped + records original values so the overwrite is
# auditable from the settlement detail view alone. Never edited in place. # auditable from the settlement detail view alone. Never edited in place.
notes: Optional[str] = None notes: str | None = None
# Optimistic-lock claim token written when status flips to 'processing'. # Optimistic-lock claim token written when status flips to 'processing'.
# Two concurrent process_settlement invocations can't both win the claim # Two concurrent process_settlement invocations can't both win the claim
# (only one matching read-back). Cleared back to NULL when the leg- # (only one matching read-back). Cleared back to NULL when the leg-
# writing pass completes (status='processed' or 'errored'). # writing pass completes (status='processed' or 'errored').
processing_claim: Optional[str] = None processing_claim: str | None = None
# ============================================================================= # =============================================================================
@ -286,7 +285,7 @@ class CommissionSplitLeg(BaseModel):
""" """
target: str target: str
label: Optional[str] = None label: str | None = None
fraction: float fraction: float
sort_order: int = 0 sort_order: int = 0
@ -306,10 +305,10 @@ class CommissionSplitLeg(BaseModel):
class CommissionSplit(BaseModel): class CommissionSplit(BaseModel):
id: str id: str
machine_id: Optional[str] # None = operator's default ruleset machine_id: str | None # None = operator's default ruleset
operator_user_id: str operator_user_id: str
target: str target: str
label: Optional[str] label: str | None
fraction: float fraction: float
sort_order: int sort_order: int
created_at: datetime created_at: datetime
@ -322,7 +321,7 @@ class SetCommissionSplitsData(BaseModel):
machine without an explicit override). Otherwise scoped per machine. machine without an explicit override). Otherwise scoped per machine.
""" """
machine_id: Optional[str] = None machine_id: str | None = None
legs: list[CommissionSplitLeg] legs: list[CommissionSplitLeg]
@validator("legs") @validator("legs")
@ -339,35 +338,35 @@ class SetCommissionSplitsData(BaseModel):
class CreateDcaPaymentData(BaseModel): class CreateDcaPaymentData(BaseModel):
settlement_id: Optional[str] = None settlement_id: str | None = None
client_id: Optional[str] = None client_id: str | None = None
machine_id: str machine_id: str
operator_user_id: str operator_user_id: str
leg_type: str leg_type: str
# 'dca' | 'super_fee' | 'operator_split' | 'settlement' | 'autoforward' | 'refund' # 'dca' | 'super_fee' | 'operator_split' | 'settlement' | 'autoforward' | 'refund'
destination_wallet_id: Optional[str] = None destination_wallet_id: str | None = None
destination_ln_address: Optional[str] = None destination_ln_address: str | None = None
amount_sats: int amount_sats: int
amount_fiat: Optional[float] = None amount_fiat: float | None = None
exchange_rate: Optional[float] = None exchange_rate: float | None = None
transaction_time: datetime transaction_time: datetime
external_payment_hash: Optional[str] = None external_payment_hash: str | None = None
class DcaPayment(BaseModel): class DcaPayment(BaseModel):
id: str id: str
settlement_id: Optional[str] settlement_id: str | None
client_id: Optional[str] client_id: str | None
machine_id: str machine_id: str
operator_user_id: str operator_user_id: str
leg_type: str leg_type: str
destination_wallet_id: Optional[str] destination_wallet_id: str | None
destination_ln_address: Optional[str] destination_ln_address: str | None
amount_sats: int amount_sats: int
amount_fiat: Optional[float] amount_fiat: float | None
exchange_rate: Optional[float] exchange_rate: float | None
transaction_time: datetime transaction_time: datetime
external_payment_hash: Optional[str] external_payment_hash: str | None
status: str status: str
# Leg status enum: # Leg status enum:
# 'pending' — row written, payment not yet attempted # 'pending' — row written, payment not yet attempted
@ -378,7 +377,7 @@ class DcaPayment(BaseModel):
# 'skipped' — intentionally not paid (no super wallet configured, # 'skipped' — intentionally not paid (no super wallet configured,
# no commission ruleset, no exchange rate, no LPs) # no commission ruleset, no exchange rate, no LPs)
# 'refunded' — reserved for future refund flows # 'refunded' — reserved for future refund flows
error_message: Optional[str] error_message: str | None
created_at: datetime created_at: datetime
@ -391,22 +390,22 @@ class TelemetrySnapshot(BaseModel):
machine_id: str machine_id: str
# Beacon (kind-30078) — all fields are nullable because the upstream payload # Beacon (kind-30078) — all fields are nullable because the upstream payload
# is sparse today. As lamassu-next#43 lands, the post-#43 columns fill in. # is sparse today. As lamassu-next#43 lands, the post-#43 columns fill in.
beacon_cash_in: Optional[bool] = None beacon_cash_in: bool | None = None
beacon_cash_out: Optional[bool] = None beacon_cash_out: bool | None = None
beacon_cash_level: Optional[str] = None beacon_cash_level: str | None = None
beacon_fiat: Optional[str] = None beacon_fiat: str | None = None
beacon_model: Optional[str] = None beacon_model: str | None = None
beacon_name: Optional[str] = None beacon_name: str | None = None
beacon_location: Optional[str] = None beacon_location: str | None = None
beacon_geo: Optional[str] = None beacon_geo: str | None = None
beacon_fees_json: Optional[str] = None beacon_fees_json: str | None = None
beacon_limits_json: Optional[str] = None beacon_limits_json: str | None = None
beacon_denominations_json: Optional[str] = None beacon_denominations_json: str | None = None
beacon_version: Optional[str] = None beacon_version: str | None = None
beacon_received_at: Optional[datetime] = None beacon_received_at: datetime | None = None
# Fleet telemetry (kind-30079) — operator-only, awaits lamassu-next#42. # Fleet telemetry (kind-30079) — operator-only, awaits lamassu-next#42.
telemetry_json: Optional[str] = None telemetry_json: str | None = None
telemetry_received_at: Optional[datetime] = None telemetry_received_at: datetime | None = None
# ============================================================================= # =============================================================================
@ -417,13 +416,13 @@ class TelemetrySnapshot(BaseModel):
class SuperConfig(BaseModel): class SuperConfig(BaseModel):
id: str id: str
super_fee_fraction: float super_fee_fraction: float
super_fee_wallet_id: Optional[str] super_fee_wallet_id: str | None
updated_at: datetime updated_at: datetime
class UpdateSuperConfigData(BaseModel): class UpdateSuperConfigData(BaseModel):
super_fee_fraction: Optional[float] = None super_fee_fraction: float | None = None
super_fee_wallet_id: Optional[str] = None super_fee_wallet_id: str | None = None
@validator("super_fee_fraction") @validator("super_fee_fraction")
def fee_in_unit_range(cls, v): def fee_in_unit_range(cls, v):
@ -448,9 +447,9 @@ class PartialDispenseData(BaseModel):
""" """
settlement_id: str settlement_id: str
dispensed_fraction: Optional[float] = None dispensed_fraction: float | None = None
dispensed_sats: Optional[int] = None dispensed_sats: int | None = None
notes: Optional[str] = None notes: str | None = None
@validator("dispensed_fraction") @validator("dispensed_fraction")
def fraction_in_unit_range(cls, v): def fraction_in_unit_range(cls, v):
@ -530,8 +529,8 @@ class SettleBalanceData(BaseModel):
# there's no ambiguity about what rate was used. # there's no ambiguity about what rate was used.
exchange_rate: float exchange_rate: float
# If None, settle the LP's full remaining balance. Else partial. # If None, settle the LP's full remaining balance. Else partial.
amount_fiat: Optional[float] = None amount_fiat: float | None = None
notes: Optional[str] = None notes: str | None = None
@validator("exchange_rate") @validator("exchange_rate")
def positive_rate(cls, v): def positive_rate(cls, v):
@ -585,11 +584,11 @@ class CassetteConfig(BaseModel):
denomination: int denomination: int
count: int count: int
updated_at: datetime updated_at: datetime
updated_by: Optional[str] updated_by: str | None
state_denomination: Optional[int] state_denomination: int | None
state_count: Optional[int] state_count: int | None
state_at: Optional[datetime] state_at: datetime | None
state_event_id: Optional[str] state_event_id: str | None
class UpsertCassetteConfigData(BaseModel): class UpsertCassetteConfigData(BaseModel):
@ -597,8 +596,8 @@ class UpsertCassetteConfigData(BaseModel):
the dashboard. Both fields optional; pass only those changed. the dashboard. Both fields optional; pass only those changed.
Position is not edited it's the row's identity (hardware bay).""" Position is not edited it's the row's identity (hardware bay)."""
denomination: Optional[int] = None denomination: int | None = None
count: Optional[int] = None count: int | None = None
@validator("denomination") @validator("denomination")
def denomination_positive(cls, v): def denomination_positive(cls, v):
@ -664,9 +663,7 @@ class PublishCassettesPayload(BaseModel):
try: try:
key_int = int(k) key_int = int(k)
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise ValueError( raise ValueError(f"position key {k!r} is not an int") from exc
f"position key {k!r} is not an int"
) from exc
if key_int <= 0: if key_int <= 0:
raise ValueError(f"position must be > 0 (got {key_int})") raise ValueError(f"position must be > 0 (got {key_int})")
out[key_int] = val out[key_int] = val

View file

@ -57,7 +57,6 @@ import hashlib
import hmac as hmac_stdlib import hmac as hmac_stdlib
import os import os
import struct import struct
from typing import Optional
import coincurve import coincurve
from cryptography.hazmat.primitives import hashes, hmac from cryptography.hazmat.primitives import hashes, hmac
@ -71,7 +70,9 @@ _MIN_PLAINTEXT_LEN = 1
_MAX_PLAINTEXT_LEN = 65535 _MAX_PLAINTEXT_LEN = 65535
_NONCE_LEN = 32 _NONCE_LEN = 32
_MAC_LEN = 32 _MAC_LEN = 32
_MIN_PAYLOAD_LEN = 1 + _NONCE_LEN + (2 + 32) + _MAC_LEN # version + nonce + min padded + mac _MIN_PAYLOAD_LEN = (
1 + _NONCE_LEN + (2 + 32) + _MAC_LEN
) # version + nonce + min padded + mac
_MAX_PAYLOAD_LEN = 1 + _NONCE_LEN + (2 + 65536) + _MAC_LEN _MAX_PAYLOAD_LEN = 1 + _NONCE_LEN + (2 + 65536) + _MAC_LEN
@ -205,7 +206,7 @@ def encrypt_with_conversation_key(
plaintext: str, plaintext: str,
conversation_key: bytes, conversation_key: bytes,
*, *,
nonce: Optional[bytes] = None, nonce: bytes | None = None,
) -> str: ) -> str:
"""Encrypt `plaintext` under a precomputed `conversation_key` (32B PRK). """Encrypt `plaintext` under a precomputed `conversation_key` (32B PRK).
@ -224,9 +225,9 @@ def encrypt_with_conversation_key(
chacha_key, chacha_nonce, hmac_key = _derive_message_keys(conversation_key, nonce) chacha_key, chacha_nonce, hmac_key = _derive_message_keys(conversation_key, nonce)
ciphertext = _chacha20(chacha_key, chacha_nonce, padded) ciphertext = _chacha20(chacha_key, chacha_nonce, padded)
mac = _hmac_aad(hmac_key, nonce, ciphertext) mac = _hmac_aad(hmac_key, nonce, ciphertext)
return base64.b64encode( return base64.b64encode(bytes([_VERSION]) + nonce + ciphertext + mac).decode(
bytes([_VERSION]) + nonce + ciphertext + mac "ascii"
).decode("ascii") )
def decrypt_with_conversation_key(payload_b64: str, conversation_key: bytes) -> str: def decrypt_with_conversation_key(payload_b64: str, conversation_key: bytes) -> str:
@ -239,7 +240,9 @@ def decrypt_with_conversation_key(payload_b64: str, conversation_key: bytes) ->
""" """
try: try:
raw = base64.b64decode(payload_b64, validate=True) raw = base64.b64decode(payload_b64, validate=True)
except Exception as exc: # noqa: BLE001 — we want any base64 failure surfaced uniformly except (
Exception
) as exc:
raise Nip44LengthError(f"payload is not valid base64: {exc}") from exc raise Nip44LengthError(f"payload is not valid base64: {exc}") from exc
if len(raw) < _MIN_PAYLOAD_LEN or len(raw) > _MAX_PAYLOAD_LEN: if len(raw) < _MIN_PAYLOAD_LEN or len(raw) > _MAX_PAYLOAD_LEN:
@ -272,7 +275,7 @@ def encrypt_for(
sender_privkey_hex: str, sender_privkey_hex: str,
recipient_pubkey_hex: str, recipient_pubkey_hex: str,
*, *,
nonce: Optional[bytes] = None, nonce: bytes | None = None,
) -> str: ) -> str:
"""Encrypt `plaintext` from the sender (holding the privkey) to the recipient """Encrypt `plaintext` from the sender (holding the privkey) to the recipient
(identified by pubkey). The recipient can decrypt with `decrypt_from( (identified by pubkey). The recipient can decrypt with `decrypt_from(

View file

@ -25,7 +25,6 @@
# sat-amount invariants (range/sum). # sat-amount invariants (range/sum).
import asyncio import asyncio
from typing import Optional
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
@ -126,9 +125,7 @@ async def _handle_payment(payment: Payment) -> None:
# stamp is missing, SettlementInvariantError on any range/sum # stamp is missing, SettlementInvariantError on any range/sum
# breach. # breach.
super_config = await get_super_config() super_config = await get_super_config()
super_fee_fraction = ( super_fee_fraction = float(super_config.super_fee_fraction) if super_config else 0.0
float(super_config.super_fee_fraction) if super_config else 0.0
)
try: try:
data = parse_settlement( data = parse_settlement(
machine=machine, machine=machine,
@ -195,9 +192,7 @@ async def _handle_payment(payment: Payment) -> None:
task.add_done_callback(_inflight_distributions.discard) task.add_done_callback(_inflight_distributions.discard)
async def _record_rejected( async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -> None:
payment: Payment, machine: Machine, exc: Exception
) -> None:
"""Insert a minimal `dca_settlements` row with `status='rejected'` and """Insert a minimal `dca_settlements` row with `status='rejected'` and
the exception message for operator forensics. the exception message for operator forensics.
@ -288,7 +283,7 @@ async def wait_for_cassette_state_events() -> None:
"satmachineadmin v2: cassette bootstrap consumer starting " "satmachineadmin v2: cassette bootstrap consumer starting "
f"(sub_id={CASSETTE_BOOTSTRAP_SUB_ID})" f"(sub_id={CASSETTE_BOOTSTRAP_SUB_ID})"
) )
current_filter_key: Optional[str] = None current_filter_key: str | None = None
while True: while True:
try: try:
current_filter_key = await _cassette_consumer_tick(current_filter_key) current_filter_key = await _cassette_consumer_tick(current_filter_key)
@ -304,8 +299,7 @@ async def wait_for_cassette_state_events() -> None:
await asyncio.sleep(_CASSETTE_BACKOFF_S) await asyncio.sleep(_CASSETTE_BACKOFF_S)
except Exception as exc: # listener must never die except Exception as exc: # listener must never die
logger.error( logger.error(
f"satmachineadmin: cassette consumer loop error (continuing): " f"satmachineadmin: cassette consumer loop error (continuing): " f"{exc}"
f"{exc}"
) )
await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S) await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S)
@ -316,7 +310,7 @@ class _NostrclientUnavailable(Exception):
at any time.""" at any time."""
async def _cassette_consumer_tick(current_filter_key: Optional[str]) -> str: async def _cassette_consumer_tick(current_filter_key: str | None) -> str:
"""Single iteration of the bootstrap-consumer loop. Returns the filter """Single iteration of the bootstrap-consumer loop. Returns the filter
key used this tick so the caller can detect filter-set changes. key used this tick so the caller can detect filter-set changes.
@ -345,34 +339,34 @@ async def _cassette_consumer_tick(current_filter_key: Optional[str]) -> str:
if filter_key != current_filter_key: if filter_key != current_filter_key:
if d_tags: if d_tags:
filters = [{"kinds": [30078], "#d": d_tags}] filters = [{"kinds": [30078], "#d": d_tags}]
# nostrclient's add_subscription is typed as list[str] but the
# actual relay protocol accepts list[Filter-dict] — type ignore
# the upstream typing mismatch.
nostr_client.relay_manager.add_subscription( nostr_client.relay_manager.add_subscription(
CASSETTE_BOOTSTRAP_SUB_ID, filters CASSETTE_BOOTSTRAP_SUB_ID, filters # type: ignore[arg-type]
) )
logger.info( logger.info(
"satmachineadmin: (re)registered cassette bootstrap " "satmachineadmin: (re)registered cassette bootstrap "
f"subscription with {len(d_tags)} d-tag(s)" f"subscription with {len(d_tags)} d-tag(s)"
) )
else: else:
nostr_client.relay_manager.close_subscription( nostr_client.relay_manager.close_subscription(CASSETTE_BOOTSTRAP_SUB_ID)
CASSETTE_BOOTSTRAP_SUB_ID
)
logger.info( logger.info(
"satmachineadmin: no active machines; closed cassette " "satmachineadmin: no active machines; closed cassette "
"bootstrap subscription" "bootstrap subscription"
) )
inbound = NostrRouter.received_subscription_events.get( inbound = NostrRouter.received_subscription_events.get(CASSETTE_BOOTSTRAP_SUB_ID)
CASSETTE_BOOTSTRAP_SUB_ID
)
if inbound: if inbound:
while inbound: while inbound:
event_message = inbound.pop(0) event_message = inbound.pop(0)
try: try:
await _handle_cassette_state_event( await _handle_cassette_state_event(
event_message, get_machine_by_atm_pubkey_hex, event_message,
get_machine_by_atm_pubkey_hex,
apply_bootstrap_state, apply_bootstrap_state,
) )
except Exception as exc: # noqa: BLE001 — log + skip except Exception as exc:
logger.warning( logger.warning(
f"satmachineadmin: cassette state event handler " f"satmachineadmin: cassette state event handler "
f"failed (skipping): {exc}" f"failed (skipping): {exc}"
@ -450,9 +444,7 @@ async def _handle_cassette_state_event(
return return
try: try:
account, signer = await _resolve_operator_signer( account, signer = await _resolve_operator_signer(machine.operator_user_id)
machine.operator_user_id
)
except CassetteTransportError as exc: except CassetteTransportError as exc:
# OperatorIdentityMissing / SignerUnavailable — log + skip. # OperatorIdentityMissing / SignerUnavailable — log + skip.
logger.warning( logger.warning(
@ -463,9 +455,7 @@ async def _handle_cassette_state_event(
return return
try: try:
payload = await decrypt_and_parse_state_event( payload = await decrypt_and_parse_state_event(event_obj, account, signer)
event_obj, account, signer
)
except CassetteEventTransientError as exc: except CassetteEventTransientError as exc:
logger.info( logger.info(
f"satmachineadmin: cassette state event for machine {machine.id} " f"satmachineadmin: cassette state event for machine {machine.id} "
@ -482,9 +472,7 @@ async def _handle_cassette_state_event(
event_id = event_obj.get("id", "") event_id = event_obj.get("id", "")
created_at_unix = event_obj.get("created_at", 0) created_at_unix = event_obj.get("created_at", 0)
event_created_at = _datetime.fromtimestamp( event_created_at = _datetime.fromtimestamp(int(created_at_unix), tz=_timezone.utc)
int(created_at_unix), tz=_timezone.utc
)
applied = await apply_bootstrap_state( applied = await apply_bootstrap_state(
machine.id, event_id, event_created_at, payload machine.id, event_id, event_created_at, payload

View file

@ -30,7 +30,6 @@ from ..models import (
UpsertCassetteConfigData, UpsertCassetteConfigData,
) )
# ============================================================================= # =============================================================================
# PublishCassettesPayload — wire-shape validators # PublishCassettesPayload — wire-shape validators
# ============================================================================= # =============================================================================
@ -95,49 +94,35 @@ class TestPublishCassettesPayload:
def test_rejects_non_int_position_key(self): def test_rejects_non_int_position_key(self):
with pytest.raises(ValueError) as exc: with pytest.raises(ValueError) as exc:
PublishCassettesPayload( PublishCassettesPayload(positions={"abc": {"denomination": 20, "count": 1}})
positions={"abc": {"denomination": 20, "count": 1}}
)
assert "is not an int" in str(exc.value) assert "is not an int" in str(exc.value)
def test_rejects_non_positive_position(self): def test_rejects_non_positive_position(self):
with pytest.raises(ValueError) as exc: with pytest.raises(ValueError) as exc:
PublishCassettesPayload( PublishCassettesPayload(positions={"0": {"denomination": 20, "count": 1}})
positions={"0": {"denomination": 20, "count": 1}}
)
assert "position must be > 0" in str(exc.value) assert "position must be > 0" in str(exc.value)
def test_rejects_negative_position(self): def test_rejects_negative_position(self):
with pytest.raises(ValueError) as exc: with pytest.raises(ValueError) as exc:
PublishCassettesPayload( PublishCassettesPayload(positions={"-1": {"denomination": 20, "count": 1}})
positions={"-1": {"denomination": 20, "count": 1}}
)
assert "position must be > 0" in str(exc.value) assert "position must be > 0" in str(exc.value)
def test_rejects_negative_count(self): def test_rejects_negative_count(self):
with pytest.raises(ValueError): with pytest.raises(ValueError):
PublishCassettesPayload( PublishCassettesPayload(positions={"1": {"denomination": 20, "count": -1}})
positions={"1": {"denomination": 20, "count": -1}}
)
def test_rejects_zero_denomination(self): def test_rejects_zero_denomination(self):
with pytest.raises(ValueError): with pytest.raises(ValueError):
PublishCassettesPayload( PublishCassettesPayload(positions={"1": {"denomination": 0, "count": 49}})
positions={"1": {"denomination": 0, "count": 49}}
)
def test_rejects_negative_denomination(self): def test_rejects_negative_denomination(self):
with pytest.raises(ValueError): with pytest.raises(ValueError):
PublishCassettesPayload( PublishCassettesPayload(positions={"1": {"denomination": -20, "count": 49}})
positions={"1": {"denomination": -20, "count": 49}}
)
def test_allows_zero_count(self): def test_allows_zero_count(self):
"""An empty cassette is a legal state — operator must be able to """An empty cassette is a legal state — operator must be able to
record `count=0` after a dispatcher pulled the cassette mid-day.""" record `count=0` after a dispatcher pulled the cassette mid-day."""
p = PublishCassettesPayload( p = PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 0}})
positions={"1": {"denomination": 20, "count": 0}}
)
assert p.positions[1].count == 0 assert p.positions[1].count == 0
@ -221,17 +206,13 @@ class TestShouldApplyBootstrapState:
assert _should_apply_bootstrap_state(None, "new-event-id") is True assert _should_apply_bootstrap_state(None, "new-event-id") is True
def test_applies_when_existing_event_id_differs(self): def test_applies_when_existing_event_id_differs(self):
assert ( assert _should_apply_bootstrap_state("old-event-id", "new-event-id") is True
_should_apply_bootstrap_state("old-event-id", "new-event-id") is True
)
def test_skips_when_existing_event_id_matches(self): def test_skips_when_existing_event_id_matches(self):
"""The same bootstrap event re-delivered after a relay reconnect """The same bootstrap event re-delivered after a relay reconnect
or satmachineadmin restart should no-op, not re-upsert the same or satmachineadmin restart should no-op, not re-upsert the same
rows (which would clobber any operator edits since).""" rows (which would clobber any operator edits since)."""
assert ( assert _should_apply_bootstrap_state("same-event", "same-event") is False
_should_apply_bootstrap_state("same-event", "same-event") is False
)
def test_applies_when_existing_is_empty_string_and_incoming_is_id(self): def test_applies_when_existing_is_empty_string_and_incoming_is_id(self):
"""Defensive — a sentinel empty-string existing_state_event_id """Defensive — a sentinel empty-string existing_state_event_id

View file

@ -30,11 +30,9 @@ convention (see test_deposit_currency.py rationale).
import asyncio import asyncio
import json import json
from types import SimpleNamespace from types import SimpleNamespace
from typing import Optional
import coincurve import coincurve
import pytest import pytest
from lnbits.core.services.nip46_bunker_client import ( from lnbits.core.services.nip46_bunker_client import (
NsecBunkerRpcError, NsecBunkerRpcError,
NsecBunkerTimeoutError, NsecBunkerTimeoutError,
@ -59,7 +57,6 @@ from ..nip44 import (
get_conversation_key, get_conversation_key,
) )
# Canonical keys (integer 1 + integer 2, the paulmillr/nip44 reference pair). # Canonical keys (integer 1 + integer 2, the paulmillr/nip44 reference pair).
_OP_SEC = "00" * 31 + "01" _OP_SEC = "00" * 31 + "01"
_ATM_SEC = "00" * 31 + "02" _ATM_SEC = "00" * 31 + "02"
@ -118,14 +115,10 @@ class _FakeLocalSignerStub:
return True return True
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str: async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
raise SignerUnavailableError( raise SignerUnavailableError("LocalSigner does not implement nip44_encrypt")
"LocalSigner does not implement nip44_encrypt"
)
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str: async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
raise SignerUnavailableError( raise SignerUnavailableError("LocalSigner does not implement nip44_decrypt")
"LocalSigner does not implement nip44_decrypt"
)
class _FakeRaisingSigner: class _FakeRaisingSigner:
@ -148,7 +141,7 @@ class _FakeRaisingSigner:
def _fake_account( def _fake_account(
signer_type: str = "RemoteBunkerSigner", signer_type: str = "RemoteBunkerSigner",
prvkey: Optional[str] = None, prvkey: str | None = None,
): ):
"""Account-shaped duck-typed object. decrypt_and_parse_state_event + """Account-shaped duck-typed object. decrypt_and_parse_state_event +
_nip44_decrypt_via_signer only read `.signer_type` and `.prvkey`; the _nip44_decrypt_via_signer only read `.signer_type` and `.prvkey`; the
@ -211,9 +204,7 @@ class TestDecryptViaBunkerSigner:
account = _fake_account(signer_type="RemoteBunkerSigner") account = _fake_account(signer_type="RemoteBunkerSigner")
signer = _FakeBunkerSigner(_OP_SEC) signer = _FakeBunkerSigner(_OP_SEC)
recovered = asyncio.run( recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
assert sorted(recovered.positions.keys()) == [1, 2] assert sorted(recovered.positions.keys()) == [1, 2]
assert recovered.positions[1].denomination == 20 assert recovered.positions[1].denomination == 20
assert recovered.positions[1].count == 49 assert recovered.positions[1].count == 49
@ -235,9 +226,7 @@ class TestDecryptViaBunkerSigner:
account = _fake_account() account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC) signer = _FakeBunkerSigner(_OP_SEC)
recovered = asyncio.run( recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
assert len(recovered.positions) == 4 assert len(recovered.positions) == 4
for pos in (1, 2, 3, 4): for pos in (1, 2, 3, 4):
assert recovered.positions[pos].denomination == 20 assert recovered.positions[pos].denomination == 20
@ -264,9 +253,7 @@ class TestDecryptViaLocalSignerFallback:
account = _fake_account(signer_type="LocalSigner", prvkey=_OP_SEC) account = _fake_account(signer_type="LocalSigner", prvkey=_OP_SEC)
signer = _FakeLocalSignerStub() signer = _FakeLocalSignerStub()
recovered = asyncio.run( recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
assert recovered.positions[1].denomination == 20 assert recovered.positions[1].denomination == 20
assert recovered.positions[1].count == 49 assert recovered.positions[1].count == 49
@ -283,9 +270,7 @@ class TestDecryptViaLocalSignerFallback:
signer = _FakeLocalSignerStub() signer = _FakeLocalSignerStub()
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
asyncio.run( asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
def test_clientonlysigner_raises_decode_error(self): def test_clientonlysigner_raises_decode_error(self):
"""ClientSideOnlySigner has no server-side decrypt path at all; """ClientSideOnlySigner has no server-side decrypt path at all;
@ -295,15 +280,11 @@ class TestDecryptViaLocalSignerFallback:
positions={"1": {"denomination": 20, "count": 49}} positions={"1": {"denomination": 20, "count": 49}}
) )
event = _make_state_event(payload) event = _make_state_event(payload)
account = _fake_account( account = _fake_account(signer_type="ClientSideOnlySigner", prvkey=None)
signer_type="ClientSideOnlySigner", prvkey=None
)
signer = _FakeLocalSignerStub() # behaves the same way: raises signer = _FakeLocalSignerStub() # behaves the same way: raises
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
asyncio.run( asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
# ============================================================================= # =============================================================================
@ -326,13 +307,9 @@ class TestBunkerErrorMapping:
) )
event = _make_state_event(payload) event = _make_state_event(payload)
account = _fake_account() account = _fake_account()
signer = _FakeRaisingSigner( signer = _FakeRaisingSigner(NsecBunkerTimeoutError("bunker unreachable"))
NsecBunkerTimeoutError("bunker unreachable")
)
with pytest.raises(CassetteEventTransientError): with pytest.raises(CassetteEventTransientError):
asyncio.run( asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
def test_rpc_reject_maps_to_decode_error(self): def test_rpc_reject_maps_to_decode_error(self):
"""Bunker rejected the RPC (policy / MAC / config) → """Bunker rejected the RPC (policy / MAC / config) →
@ -347,9 +324,7 @@ class TestBunkerErrorMapping:
NsecBunkerRpcError("bunker policy reject: kind 30078 not authorised") NsecBunkerRpcError("bunker policy reject: kind 30078 not authorised")
) )
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
asyncio.run( asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
# ============================================================================= # =============================================================================
@ -371,9 +346,7 @@ class TestPayloadValidation:
account = _fake_account() account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC) signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
asyncio.run( asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
def test_wrong_signer_privkey_rejected(self): def test_wrong_signer_privkey_rejected(self):
"""Wrong privkey on the signer → wrong conversation key → MAC """Wrong privkey on the signer → wrong conversation key → MAC
@ -389,37 +362,27 @@ class TestPayloadValidation:
wrong_sec = "00" * 31 + "03" wrong_sec = "00" * 31 + "03"
signer = _FakeBunkerSigner(wrong_sec) signer = _FakeBunkerSigner(wrong_sec)
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
asyncio.run( asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
def test_missing_content_rejected(self): def test_missing_content_rejected(self):
event = _make_state_event( event = _make_state_event(
PublishCassettesPayload( PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 49}})
positions={"1": {"denomination": 20, "count": 49}}
)
) )
del event["content"] del event["content"]
account = _fake_account() account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC) signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
asyncio.run( asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
def test_missing_pubkey_rejected(self): def test_missing_pubkey_rejected(self):
event = _make_state_event( event = _make_state_event(
PublishCassettesPayload( PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 49}})
positions={"1": {"denomination": 20, "count": 49}}
)
) )
del event["pubkey"] del event["pubkey"]
account = _fake_account() account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC) signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
asyncio.run( asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
def test_decrypted_garbage_json_rejected(self): def test_decrypted_garbage_json_rejected(self):
"""If plaintext decrypts cleanly but isn't valid JSON, surface """If plaintext decrypts cleanly but isn't valid JSON, surface
@ -428,9 +391,7 @@ class TestPayloadValidation:
event = { event = {
"kind": 30078, "kind": 30078,
"pubkey": _ATM_PUB, "pubkey": _ATM_PUB,
"content": encrypt_with_conversation_key( "content": encrypt_with_conversation_key("definitely not json", ck),
"definitely not json", ck
),
"tags": [], "tags": [],
"created_at": 0, "created_at": 0,
"id": "x", "id": "x",
@ -438,9 +399,7 @@ class TestPayloadValidation:
account = _fake_account() account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC) signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
asyncio.run( asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
def test_decrypted_wrong_shape_rejected(self): def test_decrypted_wrong_shape_rejected(self):
"""Well-formed JSON but missing 'positions' → payload-shape """Well-formed JSON but missing 'positions' → payload-shape
@ -457,9 +416,7 @@ class TestPayloadValidation:
account = _fake_account() account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC) signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
asyncio.run( asyncio.run(decrypt_and_parse_state_event(event, account, signer))
decrypt_and_parse_state_event(event, account, signer)
)
# ============================================================================= # =============================================================================

View file

@ -32,6 +32,7 @@ from ..nip44 import (
get_conversation_key, get_conversation_key,
) )
# Helper: derive a compressed-x-coord pubkey hex string from a secret hex. # Helper: derive a compressed-x-coord pubkey hex string from a secret hex.
def _pub_hex(sec_hex: str) -> str: def _pub_hex(sec_hex: str) -> str:
return ( return (
@ -222,7 +223,10 @@ class TestPaddingFormula:
(32, 32), (32, 32),
(33, 64), # > 32 → next chunk (33, 64), # > 32 → next chunk
(64, 64), (64, 64),
(65, 96), # chunk = 32 for L=65 (next_power(64) = 64; 64//8 = 8; max(32, 8) = 32) (
65,
96,
), # chunk = 32 for L=65 (next_power(64) = 64; 64//8 = 8; max(32, 8) = 32)
(100, 128), (100, 128),
(128, 128), (128, 128),
# L=129: next_power(128) = 1<<8 = 256; chunk = max(32, 256//8) = 32; # L=129: next_power(128) = 1<<8 = 256; chunk = max(32, 256//8) = 32;
@ -230,7 +234,10 @@ class TestPaddingFormula:
(129, 160), (129, 160),
(256, 256), # chunk = 32 for L=256 (next_power(255)=256; max(32, 32) = 32) (256, 256), # chunk = 32 for L=256 (next_power(255)=256; max(32, 32) = 32)
(257, 320), (257, 320),
(1000, 1024), # chunk = 128 for L=1000 (next_power(999)=1024; max(32, 128) = 128) (
1000,
1024,
), # chunk = 128 for L=1000 (next_power(999)=1024; max(32, 128) = 128)
], ],
) )
def test_calc_padded_len(self, plaintext_len, expected_padded): def test_calc_padded_len(self, plaintext_len, expected_padded):
@ -300,12 +307,8 @@ _BITSPIRE_FIXTURE = {
], ],
], ],
"created_at": 1780173222, "created_at": 1780173222,
"pubkey": ( "pubkey": ("217bdc9a65b571c4d9b59da6227a7aa6ca5bbfd5280af791417c57a79d92852b"),
"217bdc9a65b571c4d9b59da6227a7aa6ca5bbfd5280af791417c57a79d92852b" "id": ("72c09f333386dd4ad6125f8c69823824eea50d8091b694458bcd60701517eece"),
),
"id": (
"72c09f333386dd4ad6125f8c69823824eea50d8091b694458bcd60701517eece"
),
"sig": ( "sig": (
"07ecafacf0169f074e564a999ee1c31446930b43391d007c4a1f9ef7ad890d6c" "07ecafacf0169f074e564a999ee1c31446930b43391d007c4a1f9ef7ad890d6c"
"2aa6e3ecc5318edeb5748fbd64c7ca33407099a97154e2ff7e0c626e48d71925" "2aa6e3ecc5318edeb5748fbd64c7ca33407099a97154e2ff7e0c626e48d71925"

View file

@ -872,9 +872,7 @@ async def api_publish_machine_cassettes(
updated = await update_cassette_config( updated = await update_cassette_config(
machine_id, machine_id,
pos, pos,
UpsertCassetteConfigData( UpsertCassetteConfigData(denomination=row.denomination, count=row.count),
denomination=row.denomination, count=row.count
),
updated_by=user.id, updated_by=user.id,
) )
if updated is None: if updated is None:
@ -894,8 +892,6 @@ async def api_publish_machine_cassettes(
except RelayUnavailable as exc: except RelayUnavailable as exc:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) from exc raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) from exc
except CassetteTransportError as exc: except CassetteTransportError as exc:
raise HTTPException( raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) from exc
HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)
) from exc
return await list_cassette_configs_for_machine(machine_id) return await list_cassette_configs_for_machine(machine_id)