feat(v2): operator-side cassette inventory v1.1 + signer.nip44_* migration (#29) #30
10 changed files with 249 additions and 352 deletions
chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1)
Some checks failed
ci.yml / chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1) (pull_request) Failing after 0s
Some checks failed
ci.yml / chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1) (pull_request) Failing after 0s
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>
commit
d448fab0d2
|
|
@ -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
147
crud.py
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
179
models.py
|
|
@ -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
|
||||||
|
|
|
||||||
19
nip44.py
19
nip44.py
|
|
@ -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(
|
||||||
|
|
|
||||||
46
tasks.py
46
tasks.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue