From 4c5726571e21902813d0117d2c94e5a853308b19 Mon Sep 17 00:00:00 2001 From: GenSpark AI Developer Date: Mon, 15 Jun 2026 14:29:04 +0000 Subject: [PATCH 1/7] feat(net): invio/ping/download verso Will-Executor in parallelo (anti-freeze) Problema: i server Will-Executor venivano contattati in sequenza e, su timeout, send_request riprovava 10x con sleep 3s (~130s per server morto). Un solo server irraggiungibile bloccava l'intera operazione (impallamento UI). Soluzione: - ThreadPoolExecutor: ping e push ora in parallelo (tempo ~= server piu' lento, non la somma). Un server morto non blocca piu' gli altri. - Fast-fail per operazioni interattive (ping/info/download): max_retries=0, niente retry-storm. - Feedback live: callback on_each() aggiorna il dialog server-per-server (thread-safe via pyqtSignal di BalWaitingDialog.update). - Push transazioni: parallelo ma con retry per-server mantenuti (no perdita tx). File: - bal/core/willexecutors.py: send_request(+max_retries,+retry_sleep), get_info_task fast-fail, NEW ping_servers_parallel(), push_transactions_parallel(), DEFAULT_TIMEOUT=5. - bal/gui/qt/window.py: ping_willexecutors_task + push_transactions_to_willexecutors riscritti su helper paralleli con feedback live; fetch_will_executors_list fast-fail. - bal/core/util.py: BUGFIX get_value_amount usava in_output (bool) invece di din_output (tupla) -> TypeError. Scoperto dai test ufficiali Gitea. Test (contro il codice refactor): - pytest tests/ ufficiali: 117 core + 65 gui = 182 passed. - smoke/external_zip/windows_overflow/gui_fixes: OK. - parallel_ping_test (nuovo): 0.50s per 8 server vs ~4.00s sequenziale. - ruff: nessun nuovo problema introdotto (codice nuovo PEP8-compliant). Aggiunti i test ufficiali del repo Gitea + REPORT_NETWORKING_PARALLELO.md. --- REPORT_NETWORKING_PARALLELO.md | 164 ++++++++++++ bal/core/util.py | 2 +- bal/core/willexecutors.py | 143 +++++++++- bal/gui/qt/window.py | 145 ++++++---- tests/parallel_ping_test.py | 130 +++++++++ tests/test_core_heirs.py | 266 +++++++++++++++++++ tests/test_core_heirs_extra.py | 182 +++++++++++++ tests/test_core_plugin_base.py | 259 ++++++++++++++++++ tests/test_core_util.py | 470 +++++++++++++++++++++++++++++++++ tests/test_core_will.py | 272 +++++++++++++++++++ tests/test_core_will_extra.py | 167 ++++++++++++ tests/test_gui_calendar.py | 142 ++++++++++ tests/test_gui_common.py | 102 +++++++ tests/test_gui_theme.py | 100 +++++++ tests/test_gui_widgets.py | 255 ++++++++++++++++++ tests/test_gui_window_utils.py | 103 ++++++++ 16 files changed, 2843 insertions(+), 59 deletions(-) create mode 100644 REPORT_NETWORKING_PARALLELO.md create mode 100644 tests/parallel_ping_test.py create mode 100644 tests/test_core_heirs.py create mode 100644 tests/test_core_heirs_extra.py create mode 100644 tests/test_core_plugin_base.py create mode 100644 tests/test_core_util.py create mode 100644 tests/test_core_will.py create mode 100644 tests/test_core_will_extra.py create mode 100644 tests/test_gui_calendar.py create mode 100644 tests/test_gui_common.py create mode 100644 tests/test_gui_theme.py create mode 100644 tests/test_gui_widgets.py create mode 100644 tests/test_gui_window_utils.py diff --git a/REPORT_NETWORKING_PARALLELO.md b/REPORT_NETWORKING_PARALLELO.md new file mode 100644 index 0000000..6cbfbc0 --- /dev/null +++ b/REPORT_NETWORKING_PARALLELO.md @@ -0,0 +1,164 @@ +# Report tecnico — Networking parallelo (anti-freeze Will-Executor) + +**Destinatario:** programmatore esterno / manutentore del plugin +**Autore:** refactoring AI (lavoro su GitHub `Bitcoin-after-life/test`) +**Data:** 2026-06-15 +**Branch:** `feature/networking-parallelo` +**Repository Gitea privato `kaibot/bal-plugin-ai`: NON modificato** (per richiesta esplicita). + +--- + +## 1. Problema + +Quando il plugin contatta i server Will-Executor (invio transazioni, ping/aggiornamento +eredità, download lista), lo fa **in sequenza**. Se un server non risponde, il thread +resta bloccato sui timeout di connessione e, peggio, sui **retry**: + +- `send_request` riprovava fino a **10 volte** con `time.sleep(3)` ad ogni timeout + → circa **130 secondi per ogni server irraggiungibile**, sommati uno dopo l'altro. + +Conseguenze: +- Con pochi server già si avverte; con **20 server** diventa ingestibile. +- L'utente vede "Rimani in attesa — Non risponde" senza capire cosa succede. +- Un singolo server morto blocca l'intera operazione. + +--- + +## 2. Soluzione (in sintesi) + +1. **Parallelismo** con `ThreadPoolExecutor`: i server vengono contattati + contemporaneamente. Il tempo totale ≈ server **più lento**, non la **somma**. +2. **Fast-fail** per le operazioni interattive (ping/info/download): niente retry-storm, + un solo timeout breve e il server viene marcato "KO". +3. **Feedback live**: callback `on_each(...)` che aggiorna la finestra di attesa + server-per-server, in modo thread-safe. +4. **Push transazioni**: parallelo, ma mantiene i retry per ogni singolo server + (una transazione reale non deve andare persa per un hiccup transitorio). + +### Perché è thread-safe +`Network.send_http_on_proxy()` usa `asyncio.run_coroutine_threadsafe(coro, loop)` e poi +`coro.result()`: ogni chiamata schedula la propria coroutine sullo stesso loop asyncio +condiviso di Electrum e blocca **solo il proprio worker thread**. Più chiamate +concorrenti sono quindi sicure → `ThreadPoolExecutor` dà vero parallelismo. + +Gli aggiornamenti UI passano per `BalWaitingDialog.update()` che emette un +`pyqtSignal` → marshalling automatico sul thread GUI. I callback dai worker thread +possono quindi aggiornare il dialog in sicurezza. + +--- + +## 3. File modificati (tutto su GitHub `Bitcoin-after-life/test`, branch `feature/networking-parallelo`) + +### 3.1 `bal/core/willexecutors.py` + +**`send_request(...)`** — aggiunti due parametri keyword-only: +```python +def send_request(method, url, data=None, *, timeout=10, handle_response=None, + count_reply=0, max_retries=10, retry_sleep=3): +``` +- `max_retries` / `retry_sleep` controllano i retry sui timeout. +- **Default invariato** (`10` / `3s`) → il push critico mantiene il comportamento storico. +- Le operazioni interattive passano `max_retries=0` → fast-fail. + +**`get_info_task(...)`** — fast-fail di default: +```python +def get_info_task(url, willexecutor, *, timeout=DEFAULT_TIMEOUT, + max_retries=0, retry_sleep=0): +``` +- Se la risposta non è un `dict` (timeout/vuoto) → `status="KO"`. + +**NUOVO `ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8, timeout=DEFAULT_TIMEOUT)`** +- `ThreadPoolExecutor` + `as_completed`; un worker per server (`_ping_one`). +- Muta `willexecutors` in place (come il vecchio `ping_servers`). +- Invoca `on_each(url, we, ok)` man mano che arrivano i risultati. +- Un worker che esplode non blocca gli altri (try/except difensivo). + +**NUOVO `push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8)`** +- Push in parallelo solo verso le voci con chiave `"txs"`. +- Ogni server mantiene i propri retry (`push_transactions_to_willexecutor`). +- `on_each(url, we, ok, exc)`; raccoglie `AlreadyPresentException` separatamente. +- Ritorna `{url: (ok, exc)}`. + +**`DEFAULT_TIMEOUT = 5`** (costante a livello di modulo). + +### 3.2 `bal/gui/qt/window.py` + +- **`ping_willexecutors_task(self, wes)`** riscritto su `ping_servers_parallel(...)` + con feedback live (set `pinged`/`failed`, `get_title()` mostra "Ok"/"Ko"/"waiting..."). +- **`push_transactions_to_willexecutors(self, force=False)`** riscritto su + `push_transactions_parallel(...)`. `on_each` fa book-keeping + update UI thread-safe; + i server "already present" sono raccolti in `already_present[]` e il loro + `check_transaction` viene eseguito dopo, nel task thread (logica di check originale intatta). +- **`fetch_will_executors_list(...)`** download fast-fail: + `send_request("get", url, timeout=10, max_retries=1, retry_sleep=1)`. + +### 3.3 `bal/core/util.py` — BUGFIX (regressione pre-esistente) + +In `get_value_amount` (riga 324) era stato erroneamente usato `Util.in_output(...)` +(ritorna `bool`) al posto di `Util.din_output(...)` (ritorna la tupla +`(same_amount, same_address)`), causando: +``` +TypeError: cannot unpack non-iterable bool object +``` +**Corretto** ripristinando `din_output`. Bug scoperto eseguendo i test ufficiali del +repo Gitea (`tests/test_core_util.py::test_get_value_amount`). + +--- + +## 4. Verifica (ruff + test ufficiali) + +### 4.1 ruff (lint / PEP8) +- `ruff check` sul codice nuovo: **nessun nuovo problema** introdotto + (i `F403/F405/F401` presenti derivano dal pattern `from .common import *` + dell'originale; conteggio identico HEAD vs working tree: 121 = 121). +- Le funzioni parallele nuove rispettano il limite di 88 caratteri (0 `E501`). +- `ruff check tests/parallel_ping_test.py` → **All checks passed**. + +### 4.2 Test ufficiali del repo Gitea `kaibot/bal-plugin-ai/tests` +Eseguiti contro il codice refactorizzato (con le modifiche networking): + +| Suite | Esito | +|-------|-------| +| `test_core_*` (pytest) | **117 passed** | +| `test_gui_*` (pytest) | **65 passed** | +| `smoke_test.py` | OK | +| `external_zip_test.py` | OK | +| `windows_overflow_test.py` | OK | +| `gui_fixes_test.py` | OK | +| `parallel_ping_test.py` (nuovo) | OK — `0.50s` per 8 server (sequenziale ~`4.00s`) | + +Comandi (come da README): +```bash +QT_QPA_PLATFORM=offscreen PYTHONPATH= \ + python3 -m pytest tests/ -q +QT_QPA_PLATFORM=offscreen PYTHONPATH= \ + python3 tests/smoke_test.py electrum.plugins.bal +QT_QPA_PLATFORM=offscreen PYTHONPATH= \ + python3 tests/external_zip_test.py bal-electrum-plugin.zip +``` + +--- + +## 5. Note di integrazione / rischi + +- **Nessuna modifica al protocollo server**: solo il *come* (parallelo) e il *quando* + (retry) delle chiamate cambia, non i payload. +- **Push transazioni**: i retry per-server sono mantenuti apposta, per non perdere + una transazione reale per un hiccup. Solo il ping/info/download usa fast-fail. +- **`max_workers=8`** è prudente; con molti server (es. 20) si può alzare, ma 8 + worker già abbattono il tempo totale al server più lento. +- **Thread/UI**: tutti gli aggiornamenti UI dai worker passano per + `BalWaitingDialog.update()` (pyqtSignal) → safe. Non toccare quel canale. +- **Compatibilità**: firme retro-compatibili (i nuovi parametri sono keyword-only + con default che preservano il vecchio comportamento). + +--- + +## 6. Come provare + +1. Installare lo zip `bal-electrum-plugin.zip` (Tools → Plugins → install from file). +2. Configurare più Will-Executor, includendone **almeno uno irraggiungibile**. +3. Lanciare invio transazioni / ping: il dialog mostra lo stato server-per-server + e **non resta più bloccato** sul server morto. + +SHA-256 dello zip stampato da `build_zip.py` a fine build (verificare l'integrità). diff --git a/bal/core/util.py b/bal/core/util.py index 2ff6749..32d7ec1 100644 --- a/bal/core/util.py +++ b/bal/core/util.py @@ -321,7 +321,7 @@ class Util: value_amount = 0 for outa in outputsa: - same_amount, same_address = Util.in_output(outa, txb.outputs()) + same_amount, same_address = Util.din_output(outa, txb.outputs()) if not (same_amount or same_address): return False if same_amount and same_address: diff --git a/bal/core/willexecutors.py b/bal/core/willexecutors.py index e025ac8..4231766 100644 --- a/bal/core/willexecutors.py +++ b/bal/core/willexecutors.py @@ -145,8 +145,21 @@ class Willexecutors: @staticmethod def send_request( - method, url, data=None, *, timeout=10, handle_response=None, count_reply=0 + method, url, data=None, *, timeout=10, handle_response=None, count_reply=0, + max_retries=10, retry_sleep=3, ): + """Send an HTTP request to a will-executor server. + + ``max_retries`` / ``retry_sleep`` control the timeout-retry behaviour: + + * For *critical* operations (pushing inheritance transactions) the + historical default of up to 10 retries with a 3s back-off is kept, so + a transient network hiccup does not lose a transaction. + * For *interactive* operations (ping / info / list download) callers + should pass ``max_retries=0`` so a dead server fails fast (one short + timeout) instead of blocking the UI for minutes. See + :meth:`ping_servers_parallel`. + """ network = Network.get_instance() if not network: raise Exception("You are offline.") @@ -178,9 +191,12 @@ class Willexecutors: else: raise Exception(f"unexpected {method=!r}") except TimeoutError: - if count_reply < 10: - _logger.debug(f"timeout({count_reply}) error: retry in 3 sec...") - time.sleep(3) + if count_reply < max_retries: + _logger.debug( + f"timeout({count_reply}) error: retry in {retry_sleep} sec..." + ) + if retry_sleep: + time.sleep(retry_sleep) return Willexecutors.send_request( method, url, @@ -188,6 +204,8 @@ class Willexecutors: timeout=timeout, handle_response=handle_response, count_reply=count_reply + 1, + max_retries=max_retries, + retry_sleep=retry_sleep, ) else: _logger.debug(f"Too many timeouts: {count_reply}") @@ -254,18 +272,28 @@ class Willexecutors: Willexecutors.get_info_task(url, we) @staticmethod - def get_info_task(url, willexecutor): + def get_info_task(url, willexecutor, *, timeout=DEFAULT_TIMEOUT, + max_retries=0, retry_sleep=0): w = None try: _logger.info("GETINFO_WILLEXECUTOR") _logger.debug(url) - w = Willexecutors.send_request("get", url + "/" + chainname + "/info") + # Fast-fail by default (max_retries=0): a dead server returns after a + # single short timeout instead of retrying 10x with sleeps, which + # used to freeze the UI for minutes per unreachable server. + w = Willexecutors.send_request( + "get", url + "/" + chainname + "/info", + timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, + ) if isinstance(w, dict): willexecutor["url"] = url willexecutor["status"] = 200 willexecutor["base_fee"] = w["base_fee"] willexecutor["address"] = w["address"] willexecutor["info"] = w["info"] + else: + # No dict reply (timeout / empty) -> mark as unreachable. + willexecutor["status"] = "KO" _logger.debug(f"response_data {w}") except Exception as e: _logger.error(f"error {e} contacting {url}: {w}") @@ -274,6 +302,109 @@ class Willexecutors: willexecutor["last_update"] = datetime.now().timestamp() return willexecutor + @staticmethod + def ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8, + timeout=DEFAULT_TIMEOUT): + """Ping every will-executor concurrently and report results as they + arrive. + + Network requests run in a thread pool: each ``send_http_on_proxy`` call + schedules its coroutine on Electrum's shared asyncio loop and blocks + only its *own* worker thread, so the total wall-clock time is roughly + that of the slowest server rather than the *sum* of all of them. A + single dead server can no longer stall the whole batch. + + Args: + willexecutors: ``{url: we_dict}`` mapping (mutated in place with the + ping result, exactly like the old sequential ``ping_servers``). + on_each: optional ``callback(url, we_dict, ok: bool)`` invoked from a + worker thread each time a server answers (or fails), so the GUI + can update its list live. Must be thread-safe / marshalled to + the GUI thread by the caller. + max_workers: maximum number of concurrent pings. + timeout: per-request timeout in seconds (fast-fail, no retries). + + Returns: + The same ``willexecutors`` mapping, updated in place. + """ + from concurrent.futures import ThreadPoolExecutor, as_completed + + items = list(willexecutors.items()) + if not items: + return willexecutors + + def _ping_one(url, we): + we = Willexecutors.get_info_task( + url, we, timeout=timeout, max_retries=0, retry_sleep=0 + ) + ok = we.get("status") == 200 + return url, we, ok + + workers = max(1, min(max_workers, len(items))) + with ThreadPoolExecutor(max_workers=workers, + thread_name_prefix="bal-ping") as pool: + futures = [pool.submit(_ping_one, url, we) for url, we in items] + for fut in as_completed(futures): + try: + url, we, ok = fut.result() + except Exception as e: # defensive: never let one server crash all + _logger.error(f"ping_servers_parallel worker error: {e}") + continue + willexecutors[url] = we + if on_each is not None: + try: + on_each(url, we, ok) + except Exception as cb_err: + _logger.error(f"ping on_each callback error: {cb_err}") + return willexecutors + + @staticmethod + def push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8): + """Push transactions to multiple will-executors concurrently. + + Like :meth:`ping_servers_parallel` but for the ``pushtxs`` operation. + Each server keeps the historical retry behaviour of + :meth:`push_transactions_to_willexecutor` (which is important so a real + transaction is not lost to a transient hiccup), but the servers are now + contacted in parallel instead of one-after-another, and results are + reported via ``on_each(url, we_dict, ok, exc)`` as they complete. + + Returns ``{url: (ok, exception_or_None)}``. + """ + from concurrent.futures import ThreadPoolExecutor, as_completed + + targets = [(url, we) for url, we in willexecutors.items() if "txs" in we] + results = {} + if not targets: + return results + + def _push_one(url, we): + try: + ok = Willexecutors.push_transactions_to_willexecutor(we) + return url, we, ok, None + except Willexecutors.AlreadyPresentException as ape: + return url, we, False, ape + except Exception as e: + return url, we, False, e + + workers = max(1, min(max_workers, len(targets))) + with ThreadPoolExecutor(max_workers=workers, + thread_name_prefix="bal-push") as pool: + futures = [pool.submit(_push_one, url, we) for url, we in targets] + for fut in as_completed(futures): + try: + url, we, ok, exc = fut.result() + except Exception as e: + _logger.error(f"push_transactions_parallel worker error: {e}") + continue + results[url] = (ok, exc) + if on_each is not None: + try: + on_each(url, we, ok, exc) + except Exception as cb_err: + _logger.error(f"push on_each callback error: {cb_err}") + return results + @staticmethod def initialize_willexecutor(willexecutor, url, status=None, old_willexecutor=None): old_willexecutor=old_willexecutor if old_willexecutor is not None else {} diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 5a2005c..cfca79e 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -797,48 +797,72 @@ class BalWindow: msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n" return msg - error = False + # Initialise statuses + show the list immediately. for url in willexecutors: - if self.waiting_dialog._stopping: - return - willexecutor = willexecutors[url] + willexecutors[url].setdefault("broadcast_status", _("waiting...")) + try: self.waiting_dialog.update(getMsg(willexecutors)) - if "txs" in willexecutor: - try: - if Willexecutors.push_transactions_to_willexecutor( - willexecutors[url] - ): - for wid in willexecutors[url]["txsids"]: - self.willitems[wid].set_status("PUSHED", True) - willexecutors[url]["broadcast_status"] = _("Success") - else: - for wid in willexecutors[url]["txsids"]: - self.willitems[wid].set_status("PUSH_FAIL", True) - error = True - willexecutors[url]["broadcast_status"] = _("Failed") - del willexecutor["txs"] - except Willexecutors.AlreadyPresentException: - for wid in willexecutor["txsids"]: - if self.waiting_dialog._stopping: - return - self.waiting_dialog.update( - "checking {} - {} : {}".format( - self.willitems[wid].we["url"], wid, "Waiting" - ) - ) - w = self.willitems[wid] - w.set_check_willexecutor( - Willexecutors.check_transaction(wid, w.we["url"]) - ) - self.waiting_dialog.update( - "checked {} - {} : {}".format( - self.willitems[wid].we["url"], - wid, - self.willitems[wid].get_status("CHECKED"), - ) - ) + except Exception: + pass - if error: + error = {"flag": False} + already_present = [] + + def on_each(url, willexecutor, ok, exc): + # Runs from a worker thread. We only do book-keeping + a thread-safe + # signal-based UI update here; the heavier "already present" check + # path (which itself does network I/O) is handled below in the main + # task thread to keep the original sequential behaviour for it. + if isinstance(exc, Willexecutors.AlreadyPresentException): + already_present.append(url) + willexecutor["broadcast_status"] = _("checking...") + elif ok: + for wid in willexecutor.get("txsids", []): + self.willitems[wid].set_status("PUSHED", True) + willexecutor["broadcast_status"] = _("Success") + else: + for wid in willexecutor.get("txsids", []): + self.willitems[wid].set_status("PUSH_FAIL", True) + error["flag"] = True + willexecutor["broadcast_status"] = _("Failed") + willexecutor.pop("txs", None) + try: + self.waiting_dialog.update(getMsg(willexecutors)) + except Exception: + pass + + if self.waiting_dialog._stopping: + return + # Push to all servers in parallel (each server keeps its own retry + # behaviour, but a slow/dead server no longer blocks the others). + Willexecutors.push_transactions_parallel(willexecutors, on_each=on_each) + + # Handle the "already present" servers: verify each stored tx. This + # keeps the exact original check logic, just executed after the parallel + # push has identified which servers need it. + for url in already_present: + willexecutor = willexecutors[url] + for wid in willexecutor.get("txsids", []): + if self.waiting_dialog._stopping: + return + self.waiting_dialog.update( + "checking {} - {} : {}".format( + self.willitems[wid].we["url"], wid, "Waiting" + ) + ) + w = self.willitems[wid] + w.set_check_willexecutor( + Willexecutors.check_transaction(wid, w.we["url"]) + ) + self.waiting_dialog.update( + "checked {} - {} : {}".format( + self.willitems[wid].we["url"], + wid, + self.willitems[wid].get_status("CHECKED"), + ) + ) + + if error["flag"]: return True def export_json_file(self, path): @@ -941,7 +965,13 @@ class BalWindow: for url in candidates: _logger.info(f"fetch_will_executors_list: trying {url}") try: - resp = Willexecutors.send_request("get", url, timeout=20) + # Fast-fail with a couple of short retries instead of the + # default 10x/3s storm: if the user's connection is flaky we + # want to fall back to the next URL (and then show the simple + # error message) quickly, not freeze for minutes. + resp = Willexecutors.send_request( + "get", url, timeout=10, max_retries=1, retry_sleep=1, + ) _logger.info( f"fetch_will_executors_list: resp type={type(resp).__name__} " f"len={len(resp) if hasattr(resp, '__len__') else 'n/a'}" @@ -997,8 +1027,13 @@ class BalWindow: def ping_willexecutors_task(self, wes): _logger.info("ping willexecutots task") - pinged = [] - failed = [] + # Track per-url state for the live status text. Servers are contacted + # in parallel (see Willexecutors.ping_servers_parallel), so a single + # unreachable server no longer blocks all the others: the whole batch + # now takes about as long as the slowest server instead of the sum of + # every server's (possibly timing-out) request. + pinged = set() + failed = set() def get_title(): msg = _("Ping Will-Executors:") @@ -1006,26 +1041,32 @@ class BalWindow: for url in wes: urlstr = "{:<50}: ".format(url[:50]) if url in pinged: - urlstr += "Ok" + urlstr += _("Ok") elif url in failed: - urlstr += "Ko" + urlstr += _("Ko") else: - urlstr += "--" + urlstr += _("waiting...") urlstr += "\n" msg += urlstr - return msg - for url, we in wes.items(): + def on_each(url, we, ok): + if ok: + pinged.add(url) + else: + failed.add(url) try: self.waiting_dialog.update(get_title()) except Exception: pass - wes[url] = Willexecutors.get_info_task(url, we) - if wes[url]["status"] == "KO": - failed.append(url) - else: - pinged.append(url) + + # Show the initial "waiting..." list immediately. + try: + self.waiting_dialog.update(get_title()) + except Exception: + pass + + Willexecutors.ping_servers_parallel(wes, on_each=on_each) def ping_willexecutors(self, wes, fn_on_success, fn_on_failure=None): def on_success(result): diff --git a/tests/parallel_ping_test.py b/tests/parallel_ping_test.py new file mode 100644 index 0000000..23bbe61 --- /dev/null +++ b/tests/parallel_ping_test.py @@ -0,0 +1,130 @@ +""" +Regression / behaviour test for the parallel will-executor networking. + +Before this change, pinging / pushing to will-executor servers was done in a +sequential loop where every unreachable server blocked the whole batch for the +full timeout (plus up to 10 retries with 3s sleeps). With N servers the total +wall-clock time was the *sum* of every server's time, so a couple of dead +servers froze the GUI ("Non risponde") for minutes. + +This test patches Willexecutors.get_info_task / push_transactions_to_willexecutor +with slow stubs and asserts that: + * ping_servers_parallel contacts servers concurrently (total time ~= the + slowest server, NOT the sum), and + * the on_each callback is invoked once per server with the right ok flag, + * push_transactions_parallel behaves the same way. + +Run with: + QT_QPA_PLATFORM=offscreen PYTHONPATH= \ + python3 tests/parallel_ping_test.py +""" +import importlib +import sys +import time + +PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.bal" + +SLOW = 0.5 # seconds each simulated server takes to answer +N = 8 # number of servers + + +def main(): + we_mod = importlib.import_module(f"{PKG}.core.willexecutors") + W = we_mod.Willexecutors + + # ---- 1) ping_servers_parallel: time ~= slowest, not sum ---- + def slow_get_info(url, we, **kwargs): + time.sleep(SLOW) + # half the servers "fail" + if "dead" in url: + we["status"] = "KO" + else: + we["status"] = 200 + return we + + orig_get_info = W.get_info_task + W.get_info_task = staticmethod(slow_get_info) + try: + wes = {} + for i in range(N): + kind = "dead" if i % 2 else "ok" + wes[f"https://{kind}-{i}.example"] = {} + + seen = [] + + def on_each(url, we, ok): + seen.append((url, ok)) + + start = time.time() + W.ping_servers_parallel(wes, on_each=on_each, max_workers=N) + elapsed = time.time() - start + + # Sequential would take ~ N * SLOW. Parallel must be far less. + sequential = N * SLOW + assert elapsed < sequential * 0.6, ( + f"not parallel: {elapsed:.2f}s vs sequential {sequential:.2f}s") + print(f"[OK] ping parallel: {elapsed:.2f}s for {N} servers " + f"(sequential would be ~{sequential:.2f}s)") + + # callback fired once per server, with correct ok flags + assert len(seen) == N, seen + for url, ok in seen: + assert ok == ("ok" in url), (url, ok) + print("[OK] on_each fired once per server with correct ok flag") + + # results written back into the mapping + for url, we in wes.items(): + if "ok" in url: + assert we["status"] == 200, (url, we) + else: + assert we["status"] == "KO", (url, we) + print("[OK] ping results written back into the willexecutors mapping") + finally: + W.get_info_task = orig_get_info + + # ---- 2) push_transactions_parallel: time ~= slowest, not sum ---- + def slow_push(we, **kwargs): + time.sleep(SLOW) + return "fail" not in we["url"] + + orig_push = W.push_transactions_to_willexecutor + W.push_transactions_to_willexecutor = staticmethod(slow_push) + try: + wes = {} + for i in range(N): + kind = "fail" if i % 2 else "good" + wes[f"https://{kind}-{i}.example"] = { + "url": f"https://{kind}-{i}.example", + "txs": "deadbeef", + "txsids": [f"id{i}"], + } + + pushed = [] + + def on_each_push(url, we, ok, exc): + pushed.append((url, ok)) + + start = time.time() + results = W.push_transactions_parallel(wes, on_each=on_each_push, + max_workers=N) + elapsed = time.time() - start + + sequential = N * SLOW + assert elapsed < sequential * 0.6, ( + f"push not parallel: {elapsed:.2f}s vs {sequential:.2f}s") + print(f"[OK] push parallel: {elapsed:.2f}s for {N} servers " + f"(sequential would be ~{sequential:.2f}s)") + + assert len(results) == N, results + for url, (ok, exc) in results.items(): + assert ok == ("good" in url), (url, ok) + print("[OK] push results correct for every server") + finally: + W.push_transactions_to_willexecutor = orig_push + + print(f"\n[OK] parallel networking test passed for package {PKG!r}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_core_heirs.py b/tests/test_core_heirs.py new file mode 100644 index 0000000..f7ea6ab --- /dev/null +++ b/tests/test_core_heirs.py @@ -0,0 +1,266 @@ +""" +Tests for ``bal.core.heirs``. + +Covers constants, OP_RETURN helper, exceptions, validation methods, +and the Heirs model where testable without a live wallet. + +Run: + source electrum/env/bin/activate + python3 tests/test_core_heirs.py +""" + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +from bal.core.heirs import ( + HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT, + HEIR_DUST_AMOUNT, TRANSACTION_LABEL, + create_op_return_script, + AliasNotFoundException, + NotAnAddress, AmountNotValid, LocktimeNotValid, + HeirExpiredException, HeirAmountIsDustException, + NoHeirsException, WillExecutorFeeException, + BalanceTooLowException, + Heirs, +) + + +# ------------------------------------------------------------------ # +# Constants +# ------------------------------------------------------------------ # + +def test_constants(): + assert HEIR_ADDRESS == 0 + assert HEIR_AMOUNT == 1 + assert HEIR_LOCKTIME == 2 + assert HEIR_REAL_AMOUNT == 3 + assert HEIR_DUST_AMOUNT == 4 + assert TRANSACTION_LABEL == "inheritance transaction" + + +# ------------------------------------------------------------------ # +# create_op_return_script +# ------------------------------------------------------------------ # + +def test_op_return_short(): + script = create_op_return_script("42414c") # "BAL" in hex + assert isinstance(script, bytes) + assert script[0] == 0x6a # OP_RETURN + assert len(script) > 3 + + +def test_op_return_long(): + # 76 bytes of data (between 75 and 80) + long_hex = "ab" * 76 + script = create_op_return_script(long_hex) + assert isinstance(script, bytes) + assert script[0] == 0x6a # OP_RETURN + assert script[1] == 0x4c # OP_PUSHDATA1 + + +def test_op_return_empty(): + script = create_op_return_script("") + assert isinstance(script, bytes) + assert len(script) == 2 # OP_RETURN + 0x00 + + +def test_op_return_too_big(): + try: + create_op_return_script("ab" * 81) # 81 bytes > max 80 + assert False, "expected ValueError" + except ValueError: + pass + + +# ------------------------------------------------------------------ # +# Heirs class (without wallet) +# ------------------------------------------------------------------ # + +class FakeDB: + def __init__(self, data=None): + self._data = data or {} + def get(self, key, default=None): + return self._data.get(key, default) + def put(self, key, value): + self._data[key] = value + + +class FakeWallet: + def __init__(self): + self.db = FakeDB({"heirs": { + "alice": ["addr1", "50%", "30d"], + "bob": ["addr2", "10000", "90d"], + }}) + self._dust = 500 + + +def test_heirs_init_from_db(): + wallet = FakeWallet() + heirs = Heirs(wallet) + assert "alice" in heirs + assert "bob" in heirs + assert len(heirs) == 2 + + +def test_heirs_init_empty(): + wallet = FakeWallet() + wallet.db = FakeDB({}) + heirs = Heirs(wallet) + assert len(heirs) == 0 + + +def test_heirs_setitem_saves(): + wallet = FakeWallet() + heirs = Heirs(wallet) + assert len(heirs) == 2 + heirs["charlie"] = ["addr3", "20000", "30d"] + assert "charlie" in heirs + assert "charlie" in wallet.db._data.get("heirs", {}) + + +def test_heirs_pop(): + wallet = FakeWallet() + heirs = Heirs(wallet) + result = heirs.pop("alice") + assert result is not None + assert "alice" not in heirs + assert heirs.pop("nonexistent") is None + + +def test_heirs_check_locktime(): + wallet = FakeWallet() + heirs = Heirs(wallet) + assert heirs.check_locktime() is False + + +def test_heirs_get_locktimes(): + wallet = FakeWallet() + heirs = Heirs(wallet) + # all heirs have locktime "30d" or "90d" -> timestamps > 0 + locktimes = heirs.get_locktimes(0) + assert len(locktimes) >= 1 + for lt in locktimes: + assert lt > 0 + + +def test_heirs_amount_to_float(): + wallet = FakeWallet() + heirs = Heirs(wallet) + + # plain number + assert heirs.amount_to_float(100.5) == 100.5 + # string with percent + assert heirs.amount_to_float("50%") == 50.0 + # invalid -> 0.0 + assert heirs.amount_to_float("notanumber") == 0.0 + + +# ------------------------------------------------------------------ # +# Validation (static methods) +# ------------------------------------------------------------------ # + +def test_validate_address_invalid(): + # This requires a real network, so just verify the exception class + assert issubclass(NotAnAddress, ValueError) + + +def test_validate_amount(): + # Valid percentage + result = Heirs.validate_amount("50%") + assert result == "50%" + + # Valid number + result = Heirs.validate_amount("0.01") + assert result == "0.01" + + # Invalid + try: + Heirs.validate_amount("0.000000001") + assert False, "expected AmountNotValid" + except AmountNotValid: + pass + + try: + Heirs.validate_amount("-1") + assert False, "expected AmountNotValid" + except AmountNotValid: + pass + + +def test_validate_locktime(): + # Valid relative + result = Heirs.validate_locktime("30d") + assert result == "30d" + + result = Heirs.validate_locktime("1y") + assert result == "1y" + + # Empty string returns as-is (no timestamp_to_check, so no validation) + result = Heirs.validate_locktime("") + assert result == "" + + +def test_validate_locktime_expired(): + """A locktime in the past should raise LocktimeNotValid (wrapping HeirExpiredException)""" + import time + past = int(time.time()) - 86400 # yesterday + try: + Heirs.validate_locktime(str(past), timestamp_to_check=past + 1) + assert False, "expected LocktimeNotValid" + except LocktimeNotValid: + pass + + +# ------------------------------------------------------------------ # +# Exceptions +# ------------------------------------------------------------------ # + +def test_alias_not_found(): + exc = AliasNotFoundException() + assert isinstance(exc, Exception) + + +def test_heir_amount_is_dust(): + exc = HeirAmountIsDustException() + assert isinstance(exc, Exception) + + +def test_no_heirs_exception(): + exc = NoHeirsException() + assert isinstance(exc, Exception) + + +def test_will_executor_fee_exception(): + we = {"url": "https://we.example", "base_fee": 1000} + exc = WillExecutorFeeException(we) + assert "WillExecutorFeeException" in str(exc) + assert "1000" in str(exc) + + +def test_balance_too_low_exception(): + exc = BalanceTooLowException(100, 500, 50) + assert "100" in str(exc) + assert "500" in str(exc) + assert "50" in str(exc) + + +# ------------------------------------------------------------------ # +# Heirs static validation (_validate) +# ------------------------------------------------------------------ # + +def test_validate_removes_invalid(): + data = { + "alice": ["addr1", "50%", "30d"], + "bad": ["not_an_address!", "50%", "30d"], + } + result = Heirs._validate(dict(data)) + assert "alice" in result or True # may or may not pass address check + + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print(f"[OK] All heirs tests passed") diff --git a/tests/test_core_heirs_extra.py b/tests/test_core_heirs_extra.py new file mode 100644 index 0000000..79b909a --- /dev/null +++ b/tests/test_core_heirs_extra.py @@ -0,0 +1,182 @@ +""" +Tests for wallet/db-dependent methods in ``bal.core.heirs``. + +Uses mocking to simulate Electrum wallet, db, and bitcoin module. + +Run: + source electrum/env/bin/activate + QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py +""" + +import sys +import os +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +from bal.core.heirs import ( + Heirs, create_op_return_script, reduce_outputs, + HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT, +) +from bal.core.willexecutors import Willexecutors + + +# ------------------------------------------------------------------ # +# Heirs db-dependent methods +# ------------------------------------------------------------------ # + +def test_heirs_init_from_db(): + wallet = MagicMock() + wallet.db.get .return_value = {"alice": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 5000, "30d"]} + h = Heirs(wallet) + assert "alice" in h + + +def test_heirs_init_empty_db(): + wallet = MagicMock() + wallet.db.get.return_value = {} + h = Heirs(wallet) + assert len(h) == 0 + + +def test_heirs_save(): + wallet = MagicMock() + wallet.db.get.return_value = {} + h = Heirs(wallet) + h["bob"] = ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 3000, "60d"] + wallet.db.put.assert_called() + + +def test_heirs_pop_saves(): + wallet = MagicMock() + wallet.db.get.return_value = {"bob": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 3000, "60d"]} + h = Heirs(wallet) + h.pop("bob") + wallet.db.put.assert_called() + + +# ------------------------------------------------------------------ # +# Heirs wallet-dependent methods +# ------------------------------------------------------------------ # + +def test_heirs_normalize_perc(): + wallet = MagicMock() + wallet.dust_threshold.return_value = 500 + heir_list = {"a": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", "50%", "30d"]} + h = Heirs.__new__(Heirs) + h._Heirs__normal_perc = True + h.update(heir_list) + h.normalize_perc(heir_list, 100000, 100000, wallet) + # "50%" of 100000 = 50000 → above dust threshold, value stays + assert h["a"][HEIR_AMOUNT] == "50%" + + +def test_heirs_prepare_lists(): + wallet = MagicMock() + wallet.dust_threshold.return_value = 500 + h = Heirs.__new__(Heirs) + h.update({"a": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 5000, "30d"]}) + result, onlyfixed = h.prepare_lists(100000, 100, wallet) + assert len(result) > 0 + assert isinstance(result, dict) + + +# ------------------------------------------------------------------ # +# Heirs static methods (pure but use Electrum constants) +# ------------------------------------------------------------------ # + +def test_validate_address_valid(): + with patch("bal.core.heirs.bitcoin.is_address", return_value=True): + result = Heirs.validate_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") + assert result == "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + + +def test_validate_address_invalid(): + with patch("bal.core.heirs.bitcoin.is_address", return_value=False): + from bal.core.heirs import NotAnAddress + try: + Heirs.validate_address("bad") + assert False, "should have raised" + except NotAnAddress: + pass + + +# ------------------------------------------------------------------ # +# create_op_return_script (pure) +# ------------------------------------------------------------------ # + +def test_create_op_return_script(): + data = "42414c" # "BAL" in hex + script = create_op_return_script(data) + assert script.startswith(b"\x6a") + + +# ------------------------------------------------------------------ # +# reduce_outputs (pure) +# ------------------------------------------------------------------ # + +def test_reduce_outputs_noop(): + outputs = [("bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 1000), ("bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 2000)] + reduce_outputs(5000, 5000, 100, outputs) # no crash, no modification + + +def test_reduce_outputs_reduces(): + class FakeOut: + def __init__(self, v): + self.value = v + outputs = [FakeOut(1000), FakeOut(2000)] + reduce_outputs(100, 5000, 10, outputs) + assert outputs[0].value < 1000 + + +# ------------------------------------------------------------------ # +# Willexecutors (pure / light mocking) +# ------------------------------------------------------------------ # + +def test_willexecutors_compute_id(): + wid = Willexecutors.compute_id({"url": "example.com", "chain": "mainnet"}) + assert isinstance(wid, str) + assert "example.com" in wid + + +def test_willexecutors_is_selected(): + assert Willexecutors.is_selected({}) is False + data = {"url": "x"} + assert Willexecutors.is_selected(data) is False + assert Willexecutors.is_selected(data, True) is True + assert data.get("selected") is True + + +def test_willexecutors_get_we_url_from_response(): + class FakeResp: + url = "http://example.com/willexecutor" + result = Willexecutors.get_we_url_from_response(FakeResp()) + # With 4 path segments, result is first 2 segments joined + assert result == "http:/" + # More realistic: a deeper URL returns the host part + class FakeResp2: + url = "http://example.com/api/v1/endpoint" + result2 = Willexecutors.get_we_url_from_response(FakeResp2()) + assert "example.com" in result2 + + +def test_willexecutors_initialize_willexecutor(): + we = {} + Willexecutors.initialize_willexecutor(we, "http://example.com") + assert len(we) > 0 + + +def test_willexecutors_get_willexecutor_transactions_empty(): + assert Willexecutors.get_willexecutor_transactions({}) == {} + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print("[OK] All heirs/willexecutors extra tests passed") diff --git a/tests/test_core_plugin_base.py b/tests/test_core_plugin_base.py new file mode 100644 index 0000000..7a83007 --- /dev/null +++ b/tests/test_core_plugin_base.py @@ -0,0 +1,259 @@ +""" +Comprehensive tests for ``bal.core.plugin_base``. + +Covers BalTimestamp, BalConfig, and BalPlugin static helpers. + +Run: + source electrum/env/bin/activate + python3 tests/test_core_plugin_base.py +""" + +import sys +import os +import time +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +from datetime import datetime, date, timedelta +from bal.core.plugin_base import BalTimestamp, BalPlugin, BalConfig + + +# ------------------------------------------------------------------ # +# BalTimestamp +# ------------------------------------------------------------------ # + +def test_bt_create_and_str(): + bt = BalTimestamp("30d") + assert bt.unit == "d" + assert bt.value == 30 + + bt2 = BalTimestamp("1y") + assert bt2.unit == "y" + assert bt2.value == 1 + + bt3 = BalTimestamp(1700000000) + assert bt3.unit is None + assert bt3.value == 1700000000 + + bt4 = BalTimestamp("garbage") + # fallback: value=1, unit=None + assert bt4.value == 1 + assert bt4.unit is None + + bt5 = BalTimestamp(0) + assert bt5.unit is None + assert bt5.value == 0 + + bt6 = BalTimestamp("7d") + assert str(bt6) == "7d" + + bt7 = BalTimestamp("2y") + assert str(bt7) == "2y" + + # absolute timestamp str -> ISO format + bt8 = BalTimestamp(1700000000) + s = str(bt8) + assert "202" in s or "197" in s # year present + + +def test_bt_duration_to_days(): + assert BalTimestamp("30d").duration_to_days() == 30 + assert BalTimestamp("1y").duration_to_days() == 365 + assert BalTimestamp("0d").duration_to_days() == 0 + assert BalTimestamp(1700000000).duration_to_days() == 1700000000 # unit None -> raw value + + +def test_bt_to_date_absolute(): + bt = BalTimestamp(1700000000) + d = bt.to_date() + assert isinstance(d, datetime) + + # absolute with from_date (should be ignored for absolute) + d2 = bt.to_date(from_date=datetime(2020, 1, 1)) + assert d == d2 + + +def test_bt_to_date_relative(): + now = datetime.now() + + # relative days from now + bt = BalTimestamp("7d") + d = bt.to_date() + assert d.hour == 0 and d.minute == 0 # normalized to midnight + assert d > now + + # reverse (subtract days) + d_rev = bt.to_date(reverse=True) + assert d_rev < now + + # from explicit datetime + base = datetime(2025, 6, 1, 12, 0, 0) + d = bt.to_date(from_date=base) + expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0) + assert d == expected + + # from int timestamp + ts = int(base.timestamp()) + d = bt.to_date(from_date=ts) + assert d == expected + + +def test_bt_to_date_years(): + bt = BalTimestamp("1y") + d = bt.to_date() + assert d > datetime.now() + + +def test_bt_to_date_overflow(): + """Huge relative durations should not crash (clamp to INT32_MAX).""" + bt = BalTimestamp("999999999d") + d = bt.to_date() + # should not raise + assert d is not None + assert isinstance(d, datetime) + + +def test_bt_to_timestamp(): + bt = BalTimestamp("7d") + ts = bt.to_timestamp() + assert ts > time.time() + assert isinstance(ts, float) + + bt2 = BalTimestamp(1700000000) + assert abs(bt2.to_timestamp() - 1700000000) < 86400 # close to original + + +def test_bt_repr(): + assert repr(BalTimestamp("7d")) == "7d" + r = repr(BalTimestamp(1700000000)) + assert isinstance(r, str) + assert len(r) > 0 + + +def test_bt_edge_values(): + # zero timestamp + bt0 = BalTimestamp(0) + d = bt0.to_date() + assert d is not None + + # negative? (may depend on platform) + try: + bt_neg = BalTimestamp(-1) + _ = bt_neg.to_date() + except (OSError, ValueError, OverflowError): + pass # acceptable on some platforms + + +# ------------------------------------------------------------------ # +# BalTimestamp._safe_fromtimestamp +# ------------------------------------------------------------------ # + +def test_safe_fromtimestamp_normal(): + d = BalTimestamp._safe_fromtimestamp(1700000000) + assert isinstance(d, datetime) + + +def test_safe_fromtimestamp_nlocktime_max(): + """NLOCKTIME_MAX (2**32-1) must not raise even on 32-bit platforms.""" + d = BalTimestamp._safe_fromtimestamp(2**32 - 1) + assert d is not None + + +def test_safe_fromtimestamp_negative(): + """Negative timestamps should not crash.""" + d = BalTimestamp._safe_fromtimestamp(-1) + assert isinstance(d, datetime) + + +# ------------------------------------------------------------------ # +# BalConfig +# ------------------------------------------------------------------ # + +class FakeConfig: + """Minimal mock for Electrum config.""" + def __init__(self): + self._store = {} + def get(self, key, default=None): + return self._store.get(key, default) + def set_key(self, key, value, save=True): + self._store[key] = value + + +def test_balconfig_default(): + cfg = FakeConfig() + bc = BalConfig(cfg, "test_key", "default_val") + assert bc.get() == "default_val" + assert bc.get("override") == "override" + assert bc.get(None) == "default_val" + + +def test_balconfig_set(): + cfg = FakeConfig() + bc = BalConfig(cfg, "test_key", "default_val") + bc.set("stored_val") + assert cfg.get("test_key") == "stored_val" + assert bc.get() == "stored_val" + + +# ------------------------------------------------------------------ # +# BalPlugin +# ------------------------------------------------------------------ # + +def test_default_will_settings_relative(): + rel = BalPlugin.default_will_settings_relative() + assert rel["threshold"] == "30d" + assert rel["locktime"] == "1y" + + +def test_default_will_settings(): + settings = BalPlugin.default_will_settings() + assert settings["baltx_fees"] == 100 + assert "threshold" in settings + assert "locktime" in settings + # threshold/locktime should be absolute timestamps + assert isinstance(settings["threshold"], float) + assert isinstance(settings["locktime"], float) + + +def test_default_will_settings_absolute(): + abs_ = BalPlugin.default_will_settings_absolute() + assert "threshold" in abs_ + assert "locktime" in abs_ + # should be timestamps (in the future) + today = datetime.combine(date.today(), datetime.min.time()) + assert abs_["threshold"] >= today.timestamp() + assert abs_["locktime"] >= today.timestamp() + + +def test_validate_will_settings(): + # Note: passing None triggers `will_settings = []` which then fails + # on .get(). This is a latent bug — test passing a dict directly + result = BalPlugin.validate_will_settings(None, {"baltx_fees": 0}) + assert result["baltx_fees"] == 100 + + # normal settings unchanged + input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000} + result = BalPlugin.validate_will_settings(None, input_settings) + assert result["baltx_fees"] == 50 + assert result["threshold"] == 1700000000 + + +if __name__ == "__main__": + test_bt_create_and_str() + test_bt_duration_to_days() + test_bt_to_date_absolute() + test_bt_to_date_relative() + test_bt_to_date_years() + test_bt_to_date_overflow() + test_bt_to_timestamp() + test_bt_repr() + test_bt_edge_values() + test_safe_fromtimestamp_normal() + test_safe_fromtimestamp_nlocktime_max() + test_safe_fromtimestamp_negative() + test_balconfig_default() + test_balconfig_set() + test_default_will_settings_relative() + test_default_will_settings() + test_default_will_settings_absolute() + test_validate_will_settings() + print(f"[OK] All {sum(1 for k in dir() if k.startswith('test_'))} plugin_base tests passed") diff --git a/tests/test_core_util.py b/tests/test_core_util.py new file mode 100644 index 0000000..3284f75 --- /dev/null +++ b/tests/test_core_util.py @@ -0,0 +1,470 @@ +""" +Comprehensive unit tests for ``bal.core.util.Util``. + +Covers every static method — locktime helpers, amount helpers, comparison +helpers, UTXO helpers, and migration helpers — with edge cases. + +Run: + source electrum/env/bin/activate + python3 tests/test_core_util.py +""" + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +from bal.core.util import Util, LOCKTIME_THRESHOLD + + +def test_locktime_to_str(): + # timestamp above threshold -> ISO format + s = Util.locktime_to_str(1700000000) + assert "202" in s and "-" in s, f"expected ISO string, got {s!r}" + + # block height below threshold -> unchanged + assert Util.locktime_to_str(500000) == "500000" + + # string input -> unchanged + assert Util.locktime_to_str("hello") == "hello" + + # zero / edge + assert Util.locktime_to_str(0) == "0" + + +def test_str_to_locktime(): + # relative suffixes pass through + assert Util.str_to_locktime("30d") == "30d" + assert Util.str_to_locktime("1y") == "1y" + assert Util.str_to_locktime("144b") == "144b" + + # integer string -> int + assert isinstance(Util.str_to_locktime("500000"), int) + assert Util.str_to_locktime("500000") == 500000 + + # ISO date -> int timestamp + ts = Util.str_to_locktime("2025-01-01T00:00:00") + assert isinstance(ts, int) + assert ts > 0 + + +def test_parse_locktime_string(): + # plain int -> same int + assert Util.parse_locktime_string(500000) == 500000 + + # int as string -> int + assert Util.parse_locktime_string("500000") == 500000 + + # relative days -> > current timestamp + result = Util.parse_locktime_string("7d") + import time + assert result > time.time() - 86400 + + # relative years -> > current timestamp + result = Util.parse_locktime_string("1y") + assert result > time.time() + + # invalid -> 0 + assert Util.parse_locktime_string("") == 0 + assert Util.parse_locktime_string("garbage") == 0 + + +def test_int_locktime(): + assert Util.int_locktime(seconds=1) == 1 + assert Util.int_locktime(minutes=1) == 60 + assert Util.int_locktime(hours=1) == 3600 + assert Util.int_locktime(days=1) == 86400 + assert Util.int_locktime(blocks=1) == 600 + assert Util.int_locktime(days=1, blocks=1) == 86400 + 600 + assert Util.int_locktime() == 0 + + +def test_encode_decode_amount(): + dp = 8 # typical BTC decimal point + + # percentage passes through + assert Util.encode_amount("50%", dp) == "50%" + assert Util.decode_amount("50%", dp) == "50%" + + # satoshi encoding + assert Util.encode_amount("1.0", dp) == 100000000 + assert Util.encode_amount("0.5", dp) == 50000000 + + # decoding + assert Util.decode_amount(100000000, dp) == "1.00000000" + assert Util.decode_amount(50000000, dp) == "0.50000000" + + # edge + assert Util.encode_amount("abc", dp) == 0 + assert Util.decode_amount("abc", dp) == "abc" + + +def test_is_perc(): + assert Util.is_perc("50%") is True + assert Util.is_perc("100%") is True + assert Util.is_perc("0%") is True + assert Util.is_perc("100") is False + assert Util.is_perc(50) is False + assert Util.is_perc("") is False + assert Util.is_perc(None) is False + + +def test_cmp_array(): + assert Util.cmp_array([1, 2, 3], [1, 2, 3]) is True + assert Util.cmp_array([1, 2, 3], [1, 2]) is False + assert Util.cmp_array([], []) is True + assert Util.cmp_array([1], [2]) is False + assert Util.cmp_array(None, None) is False # exception path + + +def test_cmp_heir(): + heira = ["abc", 10000, 12345] + heirb = ["abc", 10000, 54321] + assert Util.cmp_heir(heira, heirb) is True # addr(0) + amount(1) match + + heirb2 = ["xyz", 10000, 12345] + assert Util.cmp_heir(heira, heirb2) is False # addr mismatch + + heirb3 = ["abc", 20000, 12345] + assert Util.cmp_heir(heira, heirb3) is False # amount mismatch + + +def test_cmp_willexecutor(): + a = {"url": "https://we.example", "address": "bc1abc", "base_fee": 1000} + b = {"url": "https://we.example", "address": "bc1abc", "base_fee": 1000} + assert Util.cmp_willexecutor(a, b) is True + + c = {"url": "https://we.other", "address": "bc1abc", "base_fee": 1000} + assert Util.cmp_willexecutor(a, c) is False + + assert Util.cmp_willexecutor(None, None) is True # None == None + assert Util.cmp_willexecutor({}, {}) is True # both empty + + +def test_search_heir_by_values(): + heirs = { + "alice": {0: "addr1", 1: 1000, 3: 500}, + "bob": {0: "addr2", 1: 2000, 3: 600}, + } + match = Util.search_heir_by_values(heirs, {0: "addr1", 3: 500}, [0, 3]) + assert match == "alice" + + no_match = Util.search_heir_by_values(heirs, {0: "addrX", 3: 500}, [0, 3]) + assert no_match is False + + assert Util.search_heir_by_values({}, {0: "x"}, [0]) is False + + +def test_cmp_heir_by_values(): + a = {0: "addr1", 1: 1000, 3: 500} + b = {0: "addr1", 1: 1000, 3: 500} + assert Util.cmp_heir_by_values(a, b, [0, 1]) is True + assert Util.cmp_heir_by_values(a, b, [0, 1, 3]) is True + + c = {0: "addr1", 1: 9999, 3: 500} + assert Util.cmp_heir_by_values(a, c, [1]) is False + + +def test_cmp_heirs_by_values(): + a = {"h1": {0: "a1", 1: 100}, "h2": {0: "a2", 1: 200}} + b = {"h3": {0: "a1", 1: 100}, "h4": {0: "a2", 1: 200}} + assert Util.cmp_heirs_by_values(a, b, [0, 1]) is True + + c = {"h1": {0: "aX", 1: 100}} + assert Util.cmp_heirs_by_values(a, c, [0, 1]) is False + + +def test_cmp_inputs(): + # Without real TxInput objects we test edge cases + assert Util.cmp_inputs([], []) is True + assert Util.cmp_inputs([1], []) is False + assert Util.cmp_inputs([], [1]) is False + + +def test_cmp_outputs(): + assert Util.cmp_outputs([], []) is True + assert Util.cmp_outputs([1], []) is False + assert Util.cmp_outputs([], [1]) is False + + +def test_cmp_txs(): + # No real Transaction objects, but edge coverage + class FakeTx: + def inputs(self): return [] + def outputs(self): return [] + a = FakeTx() + assert Util.cmp_txs(a, a) is True + + +def test_get_value_amount(): + class FakeOutput: + def __init__(self, addr, val): + self.address = addr + self.value = val + + class FakeTx: + def outputs(self): return self._outs + def __init__(self, outs): self._outs = outs + + # Shared addr+value → both same_amount and same_address → value counted + out_a = FakeOutput("bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 1000) + out_b = FakeOutput("bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 1000) + result = Util.get_value_amount(FakeTx([out_a]), FakeTx([out_b])) + assert result == 1000, f"expected 1000, got {result}" + + # Different address, same amount → same_amount only → not counted + out_c = FakeOutput("bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 1000) + result2 = Util.get_value_amount(FakeTx([out_a]), FakeTx([out_c])) + assert result2 == 0, f"expected 0, got {result2}" + + # No matching amount → returns False + out_d = FakeOutput("bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 999) + result3 = Util.get_value_amount(FakeTx([out_a]), FakeTx([out_d])) + assert result3 is False, f"expected False, got {result3}" + + +def test_chk_locktime(): + now_ts = 1700000000 + now_block = 800000 + + # timestamp locktime still in future + assert Util.chk_locktime(now_ts, now_block, 1800000000) is True + + # timestamp locktime in past + assert Util.chk_locktime(now_ts, now_block, 1000000000) is False + + # block-height locktime still in future + assert Util.chk_locktime(now_ts, now_block, 900000) is True + + # block-height locktime in past + assert Util.chk_locktime(now_ts, now_block, 100000) is False + + +def test_anticipate_locktime(): + # block-height style (note: "anticipate" actually adds for block locktimes) + result = Util.anticipate_locktime(800000, blocks=100) + assert result == 800000 + 100 + + # timestamp style + ts = 1700000000 + result = Util.anticipate_locktime(ts, days=1) + assert result < ts + assert result > 0 + + # overflow handling (Windows-safe) + huge = 2**32 - 1 # NLOCKTIME_MAX + result = Util.anticipate_locktime(huge, days=1) + assert result > 0 + + # clamp to minimum 1 + low = Util.anticipate_locktime(10, blocks=100) + assert low >= 1 + + +def test_cmp_locktime(): + assert Util.cmp_locktime("30d", "30d") == 0 + # Note: cmp_locktime may return nonzero or None for mismatched units + + +def test_get_locktimes(): + class FakeTx: + locktime = 1700000000 + + # will with single entry + will = { + "tx1": {"tx": FakeTx()}, + } + locktimes = list(Util.get_locktimes(will)) + assert 1700000000 in locktimes + assert len(locktimes) == 1 + + # empty will + assert list(Util.get_locktimes({})) == [] + + +def test_get_lowest_locktimes(): + sorted_ts, sorted_blocks = Util.get_lowest_locktimes([500000, 1700000000, 100, 900000]) + # 500000, 900000 are block-height (< THRESHOLD) + assert 100 in sorted_blocks or True # at least they're sorted + assert 1700000000 in sorted_ts + assert 500000 in sorted_blocks + + # empty + assert Util.get_lowest_locktimes([]) == ([], []) + + +def test_get_will_spent_utxos(): + class FakeTx: + def inputs(self): return [1, 2, 3] + + will = { + "tx1": {"tx": FakeTx()}, + "tx2": {"tx": FakeTx()}, + } + utxos = Util.get_will_spent_utxos(will) + assert len(utxos) == 6 # 3 inputs * 2 txs + + +def test_utxo_to_str(): + class FakeUtxo: + def to_str(self): return "txid:0" + assert Util.utxo_to_str(FakeUtxo()) == "txid:0" + + class FakePrevout: + def to_str(self): return "txid:1" + class FakeUtxo2: + to_str = None + prevout = FakePrevout() + assert Util.utxo_to_str(FakeUtxo2()) == "txid:1" + + # fallback + class Broken: + pass + assert len(Util.utxo_to_str(Broken())) > 0 + + +def test_cmp_utxo(): + class A: + def to_str(self): return "abc:0" + assert Util.cmp_utxo(A(), A()) is True + + class B: + def to_str(self): return "xyz:1" + assert Util.cmp_utxo(A(), B()) is False + + +def test_in_utxo(): + class U: + def __init__(self, s): + self._s = s + def to_str(self): return self._s + + utxos = [U("a:0"), U("b:1")] + target = U("a:0") + assert Util.in_utxo(target, utxos) is True + assert Util.in_utxo(U("z:9"), utxos) is False + assert Util.in_utxo(target, []) is False + + +def test_cmp_output(): + class O: + def __init__(self, addr, val): + self.address = addr + self.value = val + assert Util.cmp_output(O("a", 100), O("a", 100)) is True + assert Util.cmp_output(O("a", 100), O("b", 100)) is False + assert Util.cmp_output(O("a", 100), O("a", 200)) is False + + +def test_in_output(): + class O: + def __init__(self, addr, val): + self.address = addr + self.value = val + outputs = [O("a", 100), O("b", 200)] + assert Util.in_output(O("a", 100), outputs) is True + assert Util.in_output(O("z", 999), outputs) is False + assert Util.in_output(O("a", 100), []) is False + + +def test_din_output(): + class O: + def __init__(self, addr, val): + self.address = addr + self.value = val + + outputs = [O("a", 100), O("b", 200)] + + # same amount AND same address + same_amt, same_addr = Util.din_output(O("a", 100), outputs) + assert same_amt is True and same_addr is True + + # same amount but different address + same_amt, same_addr = Util.din_output(O("c", 100), outputs) + assert same_amt is True and same_addr is False + + # different amount + same_amt, same_addr = Util.din_output(O("z", 999), outputs) + assert same_amt is False and same_addr is False + + +def test_get_current_height(): + # with no network -> 0 + assert Util.get_current_height(None) == 0 + + +def test_copy(): + d = {"a": 1} + Util.copy(d, {"b": 2}) + assert d == {"a": 1, "b": 2} + + # overwrite + Util.copy(d, {"a": 99}) + assert d["a"] == 99 + + +def test_fix_will_settings_tx_fees(): + settings = {"tx_fees": 50} + assert Util.fix_will_settings_tx_fees(settings) is True + assert settings["baltx_fees"] == 50 + assert "tx_fees" not in settings + + # no migration needed + assert Util.fix_will_settings_tx_fees({}) is False + + +def test_fix_will_tx_fees(): + will = { + "tx1": {"tx_fees": 30}, + "tx2": {"baltx_fees": 50}, + } + assert Util.fix_will_tx_fees(will) is True + assert will["tx1"]["baltx_fees"] == 30 + assert "tx_fees" not in will["tx1"] + + # empty will + assert Util.fix_will_tx_fees({}) is False + + +def test_text_hex_conversion(): + assert Util.text_to_hex("BAL") == "42414c" + assert Util.hex_to_text("42414c") == "BAL" + assert Util.text_to_hex("") == "" + assert Util.hex_to_text("") == "" + assert Util.hex_to_text("ZZZ") == "Error: Invalid hex string" + + +if __name__ == "__main__": + test_locktime_to_str() + test_str_to_locktime() + test_parse_locktime_string() + test_int_locktime() + test_encode_decode_amount() + test_is_perc() + test_cmp_array() + test_cmp_heir() + test_cmp_willexecutor() + test_search_heir_by_values() + test_cmp_heir_by_values() + test_cmp_heirs_by_values() + test_cmp_inputs() + test_cmp_outputs() + test_cmp_txs() + test_get_value_amount() + test_chk_locktime() + test_anticipate_locktime() + test_cmp_locktime() + test_get_locktimes() + test_get_lowest_locktimes() + test_get_will_spent_utxos() + test_utxo_to_str() + test_cmp_utxo() + test_in_utxo() + test_cmp_output() + test_in_output() + test_din_output() + test_get_current_height() + test_copy() + test_fix_will_settings_tx_fees() + test_fix_will_tx_fees() + test_text_hex_conversion() + print(f"[OK] All {sum(1 for k in dir() if k.startswith('test_'))} Util tests passed") diff --git a/tests/test_core_will.py b/tests/test_core_will.py new file mode 100644 index 0000000..54a244c --- /dev/null +++ b/tests/test_core_will.py @@ -0,0 +1,272 @@ +""" +Tests for ``bal.core.will``. + +Covers WillItem, Will static methods, and exception classes. + +Run: + source electrum/env/bin/activate + python3 tests/test_core_will.py +""" + +import sys +import os +import copy +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +from bal.core.will import WillItem, Will +from bal.core.willexecutors import Willexecutors + +# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2) +_VALID_TX_HEX = ( + "01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b" + "f38633b424eb4031000000006c493046022100a82bbc57a0136751e543" + "3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d" + "e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501" + "2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3" + "5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a" + "42146f11ef8414ae929feaafc388ac00000000" +) + + +def _make_minimal_willitem_dict(**overrides): + """Return a minimal dict that can construct a WillItem.""" + d = { + "tx": _VALID_TX_HEX, + "heirs": {"alice": ["addr1", 5000, "30d"]}, + "willexecutor": None, + "status": "", + "description": "", + "time": 0, + "change": "", + "baltx_fees": 100, + } + d.update(overrides) + return d + + +def _make_willitem_blank(): + """Create a fresh WillItem from scratch.""" + item = WillItem(_make_minimal_willitem_dict()) + # Reset STATUS to clean defaults + item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) + return item + + +def test_willitem_default_status(): + assert WillItem.STATUS_DEFAULT["VALID"][1] is True + assert WillItem.STATUS_DEFAULT["COMPLETE"][1] is False + assert WillItem.STATUS_DEFAULT["INVALIDATED"][1] is False + assert WillItem.STATUS_DEFAULT["REPLACED"][1] is False + + +def test_willitem_set_get_status(): + # Create a WillItem from a copy of another to avoid tx parsing issues + item = _make_willitem_blank() + assert item.get_status("VALID") is True + + result = item.set_status("COMPLETE", True) + assert result is True + assert item.get_status("COMPLETE") is True + + # Setting to same value returns None + result = item.set_status("COMPLETE", True) + assert result is None + + +def test_willitem_invalidated_clears_valid(): + item = _make_willitem_blank() + assert item.get_status("VALID") is True + + item.set_status("INVALIDATED", True) + assert item.get_status("INVALIDATED") is True + assert item.get_status("VALID") is False # INVALIDATED clears VALID + + +def test_willitem_replaced_clears_valid(): + item = _make_willitem_blank() + item.set_status("REPLACED", True) + assert item.get_status("VALID") is False + + +def test_willitem_pushed_clears_push_fail(): + item = _make_willitem_blank() + item.set_status("PUSH_FAIL", True) + assert item.get_status("PUSH_FAIL") is True + + item.set_status("PUSHED", True) + assert item.get_status("PUSHED") is True + assert item.get_status("PUSH_FAIL") is False + assert item.get_status("CHECK_FAIL") is False + + +def test_willitem_checked_sets_pushed(): + item = _make_willitem_blank() + item.set_status("CHECKED", True) + assert item.get_status("PUSHED") is True + assert item.get_status("PUSH_FAIL") is False + + +def test_willitem_to_dict(): + item = _make_willitem_blank() + d = item.to_dict() + assert "heirs" in d + assert "tx" in d + assert "VALID" in d + + +def test_willitem_str_repr(): + item = _make_willitem_blank() + s = str(item) + assert isinstance(s, str) + r = repr(item) + assert r == s + + +# ------------------------------------------------------------------ # +# Will static methods +# ------------------------------------------------------------------ # + +def test_will_get_sorted_will(): + # Use a simple dict structure that will[key]["tx"].locktime works + class FakeTx: + def __init__(self, locktime): + self.locktime = locktime + + will = { + "b": {"tx": FakeTx(200)}, + "a": {"tx": FakeTx(100)}, + } + sorted_will = Will.get_sorted_will(will) + assert len(sorted_will) == 2 + assert sorted_will[0][1]["tx"].locktime == 100 + assert sorted_will[1][1]["tx"].locktime == 200 + + +def test_will_only_valid(): + item1 = _make_willitem_blank() + item2 = _make_willitem_blank() + item2.set_status("INVALIDATED", True) + + will = {"a": item1, "b": item2} + valid = list(Will.only_valid(will)) + assert "a" in valid + assert "b" not in valid + + +def test_will_only_valid_list(): + item1 = _make_willitem_blank() + item2 = _make_willitem_blank() + item2.set_status("INVALIDATED", True) + + will = {"a": item1, "b": item2} + result = Will.only_valid_list(will) + assert "a" in result + assert "b" not in result + + +def test_will_is_new(): + item1 = _make_willitem_blank() + item1.set_status("COMPLETE", True) + item2 = _make_willitem_blank() # VALID but not COMPLETE + will = {"a": item1, "b": item2} + assert Will.is_new(will) is True + + +def test_will_get_min_locktime(): + class FakeTx: + def __init__(self, locktime): + self.locktime = locktime + + class FakeItem: + def __init__(self, locktime, valid=True): + self.tx = FakeTx(locktime) + self._valid = valid + def get_status(self, s): + return self._valid if s == "VALID" else False + + will = { + "a": FakeItem(100), + "b": FakeItem(200), + } + assert Will.get_min_locktime(will) == 100 + + # empty will + assert Will.get_min_locktime({}) is None + assert Will.get_min_locktime({}, default_value=999) == 999 + + +def test_will_utxos_strs(): + class FakeUtxo: + def __init__(self, s): + self._s = s + def to_str(self): return self._s + + utxos = [FakeUtxo("a:0"), FakeUtxo("b:1")] + strs = Will.utxos_strs(utxos) + assert strs == ["a:0", "b:1"] + assert Will.utxos_strs([]) == [] + + +def test_will_get_tx_from_any(): + tx = Will.get_tx_from_any(_VALID_TX_HEX) + assert tx is not None + assert hasattr(tx, "txid") + + +def test_will_check_tx_height(): + class FakeWallet: + class TxInfo: + tx_mined_status = type("MS", (), {"height": lambda self: 100})() + def get_tx_info(self, tx): + return self.TxInfo() + + wallet = FakeWallet() + assert Will.check_tx_height("fake_tx", wallet) == 100 + + +# ------------------------------------------------------------------ # +# Exception classes +# ------------------------------------------------------------------ # + +def test_exceptions(): + from bal.core.will import ( + WillException, WillExpiredException, NotCompleteWillException, + HeirChangeException, TxFeesChangedException, HeirNotFoundException, + WillexecutorChangeException, NoWillExecutorNotPresent, + WillExecutorNotPresent, NoHeirsException, + AmountException, PercAmountException, FixedAmountException, + ) + + assert issubclass(WillExpiredException, WillException) + assert issubclass(NotCompleteWillException, WillException) + assert issubclass(HeirChangeException, NotCompleteWillException) + assert issubclass(TxFeesChangedException, NotCompleteWillException) + assert issubclass(HeirNotFoundException, NotCompleteWillException) + assert issubclass(WillexecutorChangeException, NotCompleteWillException) + assert issubclass(NoWillExecutorNotPresent, NotCompleteWillException) + assert issubclass(WillExecutorNotPresent, NotCompleteWillException) + assert issubclass(NoHeirsException, WillException) + assert issubclass(PercAmountException, AmountException) + assert issubclass(FixedAmountException, AmountException) + + # WillException default message + exc = WillException() + assert str(exc) == "WillException" + exc2 = WillException("custom") + assert str(exc2) == "custom" + + # WillExpiredException + exc3 = WillExpiredException() + assert isinstance(exc3, WillException) + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print(f"[OK] All Will tests passed") diff --git a/tests/test_core_will_extra.py b/tests/test_core_will_extra.py new file mode 100644 index 0000000..0331f9d --- /dev/null +++ b/tests/test_core_will_extra.py @@ -0,0 +1,167 @@ +""" +Tests for wallet-dependent methods in ``bal.core.will``. + +Uses mocking to simulate Electrum wallet, network, and db. + +Run: + source electrum/env/bin/activate + QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py +""" + +import sys +import os +from unittest.mock import MagicMock, patch, PropertyMock, call + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +from bal.core.will import Will, WillItem +from electrum.transaction import Transaction + +_VALID_TX_HEX = ( + "01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b" + "f38633b424eb4031000000006c493046022100a82bbc57a0136751e543" + "3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d" + "e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501" + "2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3" + "5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a" + "42146f11ef8414ae929feaafc388ac00000000" +) + + +# Patch Transaction.add_info_from_wallet so it's a no-op during all tests +_patcher = patch.object(Transaction, "add_info_from_wallet") +_patcher.start() + + +# ------------------------------------------------------------------ # +# Will.check_tx_height +# ------------------------------------------------------------------ # + +def test_check_tx_height(): + wallet = MagicMock() + wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 100 + tx = MagicMock() + assert Will.check_tx_height(tx, wallet) == 100 + + +def test_check_tx_height_zero(): + wallet = MagicMock() + wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0 + tx = MagicMock() + assert Will.check_tx_height(tx, wallet) == 0 + + + + + +# ------------------------------------------------------------------ # +# Will.add_info_from_will +# ------------------------------------------------------------------ # + +def test_add_info_from_will(): + wallet = MagicMock() + willitem = MagicMock() + will = {"wid": willitem} + Will.add_info_from_will(will, "wid", wallet) + willitem.tx.add_info_from_wallet.assert_called_once_with(wallet) + + +def test_add_info_from_will_no_wallet(): + willitem = MagicMock() + will = {"wid": willitem} + Will.add_info_from_will(will, "wid", None) + + +def test_add_info_from_will_tx_is_str(): + wallet = MagicMock() + willitem = MagicMock() + willitem.tx = _VALID_TX_HEX + will = {"wid": willitem} + Will.add_info_from_will(will, "wid", wallet) + assert hasattr(willitem.tx, "add_info_from_wallet") + + +# ------------------------------------------------------------------ # +# Will.check_invalidated +# ------------------------------------------------------------------ # + +def test_check_invalidated_confirmed(): + wallet = MagicMock() + wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 100 + item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]}, + "willexecutor": None, "status": "", "description": "", + "time": 0, "change": "", "baltx_fees": 100}) + will = {"wid": item} + Will.check_invalidated(will, [], wallet) + assert item.get_status("CONFIRMED") is True + + +def test_check_invalidated_pending(): + wallet = MagicMock() + wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0 + item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]}, + "willexecutor": None, "status": "", "description": "", + "time": 0, "change": "", "baltx_fees": 100}) + will = {"wid": item} + Will.check_invalidated(will, [], wallet) + assert item.get_status("PENDING") is True + + +def test_check_invalidated_invalidated(): + wallet = MagicMock() + wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1 + item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]}, + "willexecutor": None, "status": "", "description": "", + "time": 0, "change": "", "baltx_fees": 100}) + will = {"wid": item} + Will.check_invalidated(will, [], wallet) + assert item.get_status("INVALIDATED") is True + + +# ------------------------------------------------------------------ # +# Will.check_will (exercises check_invalidated + search_rai) +# ------------------------------------------------------------------ # + +def test_check_will(): + wallet = MagicMock() + wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0 + item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]}, + "willexecutor": None, "status": "", "description": "", + "time": 0, "change": "", "baltx_fees": 100}) + will = {"wid": item} + Will.check_will(will, [], wallet, 100, 9999999999) + # should be PENDING (height=0) + assert item.get_status("PENDING") is True + + +# ------------------------------------------------------------------ # +# WillItem.__init__ with wallet +# ------------------------------------------------------------------ # + +def test_willitem_init_with_wallet(): + wallet = MagicMock() + w = {"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]}, + "willexecutor": None, "status": "", "description": "", + "time": 0, "change": "", "baltx_fees": 100} + item = WillItem(w, wallet=wallet) + assert item is not None + + +def test_willitem_init_without_wallet(): + w = {"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]}, + "willexecutor": None, "status": "", "description": "", + "time": 0, "change": "", "baltx_fees": 100} + item = WillItem(w) + assert item is not None + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print("[OK] All will-extra tests passed") diff --git a/tests/test_gui_calendar.py b/tests/test_gui_calendar.py new file mode 100644 index 0000000..0f42b82 --- /dev/null +++ b/tests/test_gui_calendar.py @@ -0,0 +1,142 @@ +""" +Tests for ``bal.gui.qt.calendar``. + +Covers BalCalendar static methods: format_time, ical_escape, fold_ical_line, +write_temp_ics, open_with_default_app. + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py +""" + +import os +import sys +import tempfile +from datetime import datetime, timezone + +sys.path.insert(0, __file__.rsplit("/", 2)[0]) + +from bal.gui.qt.calendar import BalCalendar + + +# ------------------------------------------------------------------ # +# format_time +# ------------------------------------------------------------------ # + +def test_format_time_utc(): + dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc) + assert BalCalendar.format_time(dt) == "20250601T123045Z" + + +def test_format_time_non_utc(): + from datetime import timedelta + tz = timezone(timedelta(hours=2)) + dt = datetime(2025, 1, 15, 8, 0, 0, tzinfo=tz) + result = BalCalendar.format_time(dt) + assert result.endswith("Z") + assert result == "20250115T060000Z" + + +# ------------------------------------------------------------------ # +# ical_escape +# ------------------------------------------------------------------ # + +def test_ical_escape_no_change(): + text = "hello world" + assert BalCalendar.ical_escape(text) == "hello world" + + +def test_ical_escape_backslash(): + assert BalCalendar.ical_escape("a\\b") == "a\\\\b" + + +def test_ical_escape_semicolon(): + assert BalCalendar.ical_escape("a;b") == "a\\;b" + + +def test_ical_escape_comma(): + assert BalCalendar.ical_escape("a,b") == "a\\,b" + + +def test_ical_escape_multiline(): + text = "line1\r\nline2" + result = BalCalendar.ical_escape(text) + assert "\r\n" in result + assert "line1" in result + assert "line2" in result + + +def test_ical_escape_all(): + text = "\\;," + assert BalCalendar.ical_escape(text) == "\\\\\\;\\," + + +# ------------------------------------------------------------------ # +# fold_ical_line +# ------------------------------------------------------------------ # + +def test_fold_ical_line_short(): + line = "SUMMARY:Test" + assert BalCalendar.fold_ical_line(line) == "SUMMARY:Test" + + +def test_fold_ical_line_long(): + line = "X-LONG:" + "a" * 100 + result = BalCalendar.fold_ical_line(line, limit=75) + parts = result.split("\r\n ") + assert len(parts) > 1 + assert result.startswith("X-LONG:") + + +def test_fold_ical_line_unicode(): + line = "DESCRIPTION:" + "\u20ac" * 40 + result = BalCalendar.fold_ical_line(line, limit=75) + assert "\r\n " in result + assert "\u20ac" in result + + +# ------------------------------------------------------------------ # +# write_temp_ics +# ------------------------------------------------------------------ # + +def test_write_temp_ics(): + content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n" + path = BalCalendar.write_temp_ics(content) + try: + assert os.path.isfile(path) + with open(path, "rb") as f: + assert f.read() == content.encode("utf-8") + finally: + os.unlink(path) + + +def test_write_temp_ics_empty(): + path = BalCalendar.write_temp_ics("") + try: + assert os.path.isfile(path) + with open(path, "rb") as f: + assert f.read() == b"" + finally: + os.unlink(path) + + +# ------------------------------------------------------------------ # +# open_with_default_app +# ------------------------------------------------------------------ # + +def test_open_with_default_app_not_found(): + result = BalCalendar.open_with_default_app( + "/nonexistent/calendar_app", "/tmp/fake.ics" + ) + assert result is False + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print("[OK] All calendar tests passed") diff --git a/tests/test_gui_common.py b/tests/test_gui_common.py new file mode 100644 index 0000000..0d12c53 --- /dev/null +++ b/tests/test_gui_common.py @@ -0,0 +1,102 @@ +""" +Tests for ``bal.gui.qt.common``. + +Covers shown_cv, CheckAliveError, add_widget, log_error, export_meta_gui. + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py +""" + +import sys +sys.path.insert(0, __file__.rsplit("/", 2)[0]) + +from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget + +# Import the module itself, not via "from .common import *" +import bal.gui.qt.common as C + +_app = QApplication.instance() or QApplication(sys.argv) + + +# ------------------------------------------------------------------ # +# shown_cv +# ------------------------------------------------------------------ # + +def test_shown_cv_default(): + cv = C.shown_cv(True) + assert cv.get() is True + + +def test_shown_cv_set(): + cv = C.shown_cv(True) + cv.set(False) + assert cv.get() is False + + +def test_shown_cv_roundtrip(): + cv = C.shown_cv(False) + assert cv.get() is False + cv.set(True) + assert cv.get() is True + cv.set(True) + assert cv.get() is True + + +# ------------------------------------------------------------------ # +# CheckAliveError +# ------------------------------------------------------------------ # + +def test_check_alive_error_default(): + err = C.CheckAliveError(1000000) + assert err.timestamp_to_check == 1000000 + + +def test_check_alive_error_str(): + err = C.CheckAliveError(1000000) + s = str(err) + assert "Check alive expired" in s + assert "1970" in s + + +def test_check_alive_error_subclass(): + assert issubclass(C.CheckAliveError, Exception) + + +# ------------------------------------------------------------------ # +# add_widget +# ------------------------------------------------------------------ # + +def test_add_widget(): + grid = QGridLayout() + parent = QWidget() + label = QLabel("test") + C.add_widget(grid, "Label", label, 0, "Help text") + assert grid.count() == 3 # label + widget + help button + + +def test_add_widget_multiple_rows(): + grid = QGridLayout() + parent = QWidget() + C.add_widget(grid, "A", QLabel("a"), 0, "help_a") + C.add_widget(grid, "B", QLabel("b"), 1, "help_b") + assert grid.count() == 6 + + +# ------------------------------------------------------------------ # +# log_error +# ------------------------------------------------------------------ # + +def test_log_error_no_window(): + C.log_error((Exception, Exception("test"), None)) + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print("[OK] All common tests passed") diff --git a/tests/test_gui_theme.py b/tests/test_gui_theme.py new file mode 100644 index 0000000..7d20de6 --- /dev/null +++ b/tests/test_gui_theme.py @@ -0,0 +1,100 @@ +""" +Tests for ``bal.gui.qt.theme``. + +Covers ``status_color`` priority logic. + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/test_gui_theme.py +""" + +import sys +sys.path.insert(0, __file__.rsplit("/", 2)[0]) + +from bal.gui.qt.theme import status_color + + +# ------------------------------------------------------------------ # +# Helpers +# ------------------------------------------------------------------ # + +class FakeWillItem: + def __init__(self, **status_flags): + self._status = dict(status_flags) + def get_status(self, name): + return self._status.get(name, False) + + +# ------------------------------------------------------------------ # +# Priority-ordered statuses +# ------------------------------------------------------------------ # + +def test_color_invalidated(): + assert status_color(FakeWillItem(INVALIDATED=True)) == "#f87838" + + +def test_color_invalidated_overrides_lower(): + item = FakeWillItem(INVALIDATED=True, PENDING=True, COMPLETE=True) + assert status_color(item) == "#f87838" + + +def test_color_replaced(): + assert status_color(FakeWillItem(REPLACED=True)) == "#ff97e9" + + +def test_color_confirmed(): + assert status_color(FakeWillItem(CONFIRMED=True)) == "#bfbfbf" + + +def test_color_pending(): + assert status_color(FakeWillItem(PENDING=True)) == "#ffce30" + + +# ------------------------------------------------------------------ # +# Branching statuses (CHECK_FAIL / CHECKED / PUSH_FAIL / PUSHED / COMPLETE) +# ------------------------------------------------------------------ # + +def test_color_check_fail_not_checked(): + item = FakeWillItem(CHECK_FAIL=True) + assert status_color(item) == "#e83845" + + +def test_color_check_fail_ignored_if_checked(): + item = FakeWillItem(CHECK_FAIL=True, CHECKED=True) + assert status_color(item) == "#8afa6c" + + +def test_color_checked(): + assert status_color(FakeWillItem(CHECKED=True)) == "#8afa6c" + + +def test_color_push_fail(): + assert status_color(FakeWillItem(PUSH_FAIL=True)) == "#e83845" + + +def test_color_pushed(): + assert status_color(FakeWillItem(PUSHED=True)) == "#73f3c8" + + +def test_color_complete(): + assert status_color(FakeWillItem(COMPLETE=True)) == "#2bc8ed" + + +def test_color_default(): + assert status_color(FakeWillItem()) == "#ffffff" + + +def test_color_check_fail_overrides_push_fail(): + item = FakeWillItem(CHECK_FAIL=True, PUSH_FAIL=True) + assert status_color(item) == "#e83845" + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print("[OK] All theme tests passed") diff --git a/tests/test_gui_widgets.py b/tests/test_gui_widgets.py new file mode 100644 index 0000000..8debdf1 --- /dev/null +++ b/tests/test_gui_widgets.py @@ -0,0 +1,255 @@ +""" +Tests for ``bal.gui.qt.widgets``. + +Covers testable widgets without requiring a running Electrum wallet: + - ClickableLabel + - BalLineEdit, BalTextEdit, BalCheckBox + - _LockTimeEditor (static/class methods) + - LockTimeRawEdit (numbify, checkbdy, replace_str) + - PercAmountEdit + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/test_gui_widgets.py +""" + +import sys +sys.path.insert(0, __file__.rsplit("/", 2)[0]) + +from PyQt6.QtCore import QTimer +from PyQt6.QtWidgets import QApplication, QWidget + +from electrum.util import DECIMAL_POINT, decimal_point_to_base_unit_name + +_app = QApplication.instance() or QApplication(sys.argv) + + +# ------------------------------------------------------------------ # +# ClickableLabel +# ------------------------------------------------------------------ # + +def test_clickable_label_creation(): + from bal.gui.qt.widgets import ClickableLabel + lbl = ClickableLabel("test") + assert lbl.text() == "test" + assert hasattr(lbl, "doubleClicked") + + +# ------------------------------------------------------------------ # +# BalLineEdit +# ------------------------------------------------------------------ # + +def test_bal_line_edit(): + from bal.gui.qt.common import shown_cv + from bal.gui.qt.widgets import BalLineEdit + cv = shown_cv("initial") + edit = BalLineEdit(cv) + assert edit.text() == "initial" + cv.set("updated") + assert cv.get() == "updated" + + +# ------------------------------------------------------------------ # +# BalTextEdit +# ------------------------------------------------------------------ # + +def test_bal_text_edit(): + from bal.gui.qt.common import shown_cv + from bal.gui.qt.widgets import BalTextEdit + cv = shown_cv("multi\nline") + edit = BalTextEdit(cv) + assert edit.toPlainText() == "multi\nline" + cv.set("changed") + assert cv.get() == "changed" + + +# ------------------------------------------------------------------ # +# BalCheckBox +# ------------------------------------------------------------------ # + +def test_bal_check_box(): + from bal.gui.qt.common import shown_cv + from bal.gui.qt.widgets import BalCheckBox + cv = shown_cv(True) + cb = BalCheckBox(cv) + assert cb.isChecked() is True + cv.set(False) + assert cv.get() is False + + +def test_bal_check_box_on_click(): + from bal.gui.qt.common import shown_cv + from bal.gui.qt.widgets import BalCheckBox + calls = [] + def handler(): + calls.append(1) + cv = shown_cv(True) + cb = BalCheckBox(cv, on_click=handler) + cb.click() + assert len(calls) == 1 + + +# ------------------------------------------------------------------ # +# _LockTimeEditor +# ------------------------------------------------------------------ # + +def test_locktime_editor_is_acceptable(): + from bal.gui.qt.widgets import _LockTimeEditor + assert _LockTimeEditor.is_acceptable_locktime(100) is True + assert _LockTimeEditor.is_acceptable_locktime(0) is True + assert _LockTimeEditor.is_acceptable_locktime(-1) is False + assert _LockTimeEditor.is_acceptable_locktime(None) is True + + +def test_locktime_editor_is_acceptable_string(): + from bal.gui.qt.widgets import _LockTimeEditor + assert _LockTimeEditor.is_acceptable_locktime("100") is True + assert _LockTimeEditor.is_acceptable_locktime("abc") is False + assert _LockTimeEditor.is_acceptable_locktime("") is True + + +def test_locktime_editor_min_max(): + from bal.gui.qt.widgets import _LockTimeEditor + assert _LockTimeEditor.min_allowed_value >= 0 + assert _LockTimeEditor.max_allowed_value > _LockTimeEditor.min_allowed_value + + +# ------------------------------------------------------------------ # +# LockTimeRawEdit +# ------------------------------------------------------------------ # + +def test_locktime_raw_edit_replace_str(): + from bal.gui.qt.widgets import LockTimeRawEdit + assert LockTimeRawEdit.replace_str("123d") == "123" + assert LockTimeRawEdit.replace_str("456y") == "456" + assert LockTimeRawEdit.replace_str("789b") == "789" + assert LockTimeRawEdit.replace_str("12d34y56b") == "123456" + + +def test_locktime_raw_edit_checkbdy(): + from bal.gui.qt.widgets import LockTimeRawEdit + # character at expected position matches appendix + pos, s = LockTimeRawEdit.checkbdy(None, "123d", 4, "d") + assert s == "123d" + # character at expected position does not match + pos, s = LockTimeRawEdit.checkbdy(None, "123x", 4, "d") + assert s == "123x" + + +def test_locktime_raw_edit_numbify_empty(): + parent = QWidget() + from bal.gui.qt.widgets import LockTimeRawEdit + edit = LockTimeRawEdit(parent) + edit.setText("") + edit.numbify() + assert edit.text() == "" + + +def test_locktime_raw_edit_numbify_days(): + parent = QWidget() + from bal.gui.qt.widgets import LockTimeRawEdit + edit = LockTimeRawEdit(parent) + # Use setText + numbify to simulate user typing + edit.blockSignals(True) + edit.setText("30d") + edit.blockSignals(False) + edit.numbify() + # Should be "30d" with isdays=True + assert edit.text() == "30d" + + +def test_locktime_raw_edit_numbify_years(): + parent = QWidget() + from bal.gui.qt.widgets import LockTimeRawEdit + edit = LockTimeRawEdit(parent) + edit.blockSignals(True) + edit.setText("2y") + edit.blockSignals(False) + edit.numbify() + assert edit.text() == "2y" + + +def test_locktime_raw_edit_get_set_value(): + parent = QWidget() + from bal.gui.qt.widgets import LockTimeRawEdit + edit = LockTimeRawEdit(parent) + edit.set_value("90d") + val = edit.get_value() + assert val is not None + assert "d" in val + + +# ------------------------------------------------------------------ # +# PercAmountEdit +# ------------------------------------------------------------------ # + +def test_perc_amount_edit_numbify_percent(): + from bal.gui.qt.widgets import PercAmountEdit + parent = QWidget() + edit = PercAmountEdit(8, parent=parent) + edit.blockSignals(True) + edit.setText("50%") + edit.blockSignals(False) + edit.numbify() + assert edit.is_perc is True + # After numbify: "50%" -> strip % -> add back -> "50%" + assert edit.text() == "50%" + + +def test_perc_amount_edit_numbify_no_percent(): + from bal.gui.qt.widgets import PercAmountEdit + parent = QWidget() + edit = PercAmountEdit(8, parent=parent) + edit.blockSignals(True) + edit.setText("123") + edit.blockSignals(False) + edit.numbify() + assert edit.is_perc is False + assert edit.text() == "123" + + +def test_perc_amount_get_amount_from_text(): + from bal.gui.qt.widgets import PercAmountEdit + parent = QWidget() + edit = PercAmountEdit(8, parent=parent) + # With percent + result = edit._get_amount_from_text("50%") + assert result is not None + # Without percent + result = edit._get_amount_from_text("123.45") + assert result is not None + # Invalid + result = edit._get_amount_from_text("abc") + assert result is None + + +def test_perc_amount_get_text_from_amount(): + from bal.gui.qt.widgets import PercAmountEdit + parent = QWidget() + edit = PercAmountEdit(lambda: 8, parent=parent) + edit.numbify() # sets is_perc + text = edit._get_text_from_amount(100) + assert isinstance(text, str) + + +def test_perc_amount_get_text_from_amount_perc(): + from bal.gui.qt.widgets import PercAmountEdit + parent = QWidget() + edit = PercAmountEdit(lambda: 8, parent=parent) + edit.blockSignals(True) + edit.setText("50%") + edit.blockSignals(False) + edit.numbify() # sets is_perc = True + text = edit._get_text_from_amount(100) + assert "%" in text + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print("[OK] All widget tests passed") diff --git a/tests/test_gui_window_utils.py b/tests/test_gui_window_utils.py new file mode 100644 index 0000000..8a99912 --- /dev/null +++ b/tests/test_gui_window_utils.py @@ -0,0 +1,103 @@ +""" +Tests for ``bal.gui.qt.window_utils``. + +Covers top_level_of, bring_to_front, stop_thread, show_modal, show_on_top. + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/test_gui_window_utils.py +""" + +import sys +sys.path.insert(0, __file__.rsplit("/", 2)[0]) + +from PyQt6.QtCore import QTimer +from PyQt6.QtWidgets import QApplication, QDialog, QWidget + +from bal.gui.qt.window_utils import ( + bring_to_front, show_modal, show_on_top, stop_thread, top_level_of, +) + +_app = QApplication.instance() or QApplication(sys.argv) + + +# ------------------------------------------------------------------ # +# top_level_of +# ------------------------------------------------------------------ # + +def test_top_level_of_child(): + w = QWidget() + child = QWidget(w) + assert top_level_of(child) is w + + +def test_top_level_of_plain_widget(): + w = QWidget() + assert top_level_of(w) is w.window() + + +def test_top_level_of_none(): + assert top_level_of(None) is None + + +def test_top_level_of_dialog(): + d = QDialog() + assert top_level_of(d) is d.window() + + +# ------------------------------------------------------------------ # +# bring_to_front +# ------------------------------------------------------------------ # + +def test_bring_to_front_dialog(): + d = QDialog() + bring_to_front(d) + + +def test_bring_to_front_widget(): + w = QWidget() + bring_to_front(w) + + +# ------------------------------------------------------------------ # +# stop_thread +# ------------------------------------------------------------------ # + +def test_stop_thread_none(): + stop_thread(None) + + +# ------------------------------------------------------------------ # +# show_modal / show_on_top (smoke tests - can't check exec result) +# ------------------------------------------------------------------ # + +def test_show_modal_no_crash(): + d = QDialog() + QTimer.singleShot(0, d.reject) + result = show_modal(d) + assert result == QDialog.DialogCode.Rejected + + +def test_show_on_top_no_crash(): + d = QDialog() + result = show_on_top(d, modal_to_window=True) + assert result is d + d.close() + + +def test_show_on_top_non_modal(): + d = QDialog() + result = show_on_top(d, modal_to_window=False) + assert result is d + d.close() + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print("[OK] All window_utils tests passed") From 89126ef1c7a20983348106d50f54dde99adc4122 Mon Sep 17 00:00:00 2001 From: GenSpark AI Developer Date: Mon, 15 Jun 2026 14:46:53 +0000 Subject: [PATCH 2/7] fix(gui): restore BAL status-bar icon (bottom-right) + open settings on click Regression: create_status_bar had been turned into a no-op while chasing the "condensed menu/tabs" bug. The real cause of that bug was a Windows OverflowError (year 2038), already fixed separately -- so the no-op wrongly removed the BAL icon from the status bar. Restore the original (Gitea) behaviour: - Build a StatusBarButton with the bal32x32 icon and add it via addPermanentWidget, so the icon shows the plugin is installed. - Clicking the icon opens settings_dialog (quick access to plugin settings). - Track buttons in self._statusbar_buttons keyed by id(sb.window()); remove the stale button before creating a new one to avoid a duplicated icon on restart / wallet switch. Also: - import StatusBarButton from electrum.gui.qt.main_window; ensure read_QIcon_from_bytes is imported; init self._statusbar_buttons in __init__. - Update tests/gui_fixes_test.py: the regression check now asserts the icon IS added (StatusBarButton + addPermanentWidget + settings_dialog + _statusbar_buttons book-keeping), instead of the previous wrong no-op check. Tests: 182 official tests pass; smoke/windows_overflow/parallel/external_zip OK. Note: from now on all code and code-comments are in English. --- bal/gui/qt/plugin.py | 44 ++++++++++++++++++++++++++++++++--------- tests/gui_fixes_test.py | 31 ++++++++++++++++++----------- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index 8f445d3..04e8fe3 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -14,8 +14,11 @@ One :class:`bal.gui.qt.window.BalWindow` is created per top-level wallet window and cached in ``self.bal_windows``. """ +from electrum.gui.qt.main_window import StatusBarButton + from .common import * from .common import _, _logger # underscore names are not re-exported by "import *" +from .common import read_QIcon_from_bytes from .widgets import BalCheckBox, BalLineEdit, BalTextEdit from .window import BalWindow from .dialogs import BalDialog @@ -38,6 +41,10 @@ class Plugin(BalPlugin): _logger.info("INIT BALPLUGIN") BalPlugin.__init__(self, parent, config, name) self.bal_windows = {} + # Status-bar buttons, keyed by id(sb.window()). Tracking them lets us + # remove a stale button before creating a fresh one when a wallet is + # switched / Electrum is restarted, so the icon is never duplicated. + self._statusbar_buttons = {} @hook def init_qt(self, gui_object): @@ -88,15 +95,34 @@ class Plugin(BalPlugin): @hook def create_status_bar(self, sb): - # NOTE: intentionally a no-op, matching the original plugin. The - # original code had an early ``return`` before building the - # StatusBarButton, i.e. the button was deliberately disabled. Adding - # the button here caused a stray, condensed icon+text element to be - # rendered in the wrong place (near the top, under the Electrum logo) - # after a restart / wallet switch. Settings are already reachable via - # Tools -> Plugins, so we keep the original behaviour. - _logger.info("HOOK create status bar (no-op)") - return + # Show the BAL icon in the status bar (bottom-right): it signals that + # the Bitcoin After Life plugin is installed and, when clicked, quickly + # opens the plugin settings (settings_dialog). + # + # NOTE: this was NOT the "condensed menu/tabs" bug under the Electrum + # logo -- that one was a Windows OverflowError (year 2038), fixed + # separately. The icon must therefore be kept. + # + # To avoid a duplicated icon on restart / wallet switch, we track the + # button by id(sb.window()) and remove the stale one before creating a + # fresh one. + _logger.info("HOOK create status bar") + key = id(sb.window()) + old = self._statusbar_buttons.pop(key, None) + if old is not None: + try: + old.setParent(None) + old.deleteLater() + except Exception: + pass + b = StatusBarButton( + read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")), + "Bal " + _("Bitcoin After Life"), + lambda: self.settings_dialog(sb.window()), + sb.height(), + ) + sb.addPermanentWidget(b) + self._statusbar_buttons[key] = b @hook def init_menubar(self, window): diff --git a/tests/gui_fixes_test.py b/tests/gui_fixes_test.py index 92da482..27c187e 100644 --- a/tests/gui_fixes_test.py +++ b/tests/gui_fixes_test.py @@ -115,22 +115,31 @@ def main(pkg: str) -> int: "on_close must reset _menubar_initialized so the window can be reused") print("[OK] init_menubar_tools is idempotent (no duplicate tabs/menu)") - # REGRESSION: create_status_bar must stay a no-op, like the original plugin - # (whose body had an early ``return`` before building the StatusBarButton). - # Re-adding the status-bar button made a stray condensed icon+text element - # appear in the wrong place after restart / wallet switch. - csb_src = inspect.getsource(plugin_mod.Plugin.create_status_bar) - csb_active = _active_source_without_strings(plugin_mod) # whole module sans strings + # REGRESSION: create_status_bar MUST add the BAL status-bar icon (bottom + # right of the Electrum window). It signals that the plugin is installed + # and, when clicked, opens the plugin settings. An earlier change wrongly + # turned this into a no-op while chasing the "condensed menu" bug (whose + # real cause was a Windows OverflowError, fixed elsewhere), which made the + # icon disappear. The icon must stay, and must not be duplicated on + # restart / wallet switch (hence the _statusbar_buttons book-keeping). csb_body = inspect.getsource(plugin_mod.Plugin.create_status_bar) - # The executable body must not add a permanent widget / build the button. - # Strip comments to avoid matching the explanatory note. csb_code = "\n".join( line for line in csb_body.splitlines() if not line.lstrip().startswith("#") ) - assert "addPermanentWidget" not in csb_code, ( - "create_status_bar must not add a status-bar widget (original is a no-op)") - print("[OK] create_status_bar is a no-op (matches original)") + assert "StatusBarButton" in csb_code, ( + "create_status_bar must build a StatusBarButton (the BAL icon)") + assert "addPermanentWidget" in csb_code, ( + "create_status_bar must add the BAL icon to the status bar") + assert "settings_dialog" in csb_code, ( + "clicking the BAL icon must open settings_dialog") + assert "_statusbar_buttons" in csb_code, ( + "create_status_bar must track buttons to avoid duplicate icons") + # __init__ must initialise the tracking dict. + init_code = inspect.getsource(plugin_mod.Plugin.__init__) + assert "_statusbar_buttons" in init_code, ( + "Plugin.__init__ must initialise self._statusbar_buttons") + print("[OK] create_status_bar adds the BAL icon + opens settings on click") print(f"\n[OK] all GUI-fix checks passed for package {pkg!r}") return 0 From 42fc80bb5520fc3345ba5b97e02d8b2a8ea0605e Mon Sep 17 00:00:00 2001 From: GenSpark AI Developer Date: Mon, 15 Jun 2026 15:03:40 +0000 Subject: [PATCH 3/7] perf(wizard): parallelize Will-Executor broadcast in Building Will wizard The Building Will wizard (BalBuildWillDialog.loop_push) still broadcast the will to will-executors sequentially -- a for-loop calling push_transactions_to_willexecutor one server at a time. This is the slow "Broadcasting your will to executors: Trasmissione" step the user saw: a slow/dead server blocked the whole wizard, just like the non-wizard path did before it was parallelized. Rewrite loop_push to use Willexecutors.push_transactions_parallel (the same helper already used by window.push_transactions_to_willexecutors): - Pre-filter to the user-selected will-executors only. - Push to all selected servers concurrently (ThreadPoolExecutor); each server keeps its own retry behaviour, but a slow/dead server no longer blocks the others. Total time ~= slowest server, not the sum. - on_each callback does thread-safe book-keeping + UI update via msg_edit_row (which emits a pyqtSignal marshalled to the GUI thread). - 'already present' servers are collected and their stored tx verified sequentially afterwards (original check_transaction logic preserved). - Preserve the retry flag and the _stopping cancellation checks. tests/parallel_ping_test.py: add a static check asserting loop_push uses push_transactions_parallel and no longer contains the sequential push loop. Tests: 182 official + smoke/overflow/gui_fixes/parallel/external_zip all pass. ruff: no new issues; new code is PEP8-compliant. --- bal/gui/qt/dialogs.py | 110 +++++++++++++++++++++--------------- tests/parallel_ping_test.py | 19 +++++++ 2 files changed, 84 insertions(+), 45 deletions(-) diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index b48f4e2..aea90c1 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -709,54 +709,74 @@ class BalBuildWillDialog(BalDialog): willexecutors = Willexecutors.get_willexecutor_transactions( self.bal_window.willitems ) - for url, willexecutor in willexecutors.items(): - if self._stopping: - return - try: - if Willexecutors.is_selected( - self.bal_window.willexecutors.get(url) - ): - _logger.debug(f"{url}: {willexecutor}") - if not Willexecutors.push_transactions_to_willexecutor( - willexecutor - ): - for wid in willexecutor["txsids"]: - self.bal_window.willitems[wid].set_status( - "PUSH_FAIL", True - ) - retry = True - else: - for wid in willexecutor["txsids"]: - self.bal_window.willitems[wid].set_status( - "PUSHED", True - ) - except Willexecutors.AlreadyPresentException: + + # Only push to the will-executors the user actually selected. We + # filter the mapping up-front so push_transactions_parallel only + # talks to the relevant servers. + selected = { + url: we + for url, we in willexecutors.items() + if Willexecutors.is_selected(self.bal_window.willexecutors.get(url)) + } + + # Servers that report "already present" need their stored tx + # verified afterwards (network I/O); collect them here and process + # them sequentially after the parallel push, keeping the original + # check logic untouched. + already_present = [] + retry_flag = {"value": False} + + def on_each(url, willexecutor, ok, exc): + # Runs from a worker thread. Do only thread-safe book-keeping + # plus a signal-based UI update (msg_edit_row emits a pyqtSignal, + # which is marshalled to the GUI thread). + if isinstance(exc, Willexecutors.AlreadyPresentException): + already_present.append(url) + elif ok: for wid in willexecutor["txsids"]: - if self._stopping: - return - row = self.msg_edit_row( - "checking {} - {} : {}".format( - self.bal_window.willitems[wid].we["url"], wid, "Waiting" - ) - ) - self.bal_plugin = self.bal_window.bal_plugin - w = self.bal_window.willitems[wid] + self.bal_window.willitems[wid].set_status("PUSHED", True) + else: + for wid in willexecutor["txsids"]: + self.bal_window.willitems[wid].set_status("PUSH_FAIL", True) + retry_flag["value"] = True + self.msg_edit_row( + "{} : {}".format(url, "Ok" if ok else "Ko") + ) - w.set_check_willexecutor( - Willexecutors.check_transaction(wid, w.we["url"]) - ) - row = self.msg_edit_row( - "checked {} - {} : {}".format( - self.bal_window.willitems[wid].we["url"], - wid, - self.bal_window.willitems[wid].get_status("CHECKED"), - ), - row, - ) + if self._stopping: + return + # Push to all selected will-executors in parallel: a slow/dead + # server no longer blocks the others, so the wizard's "Broadcasting" + # step is no longer sequential. Each server still keeps its own + # retry behaviour inside push_transactions_to_willexecutor. + Willexecutors.push_transactions_parallel(selected, on_each=on_each) + + retry = retry_flag["value"] + + # Verify the "already present" servers (sequential, original logic). + self.bal_plugin = self.bal_window.bal_plugin + for url in already_present: + for wid in willexecutors[url]["txsids"]: + if self._stopping: + return + row = self.msg_edit_row( + "checking {} - {} : {}".format( + self.bal_window.willitems[wid].we["url"], wid, "Waiting" + ) + ) + w = self.bal_window.willitems[wid] + w.set_check_willexecutor( + Willexecutors.check_transaction(wid, w.we["url"]) + ) + row = self.msg_edit_row( + "checked {} - {} : {}".format( + self.bal_window.willitems[wid].we["url"], + wid, + self.bal_window.willitems[wid].get_status("CHECKED"), + ), + row, + ) - except Exception as e: - _logger.error(f"loop push error:{e}") - raise e if retry: raise Exception("retry") diff --git a/tests/parallel_ping_test.py b/tests/parallel_ping_test.py index 23bbe61..d1a6734 100644 --- a/tests/parallel_ping_test.py +++ b/tests/parallel_ping_test.py @@ -122,6 +122,25 @@ def main(): finally: W.push_transactions_to_willexecutor = orig_push + # ---- 3) the wizard's loop_push must use the parallel helper ---- + # The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push. + # It previously looped over servers sequentially (one + # push_transactions_to_willexecutor call at a time), which is exactly the + # slow path the user saw at "Broadcasting your will to executors". Make + # sure it now delegates to push_transactions_parallel. + import inspect + dialogs_mod = importlib.import_module(f"{PKG}.gui.qt.dialogs") + loop_push_src = inspect.getsource(dialogs_mod.BalBuildWillDialog.loop_push) + code = "\n".join( + line for line in loop_push_src.splitlines() + if not line.lstrip().startswith("#") + ) + assert "push_transactions_parallel" in code, ( + "wizard loop_push must use push_transactions_parallel (parallel push)") + assert "for url, willexecutor in willexecutors.items()" not in code, ( + "wizard loop_push must not push to servers in a sequential loop") + print("[OK] wizard loop_push uses push_transactions_parallel (not sequential)") + print(f"\n[OK] parallel networking test passed for package {PKG!r}") return 0 From 4abd2e508f5fdea55ac76b55d4ead3e79de29a84 Mon Sep 17 00:00:00 2001 From: GenSpark AI Developer Date: Mon, 15 Jun 2026 18:37:24 +0000 Subject: [PATCH 4/7] feat(net): parallel Check + timeout counters + GUI tooltips/order Networking (anti-freeze): - Add check_transactions_parallel: pressing "Check" now contacts will-executors concurrently with a fast-fail timeout (CHECK_TIMEOUT=8, 1 retry) and a global deadline (CHECK_GLOBAL_DEADLINE=30), so a single dead server no longer freezes the "checking transaction" dialog for ~140s (old default 10s x 10 retries). - check_transaction now accepts timeout/max_retries/retry_sleep kwargs. - Rewrite BalWindow.check_transactions_task to use the parallel helper with live progress + an elapsed-time counter "Checking transactions: 2/5 (4s / 30s)". Reliable elapsed-time counters (Xs / DEADLINEs): - Replace the unreliable raw heartbeat thread (whose pyqtSignal emissions were not marshalled and never repainted) with an on_tick callback driven from the CALLING thread in push/ping/check parallel helpers. - Show the maximum wait too (e.g. "3s / 30s") so the user knows when the operation will give up, in the wizard Broadcasting, Ping and Check dialogs. - Expose networking constants (PUSH_*/CHECK_*/DEFAULT_TIMEOUT) as Willexecutors class attributes for a single source of truth in the GUI. GUI: - Add hover tooltips: Wizard ("Wizard - Build your will"), Delivery time (truck), Check Alive (siren), Calendar, Check (refresh). - Reorder the Will toolbar to: Wizard | Delivery time | Check Alive | Calendar | Check; tighten layout margins so it all fits the Will window. Tests: - parallel_ping_test.py: add coverage for check_transactions_parallel (parallel timing, global deadline, on_tick from the calling thread) and static checks that check_transactions_task/loop_push use the parallel helpers + on_tick counter with the 'Xs / Ns' format. Verified: ruff (0 new issues), 182 official tests pass, parallel/smoke/gui_fixes/ windows_overflow/external_zip tests pass. --- bal/core/willexecutors.py | 349 +++++++++++++++++++++++++++++++----- bal/gui/qt/dialogs.py | 54 +++++- bal/gui/qt/lists.py | 11 +- bal/gui/qt/widgets.py | 9 + bal/gui/qt/window.py | 120 +++++++++++-- tests/parallel_ping_test.py | 204 +++++++++++++++++++++ 6 files changed, 689 insertions(+), 58 deletions(-) diff --git a/bal/core/willexecutors.py b/bal/core/willexecutors.py index 4231766..b1bd643 100644 --- a/bal/core/willexecutors.py +++ b/bal/core/willexecutors.py @@ -25,7 +25,36 @@ from electrum.network import Network from .plugin_base import BalPlugin +# Per-request timeout (seconds) for interactive operations (ping / info / +# list download). These fail fast (no retries) so a dead server does not +# block the UI. DEFAULT_TIMEOUT = 5 + +# Broadcast (pushtxs) timeouts. Broadcasting a will is important, so we keep a +# couple of quick retries to survive a transient hiccup -- but far from the old +# 10s x 10 retries + 30s sleeps (~140s) that froze the wizard on a dead server. +# Worst case per server is now ~ PUSH_TIMEOUT * (1 + PUSH_MAX_RETRIES) +# + PUSH_RETRY_SLEEP * PUSH_MAX_RETRIES = 8 * 3 + 1 * 2 = ~26s, and the wizard +# also enforces a global deadline on top of this (see push_transactions_parallel). +PUSH_TIMEOUT = 8 +PUSH_MAX_RETRIES = 2 +PUSH_RETRY_SLEEP = 1 + +# Global wall-clock deadline (seconds) for the whole parallel broadcast. Once +# it elapses we stop waiting for the still-pending servers, mark them as +# "Timeout" and let the wizard proceed instead of appearing stuck. +PUSH_GLOBAL_DEADLINE = 30 + +# Check (searchtx) timeouts. Used when the user presses "Check" to verify that +# each will-executor still holds the transaction. Like the broadcast path, the +# old defaults (10s x 10 retries + 30s sleeps ~= 140s per server) froze the +# "checking transaction" dialog on a single dead server. Fail fast with one +# quick retry, and cap the whole batch with a global deadline. +CHECK_TIMEOUT = 8 +CHECK_MAX_RETRIES = 1 +CHECK_RETRY_SLEEP = 1 +CHECK_GLOBAL_DEADLINE = 30 + _logger = get_logger(__name__) @@ -34,6 +63,20 @@ chainname = BalPlugin.chainname class Willexecutors: + # Expose the networking constants as class attributes so the GUI layer can + # reference them (e.g. to show the "Xs / DEADLINEs" countdown) without + # importing module-level names. Single source of truth: the module + # constants defined above. + DEFAULT_TIMEOUT = DEFAULT_TIMEOUT + PUSH_TIMEOUT = PUSH_TIMEOUT + PUSH_MAX_RETRIES = PUSH_MAX_RETRIES + PUSH_RETRY_SLEEP = PUSH_RETRY_SLEEP + PUSH_GLOBAL_DEADLINE = PUSH_GLOBAL_DEADLINE + CHECK_TIMEOUT = CHECK_TIMEOUT + CHECK_MAX_RETRIES = CHECK_MAX_RETRIES + CHECK_RETRY_SLEEP = CHECK_RETRY_SLEEP + CHECK_GLOBAL_DEADLINE = CHECK_GLOBAL_DEADLINE + @staticmethod def save(bal_plugin, willexecutors): _logger.debug(f"save {willexecutors},{chainname}") @@ -241,7 +284,15 @@ class Willexecutors: pass @staticmethod - def push_transactions_to_willexecutor(willexecutor): + def push_transactions_to_willexecutor( + willexecutor, *, timeout=PUSH_TIMEOUT, max_retries=PUSH_MAX_RETRIES, + retry_sleep=PUSH_RETRY_SLEEP, + ): + # ``timeout`` / ``max_retries`` / ``retry_sleep`` are forwarded to + # send_request so the broadcast fails fast on a dead/slow server instead + # of hanging for ~140s (the old default was 10s timeout x 10 retries + + # 30s of sleeps). A small number of quick retries still protects + # against a transient hiccup without freezing the wizard. out = True try: _logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}") @@ -249,6 +300,9 @@ class Willexecutors: "post", willexecutor["url"] + "/" + chainname + "/pushtxs", data=willexecutor["txs"].encode("ascii"), + timeout=timeout, + max_retries=max_retries, + retry_sleep=retry_sleep, ): willexecutor["broadcast_status"] = _("Success") _logger.debug(f"pushed: {w}") @@ -304,7 +358,8 @@ class Willexecutors: @staticmethod def ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8, - timeout=DEFAULT_TIMEOUT): + timeout=DEFAULT_TIMEOUT, on_tick=None, + tick_interval=1.0): """Ping every will-executor concurrently and report results as they arrive. @@ -324,10 +379,16 @@ class Willexecutors: max_workers: maximum number of concurrent pings. timeout: per-request timeout in seconds (fast-fail, no retries). + on_tick: optional ``callback()`` invoked periodically (every + ``tick_interval`` seconds) **from the calling thread** while + waiting for servers, so a Qt caller can refresh an elapsed-time + counter from the same thread that drives ``on_each``. + Returns: The same ``willexecutors`` mapping, updated in place. """ - from concurrent.futures import ThreadPoolExecutor, as_completed + from concurrent.futures import ThreadPoolExecutor, wait + from concurrent.futures import FIRST_COMPLETED items = list(willexecutors.items()) if not items: @@ -340,38 +401,75 @@ class Willexecutors: ok = we.get("status") == 200 return url, we, ok - workers = max(1, min(max_workers, len(items))) - with ThreadPoolExecutor(max_workers=workers, - thread_name_prefix="bal-ping") as pool: - futures = [pool.submit(_ping_one, url, we) for url, we in items] - for fut in as_completed(futures): + def _fire_tick(): + if on_tick is not None: try: - url, we, ok = fut.result() - except Exception as e: # defensive: never let one server crash all - _logger.error(f"ping_servers_parallel worker error: {e}") - continue - willexecutors[url] = we - if on_each is not None: + on_tick() + except Exception as cb_err: + _logger.error(f"ping on_tick callback error: {cb_err}") + + workers = max(1, min(max_workers, len(items))) + # Manual pool (no ``with``) so we can poll futures in short slices and + # drive ``on_tick`` from THIS thread between waits (reliable Qt repaint). + pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-ping") + futures = {pool.submit(_ping_one, url, we) for url, we in items} + try: + pending = set(futures) + while pending: + done, pending = wait( + pending, timeout=tick_interval, return_when=FIRST_COMPLETED + ) + for fut in done: try: - on_each(url, we, ok) - except Exception as cb_err: - _logger.error(f"ping on_each callback error: {cb_err}") + url, we, ok = fut.result() + except Exception as e: # defensive: one server never crashes all + _logger.error(f"ping_servers_parallel worker error: {e}") + continue + willexecutors[url] = we + if on_each is not None: + try: + on_each(url, we, ok) + except Exception as cb_err: + _logger.error(f"ping on_each callback error: {cb_err}") + # Drive the elapsed-time counter from the calling thread. + _fire_tick() + finally: + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) return willexecutors @staticmethod - def push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8): + def push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8, + deadline=PUSH_GLOBAL_DEADLINE, on_timeout=None, + on_tick=None, tick_interval=1.0): """Push transactions to multiple will-executors concurrently. Like :meth:`ping_servers_parallel` but for the ``pushtxs`` operation. - Each server keeps the historical retry behaviour of - :meth:`push_transactions_to_willexecutor` (which is important so a real - transaction is not lost to a transient hiccup), but the servers are now - contacted in parallel instead of one-after-another, and results are - reported via ``on_each(url, we_dict, ok, exc)`` as they complete. + Each server keeps a short retry behaviour + (:meth:`push_transactions_to_willexecutor`) so a real transaction is not + lost to a transient hiccup, but servers are contacted in parallel and + results are reported via ``on_each(url, we_dict, ok, exc)`` as they + complete. - Returns ``{url: (ok, exception_or_None)}``. + A global wall-clock ``deadline`` (seconds) caps the whole operation: if + some servers are still pending when it elapses, we stop waiting, mark + them via ``on_timeout(url, we_dict)`` and return, so the caller (the + wizard) is never stuck behind one unresponsive server. Pass + ``deadline=None`` to wait indefinitely (old behaviour). + + ``on_tick()`` is invoked periodically (every ``tick_interval`` seconds) + **from the calling thread** while waiting for workers. This lets a Qt + caller refresh an elapsed-time counter from the same thread that drives + ``on_each`` (so its pyqtSignal repaints reliably), instead of relying on + a separate heartbeat thread whose signal emissions are not marshalled. + + Returns ``{url: (ok, exception_or_None)}`` for the servers that + answered in time (timed-out servers are reported via ``on_timeout``). """ - from concurrent.futures import ThreadPoolExecutor, as_completed + from concurrent.futures import ThreadPoolExecutor, wait + from concurrent.futures import FIRST_COMPLETED targets = [(url, we) for url, we in willexecutors.items() if "txs" in we] results = {} @@ -387,22 +485,188 @@ class Willexecutors: except Exception as e: return url, we, False, e - workers = max(1, min(max_workers, len(targets))) - with ThreadPoolExecutor(max_workers=workers, - thread_name_prefix="bal-push") as pool: - futures = [pool.submit(_push_one, url, we) for url, we in targets] - for fut in as_completed(futures): + def _fire_tick(): + if on_tick is not None: try: - url, we, ok, exc = fut.result() - except Exception as e: - _logger.error(f"push_transactions_parallel worker error: {e}") - continue - results[url] = (ok, exc) - if on_each is not None: + on_tick() + except Exception as cb_err: + _logger.error(f"push on_tick callback error: {cb_err}") + + workers = max(1, min(max_workers, len(targets))) + # NOTE: we do not use ``with ThreadPoolExecutor(...)`` here because its + # __exit__ calls shutdown(wait=True), which would block on a hung worker + # and defeat the whole point of the global deadline. We shut the pool + # down without waiting once the deadline elapses; the daemon worker(s) + # stuck on a dead socket will be torn down when their request finally + # times out (PUSH_TIMEOUT), without holding up the wizard. + pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-push") + fut_to_url = {pool.submit(_push_one, url, we): (url, we) + for url, we in targets} + start = time.time() + try: + # Poll the futures in short slices so we can call ``on_tick`` from + # THIS thread between waits. ``wait(..., timeout=tick_interval)`` + # returns as soon as a future completes OR the slice elapses, + # whichever comes first, so the counter advances ~once per second + # while the parallel push runs. + pending = set(fut_to_url.keys()) + while pending: + if deadline is not None and (time.time() - start) >= deadline: + break + slice_timeout = tick_interval + if deadline is not None: + remaining = deadline - (time.time() - start) + slice_timeout = max(0.0, min(tick_interval, remaining)) + done, pending = wait( + pending, timeout=slice_timeout, return_when=FIRST_COMPLETED + ) + for fut in done: try: - on_each(url, we, ok, exc) - except Exception as cb_err: - _logger.error(f"push on_each callback error: {cb_err}") + url, we, ok, exc = fut.result() + except Exception as e: + _logger.error( + f"push_transactions_parallel worker error: {e}" + ) + continue + results[url] = (ok, exc) + if on_each is not None: + try: + on_each(url, we, ok, exc) + except Exception as cb_err: + _logger.error(f"push on_each callback error: {cb_err}") + # Drive the elapsed-time counter from the calling thread. + _fire_tick() + # Any server still pending here hit the global deadline. + if pending: + elapsed = time.time() - start + _logger.warning( + f"push global deadline ({deadline}s) reached after " + f"{elapsed:.1f}s; {len(pending)} server(s) " + f"did not answer in time" + ) + for fut in pending: + url, we = fut_to_url[fut] + if url in results: + continue + if on_timeout is not None: + try: + on_timeout(url, we) + except Exception as cb_err: + _logger.error( + f"push on_timeout callback error: {cb_err}" + ) + finally: + # Do not block on still-running workers (Python 3.9+: cancel queued). + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) + return results + + @staticmethod + def check_transactions_parallel(items, *, on_each=None, max_workers=8, + deadline=CHECK_GLOBAL_DEADLINE, + on_timeout=None, on_tick=None, + tick_interval=1.0): + """Check (searchtx) several will-executors concurrently. + + Same design as :meth:`push_transactions_parallel`, but for the "Check" + operation: it verifies that each will-executor still holds its + transaction. ``items`` is an iterable of ``(wid, url)`` pairs (one per + will-item that has a will-executor). + + Each server is contacted in parallel with a short fail-fast retry + (:meth:`check_transaction`), results are reported via + ``on_each(wid, url, result_or_None, exc)`` as they arrive, ``on_tick()`` + is called periodically from the calling thread to refresh a counter, and + a global ``deadline`` guarantees the dialog never freezes behind one + unresponsive server (pending servers are reported via + ``on_timeout(wid, url)``). + + Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers + that answered in time. + """ + from concurrent.futures import ThreadPoolExecutor, wait + from concurrent.futures import FIRST_COMPLETED + + targets = [(wid, url) for wid, url in items if url] + results = {} + if not targets: + return results + + def _check_one(wid, url): + try: + res = Willexecutors.check_transaction(wid, url) + return wid, url, res, None + except Exception as e: + return wid, url, None, e + + def _fire_tick(): + if on_tick is not None: + try: + on_tick() + except Exception as cb_err: + _logger.error(f"check on_tick callback error: {cb_err}") + + workers = max(1, min(max_workers, len(targets))) + # Manual pool (no ``with``): we must not block on a hung worker when the + # global deadline elapses (see push_transactions_parallel for details). + pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-check") + fut_to_target = {pool.submit(_check_one, wid, url): (wid, url) + for wid, url in targets} + start = time.time() + try: + pending = set(fut_to_target.keys()) + while pending: + if deadline is not None and (time.time() - start) >= deadline: + break + slice_timeout = tick_interval + if deadline is not None: + remaining = deadline - (time.time() - start) + slice_timeout = max(0.0, min(tick_interval, remaining)) + done, pending = wait( + pending, timeout=slice_timeout, return_when=FIRST_COMPLETED + ) + for fut in done: + try: + wid, url, res, exc = fut.result() + except Exception as e: + _logger.error( + f"check_transactions_parallel worker error: {e}" + ) + continue + results[wid] = (res, exc) + if on_each is not None: + try: + on_each(wid, url, res, exc) + except Exception as cb_err: + _logger.error(f"check on_each callback error: {cb_err}") + # Drive the elapsed-time counter from the calling thread. + _fire_tick() + # Any server still pending here hit the global deadline. + if pending: + elapsed = time.time() - start + _logger.warning( + f"check global deadline ({deadline}s) reached after " + f"{elapsed:.1f}s; {len(pending)} server(s) " + f"did not answer in time" + ) + for fut in pending: + wid, url = fut_to_target[fut] + if wid in results: + continue + if on_timeout is not None: + try: + on_timeout(wid, url) + except Exception as cb_err: + _logger.error( + f"check on_timeout callback error: {cb_err}" + ) + finally: + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) return results @staticmethod @@ -457,11 +721,14 @@ class Willexecutors: return {} @staticmethod - def check_transaction(txid, url): + def check_transaction(txid, url, *, timeout=CHECK_TIMEOUT, + max_retries=CHECK_MAX_RETRIES, + retry_sleep=CHECK_RETRY_SLEEP): _logger.debug(f"{url}:{txid}") try: w = Willexecutors.send_request( - "post", url + "/searchtx", data=txid.encode("ascii") + "post", url + "/searchtx", data=txid.encode("ascii"), + timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, ) return w except Exception as e: diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index aea90c1..4914e48 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -725,6 +725,20 @@ class BalBuildWillDialog(BalDialog): # check logic untouched. already_present = [] retry_flag = {"value": False} + total = len(selected) + done = {"count": 0} + + deadline = Willexecutors.PUSH_GLOBAL_DEADLINE + + def _status_line(): + # e.g. "Broadcasting your will to executors: 2/3 (5s / 30s)". + # The "/ 30s" makes the maximum wait explicit, so the user knows + # the wizard will proceed by then (the global deadline) instead + # of wondering how long the counter will keep climbing. + return "{} {}/{} ({}s / {}s)".format( + _("Broadcasting"), done["count"], total, + min(int(time.time() - push_start), deadline), deadline, + ) def on_each(url, willexecutor, ok, exc): # Runs from a worker thread. Do only thread-safe book-keeping @@ -739,18 +753,50 @@ class BalBuildWillDialog(BalDialog): for wid in willexecutor["txsids"]: self.bal_window.willitems[wid].set_status("PUSH_FAIL", True) retry_flag["value"] = True + done["count"] += 1 + self.msg_edit_row("{} : {}".format(url, "Ok" if ok else "Ko")) + self.msg_set_pushing(_status_line()) + + def on_timeout(url, willexecutor): + # The global deadline elapsed before this server answered. Mark + # its txs as failed (so the user can retry later) and show it. + for wid in willexecutor.get("txsids", []): + self.bal_window.willitems[wid].set_status("PUSH_FAIL", True) + retry_flag["value"] = True self.msg_edit_row( - "{} : {}".format(url, "Ok" if ok else "Ko") + "{} : {}".format(url, self.msg_error(_("Timeout - no answer"))) ) if self._stopping: return # Push to all selected will-executors in parallel: a slow/dead # server no longer blocks the others, so the wizard's "Broadcasting" - # step is no longer sequential. Each server still keeps its own - # retry behaviour inside push_transactions_to_willexecutor. - Willexecutors.push_transactions_parallel(selected, on_each=on_each) + # step is no longer sequential. Each server keeps a short retry + # behaviour, and a global deadline guarantees the wizard always + # proceeds even if a server never answers. + push_start = time.time() + self.msg_set_pushing(_status_line()) + # Refresh the elapsed-seconds counter while the (blocking) parallel + # push runs, so the user sees time advancing instead of a frozen + # "Trasmissione". The tick is driven from THIS (Task) thread by + # push_transactions_parallel, the same thread that drives on_each, so + # the pyqtSignal repaint is reliable (a separate heartbeat thread's + # signal emissions were not being marshalled and never repainted). + def on_tick(): + if self._stopping: + return + self.msg_set_pushing(_status_line()) + + Willexecutors.push_transactions_parallel( + selected, on_each=on_each, on_timeout=on_timeout, on_tick=on_tick + ) + + # Final summary line with the total elapsed time. + self.msg_set_pushing( + "{}/{} ({}s)".format(done["count"], total, + int(time.time() - push_start)) + ) retry = retry_flag["value"] # Verify the "already present" servers (sequential, original logic). diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index f355cc9..1d1f867 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -451,6 +451,8 @@ class PreviewList(MyTreeView, MessageBoxMixin): self.bal_window.bal_plugin.read_file("icons/wizard.png") ) ) + # Tooltip so the icon is self-explanatory when hovered. + wizard.setToolTip(_("Wizard - Build your will")) wizard.clicked.connect(self.bal_window.init_wizard) # display = QPushButton(_("Display")) # display.clicked.connect(self.bal_window.preview_modal_dialog) @@ -461,13 +463,20 @@ class PreviewList(MyTreeView, MessageBoxMixin): self.bal_window.bal_plugin.read_file("icons/reload.png") ) ) + # Tooltip so the icon is self-explanatory when hovered. + refresh.setToolTip(_("Check")) refresh.clicked.connect(self.check) widget = QWidget(self) hlayout = QHBoxLayout(widget) + hlayout.setContentsMargins(0, 0, 0, 0) self.will_settings_widget = WillSettingsWidget(self.bal_window, self) - hlayout.addWidget(self.will_settings_widget) + # Toolbar order (left -> right): + # Wizard | Delivery time | Check Alive | Calendar | Check (refresh) + # The Wizard button goes first (leftmost); the settings widget already + # lays out delivery/check-alive/calendar in that order internally. hlayout.addWidget(wizard) + hlayout.addWidget(self.will_settings_widget) hlayout.addWidget(refresh) toolbar.insertWidget(2, widget) diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 6c68079..80f54d7 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -150,6 +150,7 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor): + " - y: number of years after currrent day(ex: 1y means one year from today)\n" ) label_text = None + tooltip_text = None base_field = None def __init__(self, bal_window, parent, default_locktime=None): @@ -182,6 +183,10 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor): #hbox.addWidget(QLabel(self.label_text)) help_button=HelpButton(self.help_text) help_button.setText(self.label_text) + # Show a short label (e.g. "Delivery time" / "Check Alive") when the + # user hovers the icon, so the emoji button is self-explanatory. + if self.tooltip_text: + help_button.setToolTip(_(self.tooltip_text)) #help_button.setStyleSheet("font-size: 155555); hbox.addWidget(help_button) self.combo.currentIndexChanged.connect(self.on_current_index_changed) @@ -422,6 +427,7 @@ class ThresholdTimeWidget(BalTimeEditWidget): ) label_text = "🚨" #label_text = "Check Alive" + tooltip_text = "Check Alive" base_field = "threshold" def __init__(self, bal_window, parent, init_value=None): @@ -442,6 +448,7 @@ class LockTimeWidget(BalTimeEditWidget): ) label_text = "🚛" #label_text = "Locktime" + tooltip_text = "Delivery time" base_field = "locktime" def __init__(self, bal_window, parent, init_value=None): @@ -468,6 +475,8 @@ class WillSettingsWidget(QWidget): self.bal_window.bal_plugin.read_file("icons/calendar.png") ) ) + # Tooltip so the icon is self-explanatory when hovered. + self.calendar_button.setToolTip(_("Calendar")) self.calendar_button.clicked.connect(self.open_or_save_calendar) self.widgets["locktime"] = LockTimeWidget(bal_window, self) self.widgets["threshold"] = ThresholdTimeWidget(bal_window, self) diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index cfca79e..975d85a 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -14,6 +14,8 @@ The actual Bitcoin logic lives in :mod:`bal.core`; this class only coordinates it with the GUI. """ +import threading + from .common import * from .common import _, _logger # underscore names are not re-exported by "import *" from .widgets import LockTimeWidget, PercAmountEdit, WillSettingsWidget @@ -898,15 +900,50 @@ class BalWindow: def check_transactions_task(self, will): start = time.time() - for wid, w in will.items(): - if self.waiting_dialog._stopping: - return - if w.we: - self.waiting_dialog.update( - "checking transaction: {}\n willexecutor: {}".format(wid, w.we["url"]) - ) + # Servers are now contacted in parallel (see + # Willexecutors.check_transactions_parallel) with a fast-fail timeout and + # a global deadline, so a single slow/dead will-executor no longer + # freezes the "checking transaction" dialog for minutes. The dialog + # shows live progress plus an elapsed-time counter (Xs / DEADLINEs). + targets = [(wid, w.we["url"]) for wid, w in will.items() if w.we] + total = len(targets) + deadline = Willexecutors.CHECK_GLOBAL_DEADLINE + done = {"count": 0} - w.set_check_willexecutor(Willexecutors.check_transaction(wid, w.we["url"])) + def _status_line(): + return "{} {}/{} ({}s / {}s)".format( + _("Checking transactions"), done["count"], total, + min(int(time.time() - start), deadline), deadline, + ) + + def on_each(wid, url, res, exc): + # Reuse the original per-item logic: set_check_willexecutor handles + # both a real response and a None/failure (-> CHECK_FAIL). + try: + will[wid].set_check_willexecutor(res) + except Exception as e: + _logger.error(f"check on_each error for {wid}: {e}") + done["count"] += 1 + self.waiting_dialog.update(_status_line()) + + def on_timeout(wid, url): + # The global deadline elapsed before this server answered: mark the + # item as failed (None response) so the user can retry later. + try: + will[wid].set_check_willexecutor(None) + except Exception as e: + _logger.error(f"check on_timeout error for {wid}: {e}") + + def on_tick(): + if getattr(self.waiting_dialog, "_stopping", False): + return + self.waiting_dialog.update(_status_line()) + + if total: + self.waiting_dialog.update(_status_line()) + Willexecutors.check_transactions_parallel( + targets, on_each=on_each, on_timeout=on_timeout, on_tick=on_tick + ) if time.time() - start < 3: time.sleep(3 - (time.time() - start)) @@ -1005,8 +1042,43 @@ class BalWindow: if fn_on_failure is None: fn_on_failure = log_error + base_msg = _("Downloading will-executors list...") + download_start = time.time() + # Upper bound shown to the user. fetch_will_executors_list tries up to + # two endpoints, each with timeout=10 and one retry (~21s worst case), + # so ~45s is a realistic maximum. Showing "Xs / 45s" tells the user how + # long they may have to wait instead of an open-ended counter. + download_deadline = 45 + def task(): - return self.fetch_will_executors_list(willexecutors) + # Heartbeat: show an elapsed-seconds counter (with the max wait made + # explicit) while the (blocking) download runs, so the user sees time + # advancing instead of a seemingly frozen dialog on a slow link. + stop_heartbeat = threading.Event() + + def _heartbeat(): + while not stop_heartbeat.wait(1.0): + if getattr(self.waiting_dialog, "_stopping", False): + return + try: + self.waiting_dialog.update( + "{} ({}s / {}s)".format( + base_msg, + min(int(time.time() - download_start), + download_deadline), + download_deadline, + ) + ) + except Exception: + return + + hb = threading.Thread(target=_heartbeat, name="bal-dl-hb", + daemon=True) + hb.start() + try: + return self.fetch_will_executors_list(willexecutors) + finally: + stop_heartbeat.set() def on_success(result): if result: @@ -1019,9 +1091,8 @@ class BalWindow: _logger.error(f"download_list failed: {exc_info}") self.show_warning(_(self.DOWNLOAD_FAILED_MESSAGE)) - msg = _("Downloading will-executors list...") self.waiting_dialog = BalWaitingDialog( - self, msg, task, on_success, on_failure, exe=False + self, base_msg, task, on_success, on_failure, exe=False ) self.waiting_dialog.exe() @@ -1034,9 +1105,23 @@ class BalWindow: # every server's (possibly timing-out) request. pinged = set() failed = set() + total = len(wes) + ping_start = time.time() + + ping_deadline = Willexecutors.PUSH_GLOBAL_DEADLINE def get_title(): + # Header shows progress + an elapsed-seconds counter with the max + # wait made explicit (e.g. "3s / 30s"), so the user sees time + # advancing and knows how long it may take, instead of a seemingly + # frozen dialog. + answered = len(pinged) + len(failed) msg = _("Ping Will-Executors:") + msg += " {}/{} ({}s / {}s)".format( + answered, total, + min(int(time.time() - ping_start), ping_deadline), + ping_deadline, + ) msg += "\n\n" for url in wes: urlstr = "{:<50}: ".format(url[:50]) @@ -1066,7 +1151,18 @@ class BalWindow: except Exception: pass - Willexecutors.ping_servers_parallel(wes, on_each=on_each) + # Refresh the elapsed-seconds counter while the (blocking) parallel ping + # runs. The tick is driven from THIS thread by ping_servers_parallel, + # the same thread that drives on_each, so the dialog repaint is reliable. + def on_tick(): + if getattr(self.waiting_dialog, "_stopping", False): + return + try: + self.waiting_dialog.update(get_title()) + except Exception: + pass + + Willexecutors.ping_servers_parallel(wes, on_each=on_each, on_tick=on_tick) def ping_willexecutors(self, wes, fn_on_success, fn_on_failure=None): def on_success(result): diff --git a/tests/parallel_ping_test.py b/tests/parallel_ping_test.py index d1a6734..6041f27 100644 --- a/tests/parallel_ping_test.py +++ b/tests/parallel_ping_test.py @@ -20,6 +20,7 @@ Run with: """ import importlib import sys +import threading import time PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.bal" @@ -122,6 +123,167 @@ def main(): finally: W.push_transactions_to_willexecutor = orig_push + # ---- 2b) global deadline: a hung server must not block past `deadline` ---- + def hanging_push(we, **kwargs): + # Simulate a server that never answers within the test window. + time.sleep(10) + return True + + orig_push2 = W.push_transactions_to_willexecutor + W.push_transactions_to_willexecutor = staticmethod(hanging_push) + try: + wes = { + "https://fast.example": { + "url": "https://fast.example", "txs": "x", "txsids": ["a"], + }, + "https://hang.example": { + "url": "https://hang.example", "txs": "y", "txsids": ["b"], + }, + } + # fast one answers quickly, hang one never does within the deadline + def fast_or_hang(we, **kwargs): + if "fast" in we["url"]: + return True + time.sleep(10) + return True + W.push_transactions_to_willexecutor = staticmethod(fast_or_hang) + + timed_out = [] + + def on_timeout(url, we): + timed_out.append(url) + + start = time.time() + W.push_transactions_parallel( + wes, max_workers=2, deadline=1.0, on_timeout=on_timeout + ) + elapsed = time.time() - start + assert elapsed < 3.0, f"deadline not enforced: waited {elapsed:.1f}s" + assert "https://hang.example" in timed_out, timed_out + print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, " + f"hung server reported via on_timeout") + finally: + W.push_transactions_to_willexecutor = orig_push2 + + # ---- 2c) on_tick is fired periodically from the CALLING thread ---- + # The elapsed-time counter is driven by an on_tick callback called from the + # thread that invokes push_transactions_parallel (the same thread that drives + # on_each), so its pyqtSignal repaints reliably. Assert the callback runs + # roughly once per tick_interval while the push is in flight, and that it + # runs on the calling thread (not on a worker/heartbeat thread). + def slow_push2(we, **kwargs): + time.sleep(SLOW * 6) # ~3s, long enough for several ticks + return True + + orig_push3 = W.push_transactions_to_willexecutor + W.push_transactions_to_willexecutor = staticmethod(slow_push2) + try: + wes = { + "https://tick.example": { + "url": "https://tick.example", "txs": "x", "txsids": ["a"], + }, + } + ticks = [] + caller_thread = threading.current_thread() + tick_threads = set() + + def on_tick(): + ticks.append(time.time()) + tick_threads.add(threading.current_thread()) + + W.push_transactions_parallel( + wes, max_workers=1, on_tick=on_tick, tick_interval=0.5 + ) + # ~3s push with 0.5s ticks => at least a few ticks. + assert len(ticks) >= 3, f"on_tick fired too few times: {len(ticks)}" + assert tick_threads == {caller_thread}, ( + "on_tick must run on the calling thread, got " + f"{[t.name for t in tick_threads]}" + ) + print(f"[OK] on_tick fired {len(ticks)} times from the calling thread") + finally: + W.push_transactions_to_willexecutor = orig_push3 + + # ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ---- + # Pressing "Check" verifies each will-executor still holds its tx. This used + # to be a sequential loop with default (~140s) timeouts, freezing the + # "checking transaction" dialog on a dead server. It must now run in + # parallel, enforce a global deadline, and drive an on_tick counter from the + # calling thread. + def slow_check(txid, url, **kwargs): + time.sleep(SLOW) + return {"tx": "ok"} if "good" in url else None + + orig_check = W.check_transaction + W.check_transaction = staticmethod(slow_check) + try: + targets = [] + for i in range(N): + kind = "good" if i % 2 else "bad" + targets.append((f"id{i}", f"https://{kind}-{i}.example")) + + checked = [] + + def on_each_check(wid, url, res, exc): + checked.append((wid, res)) + + start = time.time() + results = W.check_transactions_parallel( + targets, on_each=on_each_check, max_workers=N + ) + elapsed = time.time() - start + sequential = N * SLOW + assert elapsed < sequential * 0.6, ( + f"check not parallel: {elapsed:.2f}s vs {sequential:.2f}s") + assert len(results) == N, results + print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers " + f"(sequential would be ~{sequential:.2f}s)") + finally: + W.check_transaction = orig_check + + # 2d-bis) global deadline + on_tick from the calling thread + def hanging_check(txid, url, **kwargs): + if "fast" in url: + return {"tx": "ok"} + time.sleep(10) + return {"tx": "ok"} + + orig_check2 = W.check_transaction + W.check_transaction = staticmethod(hanging_check) + try: + targets = [ + ("idf", "https://fast.example"), + ("idh", "https://hang.example"), + ] + timed_out = [] + ticks = [] + caller_thread = threading.current_thread() + tick_threads = set() + + def on_timeout_check(wid, url): + timed_out.append(wid) + + def on_tick_check(): + ticks.append(time.time()) + tick_threads.add(threading.current_thread()) + + start = time.time() + W.check_transactions_parallel( + targets, max_workers=2, deadline=2.0, + on_timeout=on_timeout_check, on_tick=on_tick_check, + tick_interval=0.5, + ) + elapsed = time.time() - start + assert elapsed < 4.0, f"check deadline not enforced: {elapsed:.1f}s" + assert "idh" in timed_out, timed_out + assert len(ticks) >= 2, f"check on_tick fired too few times: {len(ticks)}" + assert tick_threads == {caller_thread}, ( + "check on_tick must run on the calling thread") + print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick " + f"fired {len(ticks)}x from the calling thread") + finally: + W.check_transaction = orig_check2 + # ---- 3) the wizard's loop_push must use the parallel helper ---- # The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push. # It previously looped over servers sequentially (one @@ -141,6 +303,48 @@ def main(): "wizard loop_push must not push to servers in a sequential loop") print("[OK] wizard loop_push uses push_transactions_parallel (not sequential)") + # The wizard counter must be driven via on_tick from the calling thread, NOT + # via a separate heartbeat thread (whose pyqtSignal emissions never + # repainted the dialog -> the counter was invisible during "Broadcasting"). + assert "on_tick" in code, ( + "wizard loop_push must drive the counter via on_tick (calling thread)") + assert "threading.Thread" not in code, ( + "wizard loop_push must not use a heartbeat thread for the counter " + "(its pyqtSignal emissions are not marshalled / never repaint)") + print("[OK] wizard loop_push drives the counter via on_tick (no heartbeat " + "thread)") + + # The counter must show the maximum wait too ("Xs / DEADLINEs"), so the user + # knows when the wizard will give up waiting, not just an open-ended number. + assert "PUSH_GLOBAL_DEADLINE" in code, ( + "wizard counter must reference the global deadline so it can show " + "'Xs / DEADLINEs'") + assert "{}s / {}s" in code or "s / {}s" in code, ( + "wizard counter must render the elapsed time AND the deadline " + "(e.g. '3s / 30s')") + print("[OK] wizard counter shows elapsed time AND the max deadline " + "(Xs / 30s)") + + # ---- 4) the "Check" dialog must use check_transactions_parallel ---- + # Pressing "Check" runs BalWindow.check_transactions_task. It used to loop + # over will-items sequentially calling check_transaction (default ~140s + # timeouts), freezing the "checking transaction" dialog. It must now use the + # parallel helper and show the elapsed-time counter. + window_mod = importlib.import_module(f"{PKG}.gui.qt.window") + check_src = inspect.getsource(window_mod.BalWindow.check_transactions_task) + check_code = "\n".join( + line for line in check_src.splitlines() + if not line.lstrip().startswith("#") + ) + assert "check_transactions_parallel" in check_code, ( + "check_transactions_task must use check_transactions_parallel") + assert "on_tick" in check_code, ( + "check dialog must drive its counter via on_tick (calling thread)") + assert "{}s / {}s" in check_code, ( + "check dialog counter must render elapsed time AND the deadline") + print("[OK] check_transactions_task uses check_transactions_parallel " + "with on_tick counter (Xs / 30s)") + print(f"\n[OK] parallel networking test passed for package {PKG!r}") return 0 From a8155183d7458eb594a0e6e3e1c0583eb28db65a Mon Sep 17 00:00:00 2001 From: GenSpark AI Developer Date: Mon, 15 Jun 2026 18:50:45 +0000 Subject: [PATCH 5/7] docs: translate networking report to English and update with Check/counters/GUI Translate REPORT_NETWORKING_PARALLELO.md to English and bring it up to date: - parallel push/ping/check with fast-fail timeouts + global deadline - reliable Xs/Ns elapsed-time counter driven by on_tick from the calling thread (replacing the unreliable raw heartbeat thread) - status-bar icon restore, toolbar tooltips and reorder - updated verification section (182 official tests, ruff 0 new issues) --- REPORT_NETWORKING_PARALLELO.md | 289 +++++++++++++++++++++------------ 1 file changed, 189 insertions(+), 100 deletions(-) diff --git a/REPORT_NETWORKING_PARALLELO.md b/REPORT_NETWORKING_PARALLELO.md index 6cbfbc0..d77d737 100644 --- a/REPORT_NETWORKING_PARALLELO.md +++ b/REPORT_NETWORKING_PARALLELO.md @@ -1,133 +1,213 @@ -# Report tecnico — Networking parallelo (anti-freeze Will-Executor) +# Technical report — Parallel networking (Will-Executor anti-freeze) + UI feedback -**Destinatario:** programmatore esterno / manutentore del plugin -**Autore:** refactoring AI (lavoro su GitHub `Bitcoin-after-life/test`) -**Data:** 2026-06-15 -**Branch:** `feature/networking-parallelo` -**Repository Gitea privato `kaibot/bal-plugin-ai`: NON modificato** (per richiesta esplicita). +**Audience:** external programmer / plugin maintainer +**Author:** AI refactoring work (on GitHub `Bitcoin-after-life/test`) +**Date:** 2026-06-15 +**Branch:** `feature/networking-parallelo` (Pull Request #4) +**Private Gitea repo `kaibot/bal-plugin-ai`: NOT modified** (per explicit request; cloned read-only only to run the official tests). --- -## 1. Problema +## 1. Problem -Quando il plugin contatta i server Will-Executor (invio transazioni, ping/aggiornamento -eredità, download lista), lo fa **in sequenza**. Se un server non risponde, il thread -resta bloccato sui timeout di connessione e, peggio, sui **retry**: +When the plugin contacts the Will-Executor servers (pushing transactions, +pinging/refreshing the inheritance, downloading the list, and **checking** +transactions), it used to do it **sequentially**. If a server did not answer, +the thread stayed blocked on the connection timeouts and, worse, on the +**retries**: -- `send_request` riprovava fino a **10 volte** con `time.sleep(3)` ad ogni timeout - → circa **130 secondi per ogni server irraggiungibile**, sommati uno dopo l'altro. +- `send_request` retried up to **10 times** with `time.sleep(3)` on every + timeout → roughly **~140 seconds per unreachable server**, summed one after + another. -Conseguenze: -- Con pochi server già si avverte; con **20 server** diventa ingestibile. -- L'utente vede "Rimani in attesa — Non risponde" senza capire cosa succede. -- Un singolo server morto blocca l'intera operazione. +Consequences: +- Noticeable already with a few servers; with **20 servers** it became + unusable. +- The user saw "Stay waiting — Not responding" with no idea what was happening. +- A single dead server blocked the whole operation. --- -## 2. Soluzione (in sintesi) +## 2. Solution (overview) -1. **Parallelismo** con `ThreadPoolExecutor`: i server vengono contattati - contemporaneamente. Il tempo totale ≈ server **più lento**, non la **somma**. -2. **Fast-fail** per le operazioni interattive (ping/info/download): niente retry-storm, - un solo timeout breve e il server viene marcato "KO". -3. **Feedback live**: callback `on_each(...)` che aggiorna la finestra di attesa - server-per-server, in modo thread-safe. -4. **Push transazioni**: parallelo, ma mantiene i retry per ogni singolo server - (una transazione reale non deve andare persa per un hiccup transitorio). +1. **Parallelism** with `ThreadPoolExecutor`: servers are contacted + concurrently. Total time ≈ the **slowest** server, not the **sum**. +2. **Fast-fail** for interactive operations (ping/info/download): no retry + storm, a single short timeout and the server is marked "KO". +3. **Aggressive timeouts + global deadline** for push/check: a short per-server + retry budget is kept (a real transaction must survive a transient hiccup), + but a wall-clock **global deadline** caps the whole batch so a dialog never + freezes behind one unresponsive server. +4. **Live feedback + reliable elapsed-time counter**: `on_each(...)` updates the + dialog as results arrive, and `on_tick()` refreshes an elapsed-time counter + (`Xs / DEADLINEs`) so the user always knows progress and the maximum wait. -### Perché è thread-safe -`Network.send_http_on_proxy()` usa `asyncio.run_coroutine_threadsafe(coro, loop)` e poi -`coro.result()`: ogni chiamata schedula la propria coroutine sullo stesso loop asyncio -condiviso di Electrum e blocca **solo il proprio worker thread**. Più chiamate -concorrenti sono quindi sicure → `ThreadPoolExecutor` dà vero parallelismo. +### Why it is thread-safe +`Network.send_http_on_proxy()` uses `asyncio.run_coroutine_threadsafe(coro, +loop)` and then `coro.result()`: every call schedules its own coroutine on +Electrum's shared asyncio loop and blocks **only its own worker thread**. +Multiple concurrent calls are therefore safe → `ThreadPoolExecutor` gives true +parallelism. -Gli aggiornamenti UI passano per `BalWaitingDialog.update()` che emette un -`pyqtSignal` → marshalling automatico sul thread GUI. I callback dai worker thread -possono quindi aggiornare il dialog in sicurezza. +UI updates go through `BalWaitingDialog.update()` / the dialog's `pyqtSignal`, +which marshals to the GUI thread automatically. Callbacks from worker threads +can therefore update the dialog safely. + +### Why the counter is driven from the calling thread (important) +An earlier attempt refreshed the elapsed-time counter from a separate raw +`threading.Thread` heartbeat that emitted the `pyqtSignal`. That proved +**unreliable**: a `pyqtSignal` emitted from a raw (non-Qt) Python thread inside +the wizard's `TaskThread` was not reliably marshalled and the dialog never +repainted — the counter was invisible. + +The fix: the parallel helpers accept an **`on_tick` callback that is invoked +periodically from the CALLING thread** (the same thread that already drives +`on_each` and successfully repaints). The helpers poll the futures in short +slices (`concurrent.futures.wait(..., timeout=tick_interval)`) and call +`on_tick()` between waits. No heartbeat thread is used anymore. --- -## 3. File modificati (tutto su GitHub `Bitcoin-after-life/test`, branch `feature/networking-parallelo`) +## 3. Modified files (all on GitHub `Bitcoin-after-life/test`, branch `feature/networking-parallelo`) ### 3.1 `bal/core/willexecutors.py` -**`send_request(...)`** — aggiunti due parametri keyword-only: +**Networking constants** (module level, also exposed as `Willexecutors` class +attributes for a single source of truth in the GUI): +```python +DEFAULT_TIMEOUT = 5 # interactive ops (ping/info/list) + +PUSH_TIMEOUT = 8 # broadcast (pushtxs) +PUSH_MAX_RETRIES = 2 +PUSH_RETRY_SLEEP = 1 +PUSH_GLOBAL_DEADLINE = 30 # wall-clock cap for the whole parallel push + +CHECK_TIMEOUT = 8 # check (searchtx) +CHECK_MAX_RETRIES = 1 +CHECK_RETRY_SLEEP = 1 +CHECK_GLOBAL_DEADLINE = 30 # wall-clock cap for the whole parallel check +``` +Worst case per server is now ~26s (push) / ~17s (check) instead of ~140s, and +the global deadline guarantees the dialog proceeds within 30s regardless. + +**`send_request(...)`** — keyword-only retry controls: ```python def send_request(method, url, data=None, *, timeout=10, handle_response=None, count_reply=0, max_retries=10, retry_sleep=3): ``` -- `max_retries` / `retry_sleep` controllano i retry sui timeout. -- **Default invariato** (`10` / `3s`) → il push critico mantiene il comportamento storico. -- Le operazioni interattive passano `max_retries=0` → fast-fail. +- Defaults unchanged → callers that need the historical behaviour are + unaffected. +- Interactive callers pass `max_retries=0` → fast-fail. -**`get_info_task(...)`** — fast-fail di default: -```python -def get_info_task(url, willexecutor, *, timeout=DEFAULT_TIMEOUT, - max_retries=0, retry_sleep=0): -``` -- Se la risposta non è un `dict` (timeout/vuoto) → `status="KO"`. +**`get_info_task(...)`** — fast-fail by default (`max_retries=0`); a +timeout/empty response yields `status="KO"`. -**NUOVO `ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8, timeout=DEFAULT_TIMEOUT)`** -- `ThreadPoolExecutor` + `as_completed`; un worker per server (`_ping_one`). -- Muta `willexecutors` in place (come il vecchio `ping_servers`). -- Invoca `on_each(url, we, ok)` man mano che arrivano i risultati. -- Un worker che esplode non blocca gli altri (try/except difensivo). +**`check_transaction(...)`** — now accepts `timeout`/`max_retries`/`retry_sleep` +(defaults from the `CHECK_*` constants) and forwards them to `send_request`, +replacing the old ~140s default storm. -**NUOVO `push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8)`** -- Push in parallelo solo verso le voci con chiave `"txs"`. -- Ogni server mantiene i propri retry (`push_transactions_to_willexecutor`). -- `on_each(url, we, ok, exc)`; raccoglie `AlreadyPresentException` separatamente. -- Ritorna `{url: (ok, exc)}`. +**NEW `ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8, +timeout=DEFAULT_TIMEOUT, on_tick=None, tick_interval=1.0)`** +- `ThreadPoolExecutor`; polls futures in slices and calls `on_tick()` from the + calling thread; mutates `willexecutors` in place; invokes + `on_each(url, we, ok)` as results arrive; a worker exception never blocks the + others (defensive try/except). -**`DEFAULT_TIMEOUT = 5`** (costante a livello di modulo). +**NEW `push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8, +deadline=PUSH_GLOBAL_DEADLINE, on_timeout=None, on_tick=None, +tick_interval=1.0)`** +- Parallel push only to entries that have a `"txs"` key; each server keeps its + short retry budget. +- `on_each(url, we, ok, exc)` per server; `on_timeout(url, we)` for servers + still pending when the global deadline elapses; `on_tick()` for the counter. +- Manual pool (no `with`) so `shutdown(wait=False, cancel_futures=True)` does + not block on a hung worker once the deadline is reached. +- Returns `{url: (ok, exc)}` for the servers that answered in time. + +**NEW `check_transactions_parallel(items, *, on_each=None, max_workers=8, +deadline=CHECK_GLOBAL_DEADLINE, on_timeout=None, on_tick=None, +tick_interval=1.0)`** +- Same design as the push helper but for the **Check** (searchtx) operation. +- `items` is an iterable of `(wid, url)` pairs; `_check_one` calls + `check_transaction`. +- `on_each(wid, url, result_or_None, exc)`, `on_timeout(wid, url)`, `on_tick()`. +- Returns `{wid: (result_or_None, exc)}`. ### 3.2 `bal/gui/qt/window.py` -- **`ping_willexecutors_task(self, wes)`** riscritto su `ping_servers_parallel(...)` - con feedback live (set `pinged`/`failed`, `get_title()` mostra "Ok"/"Ko"/"waiting..."). -- **`push_transactions_to_willexecutors(self, force=False)`** riscritto su - `push_transactions_parallel(...)`. `on_each` fa book-keeping + update UI thread-safe; - i server "already present" sono raccolti in `already_present[]` e il loro - `check_transaction` viene eseguito dopo, nel task thread (logica di check originale intatta). -- **`fetch_will_executors_list(...)`** download fast-fail: - `send_request("get", url, timeout=10, max_retries=1, retry_sleep=1)`. +- **`ping_willexecutors_task(self, wes)`** rewritten on `ping_servers_parallel` + with live feedback and a counter `Ping Will-Executors: 2/3 (3s / 30s)` driven + by `on_tick` from the calling thread. +- **`push_transactions_to_willexecutors(self, force=False)`** rewritten on + `push_transactions_parallel`; `on_each` does thread-safe book-keeping + UI + update; "already present" servers are verified afterwards (original check + logic intact). +- **`check_transactions_task(self, will)`** rewritten on + `check_transactions_parallel`; shows `Checking transactions: 2/5 (4s / 30s)`, + reusing the original `set_check_willexecutor(...)` per-item logic inside + `on_each` (and `set_check_willexecutor(None)` on `on_timeout`). +- **`fetch_will_executors_list(...)`** fast-fail download + (`timeout=10, max_retries=1, retry_sleep=1`); the download dialog shows + `Downloading will-executors list... (Xs / 45s)`. -### 3.3 `bal/core/util.py` — BUGFIX (regressione pre-esistente) +### 3.3 `bal/gui/qt/dialogs.py` -In `get_value_amount` (riga 324) era stato erroneamente usato `Util.in_output(...)` -(ritorna `bool`) al posto di `Util.din_output(...)` (ritorna la tupla -`(same_amount, same_address)`), causando: +- **`BalBuildWillDialog.loop_push`** (the "Building Will" wizard broadcast step) + rewritten on `push_transactions_parallel` with the `on_tick` counter + `Broadcasting 2/3 (5s / 30s)`. The previous raw heartbeat thread was removed. + +### 3.4 `bal/gui/qt/plugin.py` — status-bar icon (restored) + +`create_status_bar` re-adds the BAL `StatusBarButton` (bottom-right of the +Electrum status bar). It shows that the plugin is installed and opens the plugin +settings on click; it also de-duplicates the button per window. (Comments in +English.) + +### 3.5 `bal/gui/qt/lists.py` and `bal/gui/qt/widgets.py` — GUI usability + +- **Tooltips** (hover) on the Will toolbar icons, all in English: + Wizard (`Wizard - Build your will`), Delivery time (truck), Check Alive + (siren), Calendar, Check (refresh). +- **Toolbar order** changed to: + `Wizard | Delivery time | Check Alive | Calendar | Check`; layout margins + tightened so everything fits the Will window. + +### 3.6 `bal/core/util.py` — BUGFIX (pre-existing regression) + +In `get_value_amount` (line 324) `Util.in_output(...)` (returns `bool`) had been +used instead of `Util.din_output(...)` (returns the tuple +`(same_amount, same_address)`), causing: ``` TypeError: cannot unpack non-iterable bool object ``` -**Corretto** ripristinando `din_output`. Bug scoperto eseguendo i test ufficiali del -repo Gitea (`tests/test_core_util.py::test_get_value_amount`). +**Fixed** by restoring `din_output`. Found by running the official Gitea tests +(`tests/test_core_util.py::test_get_value_amount`). --- -## 4. Verifica (ruff + test ufficiali) +## 4. Verification (ruff + official tests) ### 4.1 ruff (lint / PEP8) -- `ruff check` sul codice nuovo: **nessun nuovo problema** introdotto - (i `F403/F405/F401` presenti derivano dal pattern `from .common import *` - dell'originale; conteggio identico HEAD vs working tree: 121 = 121). -- Le funzioni parallele nuove rispettano il limite di 88 caratteri (0 `E501`). -- `ruff check tests/parallel_ping_test.py` → **All checks passed**. +- `ruff check` on the new code: **no new issues** introduced. The `F403/F405/ + F401` warnings come from the original `from .common import *` pattern; + per-file counts are identical between HEAD and the working tree. +- The new parallel functions add **0 `E501`** (line-length) issues; in + `window.py` the count actually decreased after the rewrite. +- `ruff check tests/parallel_ping_test.py` → no new issues. -### 4.2 Test ufficiali del repo Gitea `kaibot/bal-plugin-ai/tests` -Eseguiti contro il codice refactorizzato (con le modifiche networking): +### 4.2 Official tests from the Gitea repo `kaibot/bal-plugin-ai/tests` +Run against the refactored code (with all the networking + UI changes): -| Suite | Esito | -|-------|-------| -| `test_core_*` (pytest) | **117 passed** | -| `test_gui_*` (pytest) | **65 passed** | +| Suite | Result | +|-------|--------| +| `test_core_*` + `test_gui_*` (pytest) | **182 passed** | | `smoke_test.py` | OK | | `external_zip_test.py` | OK | | `windows_overflow_test.py` | OK | | `gui_fixes_test.py` | OK | -| `parallel_ping_test.py` (nuovo) | OK — `0.50s` per 8 server (sequenziale ~`4.00s`) | +| `parallel_ping_test.py` (new) | OK — parallel ping/push/check ~`0.50s` for 8 servers (sequential would be ~`4.00s`); global deadline enforced; `on_tick` fired from the calling thread; static checks that the dialogs use the parallel helpers + the `Xs / Ns` counter | -Comandi (come da README): +Commands (as per README): ```bash QT_QPA_PLATFORM=offscreen PYTHONPATH= \ python3 -m pytest tests/ -q @@ -135,30 +215,39 @@ QT_QPA_PLATFORM=offscreen PYTHONPATH= \ python3 tests/smoke_test.py electrum.plugins.bal QT_QPA_PLATFORM=offscreen PYTHONPATH= \ python3 tests/external_zip_test.py bal-electrum-plugin.zip +QT_QPA_PLATFORM=offscreen PYTHONPATH= \ + python3 tests/parallel_ping_test.py bal ``` --- -## 5. Note di integrazione / rischi +## 5. Integration notes / risks -- **Nessuna modifica al protocollo server**: solo il *come* (parallelo) e il *quando* - (retry) delle chiamate cambia, non i payload. -- **Push transazioni**: i retry per-server sono mantenuti apposta, per non perdere - una transazione reale per un hiccup. Solo il ping/info/download usa fast-fail. -- **`max_workers=8`** è prudente; con molti server (es. 20) si può alzare, ma 8 - worker già abbattono il tempo totale al server più lento. -- **Thread/UI**: tutti gli aggiornamenti UI dai worker passano per - `BalWaitingDialog.update()` (pyqtSignal) → safe. Non toccare quel canale. -- **Compatibilità**: firme retro-compatibili (i nuovi parametri sono keyword-only - con default che preservano il vecchio comportamento). +- **No change to the server protocol**: only the *how* (parallel) and the + *when* (retries/deadline) of the calls changed, not the payloads. +- **Push transactions**: per-server retries are intentionally kept so a real + transaction is not lost to a transient hiccup; only ping/info/download use + fast-fail. The global deadline marks unanswered servers as failed (`on_timeout`) + so the user can retry later. +- **`max_workers=8`** is conservative; with many servers (e.g. 20) it can be + raised, but 8 workers already collapse the total time to the slowest server. +- **Thread/UI**: all UI updates from workers go through `pyqtSignal`-based + dialog updates; the periodic counter is driven by `on_tick` from the calling + thread. Do **not** reintroduce a raw heartbeat thread emitting signals — it + does not repaint reliably. +- **Compatibility**: signatures are backward compatible (new parameters are + keyword-only with defaults that preserve the old behaviour). --- -## 6. Come provare +## 6. How to test -1. Installare lo zip `bal-electrum-plugin.zip` (Tools → Plugins → install from file). -2. Configurare più Will-Executor, includendone **almeno uno irraggiungibile**. -3. Lanciare invio transazioni / ping: il dialog mostra lo stato server-per-server - e **non resta più bloccato** sul server morto. +1. Install `bal-electrum-plugin.zip` (Tools → Plugins → install from file). + Fully close and reopen Electrum to avoid the cached zip import. +2. Configure several Will-Executors, including **at least one unreachable**. +3. Run push / ping / Check: each dialog shows per-server status plus a counter + `N/total (Xs / 30s)` and **no longer freezes** on the dead server — within + the global deadline the operation reports the dead server and proceeds. -SHA-256 dello zip stampato da `build_zip.py` a fine build (verificare l'integrità). +The SHA-256 of the zip is printed by `build_zip.py` at the end of the build +(use it to verify integrity). From 714b17eacd258bdc8ab409e499f2fee09d893532 Mon Sep 17 00:00:00 2001 From: GenSpark AI Developer Date: Mon, 15 Jun 2026 20:35:55 +0000 Subject: [PATCH 6/7] fix(qt): auto-close Plugins manager, read-only field styling, RLock-safe heirs persistence GUI / plugin lifecycle: - Auto-close Electrum's native 'Electrum Plugins' manager dialog after the BAL plugin is hot-enabled. Electrum 4.7.x no longer calls the old init_qt hook, so the close is now triggered from the create_status_bar, init_menubar and load_wallet hooks (fired when reload_windows() recreates the window). - Robust dialog matching (isinstance / class name / localized window title) to cope with zipimport module-identity mismatches. - Robust dismissal of the modal dialog (reject()/done()/close()) with a retry schedule [400, 800, 1500] ms; if it still cannot be closed, fall back to bringing it to the front (showNormal/raise_/activateWindow) so it never lingers hidden in the background. Counting only visible top-levels avoids treating an already-closed dialog as still open. Read-only field styling: - Paint the locked Delivery time / Check Alive date editors and the mining-fee spinbox with a light-grey background (#f0f0f0) so the user can see at a glance that they are not editable outside the 'Build your will' wizard; the styling is cleared when the fields are made editable again. Pickle/RLock crash on 'Build will': - heirs.save() now sanitises the heirs mapping via _json_safe() before handing it to json_db.put(), which deep-copies the value. A live runtime object (holding a threading.RLock) slipping into an heir value previously raised 'TypeError: cannot pickle _thread.RLock object' and aborted the task; such values are now coerced to str and logged with their path. - init_heirs_to_locktime() coerces the locktime to a plain serializable scalar. - log_error() now accepts both a sys.exc_info() triple and a single exception instance, fixing the secondary 'TypeError object is not subscriptable' that masked the real error. --- bal/core/heirs.py | 42 ++++++++++- bal/gui/qt/common.py | 42 +++++++---- bal/gui/qt/dialogs.py | 5 +- bal/gui/qt/plugin.py | 168 ++++++++++++++++++++++++++++++++++++++++++ bal/gui/qt/widgets.py | 88 ++++++++++++++++++++-- bal/gui/qt/window.py | 25 +++++-- 6 files changed, 342 insertions(+), 28 deletions(-) diff --git a/bal/core/heirs.py b/bal/core/heirs.py index 4200f54..9a369cd 100644 --- a/bal/core/heirs.py +++ b/bal/core/heirs.py @@ -306,6 +306,42 @@ def get_change_output(wallet, in_amount, out_amount, fee): return out +def _json_safe(value, _path="heirs", _depth=0): + """Return a JSON-serializable deep copy of *value*. + + The wallet DB persists the heirs dict via ``json_db.put``, which calls + ``copy.deepcopy`` on the value. If any nested element is a live runtime + object (e.g. one holding a ``threading.RLock``), deepcopy raises + ``TypeError: cannot pickle '_thread.RLock' object`` and the whole + "Build will" task fails. + + To make persistence robust we coerce the structure to plain + JSON-compatible types (dict / list / str / int / float / bool / None). + Anything else is converted to ``str(value)`` and logged with its path so + the offending field can be identified, instead of crashing the task. + """ + # Primitive JSON scalars are kept as-is. + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, dict): + return { + str(k): _json_safe(v, "{}[{!r}]".format(_path, k), _depth + 1) + for k, v in value.items() + } + if isinstance(value, (list, tuple)): + return [ + _json_safe(v, "{}[{}]".format(_path, i), _depth + 1) + for i, v in enumerate(value) + ] + # Unexpected runtime object: do not let it reach deepcopy. Log where it + # was found so the real source can be fixed, then store a safe string. + _logger.error( + "heirs.save: non-serializable value at {} (type={}); coercing to str. " + "value={!r}".format(_path, type(value).__name__, value) + ) + return str(value) + + class Heirs(dict, Logger): def __init__(self, wallet): @@ -322,7 +358,11 @@ class Heirs(dict, Logger): invalidate_inheritance_transactions(wallet) def save(self): - self.db.put("heirs", dict(self)) + # Sanitise the heirs mapping before handing it to the wallet DB: this + # guarantees only JSON-serializable values are stored and prevents the + # "cannot pickle '_thread.RLock' object" failure that aborted the + # Build-will task when a runtime object slipped into an heir value. + self.db.put("heirs", _json_safe(dict(self))) def import_file(self, path): data = read_json_file(path) diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index 9d2ab69..53126de 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -53,10 +53,10 @@ from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, Qt, QTimer, pyqtSignal) from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem, QStandardItemModel) -from PyQt6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, - QDateTimeEdit, QGridLayout, QHBoxLayout, QLabel, - QLineEdit, QTextEdit, QMenu, QMenuBar, QPushButton, - QScrollArea, QSizePolicy, QSpinBox, +from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox, + QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout, + QLabel, QLineEdit, QTextEdit, QMenu, QMenuBar, + QPushButton, QScrollArea, QSizePolicy, QSpinBox, QStackedWidget, QStyle, QStyleOptionFrame, QVBoxLayout, QWidget, QDialog) @@ -116,18 +116,34 @@ class CheckAliveError(Exception): def log_error(exec_info, window=None): - _logger.error(f"LOG_ERROR: {exec_info}") - #tb = traceback.format_exc() - try: - tb=exec_info[1] - _logger.error(tb) - except Exception: - tb = traceback.format_exc() - _logger.error(tb) + """Log an error and optionally show it. + ``exec_info`` may be either a ``sys.exc_info()`` triple + ``(type, value, traceback)`` or a single exception instance (callers use + both forms), so we handle both and always try to log a full traceback. + """ + _logger.error(f"LOG_ERROR: {exec_info}") + exc = None + if isinstance(exec_info, BaseException): + exc = exec_info + elif isinstance(exec_info, (tuple, list)) and len(exec_info) >= 2: + # sys.exc_info() form: the exception instance is the 2nd element. + exc = exec_info[1] + try: + if exc is not None: + _logger.error( + "".join( + traceback.format_exception(type(exc), exc, exc.__traceback__) + ) + ) + else: + _logger.error(traceback.format_exc()) + except Exception: + _logger.error(traceback.format_exc()) if window is not None: - window.show_error(exec_info) + # show_error expects a human-readable message, not a triple. + window.show_error(str(exc) if exc is not None else str(exec_info)) diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 4914e48..f187bf2 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -381,7 +381,10 @@ class BalWizardLocktimeAndFeeWidget(BalWizardWidget): widget = QWidget() layout = QVBoxLayout(widget) - layout.addWidget(WillSettingsWidget(self.bal_window, self, "v")) + # The wizard ("Build your will") is the ONLY place the delivery time, + # check alive and fee can be edited, so it is the only read_only=False. + layout.addWidget(WillSettingsWidget(self.bal_window, self, "v", + read_only=False)) spacer_widget = QWidget() spacer_widget.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index 04e8fe3..d52e588 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -61,6 +61,154 @@ class Plugin(BalPlugin): _logger.error("Error loading plugin {}".format(e)) raise e + @staticmethod + def _close_plugins_manager_dialog(): + """Close Electrum's "Electrum Plugins" manager dialog if it is open. + + This is the native Electrum ``PluginsDialog`` (a ``WindowModalDialog``); + it is not owned by this plugin, so we locate it among the application's + top-level widgets and close it. Failures are non-fatal: leaving the + dialog open is harmless, so we never propagate exceptions from here. + """ + Plugin._handle_plugins_manager_dialog(attempt=0) + + @staticmethod + def _find_plugins_manager_dialogs(): + """Return the open Electrum "Electrum Plugins" manager dialog(s). + + The match is intentionally permissive: when our plugin is loaded from a + zip (``electrum_external_plugins``), ``isinstance`` against the imported + ``PluginsDialog`` class can fail due to differing module identities, so + we also match by class name and by window title (including the localized + title, since the user runs Electrum under a non-English locale). + """ + try: + from PyQt6.QtWidgets import QApplication + except Exception: + return [] + try: + from electrum.gui.qt.plugins_dialog import PluginsDialog + except Exception: + PluginsDialog = None + app = QApplication.instance() + if app is None: + return [] + # Accept both the English title and the translated one. We cannot rely + # only on _() because the dialog object may have been built with a + # different gettext binding than ours when loaded from a zip. + titles = {"Electrum Plugins"} + try: + titles.add(_("Electrum Plugins")) + except Exception: + pass + found = [] + for w in app.topLevelWidgets(): + try: + is_match = False + if PluginsDialog is not None and isinstance(w, PluginsDialog): + is_match = True + elif type(w).__name__ == "PluginsDialog": + is_match = True + elif w.windowTitle() in titles: + is_match = True + if not is_match: + continue + # Only count it as "open" if it is actually visible: after a + # successful close()/reject() the QDialog object still lives in + # topLevelWidgets() but becomes invisible, so filtering by + # isVisible() is what tells "still open" from "already closed". + visible = w.isVisible() + _logger.info( + "plugins manager dialog match: cls={} title={!r} " + "visible={}".format( + type(w).__name__, w.windowTitle(), visible + ) + ) + if visible: + found.append(w) + except Exception as e: + _logger.debug("inspecting top-level widget failed: {}".format(e)) + return found + + @staticmethod + def _try_dismiss_dialog(d): + """Attempt to dismiss a (possibly modal) dialog as robustly as we can. + + A ``PluginsDialog`` is opened with ``exec()`` (a nested, *application- + modal* event loop). Inside such a loop a plain ``close()`` is not + always honoured, so we also try ``reject()`` / ``done()`` which end the + modal loop directly. Any of these may fail depending on Qt state, so + each is guarded independently. + """ + try: + from PyQt6.QtWidgets import QDialog + except Exception: + QDialog = None + # 1) reject() / done(): the reliable way to end an exec() modal loop. + if QDialog is not None and isinstance(d, QDialog): + try: + d.reject() + except Exception as e: + _logger.debug("reject() failed: {}".format(e)) + try: + d.done(QDialog.DialogCode.Rejected) + except Exception as e: + _logger.debug("done() failed: {}".format(e)) + # 2) close(): covers non-QDialog top-levels and is a harmless extra. + try: + d.close() + except Exception as e: + _logger.debug("could not close plugins dialog: {}".format(e)) + + @staticmethod + def _handle_plugins_manager_dialog(attempt=0): + """Try to auto-close the manager dialog; retry a few times. + + Enabling the plugin happens while Electrum's ``PluginsDialog`` may still + be running its own modal event loop, so a single ``close()`` can be + ignored. We retry on a short schedule and, if it is still open after the + last attempt, fall back to bringing it to the front so the user notices + it and closes it themselves (it must not linger in the background). + """ + try: + from PyQt6.QtCore import QTimer + except Exception: + QTimer = None + # Schedule of retry delays (ms) measured from each call. + retry_delays = [400, 800, 1500] + dialogs = Plugin._find_plugins_manager_dialogs() + _logger.info( + "auto-close plugins dialog: attempt={} found={}".format( + attempt, len(dialogs) + ) + ) + for d in dialogs: + Plugin._try_dismiss_dialog(d) + # Re-check: anything still visible? + still_open = Plugin._find_plugins_manager_dialogs() + if not still_open: + _logger.info("plugins dialog closed successfully") + return + if attempt < len(retry_delays) and QTimer is not None: + QTimer.singleShot( + retry_delays[attempt], + lambda: Plugin._handle_plugins_manager_dialog(attempt + 1), + ) + return + # Final fallback: we could not close it -> at least raise it to the + # front so it does not stay hidden in the background. + _logger.info( + "could not close plugins dialog after {} attempts; " + "bringing it to front".format(attempt + 1) + ) + for d in still_open: + try: + d.showNormal() + d.raise_() + d.activateWindow() + except Exception as e: + _logger.debug("could not raise plugins dialog: {}".format(e)) + def _setup_window(self, window, *, load_open_wallet): """Create the BalWindow for *window* and wire its menu (and, when enabling hot, the already-open wallet). @@ -124,11 +272,28 @@ class Plugin(BalPlugin): sb.addPermanentWidget(b) self._statusbar_buttons[key] = b + # When the plugin is enabled "hot" from Tools -> Plugins, Electrum keeps + # its "Electrum Plugins" manager dialog open and even calls + # bring_to_front on it. Enabling triggers reload_windows(), which + # recreates the window and therefore fires this create_status_bar hook; + # that makes this the right place to auto-close the leftover manager + # dialog (Electrum 4.7.x no longer calls the old init_qt hook). + # + # We use a QTimer so this runs *after* Electrum's own bring_to_front + # (QTimer.singleShot(100, ...)); a slightly larger delay makes our close + # win. On a normal startup no PluginsDialog is open, so the helper is a + # harmless no-op. + QTimer.singleShot(250, self._close_plugins_manager_dialog) + @hook def init_menubar(self, window): _logger.info("HOOK init_menubar") w = self.get_window(window) w.init_menubar_tools(window.tools_menu) + # Also try here: init_menubar is one of the hooks fired when Electrum + # recreates the window during a hot enable (reload_windows()), so it is + # another reliable trigger to auto-close the leftover manager dialog. + QTimer.singleShot(300, self._close_plugins_manager_dialog) @hook def load_wallet(self, wallet, main_window): @@ -142,6 +307,9 @@ class Plugin(BalPlugin): ) w.disable_plugin = False w.ok = True + # load_wallet is fired on the recreated window during a hot enable too; + # use it as an extra trigger to auto-close the leftover manager dialog. + QTimer.singleShot(350, self._close_plugins_manager_dialog) @hook def close_wallet(self, wallet): diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 80f54d7..997d8b5 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -64,6 +64,23 @@ class BalTxFeesWidget(QWidget): def doubleclick(self, event=None): pass + + def set_read_only(self, read_only=True): + # Show the fee but make it non-editable (no spin arrows, no keyboard), + # so it can only be changed from the "Build your will" wizard. + self.txfee_widget.setReadOnly(read_only) + self.txfee_widget.setButtonSymbols( + QAbstractSpinBox.ButtonSymbols.NoButtons + if read_only + else QAbstractSpinBox.ButtonSymbols.UpDownArrows + ) + # Light-grey background when locked, so the read-only state is visible + # (same look as the date fields); empty stylesheet restores the + # editable appearance used inside the wizard. + self.txfee_widget.setStyleSheet( + "QSpinBox{background-color:#f0f0f0;}" if read_only else "" + ) + def get_value(self): return self.txfee_widget.value() @@ -275,6 +292,17 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor): self.current_value = x self.bal_window.update_setting_widgets(x, self.base_field) + def set_read_only(self, read_only=True): + """Show the value but make it non-editable. + + Used everywhere except the "Build your will" wizard, where the date is + the only place the user is allowed to change it. The Raw/Date combo is + disabled and both editors become read-only with no spin buttons. + """ + self.combo.setEnabled(not read_only) + for w in self.editors: + w.set_read_only(read_only) + class TimeRawEditWidget(QWidget): @@ -295,6 +323,14 @@ class TimeRawEditWidget(QWidget): self.get_value = self.editor.get_value self.set_value = self.editor.set_value + def set_read_only(self, read_only=True): + self.editor.setReadOnly(read_only) + # Match the Date editor: grey background when locked so the read-only + # state is visible; empty stylesheet restores the editable look. + self.editor.setStyleSheet( + "QLineEdit{background-color:#f0f0f0;}" if read_only else "" + ) + class LockTimeRawEdit(QLineEdit, _LockTimeEditor): @@ -387,6 +423,24 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor): #self.setDateTime(QDateTime.currentDateTime()) self.time_edit = time_edit + def set_read_only(self, read_only=True): + # Read-only display: keyboard editing disabled and the up/down spin + # arrows removed, so the date can only be changed from the wizard. + self.setReadOnly(read_only) + self.setButtonSymbols( + QAbstractSpinBox.ButtonSymbols.NoButtons + if read_only + else QAbstractSpinBox.ButtonSymbols.UpDownArrows + ) + # A read-only QDateTimeEdit keeps a white background by default, which + # does not visually signal that it is locked. Paint it light grey (like + # the disabled combo/fee fields next to it) so the user sees at a glance + # that the date is not editable here; an empty stylesheet restores the + # default look when the field is made editable again (in the wizard). + self.setStyleSheet( + "QDateTimeEdit{background-color:#f0f0f0;}" if read_only else "" + ) + def get_value(self) -> Optional[int]: #dt = self.dateTime().toPyDateTime() #locktime = int(time.mktime(dt.timetuple())) @@ -419,11 +473,15 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor): class ThresholdTimeWidget(BalTimeEditWidget): + # rich_text=True is used by the HelpButton, so HTML tags (,
) render. help_text = ( - "Check to ask for invalidation.\n\n" - "When less then this time is missing, ask to invalidate.\n" - "If you fail to invalidate during this time, your transactions will be delivered to your heirs.\n\n" - f"{BalTimeEditWidget.help_text}" + "CHECK ALIVE

" + "Check to ask for invalidation.

" + "When less then this time is missing, ask to invalidate.
" + "If you fail to invalidate during this time, your transactions will be delivered to your heirs.

" + "if you choose Raw, you can insert various options based on suffix:
" + " - d: number of days after current day(ex: 1d means tomorrow)
" + " - y: number of years after currrent day(ex: 1y means one year from today)
" ) label_text = "🚨" #label_text = "Check Alive" @@ -441,10 +499,14 @@ class ThresholdTimeWidget(BalTimeEditWidget): class LockTimeWidget(BalTimeEditWidget): + # rich_text=True is used by the HelpButton, so HTML tags (,
) render. help_text = ( - "Set Locktime for transactions.\n" - "Any time is needed transaction will be anticipated by 1day\n" - f"{BalTimeEditWidget.help_text}" + "DELIVERY TIME

" + "Set Locktime for transactions.
" + "Any time is needed transaction will be anticipated by 1day

" + "if you choose Raw, you can insert various options based on suffix:
" + " - d: number of days after current day(ex: 1d means tomorrow)
" + " - y: number of years after currrent day(ex: 1y means one year from today)
" ) label_text = "🚛" #label_text = "Locktime" @@ -463,10 +525,15 @@ class LockTimeWidget(BalTimeEditWidget): class WillSettingsWidget(QWidget): - def __init__(self, bal_window: "BalWindow", parent, layout_type="h"): + def __init__(self, bal_window: "BalWindow", parent, layout_type="h", + read_only=True): self.widgets = {} QWidget.__init__(self, parent) self.bal_window = bal_window + # When read_only=True (toolbars, Heirs tab) the delivery time, check + # alive and fee fields are display-only; they can only be edited from + # the "Build your will" wizard, which passes read_only=False. + self.read_only = read_only box = QHBoxLayout(self) if layout_type == "h" else QVBoxLayout(self) self.calendar_button = QPushButton() @@ -496,6 +563,11 @@ class WillSettingsWidget(QWidget): box.addWidget(self.calendar_button) box.addWidget(self.widgets["baltx_fees"]) + if self.read_only: + self.widgets["locktime"].set_read_only(True) + self.widgets["threshold"].set_read_only(True) + self.widgets["baltx_fees"].set_read_only(True) + def create_alarms(self, alarm_start, alarm_end): days = (alarm_end - alarm_start).days+1 lines = [] diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 975d85a..f3e4cb6 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -432,11 +432,26 @@ class BalWindow: self.bal_plugin.WILL_SETTINGS.set(self.will_settings) def init_heirs_to_locktime(self, multiverse=False): - #pass - for heir in self.heirs: - h = self.heirs[heir] - if not multiverse: - self.heirs[heir] = [h[0], h[1], self.will_settings["locktime"]] + if multiverse: + return + # Coerce the locktime to a plain serializable scalar: will_settings is + # read from Electrum's config and a non-primitive value here would end + # up inside the heirs dict and break json_db persistence (this was one + # path to the "cannot pickle '_thread.RLock' object" error). + locktime = self.will_settings["locktime"] + if not isinstance(locktime, (int, float, str)): + locktime = str(locktime) + # Iterate over a snapshot of the keys: assigning to self.heirs[...] + # triggers Heirs.__setitem__ -> save(), which mutates the mapping while + # we iterate it. Building the new values first and applying them after + # the loop avoids "dict changed size during iteration" and the repeated + # save() on every heir. + 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 def init_class_variables(self): if not self.heirs: From a394cde0b53f2af3ae7271eb90617e4e1046fec9 Mon Sep 17 00:00:00 2001 From: GenSpark AI Developer Date: Mon, 15 Jun 2026 21:52:22 +0000 Subject: [PATCH 7/7] feat(will): invalidate signed will on postpone + add Server status column Postpone safety (Strategy B): - A signed/sent will carries an immutable locktime; postponing the delivery time previously did nothing, so a will-executor could still broadcast the old (earlier-locktime) transaction and execute the inheritance too early. - core/will.py: add WillPostponedException and detect postpone by comparing the requested locktime against w.tx.locktime (the locktime frozen in the signed transaction) instead of the in-memory heir entry, which is updated together with the new value and would always compare equal. - gui/qt/dialogs.py (BalBuildWillDialog.task_phase1, the real path used by Tools -> Prepare): handle WillPostponedException before NotCompleteWill; return (None, tx) to trigger sign + broadcast of the invalidation, then the user presses Prepare again to rebuild/re-sign/re-send (two explicit steps). - gui/qt/window.py: mirror the branch in build_inheritance_transaction with an explanatory message; wording aligned to the 'Prepare' button. - gui/qt/common.py: export WillPostponedException. - A postpone on a will that was never signed/sent just rebuilds (no on-chain fee). Server status column: - gui/qt/lists.py: add a dedicated 'Server' column to PreviewList with an always-readable label and a tooltip (will-executor URL + state). - gui/qt/theme.py: add server_status_text() and server_status_tooltip(), reusing the existing status flags. - gui/qt/common.py: export the new theme helpers. Docs: update README.md, bal/README.md and CHANGELOG_REFACTOR.md. Tests: 182 passed; smoke + external-zip OK; ruff has no new real findings. --- CHANGELOG_REFACTOR.md | 61 +++++++++++++++++++++++++++++++++++++++++++ README.md | 42 +++++++++++++++++++++++++++++ bal/README.md | 21 +++++++++++++++ bal/core/will.py | 58 ++++++++++++++++++++++++++++++++++------ bal/gui/qt/common.py | 4 +-- bal/gui/qt/dialogs.py | 14 ++++++++++ bal/gui/qt/lists.py | 12 +++++++++ bal/gui/qt/theme.py | 38 +++++++++++++++++++++++++++ bal/gui/qt/window.py | 23 ++++++++++++++++ 9 files changed, 263 insertions(+), 10 deletions(-) diff --git a/CHANGELOG_REFACTOR.md b/CHANGELOG_REFACTOR.md index ecb1694..019ecf1 100644 --- a/CHANGELOG_REFACTOR.md +++ b/CHANGELOG_REFACTOR.md @@ -455,3 +455,64 @@ producono **lo stesso identico risultato** di prima. Verificato anche che il test **fallisce** senza il fix. Confermato dall'utente: **"si ora funziona"**. + +## 14. NUOVA FUNZIONE: invalidazione automatica al posticipo dell'eredità + +### Problema +Una transazione di eredità viene firmata con un **locktime fisso e immutabile** +e inviata ai will-executor, che sono economicamente incentivati a trasmetterla +(incassano le fee). Se l'utente, dopo aver firmato/inviato, **posticipa** la +data di consegna (es. di un anno), la **vecchia** transazione gia firmata resta +valida sui server dei will-executor. Poiche ha il locktime piu basso, un +will-executor potrebbe trasmetterla appena scade, eseguendo l'eredita **in +anticipo** rispetto alla nuova volonta dell'utente. La versione precedente +**non gestiva** questo caso: il posticipo non produceva alcuna azione. + +### Soluzione (Strategia B — invalidazione esplicita on-chain) +Al posticipo di un'eredita **gia firmata e/o inviata** (stato `COMPLETE` o +`PUSHED`), il plugin chiede di **invalidare on-chain** i fondi prima di +ricostruire la nuova eredita. L'invalidazione spende gli stessi UTXO verso un +nuovo indirizzo di change con `locktime = altezza corrente` (RBF), quindi e +trasmettibile subito: una volta confermata, la vecchia transazione pre-firmata +diventa **definitivamente inutilizzabile**, vincendo la corsa contro qualunque +will-executor. + +### Dettagli tecnici +- **`core/will.py`**: + - nuova eccezione `WillPostponedException` (sottoclasse di + `NotCompleteWillException`); + - `check_willexecutors_and_heirs`: il confronto del locktime non usa piu + l'entry dell'erede memorizzata (`their[2]`), che viene aggiornata in memoria + insieme al nuovo valore al momento del posticipo e quindi risulterebbe + sempre uguale. Ora confronta il locktime richiesto con **`w.tx.locktime`**, + cioe il locktime **congelato** nella transazione firmata (immutabile, e + quello che i will-executor possiedono). Tre casi: invariato → coerente; + nuovo > tx su will firmato/inviato → `WillPostponedException`; nuovo > tx su + will mai inviato → semplice ricostruzione (nessuna fee on-chain). +- **`gui/qt/dialogs.py`** (`BalBuildWillDialog.task_phase1`, il percorso reale + usato da **Tools → Prepare**): aggiunto il ramo `except WillPostponedException` + **prima** di `NotCompleteWillException`; si comporta come il caso "will + scaduto" e ritorna `(None, tx)` per innescare firma + broadcast + dell'invalidazione. L'utente preme di nuovo **Prepare** per ricostruire, + rifirmare e reinviare la nuova eredita (due passi espliciti, per maggior + controllo). +- **`gui/qt/window.py`** (`build_inheritance_transaction`): aggiunto lo stesso + ramo per completezza del percorso alternativo, con messaggio esplicativo. +- **`gui/qt/common.py`**: `WillPostponedException` esportato. + +### NUOVA COLONNA "Server" nella lista transazioni +Per dare all'utente visibilita costante sullo stato online delle proprie +transazioni di eredita, e stata aggiunta una colonna dedicata **"Server"** in +`PreviewList` (`gui/qt/lists.py`), con etichetta sempre leggibile +(`Confirmed on server`, `Sent (not checked)`, `Send failed`, `Not on server`, +`Signed (not sent)`, `Not sent`) e **tooltip** con URL del will-executor e +stato. Le funzioni `server_status_text()` e `server_status_tooltip()` sono in +`gui/qt/theme.py` e riusano gli stessi flag di stato gia esistenti. + +### Test +- I 182 test ufficiali continuano a passare; smoke test ed external-zip test + OK; `ruff` senza nuove segnalazioni reali. +- Verificato sui dati reali del log dell'utente: il posticipo di un'eredita + firmata ora rileva correttamente la condizione e avvia l'invalidazione. + +Confermato dall'utente: **"mi pare che funziona"**. diff --git a/README.md b/README.md index 6d5f70c..5e1449c 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,48 @@ Copy the `bal/` directory into your Electrum installation's `electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json` exists, then enable it from **Tools → Plugins**. +## Inheritance safety: anticipate / postpone + +A will transaction is signed with a **fixed, immutable locktime** and then +optionally sent to will-executor servers, which are economically incentivised +to broadcast it (they collect fees). Because the locktime is baked into the +signed transaction, simply changing the delivery time later is **not enough**: +the old, already-signed transaction keeps living on the will-executors. + +The plugin handles the two cases as follows (triggered when you press +**Tools → Prepare**): + +* **Anticipate** (new delivery time *earlier* than the signed locktime): the + will is treated as expired and you are asked to **invalidate** the old + transaction on-chain, then rebuild. +* **Postpone** (new delivery time *later* than the signed locktime) on a will + that was already **signed and/or pushed**: the previously committed coins + must be invalidated on-chain **first**, otherwise a will-executor could + broadcast the old (earlier-locktime) transaction and execute the inheritance + *too early*. The plugin detects this by comparing the requested locktime with + the locktime **frozen inside the signed transaction** (`tx.locktime`), and + asks you to sign and broadcast an invalidation transaction. After it is + broadcast, press **Prepare** again to rebuild, re-sign and re-send the new + (postponed) inheritance. Postponing a will that was *never* signed/sent just + rebuilds it (no on-chain fee). + +## Transaction list: the "Server" column + +The will transaction list shows a dedicated **Server** column so you always +know whether each inheritance transaction is actually stored on the +will-executor servers, independently of the row colour: + +| Label | Meaning | +| --- | --- | +| `Confirmed on server` | the will-executor confirmed it stored the transaction | +| `Sent (not checked)` | pushed to the will-executor, not yet re-checked | +| `Send failed` / `Not on server` | push failed or the server no longer has it | +| `Signed (not sent)` | signed locally, not sent to any will-executor | +| `Not sent` | not signed/sent yet | + +Hovering the cell shows a tooltip with the will-executor URL and the current +state. + ## Testing ```bash diff --git a/bal/README.md b/bal/README.md index 5896ad7..9cb3127 100644 --- a/bal/README.md +++ b/bal/README.md @@ -1,2 +1,23 @@ # BalPlugin Bitcoin After Life Electrum Plugin + +Free and decentralized Bitcoin inheritance support for Electrum: build +time-locked "will" transactions that transfer your funds to your heirs if you +stop refreshing them (dead-man's switch), optionally relayed by will-executor +servers. + +## Key behaviours + +- **Anticipate / postpone safety**: changing the delivery time of an + already-signed will is handled safely. Postponing a signed/sent will first + asks you to invalidate the old transaction on-chain (so a will-executor can + never broadcast the earlier-locktime transaction and execute the inheritance + too early), then lets you rebuild and re-send the new one via + **Tools → Prepare**. +- **"Server" column**: the will transaction list shows whether each transaction + is actually stored on the will-executor servers + (`Confirmed on server`, `Sent (not checked)`, `Send failed`, + `Not on server`, `Signed (not sent)`, `Not sent`), with a tooltip showing the + will-executor URL. + +See the top-level [`README.md`](../README.md) for installation and testing. diff --git a/bal/core/will.py b/bal/core/will.py index f4a5f33..1576c6e 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -671,14 +671,41 @@ class Will: their = will[wid].heirs[wheir] if heir := heirs.get(wheir, None): - if ( - heir[0] == their[0] - and heir[1] == their[1] - and Util.parse_locktime_string(heir[2]) - >= Util.parse_locktime_string(their[2]) - ): - count = heirs_found.get(wheir, 0) - heirs_found[wheir] = count + 1 + if heir[0] == their[0] and heir[1] == their[1]: + # The requested (possibly new) locktime for this heir. + new_locktime = Util.parse_locktime_string(heir[2]) + # IMPORTANT: compare against the locktime that is + # actually frozen inside the already-signed Bitcoin + # transaction (w.tx.locktime), NOT against their[2]. + # their[2] is the heir entry stored in the will item, + # which is updated in memory together with the new + # heirs dict when the user postpones, so it would + # always equal new_locktime and the postpone would go + # undetected. w.tx.locktime is immutable once signed + # and is exactly what the will-executors hold. + tx_locktime = int(w.tx.locktime) + if new_locktime == tx_locktime: + # Unchanged: this heir is still coherent. + count = heirs_found.get(wheir, 0) + heirs_found[wheir] = count + 1 + elif new_locktime > tx_locktime and ( + w.get_status("COMPLETE") or w.get_status("PUSHED") + ): + # POSTPONE of an already signed/sent will: the + # old pre-signed tx must be invalidated on-chain + # first, otherwise a will-executor could + # broadcast the earlier-locktime tx and execute + # the inheritance too early. + raise WillPostponedException( + f"{wheir}: locktime postponed " + f"{tx_locktime}->{new_locktime} " + f"on a signed/sent will" + ) + # new_locktime < tx_locktime (anticipate) is left to + # check_will_expired -> WillExpiredException. + # new_locktime > tx_locktime on a will that was never + # signed/sent falls through here -> a plain rebuild via + # HeirNotFoundException (no on-chain fee needed). else: _logger.debug( f"heir not present transaction is not valid:{wheir} {wid}, {w}" @@ -912,6 +939,21 @@ class HeirNotFoundException(NotCompleteWillException): pass +class WillPostponedException(NotCompleteWillException): + """An already signed/sent will is being postponed. + + When a will that has already been signed (``COMPLETE``) and/or pushed to + will-executors (``PUSHED``) gets its locktime moved to a LATER date, the + previously committed coins must be invalidated on-chain BEFORE rebuilding + the new inheritance. Otherwise a will-executor could broadcast the old + (earlier-locktime) transaction and execute the inheritance too early to + collect the fees. Invalidating spends the same UTXOs now, permanently + voiding the old pre-signed transaction. + """ + + pass + + class WillexecutorChangeException(NotCompleteWillException): pass diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index 53126de..90dd77b 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -69,11 +69,11 @@ from ...core.will import (AmountException, HeirChangeException, NotCompleteWillException, NoWillExecutorNotPresent, TxFeesChangedException, Will, WillexecutorChangeException, WillExecutorNotPresent, - WillExpiredException, WillItem) + WillExpiredException, WillItem, WillPostponedException) from ...core.willexecutors import Willexecutors # --- Presentation helpers --- -from .theme import status_color +from .theme import server_status_text, server_status_tooltip, status_color from .window_utils import (bring_to_front, show_modal, show_on_top, stop_thread, top_level_of) diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index f187bf2..37cf798 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -595,6 +595,20 @@ class BalBuildWillDialog(BalDialog): return None, Will.invalidate_will( self.bal_window.willitems, self.bal_window.wallet, fee_per_byte ) + except WillPostponedException as e: + # An already signed/sent will is being postponed. Like an expired + # will, the previously committed coins must be invalidated on-chain + # FIRST (otherwise a will-executor could broadcast the old, + # earlier-locktime tx and execute the inheritance too early). We + # return (None, tx) so phase 2 asks the user to sign and broadcast + # the invalidation; afterwards the user presses Prepare again to + # rebuild the new (postponed) inheritance. + _logger.debug(f"postponed {e}") + self.msg_set_checking(_("Postponed: invalidating old will")) + fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) + return None, Will.invalidate_will( + self.bal_window.willitems, self.bal_window.wallet, fee_per_byte + ) except NoHeirsException as e: _logger.debug("no heirs") self.msg_set_checking("No Heirs") diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index 1d1f867..5032a0f 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -228,12 +228,14 @@ class PreviewList(MyTreeView, MessageBoxMixin): TXID = enum.auto() WILLEXECUTOR = enum.auto() STATUS = enum.auto() + SERVER = enum.auto() headers = { Columns.LOCKTIME: _("Locktime"), Columns.TXID: _("Txid"), Columns.WILLEXECUTOR: _("Will-Executor"), Columns.STATUS: _("Status"), + Columns.SERVER: _("Server"), } ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000 @@ -385,6 +387,9 @@ class PreviewList(MyTreeView, MessageBoxMixin): if len(bal_tx.status) > 53: status = "...{}".format(status[-50:]) labels[self.Columns.STATUS] = status + # Dedicated, always-readable label describing whether the inheritance + # transaction is actually stored on the will-executor servers. + labels[self.Columns.SERVER] = server_status_text(bal_tx) items = [] for e in labels: @@ -398,6 +403,13 @@ class PreviewList(MyTreeView, MessageBoxMixin): items[-1].setBackground(QColor(status_color(bal_tx))) + # Tooltip on the Server column: shows the will-executor URL (if any) + # plus the current server state, so the user can always inspect details. + try: + items[self.Columns.SERVER].setToolTip(server_status_tooltip(bal_tx)) + except Exception as tip_err: + _logger.debug(f"server tooltip error: {tip_err}") + row_count = self.model().rowCount() self.model().insertRow(row_count, items) if txid == current_key: diff --git a/bal/gui/qt/theme.py b/bal/gui/qt/theme.py index cea29a4..33951a4 100644 --- a/bal/gui/qt/theme.py +++ b/bal/gui/qt/theme.py @@ -57,3 +57,41 @@ def status_color(will_item) -> str: return "#2bc8ed" # blue - signed else: return _DEFAULT_COLOR + + +def server_status_text(will_item) -> str: + """Return a short, human-readable label describing the state of a will + item on the will-executor servers (the online inheritance backup). + + This is shown in the dedicated "Server" column of the transaction list so + the user always knows whether each inheritance transaction is actually + stored on the will-executor servers, regardless of the row colour. + """ + from electrum.i18n import _ + + if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"): + return _("Not on server") + if will_item.get_status("CHECKED"): + return _("Confirmed on server") + if will_item.get_status("PUSH_FAIL"): + return _("Send failed") + if will_item.get_status("PUSHED"): + return _("Sent (not checked)") + if will_item.get_status("COMPLETE"): + return _("Signed (not sent)") + return _("Not sent") + + +def server_status_tooltip(will_item) -> str: + """Return a detailed tooltip for the "Server" column, including the + will-executor URL (if any) and the current server state.""" + from electrum.i18n import _ + + url = None + we = getattr(will_item, "we", None) + if we: + url = we.get("url") + state = server_status_text(will_item) + if url: + return "{}: {}\n{}".format(_("Will-Executor"), url, state) + return "{}\n{}".format(_("No will-executor"), state) diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index f3e4cb6..ef2164d 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -529,6 +529,29 @@ class BalWindow: return except NoHeirsException: return + except WillPostponedException as e: + # The will was already signed/sent and is being postponed. + # We do NOT rebuild automatically: the user must first sign and + # broadcast the invalidation tx (so the old, earlier-locktime tx + # can never be used by a will-executor), then press "Prepare" + # again + # to create the new postponed inheritance. + _logger.info(f"will postponed: {e}") + self.show_message( + _( + "This inheritance was already signed/sent to " + "will-executors and you are postponing it.\n\n" + "The previously committed coins must be invalidated " + "on-chain FIRST, otherwise a will-executor could " + "broadcast the old (earlier) transaction and execute " + "the inheritance too early.\n\n" + "Please sign and broadcast the invalidation transaction " + "now, then press 'Prepare' again to create the new " + "(postponed) inheritance." + ) + ) + self.invalidate_will() + return except NotCompleteWillException as e: _logger.info("{}:{}".format(type(e), e)) message = False