From 4bbdf261e36b8b1bf487b97928f1e8e8d6a28122 Mon Sep 17 00:00:00 2001 From: svatantrya Date: Fri, 14 Aug 2026 23:57:28 -0400 Subject: [PATCH] cli: add headless command-line layer (bal_* commands for the daemon, cmdline entry point, manifest 'cmdline' support, offline controller tests) --- PLAN_CMDLINE_PLUGIN.md | 415 +++++++++ bal/__init__.py | 91 +- bal/cli/__init__.py | 19 + bal/cli/commands.py | 407 +++++++++ bal/cli/controller.py | 1129 +++++++++++++++++++++++++ bal/cli/plugin.py | 21 + bal/cmdline.py | 69 ++ bal/manifest.json | 3 +- tests/karen7 | 12 +- tests/test_cli_commands_registered.py | 149 ++++ tests/test_cli_controller_offline.py | 233 +++++ 11 files changed, 2545 insertions(+), 3 deletions(-) create mode 100644 PLAN_CMDLINE_PLUGIN.md create mode 100644 bal/cli/__init__.py create mode 100644 bal/cli/commands.py create mode 100644 bal/cli/controller.py create mode 100644 bal/cli/plugin.py create mode 100644 bal/cmdline.py create mode 100644 tests/test_cli_commands_registered.py create mode 100644 tests/test_cli_controller_offline.py diff --git a/PLAN_CMDLINE_PLUGIN.md b/PLAN_CMDLINE_PLUGIN.md new file mode 100644 index 0000000..e1db337 --- /dev/null +++ b/PLAN_CMDLINE_PLUGIN.md @@ -0,0 +1,415 @@ +# Piano: supporto da riga di comando (CLI) per il plugin BAL + +> **Stato**: solo piano. Nessun codice viene modificato finché il piano non viene approvato. +> +> **Versione di riferimento**: commit `2221389` (`core: anchor relative locktime/threshold recipes...`), working tree pulito. + +--- + +## 1. Obiettivo + +Rendere il plugin **Bitcoin After Life** utilizzabile da riga di comando / daemon +di Electrum, senza GUI Qt, esponendo comandi per: + +1. **Willexecutors** — elenco, aggiunta, modifica, selezione, eliminazione, import/export, ping, download lista. +2. **Heirs** — elenco, aggiunta, modifica, eliminazione, import/export. +3. **Impostazioni** — lettura e modifica (`settings set chiave=valore`), reset a default. +4. **Will** — ciclo di vita completo: visualizza stato, check di coerenza, prepara/ricostruisci, firma, import/merge, esporta, invalida, trasmette ai will-executor, verifica lato will-executor (searchtx). + +Il tutto riusando **esclusivamente la logica già presente in `bal/core/`** (che è +già GUI-free) e senza importare mai PyQt. + +--- + +## 2. Stato attuale (verificato sul codice) + +### 2.1 Meccanica di Electrum (4.8.0, checkout `electrum/`) + +Ho verificato sul codice reale (`electrum/commands.py`, `electrum/plugin.py`, +`electrum/daemon.py`, `run_electrum`) i punti che governano i comandi dei plugin: + +- **Registrazione comandi**: `@plugin_command(s, plugin_name)` in + `electrum/commands.py:2317`. Un comando plugin: + - è **sempre** un `async def`; + - viene registrato come `bal_` su `Commands` (quindi anche nel parser CLI); + - **forza il flag `'n'`** (richiede rete/daemon): *tutti* i comandi plugin richiedono un daemon in esecuzione e NON funzionano con `--offline`; + - alla chiamata inietta `plugin = daemon._plugins.get_plugin('bal')` (riga 2337). +- **Pre-parse CLI** (`run_electrum` riga 425): `Plugins(tmp_config, cmd_only=True)` importa solo l'`__init__.py` di ogni plugin abilitato per registrare i comandi nel parser. In modalità `cmd_only` il filtro `available_for` viene **saltato** (`plugin.py:128`), ma serve `config['plugins.bal.enabled'] is True` (`plugin.py:117`). +- **Daemon** (`daemon.py:626`): `Plugins(self.config, 'cmdline')`. Qui il filtro `available_for` **vale**: il plugin deve dichiarare `"cmdline"`. +- **Caricamento entry-point** (`plugin.py:622`): il daemon importa `electrum.plugins.bal.` con `gui_name='cmdline'`, quindi serve un modulo `bal/cmdline.py` con una classe `Plugin`. +- **Iniezione wallet**: il decorator `@command` (righe 170-194) gestisce i flag: + - `'w'` → risolve e inietta `wallet` da `daemon.get_wallet(wallet_path)` (il wallet deve essere già caricato con `electrum load_wallet`); + - `'p'` → richiede `--password` (o wallet già sbloccato) per le operazioni di firma. +- **Output**: il valore di ritorno del comando viene stampato come JSON da `run_electrum` (righe 626-630); in modalità daemon gli errori `UserFacingException` vengono stampati con exit code 1. + +### 2.2 Il plugin (bal v0.6.1) + +- `bal/core/` è già GUI-free e contiene tutta la logica riutilizzabile: + - `heirs.py` — `Heirs` (dict persistito in wallet DB, chiave `"heirs"`), validazione (`validate_heir`, `_validate`), `import_file`/`export_file`, `get_transactions`/`buildTransactions`. + - `willexecutors.py` — `Willexecutors` (config `bal_willexecutors`, chiave per `chainname`), `get_willexecutors`, `save`, `initialize_willexecutor`, `is_selected`, `is_valid`, `ping_servers_parallel`, `push_transactions_parallel`, `check_transactions_parallel`, `check_transaction`, `download_list`, `get_willexecutors_list_from_json`. + - `will.py` — `Will` (statiche) e `WillItem` (stato per-tx: `VALID/COMPLETE/PUSHED/CHECKED/...`), `is_will_valid`, `check_will`, `check_willexecutors_and_heirs`, `invalidate_will`, `normalize_will`, `get_min_locktime`, `get_tx_from_any`, `set_check_willexecutor`, `save_valid_transactions_to_history`. + - `plugin_base.py` — `BalPlugin` (tutte le `BalConfig`: chiavi `bal_*`), `BalTimestamp`, `get_version`, registrazione dei dict `heirs`/`will`/`will_settings` nel wallet DB. + - `checkalive.py` — `resolve_date_to_check`, `check_alive_expired` (riferimento temporale unico per ogni check). + - `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`). +- `bal/gui/qt/window.py` — `BalWindow` contiene i flussi da **replicare in headless** (non riusabile direttamente perché legato a Qt): + - `init_will` (riga 151), `load_willitems`/`save_willitems` (120/129), + - `init_class_variables` (618) e `build_will` (397), + - `build_inheritance_transaction` (678) → il flusso completo "prepara will", + - `sign_transactions` (952), `ask_password_and_sign_transactions` (1084), + - `push_transactions_to_willexecutors` (1164), `broadcast_transactions` (1127), + - `check_transactions_task`/`check_transactions` (1414/1464), + - `export_json_file` (1246), `merge_will` (1264), `merge_will_from_file` (1348), `_load_will_file` (1406), + - `invalidate_will` (917). +- `bal/manifest.json`: `"available_for": ["qt"]`, `"version": "0.6.1"`. +- `build_zip.py`: cammina ricorsivamente su `bal/` (esclude `__pycache__`, `.pyc`), quindi **includerà automaticamente** i nuovi file di `bal/cli/` e `bal/cmdline.py`. + +--- + +## 3. Architettura proposta + +``` +bal/ + __init__.py # MODIFICATO: importa ``from .cli import commands`` (registra i comandi) + cmdline.py # NUOVO: shim zip-safe (come qt.py) che ri-espone Plugin da bal.cli.plugin + cli/ + __init__.py # NUOVO + commands.py # NUOVO: tutti i @plugin_command (async), sottili, delegano al controller + controller.py # NUOVO: BalController — facciata headless per-wallet (replica di BalWindow senza Qt) + plugin.py # NUOVO: class Plugin(BalPlugin) — entry-point per il daemon (gui_name='cmdline') + manifest.json # MODIFICATO: available_for = ["qt", "cmdline"] +``` + +Principi: + +- **`bal/cli/` non importa mai Qt** (stessa regola di `bal/core/`). Può importare solo `bal.core`, `electrum.*` e stdlib. +- **`commands.py` = livello di trasporto**: firma `async def bal_x(self, wallet=None, plugin=None, ...)`, valida/parsa argomenti, chiama il controller, ritorna strutture JSON-serializzabili. Zero logica di business. +- **`controller.py` = il cuore**: replica i passi GUI-free di `BalWindow`, ma con errori espressi come eccezioni (i messaggi GUI `show_message`/`show_error` diventano raise/ritorni), e persiste esplicitamente su wallet DB. +- **`plugin.py`** è quasi vuoto: eredita `BalPlugin.__init__` e basta (serve solo perché Electrum istanzi `module.Plugin(self, config, name)`). +- **Nessuna dipendenza nuova** richiesta: `aiohttp`, `dns` e il resto sono già usati da `bal/core`. + +### 3.1 Perché i comandi richiedono il daemon + +`plugin_command` forza il flag `'n'` in `commands.py:2321-2322`. Conseguenza +architetturale da documentare chiaramente: + +``` +electrum daemon -d # avvia il daemon (rete + plugin cmdline) +electrum load_wallet # carica/sblocca il wallet +electrum bal_heirs_list # i comandi BAL girano contro il daemon +``` + +Questa è la stessa limitazione di tutti gli altri plugin con comandi CLI +(es. `swapserver`, `nwc`). Non è aggirabile senza hackare `plugin_command`, che +escludiamo dal piano. + +--- + +## 4. Modifiche ai file esistenti + +### 4.1 `bal/manifest.json` +- `"available_for": ["qt", "cmdline"]`. + +Nessun cambio di versione necessario per lo sviluppo; la versione si alzerà in +`make-release.sh` come già avviene. + +### 4.2 `bal/__init__.py` +- Aggiungere in fondo: + ```python + # Registra i comandi CLI (bal_*) appena Electrum importa il pacchetto, + # sia in modalità cmd_only (pre-parse) sia nel daemon. + from . import cli # noqa: F401 (importa bal.cli.commands, che registra i @plugin_command) + ``` + (oppure `from .cli import commands` esplicito). +- Accortezza: `bal/cli/commands.py` deve essere importabile **senza Qt** e senza + effetti collaterali pesanti, perché viene importato anche nel pre-parse CLI e + all'avvio della GUI. + +### 4.3 `build_zip.py` +- Nessuna modifica obbligatoria: il walker include già `cli/` e `cmdline.py`. +- **Opzionale (consigliato)**: aggiungere una stampa di avviso quando l'archivio + contiene sia `cmdline.py` che `qt.py`, e verificare che `manifest.json` abbia + entrambi i valori in `available_for`. + +--- + +## 5. Nuovi file + +### 5.1 `bal/cmdline.py` (shim, ~stesso schema di `qt.py`) + +Riproduce il pattern zip-safe di `qt.py` (creazione dei package intermedi in +`sys.modules`, import via `importlib.import_module`), ma punta a +`bal.cli.plugin`: + +```python +Plugin = _plugin_module.Plugin +``` + +### 5.2 `bal/cli/plugin.py` + +```python +class Plugin(BalPlugin): + def __init__(self, parent, config, name): + BalPlugin.__init__(self, parent, config, name) +``` + +Niente hook Qt, niente `bal_windows`. Il daemon lo istanzia quando +`get_plugin('bal')` viene chiamato dal wrapper di `plugin_command`. + +### 5.3 `bal/cli/controller.py` — `BalController` + +Facciata per-wallet che incapsula lo stato e i flussi. Attributi (speculari a +`BalWindow`): +- `plugin` (il `BalPlugin`/`Plugin` iniettato), +- `wallet` (iniettato da Electrum), +- `will_settings` (da `plugin.WILL_SETTINGS.get()` + `Util.fix_will_settings_tx_fees`), +- `heirs` (`Heirs(wallet)` validati), +- `willexecutors` (`Willexecutors.get_willexecutors(plugin)`), +- `willitems` (da `wallet.db.get_dict("will")` → `WillItem(w, wallet=wallet)`), +- `date_to_check` (via `resolve_date_to_check`). + +Metodi principali (replicano le funzioni Qt, senza dialoghi): + +| Metodo | Replica di (`window.py`) | Note | +|---|---|---| +| `load_willitems()` | 120 | Costruisce i `WillItem` dal dict `will` del wallet DB. | +| `save_willitems()` | 129 | `to_dict()` con `tx` serializzato a stringa, `json.dumps` di prova, scrittura su `wallet.db` + `wallet.save_db()`. | +| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `check_alive_expired`. | +| `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. | +| `build_inheritance_transaction()` | 678 | Flusso 1/7→2/7 replicato: `Will.check_amounts`, guardie locktime/willexecutor, `check_will()` e rebuild su `NotCompleteWillException`. Le `show_message/show_error` diventano raise (`UserFacingException` con testo chiaro) oppure ritorni `{"status": "postponed", "invalidation": tx}`. | +| `sign_transactions(password)` | 952 | Firma i `VALID` non completi: fixup input dai willitems padre, `wallet.sign_transaction(tx, password, ignore_warnings=True)`, `set_status("COMPLETE")`, `check_signatures`. | +| `push_transactions_to_willexecutors(force)` | 1164 | `get_willexecutor_transactions` + `push_transactions_parallel` + gestione "already present" con `check_transaction`. Aggiorna `PUSHED/PUSH_FAIL`. | +| `check_transactions()` | 1414 | `check_transactions_parallel` + `set_check_willexecutor(res)` per item. | +| `export_json_file(path)` | 1246 | `write_json_file(path, {wid: wi.to_dict()...})` con `tx` come stringa (formato identico a `_load_will_file`). | +| `merge_will_from_file(path)` | 1348 | `_load_will_file` + `merge_will` (stessa semantica di `window.py:1264`). | +| `_load_will_file(path)` | 1406 | `read_json_file` + `tx_from_any` + `WillItem`. | +| `invalidate_will()` | 917 | `Will.invalidate_will(...)` con `history_label` e `will_locktime`. | +| `fetch_will_executors_list()` / `ping()` | 1491/1771 | `download_list(old, welist_server)` + `ping_servers_parallel`, poi `Willexecutors.save(plugin, ...)`. | +| `apply_settings(cfg_name, value)` | — | Mappa il nome chiave all'attributo `BalConfig` del plugin e fa `set(...)`. | + +Regole di persistenza (fondamentali): +- **heirs** → `heirs.save()` (via `__setitem__`/`pop` già implementati) + `wallet.save_db()`. +- **will** → `save_willitems()` + `wallet.save_db()`. +- **willexecutors** → `Willexecutors.save(plugin, willexecutors)` (config, non wallet DB). +- **settings** → `BalConfig.set(...)` (config). + +### 5.4 `bal/cli/commands.py` — comandi (tutti `async def` + `@plugin_command`) + +Firma standard: `async def bal_x(self, wallet=None, plugin=None, ...)`. Flag: +- `'n'` — imposto automaticamente da `plugin_command` (rete/daemon). +- `'w'` — wallet richiesto e iniettato da Electrum. +- `'p'` — solo per i comandi che firmano (richiede `--password`). + +Tutti i comandi costruiscono `controller = BalController(plugin, wallet)` e +ritornano strutture JSON-serializzabili. Elenco completo al §6. + +--- + +## 6. Tabella comandi + +Convenzioni: +- ``: wallet caricato nel daemon (non serve passarlo; Electrum usa quello + configurato o `--wallet`). +- Output: `list`/`dict` stampati come JSON; exit 0 su successo, 1 su errore. +- `*` = richiede password (`--password`) se il wallet è cifrato. + +### 6.1 Willexecutors + +| Comando | Flag | Argomenti | Descrizione / output | +|---|---|---|---| +| `bal_willexecutors_list` | `nw` | — | Elenco `{url: {address, base_fee, status, info, selected, last_update, sort}}` per la chain corrente. | +| `bal_willexecutors_show` | `nw` | `url` | Dettaglio di un singolo will-executor. | +| `bal_willexecutors_add` | `nw` | `url` `address` `base_fee` | Aggiunge/aggiorna un will-executor (via `initialize_willexecutor`), `selected=false` di default. Ritorna il record. | +| `bal_willexecutors_update` | `nw` | `url` `[address]` `[base_fee]` `[info]` `[promo_code]` | Modifica i campi indicati e salva. | +| `bal_willexecutors_select` | `nw` | `url` `value` | `is_selected(we, eval_bool(value))` + salva. | +| `bal_willexecutors_delete` | `nw` | `url` | Rimuove dalla lista e salva. | +| `bal_willexecutors_ping` | `nw` | `[url]` | `ping_servers_parallel` (tutti o uno); aggiorna `status/base_fee/address`; salva. Output: risultati per url. | +| `bal_willexecutors_download` | `nw` | — | `download_list(old, plugin.WELIST_SERVER.get())`; unisce e salva. Output: n. record. | +| `bal_willexecutors_import` | `nw` | `path` | Legge un JSON `{url: record}` (stesso formato di export), `initialize_willexecutor` per record, salva. | +| `bal_willexecutors_export` | `nw` | `path` | Scrive `{url: record}` su file JSON. | + +### 6.2 Heirs + +| Comando | Flag | Argomenti | Descrizione / output | +|---|---|---|---| +| `bal_heirs_list` | `nw` | — | `{name: [address, amount, locktime, ...]}` (tutte le colonne `HEIR_*`). | +| `bal_heirs_show` | `nw` | `name` | Dettaglio di un singolo heir. | +| `bal_heirs_add` | `nw` | `name` `address` `amount` `locktime` | Valida con `Heirs.validate_heir` (OP_RETURN incluso) e salva. `amount` può essere satoshi o `"50%"`. `locktime` può essere timestamp assoluto o relativo `"30d"`/`"1y"`. | +| `bal_heirs_update` | `nw` | `name` `[address]` `[amount]` `[locktime]` | Modifica i campi indicati (ri-validazione) e salva. | +| `bal_heirs_delete` | `nw` | `name` | `heirs.pop(name)` + `save_db()`. | +| `bal_heirs_import` | `nw` | `path` | `Heirs.import_file(path)` (validazione + merge). | +| `bal_heirs_export` | `nw` | `path` | `Heirs.export_file(path)`. | + +### 6.3 Impostazioni + +| Comando | Flag | Argomenti | Descrizione / output | +|---|---|---|---| +| `bal_settings_list` | `n` | — | Elenco di tutte le `BalConfig` del plugin: `{chiave: {value, default, name}}` (nome leggibile). | +| `bal_settings_get` | `n` | `key` | Valore corrente di una chiave (`bal_*`). | +| `bal_settings_set` | `n` | `key=value` | Scrive il valore (conversione di tipo: bool/int/str/JSON) via `BalConfig.set(...)`. `bal_will_settings` accetta JSON. | +| `bal_settings_reset` | `n` | `key` | `BalConfig.set(cfg.default)`. | + +### 6.4 Will + +| Comando | Flag | Argomenti | Descrizione / output | +|---|---|---|---| +| `bal_will_status` | `nw` | — | Per ogni `wid` (txid): locktime, `heirsvalue`, executor, flag di stato (`VALID/COMPLETE/PUSHED/CHECKED/CHECK_FAIL/...`), `sigs_have/sigs_required`, `tx_fees`, executor URL. | +| `bal_will_check` | `nw` | — | `check_will()` (coerenza heirs+executor+fees+locktime, in locale). Ritorna `{"valid": true}` o un errore esplicito (es. `HeirNotFound`, `WillPostponed`, `WillExpired`, `NoHeirs`). | +| `bal_will_prepare` | `nw` | — | Flusso completo `build_inheritance_transaction`: check → rebuild se non coerente → persiste. Output: riepilogo tx nuova/aggiornata per wid. | +| `bal_will_sign` | `nwp` | `[txid]` | Firma i `VALID` non completi (o solo `txid`). Aggiorna `COMPLETE` e `sigs_*`; persiste. Output per txid. | +| `bal_will_broadcast` | `nw` | `[txid]` `force` | `push_transactions_to_willexecutors(force, txids)` parallelo; aggiorna `PUSHED/PUSH_FAIL`. Output: `{url: status}`. | +| `bal_will_export` | `nw` | `path` | `export_json_file(path)`. | +| `bal_will_import_merge` | `nw` | `path` | `merge_will_from_file(path)` (stessa semantica GUI: merge psbt/stati, mai perdere una tx viva). | +| `bal_will_invalidate` | `nw` | — | `Will.invalidate_will(...)`; ritorna la tx di invalidazione (da firmare+trasmettere con i comandi sopra). | +| `bal_will_check_executor` | `nw` | `[txid]` | Verifica lato will-executor: `check_transactions_parallel` (searchtx) per i `VALID+PUSHED` non `CHECKED`; applica `set_check_willexecutor`. Output: `{wid: {url, checked, ok}}`. | + +--- + +## 7. Flusso dati e persistenza + +``` +CLI (electrum bal_*) Daemon (Electrum 4.8.0) +┌───────────────────────┐ ┌──────────────────────────────────────┐ +│ run_electrum │ RPC │ Daemon.run_cmdline │ +│ pre-parse cmd_only │ ─────────────► │ plugin_command wrapper │ +│ -> importa bal │ jsonrpc │ inietta plugin + wallet │ +│ (registra bal_*) │ │ bal/cli/commands.py │ +└───────────────────────┘ │ -> BalController(plugin, wallet) │ + │ -> bal.core.* │ + │ -> wallet.db / config (persist) │ + └──────────────────────────────────────┘ +``` + +- **Lettura**: `wallet.db.get_dict("will")` (wills), `Heirs(wallet)` (heirs), + `plugin.WILLEXECUTORS.get()`/`plugin.WILL_SETTINGS.get()` (config). +- **Scrittura**: `save_willitems()` → `wallet.db` + `wallet.save_db()`; + `heirs.save()`; `Willexecutors.save(...)`; `BalConfig.set(...)`. +- **Firma**: `wallet.sign_transaction(tx, password, ignore_warnings=True)` — + idem GUI, quindi compatibile con multisig e wallet cifrati (password via `--password`). +- **Rete**: `Network.get_instance()` già usato da `bal/core/willexecutors.py` + (i comandi `'n'` garantiscono rete attiva). + +--- + +## 8. Errori, exit code, output + +- Ritorno `None` → nessun output; `str` → stampato; `dict`/`list` → `json_encode`. +- Errori utente: sollevare `electrum.util.UserFacingException(msg)` → in modalità + daemon viene stampato `msg` con exit 1. +- Errori di dominio BAL (`WillExpiredException`, `WillPostponedException`, + `HeirNotFoundException`, `NoWillExecutorNotPresent`, `CheckAliveError`, + `AmountException`, ...): il controller le converte in `UserFacingException` + con testo in chiaro (riuso dei messaggi già presenti, senza HTML/Qt). +- Convenzione consigliata per comandi che producono più di un risultato: + ritornare un `dict` con chiave `"result"`/`"warnings"` quando servono avvisi + (es. dopo `prepare` con heirs scartati per dust). + +--- + +## 9. Compatibilità Electrum 4.7.2 / 4.8.0 + +- `plugin_command`, il wrapper `@command` e `daemon._plugins.get_plugin` esistono + in entrambe le versioni (verificati su 4.8.0; usati identici da `swapserver`). +- Il `BalPlugin` già gestisce il cambio API di registrazione dict + (`json_db.register_dict` vs `stored_dict.register_name`): nessun intervento. +- `available_for: ["cmdline"]` è lo stesso meccanismo di `trustedcoin` + (che ha già `cmdline.py` in 4.8.0). +- **Nessun nuovo import Qt** in `bal/cli/`: verificabile in CI con un check + statico su `bal/cli/*.py` e `bal/cmdline.py`. + +--- + +## 10. Build / release + +- `python3 build_zip.py` produce `bal-electrum-plugin.zip` con `cli/`, `cmdline.py` + e il manifest aggiornato. Lo zip serve sia per la GUI che per il daemon. +- Il test `external_zip_test.py` andrà esteso (vedi §11) per verificare che il + zip, caricato da Electrum, registri anche i comandi `bal_*`. +- Nessun cambiamento a `make-release.sh` (la versione resta nel manifest). + +--- + +## 11. Piano di test e verifica + +### 11.1 Nuovi test standalone (stile repo: `tests/test_*.py` con `if __name__ == "__main__"`) + +- `tests/test_cli_commands_registered.py` (runtime env): + - importa `electrum.plugins.bal` con `Plugins(config, cmd_only=True)`; + - asserisce che `known_commands` contenga tutti i nomi `bal_*` della tabella; + - asserisce che ogni funzione sia coroutine e abbia il flag `n`. +- `tests/test_cli_controller.py` (runtime env, offline, senza rete): + - wallet "fake"/temporaneo (pattern di `test_core_heirs.py`); + - CRUD heirs e willexecutors, settings get/set/reset, export/import will + (merge), build will con fixtures note. +- `tests/test_cli_zip.py` (o estensione di `external_zip_test.py`): + - costruisce lo zip, lo carica come `electrum_external_plugins.bal` con + `Plugins(config, 'cmdline')`, asserisce `available_for` include `"cmdline"` + e che `get_plugin('bal')` restituisca il `Plugin` di `bal.cli.plugin` + (nessun import Qt eseguito). +- `tests/test_cli_will_flows.py` (offline, dove possibile): + - prepare → sign → export → merge su un wallet di test con heirs fissi; + - verifica che `wallet.db.get_dict("will")` rifletta COMPLETE/PUSHED dopo + le operazioni che non toccano rete. + +### 11.2 Verifica manuale (da documentare nel README/HANDOFF) + +```bash +source /home/steal/devel/bal/electrum/env/bin/activate +electrum daemon -d +electrum load_wallet +electrum bal_heirs_list +electrum bal_settings_list +electrum bal_will_status +electrum bal_will_prepare +electrum bal_will_sign --password '...' # se wallet cifrato +electrum bal_will_broadcast +electrum bal_will_check_executor +electrum bal_willexecutors_ping +electrum stop +``` + +### 11.3 Regressione + +- `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal` + deve continuare a passare (prova che `bal/__init__` + Qt convivono con il + nuovo import di `bal.cli.commands`). +- Eseguire i `test_core_*.py` esistenti (nessuna logica core toccata). +- Ruff: evitare nuove violazioni in `bal/cli/`. + +--- + +## 12. Rischi e decisioni aperte + +1. **Daemon obbligatorio** (non `--offline`): imposto da `plugin_command`. + → Accettato; documentato al §3.1. +2. **Wallet pre-caricato**: i comandi `w` falliscono con "wallet not loaded" se + non si lancia prima `electrum load_wallet`. → Documentare. +3. **`bal/__init__.py` che importa `bal.cli.commands`**: viene eseguito anche + all'avvio della GUI. `commands.py` deve restare leggero (solo definizioni + + import di `electrum.commands` e `bal.core`). Da verificare con `smoke_test.py`. +4. **Doppio caricamento**: se un install è contemporaneamente interno E zip + esterno, la seconda importazione di `commands.py` potrebbe sollevare + "Command name bal_... already exists". Pratica corrente: un solo install; + si può mitigare con un guard `if not getattr(module, '_registered')`. +5. **OP_RETURN heirs** in CLI: gestiti come in GUI (`validate_op_return_hex`, + colonne quantità `"0"`). Da testare. +6. **Persistenza `will_settings`**: oggi letta dalla config globale + (`bal_will_settings`) in `BalWindow.__init__`, non dal wallet DB. Il + controller deve replicare esattamente questo (config), non introdurre una + seconda sorgente. +7. **Multisig**: la firma usa `wallet.sign_transaction` → supportata; il flusso + "merge PSBT" copre la firma parziale. Test dedicato con wallet multisig in + fase di implementazione. + +--- + +## 13. Fasi di implementazione (ordine proposto) + +1. `bal/cli/__init__.py`, `bal/cli/plugin.py`, `bal/cmdline.py`, update + `bal/manifest.json` + `bal/__init__.py`. +2. `tests/test_cli_commands_registered.py` + verifica `smoke_test.py`. +3. `bal/cli/controller.py` (read-only: status/list/show) → `commands.py` per + willexecutors/heirs/settings (senza rete). +4. Comandi will: `prepare`, `sign`, `export`, `import_merge`, `invalidate`. +5. Comandi di rete: `ping`, `download`, `broadcast`, `check_executor`. +6. Test zip (`test_cli_zip.py`), estensione `external_zip_test.py`, prova + manuale col daemon, aggiornamento README/HANDOFF. diff --git a/bal/__init__.py b/bal/__init__.py index 1576d15..09ed7c9 100644 --- a/bal/__init__.py +++ b/bal/__init__.py @@ -24,11 +24,18 @@ distinct sub-packages: lists.py Tree/list views (heirs, preview, will-executors) window.py BalWindow controller (per-wallet GUI state) plugin.py Plugin class wiring Electrum @hooks to the GUI + cli/ Headless command-line layer (no Qt) + commands.py The @plugin_command transport layer (registers + the ``bal_*`` commands) + controller.py Headless replica of the Qt flows (later phases) + plugin.py Plugin(BalPlugin) entry point for the daemon qt.py Thin loader shim re-exporting `Plugin` for Electrum + cmdline.py Thin loader shim re-exporting `Plugin` for the daemon Electrum discovers the plugin through ``manifest.json`` and loads the GUI entry point from ``qt.py`` (the shim), which imports the real ``Plugin`` -from ``gui.qt.plugin``. +from ``gui.qt.plugin``; the command-line/daemon entry point is ``cmdline.py`` +(the shim), which imports ``Plugin`` from ``cli.plugin``. The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed ``json_db.register_dict`` and replaced it with the path-based @@ -40,3 +47,85 @@ available and adapts, so both releases keep working. # (the single source of truth) and is read at runtime via ``get_version()`` in # ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property). # Keeping a hardcoded ``__version__`` here would just be a stale duplicate. + +# --------------------------------------------------------------------------- # +# CLI command registration +# --------------------------------------------------------------------------- # +# Electrum's CLI pre-parse (run_electrum calls ``Plugins(config, cmd_only=True)``) +# only imports the plugin package ``__init__`` to discover its commands. +# Importing ``bal.cli.commands`` here registers every ``bal_*`` command with +# ``electrum.commands`` (``known_commands`` + the ``Commands`` class), so the +# commands become available on the command line and over JSON-RPC without any Qt. +# +# The import must be zip-safe: when the plugin is loaded as an external zip, +# Electrum registers the package under the synthetic name +# ``electrum_external_plugins.bal``, but the module's ``__package__`` is only +# ``bal`` (the zip-internal directory name), which is not present in +# ``sys.modules`` and cannot be used for sub-module imports. We therefore +# resolve the real package name and import through ``importlib`` (the same +# trick as ``qt.py``). +import importlib +import sys as _sys + + +def _resolve_package_name() -> str: + """Return the name this package is registered under in ``sys.modules``. + + Internal plugins are imported as ``electrum.plugins.bal`` (a normal import, + so ``__package__`` is already correct). External zip plugins are imported + under the synthetic name ``electrum_external_plugins.bal`` with + ``__package__`` set to just the zip-internal directory name (``bal``); only + the synthetic name is present in ``sys.modules``. + """ + pkg = __package__ or "bal" + if pkg in _sys.modules: + return pkg + synthetic = "electrum_external_plugins." + __name__ + if synthetic in _sys.modules: + return synthetic + return pkg + + +def _ensure_parent_packages(pkg_name: str) -> None: + """Backfill missing ancestor packages in ``sys.modules``. + + When loaded from a zip as an external plugin, Electrum only executes the + package ``__init__``; the synthetic root package (``electrum_external_plugins``) + may be missing, which would break sub-module imports. We stub it out as a + namespace package so ``importlib`` can still resolve its children (same + helper as ``qt.py``). + """ + parts = pkg_name.split(".") + for i in range(1, len(parts)): + ancestor = ".".join(parts[:i]) + if ancestor in _sys.modules: + continue + try: + importlib.import_module(ancestor) + except Exception: + import types + + module = types.ModuleType(ancestor) + module.__path__ = [] # mark as a (namespace) package + _sys.modules[ancestor] = module + + +def _register_cli_commands() -> None: + """Import ``bal.cli.commands`` so Electrum registers the ``bal_*`` commands. + + Guarded so a dual install (internal package AND external zip) cannot + register the same command names twice, which would make + ``electrum.commands.plugin_command`` raise + "Command name bal_... already exists". + """ + from electrum import commands as _electrum_commands + + if getattr(_electrum_commands, "_bal_cli_commands_registered", False): + return + pkg = _resolve_package_name() + _ensure_parent_packages(pkg) + importlib.import_module(pkg + ".cli.commands") + _electrum_commands._bal_cli_commands_registered = True + + +_register_cli_commands() diff --git a/bal/cli/__init__.py b/bal/cli/__init__.py new file mode 100644 index 0000000..fcfb15f --- /dev/null +++ b/bal/cli/__init__.py @@ -0,0 +1,19 @@ +""" +bal.cli +======= + +Headless command-line layer of the Bitcoin After Life (BAL) Electrum plugin. + +This sub-package implements the ``"cmdline"`` front-end: it exposes the +plugin's functionality through Electrum ``bal_*`` commands while reusing only +the GUI-free logic from ``bal.core``. Like ``bal.core``, it MUST never import +PyQt or ``electrum.gui``. + + * ``bal.cli.commands`` -> the ``@plugin_command`` transport layer + * ``bal.cli.controller`` -> headless replica of the Qt flows (later phases) + * ``bal.cli.plugin`` -> ``Plugin(BalPlugin)`` entry point for the daemon + +Electrum discovers the plugin through ``manifest.json`` (``available_for`` +includes ``"cmdline"``) and loads the entry point from ``cmdline.py``, a thin +zip-safe shim following the same pattern as ``qt.py``. +""" diff --git a/bal/cli/commands.py b/bal/cli/commands.py new file mode 100644 index 0000000..2b53a83 --- /dev/null +++ b/bal/cli/commands.py @@ -0,0 +1,407 @@ +""" +bal.cli.commands +================ + +CLI commands (``bal_*``) for the Bitcoin After Life plugin. + +This module is the *transport layer* of the command-line front-end: every +function is a coroutine decorated with ``@plugin_command`` so Electrum exposes +it as ``bal_`` both on the command line and over JSON-RPC. The functions +validate their arguments and delegate the real work to +:mod:`bal.cli.controller` (a headless replica of the Qt flows); this module +never imports Qt. + +It must stay lightweight: Electrum imports it during the CLI pre-parse +(``run_electrum`` calls ``Plugins(config, cmd_only=True)``) and on every +GUI/daemon startup, before any wallet or network object exists. The heavy +imports (``bal.core``, the controller) happen lazily inside each command. + +Flags (see ``electrum.commands.plugin_command``): + + * ``n`` -> requires a running daemon/network (always set for plugins); + * ``w`` -> resolves and injects the wallet from the daemon; + * ``p`` -> requires the wallet password (for signing). +""" + +from electrum.commands import plugin_command +from electrum.util import UserFacingException + +from .controller import BalController, _user_facing + +plugin_name = "bal" + + +def _controller(plugin, wallet): + """Build the headless controller, or fail with a clear message.""" + if plugin is None: + raise UserFacingException("the bal plugin is not enabled in this daemon") + if wallet is None: + raise UserFacingException("wallet not loaded") + return BalController(plugin, wallet) + + +def _call(plugin, wallet, method, *args, **kwargs): + controller = _controller(plugin, wallet) + try: + return getattr(controller, method)(*args, **kwargs) + except Exception as e: + raise _user_facing(e) from e + + +# --------------------------------------------------------------------------- # +# Settings +# --------------------------------------------------------------------------- # +@plugin_command("n", plugin_name) +async def settings_list(self, plugin=None): + """List all BAL plugin configuration options (key, name and value). + + Returns a JSON object mapping every BAL configuration option (``bal_*``) + to an object with ``value``, ``default`` and ``name``. + """ + return _call(plugin, None, "settings_list") + + +@plugin_command("n", plugin_name) +async def settings_get(self, key, plugin=None): + """Show the current value of one BAL configuration option. + + arg:str:key:The configuration key (e.g. ``bal_tx_fees``). + """ + return _call(plugin, None, "settings_get", key) + + +@plugin_command("n", plugin_name) +async def settings_set(self, key, value, plugin=None): + """Set a BAL configuration option (booleans, integers, strings, JSON). + + arg:str:key:The configuration key (e.g. ``bal_user_type``). + arg:str:value:The new value; JSON for object-typed keys such as ``bal_will_settings``. + """ + return _call(plugin, None, "settings_set", key, value) + + +@plugin_command("n", plugin_name) +async def settings_reset(self, key, plugin=None): + """Reset a BAL configuration option to its default value. + + arg:str:key:The configuration key (e.g. ``bal_tx_fees``). + """ + return _call(plugin, None, "settings_reset", key) + + +# --------------------------------------------------------------------------- # +# Heirs +# --------------------------------------------------------------------------- # +@plugin_command("nw", plugin_name) +async def heirs_list(self, wallet=None, plugin=None): + """List the heirs of the current wallet. + + Returns a JSON object mapping heir names to their ``[address, amount, + locktime]`` values. + """ + return _call(plugin, wallet, "heirs_list") + + +@plugin_command("nw", plugin_name) +async def heirs_show(self, name, wallet=None, plugin=None): + """Show the details of a single heir. + + arg:str:name:The heir name. + """ + return _call(plugin, wallet, "heirs_show", name) + + +@plugin_command("nw", plugin_name) +async def heirs_add(self, name, address, amount, locktime=None, wallet=None, plugin=None): + """Add (or replace) an heir in the current wallet. + + arg:str:name:The heir name. + arg:str:address:The destination address (or ``OP_RETURN:`` for an OP_RETURN heir). + arg:str:amount:The amount in satoshis or a percentage like ``50%%``. + arg:str:locktime:The delivery locktime (absolute timestamp or ``30d``/``1y``); defaults to the will locktime. + """ + return _call(plugin, wallet, "heirs_add", name, address, amount, locktime) + + +@plugin_command("nw", plugin_name) +async def heirs_update( + self, + name, + address=None, + amount=None, + locktime=None, + wallet=None, + plugin=None, +): + """Update an existing heir (only the given fields). + + arg:str:name:The heir name. + arg:str:address:The new destination address. + arg:str:amount:The new amount in satoshis or a percentage. + arg:str:locktime:The new delivery locktime. + """ + return _call(plugin, wallet, "heirs_update", name, address, amount, locktime) + + +@plugin_command("nw", plugin_name) +async def heirs_delete(self, names, wallet=None, plugin=None): + """Delete one or more heirs. + + arg:json:names:A JSON array of heir names (e.g. ``["Alice","Bob"]``). + """ + return _call(plugin, wallet, "heirs_delete", names) + + +@plugin_command("nw", plugin_name) +async def heirs_import(self, path, wallet=None, plugin=None): + """Import heirs from a JSON file (validated, merged). + + arg:str:path:Path to the JSON file. + """ + return _call(plugin, wallet, "heirs_import", path) + + +@plugin_command("nw", plugin_name) +async def heirs_export(self, path, wallet=None, plugin=None): + """Export the heirs to a JSON file. + + arg:str:path:Destination file path. + """ + return _call(plugin, wallet, "heirs_export", path) + + +# --------------------------------------------------------------------------- # +# Will-Executors +# --------------------------------------------------------------------------- # +@plugin_command("nw", plugin_name) +async def willexecutors_list(self, wallet=None, plugin=None): + """List the will-executors for the current network. + + Returns a JSON object mapping executor URLs to their records (address, + base_fee, status, info, selected, ...). + """ + return _call(plugin, wallet, "willexecutors_list") + + +@plugin_command("nw", plugin_name) +async def willexecutors_show(self, url, wallet=None, plugin=None): + """Show the details of a single will-executor. + + arg:str:url:The will-executor URL. + """ + return _call(plugin, wallet, "willexecutors_show", url) + + +@plugin_command("nw", plugin_name) +async def willexecutors_add( + self, + url, + address="", + base_fee=0, + info=None, + wallet=None, + plugin=None, +): + """Add a new will-executor (not selected by default). + + arg:str:url:The will-executor base URL. + arg:str:address:The executor fee address for this network. + arg:int:base_fee:The executor base fee in satoshis. + arg:str:info:A human-readable description. + """ + return _call(plugin, wallet, "willexecutors_add", url, address, base_fee, info) + + +@plugin_command("nw", plugin_name) +async def willexecutors_update( + self, + url, + address=None, + base_fee=None, + info=None, + promo_code=None, + rename_to=None, + wallet=None, + plugin=None, +): + """Update an existing will-executor (only the given fields). + + arg:str:url:The will-executor URL to update. + arg:str:address:The new fee address. + arg:int:base_fee:The new base fee in satoshis. + arg:str:info:The new description. + arg:str:promo_code:The new promo code. + arg:str:rename_to:Optionally move the record to a new URL. + """ + return _call( + plugin, + wallet, + "willexecutors_update", + url, + address, + base_fee, + info, + promo_code, + rename_to, + ) + + +@plugin_command("nw", plugin_name) +async def willexecutors_select( + self, url, value=True, wallet=None, plugin=None +): + """Select (or deselect) a will-executor. + + arg:str:url:The will-executor URL. + arg:bool:value:True to select, False to deselect. + """ + return _call(plugin, wallet, "willexecutors_select", [url], value) + + +@plugin_command("nw", plugin_name) +async def willexecutors_delete(self, urls, wallet=None, plugin=None): + """Delete one or more will-executors. + + arg:json:urls:A JSON array of executor URLs (e.g. ``["https://we.example.com"]``). + """ + return _call(plugin, wallet, "willexecutors_delete", urls) + + +@plugin_command("nw", plugin_name) +async def willexecutors_ping(self, urls=None, wallet=None, plugin=None): + """Ping the selected (or the given) will-executor servers. + + Updates status/base_fee/address from each server and saves. Returns + ``{url: {status, ok}}``. + + arg:json:urls:Optional JSON array of URLs to ping; defaults to the selected executors. + """ + return _call(plugin, wallet, "willexecutors_ping", urls) + + +@plugin_command("nw", plugin_name) +async def willexecutors_download(self, wallet=None, plugin=None): + """Download the will-executor list from the welist server and merge it. + + Returns the number of records downloaded and the new total. + """ + return _call(plugin, wallet, "willexecutors_download") + + +@plugin_command("nw", plugin_name) +async def willexecutors_import(self, path, wallet=None, plugin=None): + """Import will-executors from a JSON file (``{url: record}``). + + arg:str:path:Path to the JSON file. + """ + return _call(plugin, wallet, "willexecutors_import", path) + + +@plugin_command("nw", plugin_name) +async def willexecutors_export(self, path, wallet=None, plugin=None): + """Export the will-executors to a JSON file. + + arg:str:path:Destination file path. + """ + return _call(plugin, wallet, "willexecutors_export", path) + + +# --------------------------------------------------------------------------- # +# Will +# --------------------------------------------------------------------------- # +@plugin_command("nw", plugin_name) +async def will_status(self, wallet=None, plugin=None): + """Show the current will: per-transaction status, locktime and executors. + + Returns a JSON object with a per-txid detail list and global status counts. + """ + return _call(plugin, wallet, "will_status") + + +@plugin_command("nw", plugin_name) +async def will_check(self, wallet=None, plugin=None): + """Check the local coherence of the will (heirs, executors, fees, locktime). + + Returns ``{"valid": true}`` when coherent, or raises a descriptive error. + """ + return _call(plugin, wallet, "will_check") + + +@plugin_command("nw", plugin_name) +async def will_prepare(self, wallet=None, plugin=None): + """Run the full prepare/inheritance flow (check, rebuild, persist). + + Returns a JSON object with ``result`` (``coherent``, ``rebuilt``, + ``expired``, ``postponed``) and, when needed, the invalidation + transaction to sign and broadcast. + """ + return _call(plugin, wallet, "prepare_will") + + +@plugin_command("nwp", plugin_name) +async def will_sign(self, txid=None, password=None, wallet=None, plugin=None): + """Sign the valid, not-yet-complete will transactions (or just one). + + Updates the COMPLETE status and the signature counters and persists. + + arg:str:txid:Optional transaction id to sign; signs all valid ones when omitted. + """ + txids = [txid] if txid is not None else None + txs = _call(plugin, wallet, "sign_transactions", password, txids) + return {wid: str(tx) for wid, tx in txs.items()} + + +@plugin_command("nw", plugin_name) +async def will_broadcast( + self, txid=None, force=False, wallet=None, plugin=None +): + """Send the signed will transactions to their will-executors (in parallel). + + Updates the PUSHED/PUSH_FAIL statuses and persists. Returns ``{url: status}``. + + arg:str:txid:Optional transaction id to broadcast; all valid+signed ones when omitted. + arg:bool:force:Force re-pushing transactions already marked as PUSHED. + """ + txids = [txid] if txid is not None else None + return _call(plugin, wallet, "push_transactions_to_willexecutors", force, txids) + + +@plugin_command("nw", plugin_name) +async def will_export(self, path, wallet=None, plugin=None): + """Export the whole will to a JSON file. + + arg:str:path:Destination file path. + """ + return _call(plugin, wallet, "export_will", path) + + +@plugin_command("nw", plugin_name) +async def will_import_merge(self, path, wallet=None, plugin=None): + """Merge a will file into the current will (PSBTs and statuses are merged). + + arg:str:path:Path to the will JSON file. + """ + return _call(plugin, wallet, "merge_will_from_file", path) + + +@plugin_command("nw", plugin_name) +async def will_invalidate(self, wallet=None, plugin=None): + """Build the on-chain invalidation transaction for the current will. + + Returns ``{txid, tx}`` (or nulls when there is nothing to invalidate); the + transaction still needs to be signed and broadcast. + """ + return _call(plugin, wallet, "invalidate_will_command") + + +@plugin_command("nw", plugin_name) +async def will_check_executor(self, txid=None, wallet=None, plugin=None): + """Ask the will-executors whether they hold our pushed transactions. + + Runs the searchtx check in parallel, applies the per-item status and + persists. Returns ``{txid: {url, pushed, checked, check_fail}}``. + + arg:str:txid:Optional transaction id to check; checks all pending ones when omitted. + """ + txids = [txid] if txid is not None else None + return _call(plugin, wallet, "check_transactions", txids) diff --git a/bal/cli/controller.py b/bal/cli/controller.py new file mode 100644 index 0000000..038f47f --- /dev/null +++ b/bal/cli/controller.py @@ -0,0 +1,1129 @@ +""" +bal.cli.controller +================== + +Headless replica of the Qt flows (``bal.gui.qt.window.BalWindow``) for the +command-line front-end. + +The CLI layer is a daemon, so there is no widget to drive: every operation +must run to completion synchronously and return a JSON-serializable result +(or raise ``electrum.util.UserFacingException`` with a human-readable message). +This controller therefore mirrors the *logic* of the GUI window (its state +object, the build/check/sign/push flows and the willexecutors CRUD) while +reusing only ``bal.core`` - it MUST never import PyQt. + +Errors: + - Domain exceptions from ``bal.core`` are translated into + ``UserFacingException`` so the JSON-RPC layer can print them cleanly. + - Network flows (ping/push/check/download) block the calling thread with + the same timeouts the GUI uses, so the daemon never hangs forever. + +This module is imported lazily (only when a ``bal_*`` command actually runs), +so a missing wallet or a network-less daemon can still start Electrum. +""" + +import copy +import json +import time + +from electrum import bitcoin, constants +from electrum.i18n import _ +from electrum.logging import get_logger +from electrum.transaction import tx_from_any +from electrum.util import ( + MyEncoder, + UserFacingException, + read_json_file, + write_json_file, +) + +from ..core import checkalive +from ..core import heirs as heirs_mod +from ..core import will as will_mod +from ..core.checkalive import ( + CheckAliveError, + check_alive_expired, + resolve_date_to_check, +) +from ..core.heirs import Heirs, is_op_return_address +from ..core.plugin_base import BalConfig, BalPlugin +from ..core.util import Util +from ..core.will import Will, WillItem +from ..core.willexecutors import Willexecutors, is_onion_url, is_tor_active + +_logger = get_logger(__name__) + + +def _user_facing(e): + """Translate a ``bal.core`` domain exception into a user-facing message. + + Unknown exceptions are passed through unchanged so their real type reaches + the caller (and, eventually, the Electrum log). + """ + if isinstance(e, UserFacingException): + return e + if isinstance(e, will_mod.NoHeirsException): + return UserFacingException(_("There are no valid heirs")) + if isinstance(e, will_mod.WillExpiredException): + return UserFacingException( + _("The will is expired and must be invalidated on-chain and rebuilt") + ) + if isinstance(e, will_mod.WillPostponedException): + return UserFacingException( + _( + "This inheritance was already signed/sent to will-executors and " + "you are postponing it. The invalidation transaction must be " + "signed and broadcast FIRST, then the will prepared again." + ) + ) + if isinstance(e, will_mod.HeirNotFoundException): + return UserFacingException( + _("Found CHANGES to the DATE or the HEIRS, a new WILL must be prepared") + ) + if isinstance(e, will_mod.TxFeesChangedException): + return UserFacingException(_("Transaction fees are changed")) + if isinstance(e, will_mod.WillExecutorNotPresent): + return UserFacingException(_("Will-Executor not present")) + if isinstance(e, will_mod.NoWillExecutorNotPresent): + return UserFacingException(_("No backup transaction or will-executor selected")) + if isinstance(e, will_mod.AmountException): + return UserFacingException( + _( + "In the inheritance process, the entire wallet will always be " + "fully emptied. Your settings require an adjustment of the " + f"amounts: {e}" + ) + ) + if isinstance(e, heirs_mod.WillExecutorFeeTooHighException): + return UserFacingException(_(f"Will-executor fee too high: {e}")) + if isinstance(e, heirs_mod.BalanceTooLowException): + return UserFacingException(str(e)) + if isinstance(e, heirs_mod.HeirAmountIsDustException): + return UserFacingException(str(e)) + if isinstance(e, heirs_mod.NotAnAddress): + return UserFacingException(_(f"not an address, {e}")) + if isinstance(e, heirs_mod.AmountNotValid): + return UserFacingException(str(e)) + if isinstance(e, heirs_mod.LocktimeNotValid): + return UserFacingException(str(e)) + if isinstance(e, checkalive.CheckAliveError): + return UserFacingException( + _( + "CheckAlive is in the past: update it to a date in the future " + "but less than the locktime" + ) + ) + return e + + +class BalController: + """Headless per-wallet controller replicating :class:`BalWindow`. + + One instance wraps one wallet; commands construct it on demand with + ``BalController(plugin, wallet)``. It mirrors the GUI's state object + (``will_settings``, ``heirs``, ``will``, ``willitems``, ``willexecutors``, + ``date_to_check``) and its flows. + """ + + def __init__(self, plugin, wallet): + self.plugin = plugin + self.wallet = wallet + self.heirs = {} + self.will = {} + self.willitems = {} + self.willexecutors = {} + self.will_settings = {} + self.date_to_check = None + self.no_willexecutor = False + + # The GUI wires ``plugin.get_decimal_point`` from the window; here the + # daemon reads Electrum's global unit setting (falling back to 8 when + # the config object does not expose it). + plugin.get_decimal_point = self._get_decimal_point + + self._init_settings() + self._init_heirs() + self._init_will() + self._init_willexecutors() + + # ------------------------------------------------------------------ # + # Init + # ------------------------------------------------------------------ # + def _get_decimal_point(self): + try: + return self.plugin.config.BTC_AMOUNTS_DECIMAL_POINT + except AttributeError: + return 8 + + def _init_settings(self): + self.will_settings = self.plugin.WILL_SETTINGS.get() + if not self.will_settings: + self.will_settings = self.plugin.default_will_settings() + Util.fix_will_settings_tx_fees(self.will_settings) + + def _init_heirs(self): + self.heirs = Heirs._validate(Heirs(self.wallet)) + + def _init_will(self): + self.will = self.wallet.db.get_dict("will") + Util.fix_will_tx_fees(self.will) + self.load_willitems() + + def _init_willexecutors(self): + self.willexecutors = Willexecutors.get_willexecutors( + self.plugin, update=False, task=False + ) + self.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get()) + + def load_willitems(self): + self.willitems = {} + for wid, w in self.will.items(): + self.willitems[wid] = WillItem(w, wallet=self.wallet) + + def save_willitems(self): + """Persist the in-memory willitems into the wallet's ``will`` dict. + + The transaction is stored serialized (exactly like the GUI, which + avoids deep-copying a live Transaction holding a ``threading.RLock``) + and every value is proven JSON-serializable before it is written. + """ + keys = list(self.will.keys()) + for k in keys: + del self.will[k] + for wid, w in self.willitems.items(): + d = w.to_dict() + d["tx"] = str(d["tx"]) + try: + json.dumps(d, cls=MyEncoder) + except Exception as e: + _logger.error(f"save_willitems: will {wid} is not serializable: {e!r}") + raise + self.will[wid] = d + self.wallet.save_db() + + def _save_to_history(self): + """Persist the will state into the wallet's LOCAL history (best-effort). + + Mirrors ``BalWindow._save_will_to_history``: only when the + ``SAVE_HISTORY`` setting is enabled, and never raises. + """ + try: + if not bool(self.plugin.SAVE_HISTORY.get()): + return + Will.save_valid_transactions_to_history( + self.willitems, self.wallet, self.plugin.HISTORY_LABEL.get() + ) + except Exception as e: + _logger.error(f"save_to_history failed: {e}") + + # ------------------------------------------------------------------ # + # Serialization helpers + # ------------------------------------------------------------------ # + def _willitem_summary(self, wi): + out = { + "txid": wi._id, + "tx": str(wi.tx) if wi.tx is not None else None, + "locktime": int(wi.tx.locktime) if wi.tx is not None else None, + "heirs": list((wi.heirs or {}).keys()) if wi.heirs is not None else [], + "tx_fees": int(wi.tx_fees), + "description": wi.description, + "sigs_have": int(getattr(wi, "sigs_have", 0)), + "sigs_required": int(getattr(wi, "sigs_required", 0)), + } + if wi.we: + out["willexecutor"] = wi.we.get("url") + out["status"] = {} + for key, value in wi.STATUS.items(): + out["status"][key] = bool(value[1]) + return out + + def _will_status_dict(self): + items = [self._willitem_summary(w) for w in self.willitems.values()] + counts = {} + for w in self.willitems.values(): + for key, value in w.STATUS.items(): + counts[key] = counts.get(key, 0) + (1 if value[1] else 0) + return { + "count": len(items), + "items": items, + "status_counts": counts, + "date_to_check": self.date_to_check, + } + + def _tx_out(self, tx): + if tx is None: + return {"txid": None, "tx": None} + return {"txid": tx.txid(), "tx": str(tx)} + + def _available_utxos(self): + return Util.get_available_utxos( + self.wallet, + self.plugin.HISTORY_LABEL.get(), + Will.get_min_locktime(self.willitems, default_value=self.date_to_check), + ) + + # ------------------------------------------------------------------ # + # Core flows (mirror of BalWindow) + # ------------------------------------------------------------------ # + def init_class_variables(self): + if not self.heirs: + raise will_mod.NoHeirsException(_("Heirs are not defined")) + self.date_to_check = resolve_date_to_check( + self.plugin.is_basic_mode(), + self.will_settings, + built_locktime=Will.get_min_locktime(self.willitems), + ) + self.no_willexecutor = bool(self.plugin.NO_WILLEXECUTOR.get()) + self.willexecutors = Willexecutors.get_willexecutors( + self.plugin, update=True, task=False + ) + if check_alive_expired(self.plugin.is_basic_mode(), self.date_to_check): + raise CheckAliveError(self.date_to_check) + self._init_heirs_to_locktime(self.plugin.ENABLE_MULTIVERSE.get()) + + def _init_heirs_to_locktime(self, multiverse=False): + if multiverse: + return + locktime = self.will_settings["locktime"] + if not isinstance(locktime, (int, float, str)): + locktime = str(locktime) + updates = { + heir: [self.heirs[heir][0], self.heirs[heir][1], locktime] + for heir in list(self.heirs) + } + for heir, value in updates.items(): + self.heirs[heir] = value + self.wallet.save_db() + + def check_will(self): + return Will.is_will_valid( + self.willitems, + self.date_to_check, + self.will_settings["baltx_fees"], + self._available_utxos(), + heirs=self.heirs, + willexecutors=self.willexecutors, + self_willexecutor=self.no_willexecutor, + wallet=self.wallet, + ) + + def build_will(self, ignore_duplicate=True, keep_original=True): + """Build (or rebuild) the inheritance transactions. + + Mirrors ``BalWindow.build_will``; raises ``NoWillExecutorNotPresent`` + when no valid will-executor is selected and the user is not their own + executor. + """ + will = {} + self.willexecutors = Willexecutors.get_willexecutors( + self.plugin, update=False, task=False + ) + if not self.no_willexecutor: + valid = False + for _u, w in self.willexecutors.items(): + if Willexecutors.is_selected(w) and Willexecutors.is_valid( + w, + max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(), + dust=self.wallet.dust_threshold(), + ): + valid = True + if not valid: + raise will_mod.NoWillExecutorNotPresent( + "No Will-Executor or backup transaction selected" + ) + txs = self.heirs.get_transactions( + self.plugin, + self.wallet, + self.will_settings["baltx_fees"], + self._available_utxos(), + self.date_to_check, + ) + creation_time = time.time() + if txs: + for txid in txs: + tx = {} + tx["tx"] = txs[txid] + tx["my_locktime"] = txs[txid].my_locktime + tx["heirsvalue"] = txs[txid].heirsvalue + tx["description"] = txs[txid].description + tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor) + tx["status"] = _("New") + tx["baltx_fees"] = txs[txid].tx_fees + tx["time"] = creation_time + tx["heirs"] = copy.deepcopy(txs[txid].heirs) + tx["txchildren"] = [] + will[txid] = WillItem(tx, _id=txid, wallet=self.wallet) + Will.update_will(self.willitems, will) + self.willitems.update(will) + Will.normalize_will(self.willitems, self.wallet) + else: + _logger.info("No transactions was built") + return {} + return self.willitems + + def invalidate_will(self, will=None): + """Build the on-chain invalidation transaction (real fee). + + Returns the raw ``PartialTransaction`` (or ``None`` when there is + nothing to invalidate); the caller decides how to show/sign it. + """ + willitems = will if will is not None else self.willitems + fee_per_byte = self.will_settings.get("baltx_fees", 1) + tx = Will.invalidate_will( + willitems, + self.wallet, + fee_per_byte, + history_label=self.plugin.HISTORY_LABEL.get(), + will_locktime=Will.get_min_locktime( + willitems, default_value=self.date_to_check + ), + ) + if tx is not None: + try: + self.wallet.set_label(tx.txid(), "BAL Invalidate transaction") + except Exception as e: + _logger.debug(f"invalidate_will set_label failed: {e}") + return tx + + def invalidate_will_command(self): + """Command-facing wrapper of :meth:`invalidate_will` (JSON-safe).""" + return self._tx_out(self.invalidate_will()) + + def will_status(self): + """Command-facing snapshot of the current will (JSON-safe).""" + return self._will_status_dict() + + def will_check(self): + """Check the local coherence of the will (heirs, executors, fees). + + Returns ``{"valid": true}`` or raises a translated domain exception. + """ + try: + self.init_class_variables() + self.check_will() + except Exception as e: + raise _user_facing(e) from e + return {"valid": True} + + def prepare_will(self, ignore_duplicate=True, keep_original=True): + """Run the full "prepare inheritance" flow and return a status dict. + + Mirrors ``BalWindow.build_inheritance_transaction``: + + * ``coherent`` -> the existing will is still valid; nothing rebuilt. + * ``rebuilt`` -> the will was not coherent and has been rebuilt. + * ``expired`` / ``postponed`` -> an invalidation transaction must be + signed and broadcast before a new will can be prepared. + """ + if not self.heirs: + raise UserFacingException(_("Heirs are not defined: add at least one heir first")) + + self.init_class_variables() + if self.date_to_check is None: + raise UserFacingException(_("cannot resolve the check-alive date")) + date_to_check: float = self.date_to_check + try: + Will.check_amounts( + self.heirs, + self.willexecutors, + self._available_utxos(), + date_to_check, + self.wallet.dust_threshold(), + max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(), + ) + except Exception as e: + raise _user_facing(e) from e + + locktime = Util.parse_locktime_string(self.will_settings["locktime"]) + if locktime < date_to_check: + raise UserFacingException(_("locktime is lower than threshold")) + + if not self.no_willexecutor: + valid = False + for _k, we in self.willexecutors.items(): + if Willexecutors.is_selected(we) and Willexecutors.is_valid( + we, + max_fee=self.plugin.MAX_WILLEXECUTOR_FEE.get(), + dust=self.wallet.dust_threshold(), + ): + valid = True + if not valid: + raise UserFacingException( + _("no backup transaction or willexecutor selected") + ) + + try: + self.check_will() + self.save_willitems() + return { + "result": "coherent", + "message": _("The will is coherent"), + "will": self._will_status_dict(), + } + except will_mod.WillExpiredException: + inv = self._tx_out(self.invalidate_will()) + return { + "result": "expired", + "message": _( + "The will is expired: sign and broadcast the invalidation " + "transaction, then prepare again" + ), + "invalidation_tx": inv, + "will": self._will_status_dict(), + } + except will_mod.WillPostponedException as e: + _logger.info(f"will postponed: {e}") + inv = self._tx_out(self.invalidate_will()) + return { + "result": "postponed", + "message": _( + "This inheritance was already signed/sent to will-executors " + "and you are postponing it. Sign and broadcast the " + "invalidation transaction now, then prepare again." + ), + "invalidation_tx": inv, + "will": self._will_status_dict(), + } + except will_mod.NotCompleteWillException as e: + _logger.info(f"will not coherent ({type(e).__name__}): rebuilding") + self.build_will(ignore_duplicate, keep_original) + rebuilt_ok = False + try: + self.check_will() + for wid, _w in self.willitems.items(): + try: + self.wallet.set_label(wid, "BAL Inheritance transaction") + except Exception as label_err: + _logger.debug(f"prepare_will set_label failed: {label_err}") + rebuilt_ok = True + except will_mod.WillExpiredException: + inv = self._tx_out(self.invalidate_will()) + return { + "result": "rebuilt_expired", + "message": _( + "The rebuilt will is expired: invalidate on-chain, " + "then prepare again" + ), + "invalidation_tx": inv, + "will": self._will_status_dict(), + } + except will_mod.NotCompleteWillException as e2: + raise UserFacingException( + _("Error: {} Please, check your heirs, locktime and threshold!").format( + str(e2) + ) + ) from e2 + self.save_willitems() + if rebuilt_ok: + self._save_to_history() + return { + "result": "rebuilt", + "message": _( + "The will was rebuilt and needs to be signed and " + "broadcast again" + ), + "will": self._will_status_dict(), + } + raise UserFacingException(_("will not rebuilt")) from None + + def sign_transactions(self, password, txids=None): + """Sign the valid will transactions (or a subset given by ``txids``). + + Returns ``{txid: raw_tx}`` for every signed transaction. Raises + ``UserFacingException`` when the wallet is encrypted and no password is + given. + """ + willitems = self.willitems + if password is None and self.wallet.has_keystore_encryption(): + raise UserFacingException(_("Password required to sign transactions")) + + if txids is not None: + targets = [ + t for t in txids if t in willitems and willitems[t].get_status("VALID") + ] + else: + targets = Will.only_valid(willitems) + + txs = {} + for txid in targets: + wi = willitems[txid] + tx = Will.get_tx_from_any(str(wi.tx)) + if wi.get_status("COMPLETE"): + txs[txid] = tx + continue + for txin in tx.inputs(): + prevout = txin.prevout.to_json() + if prevout[0] in willitems: + change = willitems[prevout[0]].tx.outputs()[prevout[1]] + txin._trusted_value_sats = change.value + try: + txin.script_descriptor = change.script_descriptor + except Exception: + pass + txin.is_mine = True + txin._TxInput__address = change.address + txin._TxInput__scriptpubkey = change.scriptpubkey + txin._TxInput__value_sats = change.value + + self.wallet.sign_transaction(tx, password, ignore_warnings=True) + if tx.is_complete(): + wi.set_status("COMPLETE", True) + try: + have, required = tx.signature_count() + wi.sigs_have = int(have) + wi.sigs_required = int(required) + except Exception as e: + _logger.debug(f"signature_count after signing failed: {e}") + wi.tx = Will.get_tx_from_any(str(tx)) + txs[txid] = tx + + try: + Will.check_signatures(willitems, self.wallet) + except Exception as e: + _logger.error(f"check_signatures after signing failed: {e}") + self.save_willitems() + self._save_to_history() + return txs + + def push_transactions_to_willexecutors(self, force=False, txids=None): + """Push the valid+signed will transactions to their will-executors. + + Returns ``{url: broadcast_status}`` and raises ``UserFacingException`` + when no transaction matched the (optional) filter. + """ + willitems = self.willitems + if txids is not None: + willitems = {t: willitems[t] for t in txids if t in willitems} + if not willitems: + raise UserFacingException(_("No transaction matches the given txids")) + + willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force) + if not willexecutors: + return {} + + for url in willexecutors: + willexecutors[url].setdefault("broadcast_status", _("waiting...")) + + error = {"flag": False} + already_present = [] + + def on_each(url, willexecutor, ok, exc): + if isinstance(exc, Willexecutors.AlreadyPresentException): + already_present.append(url) + willexecutor["broadcast_status"] = _("checking...") + elif ok: + for wid in willexecutor.get("txsids", []): + willitems[wid].set_status("PUSHED", True) + willexecutor["broadcast_status"] = _("Success") + else: + for wid in willexecutor.get("txsids", []): + willitems[wid].set_status("PUSH_FAIL", True) + error["flag"] = True + willexecutor["broadcast_status"] = _("Failed") + willexecutor.pop("txs", None) + + Willexecutors.push_transactions_parallel(willexecutors, on_each=on_each) + + for url in already_present: + willexecutor = willexecutors[url] + for wid in willexecutor.get("txsids", []): + w = self.willitems[wid] + try: + w.set_check_willexecutor( + Willexecutors.check_transaction(wid, w.we["url"]) + ) + except Exception as e: + _logger.error(f"check after already-present failed for {wid}: {e}") + w.set_check_willexecutor(None) + + self.save_willitems() + out = { + url: we.get("broadcast_status", _("unknown")) + for url, we in willexecutors.items() + } + if error["flag"]: + out["_error"] = True + return out + + def check_transactions(self, txids=None): + """Ask every will-executor whether it holds our (pushed) transactions. + + Returns ``{txid: {pushed, checked, check_fail}}``. + """ + targets = [] + for wid, w in self.willitems.items(): + if not w.we: + continue + if txids is not None and wid not in txids: + continue + if Will.needs_server_check(w): + targets.append((wid, w.we["url"])) + + def on_each(wid, url, res, exc): + try: + self.willitems[wid].set_check_willexecutor(res) + except Exception as e: + _logger.error(f"check on_each error for {wid}: {e}") + + def on_timeout(wid, url): + try: + self.willitems[wid].set_check_willexecutor(None) + except Exception as e: + _logger.error(f"check on_timeout error for {wid}: {e}") + + Willexecutors.check_transactions_parallel( + targets, on_each=on_each, on_timeout=on_timeout + ) + + self.save_willitems() + out = {} + for wid, w in self.willitems.items(): + if w.we: + out[wid] = { + "url": w.we.get("url"), + "pushed": bool(w.get_status("PUSHED")), + "checked": bool(w.get_status("CHECKED")), + "check_fail": bool(w.get_status("CHECK_FAIL")), + } + return out + + def export_will(self, path): + """Export the whole will to ``path`` (JSON) and flag items EXPORTED.""" + for wid in self.willitems: + self.willitems[wid].set_status("EXPORTED", True) + self.will[wid] = self.willitems[wid].to_dict() + write_json_file(path, self.will) + self.save_willitems() + return {"exported": len(self.willitems), "path": path} + + def _load_will_file(self, path): + data = read_json_file(path) + willitems = {} + for k, v in data.items(): + data[k]["tx"] = tx_from_any(v["tx"]) + willitems[k] = WillItem(data[k], _id=k) + return willitems + + def merge_will(self, imported): + """Merge imported will items into the live will. + + Mirrors ``BalWindow.merge_will``: operational statuses are carried over, + unsigned live transactions are combined/substituted, new transactions + are added wholesale, then a local validity check recomputes the + valid/invalidated/replaced statuses. + """ + if self.date_to_check is None: + self.date_to_check = resolve_date_to_check( + self.plugin.is_basic_mode(), self.will_settings + ) + for wid, wi in imported.items(): + if wid in self.willitems: + live = self.willitems[wid] + was_complete = live.get_status("COMPLETE") + for status in ( + "COMPLETE", + "PUSHED", + "CHECKED", + "MEMPOOL", + "CONFIRMED", + ): + if wi.get_status(status): + live.set_status(status, True) + if not was_complete: + try: + if live.tx.txid() == wi.tx.txid(): + live.tx.combine_with_other_psbt(wi.tx) + else: + live.tx = wi.tx + except Exception: + live.tx = wi.tx + if live.tx.is_complete(): + live.set_status("COMPLETE", True) + else: + self.willitems[wid] = wi + Will.normalize_will(self.willitems, self.wallet) + self.save_willitems() + try: + Will.add_willtree(self.willitems) + all_utxos = self._available_utxos() + Will.check_invalidated( + self.willitems, Will.utxos_strs(all_utxos), self.wallet + ) + Will.search_rai( + Will.get_all_inputs(self.willitems, only_valid=True), + all_utxos, + self.willitems, + self.wallet, + ) + Will.check_signatures(self.willitems, self.wallet) + except Exception as e: + _logger.error(f"merge_will validity check failed: {e}") + self.save_willitems() + return {"merged": len(imported)} + + def merge_will_from_file(self, path): + try: + willitems = self._load_will_file(path) + except Exception as e: + raise UserFacingException(_("Invalid will file: {}").format(e)) from None + Will.normalize_will(willitems, self.wallet) + return self.merge_will(willitems) + + # ------------------------------------------------------------------ # + # Heirs CRUD + # ------------------------------------------------------------------ # + def _build_heir_entry(self, name, address, amount, locktime): + heir = [name, address, amount] + if locktime is not None: + heir.append(locktime) + else: + heir.append(self.will_settings["locktime"]) + if is_op_return_address(address): + heir[2] = "0" + return Heirs.validate_heir(heir[0], heir[1:]) + + def heirs_add(self, name, address, amount, locktime=None): + value = self._build_heir_entry(name, address, amount, locktime) + self.heirs[name] = value + self.wallet.save_db() + return {"name": name, "value": list(value)} + + def heirs_update(self, name, address=None, amount=None, locktime=None): + if name not in self.heirs: + raise UserFacingException(_("Heir not found: {}").format(name)) + current = list(self.heirs[name]) + address = address if address is not None else current[0] + amount = amount if amount is not None else current[1] + locktime = locktime if locktime is not None else current[2] + value = self._build_heir_entry(name, address, amount, locktime) + self.heirs[name] = value + self.wallet.save_db() + return {"name": name, "value": list(value)} + + def heirs_delete(self, names): + deleted = [] + for name in names: + if name in self.heirs: + self.heirs.pop(name) + deleted.append(name) + self.heirs.save() + self.wallet.save_db() + return {"deleted": deleted} + + def heirs_list(self): + return {k: list(v) for k, v in self.heirs.items()} + + def heirs_show(self, name): + if name not in self.heirs: + raise UserFacingException(_("Heir not found: {}").format(name)) + return {"name": name, "value": list(self.heirs[name])} + + def heirs_import(self, path): + self.heirs.import_file(path) + self.wallet.save_db() + return {"imported": len(self.heirs)} + + def heirs_export(self, path): + self.heirs.export_file(path) + return {"exported": len(self.heirs), "path": path} + + # ------------------------------------------------------------------ # + # Will-Executors CRUD + # ------------------------------------------------------------------ # + def willexecutors_list(self): + return {url: dict(we) for url, we in self.willexecutors.items()} + + def willexecutors_show(self, url): + if url not in self.willexecutors: + raise UserFacingException(_("Will-Executor not found: {}").format(url)) + return {"url": url, "willexecutor": dict(self.willexecutors[url])} + + def _validate_executor_address(self, address): + if address and not bitcoin.is_address(address, net=constants.net): + raise UserFacingException( + _("Invalid will-executor address for this network: {}").format(address) + ) + + def willexecutors_add(self, url, address="", base_fee=0, info=None): + if not url: + raise UserFacingException(_("URL is required")) + if url in self.willexecutors: + raise UserFacingException(_("Will-Executor already present: {}").format(url)) + self._validate_executor_address(address) + info = (info or "").strip() or "New Will Executor" + self.willexecutors[url] = { + "info": info, + "base_fee": int(base_fee), + "address": address, + "selected": False, + "status": "-1", + "promo_code": None, + } + Willexecutors.save(self.plugin, self.willexecutors) + return self.willexecutors_show(url) + + def willexecutors_update(self, url, address=None, base_fee=None, info=None, + rename_to=None): + if url not in self.willexecutors: + raise UserFacingException(_("Will-Executor not found: {}").format(url)) + we = self.willexecutors[url] + if address is not None: + self._validate_executor_address(address) + we["address"] = address + if base_fee is not None: + we["base_fee"] = int(base_fee) + if info is not None: + we["info"] = info.strip() or "New Will Executor" + if rename_to and rename_to != url: + if rename_to in self.willexecutors: + raise UserFacingException( + _("Will-Executor already present: {}").format(rename_to) + ) + self.willexecutors[rename_to] = we + del self.willexecutors[url] + url = rename_to + Willexecutors.save(self.plugin, self.willexecutors) + return self.willexecutors_show(url) + + def willexecutors_delete(self, urls): + deleted = [] + for url in urls: + if url in self.willexecutors: + del self.willexecutors[url] + deleted.append(url) + Willexecutors.save(self.plugin, self.willexecutors) + return {"deleted": deleted} + + def willexecutors_select(self, urls=None, select=True): + """Select/deselect one, many or all will-executors.""" + selected = {} + targets = urls if urls is not None else list(self.willexecutors) + for url in targets: + if url not in self.willexecutors: + continue + self.willexecutors[url]["selected"] = bool(select) + selected[url] = bool(select) + Willexecutors.save(self.plugin, self.willexecutors) + return selected + + def willexecutors_ping(self, urls=None): + """Ping the (selected) will-executor servers and refresh their info. + + Returns ``{url: {"status": int, "ok": bool}}``. + """ + targets = {} + if urls is not None: + for url in urls: + if url not in self.willexecutors: + raise UserFacingException( + _("Will-Executor not found: {}").format(url) + ) + targets[url] = self.willexecutors[url] + else: + targets = { + url: we + for url, we in self.willexecutors.items() + if Willexecutors.is_selected(we) + } + if not targets: + raise UserFacingException(_("No will-executor is selected")) + results = {} + + def on_each(url, we, ok): + results[url] = {"status": we.get("status"), "ok": bool(ok)} + + Willexecutors.ping_servers_parallel(targets, on_each=on_each) + Willexecutors.save(self.plugin, self.willexecutors) + return results + + def _fetch_will_executors_list(self): + """Download the will-executor list from the welist server. + + Mirrors ``BalWindow.fetch_will_executors_list`` (welist URL selection, + Tor gating for ``.onion`` servers, per-entry validation). Returns the + downloaded dict, ``{}`` on failure. + """ + chainname = BalPlugin.chainname + basic = self.plugin.is_basic_mode() + if basic: + base = self.plugin.WELIST_SERVER.default + else: + base = self.plugin.WELIST_SERVER.get() + base = base if base.endswith("/") else base + "/" + url = f"{base}data/{chainname}?page=0&limit=100" + + result = {} + last_error = None + try: + resp = Willexecutors.send_request( + "get", url, timeout=10, max_retries=1, retry_sleep=1 + ) + if not isinstance(resp, dict): + last_error = "invalid response format" + _logger.warning( + f"fetch_will_executors_list: {url} -> unexpected response " + f"type {type(resp).__name__}, ignoring" + ) + else: + result = resp + tor_on = is_tor_active() + for w in list(result.keys()): + if w in ("status", "url"): + continue + if not isinstance(result.get(w), dict): + del result[w] + continue + if not tor_on and is_onion_url(w): + del result[w] + continue + Willexecutors.initialize_willexecutor( + result[w], w, None, self.willexecutors.get(w, None) + ) + except Exception as e: + last_error = str(e) + _logger.error(f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}") + + if not result and not basic: + raise UserFacingException( + _("Could not reach the configured welist server.\nServer: {}\nError: {}").format( + url, last_error or "empty response" + ) + ) + return result + + def willexecutors_download(self): + """Download the will-executor list and merge it into the local one.""" + result = self._fetch_will_executors_list() + if result: + self.willexecutors.update(result) + Willexecutors.save(self.plugin, self.willexecutors) + return {"downloaded": len(result), "total": len(self.willexecutors)} + + def willexecutors_import(self, path): + data = read_json_file(path) + if not isinstance(data, dict): + raise UserFacingException(_("Invalid will-executors file")) + for url, we in data.items(): + if not isinstance(we, dict): + raise UserFacingException( + _("Invalid entry {} in will-executors file").format(url) + ) + if url not in self.willexecutors: + we = dict(we) + we.setdefault("selected", False) + we.setdefault("status", "New") + we.setdefault("promo_code", None) + self.willexecutors[url] = we + Willexecutors.save(self.plugin, self.willexecutors) + return {"imported": len(data), "total": len(self.willexecutors)} + + def willexecutors_export(self, path): + write_json_file(path, self.willexecutors) + return {"exported": len(self.willexecutors), "path": path} + + # ------------------------------------------------------------------ # + # Settings + # ------------------------------------------------------------------ # + def _configs(self): + out = {} + for attr in dir(self.plugin): + if not attr.isupper(): + continue + value = getattr(self.plugin, attr, None) + if isinstance(value, BalConfig): + out[attr] = value + return out + + def _config_key(self, key): + configs = self._configs() + aliases = {} + for attr, cfg in configs.items(): + aliases[attr.upper().replace("-", "_").replace(".", "_")] = (attr, cfg) + aliases[cfg.name.upper().replace("-", "_").replace(".", "_")] = (attr, cfg) + normalized = key.upper().replace("-", "_").replace(".", "_") + if normalized not in aliases: + raise UserFacingException( + _("Unknown BAL setting: {} (use bal_settings_list)").format(key) + ) + return aliases[normalized] + + def settings_list(self): + out = {} + for attr, cfg in self._configs().items(): + out[attr] = { + "name": cfg.name, + "default": cfg.default, + "value": cfg.get(), + } + return out + + def settings_get(self, key): + attr, cfg = self._config_key(key) + return { + "key": attr, + "name": cfg.name, + "default": cfg.default, + "value": cfg.get(), + } + + def settings_reset(self, key): + attr, cfg = self._config_key(key) + cfg.set(cfg.default) + return { + "key": attr, + "name": cfg.name, + "default": cfg.default, + "value": cfg.get(), + } + + def settings_set(self, key, value): + attr, cfg = self._config_key(key) + coerced = self._coerce_config_value(cfg, value) + cfg.set(coerced) + return { + "key": attr, + "name": cfg.name, + "default": cfg.default, + "value": cfg.get(), + } + + def _coerce_config_value(self, cfg, raw): + if isinstance(cfg.default, bool): + if isinstance(raw, str): + low = raw.strip().lower() + if low in ("true", "1", "yes", "on"): + return True + if low in ("false", "0", "no", "off"): + return False + raise UserFacingException( + _("Invalid boolean for {}: {}").format(cfg.name, raw) + ) + return bool(raw) + if isinstance(cfg.default, int): + try: + return int(raw) + except (TypeError, ValueError): + raise UserFacingException( + _("Expected an integer for {}: {}").format(cfg.name, raw) + ) from None + if isinstance(cfg.default, dict): + try: + value = json.loads(raw) if isinstance(raw, str) else raw + except (json.JSONDecodeError, TypeError): + raise UserFacingException( + _("Expected a JSON object for {}").format(cfg.name) + ) from None + if not isinstance(value, dict): + raise UserFacingException( + _("Expected a JSON object for {}").format(cfg.name) + ) + return value + return str(raw) + + +def get_decimal_point(plugin): + """Standalone helper returning Electrum's configured BTC decimal point.""" + try: + return plugin.config.BTC_AMOUNTS_DECIMAL_POINT + except AttributeError: + return 8 diff --git a/bal/cli/plugin.py b/bal/cli/plugin.py new file mode 100644 index 0000000..9273742 --- /dev/null +++ b/bal/cli/plugin.py @@ -0,0 +1,21 @@ +""" +bal.cli.plugin +============== + +The headless (command-line) entry point of the plugin. + +:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` without +adding any Qt hooks or per-window state. Electrum instantiates this class when +the plugin runs with ``gui_name='cmdline'`` (the daemon loads +``bal/cmdline.py``, which re-exports it), and it is the object injected as +``plugin`` into every ``bal_*`` command by ``electrum.commands.plugin_command``. +""" + +from ..core.plugin_base import BalPlugin + + +class Plugin(BalPlugin): + """Minimal ``BasePlugin`` subclass for the command-line front-end.""" + + def __init__(self, parent, config, name): + BalPlugin.__init__(self, parent, config, name) diff --git a/bal/cmdline.py b/bal/cmdline.py new file mode 100644 index 0000000..c7a160b --- /dev/null +++ b/bal/cmdline.py @@ -0,0 +1,69 @@ +""" +bal.cmdline +=========== + +Compatibility shim for Electrum's plugin loader (command-line front-end). + +Electrum loads a plugin with ``gui_name='cmdline'`` by importing the +``cmdline`` module of the plugin package and looking for a ``Plugin`` class. +The real implementation lives in the ``bal.cli`` sub-package, so this module +re-exports ``Plugin`` from ``bal.cli.plugin``. + +Like ``qt.py``, this file is not a one-line relative import because the very +same code may be loaded as an *external* plugin from a ``.zip``, where Electrum +imports the package under the synthetic top-level name +``electrum_external_plugins.bal`` and never registers the intermediate parent +packages. See the module docstring of ``bal.qt`` for the full rationale. The +shim resolves the run-time package name, backfills the missing parents into +``sys.modules`` and imports the real implementation via +:func:`importlib.import_module`. + +Unlike ``qt.py``, this module MUST never import PyQt (the daemon loads it in a +headless process). +""" + +import importlib +import sys + + +def _ensure_parent_packages(pkg_name: str) -> None: + """Make sure every ancestor package of *pkg_name* is in ``sys.modules``. + + When loaded from a zip as an external plugin, Electrum only executes the + plugin package ``__init__`` and the ``cmdline`` module. The synthetic root + package (e.g. ``electrum_external_plugins``) and any intermediate packages + may be missing from ``sys.modules``, which breaks relative/absolute + sub-module imports. We backfill them here using this module's own loader + so that ``importlib`` can find sibling sub-packages. + """ + parts = pkg_name.split(".") + # Walk from the top-most ancestor down to (but not including) pkg_name. + for i in range(1, len(parts)): + ancestor = ".".join(parts[:i]) + if ancestor in sys.modules: + continue + try: + importlib.import_module(ancestor) + except Exception: + # The synthetic root (e.g. 'electrum_external_plugins') often has no + # real spec. Create a minimal namespace package stub so that the + # import machinery can still resolve its children. + import types + + module = types.ModuleType(ancestor) + module.__path__ = [] # mark as a (namespace) package + sys.modules[ancestor] = module + + +# The package this module belongs to. Could be 'electrum.plugins.bal' (internal) +# or 'electrum_external_plugins.bal' (external zip), depending on how Electrum +# loaded us. +_PKG = __package__ or "bal" + +_ensure_parent_packages(_PKG) + +# Import the real implementation using the fully-qualified, run-time package +# name so it works regardless of the synthetic prefix Electrum assigned. +_plugin_module = importlib.import_module(_PKG + ".cli.plugin") + +Plugin = _plugin_module.Plugin # noqa: F401 (re-exported for Electrum) diff --git a/bal/manifest.json b/bal/manifest.json index 7f37781..311eea0 100644 --- a/bal/manifest.json +++ b/bal/manifest.json @@ -6,7 +6,8 @@ "author": "Svatantrya", "licence": "MIT", "available_for": [ - "qt" + "qt", + "cmdline" ], "icon": "icons/bal32x32.png" } \ No newline at end of file diff --git a/tests/karen7 b/tests/karen7 index 24f0629..e1658de 100644 --- a/tests/karen7 +++ b/tests/karen7 @@ -2701,7 +2701,17 @@ ], "mario2": [ "bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju", - 40000, + 40000000, + "1y" + ], + "op_return": [ + "OP_RETURN:48656c6c6f", + "0", + "1y" + ], + "op_return2": [ + "OP_RETURN:426974636f696e2041667465726c696665", + "0", "1y" ] }, diff --git a/tests/test_cli_commands_registered.py b/tests/test_cli_commands_registered.py new file mode 100644 index 0000000..7d9ed52 --- /dev/null +++ b/tests/test_cli_commands_registered.py @@ -0,0 +1,149 @@ +""" +Test: BAL plugin CLI commands are registered with Electrum. + +Verifies that importing the plugin through Electrum's own plugin loader +(``Plugins(config, cmd_only=True)``, the exact code path ``run_electrum`` uses +to pre-parse the command line) registers every ``bal_*`` command with +``electrum.commands`` (``known_commands`` + the ``Commands`` class). + +It also asserts the basic contract enforced by ``plugin_command``: each command +is a coroutine and carries the expected flags (all ``bal_*`` commands require a +daemon/network, i.e. the ``'n'`` flag; the wallet-bound ones the ``'w'`` flag; +signing also ``'p'``). + +Run: + source /home/steal/devel/bal/electrum/env/bin/activate + python3 tests/test_cli_commands_registered.py +""" + +import inspect +import tempfile + +from electrum import commands as electrum_commands +from electrum.plugin import Plugins +from electrum.simple_config import SimpleConfig + +# The full command table lives in PLAN_CMDLINE_PLUGIN.md section 6; new commands +# added in later phases must be appended here so the registration test keeps +# proving the whole list is wired up. +EXPECTED_COMMANDS = { + # Settings (no wallet required) + "bal_settings_list": { + "requires_network": True, + "requires_wallet": False, + "requires_password": False, + }, + "bal_settings_get": { + "requires_network": True, + "requires_wallet": False, + "requires_password": False, + }, + "bal_settings_set": { + "requires_network": True, + "requires_wallet": False, + "requires_password": False, + }, + "bal_settings_reset": { + "requires_network": True, + "requires_wallet": False, + "requires_password": False, + }, + # Heirs + "bal_heirs_list": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_heirs_show": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_heirs_add": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_heirs_update": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_heirs_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_heirs_import": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_heirs_export": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + # Will-Executors + "bal_willexecutors_list": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_willexecutors_show": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_willexecutors_add": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_willexecutors_update": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_willexecutors_select": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_willexecutors_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_willexecutors_ping": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_willexecutors_download": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_willexecutors_import": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_willexecutors_export": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + # Will + "bal_will_status": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_will_check": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_will_prepare": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_will_sign": {"requires_network": True, "requires_wallet": True, "requires_password": True}, + "bal_will_broadcast": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_will_export": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_will_import_merge": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_will_invalidate": {"requires_network": True, "requires_wallet": True, "requires_password": False}, + "bal_will_check_executor": {"requires_network": True, "requires_wallet": True, "requires_password": False}, +} + + +def _isolated_config(**overrides): + """A throwaway SimpleConfig that never touches the real Electrum config. + + A fresh ``electrum_path`` temp dir keeps every write isolated, so running + the tests cannot pollute the user's config files. The bal plugin is + enabled because ``Plugins(cmd_only=True)`` skips any plugin that is not + explicitly enabled (electrum.plugin.Plugins.find_directory_plugins). + """ + opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")} + opts.update(overrides) + cfg = SimpleConfig(opts) + cfg.enable_plugin("bal") + return cfg + + +def test_commands_registered(): + cfg = _isolated_config() + Plugins(cfg, cmd_only=True) + for name, flags in EXPECTED_COMMANDS.items(): + assert name in electrum_commands.known_commands, f"{name} not registered" + cmd = electrum_commands.known_commands[name] + assert cmd.name == name + assert cmd.requires_network is flags["requires_network"] + assert cmd.requires_wallet is flags["requires_wallet"] + assert cmd.requires_password is flags["requires_password"] + + +def test_commands_are_coroutines(): + cfg = _isolated_config() + Plugins(cfg, cmd_only=True) + for name in EXPECTED_COMMANDS: + func = getattr(electrum_commands.Commands, name, None) + assert func is not None, f"{name} missing from Commands" + assert inspect.iscoroutinefunction(func), f"{name} is not a coroutine" + + +def test_no_duplicate_registration(): + """Loading the plugin twice must not raise "Command name bal_... already + exists" (the guard in bal/__init__._register_cli_commands).""" + cfg = _isolated_config() + plugins = Plugins(cfg, cmd_only=True) + plugins.maybe_load_plugin_init_method("bal") # already imported -> no-op + for name in EXPECTED_COMMANDS: + assert name in electrum_commands.known_commands + + +def test_command_docstrings_document_all_args(): + """Every parameter/option must carry an ``arg:TYPE:NAME:DESC`` line (the + CLI parser prints "undocumented argument ..." otherwise).""" + cfg = _isolated_config() + Plugins(cfg, cmd_only=True) + for name in EXPECTED_COMMANDS: + cmd = electrum_commands.known_commands[name] + for varname in list(cmd.params) + list(cmd.options): + if varname in ("wallet", "wallet_path", "plugin", "password"): + continue + assert varname in cmd.arg_descriptions, ( + f"{name}: undocumented argument {varname}" + ) + + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print("[OK] All CLI registration tests passed") diff --git a/tests/test_cli_controller_offline.py b/tests/test_cli_controller_offline.py new file mode 100644 index 0000000..f12391e --- /dev/null +++ b/tests/test_cli_controller_offline.py @@ -0,0 +1,233 @@ +""" +Offline tests for the headless ``bal.cli.controller.BalController``. + +These run without a wallet, a network or Qt: the controller is exercised +against a ``FakeWallet`` plus a real ``bal.cli.plugin.Plugin`` backed by an +isolated in-memory ``SimpleConfig``. Only the flows that never touch the +network (settings/heirs/willexecutors CRUD, status snapshots, error mapping) +are covered here; build/sign/push flows need a live wallet and network and are +exercised by the group tests instead. + +Run: + source electrum/env/bin/activate + python3 tests/test_cli_controller_offline.py +""" + +import os +import shutil +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +from electrum.simple_config import SimpleConfig +from electrum.util import UserFacingException + +from bal.cli.controller import BalController + +VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf" + + +class FakeDB: + def __init__(self): + self._data = {} + + def get(self, key, default=None): + return self._data.get(key, default) + + def put(self, key, value): + self._data[key] = value + + def get_dict(self, key): + return self._data.setdefault(key, {}) + + def get_transaction(self, txid): + return None + + def add_transaction(self, tx, *args, **kwargs): + pass + + +class FakeWallet: + def __init__(self): + self.db = FakeDB() + self.network = None + self.adb = None + self._dust = 500 + + def save_db(self): + pass + + def dust_threshold(self): + return self._dust + + def has_keystore_encryption(self): + return False + + def set_label(self, txid, text): + pass + + def get_utxos(self): + return [] + + def get_change_addresses_for_new_transaction(self, *args, **kwargs): + return [VALID_ADDRESS] + + +class Plugin: + """Real ``bal.cli.plugin.Plugin`` with an isolated config directory.""" + + def __init__(self): + self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_test_") + from bal.cli.plugin import Plugin as RealPlugin + + self.config = SimpleConfig( + {"electrum_path": self.tmpdir}, + read_user_config_function=lambda path: {}, + ) + self.plugin = RealPlugin(None, self.config, "bal") + + def __enter__(self): + return self.plugin + + def __exit__(self, *exc): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + +def _make_controller(plugin): + return BalController(plugin, FakeWallet()) + + +def test_controller_init_empty(): + with Plugin() as plugin: + c = _make_controller(plugin) + assert c.willitems == {} + assert c.will == {} + assert c.heirs == {} + assert isinstance(c.will_settings, dict) + assert "baltx_fees" in c.will_settings + # Fresh config: no stored will-executors. On mainnet the default + # WILLEXECUTORS table is keyed by "mainnet" while chainname is + # "bitcoin", so nothing is injected either. + assert c.willexecutors == {} + assert c.no_willexecutor is False + + +def test_settings_roundtrip(): + with Plugin() as plugin: + c = _make_controller(plugin) + listing = c.settings_list() + assert "BAL_TX_FEES" in listing or "TX_FEES" in listing + tx_key = "BAL_TX_FEES" if "BAL_TX_FEES" in listing else "TX_FEES" + assert c.settings_get(tx_key)["value"] == 100 + + c.settings_set("bal_tx_fees", "150") + assert c.settings_get("bal_tx_fees")["value"] == 150 + assert c.settings_get("TX_FEES")["value"] == 150 + + c.settings_set("bal_no_willexecutor", "true") + assert c.settings_get("bal_no_willexecutor")["value"] is True + + c.settings_reset("bal_tx_fees") + assert c.settings_get("bal_tx_fees")["value"] == 100 + + +def test_settings_unknown_key(): + with Plugin() as plugin: + c = _make_controller(plugin) + try: + c.settings_get("bal_does_not_exist") + raise AssertionError("expected UserFacingException") + except UserFacingException as e: + assert "Unknown BAL setting" in str(e) + + +def test_heirs_crud(): + with Plugin() as plugin: + c = _make_controller(plugin) + c.heirs_add("alice", VALID_ADDRESS, "100000") + assert c.heirs["alice"][0] == VALID_ADDRESS + assert c.heirs["alice"][1] == "100000" + + c.heirs_update("alice", amount="200000") + assert c.heirs["alice"][1] == "200000" + assert c.heirs_show("alice")["value"][1] == "200000" + + assert "alice" in c.heirs_list() + c.heirs_delete(["alice"]) + assert "alice" not in c.heirs_list() + + +def test_heirs_add_op_return(): + with Plugin() as plugin: + c = _make_controller(plugin) + c.heirs_add("note", "OP_RETURN:6a0242414c", "100000") + assert c.heirs["note"][1] == "0" + + +def test_willexecutors_crud(): + with Plugin() as plugin: + c = _make_controller(plugin) + assert c.willexecutors == {} + + new_url = "https://executor.example.invalid" + c.willexecutors_add(new_url, address="", base_fee=250) + assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 250 + assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is False + + c.willexecutors_update(new_url, base_fee="300", info="Example executor") + assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 300 + + c.willexecutors_select([new_url], select=True) + assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is True + + renamed = "https://executor2.example.invalid" + c.willexecutors_update(new_url, rename_to=renamed) + assert renamed in c.willexecutors + assert new_url not in c.willexecutors + + assert c.willexecutors_delete([renamed]) == {"deleted": [renamed]} + assert renamed not in c.willexecutors + + +def test_will_status_empty(): + with Plugin() as plugin: + c = _make_controller(plugin) + status = c.will_status() + assert status["count"] == 0 + assert status["items"] == [] + + +def test_will_check_no_heirs_raises(): + with Plugin() as plugin: + c = _make_controller(plugin) + try: + c.will_check() + raise AssertionError("expected UserFacingException") + except UserFacingException as e: + assert "heir" in str(e).lower() + + +# ------------------------------------------------------------------ # +# runner +# ------------------------------------------------------------------ # + +def main(): + failures = 0 + for name, fn in sorted(globals().items()): + if not name.startswith("test_") or not callable(fn): + continue + print(f" {name}") + try: + fn() + except Exception as e: + failures += 1 + print(f" [FAIL] {name}: {e!r}") + if failures: + print(f"[FAIL] {failures} test(s) failed") + sys.exit(1) + print("[OK] All offline controller tests passed") + + +if __name__ == "__main__": + main()