From d448fab0d2097ff604b0f8db58c3ff2949b8dc79 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 31 May 2026 15:50:14 +0200 Subject: [PATCH] =?UTF-8?q?chore(v2):=20lint=20pass=20=E2=80=94=20black=20?= =?UTF-8?q?+=20ruff=20auto-fix=20+=20mypy=20regressions=20(#29=20v1.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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[]`. 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) --- cassette_transport.py | 39 ++---- crud.py | 147 +++++++++++---------- migrations.py | 18 +-- models.py | 179 +++++++++++++------------- nip44.py | 23 ++-- tasks.py | 46 +++---- tests/test_cassette_configs.py | 37 ++---- tests/test_cassette_state_consumer.py | 85 +++--------- tests/test_nip44_v2.py | 19 +-- views_api.py | 8 +- 10 files changed, 249 insertions(+), 352 deletions(-) diff --git a/cassette_transport.py b/cassette_transport.py index 0517989..1725dde 100644 --- a/cassette_transport.py +++ b/cassette_transport.py @@ -42,7 +42,6 @@ from __future__ import annotations import json import time -from typing import Optional from lnbits.core.crud.users import get_account 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) except SignerError as exc: raise SignerUnavailable( - f"signer resolve failed for operator {operator_user_id[:8]}...: " - f"{exc}" + f"signer resolve failed for operator {operator_user_id[:8]}...: " f"{exc}" ) from exc if not signer.can_sign(): raise SignerUnavailable( @@ -185,9 +183,7 @@ async def _resolve_operator_signer(operator_user_id: str): return account, signer -async def _sign_as_operator( - operator_user_id: str, event: dict -) -> Optional[dict]: +async def _sign_as_operator(operator_user_id: str, event: dict) -> dict | None: """Sign `event` using the operator's signer (LocalSigner or RemoteBunkerSigner). Mutates `event` to add `created_at` (now), `pubkey`, `id`, and `sig`. @@ -203,8 +199,7 @@ async def _sign_as_operator( return await signer.sign_event(event) except SignerUnavailableError as exc: raise SignerUnavailable( - f"signer unavailable for operator {operator_user_id[:8]}...: " - f"{exc}" + f"signer unavailable for operator {operator_user_id[:8]}...: " f"{exc}" ) from exc @@ -231,13 +226,8 @@ async def _nip44_encrypt_via_signer( try: return await signer.nip44_encrypt(plaintext, peer_pubkey_hex) except SignerUnavailableError: - if ( - account.signer_type == "LocalSigner" - and account.prvkey - ): - return _nip44_local_encrypt( - plaintext, account.prvkey, peer_pubkey_hex - ) + if account.signer_type == "LocalSigner" and account.prvkey: + return _nip44_local_encrypt(plaintext, account.prvkey, peer_pubkey_hex) # ClientSideOnly, or RemoteBunkerSigner with bunker comms failure # at config time — re-raise without wrapping; caller maps it. raise @@ -251,13 +241,8 @@ async def _nip44_decrypt_via_signer( try: return await signer.nip44_decrypt(ciphertext, peer_pubkey_hex) except SignerUnavailableError: - if ( - account.signer_type == "LocalSigner" - and account.prvkey - ): - return _nip44_local_decrypt( - ciphertext, account.prvkey, peer_pubkey_hex - ) + if account.signer_type == "LocalSigner" and account.prvkey: + return _nip44_local_decrypt(ciphertext, account.prvkey, peer_pubkey_hex) raise @@ -411,9 +396,7 @@ async def decrypt_and_parse_state_event( f"bunker rejected nip44_decrypt (policy / MAC / config): {exc}" ) from exc except SignerUnavailableError as exc: - raise CassetteEventDecodeError( - f"signer cannot nip44-decrypt: {exc}" - ) from exc + raise CassetteEventDecodeError(f"signer cannot nip44-decrypt: {exc}") from exc except Nip44Error as exc: # Hand-rolled LocalSigner fallback path (transitional) — MAC fail # / version mismatch / length issue. @@ -424,9 +407,7 @@ async def decrypt_and_parse_state_event( # coincurve raises ValueError on a malformed pubkey hex (only # reachable via the LocalSigner fallback path; the bunker handles # pubkey validation server-side). - raise CassetteEventDecodeError( - f"sender pubkey is malformed: {exc}" - ) from exc + raise CassetteEventDecodeError(f"sender pubkey is malformed: {exc}") from exc try: raw = json.loads(plaintext) @@ -437,7 +418,7 @@ async def decrypt_and_parse_state_event( try: return PublishCassettesPayload(**raw) - except Exception as exc: # noqa: BLE001 — Pydantic raises various subclasses + except Exception as exc: raise CassetteEventDecodeError( f"payload didn't validate as PublishCassettesPayload: {exc}" ) from exc diff --git a/crud.py b/crud.py index 01ee876..1144b0c 100644 --- a/crud.py +++ b/crud.py @@ -6,7 +6,6 @@ # machine model". from datetime import datetime -from typing import List, Optional from lnbits.db import Database 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( "SELECT * FROM satoshimachine.super_config WHERE id = :id", {"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} if not update_data: return await get_super_config() @@ -103,7 +102,7 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach return machine -async def get_machine(machine_id: str) -> Optional[Machine]: +async def get_machine(machine_id: str) -> Machine | None: return await db.fetchone( "SELECT * FROM satoshimachine.dca_machines WHERE id = :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( "SELECT * FROM satoshimachine.dca_machines WHERE machine_npub = :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.""" 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( """ 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 cross-operator subscription filter. Each event's pubkey routes to 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 in machine_npub. Used by the cassette bootstrap consumer to route an 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 -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} if not update_data: 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( f"SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} WHERE c.id = :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( machine_id: str, user_id: str -) -> Optional[DcaClient]: +) -> DcaClient | None: return await db.fetchone( f""" 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( f""" 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.""" return await db.fetchall( 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.""" return await db.fetchall( 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 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 via satmachineclient yet.""" return await db.fetchone( @@ -367,7 +366,7 @@ async def upsert_dca_lp( user_id: str, data: UpsertDcaLpData, *, - fallback_wallet_id: Optional[str] = None, + fallback_wallet_id: str | None = None, ) -> DcaLpPreferences: """Create or update the LP's preferences row. @@ -422,7 +421,7 @@ async def upsert_dca_lp( async def update_dca_client( 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} if not update_data: return await get_dca_client(client_id) @@ -484,7 +483,7 @@ async def create_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( "SELECT * FROM satoshimachine.dca_deposits WHERE id = :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( """ 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( """ SELECT d.* @@ -520,7 +519,7 @@ async def get_deposits_for_operator(operator_user_id: str) -> List[DcaDeposit]: async def update_deposit( 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} if not update_data: return await get_deposit(deposit_id) @@ -535,7 +534,7 @@ async def update_deposit( async def update_deposit_status( deposit_id: str, data: UpdateDepositStatusData -) -> Optional[DcaDeposit]: +) -> DcaDeposit | None: payload = { "id": deposit_id, "status": data.status, @@ -570,8 +569,8 @@ async def delete_deposit(deposit_id: str) -> None: async def create_settlement_idempotent( data: CreateDcaSettlementData, initial_status: str, - error_message: Optional[str] = None, -) -> Optional[DcaSettlement]: + error_message: str | None = None, +) -> DcaSettlement | None: """Insert a settlement keyed by payment_hash. 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) -async def get_settlement(settlement_id: str) -> Optional[DcaSettlement]: +async def get_settlement(settlement_id: str) -> DcaSettlement | None: return await db.fetchone( "SELECT * FROM satoshimachine.dca_settlements WHERE id = :id", {"id": settlement_id}, @@ -641,7 +640,7 @@ async def get_settlement(settlement_id: str) -> Optional[DcaSettlement]: async def get_settlement_by_payment_hash( payment_hash: str, -) -> Optional[DcaSettlement]: +) -> DcaSettlement | None: return await db.fetchone( """ SELECT * FROM satoshimachine.dca_settlements @@ -654,7 +653,7 @@ async def get_settlement_by_payment_hash( async def get_settlements_for_machine( machine_id: str, limit: int = 100 -) -> List[DcaSettlement]: +) -> list[DcaSettlement]: return await db.fetchall( """ SELECT * FROM satoshimachine.dca_settlements @@ -747,7 +746,7 @@ async def get_stuck_settlements_for_operator( async def force_reset_stuck_settlement( settlement_id: str, -) -> Optional[DcaSettlement]: +) -> DcaSettlement | None: """Operator escape hatch for genuinely stuck settlements (processor crashed mid-flight, etc.). Flips 'pending'/'processing' → 'errored' so 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( operator_user_id: str, limit: int = 200 -) -> List[DcaSettlement]: +) -> list[DcaSettlement]: return await db.fetchall( """ SELECT s.* @@ -788,8 +787,8 @@ async def get_settlements_for_operator( async def mark_settlement_status( settlement_id: str, status: str, - error_message: Optional[str] = None, -) -> Optional[DcaSettlement]: + error_message: str | None = None, +) -> DcaSettlement | None: """Status: 'pending' | 'processing' | 'processed' | 'partial' | 'refunded' | 'errored'. Clears processing_claim on terminal states so a fresh claim attempt won't see a stale token.""" @@ -820,7 +819,7 @@ async def mark_settlement_status( async def claim_settlement_for_processing( settlement_id: str, -) -> Optional[DcaSettlement]: +) -> DcaSettlement | None: """Optimistic-lock claim: atomically flip a settlement to 'processing' 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 @@ -850,7 +849,7 @@ async def claim_settlement_for_processing( async def reset_settlement_for_retry( settlement_id: str, -) -> Optional[DcaSettlement]: +) -> DcaSettlement | None: """Operator retry path. Flips 'errored' → 'pending' and voids any 'failed' legs so process_settlement re-runs them fresh. Completed legs 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_fiat_amount: float, appended_note: str, -) -> Optional[DcaSettlement]: +) -> DcaSettlement | None: """Overwrite the monetary fields on a settlement (partial-dispense 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( settlement_id: str, note: str, author_user_id: str -) -> Optional[DcaSettlement]: +) -> DcaSettlement | None: """Prepend an operator-authored note to settlement.notes. Each entry is timestamped (UTC) and tagged with the author's user id so the trail 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( - operator_user_id: str, machine_id: Optional[str] = None -) -> List[CommissionSplit]: + operator_user_id: str, machine_id: str | None = None +) -> list[CommissionSplit]: """Returns the rule set for the given scope. Precedence (caller's responsibility): try per-machine override first; @@ -1016,7 +1015,7 @@ async def get_commission_splits( async def get_effective_commission_splits( operator_user_id: str, machine_id: str -) -> List[CommissionSplit]: +) -> list[CommissionSplit]: """Per-machine override if set, otherwise operator's default ruleset.""" overrides = await get_commission_splits(operator_user_id, machine_id) if overrides: @@ -1026,9 +1025,9 @@ async def get_effective_commission_splits( async def replace_commission_splits( operator_user_id: str, - machine_id: Optional[str], - legs: List[CommissionSplitLeg], -) -> List[CommissionSplit]: + machine_id: str | None, + legs: list[CommissionSplitLeg], +) -> list[CommissionSplit]: """Atomic replace for the (operator, machine) scope. Caller should have already validated legs sum to 1.0 via the Pydantic model.""" if machine_id is None: @@ -1114,7 +1113,7 @@ async def create_dca_payment(data: CreateDcaPaymentData) -> DcaPayment: 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( "SELECT * FROM satoshimachine.dca_payments WHERE id = :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( """ 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( """ 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( - operator_user_id: str, leg_type: Optional[str] = None, limit: int = 200 -) -> List[DcaPayment]: + operator_user_id: str, leg_type: str | None = None, limit: int = 200 +) -> list[DcaPayment]: if leg_type is None: return await db.fetchall( """ @@ -1175,9 +1174,9 @@ async def get_payments_for_operator( async def update_payment_status( payment_id: str, status: str, - external_payment_hash: Optional[str] = None, - error_message: Optional[str] = None, -) -> Optional[DcaPayment]: + external_payment_hash: str | None = None, + error_message: str | None = None, +) -> DcaPayment | None: await db.execute( """ UPDATE satoshimachine.dca_payments @@ -1203,7 +1202,7 @@ async def update_payment_status( async def get_client_balance_summary( client_id: str, -) -> Optional[ClientBalanceSummary]: +) -> ClientBalanceSummary | None: """Per-client (and per-machine, since clients are per-machine in v2) summary. 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( "SELECT * FROM satoshimachine.dca_telemetry WHERE machine_id = :mid", {"mid": machine_id}, @@ -1263,19 +1262,19 @@ async def get_telemetry(machine_id: str) -> Optional[TelemetrySnapshot]: async def upsert_beacon_snapshot( machine_id: str, *, - cash_in: Optional[bool] = None, - cash_out: Optional[bool] = None, - cash_level: Optional[str] = None, - fiat: Optional[str] = None, - model: Optional[str] = None, - name: Optional[str] = None, - location: Optional[str] = None, - geo: Optional[str] = None, - fees_json: Optional[str] = None, - limits_json: Optional[str] = None, - denominations_json: Optional[str] = None, - version: Optional[str] = None, -) -> Optional[TelemetrySnapshot]: + cash_in: bool | None = None, + cash_out: bool | None = None, + cash_level: str | None = None, + fiat: str | None = None, + model: str | None = None, + name: str | None = None, + location: str | None = None, + geo: str | None = None, + fees_json: str | None = None, + limits_json: str | None = None, + denominations_json: str | None = None, + version: str | None = None, +) -> TelemetrySnapshot | None: """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 lamassu-next#43 — the enrichment is not yet shipped).""" @@ -1352,7 +1351,7 @@ async def upsert_beacon_snapshot( async def upsert_fleet_snapshot( machine_id: str, telemetry_json: str -) -> Optional[TelemetrySnapshot]: +) -> TelemetrySnapshot | None: """Upsert kind-30079 operator-only telemetry. Awaits lamassu-next#42 to produce a real schema; we store the raw JSON blob until then.""" existing = await get_telemetry(machine_id) @@ -1391,7 +1390,7 @@ async def upsert_fleet_snapshot( 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: """Pure-function dedup gate for apply_bootstrap_state. @@ -1408,7 +1407,7 @@ def _should_apply_bootstrap_state( async def get_cassette_config( machine_id: str, position: int -) -> Optional[CassetteConfig]: +) -> CassetteConfig | None: return await db.fetchone( "SELECT * FROM satoshimachine.cassette_configs " "WHERE machine_id = :mid AND position = :pos", @@ -1419,7 +1418,7 @@ async def get_cassette_config( async def list_cassette_configs_for_machine( machine_id: str, -) -> List[CassetteConfig]: +) -> list[CassetteConfig]: return await db.fetchall( "SELECT * FROM satoshimachine.cassette_configs " "WHERE machine_id = :mid ORDER BY position", @@ -1433,8 +1432,8 @@ async def update_cassette_config( position: int, data: UpsertCassetteConfigData, *, - updated_by: Optional[str] = None, -) -> Optional[CassetteConfig]: + updated_by: str | None = None, +) -> CassetteConfig | None: """Operator-driven row update: change denomination and/or count for a single cassette slot. Refuses to create new rows — those only land via 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 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 " "WHERE machine_id = :mid LIMIT 1", {"mid": machine_id}, ) - existing_event_id: Optional[str] = None + existing_event_id: str | None = None if existing_first is not None: existing_event_id = ( existing_first.get("state_event_id") diff --git a/migrations.py b/migrations.py index 5489f96..e1d957c 100644 --- a/migrations.py +++ b/migrations.py @@ -474,9 +474,7 @@ async def m006_rename_to_canonical_sat_vocabulary(db): ] for table, old_col, new_col in renames: try: - await db.fetchone( - f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1" - ) + await db.fetchone(f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1") except Exception: # old column doesn't exist; either rename already landed or # 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: try: - await db.fetchone( - f"SELECT {col} FROM satoshimachine.{table} LIMIT 1" - ) + await db.fetchone(f"SELECT {col} FROM satoshimachine.{table} LIMIT 1") except Exception: # column doesn't exist; either already dropped or never present. continue - await db.execute( - f"ALTER TABLE satoshimachine.{table} DROP COLUMN {col}" - ) + await db.execute(f"ALTER TABLE satoshimachine.{table} DROP COLUMN {col}") 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 # column already exists, m008 already ran — no-op. await db.fetchone( - "SELECT state_denomination FROM satoshimachine.cassette_configs " - "LIMIT 1" + "SELECT state_denomination FROM satoshimachine.cassette_configs " "LIMIT 1" ) return 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( - "ALTER TABLE satoshimachine.cassette_configs_new " - "RENAME TO cassette_configs" + "ALTER TABLE satoshimachine.cassette_configs_new " "RENAME TO cassette_configs" ) diff --git a/models.py b/models.py index bec54aa..6a18815 100644 --- a/models.py +++ b/models.py @@ -6,7 +6,6 @@ # the plan at ~/.claude/plans/snug-gliding-shamir.md. from datetime import datetime -from typing import Optional from pydantic import BaseModel, validator @@ -26,8 +25,8 @@ class CreateMachineData(BaseModel): machine_npub: str wallet_id: str - name: Optional[str] = None - location: Optional[str] = None + name: str | None = None + location: str | None = None fiat_code: str = "GTQ" @@ -36,8 +35,8 @@ class Machine(BaseModel): operator_user_id: str machine_npub: str wallet_id: str - name: Optional[str] - location: Optional[str] + name: str | None + location: str | None fiat_code: str is_active: bool created_at: datetime @@ -45,11 +44,11 @@ class Machine(BaseModel): class UpdateMachineData(BaseModel): - name: Optional[str] = None - location: Optional[str] = None - fiat_code: Optional[str] = None - is_active: Optional[bool] = None - wallet_id: Optional[str] = None + name: str | None = None + location: str | None = None + fiat_code: str | None = None + is_active: bool | None = None + wallet_id: str | None = None # ============================================================================= @@ -69,14 +68,14 @@ class CreateDcaClientData(BaseModel): machine_id: str user_id: str - username: Optional[str] = None + username: str | None = None class DcaClient(BaseModel): id: str machine_id: str user_id: str - username: Optional[str] + username: str | None status: str created_at: datetime updated_at: datetime @@ -92,8 +91,8 @@ class UpdateDcaClientData(BaseModel): / mode / autoforward changes go through satmachineclient against `dca_lp` instead.""" - username: Optional[str] = None - status: Optional[str] = None + username: str | None = None + status: str | None = None class DcaLpPreferences(BaseModel): @@ -109,8 +108,8 @@ class DcaLpPreferences(BaseModel): user_id: str dca_wallet_id: str default_dca_mode: str # 'flow' | 'fixed' - fixed_mode_daily_limit: Optional[float] - autoforward_ln_address: Optional[str] + fixed_mode_daily_limit: float | None + autoforward_ln_address: str | None autoforward_enabled: bool created_at: datetime updated_at: datetime @@ -121,11 +120,11 @@ class UpsertDcaLpData(BaseModel): edits their preferences. All fields optional on update — pass only the ones being changed.""" - dca_wallet_id: Optional[str] = None - default_dca_mode: Optional[str] = None - fixed_mode_daily_limit: Optional[float] = None - autoforward_ln_address: Optional[str] = None - autoforward_enabled: Optional[bool] = None + dca_wallet_id: str | None = None + default_dca_mode: str | None = None + fixed_mode_daily_limit: float | None = None + autoforward_ln_address: str | None = None + autoforward_enabled: bool | None = None class ClientBalanceSummary(BaseModel): @@ -156,7 +155,7 @@ class CreateDepositData(BaseModel): client_id: str machine_id: str amount: float - notes: Optional[str] = None + notes: str | None = None @validator("amount") def round_amount(cls, v): @@ -173,9 +172,9 @@ class DcaDeposit(BaseModel): amount: float currency: str status: str # 'pending' | 'confirmed' | 'rejected' - notes: Optional[str] + notes: str | None created_at: datetime - confirmed_at: Optional[datetime] + confirmed_at: datetime | None class UpdateDepositData(BaseModel): @@ -183,8 +182,8 @@ class UpdateDepositData(BaseModel): `CreateDepositData`; the currency is bound to the machine and not editable after the row lands.""" - amount: Optional[float] = None - notes: Optional[str] = None + amount: float | None = None + notes: str | None = None @validator("amount") def round_amount(cls, v): @@ -195,7 +194,7 @@ class UpdateDepositData(BaseModel): class UpdateDepositStatusData(BaseModel): status: str # 'pending' | 'confirmed' | 'rejected' - notes: Optional[str] = None + notes: str | None = None # ============================================================================= @@ -210,8 +209,8 @@ class UpdateDepositStatusData(BaseModel): class CreateDcaSettlementData(BaseModel): machine_id: str payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table) - bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion - bitspire_txid: Optional[str] = None + bitspire_event_id: str | None = None # reserved for direct-Nostr ingestion + bitspire_txid: str | None = None wire_sats: int fiat_amount: float fiat_code: str = "GTQ" @@ -221,16 +220,16 @@ class CreateDcaSettlementData(BaseModel): platform_fee_sats: int operator_fee_sats: int tx_type: str # 'cash_out' | 'cash_in' - bills_json: Optional[str] = None - cassettes_json: Optional[str] = None + bills_json: str | None = None + cassettes_json: str | None = None class DcaSettlement(BaseModel): id: str machine_id: str payment_hash: str - bitspire_event_id: Optional[str] - bitspire_txid: Optional[str] + bitspire_event_id: str | None + bitspire_txid: str | None wire_sats: int fiat_amount: float fiat_code: str @@ -240,8 +239,8 @@ class DcaSettlement(BaseModel): platform_fee_sats: int operator_fee_sats: int tx_type: str - bills_json: Optional[str] - cassettes_json: Optional[str] + bills_json: str | None + cassettes_json: str | None # 'pending' (default at insert) # 'processing' (claim taken by distribution processor) # 'processed' (all legs paid) @@ -252,19 +251,19 @@ class DcaSettlement(BaseModel): # never went near distribution. error_message holds the # reason. Retry is wrong — investigate the machine.) status: str - error_message: Optional[str] - processed_at: Optional[datetime] + error_message: str | None + processed_at: datetime | None created_at: datetime # Append-only audit memo. Populated when an operator triggers an in-place # adjustment (partial-dispense, manual reconciliation override). Each # entry timestamped + records original values so the overwrite is # 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'. # Two concurrent process_settlement invocations can't both win the claim # (only one matching read-back). Cleared back to NULL when the leg- # 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 - label: Optional[str] = None + label: str | None = None fraction: float sort_order: int = 0 @@ -306,10 +305,10 @@ class CommissionSplitLeg(BaseModel): class CommissionSplit(BaseModel): id: str - machine_id: Optional[str] # None = operator's default ruleset + machine_id: str | None # None = operator's default ruleset operator_user_id: str target: str - label: Optional[str] + label: str | None fraction: float sort_order: int created_at: datetime @@ -322,7 +321,7 @@ class SetCommissionSplitsData(BaseModel): machine without an explicit override). Otherwise scoped per machine. """ - machine_id: Optional[str] = None + machine_id: str | None = None legs: list[CommissionSplitLeg] @validator("legs") @@ -339,35 +338,35 @@ class SetCommissionSplitsData(BaseModel): class CreateDcaPaymentData(BaseModel): - settlement_id: Optional[str] = None - client_id: Optional[str] = None + settlement_id: str | None = None + client_id: str | None = None machine_id: str operator_user_id: str leg_type: str # 'dca' | 'super_fee' | 'operator_split' | 'settlement' | 'autoforward' | 'refund' - destination_wallet_id: Optional[str] = None - destination_ln_address: Optional[str] = None + destination_wallet_id: str | None = None + destination_ln_address: str | None = None amount_sats: int - amount_fiat: Optional[float] = None - exchange_rate: Optional[float] = None + amount_fiat: float | None = None + exchange_rate: float | None = None transaction_time: datetime - external_payment_hash: Optional[str] = None + external_payment_hash: str | None = None class DcaPayment(BaseModel): id: str - settlement_id: Optional[str] - client_id: Optional[str] + settlement_id: str | None + client_id: str | None machine_id: str operator_user_id: str leg_type: str - destination_wallet_id: Optional[str] - destination_ln_address: Optional[str] + destination_wallet_id: str | None + destination_ln_address: str | None amount_sats: int - amount_fiat: Optional[float] - exchange_rate: Optional[float] + amount_fiat: float | None + exchange_rate: float | None transaction_time: datetime - external_payment_hash: Optional[str] + external_payment_hash: str | None status: str # Leg status enum: # 'pending' — row written, payment not yet attempted @@ -378,7 +377,7 @@ class DcaPayment(BaseModel): # 'skipped' — intentionally not paid (no super wallet configured, # no commission ruleset, no exchange rate, no LPs) # 'refunded' — reserved for future refund flows - error_message: Optional[str] + error_message: str | None created_at: datetime @@ -391,22 +390,22 @@ class TelemetrySnapshot(BaseModel): machine_id: str # 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. - beacon_cash_in: Optional[bool] = None - beacon_cash_out: Optional[bool] = None - beacon_cash_level: Optional[str] = None - beacon_fiat: Optional[str] = None - beacon_model: Optional[str] = None - beacon_name: Optional[str] = None - beacon_location: Optional[str] = None - beacon_geo: Optional[str] = None - beacon_fees_json: Optional[str] = None - beacon_limits_json: Optional[str] = None - beacon_denominations_json: Optional[str] = None - beacon_version: Optional[str] = None - beacon_received_at: Optional[datetime] = None + beacon_cash_in: bool | None = None + beacon_cash_out: bool | None = None + beacon_cash_level: str | None = None + beacon_fiat: str | None = None + beacon_model: str | None = None + beacon_name: str | None = None + beacon_location: str | None = None + beacon_geo: str | None = None + beacon_fees_json: str | None = None + beacon_limits_json: str | None = None + beacon_denominations_json: str | None = None + beacon_version: str | None = None + beacon_received_at: datetime | None = None # Fleet telemetry (kind-30079) — operator-only, awaits lamassu-next#42. - telemetry_json: Optional[str] = None - telemetry_received_at: Optional[datetime] = None + telemetry_json: str | None = None + telemetry_received_at: datetime | None = None # ============================================================================= @@ -417,13 +416,13 @@ class TelemetrySnapshot(BaseModel): class SuperConfig(BaseModel): id: str super_fee_fraction: float - super_fee_wallet_id: Optional[str] + super_fee_wallet_id: str | None updated_at: datetime class UpdateSuperConfigData(BaseModel): - super_fee_fraction: Optional[float] = None - super_fee_wallet_id: Optional[str] = None + super_fee_fraction: float | None = None + super_fee_wallet_id: str | None = None @validator("super_fee_fraction") def fee_in_unit_range(cls, v): @@ -448,9 +447,9 @@ class PartialDispenseData(BaseModel): """ settlement_id: str - dispensed_fraction: Optional[float] = None - dispensed_sats: Optional[int] = None - notes: Optional[str] = None + dispensed_fraction: float | None = None + dispensed_sats: int | None = None + notes: str | None = None @validator("dispensed_fraction") def fraction_in_unit_range(cls, v): @@ -530,8 +529,8 @@ class SettleBalanceData(BaseModel): # there's no ambiguity about what rate was used. exchange_rate: float # If None, settle the LP's full remaining balance. Else partial. - amount_fiat: Optional[float] = None - notes: Optional[str] = None + amount_fiat: float | None = None + notes: str | None = None @validator("exchange_rate") def positive_rate(cls, v): @@ -585,11 +584,11 @@ class CassetteConfig(BaseModel): denomination: int count: int updated_at: datetime - updated_by: Optional[str] - state_denomination: Optional[int] - state_count: Optional[int] - state_at: Optional[datetime] - state_event_id: Optional[str] + updated_by: str | None + state_denomination: int | None + state_count: int | None + state_at: datetime | None + state_event_id: str | None class UpsertCassetteConfigData(BaseModel): @@ -597,8 +596,8 @@ class UpsertCassetteConfigData(BaseModel): the dashboard. Both fields optional; pass only those changed. Position is not edited — it's the row's identity (hardware bay).""" - denomination: Optional[int] = None - count: Optional[int] = None + denomination: int | None = None + count: int | None = None @validator("denomination") def denomination_positive(cls, v): @@ -664,9 +663,7 @@ class PublishCassettesPayload(BaseModel): try: key_int = int(k) except (TypeError, ValueError) as exc: - raise ValueError( - f"position key {k!r} is not an int" - ) from exc + raise ValueError(f"position key {k!r} is not an int") from exc if key_int <= 0: raise ValueError(f"position must be > 0 (got {key_int})") out[key_int] = val diff --git a/nip44.py b/nip44.py index 7bd5c32..109860d 100644 --- a/nip44.py +++ b/nip44.py @@ -57,7 +57,6 @@ import hashlib import hmac as hmac_stdlib import os import struct -from typing import Optional import coincurve from cryptography.hazmat.primitives import hashes, hmac @@ -71,7 +70,9 @@ _MIN_PLAINTEXT_LEN = 1 _MAX_PLAINTEXT_LEN = 65535 _NONCE_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 @@ -98,8 +99,8 @@ class Nip44LengthError(Nip44Error): def _calc_padded_len(plaintext_len: int) -> int: """Per NIP-44 v2 padding scheme: - if L <= 32: padded_len = 32 - else: chunk = max(32, next_power_2(L-1) // 8); padded_len = chunk * ((L-1) // chunk + 1) + if L <= 32: padded_len = 32 + else: chunk = max(32, next_power_2(L-1) // 8); padded_len = chunk * ((L-1) // chunk + 1) """ if plaintext_len <= 32: return 32 @@ -205,7 +206,7 @@ def encrypt_with_conversation_key( plaintext: str, conversation_key: bytes, *, - nonce: Optional[bytes] = None, + nonce: bytes | None = None, ) -> str: """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) ciphertext = _chacha20(chacha_key, chacha_nonce, padded) mac = _hmac_aad(hmac_key, nonce, ciphertext) - return base64.b64encode( - bytes([_VERSION]) + nonce + ciphertext + mac - ).decode("ascii") + return base64.b64encode(bytes([_VERSION]) + nonce + ciphertext + mac).decode( + "ascii" + ) 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: 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 if len(raw) < _MIN_PAYLOAD_LEN or len(raw) > _MAX_PAYLOAD_LEN: @@ -272,7 +275,7 @@ def encrypt_for( sender_privkey_hex: str, recipient_pubkey_hex: str, *, - nonce: Optional[bytes] = None, + nonce: bytes | None = None, ) -> str: """Encrypt `plaintext` from the sender (holding the privkey) to the recipient (identified by pubkey). The recipient can decrypt with `decrypt_from( diff --git a/tasks.py b/tasks.py index 22487a0..7f2a276 100644 --- a/tasks.py +++ b/tasks.py @@ -25,7 +25,6 @@ # sat-amount invariants (range/sum). import asyncio -from typing import Optional from lnbits.core.models import Payment 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 # breach. super_config = await get_super_config() - super_fee_fraction = ( - float(super_config.super_fee_fraction) if super_config else 0.0 - ) + super_fee_fraction = float(super_config.super_fee_fraction) if super_config else 0.0 try: data = parse_settlement( machine=machine, @@ -195,9 +192,7 @@ async def _handle_payment(payment: Payment) -> None: task.add_done_callback(_inflight_distributions.discard) -async def _record_rejected( - payment: Payment, machine: Machine, exc: Exception -) -> None: +async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -> None: """Insert a minimal `dca_settlements` row with `status='rejected'` and the exception message for operator forensics. @@ -288,7 +283,7 @@ async def wait_for_cassette_state_events() -> None: "satmachineadmin v2: cassette bootstrap consumer starting " f"(sub_id={CASSETTE_BOOTSTRAP_SUB_ID})" ) - current_filter_key: Optional[str] = None + current_filter_key: str | None = None while True: try: 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) except Exception as exc: # listener must never die logger.error( - f"satmachineadmin: cassette consumer loop error (continuing): " - f"{exc}" + f"satmachineadmin: cassette consumer loop error (continuing): " f"{exc}" ) await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S) @@ -316,7 +310,7 @@ class _NostrclientUnavailable(Exception): 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 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 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( - CASSETTE_BOOTSTRAP_SUB_ID, filters + CASSETTE_BOOTSTRAP_SUB_ID, filters # type: ignore[arg-type] ) logger.info( "satmachineadmin: (re)registered cassette bootstrap " f"subscription with {len(d_tags)} d-tag(s)" ) else: - nostr_client.relay_manager.close_subscription( - CASSETTE_BOOTSTRAP_SUB_ID - ) + nostr_client.relay_manager.close_subscription(CASSETTE_BOOTSTRAP_SUB_ID) logger.info( "satmachineadmin: no active machines; closed cassette " "bootstrap subscription" ) - inbound = NostrRouter.received_subscription_events.get( - CASSETTE_BOOTSTRAP_SUB_ID - ) + inbound = NostrRouter.received_subscription_events.get(CASSETTE_BOOTSTRAP_SUB_ID) if inbound: while inbound: event_message = inbound.pop(0) try: await _handle_cassette_state_event( - event_message, get_machine_by_atm_pubkey_hex, + event_message, + get_machine_by_atm_pubkey_hex, apply_bootstrap_state, ) - except Exception as exc: # noqa: BLE001 — log + skip + except Exception as exc: logger.warning( f"satmachineadmin: cassette state event handler " f"failed (skipping): {exc}" @@ -450,9 +444,7 @@ async def _handle_cassette_state_event( return try: - account, signer = await _resolve_operator_signer( - machine.operator_user_id - ) + account, signer = await _resolve_operator_signer(machine.operator_user_id) except CassetteTransportError as exc: # OperatorIdentityMissing / SignerUnavailable — log + skip. logger.warning( @@ -463,9 +455,7 @@ async def _handle_cassette_state_event( return try: - payload = await decrypt_and_parse_state_event( - event_obj, account, signer - ) + payload = await decrypt_and_parse_state_event(event_obj, account, signer) except CassetteEventTransientError as exc: logger.info( 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", "") created_at_unix = event_obj.get("created_at", 0) - event_created_at = _datetime.fromtimestamp( - int(created_at_unix), tz=_timezone.utc - ) + event_created_at = _datetime.fromtimestamp(int(created_at_unix), tz=_timezone.utc) applied = await apply_bootstrap_state( machine.id, event_id, event_created_at, payload diff --git a/tests/test_cassette_configs.py b/tests/test_cassette_configs.py index 8526951..08a7e00 100644 --- a/tests/test_cassette_configs.py +++ b/tests/test_cassette_configs.py @@ -30,7 +30,6 @@ from ..models import ( UpsertCassetteConfigData, ) - # ============================================================================= # PublishCassettesPayload — wire-shape validators # ============================================================================= @@ -95,49 +94,35 @@ class TestPublishCassettesPayload: def test_rejects_non_int_position_key(self): with pytest.raises(ValueError) as exc: - PublishCassettesPayload( - positions={"abc": {"denomination": 20, "count": 1}} - ) + PublishCassettesPayload(positions={"abc": {"denomination": 20, "count": 1}}) assert "is not an int" in str(exc.value) def test_rejects_non_positive_position(self): with pytest.raises(ValueError) as exc: - PublishCassettesPayload( - positions={"0": {"denomination": 20, "count": 1}} - ) + PublishCassettesPayload(positions={"0": {"denomination": 20, "count": 1}}) assert "position must be > 0" in str(exc.value) def test_rejects_negative_position(self): with pytest.raises(ValueError) as exc: - PublishCassettesPayload( - positions={"-1": {"denomination": 20, "count": 1}} - ) + PublishCassettesPayload(positions={"-1": {"denomination": 20, "count": 1}}) assert "position must be > 0" in str(exc.value) def test_rejects_negative_count(self): with pytest.raises(ValueError): - PublishCassettesPayload( - positions={"1": {"denomination": 20, "count": -1}} - ) + PublishCassettesPayload(positions={"1": {"denomination": 20, "count": -1}}) def test_rejects_zero_denomination(self): with pytest.raises(ValueError): - PublishCassettesPayload( - positions={"1": {"denomination": 0, "count": 49}} - ) + PublishCassettesPayload(positions={"1": {"denomination": 0, "count": 49}}) def test_rejects_negative_denomination(self): with pytest.raises(ValueError): - PublishCassettesPayload( - positions={"1": {"denomination": -20, "count": 49}} - ) + PublishCassettesPayload(positions={"1": {"denomination": -20, "count": 49}}) def test_allows_zero_count(self): """An empty cassette is a legal state — operator must be able to record `count=0` after a dispatcher pulled the cassette mid-day.""" - p = PublishCassettesPayload( - positions={"1": {"denomination": 20, "count": 0}} - ) + p = PublishCassettesPayload(positions={"1": {"denomination": 20, "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 def test_applies_when_existing_event_id_differs(self): - assert ( - _should_apply_bootstrap_state("old-event-id", "new-event-id") is True - ) + assert _should_apply_bootstrap_state("old-event-id", "new-event-id") is True def test_skips_when_existing_event_id_matches(self): """The same bootstrap event re-delivered after a relay reconnect or satmachineadmin restart should no-op, not re-upsert the same rows (which would clobber any operator edits since).""" - assert ( - _should_apply_bootstrap_state("same-event", "same-event") is False - ) + assert _should_apply_bootstrap_state("same-event", "same-event") is False def test_applies_when_existing_is_empty_string_and_incoming_is_id(self): """Defensive — a sentinel empty-string existing_state_event_id diff --git a/tests/test_cassette_state_consumer.py b/tests/test_cassette_state_consumer.py index 48aeb31..a0840bc 100644 --- a/tests/test_cassette_state_consumer.py +++ b/tests/test_cassette_state_consumer.py @@ -30,11 +30,9 @@ convention (see test_deposit_currency.py rationale). import asyncio import json from types import SimpleNamespace -from typing import Optional import coincurve import pytest - from lnbits.core.services.nip46_bunker_client import ( NsecBunkerRpcError, NsecBunkerTimeoutError, @@ -59,7 +57,6 @@ from ..nip44 import ( get_conversation_key, ) - # Canonical keys (integer 1 + integer 2, the paulmillr/nip44 reference pair). _OP_SEC = "00" * 31 + "01" _ATM_SEC = "00" * 31 + "02" @@ -118,14 +115,10 @@ class _FakeLocalSignerStub: return True async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str: - raise SignerUnavailableError( - "LocalSigner does not implement nip44_encrypt" - ) + raise SignerUnavailableError("LocalSigner does not implement nip44_encrypt") async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str: - raise SignerUnavailableError( - "LocalSigner does not implement nip44_decrypt" - ) + raise SignerUnavailableError("LocalSigner does not implement nip44_decrypt") class _FakeRaisingSigner: @@ -148,7 +141,7 @@ class _FakeRaisingSigner: def _fake_account( signer_type: str = "RemoteBunkerSigner", - prvkey: Optional[str] = None, + prvkey: str | None = None, ): """Account-shaped duck-typed object. decrypt_and_parse_state_event + _nip44_decrypt_via_signer only read `.signer_type` and `.prvkey`; the @@ -211,9 +204,7 @@ class TestDecryptViaBunkerSigner: account = _fake_account(signer_type="RemoteBunkerSigner") signer = _FakeBunkerSigner(_OP_SEC) - recovered = asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer)) assert sorted(recovered.positions.keys()) == [1, 2] assert recovered.positions[1].denomination == 20 assert recovered.positions[1].count == 49 @@ -235,9 +226,7 @@ class TestDecryptViaBunkerSigner: account = _fake_account() signer = _FakeBunkerSigner(_OP_SEC) - recovered = asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer)) assert len(recovered.positions) == 4 for pos in (1, 2, 3, 4): assert recovered.positions[pos].denomination == 20 @@ -264,9 +253,7 @@ class TestDecryptViaLocalSignerFallback: account = _fake_account(signer_type="LocalSigner", prvkey=_OP_SEC) signer = _FakeLocalSignerStub() - recovered = asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + recovered = asyncio.run(decrypt_and_parse_state_event(event, account, signer)) assert recovered.positions[1].denomination == 20 assert recovered.positions[1].count == 49 @@ -283,9 +270,7 @@ class TestDecryptViaLocalSignerFallback: signer = _FakeLocalSignerStub() with pytest.raises(CassetteEventDecodeError): - asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + asyncio.run(decrypt_and_parse_state_event(event, account, signer)) def test_clientonlysigner_raises_decode_error(self): """ClientSideOnlySigner has no server-side decrypt path at all; @@ -295,15 +280,11 @@ class TestDecryptViaLocalSignerFallback: positions={"1": {"denomination": 20, "count": 49}} ) event = _make_state_event(payload) - account = _fake_account( - signer_type="ClientSideOnlySigner", prvkey=None - ) + account = _fake_account(signer_type="ClientSideOnlySigner", prvkey=None) signer = _FakeLocalSignerStub() # behaves the same way: raises with pytest.raises(CassetteEventDecodeError): - asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + asyncio.run(decrypt_and_parse_state_event(event, account, signer)) # ============================================================================= @@ -326,13 +307,9 @@ class TestBunkerErrorMapping: ) event = _make_state_event(payload) account = _fake_account() - signer = _FakeRaisingSigner( - NsecBunkerTimeoutError("bunker unreachable") - ) + signer = _FakeRaisingSigner(NsecBunkerTimeoutError("bunker unreachable")) with pytest.raises(CassetteEventTransientError): - asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + asyncio.run(decrypt_and_parse_state_event(event, account, signer)) def test_rpc_reject_maps_to_decode_error(self): """Bunker rejected the RPC (policy / MAC / config) → @@ -347,9 +324,7 @@ class TestBunkerErrorMapping: NsecBunkerRpcError("bunker policy reject: kind 30078 not authorised") ) with pytest.raises(CassetteEventDecodeError): - asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + asyncio.run(decrypt_and_parse_state_event(event, account, signer)) # ============================================================================= @@ -371,9 +346,7 @@ class TestPayloadValidation: account = _fake_account() signer = _FakeBunkerSigner(_OP_SEC) with pytest.raises(CassetteEventDecodeError): - asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + asyncio.run(decrypt_and_parse_state_event(event, account, signer)) def test_wrong_signer_privkey_rejected(self): """Wrong privkey on the signer → wrong conversation key → MAC @@ -389,37 +362,27 @@ class TestPayloadValidation: wrong_sec = "00" * 31 + "03" signer = _FakeBunkerSigner(wrong_sec) with pytest.raises(CassetteEventDecodeError): - asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + asyncio.run(decrypt_and_parse_state_event(event, account, signer)) def test_missing_content_rejected(self): event = _make_state_event( - PublishCassettesPayload( - positions={"1": {"denomination": 20, "count": 49}} - ) + PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 49}}) ) del event["content"] account = _fake_account() signer = _FakeBunkerSigner(_OP_SEC) with pytest.raises(CassetteEventDecodeError): - asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + asyncio.run(decrypt_and_parse_state_event(event, account, signer)) def test_missing_pubkey_rejected(self): event = _make_state_event( - PublishCassettesPayload( - positions={"1": {"denomination": 20, "count": 49}} - ) + PublishCassettesPayload(positions={"1": {"denomination": 20, "count": 49}}) ) del event["pubkey"] account = _fake_account() signer = _FakeBunkerSigner(_OP_SEC) with pytest.raises(CassetteEventDecodeError): - asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + asyncio.run(decrypt_and_parse_state_event(event, account, signer)) def test_decrypted_garbage_json_rejected(self): """If plaintext decrypts cleanly but isn't valid JSON, surface @@ -428,9 +391,7 @@ class TestPayloadValidation: event = { "kind": 30078, "pubkey": _ATM_PUB, - "content": encrypt_with_conversation_key( - "definitely not json", ck - ), + "content": encrypt_with_conversation_key("definitely not json", ck), "tags": [], "created_at": 0, "id": "x", @@ -438,9 +399,7 @@ class TestPayloadValidation: account = _fake_account() signer = _FakeBunkerSigner(_OP_SEC) with pytest.raises(CassetteEventDecodeError): - asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + asyncio.run(decrypt_and_parse_state_event(event, account, signer)) def test_decrypted_wrong_shape_rejected(self): """Well-formed JSON but missing 'positions' → payload-shape @@ -457,9 +416,7 @@ class TestPayloadValidation: account = _fake_account() signer = _FakeBunkerSigner(_OP_SEC) with pytest.raises(CassetteEventDecodeError): - asyncio.run( - decrypt_and_parse_state_event(event, account, signer) - ) + asyncio.run(decrypt_and_parse_state_event(event, account, signer)) # ============================================================================= diff --git a/tests/test_nip44_v2.py b/tests/test_nip44_v2.py index 34d9c3d..31f996b 100644 --- a/tests/test_nip44_v2.py +++ b/tests/test_nip44_v2.py @@ -32,6 +32,7 @@ from ..nip44 import ( get_conversation_key, ) + # Helper: derive a compressed-x-coord pubkey hex string from a secret hex. def _pub_hex(sec_hex: str) -> str: return ( @@ -222,7 +223,10 @@ class TestPaddingFormula: (32, 32), (33, 64), # > 32 → next chunk (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), (128, 128), # L=129: next_power(128) = 1<<8 = 256; chunk = max(32, 256//8) = 32; @@ -230,7 +234,10 @@ class TestPaddingFormula: (129, 160), (256, 256), # chunk = 32 for L=256 (next_power(255)=256; max(32, 32) = 32) (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): @@ -300,12 +307,8 @@ _BITSPIRE_FIXTURE = { ], ], "created_at": 1780173222, - "pubkey": ( - "217bdc9a65b571c4d9b59da6227a7aa6ca5bbfd5280af791417c57a79d92852b" - ), - "id": ( - "72c09f333386dd4ad6125f8c69823824eea50d8091b694458bcd60701517eece" - ), + "pubkey": ("217bdc9a65b571c4d9b59da6227a7aa6ca5bbfd5280af791417c57a79d92852b"), + "id": ("72c09f333386dd4ad6125f8c69823824eea50d8091b694458bcd60701517eece"), "sig": ( "07ecafacf0169f074e564a999ee1c31446930b43391d007c4a1f9ef7ad890d6c" "2aa6e3ecc5318edeb5748fbd64c7ca33407099a97154e2ff7e0c626e48d71925" diff --git a/views_api.py b/views_api.py index e49e01e..66d9e76 100644 --- a/views_api.py +++ b/views_api.py @@ -872,9 +872,7 @@ async def api_publish_machine_cassettes( updated = await update_cassette_config( machine_id, pos, - UpsertCassetteConfigData( - denomination=row.denomination, count=row.count - ), + UpsertCassetteConfigData(denomination=row.denomination, count=row.count), updated_by=user.id, ) if updated is None: @@ -894,8 +892,6 @@ async def api_publish_machine_cassettes( except RelayUnavailable as exc: raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) from exc except CassetteTransportError as exc: - raise HTTPException( - HTTPStatus.INTERNAL_SERVER_ERROR, str(exc) - ) from exc + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) from exc return await list_cassette_configs_for_machine(machine_id)