From a62c8349e3bc7bf17dd5ecb2180a92bb4766c9bc Mon Sep 17 00:00:00 2001 From: kaibot Date: Sun, 28 Jun 2026 22:42:48 -0400 Subject: [PATCH] Remove all files --- .gitignore | 6 - CHANGELOG_REFACTOR.md | 611 ------------ DIAGNOSI_GUI.md | 319 ------ README.md | 133 --- REPORT_NETWORKING_PARALLELO.md | 253 ----- bal/LICENSE | 21 - bal/README.md | 23 - bal/VERSION | 1 - bal/__init__.py | 37 - bal/bal_resources.py | 14 - bal/core/__init__.py | 21 - bal/core/heirs.py | 846 ---------------- bal/core/plugin_base.py | 402 -------- bal/core/util.py | 619 ------------ bal/core/will.py | 1008 ------------------- bal/core/willexecutors.py | 788 --------------- bal/gui/__init__.py | 0 bal/gui/qt/__init__.py | 17 - bal/gui/qt/calendar.py | 80 -- bal/gui/qt/common.py | 173 ---- bal/gui/qt/dialogs.py | 1257 ------------------------ bal/gui/qt/lists.py | 989 ------------------- bal/gui/qt/plugin.py | 498 ---------- bal/gui/qt/theme.py | 97 -- bal/gui/qt/widgets.py | 865 ---------------- bal/gui/qt/window.py | 1249 ----------------------- bal/gui/qt/window_utils.py | 119 --- bal/icons/bal16x16.png | Bin 538 -> 0 bytes bal/icons/bal32x32.png | Bin 1128 -> 0 bytes bal/icons/calendar.png | Bin 1365 -> 0 bytes bal/icons/confirmed.png | Bin 54212 -> 0 bytes bal/icons/heir.png | Bin 871 -> 0 bytes bal/icons/reload.png | Bin 7419 -> 0 bytes bal/icons/status_connected.png | Bin 71068 -> 0 bytes bal/icons/unconfirmed.png | Bin 3532 -> 0 bytes bal/icons/will.png | Bin 831 -> 0 bytes bal/icons/wizard.png | Bin 8542 -> 0 bytes bal/manifest.json | 10 - bal/qt.py | 83 -- bal/wallet_util/README.md | 50 - bal/wallet_util/bal_wallet_utils.py | 81 -- bal/wallet_util/bal_wallet_utils_qt.py | 199 ---- build_zip.py | 73 -- tests/external_zip_test.py | 64 -- tests/gui_fixes_test.py | 152 --- tests/parallel_ping_test.py | 353 ------- tests/preview_build_will_dialog.py | 110 --- tests/preview_we_rows.py | 105 -- tests/sim_update_flows.py | 171 ---- tests/smoke_test.py | 92 -- 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 | 373 ------- 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 -- tests/windows_overflow_test.py | 97 -- 62 files changed, 14505 deletions(-) delete mode 100644 .gitignore delete mode 100644 CHANGELOG_REFACTOR.md delete mode 100644 DIAGNOSI_GUI.md delete mode 100644 README.md delete mode 100644 REPORT_NETWORKING_PARALLELO.md delete mode 100644 bal/LICENSE delete mode 100644 bal/README.md delete mode 100644 bal/VERSION delete mode 100644 bal/__init__.py delete mode 100644 bal/bal_resources.py delete mode 100644 bal/core/__init__.py delete mode 100644 bal/core/heirs.py delete mode 100644 bal/core/plugin_base.py delete mode 100644 bal/core/util.py delete mode 100644 bal/core/will.py delete mode 100644 bal/core/willexecutors.py delete mode 100644 bal/gui/__init__.py delete mode 100644 bal/gui/qt/__init__.py delete mode 100644 bal/gui/qt/calendar.py delete mode 100644 bal/gui/qt/common.py delete mode 100644 bal/gui/qt/dialogs.py delete mode 100644 bal/gui/qt/lists.py delete mode 100644 bal/gui/qt/plugin.py delete mode 100644 bal/gui/qt/theme.py delete mode 100644 bal/gui/qt/widgets.py delete mode 100644 bal/gui/qt/window.py delete mode 100644 bal/gui/qt/window_utils.py delete mode 100644 bal/icons/bal16x16.png delete mode 100644 bal/icons/bal32x32.png delete mode 100755 bal/icons/calendar.png delete mode 100644 bal/icons/confirmed.png delete mode 100644 bal/icons/heir.png delete mode 100644 bal/icons/reload.png delete mode 100644 bal/icons/status_connected.png delete mode 100644 bal/icons/unconfirmed.png delete mode 100644 bal/icons/will.png delete mode 100644 bal/icons/wizard.png delete mode 100644 bal/manifest.json delete mode 100644 bal/qt.py delete mode 100644 bal/wallet_util/README.md delete mode 100755 bal/wallet_util/bal_wallet_utils.py delete mode 100755 bal/wallet_util/bal_wallet_utils_qt.py delete mode 100644 build_zip.py delete mode 100644 tests/external_zip_test.py delete mode 100644 tests/gui_fixes_test.py delete mode 100644 tests/parallel_ping_test.py delete mode 100644 tests/preview_build_will_dialog.py delete mode 100644 tests/preview_we_rows.py delete mode 100644 tests/sim_update_flows.py delete mode 100644 tests/smoke_test.py delete mode 100644 tests/test_core_heirs.py delete mode 100644 tests/test_core_heirs_extra.py delete mode 100644 tests/test_core_plugin_base.py delete mode 100644 tests/test_core_util.py delete mode 100644 tests/test_core_will.py delete mode 100644 tests/test_core_will_extra.py delete mode 100644 tests/test_gui_calendar.py delete mode 100644 tests/test_gui_common.py delete mode 100644 tests/test_gui_theme.py delete mode 100644 tests/test_gui_widgets.py delete mode 100644 tests/test_gui_window_utils.py delete mode 100644 tests/windows_overflow_test.py diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 68d4ee6..0000000 --- a/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -**/__pycache__/ -*.pyc -*.zip -bal-electrum-plugin.zip -electrum-src/ -preview_*.png diff --git a/CHANGELOG_REFACTOR.md b/CHANGELOG_REFACTOR.md deleted file mode 100644 index f4e545b..0000000 --- a/CHANGELOG_REFACTOR.md +++ /dev/null @@ -1,611 +0,0 @@ -# BAL — Resoconto del refactoring (per l'autore originale) - -Questo documento elenca **tutte** le modifiche apportate al plugin BAL -(Bitcoin After Life) rispetto alla versione originale `0.2.8`. - -**Principio guida:** refactoring **conservativo e a comportamento invariato** -(Approccio A). La logica di business è stata mantenuta **byte-identica** dove -possibile; sono cambiati soprattutto la **disposizione dei file** e gli -**import**. Nessuna riscrittura algoritmica. - -Ambiente di verifica: **Electrum 4.7.2** + **PyQt6** (l'ultima release stabile -che espone `json_db.register_dict`). - ---- - -## 1. Riorganizzazione della struttura (separazione logica / GUI) - -Il problema principale segnalato era che la logica e la grafica erano -mescolate, in particolare in un unico file `qt.py` da **4131 righe**. - -### Struttura PRIMA (flat, 7 file) -``` -BAL/ -├── __init__.py (vuoto, 0 righe) -├── bal.py (243) logica + plugin base -├── util.py (533) helper -├── heirs.py (791) modello eredi + costruzione tx -├── will.py (927) modello will/WillItem -├── willexecutors.py (374) networking will-executor -├── qt.py (4131) TUTTA la GUI + il Plugin in un solo file -└── bal_resources.py (14) -``` - -### Struttura DOPO (core/ vs gui/) -``` -bal/ -├── manifest.json metadati conformi allo standard -├── qt.py shim di caricamento (re-export di Plugin) -├── __init__.py docstring di architettura + __version__ -├── core/ LOGICA senza dipendenze Qt -│ ├── util.py (ex util.py) -│ ├── plugin_base.py (ex bal.py) -│ ├── heirs.py (ex heirs.py) -│ ├── will.py (ex will.py) -│ └── willexecutors.py (ex willexecutors.py) -└── gui/qt/ PRESENTAZIONE PyQt6 - ├── theme.py (59) mappatura stato → colore - ├── common.py (155) import condivisi + helper GUI - ├── widgets.py (782) widget "foglia" - ├── calendar.py (80) BalCalendar - ├── dialogs.py (1127) finestre di dialogo - ├── lists.py (957) viste ad albero (eredi/preview/executor) - ├── window.py (952) controller GUI per-wallet (BalWindow) - └── plugin.py (273) classe Plugin (@hook Electrum → GUI) -``` - -Il file `qt.py` da 4131 righe è stato suddiviso per **responsabilità**. I -**corpi delle classi sono stati copiati verbatim** (riga per riga) per non -toccare la logica delicata delle transazioni di eredità. - -### Mappa: dove sono finite le 40 classi/funzioni di `qt.py` - -| Classe/funzione (riga orig.) | Nuovo modulo | -|-------------------------------------|-------------------------| -| `Plugin` (67) | `gui/qt/plugin.py` | -| `shown_cv` (317) | `gui/qt/common.py` | -| `BalWindow` (330) | `gui/qt/window.py` | -| `add_widget` (1257) | `gui/qt/common.py` | -| `ClickableLabel` (1263) | `gui/qt/widgets.py` | -| `BalTxFeesWidget` (1271) | `gui/qt/widgets.py` | -| `_LockTimeEditor` (1340) | `gui/qt/widgets.py` | -| `BalTimeEditWidget` (1374) | `gui/qt/widgets.py` | -| `TimeRawEditWidget` (1508) | `gui/qt/widgets.py` | -| `LockTimeRawEdit` (1527) | `gui/qt/widgets.py` | -| `LockTimeDateEdit` (1605) | `gui/qt/widgets.py` | -| `ThresholdTimeWidget` (1644) | `gui/qt/widgets.py` | -| `LockTimeWidget` (1664) | `gui/qt/widgets.py` | -| `WillSettingsWidget` (1683) | `gui/qt/widgets.py` | -| `PercAmountEdit` (1818) | `gui/qt/widgets.py` | -| `BalDialog` (1883) | `gui/qt/dialogs.py` | -| `BalWizardDialog` (1913) | `gui/qt/dialogs.py` | -| `BalWizardWidget` (2002) | `gui/qt/dialogs.py` | -| `BalWizardHeirsWidget` (2068) | `gui/qt/dialogs.py` | -| `BalWizardWEDownloadWidget` (2103) | `gui/qt/dialogs.py` | -| `BalWizardWEWidget` (2190) | `gui/qt/dialogs.py` | -| `BalWizardLocktimeAndFeeWidget`(2207)| `gui/qt/dialogs.py` | -| `BalWaitingDialog` (2224) | `gui/qt/dialogs.py` | -| `BalBlockingWaitingDialog` (2285) | `gui/qt/dialogs.py` | -| `BalLineEdit` (2304) | `gui/qt/widgets.py` | -| `BalTextEdit` (2312) | `gui/qt/widgets.py` | -| `BalCheckBox` (2320) | `gui/qt/widgets.py` | -| `BalBuildWillDialog` (2335) | `gui/qt/dialogs.py` | -| `HeirListWidget` (2858) | `gui/qt/lists.py` | -| `PreviewList` (3059) | `gui/qt/lists.py` | -| `WillDetailDialog` (3445) | `gui/qt/dialogs.py` | -| `WillWidget` (3545) | `gui/qt/widgets.py` | -| `WillExecutorListWidget` (3637) | `gui/qt/lists.py` | -| `WillExecutorWidget` (3873) | `gui/qt/lists.py` | -| `WillExecutorDialog` (3982) | `gui/qt/dialogs.py` | -| `CheckAliveError` (4018) | `gui/qt/common.py` | -| `log_error` (4028) | `gui/qt/common.py` | -| `export_meta_gui` (4043) | `gui/qt/common.py` | -| `BalCalendar` (4066) | `gui/qt/calendar.py` | - ---- - -## 2. Rimozioni (codice morto / debug) — comportamento invariato - -Tutte le rimozioni seguenti sono state verificate come **non utilizzate** o -**puramente di debug**, quindi non alterano il comportamento del plugin. - -1. **`util.py` → `core/util.py`**: rimossi tre helper di debug usati solo per - stampe a console: - - `print_var()` (orig. riga 439) - - `print_utxo()` (orig. riga 474) - - `print_prevout()` (orig. riga 486) - -2. **`bal.py` → `core/plugin_base.py`**: rimossa la funzione **stub vuota** - `get_will_settings(x)` (orig. righe 12-14): - ```python - def get_will_settings(x): - # print(x) - pass - ``` - ⚠️ Verificato: **non era riferita da nessun `register_dict`** — i tre - `register_dict` usano `tuple`, `dict`, `lambda x: x`. Quindi era codice - morto. La funzione **usata** `get_will(x)` è stata mantenuta identica. - -3. **`will.py` (`WillItem`) → spostato in `gui/qt/theme.py`**: il metodo - `WillItem.get_color()` (orig. riga 852) restituiva colori esadecimali — - è logica di **presentazione**, non di dominio. È stato spostato fuori dal - modello e trasformato nella funzione `status_color(will_item)` in - `gui/qt/theme.py`. **Verificato byte-identico** su tutte le combinazioni di - stato (stessa catena di `get_status(...)`, stessi codici colore). - ---- - -## 3. Cambi di import (necessari per la nuova struttura) - -Gli import sono stati aggiornati da "flat" a "a package". Esempi: - -| Prima | Dopo | -|------------------------------------|---------------------------------------| -| `from .bal import BalPlugin` | `from .plugin_base import BalPlugin` (in willexecutors) | -| `from .util import Util` | `from .util import Util` (invariato, ora dentro core/) | -| (in qt.py) `from .bal import ...` | i moduli GUI importano da `...core.X` | - -- Aggiunti `from .common import _, _logger` nei moduli GUI, perché `import *` - **non** esporta i nomi che iniziano con underscore. -- Aggiunti 3 import "lazy" (dentro le funzioni) in `dialogs.py` per spezzare il - ciclo `dialogs ↔ lists` (lists importa `BalBuildWillDialog` da dialogs). - -La **logica interna dei metodi** non è stata toccata: `prepare_transactions()`, -`buildTransactions()` ecc. sono verbatim. - ---- - -## 4. Packaging conforme allo standard Electrum - -`manifest.json` reso conforme a https://plugins.electrum.org/developers.html : - -| Campo | Prima | Dopo | -|------------------|--------------------|-------------------------------| -| `name` | `"BAL"` | `"bal"` (minuscolo = nome dir) | -| `version` | (assente, era solo nella description) | `"0.2.8"` | -| `description` | con `
` HTML | testo pulito | -| `licence` | (assente) | `"MIT"` | -| `fullname`/`author`/`available_for`/`icon` | presenti | invariati | - -- `__init__.py` (era **vuoto**): ora contiene la docstring di architettura e - `__version__ = "0.2.8"`. -- Portati nel package: `LICENSE`, `VERSION`, `README.md`, `bal_resources.py`, - e la cartella `wallet_util/` (invariata). - ---- - -## 5. CORREZIONE BUG: caricamento come plugin esterno (.zip) - -Durante i test su **Electrum 4.7.2 portable per Windows** sono emersi due -problemi reali nel caricare il plugin come **plugin esterno da .zip**: - -### Bug 5a — `ModuleNotFoundError: No module named 'electrum_external_plugins'` -- **Causa:** Electrum carica i plugin esterni da zip sotto il package sintetico - `electrum_external_plugins.bal`, ed esegue **solo** l'`__init__` del package e - il modulo `qt`. Non registra il package radice sintetico né i sotto-package - annidati (`gui`, `gui.qt`). Un semplice `from .gui.qt.plugin import Plugin` - fallisce risalendo ai parent mancanti. -- **Fix:** `qt.py` ora è uno shim resiliente che (1) rileva a runtime il proprio - nome di package (`__package__`), (2) ricostruisce in `sys.modules` gli - eventuali package padre mancanti, (3) importa `Plugin` con - `importlib.import_module`. Funziona **sia** come plugin interno - (`electrum.plugins.bal`) **sia** esterno (`electrum_external_plugins.bal`). - -### Bug 5b — `zlib.error: Error -5 ... incomplete or truncated stream` -- **Causa:** alcune build portable di Electrum su Windows non riescono a - decomprimere con `zipimport` archivi che contengono **voci di directory** o - compressione non standard. -- **Fix:** aggiunto `build_zip.py`, che genera un archivio "zipimport-friendly": - solo file (nessuna voce di directory), DEFLATE standard, ordine deterministico - (SHA-256 riproducibile), escludendo `__pycache__`/`*.pyc`. Stampa anche - l'hash SHA-256 per verificare l'integrità del download. - ---- - -## 6. Test aggiunti - -- `tests/smoke_test.py` — verifica import + comportamento di base - (`BalTimestamp`, helper di `Util`, costanti `HEIR_*`, stati di `WillItem`, - hook del `Plugin`). -- `tests/external_zip_test.py` — riproduce **fedelmente** la sequenza di - caricamento di un plugin esterno da zip di Electrum (regressione per il - Bug 5a/5b). - -Tutti i test passano sotto Electrum 4.7.2 + PyQt6. - ---- - -## 7. Riepilogo: cosa NON è cambiato - -- La logica di costruzione delle transazioni (`heirs.py`, `will.py`). -- I valori e i tipi di `json_db.register_dict(...)`. -- I codici colore degli stati (solo spostati in `theme.py`). -- L'algoritmo di tutte le classi GUI (copiate verbatim). -- Il formato dei dati salvati nel wallet. - -## 8. Note / raccomandazioni - -- Il plugin **richiede Electrum 4.7.2**: `json_db.register_dict` è stato - **rimosso** nelle versioni successive (master), dove andrebbe sostituito con - `stored_dict.register_name`. Valutare un adeguamento se si vuole supportare - Electrum più recente. -- Prima del rilascio è consigliata una prova **end-to-end in una sessione - Electrum reale** (preferibilmente su testnet), oltre agli smoke test. - ---- - -## 9. CORREZIONI GUI — finestre e ciclo di vita (B1-B10) - -Dopo il refactoring di struttura sono stati corretti **dieci difetti grafici e -di ciclo di vita** delle finestre, già presenti nel codice originale. La logica -di business è rimasta **byte-identica** (nessuna modifica a `bal/core/*`): sono -cambiati solo **presentazione, parent, modalità, z-order, ciclo di vita e -cleanup** delle finestre Qt. - -Sintomi segnalati dall'utente, ora risolti: -- **(S1)** le finestre del plugin sparivano dietro la finestra di Electrum; -- **(S2)** alcuni meccanismi funzionavano solo dopo aver chiuso e riavviato - Electrum. - -| ID | Problema (presente nell'originale) | Correzione applicata | -|-----|----------------------------------------------------------------------|----------------------| -| B1 | `self.parent = parent` sovrascriveva il metodo `parent()` di Qt, rompendo la gerarchia delle finestre | rinominato in `self._bal_parent` (in `dialogs.py`, `lists.py`, `widgets.py`); il parent reale passa da `top_level_of(parent)` | -| B2 | dialoghi aperti con `.show()` non modale → finivano sotto la finestra principale | sostituiti con `show_on_top()` / `show_modal()` e parent corretto | -| B3 | messaggio "Please restart Electrum to activate the BAL plugin": il plugin si attivava solo dopo riavvio | inizializzazione **a caldo** con `_setup_window()` che replica `load_wallet` — niente più riavvio | -| B4 | chiave del dizionario finestre usava il **metodo** `winId` invece del valore | chiave stabile `_window_key()` basata su `id(window)` | -| B5 | `on_close` ingoiava tutti gli errori con `except: pass` | riscritto: niente `except:pass`, log per ogni passo, reset pulito dello stato | -| B6 | `BalBlockingWaitingDialog` bloccava il thread della GUI (`processEvents` commentato) | ripristinato `processEvents()` → GUI reattiva durante l'attesa | -| B7 | `closeEvent`/`hideEvent` con cleanup del thread commentato | gestione esplicita di `closeEvent`/`hideEvent` + chiamata a `super()` | -| B8 | `closeEvent` incompleto in alcuni dialog | gestione uniforme dello stato di chiusura | -| B9 | `show()+raise_()` senza `activateWindow()` né modalità → finestra non in primo piano | `bring_to_front()` = `raise_()` + `activateWindow()` | -| B10 | gestione multi-wallet / multi-finestra fragile; menu cercato per titolo `&Tools` | uso dell'API ufficiale `window.tools_menu` | - -### Nuovo modulo: `gui/qt/window_utils.py` (119 righe) - -Gli helper per la gestione delle finestre sono stati **centralizzati** in un -unico modulo, così la stessa logica non viene duplicata nei vari dialog: - -- `top_level_of(widget)` — risale alla finestra di primo livello corretta da - usare come parent; -- `bring_to_front(window)` — `raise_()` + `activateWindow()` per portare in - primo piano; -- `stop_thread(thread)` — stop+wait sicuro di un `TaskThread`; -- `show_modal(dialog)` — apertura modale corretta (`exec()`); -- `show_on_top(window)` — apertura non modale ma sopra le altre finestre. - -`gui/qt/common.py` importa questi helper e li rende disponibili al resto della -GUI. - ---- - -## 10. CORREZIONE BUG: download lista will-executor - -Dopo l'installazione del pacchetto con le correzioni GUI, l'utente ha -segnalato che il comando **"download list"** dei will-executor non scaricava -più la lista. - -### Indagine - -Il codice di rete (`core/willexecutors.py`: `send_request`, `handle_response`, -`download_list`, `initialize_willexecutor`) è stato confrontato riga per riga -con l'originale Gitea ed è risultato **byte-identico** (l'unica differenza è il -parametro aggiuntivo `welist_server` in `download_list`, retro-compatibile). - -Durante l'indagine sono comunque emersi e stati corretti **due difetti reali** -introdotti dalle correzioni GUI, che potevano "perdere" il risultato del -download: - -1. **`BalDialog.closeEvent`/`hideEvent` fermavano il `TaskThread`.** In Electrum - `TaskThread.on_done` esegue `cb_done` (cioè `self.accept`, che **chiude** il - dialog) **prima** di `cb_result` (cioè `on_success`, che **aggiorna** la - lista). Fermare il thread alla chiusura del dialog **scartava** quindi il - risultato appena scaricato. → I due metodi sono stati riportati a **non** - fermare il thread (con commento esplicativo nel codice). -2. **`BalWaitingDialog.exe()` usava una modalità sbagliata** (`show_modal` / - `WindowModal`). → Ripristinato l'originale `self.exec()`, aggiungendo prima - `bring_to_front(self)` per garantire il primo piano. - -Inoltre i percorsi del **pulsante** e del **wizard** (che prima scaricavano in -modi diversi e con messaggi diversi) sono stati **unificati** in un unico -helper `fetch_will_executors_list`, eseguito dentro il worker del `TaskThread`. - -### Causa vera del mancato download: ambientale, NON del plugin - -Una probe di controllo con `urllib` che **bypassava completamente Electrum** -falliva ugualmente con `WinError 10054` ("connection forcibly closed by remote -host"): segno che la **rete/ISP dell'utente resettava la connessione HTTPS** -verso `welist.bitcoin-after.life`. La conferma definitiva: **attivando una VPN -il download è andato a buon fine.** - -L'originale "sembrava" funzionare perché spedisce comunque un will-executor di -**default già incorporato** (`https://we.bitcoin-after.life`), quindi la lista -non risultava mai del tutto vuota anche senza un download riuscito. - -### Pulizia finale (scelta dall'utente — "Opzione 1") - -- **Finestra di attesa non bloccante** mantenuta (`BalWaitingDialog`), così la - GUI non si congela durante il download. -- **Fallback dell'URL**: prima l'URL configurato (`WELIST_SERVER`), poi quello - hardcoded `https://welist.bitcoin-after.life/`. -- **Diagnostica dettagliata spostata nei soli log** (rimossa la probe `urllib` - dall'interfaccia). -- **Messaggio d'errore semplice per l'utente, in inglese** (`DOWNLOAD_FAILED_MESSAGE`): - - > *"Could not download the will-executors list. This is usually caused by - > your internet connection or a firewall, not by the plugin. Please check - > your connection (a VPN often helps) and try again."* - -### File toccati (solo presentazione/GUI, logica invariata) - -- `gui/qt/window.py` — helper condiviso `fetch_will_executors_list`, - `download_list` con `TaskThread` + `BalWaitingDialog`, costante - `DOWNLOAD_FAILED_MESSAGE`. -- `gui/qt/lists.py` — `WillExecutorWidget.download_list` instradato sul - percorso condiviso con `on_success` che aggiorna/salva la lista. -- `gui/qt/dialogs.py` — `BalDialog.closeEvent`/`hideEvent` **non** fermano più - il thread; `BalWaitingDialog.exe()` torna a `self.exec()` + `bring_to_front`. -- `tests/gui_fixes_test.py` — asserzione di **regressione**: verifica che - `closeEvent`/`hideEvent` **non** contengano `stop_thread` (per non - reintrodurre il bug che scartava il download). - ---- - -## 11. Confronto strutturale finale (originale Gitea → refactor) - -Conteggio file `.py` (escluse cartelle generate): - -| Originale (Gitea) | righe | → | Refactor (`bal/`) | righe | -|------------------------------|------:|----|-------------------------------------------|------:| -| `__init__.py` | 1 | → | `__init__.py` | 37 | -| `bal.py` | 161 | → | `core/plugin_base.py` | 351 | -| `util.py` | 1051 | → | `core/util.py` | 614 | -| `heirs.py` | 792 | → | `core/heirs.py` | 806 | -| `will.py` | 903 | → | `core/will.py` | 938 | -| `willexecutors.py` | 547 | → | `core/willexecutors.py` | 390 | -| `qt.py` (monolite GUI) | 3777 | → | suddiviso in `gui/qt/*` (vedi sotto) | — | -| `bal_resources.py` | 14 | → | `bal_resources.py` | 14 | -| `wallet_util/*.py` | 275 | → | `wallet_util/*.py` (invariati) | 280 | - -Suddivisione del vecchio `qt.py` (3777 righe) nei moduli GUI: - -| Modulo refactor | righe | Contenuto | -|-----------------------------|------:|-----------| -| `gui/qt/plugin.py` | 303 | classe `Plugin` (`@hook` Electrum → GUI) | -| `gui/qt/window.py` | 1048 | `BalWindow` (controller per-wallet) | -| `gui/qt/dialogs.py` | 1155 | finestre di dialogo + wizard | -| `gui/qt/lists.py` | 964 | viste ad albero (eredi/preview/executor) | -| `gui/qt/widgets.py` | 782 | widget "foglia" | -| `gui/qt/common.py` | 157 | import condivisi + helper | -| `gui/qt/window_utils.py` | 119 | helper finestre (NUOVO — vedi §9) | -| `gui/qt/calendar.py` | 80 | `BalCalendar` | -| `gui/qt/theme.py` | 59 | mappatura stato → colore | -| `gui/qt/__init__.py` | 17 | init package GUI | - -> Le differenze nei conteggi di righe rispetto all'originale derivano da: -> riformattazione/commenti, separazione degli import per modulo, e spostamento -> di funzioni tra `util.py`/`bal.py` e i nuovi moduli. **Gli algoritmi non sono -> stati modificati.** - ---- - -## 12. Cronologia delle modifiche su GitHub - -- **`4198a51`** — import iniziale del refactor strutturale (v0.2.8): separazione - `core/` (logica) vs `gui/qt/` (presentazione), packaging conforme, fix - caricamento zip esterno, smoke test (sezioni §1-§8). -- **`d56fa36`** — questo changelog del refactoring (in italiano). -- **`4806997`** — `DIAGNOSI_GUI.md`: diagnosi dei bug GUI di z-order e ciclo di - vita (Fase A). -- **`dd6f677`** (PR **#2**, squash) — correzioni GUI **B1-B10** + fix download - lista will-executor + `window_utils.py` + test di regressione (sezioni §9-§10). -- **PR #3** — fix **OverflowError su Windows (anno 2038)** che rompeva le schede - Will/Heirs e la voce di menu (sezione §13). - ---- - -## 13. CORREZIONE BUG: OverflowError su Windows (limite anno 2038) - -### Sintomo (Windows 11) -Dopo aver **riavviato Electrum** o **cambiato wallet**, le schede **Will** e -**Heirs** sparivano e compariva una **voce di menu condensata/illeggibile** -(icona + testo sovrapposti) sotto il logo di Electrum, accanto a *Portafogli*. -Su Linux il problema non si manifestava. - -### Causa vera (dal log di Electrum dell'utente) -``` -OverflowError: Python int too large to convert to C int - window.py __init__ -> create_heirs_tab -> WillSettingsWidget - -> on_locktime_change -> BalTimestamp.to_date - -> datetime.fromtimestamp(NLOCKTIME_MAX) -``` - -- `NLOCKTIME_MAX = 2**32 - 1 = 4294967295` viene usato come locktime di - **default/sentinella**. -- Su **Windows** `time_t` è a **32 bit**, quindi `datetime.fromtimestamp(ts)` - solleva **`OverflowError`** per qualsiasi timestamp oltre il **2038**. -- Su **Linux 64-bit** la stessa chiamata **funziona**: ecco perché il bug si - vedeva solo su Windows e i test su Linux non lo intercettavano. -- L'eccezione interrompeva `BalWindow.__init__` durante `init_menubar` / - `load_wallet`, lasciando le schede Will/Heirs e la voce di menu **a metà - costruzione** → l'elemento grafico condensato/illeggibile sotto il logo. - -> Nota: i due primi tentativi di correzione (status-bar no-op e idempotenza di -> `init_menubar_tools`) **non** centravano la causa; sono stati comunque -> mantenuti perché innocui e leggermente migliorativi, ma il vero colpevole era -> questo crash a monte. - -### Fix (comportamento invariato per tutti i valori normali) -- **`BalTimestamp._safe_fromtimestamp()`**: `datetime.fromtimestamp` con - **clamp a INT32_MAX** (anno 2038) in caso di `OverflowError`/`OSError`/ - `ValueError`, **esattamente** come la funzione `get_max_allowed_timestamp()` - dell'originale (workaround per Electrum issue **#6170**). -- Usato in `to_date` / `to_timestamp` / `__str__` / `__repr__` di - `BalTimestamp`. -- `gui/qt/widgets.py` (`set_value`): usa il converter sicuro. -- `core/util.py` (`timestamp_minus`): stessa protezione inline con clamp a - INT32_MAX. - -I valori entro il 2038 (date assolute normali, durate relative come `90d`/`5y`) -producono **lo stesso identico risultato** di prima. - -### Test -- `tests/windows_overflow_test.py` riproduce il limite 32-bit di Windows - (monkeypatch di `datetime.fromtimestamp`) e dimostra che **senza** il fix si - ottiene lo **stesso** `OverflowError` del log, mentre **con** il fix passa. - 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"**. - -## 15. TENTATIVO E REVERT: fix doppia invalidazione al posticipo (v0.3.1 -> v0.3.2) - -### v0.3.1 (RITIRATA) -Per risolvere la doppia firma dell'invalidazione al posticipo, era stato -introdotto `Will.mark_invalidated_by_tx()`, chiamato in -`loop_broadcast_invalidating` dopo il broadcast dell'invalidazione, per marcare -`INVALIDATED` le will che spendevano gli stessi UTXO della tx di invalidazione -e persistere lo stato con `save_willitems`. - -### Perche e stata ritirata -La modifica ha introdotto una regressione grave segnalata dall'utente: -**la lista eredita mostrava ancora le vecchie eredita e l'aggiornamento di -eredi/date risultava incoerente**. - -Causa: `loop_broadcast_invalidating` e il punto di broadcast usato per **TUTTI** -i tipi di invalidazione (posticipo, CheckAlive, will scaduto/anticipato), non -solo per il posticipo. Inoltre il metodo marcava e **persisteva** lo stato -`INVALIDATED` su tutte le will item che condividevano gli UTXO del wallet -(tipicamente tutte). Queste will item invalidate restavano poi in memoria e su -disco, inquinando la ricostruzione di eredi/date e lasciando vecchie voci nella -lista. - -### v0.3.2 (questa versione): REVERT completo -- Rimosso `Will.mark_invalidated_by_tx()` da `core/will.py`. -- Rimossa la chiamata in `gui/qt/dialogs.py` (`loop_broadcast_invalidating`): - il metodo torna **identico** alla v0.3.0. -- Rimossi i due test relativi; mantenuto solo l'assert di gerarchia su - `WillPostponedException` (corretto e indipendente). -- `core/will.py` e `gui/qt/dialogs.py` sono ora **byte-identici** alla v0.3.0 - funzionante (verificato con `git diff a394cde`). - -Il bug della doppia invalidazione al posticipo resta quindi **aperto** e andra -riaffrontato in modo piu mirato (senza toccare il percorso di broadcast comune e -senza persistere stati su will che condividono gli UTXO), previa conferma -dell'utente. La priorita era ripristinare il comportamento corretto di -lista/eredi/date. - -## 16. Aggiornamenti mancati, Check/Close coerenti, e rifinitura UI (v0.3.2) - -### FIX 1 - Rimozione di un erede rilevata su Check / chiusura Electrum -`core/will.py` (`check_willexecutors_and_heirs`): prima il plugin -rilevava solo l'**aggiunta** di un erede (raise `HeirNotFoundException` quando un -erede corrente non era piu nella will). Mancava il caso inverso: la -**rimozione** di un erede. Aggiunto il ramo `else` che lancia -`HeirNotFoundException` anche quando la will porta ancora un erede che non e piu -presente nel set di eredi corrente. Cosi la ricostruzione dell'eredita scatta su -**Check** e alla **chiusura di Electrum** (entrambi usano lo stesso percorso -`BalBuildWillDialog.build_will_task()`), come deciso dall'utente: nessun -aggiornamento automatico dopo la modifica, solo manuale con Check / alla -chiusura. - -### FIX 2 - Check interroga i server anche per le will gia inviate -`core/will.py` (nuovo `Will.needs_server_check(w)`) e -`gui/qt/lists.py` (`PreviewList.check`): prima il Check interrogava i server solo -per le will in stato `PUSHED`. Le will gia inviate ma rimaste su "New / Not sent" -non venivano ricontrollate ("nothing to do"). Ora `needs_server_check` include -ogni will **VALID** con un will-executor e **non ancora CHECKED**, anche se non -in stato `PUSHED`. Stesso controllo usato sia dal pulsante Check sia da -`on_close`. - -### FIX 3 - Hide invalidated/replaced da finestra Impostazioni aggiornava la lista -`core/plugin_base.py` (nuovo `sync_hide_filters()`) e -`gui/qt/window.py` (`update_all`): le checkbox "Hide Replaced" / "Hide -Invalidated" nella finestra Impostazioni scrivono direttamente la config -(`BalConfig.set`) senza toccare i flag in cache `_hide_invalidated` / -`_hide_replaced` usati dalla lista per filtrare. Risultato: la lista continuava -a filtrare col valore vecchio finche non si riavviava Electrum. Ora -`update_all()` chiama `sync_hide_filters()` che ri-legge i flag dalla config, -quindi qualunque sorgente del cambiamento (toolbar o finestra Impostazioni) -aggiorna subito la lista. - -### Rifinitura UI - Risultati in grassetto nel dialog "Building Will" -`gui/qt/dialogs.py` (`BalBuildWillDialog`): i **risultati** mostrati a destra di -ogni riga di stato (es. `Ok`, `Ko`, `Nothing to do`, `Skipped`, `Wait`, -`Timeout`) sono ora resi in **grassetto**, mantenendo i loro colori -(verde/rosso/giallo). Le etichette di stato a sinistra restano in peso normale. -Modifica centralizzata negli helper `msg_ok`, `msg_error`, `msg_warning`, -`msg_set_status`, piu le righe dei will-executor (push e check) che ora mostrano -`Ok/Ko` e `True/False` in grassetto + colore (verde/rosso). - -### Test -- 186 test ufficiali passano; smoke test, external-zip test e simulazione dei - flussi di aggiornamento (`tests/sim_update_flows.py`) OK; `ruff` senza nuove - segnalazioni reali (solo falsi positivi pre-esistenti da star-import). -- Aggiunti test in `tests/test_core_will.py`: - `test_check_heirs_unchanged_is_coherent`, - `test_check_heir_removed_triggers_rebuild`, - `test_check_heir_added_triggers_rebuild`, `test_needs_server_check`. - -Confermato dall'utente sui dati reali: dopo Sign -> Broadcast -> Check le -transazioni gia inviate sono tornate verdi ("confirmed on server"); la lista -torna pulita; il grassetto e l'aggiornamento delle hide-flag funzionano. diff --git a/DIAGNOSI_GUI.md b/DIAGNOSI_GUI.md deleted file mode 100644 index 440f17a..0000000 --- a/DIAGNOSI_GUI.md +++ /dev/null @@ -1,319 +0,0 @@ -# BAL — Diagnosi dei problemi GUI (Fase A) → ✅ RISOLTI (Fase B) - -> **STATO: tutti i bug B1-B10 sono stati CORRETTI** e mergiati in `main` -> (PR #2, squash `dd6f677`). La logica di business resta **byte-identica** -> (nessuna modifica a `bal/core/*`): sono cambiati solo presentazione, parent, -> modalità, ciclo di vita e cleanup delle finestre. -> -> | ID | Stato | Fix applicato | -> |----|-------|---------------| -> | B1 | ✅ FIXED | `self.parent` → `self._bal_parent` (dialogs/lists/widgets); parent = `top_level_of(parent)` | -> | B2 | ✅ FIXED | `.show()` → `show_on_top()` / `show_modal()` con parent corretto | -> | B3 | ✅ FIXED | init a caldo: `_setup_window()` replica `load_wallet`, niente "restart Electrum" | -> | B4 | ✅ FIXED | chiave finestra stabile `_window_key()` = `id(window)` | -> | B5 | ✅ FIXED | `on_close` riscritto: niente `except:pass`, log per-step, reset stato | -> | B6 | ✅ FIXED | `BalBlockingWaitingDialog`: `processEvents()` ripristinato | -> | B7 | ✅ FIXED | `closeEvent/hideEvent`: `stop_thread()` + `super()` | -> | B8 | ✅ FIXED | `closeEvent`: `stop_thread()` (stop+wait) + `super()` | -> | B9 | ✅ FIXED | `bring_to_front()` = `raise_()` + `activateWindow()` | -> | B10| ✅ FIXED | uso di `window.tools_menu` (API ufficiale), niente ricerca per titolo `&Tools` | -> -> Helper centralizzati in `bal/gui/qt/window_utils.py`: -> `top_level_of`, `bring_to_front`, `stop_thread`, `show_modal`, `show_on_top`. -> Test di regressione: `tests/gui_fixes_test.py` (oltre a smoke + external_zip). - ---- - -## (Storico) Diagnosi originale - -Documento di sola **diagnosi**: nessuna riga di codice funzionale era stata -modificata in Fase A. Elenca i problemi grafici/di ciclo di vita riscontrati nel -codice, la loro **causa tecnica** e il **fix proposto**, con riferimenti riga. - -I due sintomi che hai segnalato: -- **(S1)** Le finestre del plugin spariscono dietro la finestra di Electrum. -- **(S2)** Alcuni meccanismi funzionano solo dopo aver chiuso e "ripulito" - Electrum. - -Sono entrambi spiegati dai bug qui sotto. - ---- - -## Riepilogo (tabella) - -| ID | Gravità | Sintomo | File:riga | Causa breve | -|----|---------|---------|-----------|-------------| -| B1 | 🔴 Alta | S1 | `dialogs.py:40,69,475` | `self.parent = parent` sovrascrive il metodo `QWidget.parent()` | -| B2 | 🔴 Alta | S1 | `window.py:148,936`, `window.py:566` | dialoghi aperti con `.show()` (non-modali, senza stare in primo piano) | -| B3 | 🔴 Alta | S2 | `plugin.py:38-42` | messaggio "Please restart Electrum" = init a caldo non gestito | -| B4 | 🔴 Alta | S2 | `plugin.py:45,111` | chiave dizionario `winId` (metodo) invece di `winId()` (valore) | -| B5 | 🟠 Media | S2 | `window.py:664-677` | `on_close` con `except: pass` che nasconde errori di cleanup | -| B6 | 🟠 Media | S1/S2 | `dialogs.py:445-462` | `BalBlockingWaitingDialog` blocca il thread GUI, `processEvents` commentato | -| B7 | 🟠 Media | S2 | `dialogs.py:48-58` | `closeEvent/hideEvent` con cleanup thread commentato | -| B8 | 🟠 Media | S2 | `dialogs.py:828-830` | `closeEvent` chiama `thread.stop()` ma non `thread.wait()` né `super()` | -| B9 | 🟡 Bassa | S1 | `dialogs.py:1121-1122` | `show()+raise_()` senza `activateWindow()` né modalità | -| B10| 🟡 Bassa | — | `plugin.py:36` (init), vari | gestione finestre multiple/ multi-wallet fragile | - ---- - -## Dettaglio dei problemi - -### B1 — `self.parent = parent` rompe il sistema di finestre di Qt 🔴 -**Dove:** `dialogs.py:40` (in `BalDialog.__init__`), ripetuto a `:69` e `:475`; -analoghi in altri dialoghi. - -```python -self.parent = parent # <-- PROBLEMA -super().__init__(parent) -``` - -**Causa:** in Qt, `parent()` è un **metodo** di `QWidget` che restituisce il -widget genitore. Assegnando un **attributo** `self.parent`, lo si maschera: da -quel punto `self.parent` non è più il metodo ma il valore salvato. Qualunque -codice (anche interno a Qt o di Electrum) che si aspetta `widget.parent()` come -metodo può comportarsi in modo imprevisto. Inoltre il `parent` passato non è -sempre la **top-level window** corretta, quindi il dialogo non viene agganciato -gerarchicamente alla finestra di Electrum e finisce **dietro** (S1). - -**Fix proposto:** -- Non sovrascrivere `parent`: rinominare l'attributo (es. `self._bal_parent`). -- Passare sempre come `parent` la **top-level window** di Electrum - (`window.top_level_window()`), così il dialogo resta in primo piano rispetto - ad essa. - ---- - -### B2 — Dialoghi aperti con `.show()` invece che modali 🔴 -**Dove:** -- `window.py:148` `show_willexecutor_dialog` → `self.willexecutor_dialog.show()` -- `window.py:936` `preview_modal_dialog` → `self.dw.show()` (il nome dice - "modal" ma usa `show()`!) -- `window.py:566` `show_transaction_real` → `d.show()` - -**Causa:** `show()` apre una finestra **non-modale e indipendente**: se il -`parent` non è impostato correttamente (vedi B1), la finestra non resta sopra -Electrum e ci "sparisce dietro" (S1). Si nota l'incoerenza: altrove si usa -correttamente `.exec()` (es. `init_wizard` a `window.py:144`, `settings_dialog` -a `plugin.py:254`), che è modale e resta in primo piano. - -**Fix proposto:** -- Per i dialoghi che devono restare in primo piano: usare `exec()` (modale) **o** - `show()` + parent corretto + `setWindowModality(Qt.WindowModal)` + - `raise_()` + `activateWindow()`. -- Mantenere la stessa logica di "cosa fa il dialogo" (nessun cambio di - comportamento funzionale, solo z-order/modalità). - ---- - -### B3 — "Please restart Electrum to activate the BAL plugin" 🔴 -**Dove:** `plugin.py:38-42` (hook `init_qt`). - -```python -if wallet: - window.show_warning(_("Please restart Electrum to activate the BAL plugin"), ...) - return -``` - -**Causa:** quando il plugin viene **abilitato a caldo** (wallet già aperto), -l'hook `init_qt` si arrende e chiede il riavvio invece di inizializzare le tab -e i menu sul wallet già caricato. È **la causa diretta del sintomo S2**: "devi -chiudere/riavviare Electrum perché funzioni". - -**Fix proposto:** -- In `init_qt`, se c'è già un wallet aperto, eseguire la stessa inizializzazione - che normalmente avviene in `load_wallet` (creare `BalWindow`, tab, menu, - caricare il will) **senza** richiedere il riavvio. -- Simmetricamente, gestire bene `close_wallet` per smontare tab/menu, così - ri-abilitare/ricaricare non lascia stato sporco. - ---- - -### B4 — Chiave del dizionario `winId` (metodo) invece di `winId()` 🔴 -**Dove:** `plugin.py:45` (scrittura) e `plugin.py:111` (lettura). - -```python -self.bal_windows[top_level_window.winId] = w # scrive con la *funzione* winId -... -w = self.bal_windows.get(window.winId, None) # legge con la *funzione* winId -``` - -**Causa:** `winId` senza parentesi è il **metodo legato** (bound method), non -l'identificatore della finestra. Usato come chiave "funziona per caso" perché -lo stesso oggetto-finestra produce lo stesso bound method; ma è fragile e -semanticamente errato: con più finestre/wallet o dopo riaperture la -corrispondenza può saltare, creando `BalWindow` duplicati o non trovando quello -giusto → stato incoerente (contribuisce a S2). - -**Fix proposto:** -- Usare una chiave stabile e corretta, es. `int(window.winId())` oppure - `id(window)`, in modo **coerente** sia in scrittura sia in lettura. - ---- - -### B5 — `on_close` ingoia tutti gli errori 🟠 -**Dove:** `window.py:664-677`. - -```python -def on_close(self): - try: - if not self.disable_plugin: - close_window = BalBuildWillDialog(self) - close_window.build_will_task() - self.save_willitems() - self.heirs_tab.close() - ... - except Exception: - pass # <-- nasconde qualsiasi errore di cleanup -``` - -**Causa:** se una qualsiasi di queste operazioni fallisce, l'eccezione viene -silenziata: tab/menu non vengono rimossi, lo stato (`willitems`, `heirs`, tab) -resta in memoria e "sporco" finché non si riavvia Electrum (S2). - -**Fix proposto:** -- Non silenziare: loggare l'errore con `_logger`. -- Rendere il cleanup **robusto e idempotente** (ogni passo in un try/except - separato con log), così un fallimento parziale non blocca gli altri passi. -- Azzerare esplicitamente lo stato (`willitems={}`, riferimenti a tab/menu a - `None`) a fine `on_close`. - ---- - -### B6 — `BalBlockingWaitingDialog` blocca il thread della GUI 🟠 -**Dove:** `dialogs.py:445-462`. - -```python -self.show() -# QCoreApplication.processEvents() # <-- commentato -# QCoreApplication.processEvents() -try: - task() # esegue il task SUL thread GUI -> finestra "congelata" -finally: - self.accept() -``` - -**Causa:** dopo `show()` non si dà alla GUI il tempo di disegnarsi -(`processEvents` è commentato) e poi si esegue `task()` **bloccando** il thread -dell'interfaccia. Risultato: la finestra "Please wait" può apparire vuota, -non ridisegnarsi, e l'app sembra bloccata (contribuisce a S1/percezione di -freeze). - -**Fix proposto:** -- O eseguire il task in un `TaskThread` (come fa già `BalWaitingDialog`), -- oppure, se deve restare bloccante, ripristinare un `processEvents()` dopo - `show()` per far disegnare la finestra prima del task. - ---- - -### B7 — `closeEvent`/`hideEvent` con cleanup thread commentato 🟠 -**Dove:** `dialogs.py:48-58` (`BalDialog`). - -```python -def closeEvent(self, event): - self._stopping = True - #if self.thread: - # self.thread.stop() # <-- disattivato - super().closeEvent(event) -``` - -**Causa:** alla chiusura del dialogo i thread eventualmente attivi **non** -vengono fermati. Restano in esecuzione in background, possono scrivere su widget -già distrutti o tenere risorse/connessioni → comportamenti erratici finché non -si riavvia (S2). - -**Fix proposto:** -- Ripristinare in modo sicuro lo stop dei thread: `if self.thread: - self.thread.stop(); self.thread.wait()` con guardia su `None`. - ---- - -### B8 — `BalBuildWillDialog.closeEvent` incompleto 🟠 -**Dove:** `dialogs.py:828-830`. - -```python -def closeEvent(self, event): - self._stopping = True - self.thread.stop() - # manca self.thread.wait() e manca super().closeEvent(event) -``` - -**Causa:** `stop()` segnala lo stop ma non attende la fine del thread -(`wait()`), e non viene chiamato `super().closeEvent(event)`: l'evento di -chiusura non è propagato correttamente. Possibili thread orfani e finestre che -non si chiudono pulite. - -**Fix proposto:** -- `self.thread.stop(); self.thread.wait(); super().closeEvent(event)` con - guardia su `self.thread is None`. - ---- - -### B9 — `show()+raise_()` senza `activateWindow()`/modalità 🟡 -**Dove:** `dialogs.py:1121-1122` (es. `WillExecutorDialog`/dettaglio). - -```python -self.show() -self.raise_() -# manca self.activateWindow(); nessuna modalità impostata -``` - -**Causa:** `raise_()` alza la finestra nello stack ma su alcuni window manager -(incluso Windows) senza `activateWindow()` non riceve il focus e può comunque -finire dietro. Senza modalità, l'utente può tornare alla finestra principale -lasciando il dialogo nascosto. - -**Fix proposto:** -- Aggiungere `self.activateWindow()` dopo `raise_()`, e valutare - `setWindowModality(Qt.WindowModal)` dove ha senso. - ---- - -### B10 — Gestione finestre multiple / multi-wallet fragile 🟡 -**Dove:** `plugin.py:30-62` (`init_qt`), `get_window` (`plugin.py:109-115`). - -**Causa:** la mappa `bal_windows` e l'aggancio ai menu si basano su assunzioni -(B4) e sull'iterazione dei figli del menubar per nome (`"&Tools"`), che è -sensibile alla **localizzazione** (tu usi `Locale: Italian_Italy`!). Se il menu -non si chiama esattamente `&Tools` nella lingua corrente, l'aggancio può -fallire silenziosamente. - -**Fix proposto:** -- Usare l'API ufficiale `window.tools_menu` (già usata in `init_menubar`, - `plugin.py:79`) invece di cercare il menu per titolo tradotto. -- Unificare la creazione/lookup di `BalWindow` su una chiave stabile (B4). - ---- - -## Strategia di correzione proposta (per la Fase B/C) - -Per **non cambiare la logica di funzionamento** e ridurre i rischi, propongo di -introdurre un **unico punto centralizzato** di gestione finestre (un piccolo -helper, es. `gui/qt/window_utils.py`) con funzioni tipo: - -- `show_modal(dialog)` → imposta parent corretto, modalità, `exec()`. -- `show_on_top(dialog)` → `show()` + `raise_()` + `activateWindow()` per i - pochi casi che devono restare non-modali. - -E poi sostituire i `.show()`/`.exec()` sparsi con queste funzioni. Vantaggi: -- la **logica di business resta intatta** (cosa fa il dialogo non cambia); -- si tocca **solo** il "come" viene mostrato/chiuso; -- più facile da testare e da revisionare (diff piccolo e localizzato). - -### Ordine consigliato -1. **B3 + B4** (init a caldo + chiave finestre): risolvono la radice di S2. -2. **B1 + B2 + B9** (parent/modalità/z-order): risolvono S1. -3. **B5 + B7 + B8** (cleanup robusto + thread): chiudono i residui di S2. -4. **B6 + B10** (waiting dialog + menu localizzati): rifiniture. - ---- - -## Cosa serve da te per la Fase B/C -- Conferma che posso modificare il **comportamento della GUI** (parent, - modalità, cleanup, init a caldo) mantenendo invariata la logica di business. -- Test su **Electrum portable Windows** dopo ogni gruppo di fix, con descrizione - /screenshot di cosa succede (apertura dialoghi, abilitazione a caldo, - chiusura wallet). - -> Nota: i bug B1–B10 esistono **identici nell'originale** — questo refactor li -> ha preservati fedelmente (era l'obiettivo della fase precedente). La Fase B/C -> li corregge. diff --git a/README.md b/README.md deleted file mode 100644 index 5e1449c..0000000 --- a/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# BAL — Bitcoin After Life (Electrum plugin) - -Free and decentralized **Bitcoin inheritance** support for the -[Electrum](https://electrum.org) wallet. 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. - -This repository contains a **behavior-preserving refactor** of the original -plugin. The logic was kept byte-identical wherever possible; only the file -layout was reorganized to cleanly separate **business logic** from the -**PyQt GUI**. - -## Repository layout - -``` -bal/ the installable Electrum plugin package -├── manifest.json plugin metadata (Electrum reads this) -├── qt.py Qt entry-point shim (re-exports Plugin) -├── core/ GUI-free logic (importable without Qt) -│ ├── util.py -│ ├── plugin_base.py -│ ├── heirs.py -│ ├── will.py -│ └── willexecutors.py -├── gui/qt/ PyQt6 presentation layer -│ ├── theme.py status → color mapping -│ ├── common.py shared imports / helpers -│ ├── widgets.py leaf widgets -│ ├── calendar.py calendar widget -│ ├── dialogs.py dialog windows -│ ├── lists.py tree/list views -│ ├── window.py per-wallet GUI controller -│ └── plugin.py Plugin (Electrum @hooks → GUI) -├── icons/ wallet_util/ LICENSE VERSION README.md -build_zip.py builds a clean, zipimport-friendly distribution zip -tests/ smoke + external-zip regression tests -``` - -## Requirements - -- **Electrum 4.7.2** — the last stable release exposing `json_db.register_dict`, - which this plugin relies on. Newer versions removed it. -- **PyQt6** (bundled with the Electrum desktop GUI). - -## Installation - -### Build the distribution archive - -```bash -python3 build_zip.py -# -> bal-electrum-plugin.zip (prints size + SHA-256 for integrity checks) -``` - -The builder writes a `zipimport`-friendly archive (files only, standard -DEFLATE, deterministic order) to avoid loader errors seen on some Electrum -portable builds. - -### Install as an external plugin (zip) - -1. Electrum → **Tools → Plugins** → install from file → pick the built zip. -2. Enable **Bitcoin After Life** and restart Electrum. -3. (Recommended) verify the downloaded zip's SHA-256 matches the value printed - by `build_zip.py`. - -### Install as an internal plugin - -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 -# imports + behavior -QT_QPA_PLATFORM=offscreen PYTHONPATH= \ - python3 tests/smoke_test.py electrum.plugins.bal - -# external-zip loading regression -QT_QPA_PLATFORM=offscreen PYTHONPATH= \ - python3 tests/external_zip_test.py bal-electrum-plugin.zip -``` - -## ⚠️ Safety - -This plugin builds real Bitcoin inheritance transactions with time-locks. Test -on **testnet** or a fund-less wallet first, and review the generated -transactions before broadcasting. - -## License - -MIT — see [`bal/LICENSE`](bal/LICENSE). diff --git a/REPORT_NETWORKING_PARALLELO.md b/REPORT_NETWORKING_PARALLELO.md deleted file mode 100644 index d77d737..0000000 --- a/REPORT_NETWORKING_PARALLELO.md +++ /dev/null @@ -1,253 +0,0 @@ -# Technical report — Parallel networking (Will-Executor anti-freeze) + UI feedback - -**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. Problem - -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` retried up to **10 times** with `time.sleep(3)` on every - timeout → roughly **~140 seconds per unreachable server**, summed one after - another. - -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. Solution (overview) - -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. - -### 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. - -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. Modified files (all on GitHub `Bitcoin-after-life/test`, branch `feature/networking-parallelo`) - -### 3.1 `bal/core/willexecutors.py` - -**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): -``` -- Defaults unchanged → callers that need the historical behaviour are - unaffected. -- Interactive callers pass `max_retries=0` → fast-fail. - -**`get_info_task(...)`** — fast-fail by default (`max_retries=0`); a -timeout/empty response yields `status="KO"`. - -**`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. - -**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). - -**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)`** 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/gui/qt/dialogs.py` - -- **`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 -``` -**Fixed** by restoring `din_output`. Found by running the official Gitea tests -(`tests/test_core_util.py::test_get_value_amount`). - ---- - -## 4. Verification (ruff + official tests) - -### 4.1 ruff (lint / PEP8) -- `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 Official tests from the Gitea repo `kaibot/bal-plugin-ai/tests` -Run against the refactored code (with all the networking + UI changes): - -| 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` (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 | - -Commands (as per 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 -QT_QPA_PLATFORM=offscreen PYTHONPATH= \ - python3 tests/parallel_ping_test.py bal -``` - ---- - -## 5. Integration notes / risks - -- **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. How to test - -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. - -The SHA-256 of the zip is printed by `build_zip.py` at the end of the build -(use it to verify integrity). diff --git a/bal/LICENSE b/bal/LICENSE deleted file mode 100644 index c9bc88f..0000000 --- a/bal/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 copronista - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/bal/README.md b/bal/README.md deleted file mode 100644 index 9cb3127..0000000 --- a/bal/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# 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/VERSION b/bal/VERSION deleted file mode 100644 index 9fc80f9..0000000 --- a/bal/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.3.2 \ No newline at end of file diff --git a/bal/__init__.py b/bal/__init__.py deleted file mode 100644 index f309239..0000000 --- a/bal/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -"""BAL - Bitcoin After Life Electrum plugin. - -Free and decentralized Bitcoin inheritance support for the Electrum wallet. - -This package was reorganized (Approach A: conservative, behavior-preserving) -to cleanly separate logic from presentation. The original monolithic plugin -mixed the business logic with the PyQt GUI; here the two concerns live in -distinct sub-packages: - - bal/ - core/ GUI-free business logic (importable without Qt) - util.py Generic helpers (encoding, validation, ...) - plugin_base.py BasePlugin subclass, config, timestamp handling - heirs.py Heir list model + transaction building - will.py Will / WillItem domain model - willexecutors.py Will-executor (dead-man's switch) networking - gui/ - qt/ PyQt6 presentation layer - theme.py Colors / status -> color mapping (status_color) - common.py Shared imports and small GUI helpers - widgets.py Leaf widgets (editors, labels, checkboxes, ...) - calendar.py BalCalendar widget - dialogs.py Dialog windows (wizard, build-will, detail, ...) - lists.py Tree/list views (heirs, preview, will-executors) - window.py BalWindow controller (per-wallet GUI state) - plugin.py Plugin class wiring Electrum @hooks to the GUI - qt.py Thin loader shim re-exporting `Plugin` for Electrum - -Electrum discovers the plugin through ``manifest.json`` and loads the GUI -entry point from ``qt.py`` (the shim), which imports the real ``Plugin`` -from ``gui.qt.plugin``. - -The plugin targets Electrum 4.7.2 (the last stable release exposing -``json_db.register_dict``) and PyQt6. -""" - -__version__ = "0.3.2" diff --git a/bal/bal_resources.py b/bal/bal_resources.py deleted file mode 100644 index dc2924c..0000000 --- a/bal/bal_resources.py +++ /dev/null @@ -1,14 +0,0 @@ -import os - -PLUGIN_DIR = os.path.split(os.path.realpath(__file__))[0] -DEFAULT_ICON = "bal32x32.png" -DEFAULT_ICON_PATH = "icons" - - -def icon_path(icon_basename: str = DEFAULT_ICON): - path = resource_path(DEFAULT_ICON_PATH, icon_basename) - return path - - -def resource_path(*parts): - return os.path.join(PLUGIN_DIR, *parts) diff --git a/bal/core/__init__.py b/bal/core/__init__.py deleted file mode 100644 index 6e1c1b2..0000000 --- a/bal/core/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -bal.core -======== - -Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin. - -Everything in this sub-package MUST stay completely free of any GUI / Qt -imports. The rule of thumb is: - - * ``bal.core`` -> "what the plugin does" (inheritance rules, building - and validating transactions, talking to - will-executor servers, persistence helpers). - * ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views). - -Keeping the two apart is the main motivation behind this rewrite: the original -code mixed transaction-building logic and presentation inside a single -4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit. - -No behaviour is changed with respect to the original plugin; the code has only -been reorganised and documented. -""" diff --git a/bal/core/heirs.py b/bal/core/heirs.py deleted file mode 100644 index 9a369cd..0000000 --- a/bal/core/heirs.py +++ /dev/null @@ -1,846 +0,0 @@ -""" -bal.core.heirs -============== - -Heir management and inheritance-transaction building. - -This is the heart of the plugin's Bitcoin logic and the most delicate part of -the whole codebase, so the implementation below is kept byte-for-byte identical -to the original ``heirs.py``; only the dead commented-out imports were removed -and documentation was added. - -An *heir* is stored as a small list addressed by the ``HEIR_*`` column -constants defined below. ``Heirs`` is a ``dict`` subclass persisted inside the -wallet DB under the ``"heirs"`` key. - -The ``prepare_transactions`` / ``Heirs.buildTransactions`` functions turn the -heir list plus the wallet UTXOs into a set of time-locked inheritance -transactions (optionally including a will-executor fee output). - -Will-executor "heirs" are synthetic entries whose key starts with the -``w!ll3x3c"`` marker; they are skipped by most heir comparisons. -""" - -import math -import random -import re -import threading -from typing import ( - TYPE_CHECKING, - Any, - Dict, - Optional, - Tuple, -) - -import dns -from dns.exception import DNSException -from electrum import ( - bitcoin, - constants, - dnssec, -) -from electrum.logging import Logger, get_logger -from electrum.transaction import ( - PartialTransaction, - PartialTxInput, - PartialTxOutput, - TxOutpoint, -) -from electrum.util import ( - BitcoinException, - bfh, - read_json_file, - to_string, - trigger_callback, - write_json_file, -) - -from .util import Util -from .willexecutors import Willexecutors - -if TYPE_CHECKING: - from electrum.simple_config import SimpleConfig - - -_logger = get_logger(__name__) - -# Column layout of a stored heir list. These indices are part of the on-disk -# wallet format and are relied upon all over the codebase, so they must NEVER -# be reordered. -HEIR_ADDRESS = 0 # destination Bitcoin address -HEIR_AMOUNT = 1 # requested amount (satoshis or "%") -HEIR_LOCKTIME = 2 # locktime after which the heir may claim the funds -HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed -HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...") -TRANSACTION_LABEL = "inheritance transaction" - - -class AliasNotFoundException(Exception): - pass - - -def reduce_outputs(in_amount, out_amount, fee, outputs): - if in_amount < out_amount: - for output in outputs: - output.value = math.floor((in_amount - fee) / out_amount * output.value) - - -def create_op_return_script(data_hex: str) -> bytes: - """Crea scriptpubkey OP_RETURN in bytes""" - data = bytes.fromhex(data_hex) - - if len(data) > 80: - raise ValueError("OP_RETURN data too big (max 80 bytes)") - - # Costruzione manuale: OP_RETURN + push data - if len(data) <= 75: - # Formato più comune: OP_RETURN + 1-byte length + data - script = b'\x6a' + bytes([len(data)]) + data - else: - # Per dati più grandi (fino a 80) si usa OP_PUSHDATA1 - script = b'\x6a\x4c' + bytes([len(data)]) + data - - return script - -def prepare_transactions(locktimes, available_utxos, fees, wallet): - available_utxos = sorted( - available_utxos, - key=lambda x: "{}:{}:{}".format( - x.value_sats(), x.prevout.txid, x.prevout.out_idx - ), - ) - # total_used_utxos = [] - txsout = {} - locktime, _ = Util.get_lowest_locktimes(locktimes) - if not locktime: - _logger.info("prepare transactions, no locktime") - return - locktime = locktime[0] - - heirs = locktimes[locktime] - true = True - while true: - true = False - fee = fees.get(locktime, 0) - out_amount = fee - description = "" - outputs = [] - paid_heirs = {} - for name, heir in heirs.items(): - if len(heir) > HEIR_REAL_AMOUNT and "DUST" not in str( - heir[HEIR_REAL_AMOUNT] - ): - try: - real_amount = heir[HEIR_REAL_AMOUNT] - outputs.append( - PartialTxOutput.from_address_and_value( - heir[HEIR_ADDRESS], real_amount - ) - ) - out_amount += real_amount - description += f"{name}\n" - except BitcoinException as e: - _logger.info("exception decoding output {} - {}".format(type(e), e)) - heir[HEIR_REAL_AMOUNT] = e - - except Exception as e: - heir[HEIR_REAL_AMOUNT] = e - _logger.error(f"error preparing transactions: {e}") - pass - paid_heirs[name] = heir - - in_amount = 0.0 - used_utxos = [] - try: - while utxo := available_utxos.pop(): - value = utxo.value_sats() - in_amount += value - used_utxos.append(utxo) - if in_amount >= out_amount: - break - - except IndexError as e: - _logger.error( - f"error preparing transactions index error {e} {in_amount}, {out_amount}" - ) - pass - if int(in_amount) < int(out_amount): - _logger.error( - "error preparing transactions in_amount < out_amount ({} < {}) " - ) - continue - heirsvalue = out_amount - change = get_change_output(wallet, in_amount, out_amount, fee) - if change: - outputs.append(change) - for i in range(0, 100): - random.shuffle(outputs) - - #op_return_text = "Hello Bal!" - - ## Convert text to hex - #op_return_hex = op_return_text.encode('utf-8').hex() - #op_return_script = create_op_return_script(op_return_hex) - #outputs.append(PartialTxOutput(value=0, scriptpubkey=op_return_script)) - tx = PartialTransaction.from_io( - used_utxos, - outputs, - locktime=Util.parse_locktime_string(locktime, wallet), - version=2, - ) - if len(description) > 0: - tx.description = description[:-1] - else: - tx.description = "" - tx.heirsvalue = heirsvalue - tx.set_rbf(True) - tx.remove_signatures() - txid = tx.txid() - if txid is None: - raise Exception(f"txid is none: {tx}") - - tx.heirs = paid_heirs - tx.my_locktime = locktime - txsout[txid] = tx - - if change: - change_idx = tx.get_output_idxs_from_address(change.address) - prevout = TxOutpoint(txid=bfh(tx.txid()), out_idx=change_idx.pop()) - txin = PartialTxInput(prevout=prevout) - txin._trusted_value_sats = change.value - txin.script_descriptor = change.script_descriptor - txin.is_mine = True - txin._TxInput__address = change.address - txin._TxInput__scriptpubkey = change.scriptpubkey - txin._TxInput__value_sats = change.value - txin.utxo = tx - available_utxos.append(txin) - txsout[txid].available_utxos = available_utxos[:] - return txsout - - -def get_utxos_from_inputs(tx_inputs, tx, utxos): - for tx_input in tx_inputs: - prevoutstr = tx_input.prevout.to_str() - utxos[prevoutstr] = utxos.get(prevoutstr, {"input": tx_input, "txs": []}) - utxos[prevoutstr]["txs"].append(tx) - return utxos - - -# TODO calculate de minimum inputs to be invalidated -def invalidate_inheritance_transactions(wallet): - # listids = [] - utxos = {} - dtxs = {} - for k, v in wallet.get_all_labels().items(): - tx = None - if TRANSACTION_LABEL == v: - tx = wallet.adb.get_transaction(k) - if tx: - dtxs[tx.txid()] = tx - get_utxos_from_inputs(tx.inputs(), tx, utxos) - - for key, utxo in utxos.items(): - txid = key.split(":")[0] - if txid in dtxs: - for tx in utxo["txs"]: - txid = tx.txid() - del dtxs[txid] - - utxos = {} - for txid, tx in dtxs.items(): - get_utxos_from_inputs(tx.inputs(), tx, utxos) - - utxos = sorted(utxos.items(), key=lambda item: len(item[1])) - - remaining = {} - invalidated = [] - for key, value in utxos: - for tx in value["txs"]: - txid = tx.txid() - if txid not in invalidated: - invalidated.append(tx.txid()) - remaining[key] = value - - -def print_transaction(heirs, tx, locktimes, tx_fees): - jtx = tx.to_json() - print(f"TX: {tx.txid()}\t-\tLocktime: {jtx['locktime']}") - print("---") - for inp in jtx["inputs"]: - print(f"{inp['address']}: {inp['value_sats']}") - print("---") - for out in jtx["outputs"]: - heirname = "" - for key in heirs.keys(): - heir = heirs[key] - if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str( - jtx["locktime"] - ): - heirname = key - print(f"{heirname}\t{out['address']}: {out['value_sats']}") - - print() - size = tx.estimated_size() - print( - "fee: {}\texpected: {}\tsize: {}".format( - tx.input_value() - tx.output_value(), size * tx_fees, size - ) - ) - - print() - try: - print(tx.serialize_to_network()) - except Exception: - print("impossible to serialize") - print() - - -def get_change_output(wallet, in_amount, out_amount, fee): - change_amount = int(in_amount - out_amount - fee) - if change_amount > wallet.dust_threshold(): - change_addresses = wallet.get_change_addresses_for_new_transaction() - out = PartialTxOutput.from_address_and_value(change_addresses[0], change_amount) - out.is_change = True - 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): - Logger.__init__(self) - self.db = wallet.db - self.wallet = wallet - d = self.db.get("heirs", {}) - try: - self.update(d) - except Exception: - return - - def invalidate_transactions(self, wallet): - invalidate_inheritance_transactions(wallet) - - def save(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) - data = Heirs._validate(data) - self.update(data) - self.save() - - def export_file(self, path): - write_json_file(path, self) - - def __setitem__(self, key, value): - dict.__setitem__(self, key, value) - self.save() - - def pop(self, key): - if key in self.keys(): - res = dict.pop(self, key) - self.save() - return res - - def get_locktimes(self, from_locktime, a=False): - locktimes = {} - for key in self.keys(): - locktime = Util.parse_locktime_string(self[key][HEIR_LOCKTIME]) - if locktime > from_locktime and not a or locktime <= from_locktime and a: - locktimes[int(locktime)] = None - return list(locktimes.keys()) - - def check_locktime(self): - return False - - def normalize_perc( - self, heir_list, total_balance, relative_balance, wallet, real=False - ): - amount = 0 - for key, v in heir_list.items(): - try: - column = HEIR_AMOUNT - if real: - column = HEIR_REAL_AMOUNT - if "DUST" in str(v[column]): - column = HEIR_DUST_AMOUNT - value = int( - math.floor( - total_balance - / relative_balance - * self.amount_to_float(v[column]) - ) - ) - if value > wallet.dust_threshold(): - heir_list[key].insert(HEIR_REAL_AMOUNT, value) - amount += value - else: - heir_list[key].insert(HEIR_REAL_AMOUNT, f"DUST: {value}") - heir_list[key].insert(HEIR_DUST_AMOUNT, value) - _logger.info(f"{key}, {value} is dust will be ignored") - - except Exception as e: - raise e - return amount - - def amount_to_float(self, amount): - try: - return float(amount) - except Exception: - try: - return float(amount[:-1]) - except Exception: - return 0.0 - - def fixed_percent_lists_amount(self, from_locktime, dust_threshold, reverse=False): - fixed_heirs = {} - fixed_amount = 0.0 - percent_heirs = {} - percent_amount = 0.0 - fixed_amount_with_dust = 0.0 - for key in self.keys(): - try: - cmp = ( - Util.parse_locktime_string(self[key][HEIR_LOCKTIME]) - from_locktime - ) - if cmp <= 0: - _logger.debug( - "cmp < 0 {} {} {} {}".format( - cmp, key, self[key][HEIR_LOCKTIME], from_locktime - ) - ) - continue - if Util.is_perc(self[key][HEIR_AMOUNT]): - percent_amount += float(self[key][HEIR_AMOUNT][:-1]) - percent_heirs[key] = list(self[key]) - else: - heir_amount = int(math.floor(float(self[key][HEIR_AMOUNT]))) - fixed_amount_with_dust += heir_amount - fixed_heirs[key] = list(self[key]) - if heir_amount > dust_threshold: - fixed_amount += heir_amount - fixed_heirs[key].insert(HEIR_REAL_AMOUNT, heir_amount) - else: - fixed_heirs[key] = list(self[key]) - fixed_heirs[key].insert( - HEIR_REAL_AMOUNT, f"DUST: {heir_amount}" - ) - fixed_heirs[key].insert(HEIR_DUST_AMOUNT, heir_amount) - except Exception as e: - _logger.error(e) - return ( - fixed_heirs, - fixed_amount, - percent_heirs, - percent_amount, - fixed_amount_with_dust, - ) - - def prepare_lists( - self, balance, total_fees, wallet, willexecutor=False, from_locktime=0 - ): - if balance int(from_locktime): - try: - base_fee = int(willexecutor["base_fee"]) - willexecutors_amount += base_fee - h = [None] * 4 - h[HEIR_AMOUNT] = base_fee - h[HEIR_REAL_AMOUNT] = base_fee - h[HEIR_LOCKTIME] = locktime - h[HEIR_ADDRESS] = willexecutor["address"] - willexecutors[ - 'w!ll3x3c"' + willexecutor["url"] + '"' + str(locktime) - ] = h - except Exception: - return [], False - else: - _logger.error( - f"heir excluded from will locktime({locktime}){Util.int_locktime(locktime)} newbalance: - fixed_amount = self.normalize_perc( - fixed_heirs, newbalance, fixed_amount, wallet - ) - onlyfixed = True - - heir_list.update(fixed_heirs) - - newbalance -= fixed_amount - if newbalance > 0: - perc_amount = self.normalize_perc( - percent_heirs, newbalance, percent_amount, wallet - ) - newbalance -= perc_amount - heir_list.update(percent_heirs) - if newbalance > 0: - newbalance += fixed_amount - fixed_amount = self.normalize_perc( - fixed_heirs, newbalance, fixed_amount_with_dust, wallet, real=True - ) - newbalance -= fixed_amount - heir_list.update(fixed_heirs) - - heir_list = sorted( - heir_list.items(), - key=lambda item: Util.parse_locktime_string(item[1][HEIR_LOCKTIME], wallet), - ) - - locktimes = {} - for key, value in heir_list: - locktime = Util.parse_locktime_string(value[HEIR_LOCKTIME]) - if locktime not in locktimes: - locktimes[locktime] = {key: value} - else: - locktimes[locktime][key] = value - return locktimes, onlyfixed - - def is_perc(self, key): - return Util.is_perc(self[key][HEIR_AMOUNT]) - - def buildTransactions( - self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0 - ): - Heirs._validate(self) - if len(self) <= 0: - _logger.info("while building transactions there was no heirs") - return - balance = 0.0 - len_utxo_set = 0 - available_utxos = [] - if not utxos: - utxos = wallet.get_utxos() - willexecutors = Willexecutors.get_willexecutors(bal_plugin) or {} - self.decimal_point = bal_plugin.get_decimal_point() - no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get() - for utxo in utxos: - if utxo.value_sats() > 0 * tx_fees: - balance += utxo.value_sats() - len_utxo_set += 1 - available_utxos.append(utxo) - if len_utxo_set == 0: - _logger.info("no usable utxos") - return - j = -2 - willexecutorsitems = list(willexecutors.items()) - willexecutorslen = len(willexecutorsitems) - alltxs = {} - while True: - j += 1 - if j >= willexecutorslen: - break - elif 0 <= j: - url, willexecutor = willexecutorsitems[j] - if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold(): - continue - else: - willexecutor["url"] = url - elif j == -1: - if not no_willexecutors: - continue - url = willexecutor = False - else: - break - fees = {} - i = 0 - while i < 10: - txs = {} - redo = False - i += 1 - total_fees = 0 - for fee in fees: - total_fees += int(fees[fee]) - # newbalance = balance - try: - locktimes, onlyfixed = self.prepare_lists( - balance, total_fees, wallet, willexecutor, from_locktime - ) - except WillExecutorFeeException: - i = 10 - continue - if locktimes: - try: - txs = prepare_transactions( - locktimes, available_utxos[:], fees, wallet - ) - if not txs: - return {} - except Exception as e: - _logger.error( - f"build transactions: error preparing transactions: {e}" - ) - try: - if "w!ll3x3c" in e.heirname: - Willexecutors.is_selected( - e.heirname[len("w!ll3x3c") :], False - ) - break - except Exception: - raise e - total_fees = 0 - total_fees_real = 0 - total_in = 0 - for txid, tx in txs.items(): - tx.willexecutor = willexecutor - fee = tx.estimated_size() * tx_fees - txs[txid].tx_fees = tx_fees - total_fees += fee - total_fees_real += tx.get_fee() - total_in += tx.input_value() - rfee = tx.input_value() - tx.output_value() - if rfee < fee or rfee > fee + wallet.dust_threshold(): - redo = True - # oldfees = fees.get(tx.my_locktime, 0) - fees[tx.my_locktime] = fee - - if balance - total_in > wallet.dust_threshold(): - redo = True - if not redo: - break - if i >= 10: - break - else: - _logger.info( - f"no locktimes for willexecutor {willexecutor} skipped" - ) - break - alltxs.update(txs) - - return alltxs - - def get_transactions( - self, bal_plugin, wallet, tx_fees, utxos=None, from_locktime=0 - ): - txs = self.buildTransactions(bal_plugin, wallet, tx_fees, utxos, from_locktime) - if txs: - temp_txs = {} - for txid in txs: - if txs[txid].available_utxos: - temp_txs.update( - self.get_transactions( - bal_plugin, - wallet, - tx_fees, - txs[txid].available_utxos, - txs[txid].locktime, - ) - ) - txs.update(temp_txs) - return txs - - def resolve(self, k): - if bitcoin.is_address(k): - return {"address": k, "type": "address"} - if k in self.keys(): - _type, addr = self[k] - if _type == "address": - return {"address": addr, "type": "heir"} - if openalias := self.resolve_openalias(k): - return openalias - raise AliasNotFoundException("Invalid Bitcoin address or alias", k) - - @classmethod - def resolve_openalias(cls, url: str) -> Dict[str, Any]: - out = cls._resolve_openalias(url) - if out: - address, name, validated = out - return { - "address": address, - "name": name, - "type": "openalias", - "validated": validated, - } - return {} - - def by_name(self, name): - for k in self.keys(): - _type, addr = self[k] - if addr.casefold() == name.casefold(): - return {"name": addr, "type": _type, "address": k} - return None - - def fetch_openalias(self, config: "SimpleConfig"): - self.alias_info = None - alias = config.OPENALIAS_ID - if alias: - alias = str(alias) - - def f(): - self.alias_info = self._resolve_openalias(alias) - trigger_callback("alias_received") - - t = threading.Thread(target=f) - t.daemon = True - t.start() - - @classmethod - def _resolve_openalias(cls, url: str) -> Optional[Tuple[str, str, bool]]: - # support email-style addresses, per the OA standard - url = url.replace("@", ".") - try: - records, validated = dnssec.query(url, dns.rdatatype.TXT) - except DNSException as e: - _logger.info(f"Error resolving openalias: {repr(e)}") - return None - prefix = "btc" - for record in records: - string = to_string(record.strings[0], "utf8") - if string.startswith("oa1:" + prefix): - address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)") - name = cls.find_regex(string, r"recipient_name=([^;]+)") - if not name: - name = address - if not address: - continue - return address, name, validated - - @staticmethod - def find_regex(haystack, needle): - regex = re.compile(needle) - try: - return regex.search(haystack).groups()[0] - except AttributeError: - return None - - def validate_address(address): - if not bitcoin.is_address(address, net=constants.net): - raise NotAnAddress(f"not an address,{address}") - return address - - def validate_amount(amount): - try: - famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount) - if famount <= 0.00000001: - raise AmountNotValid(f"amount have to be positive {famount} < 0") - except Exception as e: - raise AmountNotValid(f"amount not properly formatted, {e}") - return amount - - def validate_locktime(locktime, timestamp_to_check=False): - try: - if timestamp_to_check: - if Util.parse_locktime_string(locktime, None) < timestamp_to_check: - raise HeirExpiredException() - except Exception as e: - raise LocktimeNotValid(f"locktime string not properly formatted, {e}") - return locktime - - def validate_heir(k, v, timestamp_to_check=False): - address = Heirs.validate_address(v[HEIR_ADDRESS]) - amount = Heirs.validate_amount(v[HEIR_AMOUNT]) - locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check) - return (address, amount, locktime) - - def _validate(data, timestamp_to_check=False): - - for k, v in list(data.items()): - if k == "heirs": - return Heirs._validate(v, timestamp_to_check) - try: - Heirs.validate_heir(k, v, timestamp_to_check) - except Exception as e: - _logger.info(f"exception heir removed {e}") - data.pop(k) - return data - - -class NotAnAddress(ValueError): - pass - - -class AmountNotValid(ValueError): - pass - - -class LocktimeNotValid(ValueError): - pass - - -class HeirExpiredException(LocktimeNotValid): - pass - - -class HeirAmountIsDustException(Exception): - pass - - -class NoHeirsException(Exception): - pass - - -class WillExecutorFeeException(Exception): - def __init__(self, willexecutor): - self.willexecutor = willexecutor - - def __str__(self): - return "WillExecutorFeeException: {} fee:{}".format( - self.willexecutor["url"], self.willexecutor["base_fee"] - ) -class BalanceTooLowException(Exception): - def __init__(self,balance, dust_threshold, fees): - self.balance=balance - self.dust_threshold = dust_threshold - self.fees = fees - def __str__(self): - return f"Balance too low, balance: {self.balance}, dust threshold: {self.dust_threshold}, fees: {self.fees}" diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py deleted file mode 100644 index 8b281db..0000000 --- a/bal/core/plugin_base.py +++ /dev/null @@ -1,402 +0,0 @@ -""" -bal.core.plugin_base -===================== - -GUI-agnostic foundation of the plugin. - -It contains: - * :class:`BalConfig` - a thin typed wrapper around an Electrum config key - with a default value. - * :class:`BalPlugin` - the base plugin class (extends Electrum's - ``BasePlugin``) holding every configuration option - and the default "will settings". The Qt-specific - ``Plugin`` subclass lives in ``bal.gui.qt.plugin``. - * :class:`BalTimestamp`- helper to convert between relative durations - (``"30d"``, ``"1y"``) and absolute timestamps. - -It also registers the three custom persisted dictionaries (``heirs``, -``will`` and ``will_settings``) with Electrum's JSON database so they are -serialised together with the wallet file. - -This module performs **no** GUI work and imports nothing from PyQt / electrum.gui. -""" - -import os -import platform -from datetime import date, datetime, timedelta - -from electrum import constants, json_db -from electrum.logging import get_logger -from electrum.plugin import BasePlugin -from electrum.transaction import tx_from_any - -_logger = get_logger(__name__) - - -# --------------------------------------------------------------------------- # -# Wallet-DB registration -# --------------------------------------------------------------------------- # -# Electrum needs to know how to (de)serialise the custom dictionaries the -# plugin stores inside the wallet file. ``register_dict`` associates a key -# name with a conversion callable applied to each value when the wallet is -# loaded. ``will`` values run through ``get_will`` so the stored transaction -# hex is turned back into a ``Transaction`` object. -def get_will(x): - """Deserialise a stored will entry, rebuilding its ``tx`` object.""" - try: - x["tx"] = tx_from_any(x["tx"]) - except Exception as e: - raise e - return x - - -json_db.register_dict("heirs", tuple, None) -json_db.register_dict("will", dict, None) -json_db.register_dict("will_settings", lambda x: x, None) - - -class BalConfig: - """Typed accessor for a single Electrum configuration key. - - Wraps ``config.get`` / ``config.set_key`` and supplies a default value - when the key is missing. - """ - - def __init__(self, config, name, default): - self.config = config - self.name = name - self.default = default - - def get(self, default=None): - """Return the stored value, falling back to ``default`` then ``self.default``.""" - v = self.config.get(self.name, default) - if v is None: - if default is not None: - v = default - else: - v = self.default - return v - - def set(self, value, save=True): - """Persist ``value`` for this key.""" - self.config.set_key(self.name, value, save=save) - - -class BalPlugin(BasePlugin): - """Base plugin: holds configuration and default inheritance settings. - - The GUI layer subclasses this in ``bal.gui.qt.plugin.Plugin`` and adds the - Electrum ``@hook`` methods. Keeping the configuration here means the CLI - layer (or unit tests) can use the plugin logic without importing Qt. - """ - - _version = None - __version__ = "0.3.2" # AUTOMATICALLY GENERATED DO NOT EDIT - - # Command used to open an .ics calendar file, per operating system. - default_app = { - "Linux": "xdg-open", - "Windows": "cmd /c start", - "Darwin": "open", - } - - # Human-readable chain name ("bitcoin", "testnet", "regtest", ...). - chainname = ( - constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin" - ) - - # Default geometry hint for some dialogs (kept from the original code). - SIZE = (159, 97) - - def version(self): - """Return the plugin version, read once from the ``VERSION`` file.""" - if not self._version: - try: - f = "" - with open("{}/VERSION".format(self.plugin_dir), "r") as fi: - f = str(fi.read()) - self._version = f.strip() - except Exception as e: - _logger.error(f"failed to get version: {e}") - self._version = "unknown" - return self._version - - def __init__(self, parent, config, name): - self.logger = get_logger(__name__) - BasePlugin.__init__(self, parent, config, name) - - # Base directory for plugin data inside the Electrum data dir. - self.base_dir = os.path.join(config.electrum_path(), "bal") - self.plugin_dir = os.path.split(os.path.realpath(__file__))[0] - - # Make the plugin importable when loaded from a zip (legacy behaviour: - # the parent directory of this file is added to ``sys.path``). - zipfile = "/".join(self.plugin_dir.split("/")[:-1]) - import sys - - sys.path.insert(0, zipfile) - - self.parent = parent - self.config = config - self.name = name - - # ---------------------------------------------------------------- # - # Configuration options (all persisted via Electrum's config). - # ---------------------------------------------------------------- # - self.ASK_BROADCAST = BalConfig(config, "bal_ask_broadcast", True) - self.BROADCAST = BalConfig(config, "bal_broadcast", True) - self.LOCKTIME_TIME = BalConfig(config, "bal_locktime_time", 90) - self.LOCKTIME_BLOCKS = BalConfig(config, "bal_locktime_blocks", 144 * 90) - self.LOCKTIMEDELTA_TIME = BalConfig(config, "bal_locktimedelta_time", 7) - self.LOCKTIMEDELTA_BLOCKS = BalConfig( - config, "bal_locktimedelta_blocks", 144 * 7 - ) - self.ENABLE_MULTIVERSE = BalConfig(config, "bal_enable_multiverse", False) - self.TX_FEES = BalConfig(config, "bal_tx_fees", 100) - self.INVALIDATE = BalConfig(config, "bal_invalidate", True) - self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True) - self.PREVIEW = BalConfig(config, "bal_preview", True) - self.SAVE_TXS = BalConfig(config, "bal_save_txs", True) - - self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True) - self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True) - self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True) - self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True) - self.FIRST_EXECUTION = BalConfig(config, "bal_first_execution", True) - self.WELIST_SERVER = BalConfig( - config, "bal_welist_server", "https://welist.bitcoin-after.life/" - ) - self.EVENT_DESCRIPTION = BalConfig( - config, - "bal_event_description", - "BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete", - ) - self.EVENT_SUMMARY = BalConfig( - config, "bal_event_summary", "BAL -Will execution of $wallet_name" - ) - - # Default will-executor servers, keyed by network. - self.WILLEXECUTORS = BalConfig( - config, - "bal_willexecutors", - { - "mainnet": { - "https://we.bitcoin-after.life": { - "base_fee": 100000, - "status": "New", - "info": "Bitcoin After Life Will Executor", - "address": "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf", - "selected": True, - } - }, - "testnet": { - "https://we.bitcoin-after.life": { - "base_fee": 100000, - "status": "New", - "info": "Bitcoin After Life Will Executor", - "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", - "selected": True, - } - }, - "testnet4": { - "https://we.bitcoin-after.life": { - "base_fee": 100000, - "status": "New", - "info": "Bitcoin After Life Will Executor", - "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", - "selected": True, - } - }, - "regtest": { - "https://we.bitcoin-after.life": { - "base_fee": 100000, - "status": "New", - "info": "Bitcoin After Life Will Executor", - "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", - "selected": True, - } - }, - }, - ) - self.WILL_SETTINGS = BalConfig( - config, - "bal_will_settings", - BalPlugin.default_will_settings(), - ) - - self.system = platform.system() - self.CALENDAR_APP = BalConfig( - config, "bal_open_app", self.default_app.get(self.system, "") - ) - - # Cached toggles used by the GUI list filters. - self._hide_invalidated = self.HIDE_INVALIDATED.get() - self._hide_replaced = self.HIDE_REPLACED.get() - - def resource_path(self, *parts): - """Absolute path to a file bundled inside the plugin directory.""" - return os.path.join(self.plugin_dir, *parts) - - def sync_hide_filters(self): - """Re-read the "hide" filter flags from the persisted config. - - The cached ``_hide_invalidated`` / ``_hide_replaced`` flags are used by - the GUI list to decide which rows to skip. They can be changed from two - different places: - - * the list toolbar buttons, which call :meth:`hide_invalidated` / - :meth:`hide_replaced` (a toggle that updates both the cache and the - config), and - * the Settings dialog checkboxes, which write the config directly - (``BalConfig.set``) without touching the cached flags. - - In the second case the cache and the config would drift apart and the - transaction list would keep filtering with the *old* value, so the - toggled rows never appear/disappear until Electrum is restarted. - Re-syncing the cache from the config here (called by ``update_all``) - keeps every code path coherent regardless of where the change came - from. - """ - self._hide_invalidated = self.HIDE_INVALIDATED.get() - self._hide_replaced = self.HIDE_REPLACED.get() - - def hide_invalidated(self): - """Toggle (and persist) the "hide invalidated transactions" filter.""" - self._hide_invalidated = not self._hide_invalidated - self.HIDE_INVALIDATED.set(self._hide_invalidated) - - def hide_replaced(self): - """Toggle (and persist) the "hide replaced transactions" filter.""" - self._hide_replaced = not self._hide_replaced - self.HIDE_REPLACED.set(self._hide_replaced) - - def validate_will_settings(self, will_settings): - """Fill in any missing will-setting with its default value.""" - defaults = BalPlugin.default_will_settings() - if not will_settings: - will_settings = [] - if int(will_settings.get("baltx_fees", 0)) < 1: - will_settings["baltx_fees"] = defaults['baltx_fees'] - if not will_settings.get("threshold"): - will_settings["threshold"] = defaults['threshold'] - if not will_settings.get("locktime"): - will_settings["locktime"] = defaults['locktime'] - return will_settings - - @staticmethod - def default_will_settings(): - """Default will settings: a fee rate plus absolute threshold/locktime.""" - will_settings = {"baltx_fees": 100} - will_settings.update(BalPlugin.default_will_settings_absolute()) - return will_settings - - @staticmethod - def default_will_settings_absolute(): - """Convert the default relative dates into absolute timestamps (from today).""" - relative_dates = BalPlugin.default_will_settings_relative() - today = date.today() - dt = datetime(today.year, today.month, today.day, 0, 0, 0) - threshold = ( - dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days()) - ).timestamp() - locktime = ( - dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days()) - ).timestamp() - return {"threshold": threshold, "locktime": locktime} - - @staticmethod - def default_will_settings_relative(): - """Default relative dates: 30 days threshold, 1 year locktime.""" - return {"threshold": "30d", "locktime": "1y"} - - -class BalTimestamp: - """Parse and convert relative durations / absolute timestamps. - - A value may be: - * ``"y"`` -> ``n`` years (unit ``"y"``) - * ``"d"`` -> ``n`` days (unit ``"d"``) - * an integer -> an absolute UNIX timestamp (``unit is None``) - """ - - value = None - unit = None - - def __init__(self, value): - str_value = str(value) - if str_value and str_value[-1].lower() in ("y", "d"): - self.value = int(str_value[:-1]) - self.unit = str_value[-1] - else: - try: - self.value = int(value) - except Exception as _e: - self.value = 1 - self.unit = None - - def duration_to_days(self): - """Return the duration expressed in days (years are ``*365``).""" - return self.value * 365 if self.unit == 'y' else self.value - - @staticmethod - def _safe_fromtimestamp(ts): - """``datetime.fromtimestamp`` that never raises ``OverflowError``. - - On Windows ``time_t`` is 32-bit, so ``datetime.fromtimestamp`` raises - ``OverflowError: Python int too large to convert to C int`` for any - timestamp past the year-2038 limit (e.g. ``NLOCKTIME_MAX = 2**32 - 1``, - used as the default/sentinel locktime). On 64-bit Linux the same call - succeeds, which is why this only crashed on the user's Windows build. - - We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own - ``get_max_allowed_timestamp`` workaround (see Electrum issue #6170). - """ - INT32_MAX = 2 ** 31 - 1 - try: - return datetime.fromtimestamp(ts) - except (OSError, OverflowError, ValueError): - try: - return datetime.fromtimestamp(min(int(ts), INT32_MAX)) - except (OSError, OverflowError, ValueError): - return datetime.fromtimestamp(INT32_MAX) - - def to_date(self, from_date=None, reverse=False): - """Resolve to a ``datetime``. - - For absolute values the stored timestamp is returned; for relative ones - the duration is added to (or, if ``reverse``, subtracted from) - ``from_date`` (defaulting to *now*), normalised to midnight. - """ - if self.unit is None: - return self._safe_fromtimestamp(self.value) - else: - if from_date is None: - from_date = datetime.now() - if isinstance(from_date, (int, float)): - from_date = self._safe_fromtimestamp(from_date) - reverse = 1 if not reverse else -1 - try: - return ( - from_date + (reverse * timedelta(days=self.duration_to_days())) - ).replace(hour=0, minute=0, second=0, microsecond=0) - except (OverflowError, OSError, ValueError): - # Duration overflowed datetime's range; clamp to INT32_MAX. - return self._safe_fromtimestamp(2 ** 31 - 1).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - - def to_timestamp(self, from_date=None, reverse=False): - """Same as :meth:`to_date` but returns a UNIX timestamp.""" - return self.to_date(from_date, reverse).timestamp() - - def __str__(self): - if self.unit is None: - return self._safe_fromtimestamp(self.value).isoformat() - else: - return f"{self.value}{self.unit}" - - def __repr__(self): - if self.unit is None: - return self._safe_fromtimestamp(self.value).isoformat() - else: - return f"{self.value}{self.unit}" diff --git a/bal/core/util.py b/bal/core/util.py deleted file mode 100644 index 32d7ec1..0000000 --- a/bal/core/util.py +++ /dev/null @@ -1,619 +0,0 @@ -""" -bal.core.util -============= - -Small, stateless helper functions shared across the whole plugin. - -This module is intentionally GUI-free: it only deals with locktimes, amount -encoding/decoding, and comparing transactions / inputs / outputs / heirs. - -Historical note ---------------- -The original ``util.py`` also contained a set of ``print_*`` debugging helpers -(``print_var``, ``print_utxo``, ``print_prevout``) that dumped objects to -``stdout``. Those were development-only scaffolding, never called by the -plugin logic, so they have been removed during the rewrite. No behavioural -function has been changed: every method below is logically identical to the -original implementation. -""" - -import bisect -from datetime import datetime, timedelta - -from electrum.transaction import PartialTxOutput - -# Bitcoin consensus rule: an nLockTime value strictly below this threshold is -# interpreted as a *block height*, otherwise it is interpreted as a *UNIX -# timestamp*. This single constant drives most of the locktime handling below. -LOCKTIME_THRESHOLD = 500000000 - - -class Util: - """Namespace of static helpers (kept as a class to preserve the original - ``Util.method(...)`` call sites used throughout the plugin).""" - - # ------------------------------------------------------------------ # - # Locktime helpers - # ------------------------------------------------------------------ # - @staticmethod - def locktime_to_str(locktime): - """Render a locktime for display. - - If the value looks like a timestamp (``> LOCKTIME_THRESHOLD``) it is - formatted as an ISO date string; otherwise it is returned as-is. - """ - try: - locktime = int(locktime) - if locktime > LOCKTIME_THRESHOLD: - dt = datetime.fromtimestamp(locktime).isoformat() - return dt - - except Exception: - pass - return str(locktime) - - @staticmethod - def str_to_locktime(locktime): - """Parse a user-entered locktime string into its stored form. - - Relative values keep their suffix (``"30d"``, ``"1y"``, ``"144b"``); - absolute ISO dates are converted to an integer UNIX timestamp. - """ - try: - if locktime[-1] in ("y", "d", "b"): - return locktime - else: - return int(locktime) - except Exception: - pass - dt_object = datetime.fromisoformat(locktime) - timestamp = dt_object.timestamp() - return int(timestamp) - - @staticmethod - def parse_locktime_string(locktime, w=None): - """Resolve a (possibly relative) locktime string into a concrete int. - - Supported forms: - * plain int / timestamp -> returned unchanged - * ``"y"`` -> n years from now (as a timestamp) - * ``"d"`` -> n days from now (as a timestamp) - * ``"b"`` -> current block height + n (needs wallet - ``w`` to read the chain height) - """ - try: - return int(locktime) - - except Exception: - pass - try: - now = datetime.now() - if locktime[-1] == "y": - locktime = str(int(locktime[:-1]) * 365) + "d" - if locktime[-1] == "d": - return int( - (now + timedelta(days=int(locktime[:-1]))) - .replace(hour=0, minute=0, second=0, microsecond=0) - .timestamp() - ) - if locktime[-1] == "b": - locktime = int(locktime[:-1]) - height = 0 - if w: - height = Util.get_current_height(w.network) - locktime += int(height) - return int(locktime) - except Exception: - pass - return 0 - - @staticmethod - def int_locktime(seconds=0, minutes=0, hours=0, days=0, blocks=0): - """Convert a human duration into seconds (blocks counted as 600s each).""" - return int( - seconds - + minutes * 60 - + hours * 60 * 60 - + days * 60 * 60 * 24 - + blocks * 600 - ) - - # ------------------------------------------------------------------ # - # Amount helpers - # ------------------------------------------------------------------ # - @staticmethod - def encode_amount(amount, decimal_point): - """Convert a displayed BTC amount into integer satoshis. - - Percentage amounts (e.g. ``"50%"``) are passed through unchanged, since - they are resolved later against the wallet balance. - """ - if Util.is_perc(amount): - return amount - else: - try: - return int(float(amount) * pow(10, decimal_point)) - except Exception: - return 0 - - @staticmethod - def decode_amount(amount, decimal_point): - """Inverse of :meth:`encode_amount`: satoshis -> displayed string.""" - if Util.is_perc(amount): - return amount - else: - basestr = "{{:0.{}f}}".format(decimal_point) - try: - return basestr.format(float(amount) / pow(10, decimal_point)) - except Exception: - return str(amount) - - @staticmethod - def is_perc(value): - """True if ``value`` is a percentage string such as ``"25%"``.""" - try: - return value[-1] == "%" - except Exception: - return False - - # ------------------------------------------------------------------ # - # Heir / will-executor comparison helpers - # ------------------------------------------------------------------ # - @staticmethod - def cmp_array(heira, heirb): - """Element-wise equality of two sequences (length-safe).""" - try: - if len(heira) != len(heirb): - return False - for h in range(0, len(heira)): - if heira[h] != heirb[h]: - return False - return True - except Exception: - return False - - @staticmethod - def cmp_heir(heira, heirb): - """Two heirs are "the same" when address (0) and amount (1) match.""" - if heira[0] == heirb[0] and heira[1] == heirb[1]: - return True - return False - - @staticmethod - def cmp_willexecutor(willexecutora, willexecutorb): - """Compare two will-executor dicts by url / address / base_fee.""" - if willexecutora == willexecutorb: - return True - try: - if ( - willexecutora["url"] == willexecutorb["url"] - and willexecutora["address"] == willexecutorb["address"] - and willexecutora["base_fee"] == willexecutorb["base_fee"] - ): - return True - except Exception: - return False - return False - - @staticmethod - def search_heir_by_values(heirs, heir, values): - """Return the key of the first heir in ``heirs`` matching ``heir`` on - every column listed in ``values`` (or ``False`` if none).""" - for h, v in heirs.items(): - found = False - for val in values: - if val in v and v[val] != heir[val]: - found = True - - if not found: - return h - return False - - @staticmethod - def cmp_heir_by_values(heira, heirb, values): - """True when two heirs agree on every column index in ``values``.""" - for v in values: - if heira[v] != heirb[v]: - return False - return True - - @staticmethod - def cmp_heirs_by_values( - heirsa, heirsb, values, exclude_willexecutors=False, reverse=True - ): - """Set-equality of two heir collections, comparing only ``values``. - - When ``exclude_willexecutors`` is set, synthetic will-executor heirs - (those whose key contains the ``w!ll3x3c"`` marker) are skipped. The - ``reverse`` flag makes the comparison symmetric by running it both ways. - """ - for heira in heirsa: - if ( - exclude_willexecutors and 'w!ll3x3c"' not in heira - ) or not exclude_willexecutors: - found = False - for heirb in heirsb: - if Util.cmp_heir_by_values(heirsa[heira], heirsb[heirb], values): - found = True - if not found: - return False - if reverse: - return Util.cmp_heirs_by_values( - heirsb, - heirsa, - values, - exclude_willexecutors=exclude_willexecutors, - reverse=False, - ) - else: - return True - - @staticmethod - def cmp_heirs( - heirsa, - heirsb, - cmp_function=lambda x, y: x[0] == y[0] and x[3] == y[3], - reverse=True, - ): - """Compare two heir collections using a custom ``cmp_function``. - - Will-executor entries are ignored. As with - :meth:`cmp_heirs_by_values`, ``reverse`` makes the relation symmetric. - """ - try: - for heir in heirsa: - if 'w!ll3x3c"' not in heir: - if heir not in heirsb or not cmp_function( - heirsa[heir], heirsb[heir] - ): - if not Util.search_heir_by_values(heirsb, heirsa[heir], [0, 3]): - return False - if reverse: - return Util.cmp_heirs(heirsb, heirsa, cmp_function, False) - else: - return True - except Exception as e: - raise e - - # ------------------------------------------------------------------ # - # Transaction input/output comparison helpers - # ------------------------------------------------------------------ # - @staticmethod - def cmp_inputs(inputsa, inputsb): - """True when both input lists reference the same set of UTXOs.""" - if len(inputsa) != len(inputsb): - return False - for inputa in inputsa: - if not Util.in_utxo(inputa, inputsb): - return False - return True - - @staticmethod - def cmp_outputs(outputsa, outputsb, willexecutor_output=None): - """True when both output lists contain the same (address, value) pairs. - - The optional ``willexecutor_output`` is treated as a wildcard match so - that the will-executor's fee output does not break the comparison. - """ - if len(outputsa) != len(outputsb): - return False - for outputa in outputsa: - if not Util.cmp_output(outputa, willexecutor_output): - if not Util.in_output(outputa, outputsb): - return False - return True - - @staticmethod - def cmp_txs(txa, txb): - """Two transactions are equivalent when their inputs and outputs match.""" - if not Util.cmp_inputs(txa.inputs(), txb.inputs()): - return False - if not Util.cmp_outputs(txa.outputs(), txb.outputs()): - return False - return True - - @staticmethod - def get_value_amount(txa, txb): - """Sum of the values of outputs that appear (same addr+value) in both - transactions. Returns ``False`` as soon as an output of ``txa`` shares - neither amount nor address with any output of ``txb``.""" - outputsa = txa.outputs() - value_amount = 0 - - for outa in outputsa: - 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: - value_amount += outa.value - if same_amount: - pass - if same_address: - pass - - return value_amount - - # ------------------------------------------------------------------ # - # Locktime arithmetic - # ------------------------------------------------------------------ # - @staticmethod - def chk_locktime(timestamp_to_check, block_height_to_check, locktime): - """Return True if ``locktime`` is still in the future. - - Timestamp-style and block-height-style locktimes are compared against - the respective "to_check" reference value. - """ - # TODO BUG: WHAT HAPPEN AT THRESHOLD? - locktime = int(locktime) - if locktime > LOCKTIME_THRESHOLD and locktime > timestamp_to_check: - return True - elif locktime < LOCKTIME_THRESHOLD and locktime > block_height_to_check: - return True - else: - return False - - @staticmethod - def anticipate_locktime(locktime, blocks=0, hours=0, days=0): - """Move a locktime earlier by the given amount. - - Works on both timestamp and block-height locktimes; never returns a - value below 1. - """ - locktime = int(locktime) - out = 0 - if locktime > LOCKTIME_THRESHOLD: - seconds = blocks * 600 + hours * 3600 + days * 86400 - # On Windows datetime.fromtimestamp raises OverflowError past 2038 - # (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170). - try: - dt = datetime.fromtimestamp(locktime) - except (OverflowError, OSError, ValueError): - dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1)) - dt -= timedelta(seconds=seconds) - out = dt.timestamp() - else: - blocks -= hours * 6 + days * 144 - out = locktime + blocks - - if out < 1: - out = 1 - return out - - @staticmethod - def cmp_locktime(locktimea, locktimeb): - """Compare two relative locktime strings sharing the same unit.""" - if locktimea == locktimeb: - return 0 - strlocktimea = str(locktimea) - strlocktimeb = str(locktimeb) - if locktimea[-1] in "ydb": - if locktimeb[-1] == locktimea[-1]: - return int(strlocktimea[-1]) - int(strlocktimeb[-1]) - else: - return int(locktimea) - (locktimeb) - - @staticmethod - def get_lowest_valid_tx(available_utxos, will): - """Placeholder kept from the original code (sorts the will by locktime).""" - will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime) - for txid, willitem in will.items(): - pass - - @staticmethod - def get_locktimes(will): - """Return the distinct locktimes used by the transactions in ``will``.""" - locktimes = {} - for txid, willitem in will.items(): - locktimes[willitem["tx"].locktime] = True - return locktimes.keys() - - @staticmethod - def get_lowest_locktimes(locktimes): - """Split a list of locktimes into (sorted_timestamps, sorted_blocks).""" - sorted_timestamp = [] - sorted_block = [] - for locktime in locktimes: - locktime = Util.parse_locktime_string(locktime) - if locktime < LOCKTIME_THRESHOLD: - bisect.insort(sorted_block, locktime) - else: - bisect.insort(sorted_timestamp, locktime) - - return sorted(sorted_timestamp), sorted(sorted_block) - - @staticmethod - def get_lowest_locktimes_from_will(will): - """Convenience wrapper: lowest locktimes directly from a will dict.""" - return Util.get_lowest_locktimes(Util.get_locktimes(will)) - - @staticmethod - def search_willtx_per_io(will, tx): - """Find a will entry whose tx has the same inputs/outputs as ``tx``.""" - for wid, w in will.items(): - if Util.cmp_txs(w["tx"], tx["tx"]): - return wid, w - return None, None - - @staticmethod - def invalidate_will(will): - raise Exception("not implemented") - - @staticmethod - def get_will_spent_utxos(will): - """Collect every input spent by any transaction in ``will``.""" - utxos = [] - for txid, willitem in will.items(): - utxos += willitem["tx"].inputs() - - return utxos - - # ------------------------------------------------------------------ # - # UTXO helpers - # ------------------------------------------------------------------ # - @staticmethod - def utxo_to_str(utxo): - """Best-effort conversion of a UTXO / input object to its ``txid:n`` str.""" - try: - return utxo.to_str() - except Exception: - pass - try: - return utxo.prevout.to_str() - except Exception: - pass - return str(utxo) - - @staticmethod - def cmp_utxo(utxoa, utxob): - """True when two UTXOs refer to the same outpoint.""" - utxoa = Util.utxo_to_str(utxoa) - utxob = Util.utxo_to_str(utxob) - if utxoa == utxob: - return True - else: - return False - - @staticmethod - def in_utxo(utxo, utxos): - """Membership test for a UTXO inside an iterable of UTXOs.""" - for s_u in utxos: - if Util.cmp_utxo(s_u, utxo): - return True - return False - - @staticmethod - def txid_in_utxo(txid, utxos): - """True if any UTXO in ``utxos`` is spent from transaction ``txid``.""" - for s_u in utxos: - if s_u.prevout.txid == txid: - return True - return False - - @staticmethod - def cmp_output(outputa, outputb): - """Two outputs are equal when both address and value match.""" - return outputa.address == outputb.address and outputa.value == outputb.value - - @staticmethod - def in_output(output, outputs): - """Membership test for an output inside an iterable of outputs.""" - for s_o in outputs: - if Util.cmp_output(s_o, output): - return True - return False - - # check all output with the same amount if none have the same address it can be a change - # return true true same address same amount - # return true false same amount different address - # return false false different amount, different address not found - @staticmethod - def din_output(out, outputs): - """Detailed output lookup used to tell a change output apart. - - Returns a ``(same_amount, same_address)`` tuple: - * ``(True, True)`` -> an output with same amount *and* address - * ``(True, False)`` -> same amount but different address (maybe change) - * ``(False, False)``-> no output with this amount - """ - same_amount = [] - for s_o in outputs: - if int(out.value) == int(s_o.value): - same_amount.append(s_o) - if out.address == s_o.address: - return True, True - else: - pass - - if len(same_amount) > 0: - return True, False - else: - return False, False - - @staticmethod - def get_change_output(wallet, in_amount, out_amount, fee): - """Build a change ``PartialTxOutput`` if the leftover exceeds dust.""" - change_amount = int(in_amount - out_amount - fee) - if change_amount > wallet.dust_threshold(): - change_addresses = wallet.get_change_addresses_for_new_transaction() - out = PartialTxOutput.from_address_and_value( - change_addresses[0], change_amount - ) - out.is_change = True - return out - - @staticmethod - def get_current_height(network): - """Return a conservative current block height for locktime purposes. - - Mirrors Electrum's own anti-fee-sniping logic: if there is no network, - the chain tip is stale, or the main server lags too far behind the - SPV-checked height, it gives up and returns 0. - """ - # if no network or not up to date, just set locktime to zero - if not network: - return 0 - chain = network.blockchain() - if chain.is_tip_stale(): - return 0 - # figure out current block height - chain_height = chain.height() # learnt from all connected servers, SPV-checked - server_height = ( - network.get_server_height() - ) # height claimed by main server, unverified - # note: main server might be lagging (either is slow, is malicious, or there is an SPV-invisible-hard-fork) - # - if it's lagging too much, it is the network's job to switch away - if server_height < chain_height - 10: - # the diff is suspiciously large... give up and use something non-fingerprintable - return 0 - # discourage "fee sniping" - height = min(chain_height, server_height) - return height - - # ------------------------------------------------------------------ # - # Misc helpers - # ------------------------------------------------------------------ # - @staticmethod - def copy(dicto, dictfrom): - """Shallow copy of ``dictfrom`` entries into ``dicto`` (in place).""" - for k, v in dictfrom.items(): - dicto[k] = v - - @staticmethod - def fix_will_settings_tx_fees(will_settings): - """Migrate the legacy ``tx_fees`` key to ``baltx_fees`` in settings. - - Returns True when a migration was performed (caller should persist). - """ - tx_fees = will_settings.get("tx_fees", False) - have_to_update = False - if tx_fees: - will_settings["baltx_fees"] = tx_fees - del will_settings["tx_fees"] - have_to_update = True - return have_to_update - - @staticmethod - def fix_will_tx_fees(will): - """Same legacy migration as above but applied to every will entry.""" - have_to_update = False - for txid, willitem in will.items(): - tx_fees = willitem.get("tx_fees", False) - if tx_fees: - will[txid]["baltx_fees"] = tx_fees - del will[txid]["tx_fees"] - have_to_update = True - return have_to_update - - @staticmethod - def text_to_hex(text: str) -> str: - """Convert text to a hexadecimal string (used for OP_RETURN payloads).""" - hex_string = text.encode('utf-8').hex() - return hex_string - - @staticmethod - def hex_to_text(hex_string: str) -> str: - """Convert a hexadecimal string back to text (for verification).""" - try: - return bytes.fromhex(hex_string).decode('utf-8') - except Exception: - return "Error: Invalid hex string" diff --git a/bal/core/will.py b/bal/core/will.py deleted file mode 100644 index 4c1f341..0000000 --- a/bal/core/will.py +++ /dev/null @@ -1,1008 +0,0 @@ -""" -bal.core.will -============= - -The "will": the set of time-locked inheritance transactions plus all the logic -to keep it coherent over time. - -Two classes live here: - - * :class:`Will` - a namespace of static methods operating on a *will* - dictionary (mapping ``txid -> WillItem``): building - the parent/child tree, anticipating locktimes, - detecting replaced/invalidated/confirmed entries, - validating that the will still matches the heirs and - will-executors, and building an "invalidation" - transaction. - * :class:`WillItem` - a single will transaction together with its status - flags, heirs, will-executor and fee. - -Separation of concerns ------------------------ -The original ``WillItem`` carried a ``get_color()`` method returning hard-coded -hex colours for the GUI. That was pure presentation living inside the core -logic, so it has been **moved** to ``bal.gui.qt.theme.status_color(will_item)``. -The status flags themselves (the source of truth) stay here; only the mapping -"status -> colour" now lives in the GUI layer. No behaviour changed. -""" - -import copy - -from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX -from electrum.i18n import _ -from electrum.logging import Logger, get_logger -from electrum.transaction import ( - PartialTransaction, - PartialTxInput, - PartialTxOutput, - Transaction, - TxOutpoint, - tx_from_any, -) -from electrum.util import ( - bfh, -) - -from .util import Util -from .willexecutors import Willexecutors - -MIN_LOCKTIME = 1 -MIN_BLOCK = 1 -_logger = get_logger(__name__) - - -class Will: - @staticmethod - def get_children(will, willid): - out = [] - for _id in will: - inputs = will[_id].tx.inputs() - for idi in range(0, len(inputs)): - _input = inputs[idi] - if _input.prevout.txid.hex() == willid: - out.append([_id, idi, _input.prevout.out_idx]) - return out - - # build a tree with parent transactions - @staticmethod - def add_willtree(will): - for willid in will: - will[willid].children = Will.get_children(will, willid) - for child in will[willid].children: - if not will[child[0]].father: - will[child[0]].father = willid - - # return a list of will sorted by locktime - @staticmethod - def get_sorted_will(will): - return sorted(will.items(), key=lambda x: x[1]["tx"].locktime) - - @staticmethod - def only_valid(will): - for k, v in will.items(): - if v.get_status("VALID"): - yield k - - @staticmethod - def needs_server_check(w): - """Return True if ``w`` should be queried on its will-executor server - when the user presses Check (or on Electrum close). - - A will needs a server check when it is VALID, has a will-executor - assigned, and is not yet CHECKED. This intentionally includes wills - that are not (yet) marked PUSHED: a will that was actually sent in the - past but whose saved status still reads "New" would otherwise be - skipped, leaving the Server column stuck on "Not sent". The server - response (see WillItem.set_check_willexecutor) then corrects the status - to PUSHED/CHECKED if the transaction is present, or CHECK_FAIL if not. - """ - return bool( - w.get_status("VALID") - and w.we - and not w.get_status("CHECKED") - ) - - @staticmethod - def search_equal_tx(will, tx, wid): - for w in will: - if w != wid and not tx.to_json() != will[w]["tx"].to_json(): - if will[w]["tx"].txid() != tx.txid(): - if Util.cmp_txs(will[w]["tx"], tx): - return will[w]["tx"] - return False - - @staticmethod - def get_tx_from_any(x): - try: - a = str(x) - return tx_from_any(a) - - except Exception as e: - raise e - - return x - - @staticmethod - def add_info_from_will(will, wid, wallet): - if isinstance(will[wid].tx, str): - will[wid].tx = Will.get_tx_from_any(will[wid].tx) - if wallet: - will[wid].tx.add_info_from_wallet(wallet) - for txin in will[wid].tx.inputs(): - txid = txin.prevout.txid.hex() - if txid in will: - change = will[txid].tx.outputs()[txin.prevout.out_idx] - txin._trusted_value_sats = change.value - try: - txin.script_descriptor = change.script_descriptor - except Exception: - pass - txin.is_mine = True - txin._TxInput__address = change.address - txin._TxInput__scriptpubkey = change.scriptpubkey - txin._TxInput__value_sats = change.value - txin._trusted_value_sats = change.value - - @staticmethod - def normalize_will(will, wallet=None, others_inputs=None): - others_input = others_inputs if others_inputs is not None else {} - to_delete = [] - to_add = {} - # add info from wallet - willitems = {} - for wid in will: - Will.add_info_from_will(will, wid, wallet) - willitems[wid] = WillItem(will[wid]) - will = willitems - errors = {} - for wid in will: - - txid = will[wid].tx.txid() - - if txid is None: - _logger.error("##########") - _logger.error(wid) - _logger.error(will[wid]) - _logger.error(will[wid].tx.to_json()) - - _logger.error("txid is none") - will[wid].set_status("ERROR", True) - errors[wid] = will[wid] - continue - - if txid != wid: - outputs = will[wid].tx.outputs() - ow = will[wid] - ow.normalize_locktime(others_inputs) - will[wid] = WillItem(ow.to_dict()) - - for i in range(0, len(outputs)): - Will.change_input( - will, wid, i, outputs[i], others_inputs, to_delete, to_add - ) - - to_delete.append(wid) - to_add[ow.tx.txid()] = ow.to_dict() - - # for eid, err in errors.items(): - # new_txid = err.tx.txid() - - for k, w in to_add.items(): - will[k] = w - - for wid in to_delete: - if wid in will: - del will[wid] - - @staticmethod - def new_input(txid, idx, change): - prevout = TxOutpoint(txid=bfh(txid), out_idx=idx) - inp = PartialTxInput(prevout=prevout) - inp._trusted_value_sats = change.value - inp.is_mine = True - inp._TxInput__address = change.address - inp._TxInput__scriptpubkey = change.scriptpubkey - inp._TxInput__value_sats = change.value - return inp - - @staticmethod - def check_anticipate(ow: "WillItem", nw: "WillItem"): - anticipate = Util.anticipate_locktime(ow.tx.locktime, days=1) - if int(nw.tx.locktime) >= int(anticipate): - if Util.cmp_heirs_by_values( - ow.heirs, nw.heirs, [0, 1], exclude_willexecutors=True - ): - if nw.we and ow.we: - if ow.we["url"] == nw.we["url"]: - if int(ow.we["base_fee"]) > int(nw.we["base_fee"]): - return anticipate - else: - if int(ow.tx_fees) != int(nw.tx_fees): - return anticipate - else: - ow.tx.locktime - else: - ow.tx.locktime - else: - if nw.we == ow.we: - if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]): - return anticipate - else: - return ow.tx.locktime - else: - return ow.tx.locktime - else: - return anticipate - return 4294967295 + 1 - - @staticmethod - def change_input(will, otxid, idx, change, others_inputs, to_delete, to_append): - ow = will[otxid] - ntxid = ow.tx.txid() - if otxid != ntxid: - for wid in will: - w = will[wid] - inputs = w.tx.inputs() - outputs = w.tx.outputs() - found = False - old_txid = w.tx.txid() - # ntx = None - for i in range(0, len(inputs)): - if ( - inputs[i].prevout.txid.hex() == otxid - and inputs[i].prevout.out_idx == idx - ): - if isinstance(w.tx, Transaction): - will[wid].tx = PartialTransaction.from_tx(w.tx) - will[wid].tx.set_rbf(True) - will[wid].tx._inputs[i] = Will.new_input(wid, idx, change) - found = True - if found: - pass - - new_txid = will[wid].tx.txid() - if old_txid != new_txid: - to_delete.append(old_txid) - to_append[new_txid] = will[wid] - outputs = will[wid].tx.outputs() - for i in range(0, len(outputs)): - Will.change_input( - will, - wid, - i, - outputs[i], - others_inputs, - to_delete, - to_append, - ) - - @staticmethod - def get_all_inputs(will, only_valid=False): - all_inputs = {} - for w, wi in will.items(): - if not only_valid or wi.get_status("VALID"): - inputs = wi.tx.inputs() - for i in inputs: - prevout_str = i.prevout.to_str() - inp = [w, will[w], i] - if prevout_str not in all_inputs: - all_inputs[prevout_str] = [inp] - else: - all_inputs[prevout_str].append(inp) - return all_inputs - - @staticmethod - def get_all_inputs_min_locktime(all_inputs): - all_inputs_min_locktime = {} - - for i, values in all_inputs.items(): - min_locktime = min(values, key=lambda x: x[1].tx.locktime)[1].tx.locktime - for w in values: - if w[1].tx.locktime == min_locktime: - if i not in all_inputs_min_locktime: - all_inputs_min_locktime[i] = [w] - else: - all_inputs_min_locktime[i].append(w) - - return all_inputs_min_locktime - - @staticmethod - def search_anticipate_rec(will, old_inputs): - redo = False - to_delete = [] - to_append = {} - new_inputs = Will.get_all_inputs(will, only_valid=True) - for nid, nwi in will.items(): - if nwi.search_anticipate(new_inputs): - if nid != nwi.tx.txid(): - redo = True - to_delete.append(nid) - to_append[nwi.tx.txid()] = nwi - outputs = nwi.tx.outputs() - for i in range(0, len(outputs)): - Will.change_input( - will, nid, i, outputs[i], new_inputs, to_delete, to_append - ) - if nwi.search_anticipate(old_inputs): - if nid != nwi.tx.txid(): - redo = True - - to_delete.append(nid) - to_append[nwi.tx.txid()] = nwi - outputs = nwi.tx.outputs() - for i in range(0, len(outputs)): - Will.change_input( - will, nid, i, outputs[i], new_inputs, to_delete, to_append - ) - - for w in to_delete: - try: - del will[w] - except Exception: - pass - for k, w in to_append.items(): - will[k] = w - if redo: - - Will.search_anticipate_rec(will, old_inputs) - - @staticmethod - def update_will(old_will, new_will): - all_old_inputs = Will.get_all_inputs(old_will, only_valid=True) - # all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_old_inputs) - # all_new_inputs = Will.get_all_inputs(new_will) - # check if the new input is already spent by other transaction - # if it is use the same locktime, or anticipate. - Will.search_anticipate_rec(new_will, all_old_inputs) - other_inputs = Will.get_all_inputs(old_will, {}) - try: - Will.normalize_will(new_will, others_inputs=other_inputs) - except Exception as e: - raise e - - for oid in Will.only_valid(old_will): - if oid in new_will: - new_heirs = new_will[oid].heirs - new_we = new_will[oid].we - - new_will[oid] = old_will[oid] - new_will[oid].heirs = new_heirs - new_will[oid].we = new_we - - continue - else: - continue - - @staticmethod - def get_higher_input_for_tx(will): - out = {} - for wid in will: - wtx = will[wid].tx - found = False - for inp in wtx.inputs(): - if inp.prevout.txid.hex() in will: - found = True - break - if not found: - out[inp.prevout.to_str()] = inp - return out - - @staticmethod - def invalidate_will(will, wallet, fees_per_byte): - will_only_valid = Will.only_valid_list(will) - inputs = Will.get_all_inputs(will_only_valid) - utxos = wallet.get_utxos() - filtered_inputs = [] - prevout_to_spend = [] - current_height = Util.get_current_height(wallet.network) - for prevout_str, ws in inputs.items(): - for w in ws: - if w[0] not in filtered_inputs: - filtered_inputs.append(w[0]) - if prevout_str not in prevout_to_spend: - prevout_to_spend.append(prevout_str) - balance = 0 - utxo_to_spend = [] - for utxo in utxos: - if utxo.is_coinbase_output() and utxo.block_height < current_height+100: - continue - utxo_str = utxo.prevout.to_str() - if utxo_str in prevout_to_spend: - balance += inputs[utxo_str][0][2].value_sats() - utxo_to_spend.append(utxo) - if len(utxo_to_spend) > 0: - change_addresses = wallet.get_change_addresses_for_new_transaction() - out = PartialTxOutput.from_address_and_value(change_addresses[0], balance) - out.is_change = True - locktime = current_height - tx = PartialTransaction.from_io( - utxo_to_spend, [out], locktime=locktime, version=2 - ) - tx.set_rbf(True) - fee = tx.estimated_size() * fees_per_byte - if balance - fee > 0: - out = PartialTxOutput.from_address_and_value( - change_addresses[0], balance - fee - ) - tx = PartialTransaction.from_io( - utxo_to_spend, [out], locktime=locktime, version=2 - ) - tx.set_rbf(True) - - _logger.debug(f"invalidation tx: {tx}") - return tx - - else: - _logger.debug(f"balance({balance}) - fee({fee}) <=0") - pass - else: - _logger.debug("len utxo_to_spend <=0") - pass - - @staticmethod - def is_new(will): - for wid, w in will.items(): - if w.get_status("VALID") and not w.get_status("COMPLETE"): - return True - - @staticmethod - def search_rai(all_inputs, all_utxos, will, wallet): - # will_only_valid = Will.only_valid_or_replaced_list(will) - for inp, ws in all_inputs.items(): - inutxo = Util.in_utxo(inp, all_utxos) - for w in ws: - wi = w[1] - if ( - wi.get_status("VALID") - or wi.get_status("CONFIRMED") - or wi.get_status("PENDING") - ): - prevout_id = w[2].prevout.txid.hex() - if not inutxo: - if prevout_id in will: - wo = will[prevout_id] - if wo.get_status("REPLACED"): - wi.set_status("REPLACED", True) - if wo.get_status("INVALIDATED"): - wi.set_status("INVALIDATED", True) - - else: - if wallet.db.get_transaction(wi._id): - wi.set_status("CONFIRMED", True) - else: - wi.set_status("INVALIDATED", True) - - for child in wi.search(all_inputs): - if child.tx.locktime < wi.tx.locktime: - _logger.debug("a child was found") - wi.set_status("REPLACED", True) - else: - pass - - @staticmethod - def utxos_strs(utxos): - return [Util.utxo_to_str(u) for u in utxos] - - @staticmethod - def set_invalidate(wid, will=None): - will = will if will is not None else {} - will[wid].set_status("INVALIDATED", True) - if will[wid].children: - for c in will[wid].children.items(): - Will.set_invalidate(c[0], will) - - @staticmethod - def check_tx_height(tx, wallet): - info = wallet.get_tx_info(tx) - return info.tx_mined_status.height() - - # check if transactions are stil valid tecnically valid - @staticmethod - def check_invalidated(willtree, utxos_list, wallet): - for wid, w in willtree.items(): - if ( - not w.father - or willtree[w.father].get_status("CONFIRMED") - or willtree[w.father].get_status("PENDING") - ): - for inp in w.tx.inputs(): - inp_str = Util.utxo_to_str(inp) - if inp_str not in utxos_list: - if wallet: - height = Will.check_tx_height(w.tx, wallet) - if height < 0: - Will.set_invalidate(wid, willtree) - elif height == 0: - w.set_status("PENDING", True) - else: - w.set_status("CONFIRMED", True) - - # def reflect_to_children(treeitem): - # if not treeitem.get_status("VALID"): - # _logger.debug(f"{tree:item._id} status not valid looking for children") - # for child in treeitem.children: - # wc = willtree[child] - # if wc.get_status("VALID"): - # if treeitem.get_status("INVALIDATED"): - # wc.set_status("INVALIDATED", True) - # if treeitem.get_status("REPLACED"): - # wc.set_status("REPLACED", True) - # if wc.children: - # Will.reflect_to_children(wc) - - @staticmethod - def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust): - fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = ( - heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True) - ) - wallet_balance = 0 - for utxo in all_utxos: - wallet_balance += utxo.value_sats() - - if fixed_amount >= wallet_balance: - raise FixedAmountException( - f"Fixed amount({fixed_amount}) >= {wallet_balance}" - ) - if perc_amount != 100: - raise PercAmountException(f"Perc amount({perc_amount}) =! 100%") - - for url, wex in willexecutors.items(): - if Willexecutors.is_selected(wex): - temp_balance = wallet_balance - int(wex["base_fee"]) - if fixed_amount >= temp_balance: - raise FixedAmountException( - f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}" - ) - - @staticmethod - def check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check): - Will.add_willtree(will) - utxos_list = Will.utxos_strs(all_utxos) - - Will.check_invalidated(will, utxos_list, wallet) - - all_inputs = Will.get_all_inputs(will, only_valid=True) - all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_inputs) - Will.check_will_expired( - all_inputs_min_locktime, block_to_check, timestamp_to_check - ) - - all_inputs = Will.get_all_inputs(will, only_valid=True) - - Will.search_rai(all_inputs, all_utxos, will, wallet) - - @staticmethod - def get_min_locktime(will,default_value=None): - return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value) - - - - @staticmethod - def is_will_valid( - will, - block_to_check, - timestamp_to_check, - tx_fees, - all_utxos, - heirs=None, - willexecutors=None, - self_willexecutor=False, - wallet=False, - callback_not_valid_tx=None, - ): - heirs = heirs if heirs is not None else {} - willexecutors= willexecutors if willexecutors is not None else {} - - Will.check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check) - if heirs: - if not Will.check_willexecutors_and_heirs( - will, - heirs, - willexecutors, - self_willexecutor, - timestamp_to_check, - tx_fees, - ): - raise NotCompleteWillException() - - all_inputs = Will.get_all_inputs(will, only_valid=True) - - _logger.info("check all utxo in wallet are spent") - if all_inputs: - for utxo in all_utxos: - if utxo.value_sats() > 68 * tx_fees: - if not Util.in_utxo(utxo, all_inputs.keys()): - _logger.info("utxo is not spent", utxo.to_json()) - _logger.debug(all_inputs.keys()) - raise NotCompleteWillException( - "Some utxo in the wallet is not included" - ) - - _logger.info("will ok") - return True - - @staticmethod - def check_will_expired(all_inputs_min_locktime, block_to_check, timestamp_to_check): - _logger.info("check if some transaction is expired") - for prevout_str, wid in all_inputs_min_locktime.items(): - for w in wid: - if w[1].get_status("VALID"): - locktime = int(wid[0][1].tx.locktime) - if locktime <= NLOCKTIME_BLOCKHEIGHT_MAX: - if locktime < int(block_to_check): - raise WillExpiredException( - f"Will Expired {wid[0][0]}: {locktime}<{block_to_check}" - ) - else: - if locktime < int(timestamp_to_check): - raise WillExpiredException( - f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}" - ) - else: - from datetime import datetime - _logger.debug(f"Will Not Expired {wid[0][0]}: {datetime.fromtimestamp(locktime).isoformat()} > {datetime.fromtimestamp(timestamp_to_check).isoformat()}") - - # def check_all_input_spent_are_in_wallet(): - # _logger.info("check all input spent are in wallet or valid txs") - # for inp, ws in all_inputs.items(): - # if not Util.in_utxo(inp, all_utxos): - # for w in ws: - # if w[1].get_status("VALID"): - # prevout_id = w[2].prevout.txid.hex() - # parentwill = will.get(prevout_id, False) - # if not parentwill or not parentwill.get_status("VALID"): - # w[1].set_status("INVALIDATED", True) - - @staticmethod - def only_valid_list(will): - out = {} - for wid, w in will.items(): - if w.get_status("VALID"): - out[wid] = w - return out - - @staticmethod - def only_valid_or_replaced_list(will): - out = [] - for wid, w in will.items(): - wi = w - if wi.get_status("VALID") or wi.get_status("REPLACED"): - out.append(wid) - return out - - @staticmethod - def check_willexecutors_and_heirs( - will, heirs, willexecutors, self_willexecutor, check_date, tx_fees - ): - _logger.debug("check willexecutors heirs") - no_willexecutor = 0 - willexecutors_found = {} - heirs_found = {} - will_only_valid = Will.only_valid_list(will) - if len(will_only_valid) < 1: - return False - for wid in Will.only_valid_list(will): - w = will[wid] - if w.tx_fees != tx_fees: - raise TxFeesChangedException(f"{tx_fees}: {w.tx_fees}") - for wheir in w.heirs: - if not 'w!ll3x3c"' == wheir[:9]: - their = will[wid].heirs[wheir] - if heir := heirs.get(wheir, None): - - 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: - # The will still carries this heir, but the heir is no - # longer present in the current heirs set: the user - # removed it. This must trigger a rebuild exactly like - # "heir added" does, otherwise the removed heir would - # silently stay in the inheritance transaction. Raising - # HeirNotFoundException reuses the same rebuild path used - # by the Check button and by on_close (Electrum quit). - _logger.debug( - f"heir removed, transaction is not valid:" - f"{wheir} {wid}, {w}" - ) - raise HeirNotFoundException(wheir) - - if willexecutor := w.we: - count = willexecutors_found.get(willexecutor["url"], 0) - if Util.cmp_willexecutor( - willexecutor, willexecutors.get(willexecutor["url"], None) - ): - willexecutors_found[willexecutor["url"]] = count + 1 - - else: - no_willexecutor += 1 - count_heirs = 0 - for h in heirs: - - if Util.parse_locktime_string(heirs[h][2]) >= check_date: - count_heirs += 1 - if h not in heirs_found: - _logger.debug(f"heir: {h} not found") - raise HeirNotFoundException(h) - if not count_heirs: - raise NoHeirsException("there are not valid heirs") - if self_willexecutor and no_willexecutor == 0: - raise NoWillExecutorNotPresent("Backup tx") - for url, we in willexecutors.items(): - if Willexecutors.is_selected(we): - if url not in willexecutors_found: - _logger.debug(f"will-executor: {url} not fount") - raise WillExecutorNotPresent(url) - _logger.info("will is coherent with heirs and will-executors") - return True - - - -class WillItem(Logger): - STATUS_DEFAULT = { - "ANTICIPATED": ["Anticipated", False], - "BROADCASTED": ["Broadcasted", False], - "CHECKED": ["Checked", False], - "CHECK_FAIL": ["Check Failed", False], - "COMPLETE": ["Signed", False], - "CONFIRMED": ["Confirmed", False], - "ERROR": ["Error", False], - "EXPIRED": ["Expired", False], - "EXPORTED": ["Exported", False], - "IMPORTED": ["Imported", False], - "INVALIDATED": ["Invalidated", False], - "PENDING": ["Pending", False], - "PUSH_FAIL": ["Push failed", False], - "PUSHED": ["Pushed", False], - "REPLACED": ["Replaced", False], - "RESTORED": ["Restored", False], - "VALID": ["Valid", True], - } - - def set_status(self, status, value=True): - # _logger.trace( - # "set status {} - {} {} -> {}".format( - # self._id, status, self.STATUS[status][1], value - # ) - # ) - if self.STATUS[status][1] == bool(value): - return None - - self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0])) - self.STATUS[status][1] = bool(value) - if value: - if status in ["INVALIDATED", "REPLACED", "CONFIRMED", "PENDING"]: - self.STATUS["VALID"][1] = False - - if status in ["CONFIRMED", "PENDING"]: - self.STATUS["INVALIDATED"][1] = False - - if status in ["PUSHED"]: - self.STATUS["PUSH_FAIL"][1] = False - self.STATUS["CHECK_FAIL"][1] = False - - if status in ["CHECKED"]: - self.STATUS["PUSHED"][1] = True - self.STATUS["PUSH_FAIL"][1] = False - - return value - - def get_status(self, status): - return self.STATUS[status][1] - - def __init__(self, w, _id=None, wallet=None): - if isinstance( - w, - WillItem, - ): - self.__dict__ = w.__dict__.copy() - else: - self.tx = Will.get_tx_from_any(w["tx"]) - self.heirs = w.get("heirs", None) - self.we = w.get("willexecutor", None) - self.status = w.get("status", None) - self.description = w.get("description", None) - self.time = w.get("time", None) - self.change = w.get("change", None) - self.tx_fees = w.get("baltx_fees", 0) - self.father = w.get("Father", None) - self.children = w.get("Children", None) - self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) - for s in self.STATUS: - self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1]) - if not _id: - self._id = self.tx.txid() - else: - self._id = _id - - if not self._id: - self.status += "ERROR!!!" - self.valid = False - - if wallet: - self.tx.add_info_from_wallet(wallet) - - def to_dict(self): - out = { - "_id": self._id, - "tx": self.tx, - "heirs": self.heirs, - "willexecutor": self.we, - "status": self.status, - "description": self.description, - "time": self.time, - "change": self.change, - "baltx_fees": self.tx_fees, - } - for key in self.STATUS: - try: - out[key] = self.STATUS[key][1] - except Exception as e: - _logger.error(f"{key},{self.STATUS[key]} {e}") - - return out - - def __repr__(self): - return str(self) - - def __str__(self): - return str(self.to_dict()) - - def set_anticipate(self, ow: "WillItem"): - nl = min(ow.tx.locktime, Will.check_anticipate(ow, self)) - if int(nl) < self.tx.locktime: - self.tx.locktime = int(nl) - return True - else: - return False - - def search_anticipate(self, all_inputs): - anticipated = False - for ow in self.search(all_inputs): - if self.set_anticipate(ow): - anticipated = True - return anticipated - - def search(self, all_inputs): - for inp in self.tx.inputs(): - prevout_str = inp.prevout.to_str() - oinps = all_inputs.get(prevout_str, []) - for oinp in oinps: - ow = oinp[1] - if ow._id != self._id: - yield ow - - def normalize_locktime(self, all_inputs): - outputs = self.tx.outputs() - for idx in range(0, len(outputs)): - inps = all_inputs.get(f"{self._id}:{idx}", []) - _logger.debug("****check locktime***") - for inp in inps: - if inp[0] != self._id: - iw = inp[1] - self.set_anticipate(iw) - - def set_check_willexecutor(self,resp): - try: - if resp : - if "tx" in resp and resp["tx"] == str(self.tx): - self.set_status("PUSHED") - self.set_status("CHECKED") - else: - self.set_status("CHECK_FAIL") - self.set_status("PUSHED", False) - return True - else: - self.set_status("CHECK_FAIL") - self.set_status("PUSHED", False) - return False - except Exception as e: - _logger.error(f"exception checking transaction: {e}") - self.set_status("CHECK_FAIL") - - # NOTE: the former ``get_color()`` method (which returned hard-coded hex - # colours for the GUI) has been moved out of the core logic to - # ``bal.gui.qt.theme.status_color``. The status flags above remain the - # single source of truth; the GUI maps them to colours. - - -class WillException(Exception): - def __init__(self,msg="WillException"): - self.msg=msg - Exception.__init__(self) - def __str__(self): - return self.msg - - - -class WillExpiredException(WillException): - pass - - -class NotCompleteWillException(WillException): - pass - - -class HeirChangeException(NotCompleteWillException): - pass - - -class TxFeesChangedException(NotCompleteWillException): - pass - - -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 - - -class NoWillExecutorNotPresent(NotCompleteWillException): - pass - - -class WillExecutorNotPresent(NotCompleteWillException): - pass - - -class NoHeirsException(WillException): - pass -class AmountException(WillException): - pass - - -class PercAmountException(AmountException): - pass - - -class FixedAmountException(AmountException): - pass diff --git a/bal/core/willexecutors.py b/bal/core/willexecutors.py deleted file mode 100644 index b1bd643..0000000 --- a/bal/core/willexecutors.py +++ /dev/null @@ -1,788 +0,0 @@ -""" -bal.core.willexecutors -======================= - -Client logic for talking to *will-executor* servers. - -A will-executor is an optional third-party service that, for a small fee, -stores the signed inheritance transactions off-line and broadcasts them once -their locktime expires (acting as a dead-man's switch backup). - -This module only contains the networking / data-shaping logic (downloading the -server list, pinging servers for their fee and address, pushing transactions, -checking whether a tx is already stored). It is GUI-free: all user -interaction is handled by the Qt layer. -""" - -import json -import time -from datetime import datetime - -from aiohttp import ClientResponse -from electrum.i18n import _ -from electrum.logging import get_logger -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__) - - -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}") - aw = bal_plugin.WILLEXECUTORS.get() - aw[chainname] = willexecutors - bal_plugin.WILLEXECUTORS.set(aw) - _logger.debug(f"saved: {aw}") - # bal_plugin.WILLEXECUTORS.set(willexecutors) - - @staticmethod - def get_willexecutors( - bal_plugin, update=False, bal_window=False, force=False, task=True - ): - willexecutors = bal_plugin.WILLEXECUTORS.get() - willexecutors = willexecutors.get(chainname, {}) - to_del = [] - for w in willexecutors: - if not isinstance(willexecutors[w], dict): - to_del.append(w) - continue - Willexecutors.initialize_willexecutor(willexecutors[w], w) - for w in to_del: - _logger.error( - "error Willexecutor to delete type:{} {}".format( - type(willexecutors[w]), w - ) - ) - del willexecutors[w] - bal = bal_plugin.WILLEXECUTORS.default.get(chainname, {}) - for bal_url, bal_executor in bal.items(): - if bal_url not in willexecutors: - _logger.debug(f"force add {bal_url} willexecutor") - willexecutors[bal_url] = bal_executor - # if update: - # found = False - # for url, we in willexecutors.items(): - # if Willexecutors.is_selected(we): - # found = True - # if found or force: - # if bal_plugin.PING_WILLEXECUTORS.get() or force: - # ping_willexecutors = True - # if bal_plugin.ASK_PING_WILLEXECUTORS.get() and not force: - # if bal_window: - # ping_willexecutors = bal_window.window.question( - # _( - # "Contact willexecutors servers to update payment informations?" - # ) - # ) - - # if ping_willexecutors: - # if task: - # bal_window.ping_willexecutors(willexecutors, task) - # else: - # bal_window.ping_willexecutors_task(willexecutors) - w_sorted = dict( - sorted( - willexecutors.items(), key=lambda w: w[1].get("sort", 0), reverse=True - ) - ) - return w_sorted - - @staticmethod - def is_selected(willexecutor, value=None): - if not willexecutor: - return False - if value is not None: - willexecutor["selected"] = value - try: - return willexecutor["selected"] - except Exception: - willexecutor["selected"] = False - return False - - @staticmethod - def get_willexecutor_transactions(will, force=False): - willexecutors = {} - for wid, willitem in will.items(): - if willitem.get_status("VALID"): - if willitem.get_status("COMPLETE"): - if not willitem.get_status("PUSHED") or force: - if willexecutor := willitem.we: - url = willexecutor["url"] - if willexecutor and Willexecutors.is_selected(willexecutor): - if url not in willexecutors: - willexecutor["txs"] = "" - willexecutor["txsids"] = [] - willexecutor["broadcast_status"] = _("Waiting...") - willexecutors[url] = willexecutor - willexecutors[url]["txs"] += str(willitem.tx) + "\n" - willexecutors[url]["txsids"].append(wid) - - return willexecutors - - # def only_selected_list(willexecutors): - # out = {} - # for url, v in willexecutors.items(): - # if Willexecutors.is_selected(url): - # out[url] = v - - # def push_transactions_to_willexecutors(will): - # willexecutors = Willexecutors.get_transactions_to_be_pushed() - # for url in willexecutors: - # willexecutor = willexecutors[url] - # if Willexecutors.is_selected(willexecutor): - # if "txs" in willexecutor: - # Willexecutors.push_transactions_to_willexecutor( - # willexecutors[url]["txs"], url - # ) - - @staticmethod - def send_request( - 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.") - _logger.debug(f"<-- {method} {url} {data}") - headers = {} - headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}" - headers["Content-Type"] = "text/plain" - if not handle_response: - handle_response = Willexecutors.handle_response - try: - if method == "get": - response = Network.send_http_on_proxy( - method, - url, - params=data, - headers=headers, - on_finish=handle_response, - timeout=timeout, - ) - elif method == "post": - response = Network.send_http_on_proxy( - method, - url, - body=data, - headers=headers, - on_finish=handle_response, - timeout=timeout, - ) - else: - raise Exception(f"unexpected {method=!r}") - except TimeoutError: - 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, - data, - 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}") - except Exception as e: - raise e - else: - _logger.debug(f"--> {response}") - return response - - @staticmethod - def get_we_url_from_response(resp): - url_slices = str(resp.url).split("/") - if len(url_slices) > 2: - url_slices = url_slices[:-2] - return "/".join(url_slices) - - @staticmethod - async def handle_response(resp: ClientResponse): - r = await resp.text() - try: - - r = json.loads(r) - # url = Willexecutors.get_we_url_from_response(resp) - # r["url"]= url - # r["status"]=resp.status - except Exception as e: - _logger.debug(f"error handling response:{e}") - pass - return r - - @staticmethod - class AlreadyPresentException(Exception): - pass - - @staticmethod - 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']}") - if w := Willexecutors.send_request( - "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}") - if w != "thx": - _logger.debug(f"error: {w}") - raise Exception(w) - else: - raise Exception("empty reply from:{willexecutor['url']}") - except Exception as e: - _logger.debug(f"error:{e}") - if str(e) == "already present": - raise Willexecutors.AlreadyPresentException() - out = False - willexecutor["broadcast_status"] = _("Failed") - - return out - - @staticmethod - def ping_servers(willexecutors): - for url, we in willexecutors.items(): - Willexecutors.get_info_task(url, we) - - @staticmethod - 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) - # 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}") - willexecutor["status"] = "KO" - - willexecutor["last_update"] = datetime.now().timestamp() - return willexecutor - - @staticmethod - def ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8, - timeout=DEFAULT_TIMEOUT, on_tick=None, - tick_interval=1.0): - """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). - - 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, wait - from concurrent.futures import FIRST_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 - - def _fire_tick(): - if on_tick is not None: - try: - 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: - 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, - 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 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. - - 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, wait - from concurrent.futures import FIRST_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 - - def _fire_tick(): - if on_tick is not None: - try: - 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: - 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 - def initialize_willexecutor(willexecutor, url, status=None, old_willexecutor=None): - old_willexecutor=old_willexecutor if old_willexecutor is not None else {} - willexecutor["url"] = url - if status is not None: - willexecutor["status"] = status - else: - willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko")) - willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False) - willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address","")) - willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code")) - - - - @staticmethod - def download_list(old_willexecutors,welist_server): - try: - welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/' - willexecutors = Willexecutors.send_request( - "get", - f"{welist_server}data/{chainname}?page=0&limit=100", - ) - # del willexecutors["status"] - for w in willexecutors: - if w not in ("status", "url"): - Willexecutors.initialize_willexecutor( - willexecutors[w], w, None, old_willexecutors.get(w,None) - ) - # bal_plugin.WILLEXECUTORS.set(l) - # bal_plugin.config.set_key(bal_plugin.WILLEXECUTORS,l,save=True) - return willexecutors - - except Exception as e: - _logger.error(f"Failed to download willexecutors list: {e}") - return {} - - @staticmethod - def get_willexecutors_list_from_json(): - try: - with open("willexecutors.json") as f: - willexecutors = json.load(f) - for w in willexecutors: - willexecutor = willexecutors[w] - Willexecutors.initialize_willexecutor(willexecutor, w, "New", False) - # bal_plugin.WILLEXECUTORS.set(willexecutors) - return willexecutors - except Exception as e: - _logger.error(f"error opening willexecutors json: {e}") - - return {} - - @staticmethod - 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"), - timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, - ) - return w - except Exception as e: - _logger.error(f"error contacting {url} for checking txs {e}") - raise e - - @staticmethod - def compute_id(willexecutor): - return "{}-{}".format(willexecutor.get("url"), willexecutor.get("chain")) - - -#class WillExecutor: -# def __init__( -# self, -# url, -# base_fee, -# chain, -# info, -# version, -# status, -# is_selected=False, -# promo_code="", -# ): -# self.url = url -# self.base_fee = base_fee -# self.chain = chain -# self.info = info -# self.version = version -# self.status = status -# self.promo_code = promo_code -# self.is_selected = is_selected -# self.id = self.compute_id() -# -# def from_dict(d): -# return WillExecutor( -# url=d.get("url", "http://localhost:8000"), -# base_fee=d.get("base_fee", 1000), -# chain=d.get("chain", chainname), -# info=d.get("info", ""), -# version=d.get("version", 0), -# status=d.get("status", "Ko"), -# is_selected=d.get("is_selected", "False"), -# promo_code=d.get("promo_code", ""), -# ) -# -# def to_dict(self): -# return { -# "url": self.url, -# "base_fee": self.base_fee, -# "chain": self.chain, -# "info": self.info, -# "version": self.version, -# "promo_code": self.promo_code, -# } -# -# def compute_id(self): -# return f"{self.url}-{self.chain}" diff --git a/bal/gui/__init__.py b/bal/gui/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/bal/gui/qt/__init__.py b/bal/gui/qt/__init__.py deleted file mode 100644 index c427e8b..0000000 --- a/bal/gui/qt/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -bal.gui.qt -========== - -The PyQt6 graphical interface of the Bitcoin After Life plugin. - -Module map (was previously one 4000-line ``qt.py``): - - common.py - shared imports + tiny helpers (shown_cv, add_widget, ...) - theme.py - colour mapping for will-item statuses (was WillItem.get_color) - calendar.py - .ics calendar generation - widgets.py - reusable leaf widgets (editors, checkboxes, will box, ...) - dialogs.py - all dialogs (settings, wizard, build-will, detail, ...) - lists.py - tree views (heirs, preview, will-executors) - window.py - BalWindow controller (one per wallet window) - plugin.py - Plugin class with the Electrum @hook methods (entry point) -""" diff --git a/bal/gui/qt/calendar.py b/bal/gui/qt/calendar.py deleted file mode 100644 index 4874b09..0000000 --- a/bal/gui/qt/calendar.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -bal.gui.qt.calendar -=================== - -iCalendar (.ics) generation and "open with default calendar app" helper. - -When a will is built, the plugin can create a calendar event reminding the user -to "check in" before the locktime expires. This module turns the event data -into an RFC-5545 .ics file and opens it with the OS default application. -""" - -from .common import * -from .common import _, _logger # underscore names are not re-exported by "import *" - -class BalCalendar: - @staticmethod - def write_temp_ics(content): - fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics") - with os.fdopen(fd, "wb") as f: - f.write(content.encode("utf-8")) - return path - - @staticmethod - def open_with_default_app(calendar_app, path): - _logger.debug("opening calendar app") - try: - subprocess.check_call([calendar_app, path]) - return True - except Exception as e: - _logger.error(f"starting calendar app {e}") - return False - - - @staticmethod - def format_time(time): - return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - #return time.astimezone(timezone.utc).strftime("%Y%m%d") - - @staticmethod - def ical_escape(text: str) -> str: - # escape per RFC5545: backslash, ; , newlines - text = text.encode("utf-8") - text = ( - text.replace(b"\\", b"\\\\") - .replace(b";", b"\\;") - .replace(b",", b"\\,") - ) - out ="" - temp=text.split(b"\r\n") - for s in temp: - encoded= s - cut =0 - while len(encoded) >75: - cut+=5 - encoded=f"{s[:len(s)-cut]}" - if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\": - cut += 1 - encoded=f"{s[:len(s)-cut]}" - encoded=f"{encoded}...\r\n".encode("utf-8") - if cut>0: - out+=str(f"{s[:len(s)-cut].decode()}...\r\n") - else: - out+=str(f"{s.decode()}\r\n") - - return out[:-2] - - @staticmethod - def fold_ical_line(line: str, limit: int = 75) -> str: - # ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive - encoded = line.encode("utf-8") - parts = [] - while len(encoded) > limit: - # taglia senza spezzare byte UTF-8 - cut = limit - while (encoded[cut] & 0xC0) == 0x80: # byte di continuazione UTF-8 - cut -= 1 - parts.append(encoded[:cut].decode("utf-8")) - encoded = encoded[cut:] - parts.append(encoded.decode("utf-8")) - return "\r\n ".join(parts) diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py deleted file mode 100644 index 90dd77b..0000000 --- a/bal/gui/qt/common.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -bal.gui.qt.common -================= - -Shared imports and tiny helper utilities for the Qt GUI layer. - -Every other ``bal.gui.qt`` module does ``from .common import *`` so that the -long list of Electrum / PyQt6 imports lives in a single place. This file also -hosts a few GUI helpers that do not deserve a module of their own: - - * :class:`shown_cv` - trivial mutable "is this tab shown?" holder. - * :func:`add_widget` - add a labelled widget (plus optional help) to a grid. - * :func:`log_error` - format an exception traceback for a dialog. - * :func:`export_meta_gui` - export plugin metadata to a JSON file. - * :class:`CheckAliveError`- raised when the "check alive" date is in the past. -""" - -import copy -import enum -import os -import subprocess -import tempfile -import time -import traceback -from datetime import datetime, timezone -from decimal import Decimal -from functools import partial -from typing import Any, Callable, Mapping, Optional, Union - -from electrum.bitcoin import (NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, - NLOCKTIME_MIN) -from electrum.gui.qt.amountedit import BTCAmountEdit -from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton -from electrum.gui.qt.my_treeview import MyTreeView -from electrum.gui.qt.password_dialog import PasswordDialog -from electrum.gui.qt.transaction_dialog import TxDialog -from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme, - EnterButton, HelpButton, MessageBoxMixin, - OkButton, TaskThread, WindowModalDialog, - char_width_in_lineedit, getSaveFileName, - import_meta_gui, read_QIcon_from_bytes, - read_QPixmap_from_bytes) -from electrum.i18n import _ -from electrum.logging import get_logger -from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError -from electrum.payment_identifier import PaymentIdentifier -from electrum.plugin import hook -from electrum.transaction import SerializationError, Transaction, tx_from_any -from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled, - decimal_point_to_base_unit_name, read_json_file, - write_json_file) -from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, Qt, - QTimer, pyqtSignal) -from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem, - QStandardItemModel) -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) - -# --- Core (GUI-free) logic layer --- -from ...core.plugin_base import BalPlugin, BalTimestamp -from ...core.heirs import HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT, Heirs -from ...core.util import Util -from ...core.will import (AmountException, HeirChangeException, - HeirNotFoundException, NoHeirsException, - NotCompleteWillException, NoWillExecutorNotPresent, - TxFeesChangedException, Will, - WillexecutorChangeException, WillExecutorNotPresent, - WillExpiredException, WillItem, WillPostponedException) -from ...core.willexecutors import Willexecutors - -# --- Presentation helpers --- -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) - -_logger = get_logger(__name__) - - -class shown_cv: - _type = bool - - def __init__(self, value): - self.value = value - - def get(self): - return self.value - - def set(self, value): - self.value = value - - - - -def add_widget(grid, label, widget, row, help_): - grid.addWidget(QLabel(_(label)), row, 0) - grid.addWidget(widget, row, 1) - grid.addWidget(HelpButton(help_), row, 2) - - - - -class CheckAliveError(Exception): - def __init__(self, timestamp_to_check): - self.timestamp_to_check = timestamp_to_check - - def __str__(self): - return "Check alive expired please update it: {}".format( - datetime.fromtimestamp(self.timestamp_to_check).isoformat() - ) - - - - -def log_error(exec_info, window=None): - """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: - # show_error expects a human-readable message, not a triple. - window.show_error(str(exc) if exc is not None else str(exec_info)) - - - - -def export_meta_gui(electrum_window, title, exporter): - filter_ = "All files (*)" - filename = getSaveFileName( - parent=electrum_window, - title=_("Select file to save your {}".format(title)), - filename="BALplugin_{}_{}_{}".format( - BalPlugin.chainname, str(electrum_window.wallet), title - ), - filter=filter_, - config=electrum_window.config, - ) - if not filename: - return - try: - exporter(filename) - except FileExportFailed as e: - electrum_window.show_critical(str(e)) - else: - electrum_window.show_message( - _("Your {0} were exported to '{1}'".format(title, str(filename))) - ) - - diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py deleted file mode 100644 index 48606ee..0000000 --- a/bal/gui/qt/dialogs.py +++ /dev/null @@ -1,1257 +0,0 @@ -""" -bal.gui.qt.dialogs -================== - -All modal/non-modal dialogs of the plugin. - - * BalDialog - common base dialog (icon, close handling). - * BalWizard* (Dialog/Widget) - the step-by-step "create your will" wizard. - * BalWaitingDialog / - BalBlockingWaitingDialog - progress dialogs for background tasks. - * BalBuildWillDialog - the central build/sign/push/broadcast flow. - * WillDetailDialog - shows the full will tree for one wallet. - * WillExecutorDialog - manage the list of will-executor servers. - -To keep the dialogs verbatim while avoiding import cycles with the list views, -the few list classes they reference are imported lazily inside the methods that -use them (see ``lists`` imports below). -""" - -from .common import * -from .common import _, _logger # underscore names are not re-exported by "import *" -from .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget, - LockTimeWidget, PercAmountEdit, ThresholdTimeWidget, - WillSettingsWidget, WillWidget) -from .calendar import BalCalendar -# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are -# imported lazily where needed to avoid a dialogs<->lists import cycle. - - -class BalDialog(QDialog,MessageBoxMixin): - _stopping = False - def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"): - import signal - from PyQt6.QtCore import QMetaObject, Qt - from PyQt6.QtWidgets import QApplication - def handler(signum, frame): - QMetaObject.invokeMethod(self, "close", Qt.ConnectionType.QueuedConnection) - - #signal.signal(signal.SIGINT, handler) - # NOTE: do NOT store this as ``self.parent`` - that would shadow - # QWidget.parent() and can make the dialog disappear behind Electrum. - self._bal_parent = parent - self.thread = None - # Anchor the dialog to the *top-level* Electrum window so it always - # stays in front of it (instead of falling behind). - super().__init__(top_level_of(parent)) - if title: - self.setWindowTitle(title) - # WindowModalDialog.__init__(self,parent) - self.setWindowIcon(read_QIcon_from_bytes(bal_plugin.read_file(icon))) - - def closeEvent(self, event): - self._stopping = True - # NOTE: we deliberately do NOT stop ``self.thread`` here. - # - # Electrum's ``TaskThread`` delivers results via ``on_done`` which calls - # ``cb_done`` (often ``self.accept`` -> closes this dialog) *before* - # ``cb_result`` (``on_success`` -> e.g. updating the will-executor - # list). If we stop/join the thread inside ``closeEvent`` the close - # triggered by ``accept`` tears the thread down *before* ``on_success`` - # runs, so the downloaded data is silently dropped. The original plugin - # left this commented out for exactly this reason; subclasses that own a - # genuinely long-lived thread stop it explicitly in their own close - # handler. - super().closeEvent(event) - - def hideEvent(self, event): - self._stopping = True - super().hideEvent(event) - - -class BalWizardDialog(BalDialog): - def __init__(self, bal_window: "BalWindow"): - assert bal_window - BalDialog.__init__( - self, bal_window.window, bal_window.bal_plugin, _("Bal Wizard Setup") - ) - self.setMinimumSize(800, 400) - self.bal_window = bal_window - self._bal_parent = bal_window.window - self.layout = QVBoxLayout(self) - self.widget = BalWizardHeirsWidget( - bal_window, self, self.on_next_heir, None, self.on_cancel_heir - ) - self.layout.addWidget(self.widget) - - def next_widget(self, widget): - self.layout.removeWidget(self.widget) - self.widget.close() - self.widget = widget - self.layout.addWidget(self.widget) - # self.update() - # self.repaint() - - def on_next_heir(self): - self.next_widget( - BalWizardLocktimeAndFeeWidget( - self.bal_window, - self, - self.on_next_locktimeandfee, - self.on_previous_heir, - self.on_cancel_heir, - ) - ) - - def on_previous_heir(self): - self.next_widget( - BalWizardHeirsWidget( - self.bal_window, self, self.on_next_heir, None, self.on_cancel_heir - ) - ) - - def on_cancel_heir(self): - pass - - def on_next_wedonwload(self): - self.next_widget( - BalWizardWEWidget( - self.bal_window, - self, - self.on_next_we, - self.on_next_locktimeandfee, - self.on_cancel_heir, - ) - ) - - def on_next_we(self): - close_window = BalBuildWillDialog(self.bal_window) - close_window.build_will_task() - self.close() - # self.next_widget(BalWizardLocktimeAndFeeWidget(self.bal_window,self,self.on_next_locktimeandfee,self.on_next_wedonwload,self.on_next_wedonwload.on_cancel_heir)) - - def on_next_locktimeandfee(self): - self.next_widget( - BalWizardWEDownloadWidget( - self.bal_window, - self, - self.on_next_wedonwload, - self.on_next_heir, - self.on_cancel_heir, - ) - ) - - def on_accept(self): - self.bal_window.update_all() - pass - - def on_reject(self): - pass - - def on_close(self): - self.bal_window.update_all() - pass - - def closeEvent(self, event): - self._stopping = True - # self.bal_window.heir_list_widget.will_settings_widget.update_will_settings() - pass - - - -class BalWizardWidget(QWidget): - title = None - message = None - - def __init__( - self, bal_window: "BalWindow", parent, on_next, on_previous, on_cancel - ): - QWidget.__init__(self, parent) - self.vbox = QVBoxLayout(self) - self.bal_window = bal_window - self._bal_parent = parent - self.on_next = on_next - self.on_cancel = on_cancel - self.titleLabel = QLabel(self.title) - self.vbox.addWidget(self.titleLabel) - self.messageLabel = QLabel(_(self.message)) - self.vbox.addWidget(self.messageLabel) - - self.content = self.get_content() - self.content_container = QWidget() - self.containrelayout = QVBoxLayout(self.content_container) - self.containrelayout.addWidget(self.content) - - self.vbox.addWidget(self.content_container) - - spacer_widget = QWidget() - spacer_widget.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - self.vbox.addWidget(spacer_widget) - - self.buttons = [] - if on_previous: - self.on_previous = on_previous - self.previous_button = QPushButton(_("Previous")) - self.previous_button.clicked.connect(self._on_previous) - self.buttons.append(self.previous_button) - - self.next_button = QPushButton(_("Next")) - self.next_button.clicked.connect(self._on_next) - self.buttons.append(self.next_button) - - self.abort_button = QPushButton(_("Cancel")) - self.abort_button.clicked.connect(self._on_cancel) - self.buttons.append(self.abort_button) - - self.vbox.addLayout(Buttons(*self.buttons)) - - def _on_cancel(self): - self.on_cancel() - self._bal_parent.close() - - def _on_next(self): - if self.validate(): - self.on_next() - - def _on_previous(self): - self.on_previous() - - def get_content(self): - pass - - def validate(self): - return True - - - -class BalWizardHeirsWidget(BalWizardWidget): - title = "Bitcoin After Life Heirs" - message = ( - "Please add your heirs\n remember that 100% of wallet balance will be spent" - ) - - def get_content(self): - # Lazy import to avoid a dialogs<->lists import cycle (lists imports - # BalBuildWillDialog from this module at load time). - from .lists import HeirListWidget - self.heir_list_widget = HeirListWidget(self.bal_window, self) - button_add = QPushButton(_("Add")) - button_add.clicked.connect(self.add_heir) - button_import = QPushButton(_("Import")) - button_import.clicked.connect(self.import_from_file) - button_export = QPushButton(_("Export")) - button_export.clicked.connect(self.export_to_file) - widget = QWidget() - vbox = QVBoxLayout(widget) - vbox.addWidget(self.heir_list_widget) - vbox.addLayout(Buttons(button_add, button_import, button_export)) - return widget - - def import_from_file(self): - self.bal_window.import_heirs() - self.heir_list_widget.update() - - def export_to_file(self): - self.bal_window.export_heirs() - - def add_heir(self): - self.bal_window.new_heir_dialog() - self.heir_list_widget.update() - - def validate(self): - return True - - - -class BalWizardWEDownloadWidget(BalWizardWidget): - title = _("Bitcoin After Life Will-Executors") - message = _("Choose willexecutors download method") - - def get_content(self): - # question = QLabel() - self.combo = QComboBox() - self.combo.addItems( - [ - "Automatically download and select willexecutors", - "Only download willexecutors list", - "Import willexecutor list from file", - "Manual", - ] - ) - # heir_name.setFixedWidth(32 * char_width_in_lineedit()) - return self.combo - - def validate(self): - return True - - def _on_next(self): - - index = self.combo.currentIndex() - _logger.debug(f"selected index:{index}") - if index < 3: - self.bal_window.willexecutors = Willexecutors.get_willexecutors( - self.bal_window.bal_plugin - ) - - if index == 2: - - def do_nothing(): - self.bal_window.willexecutors.update(self.willexecutors) - Willexecutors.save( - self.bal_window.bal_plugin, self.bal_window.willexecutors - ) - pass - - import_meta_gui( - self.bal_window.window, - _("willexecutors"), - self.import_json_file, - do_nothing, - ) - - if index < 2: - - def on_success(willexecutors): - def ping_on_success(result): - ping_on_done() - - def ping_on_failure(exec_info): - ping_on_done() - - def ping_on_done(): - if index < 1: - for we in self.bal_window.willexecutors: - if self.bal_window.willexecutors[we]["status"] == 200: - self.bal_window.willexecutors[we]["selected"] = True - Willexecutors.save( - self.bal_window.bal_plugin, self.bal_window.willexecutors - ) - - self.bal_window.ping_willexecutors( - self.bal_window.willexecutors, ping_on_success, ping_on_failure - ) - - self.bal_window.download_list(self.bal_window.willexecutors, on_success) - - elif index == 3: - # TODO DO NOTHING - pass - - self.bal_window.will_list_widget.update() - if self.validate(): - return self.on_next() - - def import_json_file(self, path): - data = read_json_file(path) - data = self._validate(data) - self.willexecutors = data - - def _validate(self, data): - return data - - - -class BalWizardWEWidget(BalWizardWidget): - title = "Bitcoin After Life Will-Executors" - message = _("Configure and select your willexecutors") - - def get_content(self): - # Lazy import to avoid a dialogs<->lists import cycle. - from .lists import WillExecutorWidget - widget = QWidget() - vbox = QVBoxLayout(widget) - vbox.addWidget( - WillExecutorWidget( - self, - self.bal_window, - Willexecutors.get_willexecutors(self.bal_window.bal_plugin), - ) - ) - return widget - - - -class BalWizardLocktimeAndFeeWidget(BalWizardWidget): - title = "Bitcoin After Life Will Settings" - message = _("") - - def get_content(self): - widget = QWidget() - layout = QVBoxLayout(widget) - - # 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 - ) - layout.addWidget(spacer_widget) - return widget - - - -class BalWaitingDialog(BalDialog): - updatemessage = pyqtSignal([str], arguments=["message"]) - - def __init__( - self, - bal_window: "BalWindow", - message: str, - task, - on_success=None, - on_error=None, - on_cancel=None, - exe=True, - ): - assert bal_window - BalDialog.__init__( - self, bal_window.window, bal_window.bal_plugin, _("Please wait") - ) - self.message_label = QLabel(message) - vbox = QVBoxLayout(self) - vbox.addWidget(self.message_label) - self.updatemessage.connect(self.update_message) - if on_cancel: - self.cancel_button = CancelButton(self) - self.cancel_button.clicked.connect(on_cancel) - vbox.addLayout(Buttons(self.cancel_button)) - self.accepted.connect(self.on_accepted) - self.task = task - self.on_success = on_success - self.on_error = on_error - self.on_cancel = on_cancel - if exe: - self.exe() - - def exe(self): - self.thread = TaskThread(self) - self.thread.finished.connect(self.deleteLater) # see #3956 - self.thread.finished.connect(self.finished) - self.thread.add(self.task, self.on_success, self.accept, self.on_error) - # IMPORTANT: keep the *application-modal* exec() of the original code. - # This dialog is driven by a TaskThread whose result (on_success, e.g. - # populating the will-executor list) is delivered via a queued signal - # while exec() spins the modal event loop. Switching to window-modal - # changed how the modal loop interacts with that delivery and could - # cause the downloaded list to never be applied. We only add the - # raise/activate so the dialog stays visible, without altering modality. - bring_to_front(self) - self.exec() - - def hello(self): - pass - - def finished(self): - pass - - - def on_accepted(self): - pass - - def update_message(self, msg): - self.message_label.setText(msg) - - def update(self, msg): - self.updatemessage.emit(msg) - - def getText(self): - return self.message_label.text() - - - - -class BalBlockingWaitingDialog(BalDialog): - def __init__(self, bal_window: "BalWindow", message: str, task: Callable[[], Any]): - BalDialog.__init__(self, bal_window, bal_window.bal_plugin, _("Please wait")) - self.message_label = QLabel(message) - vbox = QVBoxLayout(self) - vbox.addWidget(self.message_label) - self.finished.connect(self.deleteLater) # see #3956 - # show popup (window-modal + on top so it is actually visible) - show_on_top(self) - # Refresh the GUI so the popup is painted (and message_label drawn) - # BEFORE we block the GUI thread running the task; otherwise the popup - # appears empty/frozen. - from PyQt6.QtWidgets import QApplication - QApplication.processEvents() - QApplication.processEvents() - try: - # block and run given task - task() - finally: - # close popup - self.accept() - - -class BalBuildWillDialog(BalDialog): - updatemessage = pyqtSignal() - COLOR_WARNING = "#cfa808" - COLOR_ERROR = "#ff0000" - COLOR_OK = "#05ad05" - - def __init__(self, bal_window, parent=None): - if not parent: - parent = bal_window.window - BalDialog.__init__(self, parent, bal_window.bal_plugin, _("Building Will")) - # (parent already stored as self._bal_parent by BalDialog.__init__) - self.updatemessage.connect(self.msg_update) - self.bal_window = bal_window - self.bal_plugin = bal_window.bal_plugin - self.message_label = QLabel(_("Building Will:")) - self.vbox = QVBoxLayout(self) - self.vbox.addWidget(self.message_label, 0) - self.qwidget = QWidget(self) - self.vbox.addWidget(self.qwidget, 1) - self.labelsbox = QVBoxLayout(self.qwidget) - self.setMinimumWidth(600) - self.setMinimumHeight(100) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self.labels = [] - self.check_row = None - self.inval_row = None - self.build_row = None - self.sign_row = None - self.push_row = None - self.network = Network.get_instance() - self._stopping = False - self.thread = TaskThread(self) - self.thread.finished.connect(self.task_finished) # see #3956 - - def task_finished(self): - pass - - def build_will_task(self): - _logger.debug("build will task to be started") - self.thread.add( - self.task_phase1, - on_success=self.on_success_phase1, - on_done=self.on_accept, - on_error=self.on_error_phase1, - ) - # exec() already shows the dialog modally; route through the helper so - # it is window-modal and brought to the front (no separate show()). - show_modal(self) - - def task_phase1(self): - if self._stopping: - return - txs = None - _logger.debug("close plugin phase 1 started") - varrow = self.msg_set_status("checking variables") - try: - self.bal_window.init_class_variables() - except CheckAliveError as cae: - fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) - tx = Will.invalidate_will( - self.bal_window.willitems, self.bal_window.wallet, fee_per_byte - ) - if tx: - _logger.debug( - "during phase1 CAE: {}, Continue to invalidate".format(cae) - ) - self.msg_set_status("checking variables",varrow, "Check Alive Threshold Passed: you have to Invalidate your old Will",self.COLOR_ERROR) - else: - raise cae - return None, tx - except NoHeirsException: - self.msg_set_status("checking variables", varrow,"No Heirs",self.COLOR_ERROR) - #self.msg_set_checking("No Heirs") - return False, None - except Exception as e: - raise e - try: - _logger.debug("checking variables") - Will.check_amounts( - self.bal_window.heirs, - self.bal_window.willexecutors, - self.bal_window.window.wallet.get_utxos(), - self.bal_window.date_to_check, - self.bal_window.window.wallet.dust_threshold(), - ) - _logger.debug("variables ok") - self.msg_set_status("checking variables:", varrow, "Ok", self.COLOR_OK) - except AmountException: - self.msg_set_checking( - self.msg_warning( - "In the inheritance process, " - + "the entire wallet will always be fully emptied. \n" - + "Your settings require an adjustment of the amounts" - ) - ) - - self.msg_set_checking() - have_to_build = False - try: - self.bal_window.check_will() - self.msg_set_checking(self.msg_ok()) - except WillExpiredException: - _logger.debug("expired") - self.msg_set_checking("Expired") - 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 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") - except NotCompleteWillException as e: - _logger.debug(f"not complete {e} true") - message = False - have_to_build = True - if isinstance(e, HeirChangeException): - message = _("Heirs changed:") - elif isinstance(e, WillExecutorNotPresent): - message = _("Will-Executor not present") - elif isinstance(e, WillexecutorChangeException): - message = _("Will-Executor changed") - elif isinstance(e, TxFeesChangedException): - message = _("Txfees are changed") - elif isinstance(e, HeirNotFoundException): - message = _("Heir not found") - if message: - _logger.debug(f"message: {message}") - self.msg_set_checking(message) - else: - self.msg_set_checking("New") - - if have_to_build: - self.msg_set_building() - try: - txs = self.bal_window.build_will() - if not txs: - self.msg_set_building( - _("Balance is too low, or CheckAlive is in the past.Skipped"), - color = self.COLOR_ERROR, - ) - return False, None - - self.bal_window.check_will() - for wid in Will.only_valid(self.bal_window.willitems): - self.bal_window.wallet.set_label(wid, "BAL Transaction") - self.msg_set_building(self.msg_ok()) - except WillExecutorNotPresent: - self.msg_set_status( - _("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR - ) - - except Exception as e: - self.msg_set_building(self.msg_error(e)) - return False, None - - # excluded_heirs = [] - for wid in Will.only_valid(self.bal_window.willitems): - heirs = self.bal_window.willitems[wid].heirs - for hid, heir in heirs.items(): - if "DUST" in str(heir[HEIR_REAL_AMOUNT]): - self.msg_set_status( - f"{hid},{heir[HEIR_DUST_AMOUNT]} is DUST", - None, - f"Excluded from will {wid}", - self.COLOR_WARNING, - ) - - have_to_sign = False - for wid in Will.only_valid(self.bal_window.willitems): - if not self.bal_window.willitems[wid].get_status("COMPLETE"): - have_to_sign = True - break - return have_to_sign, txs - - def on_accept(self): - self.bal_window.update_all() - pass - - def on_accept_phase2(self): - self.bal_window.update_all() - pass - - def on_error_push(self): - pass - - def wait(self, secs): - wait_row = None - for i in range(secs, 0, -1): - if self._stopping: - return - wait_row = self.msg_edit_row(_(f"Please wait {i}secs"), wait_row) - time.sleep(1) - self.msg_del_row(wait_row) - - def loop_broadcast_invalidating(self, tx): - if self._stopping: - return - self.msg_set_invalidating("Broadcasting") - try: - tx.add_info_from_wallet(self.bal_window.wallet) - self.network.run_from_another_thread(tx.add_info_from_network(self.network)) - txid = self.network.run_from_another_thread( - self.network.broadcast_transaction(tx, timeout=120), timeout=120 - ) - self.msg_set_invalidating(self.msg_ok()) - if not txid: - _logger.debug(f"should not be none txid: {txid}") - - except TxBroadcastError as e: - _logger.error(f"fail to broadcast transaction:{e}") - msg = e.get_message_for_gui() - self.msg_set_invalidating(self.msg_error(msg)) - except BestEffortRequestFailed as e: - self.msg_set_invalidating(self.msg_error(e)) - - def loop_push(self): - if self._stopping: - return - self.msg_set_pushing(_("Broadcasting")) - retry = False - try: - - willexecutors = Willexecutors.get_willexecutor_transactions( - self.bal_window.willitems - ) - - # 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} - 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 - # 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"]: - 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 - done["count"] += 1 - # Show the per-server result (Ok/Ko) in bold + color so the - # outcome stands out, keeping the server URL in normal weight. - result = self.msg_ok("Ok") if ok else self.msg_error("Ko") - self.msg_edit_row("{} : {}".format(url, result)) - 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, 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 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). - 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"]) - ) - # Show the CHECKED result in bold + color (green True / - # red False) so the outcome stands out, keeping the server - # URL and tx id in normal weight. - checked = self.bal_window.willitems[wid].get_status("CHECKED") - result = self.msg_ok(checked) if checked else self.msg_error(checked) - row = self.msg_edit_row( - "checked {} - {} : {}".format( - self.bal_window.willitems[wid].we["url"], - wid, - result, - ), - row, - ) - - if retry: - raise Exception("retry") - - except Exception as e: - self.msg_set_pushing(self.msg_error(e)) - self.wait(10) - if not self._stopping: - pass - # self.loop_push() - - def invalidate_task(self, password, bal_window, tx): - if self._stopping: - return - _logger.debug(f"invalidate tx: {tx}") - # fee_per_byte = bal_window.will_settings.get("baltx_fees", 1) - tx = self.bal_window.wallet.sign_transaction(tx, password) - try: - if tx: - if tx.is_complete(): - self.loop_broadcast_invalidating(tx) - self.wait(5) - else: - raise Exception("tx not complete") - else: - raise Exception("not tx") - except Exception as e: - (f"exception:{e}") - self.msg_set_invalidating(f"Error: {e}") - raise Exception("Impossible to sign") from e - - def on_success_invalidate(self, success): - self.thread.add( - self.task_phase1, - on_success=self.on_success_phase1, - on_done=self.on_accept, - on_error=self.on_error_phase1, - ) - - def on_success_phase1(self, result): - if self._stopping: - return - self.have_to_sign, tx = list(result) - # if not tx: - # self.msg_edit_row(self.msg_error("Error, no tx was built")) - # return - _logger.debug("have to sign {}".format(self.have_to_sign)) - password = None - if self.have_to_sign is None: - _logger.debug("have to invalidate") - self.msg_set_invalidating() - # need to sign invalidate and restart phase 1 - - password = self.bal_window.get_wallet_password( - _("Invalidate your old will"), parent=self - ) - if password is False: - self.msg_set_invalidating(_("Aborted")) - self.wait(3) - self.close() - return - self.thread.add( - partial(self.invalidate_task, password, self.bal_window, tx), - on_success=self.on_success_invalidate, - on_done=self.on_accept, - on_error=self.on_error, - ) - - return - - elif self.have_to_sign: - password = self.bal_window.get_wallet_password( - _("Sign your will"), parent=self - ) - if password is False: - self.msg_set_signing(_("Aborted")) - else: - self.msg_set_signing(_("Nothing to do")) - self.thread.add( - partial(self.task_phase2, password), - on_success=self.on_success_phase2, - on_done=self.on_accept_phase2, - on_error=self.on_error_phase2, - ) - return - - def on_success_phase2(self, arg=False): - self.thread.stop() - self.bal_window.save_willitems() - self.msg_edit_row(_("Finished")) - self.close() - - def closeEvent(self, event): - self._stopping = True - # Stop AND join the thread, then propagate the close event (previously - # it neither waited nor called super().closeEvent()). - stop_thread(getattr(self, "thread", None)) - super().closeEvent(event) - - def task_phase2(self, password): - if self._stopping: - return - if self.have_to_sign: - try: - if txs := self.bal_window.sign_transactions(password): - for txid, tx in txs.items(): - self.bal_window.willitems[txid].tx = copy.deepcopy(tx) - self.bal_window.save_willitems() - self.msg_set_signing(self.msg_ok()) - except Exception as e: - self.msg_set_signing(self.msg_error(e)) - - self.msg_set_pushing() - have_to_push = False - for wid in Will.only_valid(self.bal_window.willitems): - w = self.bal_window.willitems[wid] - if w.we and w.get_status("COMPLETE") and not w.get_status("PUSHED"): - have_to_push = True - if not have_to_push: - self.msg_set_pushing(_("Nothing to do")) - else: - try: - self.loop_push() - self.msg_set_pushing(self.msg_ok()) - - except Exception as e: - # td = traceback.format_exc() - self.msg_set_pushing(self.msg_error(e)) - self.msg_edit_row(self.msg_ok()) - self.wait(5) - - def on_error(self, error): - _logger.error(error) - pass - - def on_error_phase1(self, error): - self.bal_window.update_all() - a, b, c = error - self.msg_edit_row(self.msg_error(f"Error: {b}")) - _logger.error(f"error phase1: {b}") - button=QPushButton(_("Close")) - button.clicked.connect(self.close) - self.vbox.addWidget(button) - self.resize(self.vbox.sizeHint()+button.sizeHint()*2) - self.repaint() - def on_error_phase2(self, error): - self.bal_window.upade_all() - a, b, c = error - self.msg_edit_row(self.msg_error(f"Error: {b}")) - _logger.error(f"error phase2: {b}") - - def msg_set_checking(self, status="Waiting", row=None): - row = self.check_row if row is None else row - self.check_row = self.msg_set_status(_("Checking your will"), row, status) - - def msg_set_invalidating(self, status=None, row=None): - row = self.inval_row if row is None else row - self.inval_row = self.msg_set_status( - _("Invalidating old will"), self.inval_row, status - ) - - def msg_set_building(self, status=None, row=None,color=None): - row = self.build_row if row is None else row - self.build_row = self.msg_set_status( - "Building your will", self.build_row, status, color - ) - - def msg_set_signing(self, status=None, row=None): - row = self.sign_row if row is None else row - self.sign_row = self.msg_set_status("Signing your will", self.sign_row, status) - - def msg_set_pushing(self, status=None, row=None): - row = self.push_row if row is None else row - self.push_row = self.msg_set_status( - "Broadcasting your will to executors", self.push_row, status - ) - - def msg_set_waiting(self, status=None, row=None): - row = self.wait_row if row is None else row - self.wait_row = self.msg_edit_row(f"Please wait {status}secs", self.wait_row) - - def msg_error(self, e): - # Results are shown in bold so the outcome stands out from the - # left-side state label (which stays in normal weight). - return "{}".format(self.COLOR_ERROR, e) - - def msg_ok(self, e="Ok"): - # Results are shown in bold (see msg_error). - return "{}".format(self.COLOR_OK, e) - - def msg_warning(self, e): - # Results are shown in bold (see msg_error). - return "{}".format(self.COLOR_WARNING, e) - - def msg_set_status(self, msg, row=None, status=None, color=None): - # The left "state" label keeps its normal weight; only the right-side - # result (``status``) is rendered in bold so it is easy to read at a - # glance. ``status`` may already contain rich-text emitted by - # msg_ok/msg_error/msg_warning (which add their own ...); wrapping - # it again in is harmless for those cases. - status = "Wait" if status is None else status - if color is None: - line = "{}:\t{}".format(_(msg), status) - else: - line = "{}:\t{}".format( - _(msg), color, status - ) - return self.msg_edit_row(line, row) - - def ask_password(self, msg=None): - self.password = self.bal_window.get_wallet_password(msg, parent=self) - - def msg_edit_row(self, line, row=None): - try: - self.labels[row] = line - except Exception: - self.labels.append(line) - row = len(self.labels) - 1 - - self.updatemessage.emit() - - return row - - def msg_del_row(self, row): - try: - del self.labels[row] - except Exception: - pass - self.updatemessage.emit() - - # def clear_layout(self,layout): - # while layout.count(): - # item = layout.takeAt(0) - # w = item.widget() - # if w: - # w.setParent(None) - # w.deleteLater() - - # def msg_update(self): - # self.clear_layout(self.labelsbox) - # for label in self.labels: - # label=label.replace("\n","
") - # qlabel=QLabel(label) - # qlabel.setWordWrap(True) - # self.labelsbox.addWidget(qlabel) - - # self.labelsbox.activate() - # self.qwidget.setMinimumSize(self.labelsbox.sizeHint()) - # self.qwidget.adjustSize() - # from PyQt6.QtWidgets import QApplication - # QApplication.processEvents() - # - # self.adjustSize() - def msg_update(self): - full_text = "

".join(self.labels).replace("\n", "
") - self.message_label.setText(full_text) - self.message_label.adjustSize() - # self.setMinimumHeight(len(self.labels)*40) - self.resize(self.sizeHint()) - - def get_text(self): - return self.message_label.text() - - pass - - - -class WillDetailDialog(BalDialog): - def __init__(self, bal_window): - - self.will = bal_window.willitems - self.threshold = bal_window.will_settings["real_threshold"] - - self.bal_window = bal_window - Will.add_willtree(self.will) - super().__init__(bal_window.window, bal_window.bal_plugin) - self.config = bal_window.window.config - self.wallet = bal_window.wallet - self.format_amount = bal_window.window.format_amount - self.base_unit = bal_window.window.base_unit - self.format_fiat_and_units = bal_window.window.format_fiat_and_units - self.fx = bal_window.window.fx - self.format_fee_rate = bal_window.window.format_fee_rate - self.decimal_point = bal_window.window.get_decimal_point() - self.base_unit_name = decimal_point_to_base_unit_name(self.decimal_point) - self.setWindowTitle(_("Will Details")) - self.setMinimumSize(670, 700) - self.vlayout = QVBoxLayout() - w = QWidget() - hlayout = QHBoxLayout(w) - - b = QPushButton(_("Sign")) - b.clicked.connect(self.ask_password_and_sign_transactions) - hlayout.addWidget(b) - - b = QPushButton(_("Broadcast")) - b.clicked.connect(self.broadcast_transactions) - hlayout.addWidget(b) - - b = QPushButton(_("Export")) - b.clicked.connect(self.export_will) - hlayout.addWidget(b) - b = QPushButton(_("Invalidate")) - b.clicked.connect(bal_window.invalidate_will) - hlayout.addWidget(b) - self.vlayout.addWidget(w) - - self.paint_scroll_area() - self.vlayout.addWidget( - QLabel(_("Expiration date: ") + str(BalTimestamp(self.threshold))) - ) - self.vlayout.addWidget(self.scrollbox) - w = QWidget() - hlayout = QHBoxLayout(w) - hlayout.addWidget( - QLabel(_("Valid Txs:") + str(len(Will.only_valid_list(self.will)))) - ) - hlayout.addWidget(QLabel(_("Total Txs:") + str(len(self.will)))) - self.vlayout.addWidget(w) - self.setLayout(self.vlayout) - - def paint_scroll_area(self): - self.scrollbox = QScrollArea() - viewport = QWidget(self.scrollbox) - self.willlayout = QVBoxLayout(viewport) - self.detailsWidget = WillWidget(parent=self) - self.willlayout.addWidget(self.detailsWidget) - - self.scrollbox.setWidget(viewport) - viewport.setLayout(self.willlayout) - - def ask_password_and_sign_transactions(self): - self.bal_window.ask_password_and_sign_transactions(callback=self.update) - self.update() - - def broadcast_transactions(self): - self.bal_window.broadcast_transactions() - self.update() - - def export_will(self): - self.bal_window.export_will() - - def toggle_replaced(self): - self.bal_window.bal_plugin.hide_replaced() - toggle = _("Hide") - if self.bal_window.bal_plugin._hide_replaced: - toggle = _("Unhide") - self.toggle_replace_button.setText(f"{toggle} {_('replaced')}") - self.update() - - def toggle_invalidated(self): - self.bal_window.bal_plugin.hide_invalidated() - toggle = _("Hide") - if self.bal_window.bal_plugin._hide_invalidated: - toggle = _("Unhide") - self.toggle_invalidate_button.setText(_(f"{toggle} {_('invalidated')}")) - self.update() - - def update(self): - self.will = self.bal_window.willitems - pos = self.vlayout.indexOf(self.scrollbox) - self.vlayout.removeWidget(self.scrollbox) - self.paint_scroll_area() - self.vlayout.insertWidget(pos, self.scrollbox) - super().update() - - - -class WillExecutorDialog(BalDialog, MessageBoxMixin): - def __init__(self, bal_window, parent=None): - if not parent: - parent = bal_window.window - BalDialog.__init__(self, parent, bal_window.bal_plugin) - self.bal_plugin = bal_window.bal_plugin - self.config = self.bal_plugin.config - self.bal_window = bal_window - self.willexecutors_list = Willexecutors.get_willexecutors(self.bal_plugin) - - self.setWindowTitle(_("Will-Executor Service List")) - self.setMinimumSize(1000, 200) - - # Lazy import to avoid a dialogs<->lists import cycle. - from .lists import WillExecutorWidget - vbox = QVBoxLayout(self) - self.will_executor_list_widget = WillExecutorWidget( - self, self.bal_window, self.willexecutors_list - ) - vbox.addWidget(self.will_executor_list_widget) - - def is_hidden(self): - return self.isMinimized() or self.isHidden() - - def show_or_hide(self): - if self.is_hidden(): - self.bring_to_top() - else: - self.hide() - - def bring_to_top(self): - self.show() - # raise_() alone does not grab focus on some window managers (Windows); - # activateWindow() ensures the dialog actually comes to the front. - bring_to_front(self) - - def closeEvent(self, event): - event.accept() - - diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py deleted file mode 100644 index 53bd123..0000000 --- a/bal/gui/qt/lists.py +++ /dev/null @@ -1,989 +0,0 @@ -""" -bal.gui.qt.lists -================ - -Tree/list views (subclasses of Electrum's ``MyTreeView``) and their toolbars. - - * HeirListWidget - editable list of heirs (address / amount / locktime). - * PreviewList - preview of the will transactions before signing. - * WillExecutorListWidget- list of will-executor servers. - * WillExecutorWidget - container combining the list with add/import buttons. - -These views call back into the :class:`BalWindow` controller (passed at -construction) for all business actions, so the heavy logic stays in ``window`` -and ``dialogs``. -""" - -from .common import * -from .common import _, _logger # underscore names are not re-exported by "import *" -from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget -from .dialogs import BalBuildWillDialog - - -class HeirListWidget(MyTreeView, MessageBoxMixin): - class Columns(MyTreeView.BaseColumnsEnum): - NAME = enum.auto() - ADDRESS = enum.auto() - AMOUNT = enum.auto() - - headers = { - Columns.NAME: _("Name"), - Columns.ADDRESS: _("Address"), - Columns.AMOUNT: _("Amount"), - } - filter_columns = [Columns.NAME, Columns.ADDRESS] - - ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1000 - - ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 4000 - key_role = ROLE_HEIR_KEY - - def createEditor(self, parent, option, index): - return QLineEdit(parent) - - def setEditorData(self, editor, index): - editor.setText(index.data()) - - def setModelData(self, editor, model, index): - model.setData(index, editor.text()) - - def __init__(self, bal_window: "BalWindow", parent): - super().__init__( - parent=parent, - main_window=bal_window.window, - stretch_column=self.Columns.NAME, - editable_columns=[ - self.Columns.NAME, - self.Columns.ADDRESS, - self.Columns.AMOUNT, - ], - ) - self.decimal_point = bal_window.window.get_decimal_point() - self.bal_window = bal_window - - try: - self.setModel(QStandardItemModel(self)) - self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder) - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - except Exception: - pass - - self.setSortingEnabled(True) - self.std_model = self.model() - - self.update() - - def on_activated(self, idx): - self.on_double_click(idx) - - def on_double_click(self, idx): - edit_key = self.get_edit_key_from_coordinate(idx.row(), idx.column()) - self.bal_window.heirs.get(edit_key) - self.bal_window.new_heir_dialog(edit_key) - - def on_edited(self, idx, edit_key, *, text): - original = prior_name = self.bal_window.heirs.get(edit_key) - if not prior_name: - return - col = idx.column() - try: - if col == 2: - text = Util.encode_amount(text, self.decimal_point) - elif col == 0: - self.bal_window.delete_heirs([edit_key]) - edit_key = text - prior_name[col - 1] = text - prior_name.insert(0, edit_key) - prior_name = tuple(prior_name) - except Exception: - prior_name = ( - (edit_key,) + prior_name[: col - 1] + (text,) + prior_name[col:] - ) - - try: - self.bal_window.set_heir(prior_name) - except Exception: - pass - - try: - self.bal_window.set_heir((edit_key,) + original) - except Exception: - self.update() - - def delete_heirs(self, selected_keys): - self.bal_window.delete_heirs(selected_keys) - self.update() - - def create_menu(self, position): - menu = QMenu() - idx = self.indexAt(position) - column = idx.column() or self.Columns.NAME - selected_keys = [] - for s_idx in self.selected_in_column(self.Columns.NAME): - sel_key = self.model().itemFromIndex(s_idx).data(0) - selected_keys.append(sel_key) - if selected_keys and idx.isValid(): - column_title = self.model().horizontalHeaderItem(column).text() - # ok - column_data = "\n".join( - self.model().itemFromIndex(s_idx).text() - for s_idx in self.selected_in_column(column) - ) - menu.addAction( - _("Copy {}").format(column_title), - lambda: self.place_text_on_clipboard(column_data, title=column_title), - ) - if column in self.editable_columns: - item = self.model().itemFromIndex(idx) - if item.isEditable(): - persistent = QPersistentModelIndex(idx) - menu.addAction( - _("Edit {}").format(column_title), - lambda p=persistent: self.edit(QModelIndex(p)), - ) - menu.addAction(_("Delete"), lambda: self.delete_heirs(selected_keys)) - menu.exec(self.viewport().mapToGlobal(position)) - - def update(self): - current_key = self.get_role_data_for_current_item( - col=self.Columns.NAME, role=self.ROLE_HEIR_KEY - ) - self.model().clear() - self.update_headers(self.__class__.headers) - set_current = None - for key in sorted(self.bal_window.heirs.keys()): - heir = self.bal_window.heirs[key] - labels = [""] * len(self.Columns) - labels[self.Columns.NAME] = key - labels[self.Columns.ADDRESS] = heir[0] - labels[self.Columns.AMOUNT] = Util.decode_amount( - heir[1], self.decimal_point - ) - - items = [QStandardItem(x) for x in labels] - items[self.Columns.NAME].setEditable(True) - items[self.Columns.ADDRESS].setEditable(True) - items[self.Columns.AMOUNT].setEditable(True) - items[self.Columns.NAME].setData( - key, self.ROLE_HEIR_KEY + self.Columns.NAME - ) - items[self.Columns.ADDRESS].setData( - key, self.ROLE_HEIR_KEY + self.Columns.ADDRESS - ) - items[self.Columns.AMOUNT].setData( - key, self.ROLE_HEIR_KEY + self.Columns.AMOUNT - ) - - row_count = self.model().rowCount() - self.model().insertRow(row_count, items) - - if key == current_key: - idx = self.model().index(row_count, self.Columns.NAME) - set_current = QPersistentModelIndex(idx) - try: - self.will_settings_widget.on_locktime_change() - except Exception as e: - pass - self.set_current_idx(set_current) - # FIXME refresh loses sort order; so set "default" here: - self.filter() - - def refresh_row(self, key, row): - # nothing to update here - pass - - def get_edit_key_from_coordinate(self, row, col): - a = self.get_role_data_from_coordinate(row, col, role=self.ROLE_HEIR_KEY + col) - return a - - def create_toolbar(self, config): - toolbar, menu = self.create_toolbar_with_menu("") - menu.addAction(_("&New Heir"), self.bal_window.new_heir_dialog) - menu.addAction(_("Import"), self.bal_window.import_heirs) - menu.addAction(_("Export"), lambda: self.bal_window.export_heirs()) - - newHeirButton = QPushButton(_("New Heir")) - newHeirButton.clicked.connect(self.bal_window.new_heir_dialog) - - widget = QWidget(self) - layout = QHBoxLayout(widget) - self.will_settings_widget = WillSettingsWidget(self.bal_window, self) - - layout.addWidget(self.will_settings_widget) - layout.addWidget(newHeirButton) - - toolbar.insertWidget(2, widget) - - return toolbar - - def build_transactions(self): - # will = self.bal_window.prepare_will() - self.bal_window.prepare_will() - - - -class PreviewList(MyTreeView, MessageBoxMixin): - class Columns(MyTreeView.BaseColumnsEnum): - LOCKTIME = enum.auto() - 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 - key_role = ROLE_HEIR_KEY - - def createEditor(self, parent, option, index): - return QLineEdit(parent) - - def setEditorData(self, editor, index): - editor.setText(index.data()) - - def setModelData(self, editor, model, index): - model.setData(index, editor.text()) - - def __init__(self, bal_window: "BalWindow", parent, will): - super().__init__( - parent=parent, - main_window=bal_window.window, - stretch_column=self.Columns.TXID, - ) - # self._bal_parent = parent - self.bal_window = bal_window - self.decimal_point = bal_window.window.get_decimal_point - - if will is not None: - self.will = will - else: - self.will = bal_window.willitems - - try: - self.setModel(QStandardItemModel(self)) - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder) - except Exception as e: - pass - - self.setSortingEnabled(True) - self.std_model = self.model() - - self.update() - - def on_activated(self, idx): - self.on_double_click(idx) - - def on_double_click(self, idx): - idx = self.model().index(idx.row(), self.Columns.TXID) - sel_key = self.model().itemFromIndex(idx).data(0) - self.show_transaction([sel_key]) - - def create_menu(self, position): - menu = QMenu() - idx = self.indexAt(position) - column = idx.column() or self.Columns.TXID - selected_keys = [] - for s_idx in self.selected_in_column(self.Columns.TXID): - sel_key = self.model().itemFromIndex(s_idx).data(0) - selected_keys.append(sel_key) - if selected_keys and idx.isValid(): - column_title = self.model().horizontalHeaderItem(column).text() - # column_data = "\n".join( - # self.model().itemFromIndex(s_idx).text() - # for s_idx in self.selected_in_column(column) - # ) - - menu.addAction( - _("details").format(column_title), - lambda: self.show_transaction(selected_keys), - ).setEnabled(len(selected_keys) < 2) - menu.addAction( - _("check ").format(column_title), - lambda: self.check_transactions(selected_keys), - ) - if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get(): - try: - self.importaction = self.menu.addAction( - _("Import"), self.import_will - ) - except Exception: - pass - - menu.addSeparator() - menu.addAction( - _("delete").format(column_title), lambda: self.delete(selected_keys) - ) - - menu.exec(self.viewport().mapToGlobal(position)) - - def delete(self, selected_keys): - for key in selected_keys: - del self.will[key] - try: - del self.bal_window.willitems[key] - except Exception: - pass - try: - del self.bal_window.will[key] - except Exception: - pass - self.update() - - def check_transactions(self, selected_keys): - wout = {} - for k in selected_keys: - wout[k] = self.will[k] - if wout: - self.bal_window.check_transactions(wout) - self.update() - - def show_transaction(self, selected_keys): - for key in selected_keys: - self.bal_window.show_transaction(self.will[key].tx) - - self.update() - - def select(self, selected_keys): - self.selected += selected_keys - self.update() - - def deselect(self, selected_keys): - for key in selected_keys: - self.selected.remove(key) - self.update() - - def update_will(self, will): - self.will.update(will) - self.update() - - def replace(self, set_current, current_key, txid, bal_tx): - if self.bal_window.bal_plugin._hide_replaced and bal_tx.get_status("REPLACED"): - return False - if self.bal_window.bal_plugin._hide_invalidated and bal_tx.get_status( - "INVALIDATED" - ): - return False - - if not isinstance(bal_tx, WillItem): - bal_tx = WillItem(bal_tx) - - tx = bal_tx.tx - - labels = [""] * len(self.Columns) - labels[self.Columns.LOCKTIME] = str(BalTimestamp(tx.locktime)) - labels[self.Columns.TXID] = txid - we = "None" - if bal_tx.we: - we = bal_tx.we["url"] - labels[self.Columns.WILLEXECUTOR] = we - status = bal_tx.status - 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: - if isinstance(e, list): - try: - items.append(QStandardItem(*e)) - except Exception as e: - pass - else: - items.append(QStandardItem(str(e))) - - 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: - idx = self.model().index(row_count, self.Columns.TXID) - set_current = QPersistentModelIndex(idx) - self.set_current_idx(set_current) - return set_current - - def update(self): - try: - self.menu.removeAction(self.importaction) - except Exception: - pass - - if self.will is None: - return - - current_key = self.get_role_data_for_current_item( - col=self.Columns.TXID, role=self.ROLE_HEIR_KEY - ) - self.model().clear() - self.update_headers(self.__class__.headers) - - set_current = None - for txid, bal_tx in self.will.items(): - tmp = self.replace(set_current, current_key, txid, bal_tx) - if tmp: - set_current = tmp - self.sortByColumn(self.Columns.LOCKTIME, Qt.SortOrder.AscendingOrder) - self.setSortingEnabled(True) - try: - self.will_settings_widget.on_locktime_change() - except Exception as _e: - pass - - def create_toolbar(self, config): - toolbar, menu = self.create_toolbar_with_menu("") - menu.addAction(_("Prepare"), self.build_transactions) - menu.addAction(_("Display"), self.bal_window.preview_modal_dialog) - menu.addAction(_("Sign"), self.ask_password_and_sign_transactions) - menu.addAction(_("Export"), self.export_will) - if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get(): - self.importaction = menu.addAction(_("Import"), self.import_will) - menu.addAction(_("Broadcast"), self.broadcast) - menu.addAction(_("Check"), self.check) - menu.addAction(_("Invalidate"), self.invalidate_will) - - wizard = QPushButton() - wizard.setIcon( - read_QIcon_from_bytes( - 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) - - refresh = QPushButton() - refresh.setIcon( - read_QIcon_from_bytes( - 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) - # 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) - - self.menu = menu - self.toolbar = toolbar - return toolbar - - def hide_replaced(self): - self.bal_window.bal_plugin.hide_replaced() - self.update() - - def hide_invalidated(self): - self.bal_window.bal_plugin.hide_invalidated() - self.update() - - def build_transactions(self): - will = self.bal_window.prepare_will() - if will: - self.update_will(will) - - def export_json_file(self, path): - write_json_file(path, self.will) - - def export_will(self): - self.bal_window.export_will() - self.update() - - def import_will(self): - self.bal_window.import_will() - - def ask_password_and_sign_transactions(self): - self.bal_window.ask_password_and_sign_transactions(callback=self.update) - - def broadcast(self): - self.bal_window.broadcast_transactions() - self.update() - - def check(self): - close_window = BalBuildWillDialog(self.bal_window) - close_window.build_will_task() - - will = {} - for wid, w in self.bal_window.willitems.items(): - # Query the will-executor server for every valid will that HAS a - # will-executor assigned and is not yet CHECKED. Previously only - # transactions already marked PUSHED were checked, so a will that - # had actually been sent in the past but whose saved status still - # read "New" (not PUSHED) was skipped and the Check button reported - # "nothing to do". Will.needs_server_check now also includes such - # non-PUSHED wills, so the server can confirm the transaction is - # present and correct the status (see set_check_willexecutor). - if Will.needs_server_check(w): - will[wid] = w - if will: - self.bal_window.check_transactions(will) - self.update() - - def invalidate_will(self): - self.bal_window.invalidate_will() - self.update() - - -# class PreviewDialog(BalDialog, MessageBoxMixin): -# def __init__(self, bal_window, will): -# self._bal_parent = bal_window.window -# BalDialog.__init__( -# self, bal_window=bal_window, bal_plugin=bal_window.bal_plugin -# ) -# self.bal_plugin = bal_window.bal_plugin -# self.gui_object = self.bal_plugin.gui_object -# self.config = self.bal_plugin.config -# self.bal_window = bal_window -# self.wallet = bal_window.window.wallet -# self.format_amount = bal_window.window.format_amount -# self.base_unit = bal_window.window.base_unit -# self.format_fiat_and_units = bal_window.window.format_fiat_and_units -# self.fx = bal_window.window.fx -# self.format_fee_rate = bal_window.window.format_fee_rate -# self.show_address = bal_window.window.show_address -# if not will: -# self.will = bal_window.willitems -# else: -# self.will = will -# self.setWindowTitle(_("Transactions Preview")) -# self.setMinimumSize(1000, 200) -# self.size_label = QLabel() -# self.transactions_list = PreviewList(self.bal_window,self, self.will) -# -# try: -# self.bal_window.init_class_variables() -# except Exception as e: -# _logger.error(f"PreviewDialog Exception: {e}") -# self.check_will() -# -# vbox = QVBoxLayout(self) -# vbox.addWidget(self.size_label) -# vbox.addWidget(self.transactions_list) -# buttonbox = QHBoxLayout() -# -# b = QPushButton(_("Sign")) -# b.clicked.connect(self.transactions_list.ask_password_and_sign_transactions) -# buttonbox.addWidget(b) -# -# b = QPushButton(_("Export Will")) -# b.clicked.connect(self.transactions_list.export_will) -# buttonbox.addWidget(b) -# -# b = QPushButton(_("Broadcast")) -# b.clicked.connect(self.transactions_list.broadcast) -# buttonbox.addWidget(b) -# -# b = QPushButton(_("Invalidate will")) -# b.clicked.connect(self.transactions_list.invalidate_will) -# buttonbox.addWidget(b) -# -# vbox.addLayout(buttonbox) -# -# self.update() -# -# def update_will(self, will): -# self.will.update(will) -# self.transactions_list.update_will(will) -# self.update() -# -# def update(self): -# self.transactions_list.update() -# -# def is_hidden(self): -# return self.isMinimized() or self.isHidden() -# -# def show_or_hide(self): -# if self.is_hidden(): -# self.bring_to_top() -# else: -# self.hide() -# -# def bring_to_top(self): -# self.show() -# self.raise_() -# -# def closeEvent(self, event): -# event.accept() - - - -class WillExecutorListWidget(MyTreeView): - class Columns(MyTreeView.BaseColumnsEnum): - SELECTED = enum.auto() - URL = enum.auto() - STATUS = enum.auto() - BASE_FEE = enum.auto() - INFO = enum.auto() - ADDRESS = enum.auto() - - headers = { - Columns.SELECTED: _(""), - Columns.URL: _("Url"), - Columns.STATUS: _("S"), - Columns.BASE_FEE: _("Base fee"), - Columns.INFO: _("Info"), - Columns.ADDRESS: _("Default Address"), - } - - filter_columns = [Columns.URL] - - ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 3000 - ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 3001 - key_role = ROLE_HEIR_KEY - - def __init__(self, parent: "WillExecutorWidget"): - super().__init__( - parent=parent, - stretch_column=self.Columns.ADDRESS, - editable_columns=[ - self.Columns.URL, - self.Columns.BASE_FEE, - self.Columns.ADDRESS, - self.Columns.INFO, - ], - ) - self._bal_parent = parent - try: - self.setModel(QStandardItemModel(self)) - self.sortByColumn(self.Columns.SELECTED, Qt.SortOrder.AscendingOrder) - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - except Exception: - pass - self.setSortingEnabled(True) - self.std_model = self.model() - self.config = parent.bal_plugin.config - self.get_decimal_point = parent.bal_plugin.get_decimal_point - - self.update() - - def create_menu(self, position): - menu = QMenu() - idx = self.indexAt(position) - column = idx.column() or self.Columns.URL - selected_keys = [] - for s_idx in self.selected_in_column(self.Columns.URL): - sel_key = self.model().itemFromIndex(s_idx).data(0) - selected_keys.append(sel_key) - if selected_keys and idx.isValid(): - column_title = self.model().horizontalHeaderItem(column).text() - # column_data = "\n".join( - # self.model().itemFromIndex(s_idx).text() - # for s_idx in self.selected_in_column(column) - # ) - if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]): - menu.addAction( - _("deselect").format(column_title), - lambda: self.deselect(selected_keys), - ) - else: - menu.addAction( - _("select").format(column_title), lambda: self.select(selected_keys) - ) - if column in self.editable_columns: - item = self.model().itemFromIndex(idx) - if item.isEditable(): - persistent = QPersistentModelIndex(idx) - menu.addAction( - _("Edit {}").format(column_title), - lambda p=persistent: self.edit(QModelIndex(p)), - ) - - menu.addAction( - _("Ping").format(column_title), - lambda: self.ping_willexecutors(selected_keys), - ) - menu.addSeparator() - menu.addAction( - _("delete").format(column_title), lambda: self.delete(selected_keys) - ) - - menu.exec(self.viewport().mapToGlobal(position)) - - def ping_willexecutors(self, selected_keys): - wout = {} - for k in selected_keys: - wout[k] = self._bal_parent.willexecutors_list[k] - self._bal_parent.update_willexecutors(wout) - - self._bal_parent.save_willexecutors() - self.update() - - def get_edit_key_from_coordinate(self, row, col): - role = self.ROLE_HEIR_KEY + col - a = self.get_role_data_from_coordinate(row, col, role=role) - return a - - def delete(self, selected_keys): - for key in selected_keys: - del self._bal_parent.willexecutors_list[key] - - self._bal_parent.save_willexecutors() - self.update() - - def select(self, selected_keys): - for wid, w in self._bal_parent.willexecutors_list.items(): - if wid in selected_keys: - w["selected"] = True - self._bal_parent.save_willexecutors() - self.update() - - def deselect(self, selected_keys): - for wid, w in self._bal_parent.willexecutors_list.items(): - if wid in selected_keys: - w["selected"] = False - self._bal_parent.save_willexecutors() - self.update() - - def on_edited(self, idx, edit_key, *, text): - # prior_name = self._bal_parent.willexecutors_list[edit_key] - col = idx.column() - try: - if col == self.Columns.URL: - self._bal_parent.willexecutors_list[text] = self._bal_parent.willexecutors_list[ - edit_key - ] - del self._bal_parent.willexecutors_list[edit_key] - if col == self.Columns.BASE_FEE: - self._bal_parent.willexecutors_list[edit_key]["base_fee"] = ( - Util.encode_amount(text, self.get_decimal_point()) - ) - if col == self.Columns.ADDRESS: - self._bal_parent.willexecutors_list[edit_key]["address"] = text - if col == self.Columns.INFO: - self._bal_parent.willexecutors_list[edit_key]["info"] = text - self._bal_parent.save_willexecutors() - self.update() - except Exception: - pass - - def update(self): - if self._bal_parent.willexecutors_list is None: - return - try: - current_key = self.get_role_data_for_current_item( - col=self.Columns.URL, role=self.ROLE_HEIR_KEY - ) - self.model().clear() - self.update_headers(self.__class__.headers) - - set_current = None - - for url, value in self._bal_parent.willexecutors_list.items(): - labels = [""] * len(self.Columns) - labels[self.Columns.URL] = url - if Willexecutors.is_selected(value): - - labels[self.Columns.SELECTED] = [ - read_QIcon_from_bytes( - self._bal_parent.bal_plugin.read_file("icons/confirmed.png") - ), - "", - ] - else: - labels[self.Columns.SELECTED] = "" - labels[self.Columns.BASE_FEE] = Util.decode_amount( - value.get("base_fee", 0), self.get_decimal_point() - ) - if str(value.get("status", 0)) == "200": - labels[self.Columns.STATUS] = [ - read_QIcon_from_bytes( - self._bal_parent.bal_plugin.read_file( - "icons/status_connected.png" - ) - ), - "", - ] - else: - labels[self.Columns.STATUS] = [ - read_QIcon_from_bytes( - self._bal_parent.bal_plugin.read_file("icons/unconfirmed.png") - ), - "", - ] - labels[self.Columns.ADDRESS] = str(value.get("address", "")) - labels[self.Columns.INFO] = str(value.get("info", "")) - - items = [] - for e in labels: - if isinstance(e, list): - try: - items.append(QStandardItem(*e)) - except Exception as e: - pass - else: - items.append(QStandardItem(e)) - items[self.Columns.SELECTED].setEditable(False) - items[self.Columns.URL].setEditable(True) - items[self.Columns.ADDRESS].setEditable(True) - items[self.Columns.INFO].setEditable(True) - items[self.Columns.BASE_FEE].setEditable(True) - items[self.Columns.STATUS].setEditable(False) - - items[self.Columns.URL].setData( - url, self.ROLE_HEIR_KEY + self.Columns.URL - ) - items[self.Columns.BASE_FEE].setData( - url, self.ROLE_HEIR_KEY + self.Columns.BASE_FEE - ) - items[self.Columns.INFO].setData( - url, self.ROLE_HEIR_KEY + self.Columns.INFO - ) - items[self.Columns.ADDRESS].setData( - url, self.ROLE_HEIR_KEY + self.Columns.ADDRESS - ) - row_count = self.model().rowCount() - self.model().insertRow(row_count, items) - if url == current_key: - idx = self.model().index(row_count, self.Columns.URL) - set_current = QPersistentModelIndex(idx) - self.set_current_idx(set_current) - self.filter() - except Exception as e: - _logger.error(f"error updating willexcutor {e}") - raise e - - - -class WillExecutorWidget(QWidget, MessageBoxMixin): - def __init__(self, parent, bal_window, willexecutors=None): - self.bal_window = bal_window - self.bal_plugin = bal_window.bal_plugin - self._bal_parent = parent - MessageBoxMixin.__init__(self) - QWidget.__init__(self, parent) - if willexecutors: - self.willexecutors_list = willexecutors - else: - self.willexecutors_list = Willexecutors.get_willexecutors(self.bal_plugin) - - self.size_label = QLabel() - self.will_executor_list_widget = WillExecutorListWidget(self) - - vbox = QVBoxLayout(self) - vbox.addWidget(self.size_label) - - widget = QWidget() - hbox = QHBoxLayout(widget) - hbox.addWidget(QLabel(_("Add transactions without willexecutor"))) - heir_no_willexecutor = BalCheckBox(self.bal_plugin.NO_WILLEXECUTOR) - hbox.addWidget(heir_no_willexecutor) - spacer_widget = QWidget() - spacer_widget.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - hbox.addWidget(spacer_widget) - vbox.addWidget(widget) - - vbox.addWidget(self.will_executor_list_widget) - buttonbox = QHBoxLayout() - - b = QPushButton(_("Add")) - b.clicked.connect(self.add) - buttonbox.addWidget(b) - - b = QPushButton(_("Download List")) - b.clicked.connect(self.download_list) - buttonbox.addWidget(b) - - b = QPushButton(_("Import")) - b.clicked.connect(self.import_file) - buttonbox.addWidget(b) - - b = QPushButton(_("Export")) - b.clicked.connect(self.export_file) - buttonbox.addWidget(b) - - b = QPushButton(_("Ping All")) - b.clicked.connect(self.update_willexecutors) - buttonbox.addWidget(b) - - vbox.addLayout(buttonbox) - # self.will_executor_list_widget.update() - - def add(self): - self.willexecutors_list["http://localhost:8080"] = { - "info": "New Will Executor", - "base_fee": 0, - "status": "-1", - } - self.will_executor_list_widget.update() - - def download_list(self, wes=None): - # Both this button and the wizard go through the same code path on - # BalWindow, which shows a "Downloading..." dialog (non-blocking GUI), - # tries the configured + fallback servers, logs the technical details - # and shows a simple message on failure. - def on_success(result): - self.willexecutors_list.update(result) - self.will_executor_list_widget.update() - Willexecutors.save(self.bal_window.bal_plugin, self.willexecutors_list) - self.update() - - self.bal_window.download_list(self.bal_window.willexecutors, on_success) - - def export_file(self, path): - export_meta_gui( - self.bal_window.window, "willexecutors.json", self.export_json_file - ) - - def export_json_file(self, path): - write_json_file(path, self.willexecutors_list) - - def import_file(self): - import_meta_gui( - self.bal_window.window, - _("willexecutors"), - self.import_json_file, - self.willexecutors_list.update, - ) - - def update_willexecutors(self, wes=None): - if not wes: - wes = self.willexecutors_list - self.bal_window.ping_willexecutors(wes, self.save_willexecutors) - - def import_json_file(self, path): - data = read_json_file(path) - data = self._validate(data) - self.willexecutors_list.update(data) - self.will_executor_list_widget.update() - - # TODO validate willexecutor json import file - def _validate(self, data): - return data - - def save_willexecutors(self, wes=None): - if not wes: - wes = self.willexecutors_list - self.willexecutors_list.update(wes) - self.will_executor_list_widget.update() - Willexecutors.save(self.bal_window.bal_plugin, self.willexecutors_list) - - diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py deleted file mode 100644 index d52e588..0000000 --- a/bal/gui/qt/plugin.py +++ /dev/null @@ -1,498 +0,0 @@ -""" -bal.gui.qt.plugin -================= - -The Qt entry point of the plugin. - -:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` and adds the -Electrum ``@hook`` methods that wire the plugin into the Qt GUI (status-bar -button, Tools menu, wallet load/close, settings dialog). Electrum instantiates -this class because the package ``manifest.json`` declares ``available_for: -["qt"]`` and the loader imports ``qt.py`` (a thin shim re-exporting this class). - -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 - - -def _window_key(window): - """Return a stable, hashable identity for an Electrum top-level window. - - The original code used ``window.winId`` (the *bound method*, not its - result) as a dict key. That happened to work because the same window - object yields the same bound method, but it is semantically wrong and - fragile across window re-creation / multiple wallets. ``id(window)`` is a - stable, correct identity for the lifetime of the window object. - """ - return id(window) - - -class Plugin(BalPlugin): - def __init__(self, parent, config, name): - _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): - # Called when the plugin is enabled, including *hot* (while a wallet is - # already open). The original code gave up here and asked the user to - # restart Electrum; instead we fully initialise the already-open - # window(s) so the plugin works immediately. - _logger.info("HOOK bal init qt") - try: - self.gui_object = gui_object - for window in gui_object.windows: - self._setup_window(window, load_open_wallet=True) - except Exception as e: - _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). - - This mirrors what the ``init_menubar`` + ``load_wallet`` hooks do at - normal startup, so enabling the plugin while a wallet is open no longer - requires restarting Electrum. - """ - w = self.get_window(window) - # Use Electrum's official tools_menu instead of searching the menubar - # for a menu whose *translated* title equals "&Tools" (which breaks - # under non-English locales). - tools_menu = getattr(window, "tools_menu", None) - if tools_menu is not None: - try: - w.init_menubar_tools(tools_menu) - except Exception as e: - _logger.error("init_qt: failed wiring tools menu: {}".format(e)) - if load_open_wallet and getattr(window, "wallet", None): - # Replicate load_wallet() for the wallet that is already open. - try: - w.wallet = window.wallet - w.init_will() - w.willexecutors = Willexecutors.get_willexecutors( - self, update=False, bal_window=w - ) - w.disable_plugin = False - w.ok = True - except Exception as e: - _logger.error("init_qt: failed initialising open wallet: {}".format(e)) - return w - - @hook - def create_status_bar(self, sb): - # 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 - - # 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): - _logger.debug("HOOK load wallet") - w = self.get_window(main_window) - # havetoupdate = Util.fix_will_settings_tx_fees(wallet.db) - w.wallet = wallet - w.init_will() - w.willexecutors = Willexecutors.get_willexecutors( - self, update=False, bal_window=w - ) - 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): - _logger.debug("HOOK close wallet") - # Iterate over a snapshot: on_close() may mutate the GUI/state. - for win in list(self.bal_windows.values()): - if getattr(win, "wallet", None) == wallet: - try: - win.on_close() - except Exception as e: - _logger.error("close_wallet: on_close failed: {}".format(e)) - - @hook - def init_keystore(self): - _logger.debug("init keystore") - - @hook - def daemon_wallet_loaded(self, boh, wallet): - _logger.debug("daemon wallet loaded") - - def get_window(self, window): - window = window.top_level_window() - key = _window_key(window) - w = self.bal_windows.get(key, None) - if w is None: - w = BalWindow(self, window) - self.bal_windows[key] = w - return w - - def requires_settings(self): - return True - - def settings_widget(self, window): - - w = self.get_window(window.window) - widget = QWidget() - enterbutton = EnterButton(_("Settings"), partial(w.settings_dialog, window)) - - widget.setLayout(Buttons(enterbutton, widget)) - return widget - - def password_dialog(self, msg=None, parent=None): - parent = parent or self - d = PasswordDialog(parent, msg) - return d.run() - - def get_seed(self): - password = None - if self.wallet.has_keystore_encryption(): - password = self.password_dialog(parent=self.d.parent()) - if not password: - raise UserCancelled() - - keystore = self.wallet.get_keystore() - if not keystore or not keystore.has_seed(): - return - self.extension = bool(keystore.get_passphrase(password)) - return keystore.get_seed(password) - - def settings_dialog(self, window=None, wallet=None): - - d = BalDialog(window, self, self.get_window_title("Settings")) - d.setMinimumSize(100, 200) - qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png")) - lbl_logo = QLabel() - lbl_logo.setPixmap(qicon) - - # heir_ping_willexecutors = BalCheckBox(self.PING_WILLEXECUTORS) - # heir_ask_ping_willexecutors = BalCheckBox(self.ASK_PING_WILLEXECUTORS) - # heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR) - - def on_multiverse_change(): - self.update_all() - - # heir_enable_multiverse = BalCheckBox(self.ENABLE_MULTIVERSE,on_multiverse_change) - - heir_hide_replaced = BalCheckBox(self.HIDE_REPLACED, on_multiverse_change) - - heir_hide_invalidated = BalCheckBox(self.HIDE_INVALIDATED, on_multiverse_change) - heir_repush = QPushButton("Rebroadcast transactions") - heir_repush.clicked.connect(partial(self.broadcast_transactions, True)) - bal_mode = QComboBox() - options = ["Easy", "Advanced", "Experimental"] - bal_mode.addItems(options) - - grid = QGridLayout(d) - add_widget( - grid, - "Hide Replaced", - heir_hide_replaced, - 1, - "Hide replaced transactions from will detail and list", - ) - add_widget( - grid, - "Hide Invalidated", - heir_hide_invalidated, - 2, - "Hide invalidated transactions from will detail and list", - ) - add_widget( - grid, - "Calendar App", - BalLineEdit(self.CALENDAR_APP), - 3, - "Default app used to open calendar", - ) - add_widget( - grid, - "Event summary", - BalLineEdit(self.EVENT_SUMMARY), - 4, - ( - "Default message to be used in event summary\n" - "Variables:\n" - " $wallet_name: name of wallet\n" - " $heirs_complete: list of heirs name,address,amount\n" - #" $will_details_complete: will details(id transaction, mining fees, willexecutor, willexecutor fees, locktime)\n" - ) - ) - add_widget( - grid, - "Event sescription", - BalTextEdit(self.EVENT_DESCRIPTION), - 5, - ( - "Default message to be used in event description\n" - "Variables:\n" - " $wallet_name: name of wallet\n" - " $heirs_complete: list of heirs name,address,amount\n" - #" $will_details_complete: will details(id transaction, mining fees, willexecutor, willexecutor fees, locktime)\n" - ) - ) - #add_widget(grid, "Bal Mode", bal_mode, 4, "choose bal mode") - - # add_widget( - # grid, - # "Ping Willexecutors", - # heir_ping_willexecutors, - # 3, - # "Ping willexecutors to get payment info before compiling will", - # ) - # add_widget( - # grid, - # " - Ask before", - # heir_ask_ping_willexecutors, - # 4, - # "Ask before to ping willexecutor", - # ) - # add_widget( - # grid, - # "Backup Transaction", - # heir_no_willexecutor, - # 5, - # "Add transactions without willexecutor", - # ) - # add_widget(grid,"Enable Multiverse(EXPERIMENTAL/BROKEN)",heir_enable_multiverse,6,"enable multiple locktimes, will import.... ") - grid.addWidget(heir_repush, 7, 0) - grid.addWidget( - HelpButton( - "Broadcast all transactions to willexecutors including those already pushed" - ), - 7, - 2, - ) - - if ret := bool(show_modal(d)): - try: - self.update_all() - return ret - except Exception: - pass - return False - - def broadcast_transactions(self, force): - for _k, w in self.bal_windows.items(): - w.broadcast_transactions(force) - - def update_all(self): - for _k, w in self.bal_windows.items(): - w.update_all() - - def get_window_title(self, title): - return _("BAL - ") + _(title) - - diff --git a/bal/gui/qt/theme.py b/bal/gui/qt/theme.py deleted file mode 100644 index 33951a4..0000000 --- a/bal/gui/qt/theme.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -bal.gui.qt.theme -================ - -Pure presentation helpers for the Qt layer. - -This is where colours and other look-and-feel decisions live, kept apart from -the core inheritance logic. In particular it hosts :func:`status_color`, which -used to be ``WillItem.get_color()`` inside ``will.py``. - -The status flags themselves are computed by the core layer -(:class:`bal.core.will.WillItem`); this module only translates a will item's -status into a colour for the transaction list / detail views. -""" - -# Status -> hex colour. The first matching status (checked in priority order) -# wins. These are exactly the colours the original ``WillItem.get_color`` used, -# so the GUI looks identical after the refactor. -# -# The order matters: e.g. an INVALIDATED tx must show orange even if it also -# carries other flags, so INVALIDATED is checked before everything else. -_STATUS_COLOR_PRIORITY = ( - ("INVALIDATED", "#f87838"), # orange - tx can no longer be mined - ("REPLACED", "#ff97e9"), # pink - superseded by another tx - ("CONFIRMED", "#bfbfbf"), # grey - already mined - ("PENDING", "#ffce30"), # yellow - in mempool, waiting -) - -# Default colour used when no status in the priority list matches. -_DEFAULT_COLOR = "#ffffff" - - -def status_color(will_item) -> str: - """Return the display colour (``"#rrggbb"``) for a :class:`WillItem`. - - This is a faithful, behaviour-preserving port of the old - ``WillItem.get_color()`` method. The slightly irregular handling of the - push/check states (which is not a simple priority list) is reproduced - exactly as in the original code. - """ - # First, the simple priority-ordered statuses. - for status, color in _STATUS_COLOR_PRIORITY: - if will_item.get_status(status): - return color - - # The remaining states need the original branching because of the - # CHECK_FAIL / CHECKED interaction. - if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"): - return "#e83845" # red - server check failed - elif will_item.get_status("CHECKED"): - return "#8afa6c" # green - server confirmed it stored the tx - elif will_item.get_status("PUSH_FAIL"): - return "#e83845" # red - failed to push to will-executor - elif will_item.get_status("PUSHED"): - return "#73f3c8" # teal - pushed to will-executor - elif will_item.get_status("COMPLETE"): - 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/widgets.py b/bal/gui/qt/widgets.py deleted file mode 100644 index 997d8b5..0000000 --- a/bal/gui/qt/widgets.py +++ /dev/null @@ -1,865 +0,0 @@ -""" -bal.gui.qt.widgets -================== - -Reusable, self-contained Qt widgets used to build the BAL tabs and dialogs. - -These are "leaf" widgets: they receive the :class:`BalWindow` controller (and -any data they need) as constructor arguments at runtime, so this module does -not import ``window``/``dialogs`` and therefore introduces no import cycles. - -Contents: - * ClickableLabel, BalLineEdit, BalTextEdit, BalCheckBox - thin Qt wrappers - * BalTxFeesWidget - fee-rate editor - * _LockTimeEditor + BalTimeEditWidget + raw/date editors - locktime editing - * ThresholdTimeWidget / LockTimeWidget - threshold & locktime - * WillSettingsWidget - the settings panel - * PercAmountEdit - amount-or-percentage editor - * WillWidget - single will-tx box -""" - -from .common import * -from .common import _, _logger # underscore names are not re-exported by "import *" -from .calendar import BalCalendar - - -class ClickableLabel(QLabel): - doubleClicked = pyqtSignal() - - def mouseDoubleClickEvent(self, event): - self.doubleClicked.emit() - super().mouseDoubleClickEvent(event) - - - -class BalTxFeesWidget(QWidget): - valueChanged = pyqtSignal() - current_value = None - - def __init__(self, bal_window, parent, value=None): - super().__init__(parent) - self.bal_window = bal_window - layout = QHBoxLayout(self) - self.txfee_widget = QSpinBox(self) - self.txfee_widget.setMinimum(1) - self.txfee_widget.setMaximum(10000) - value = ( - value - if value - else self.bal_window.bal_plugin.WILL_SETTINGS.get()["baltx_fees"] - ) - self.set_value(value) - self.default_value = self.bal_window.bal_plugin.default_will_settings()[ - "baltx_fees" - ] - self.txfee_widget.valueChanged.connect(self.on_heir_tx_fees) - #label = ClickableLabel("$") - #label.doubleClicked.connect(self.doubleclick) - #layout.addWidget(label) - button = HelpButton(_("mining fees expressed in sats/vbyte to be used in the Bitcoin transaction.\nHigher value ensure your transaction will be confirmed")) - button.setText("丰") - button.setStyleSheet("font-size: 16px;") - layout.addWidget(button) - layout.addWidget(self.txfee_widget) - - 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() - - def set_value(self, value, emit=True): - value = int(value) if value is not None else 20 - if getattr(self, "_updating", False): - return - - self._updating = True - try: - self.current_value = value - spin = self.txfee_widget - spin.blockSignals(True) - spin.setValue(value) - spin.blockSignals(False) - - finally: - self._updating = False - - if emit: - spin.valueChanged.emit(value) - - def on_heir_tx_fees(self, value=None, update_all=True): - if value != self.current_value: - try: - self.set_value(value) - if update_all: - self.bal_window.update_setting_widgets( - self.get_value(), "baltx_fees", True - ) - except Exception as e: - _logger.error(f"error while trying to update txfees{e}") - log_error(e) - else: - pass - - - -class _LockTimeEditor: - min_allowed_value = NLOCKTIME_MIN - max_allowed_value = NLOCKTIME_MAX - alarm = None - - def get_value(self) -> Optional[int]: - raise NotImplementedError() - - def set_value(self, x: Any, force=True) -> None: - raise NotImplementedError() - - @classmethod - def is_acceptable_locktime(cls, x: Any) -> bool: - if not x: # e.g. empty string - return True - try: - x = int(x) - except Exception as _e: - return False - return cls.min_allowed_value <= x <= cls.max_allowed_value - - @staticmethod - def get_max_allowed_timestamp() -> int: - ts = NLOCKTIME_MAX - # Test if this value is within the valid timestamp limits (which is platform-dependent). - # see #6170 - try: - datetime.fromtimestamp(ts) - except (OSError, OverflowError): - ts = 2**31 - 1 # INT32_MAX - datetime.fromtimestamp(ts) # test if raises - return ts - - - -class BalTimeEditWidget(QWidget, _LockTimeEditor): - valueEdited = pyqtSignal() - _setting_locktime = False - current_value = None - current_index = None - default_value = None - - help_text = ( - "if you choose Raw, you can insert various options based on suffix:\n" - + " - d: number of days after current day(ex: 1d means tomorrow)\n" - + " - 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): - super().__init__(parent) - self.bal_window = bal_window - - hbox = QHBoxLayout() - self.setLayout(hbox) - hbox.setContentsMargins(0, 0, 0, 0) - hbox.setSpacing(0) - self.setMinimumWidth(40 * char_width_in_lineedit()) - self.locktime_raw_e = TimeRawEditWidget(self, time_edit=self) - self.locktime_date_e = LockTimeDateEdit(self, time_edit=self) - self.editors = [self.locktime_raw_e, self.locktime_date_e] - self.combo = QComboBox() - options = [_("Raw"), _("Date")] - self.option_index_to_editor_map = { - 0: self.locktime_raw_e, - 1: self.locktime_date_e, - } - self.combo.addItems(options) - default_index = 0 - if not default_locktime: - default_locktime = self.bal_window.bal_plugin.WILL_SETTINGS.get()[self.base_field] - try: - int(default_locktime) - default_index = 1 - except Exception: - default_index = 0 - #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) - - for w in self.editors: - w.setVisible(False) - w.setEnabled(False) - - self.editor = self.option_index_to_editor_map[default_index] - self.editor.setVisible(True) - self.editor.setEnabled(True) - self.set_index(default_index) - #self.on_current_index_changed(default_index) - self.set_value(default_locktime) - self.current_value=default_locktime - hbox.addWidget(self.combo) - for w in self.editors: - hbox.addWidget(w) - - hbox.addStretch(1) - # spssscer_widget = QWidget() - # spacer_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - # hbox.addWidget(spacer_widget) - self.valueEdited.connect(lambda: self.update_will_settings(True)) - self.locktime_raw_e.editingFinished.connect(self.valueEdited.emit) - self.locktime_date_e.dateTimeChanged.connect(self.valueEdited.emit) - #self.combo.currentIndexChanged.connect(self.valueEdited.emit) - - def update_will_settings( - self, - update_all=False, - update_will_dialog=False, - update_heirs_dialog=False, - ): - self.bal_window.update_setting_widgets( - self.get_value(), - self.base_field, - update_all, - update_will_dialog, - update_heirs_dialog, - ) - - def on_current_index_changed(self, i): - self.current_index = i - for w in self.editors: - w.setVisible(False) - w.setEnabled(False) - # prev_locktime = self.editor.get_value() - self.editor = self.option_index_to_editor_map[i] - if i==0: - self.editor.set_value(self.bal_window.bal_plugin.default_will_settings_relative()[self.base_field]) - else: - self.editor.set_value(self.bal_window.bal_plugin.default_will_settings_absolute()[self.base_field]) - self.valueEdited.emit() - # if self.editor.is_acceptable_locktime(prev_locktime): - # self.editor.set_value(prev_locktime, force=False) - self.editor.setVisible(True) - self.editor.setEnabled(True) - self.bal_window.update_combo_setting_widgets(i, self.base_field,True) - - def get_value(self) -> Optional[str]: - val = self.editor.get_value() - #return self.current_value - return val - - def set_index(self, index): - if self.current_index != index: - self.combo.setCurrentIndex(index) - #self.on_current_index_changed(index, force) - - def set_value( - self, - x: Any, - force=None, - update_all=False, - update_will_dialog=False, - update_heirs_dialog=False, - ) -> None: - if not x: - if self.current_index == 0: - x = self.bal_window.bal_plugin.default_will_settings_relative()[self.base_field] - elif self.current_index == 1: - x = self.bal_window.bal_plugin.default_will_settings_absolute()[self.base_field] - if x != self.get_value(): - self.editor.set_value(x) - 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): - editingFinished = pyqtSignal() - - def is_acceptable_locktime(self, value): - return True - - def __init__(self, parent, time_edit=None): - super().__init__(parent) - self.editor = LockTimeRawEdit(parent, time_edit) - self.label = QLabel("") - self.label.setFixedWidth(10 * char_width_in_lineedit()) - self.layout = QHBoxLayout(self) - self.layout.addWidget(self.editor) - self.layout.addWidget(self.label) - self.editor.editingFinished.connect(self.editingFinished.emit) - 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): - def __init__(self, parent=None, time_edit=None): - QLineEdit.__init__(self, parent) - self.setFixedWidth(12 * char_width_in_lineedit()) - self.textChanged.connect(self.numbify) - self.isdays = False - self.isyears = False - self.isblocks = False - self.time_edit = time_edit - - @staticmethod - def replace_str(text): - return str(text).replace("d", "").replace("y", "").replace("b", "") - - def checkbdy(self, s, pos, appendix): - try: - charpos = pos - 1 - charpos = max(0, charpos) - charpos = min(len(s) - 1, charpos) - if appendix == s[charpos]: - s = self.replace_str(s) + appendix - pos = charpos - except Exception: - pass - return pos, s - - def numbify(self): - text = self.text().strip() - # chars = '0123456789bdy' removed the option to choose locktime by block - chars = "0123456789dy" - pos = self.cursorPosition() - pos = len("".join([i for i in text[:pos] if i in chars])) - s = "".join([i for i in text if i in chars]) - self.isdays = False - self.isyears = False - self.isblocks = False - - pos, s = self.checkbdy(s, pos, "d") - pos, s = self.checkbdy(s, pos, "y") - pos, s = self.checkbdy(s, pos, "b") - - if "d" in s: - self.isdays = True - if "y" in s: - self.isyears = True - if "b" in s: - self.isblocks = True - - if self.isdays: - s = self.replace_str(s) + "d" - if self.isyears: - s = self.replace_str(s) + "y" - if self.isblocks: - s = self.replace_str(s) + "b" - self.blockSignals(True) - self.setText(s) - self.blockSignals(False) - # self.set_value(s, force=False) - self.current_value = s - # setText sets Modified to False. Instead we want to remember - # if updates were because of user modification. - self.setModified(self.hasFocus()) - self.setCursorPosition(pos) - - def get_value(self) -> Optional[str]: - try: - return str(self.text()) - except Exception: - return None - - def set_value(self, x: Any, force=True) -> None: - if x != self.get_value(): - self.blockSignals(True) - self.setText(str(x)) - self.blockSignals(False) - self.numbify() - - - -class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor): - min_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX + 1 - max_allowed_value = _LockTimeEditor.get_max_allowed_timestamp() - - def __init__(self, parent=None, time_edit=None): - QDateTimeEdit.__init__(self, parent) - self.setMinimumDateTime(datetime.fromtimestamp(self.min_allowed_value)) - self.setMaximumDateTime(datetime.fromtimestamp(self.max_allowed_value)) - #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())) - #p# - #dt = dt_edit.dateTime() - ## QDateTimets = dt.toSecsSinceEpoch() - - dt = self.dateTime() - _ts = dt.toSecsSinceEpoch() - - return _ts - - - def set_value(self, x: Any, force=False) -> None: - if not self.is_acceptable_locktime(x): - self.setDateTime(QDateTime.currentDateTime()) - return - try: - x = int(x) - except Exception as e: - x = QDateTime.currentDateTime().timestamp() - finally: - # Use the overflow-safe converter: on Windows datetime.fromtimestamp - # raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX). - _dt = BalTimestamp._safe_fromtimestamp(x) - #if self.alarm != dt: - self.setDateTime(_dt) - self.alarm = _dt - - - -class ThresholdTimeWidget(BalTimeEditWidget): - # rich_text=True is used by the HelpButton, so HTML tags (,
) render. - 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" - tooltip_text = "Check Alive" - base_field = "threshold" - - def __init__(self, bal_window, parent, init_value=None): - if init_value is None: - init_value = bal_window.bal_plugin.WILL_SETTINGS.get()["threshold"] - super().__init__(bal_window, parent, init_value) - self.default_value = self.bal_window.bal_plugin.default_will_settings()[ - "threshold" - ] - - - -class LockTimeWidget(BalTimeEditWidget): - # rich_text=True is used by the HelpButton, so HTML tags (,
) render. - 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" - tooltip_text = "Delivery time" - base_field = "locktime" - - def __init__(self, bal_window, parent, init_value=None): - if init_value is None: - init_value = bal_window.bal_plugin.WILL_SETTINGS.get()["locktime"] - super().__init__(bal_window, parent, init_value) - self.default_value = self.bal_window.bal_plugin.default_will_settings()[ - "locktime" - ] - - - -class WillSettingsWidget(QWidget): - - 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() - self.calendar_button.setIcon( - read_QIcon_from_bytes( - 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) - self.widgets["locktime"].valueEdited.connect(self.on_locktime_change) - self.widgets["threshold"].valueEdited.connect(self.on_locktime_change) - # self.widgets['baltx_fees'].valueChange.connect(self.bal_window.update_setting_widgets) - self.on_locktime_change() - self.widgets["baltx_fees"] = BalTxFeesWidget(bal_window, self) - if not hasattr(bal_window, "txfee_widgets"): - bal_window.txfee_widgets = [] - - w = self.widgets["baltx_fees"] - if w not in bal_window.txfee_widgets: - bal_window.txfee_widgets.append(w) - box.addWidget(self.widgets["locktime"]) - box.addWidget(self.widgets["threshold"]) - 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 = [] - for i in range(1, days): - lines.extend( - [ - "BEGIN:VALARM", - f"TRIGGER;RELATED=END:-P{i}D", - "ACTION:DISPLAY", - # f"DESCRIPTION:{self.bal_window.bal_plugin.ALARM_DESCRIPTION.get()}", - "END:VALARM", - ] - ) - return lines - - def open_or_save_calendar(self): - now = BalCalendar.format_time(datetime.now()) - - locktime = self.widgets["locktime"].alarm - threshold = self.widgets["threshold"].alarm - alarm_end = BalCalendar.format_time(locktime) - alarm_start = BalCalendar.format_time(threshold) - days_difference = (locktime - threshold).days - - heirs_details = "\r\n".join(f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}" for heir in self.bal_window.heirs) - event_description = BalCalendar.ical_escape( - f"{self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()}".replace("$wallet_name",str(self.bal_window.wallet)).replace("$heirs_complete",heirs_details) - ) - #event_description =f"{event_description}{heirs_details}" - uid = f"bal-{str(self.bal_window.wallet)}" - summary = BalCalendar.ical_escape( - f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}".replace("$wallet_name",str(self.bal_window.wallet)) - ) - lines = [ - "BEGIN:VCALENDAR", - "VERSION:2.0", - f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}", - "BEGIN:VEVENT", - f"UID:{uid}", - f"DTSTAMP:{now}", - f"DTSTART:{alarm_end}", - f"DTEND:{alarm_end}", - f"SUMMARY:{summary}", - f"DESCRIPTION:{event_description}", - ] - lines.extend(self.create_alarms(threshold, locktime)) - lines.extend([ - "END:VEVENT", - "END:VCALENDAR", - ]) - - lines = [s.rstrip("\r\n") for s in lines] - ics_content = "\r\n".join(lines) + "\r\n" - self.temp_path = BalCalendar.write_temp_ics(ics_content) - opened = BalCalendar.open_with_default_app( - self.bal_window.bal_plugin.CALENDAR_APP.get(), self.temp_path - ) - if opened: - _logger.info(f"File opened with default app: {self.temp_path}") - else: - export_meta_gui( - self.bal_window.window, f"will_event.ics",self.save_to_cwd - - ) - - - def save_to_cwd(self,filename="event.ics"): - target = os.path.abspath(filename) - # se il file esiste, sovrascrive - _logger.debug(f"save_to_cwd {self.temp_path},{filename}") - with open(self.temp_path, "rb") as src, open(target, "wb") as dst: - dst.write(src.read()) - return target - - def on_locktime_change(self): - locktime = self.widgets["locktime"].get_value() - threshold = self.widgets["threshold"].get_value() - locktime = BalTimestamp(locktime) - threshold = BalTimestamp(threshold) - - min_locktime = min( - Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX), - locktime.to_timestamp(), - ) - td = threshold.to_date(min_locktime, True) - self.widgets["threshold"].alarm=td - self.bal_window.will_settings["real_threshold"]=td.timestamp() - try: - self.widgets["threshold"].editor.label.setText(td.strftime("%Y-%m-%d")) - except Exception as _e: - pass - - td = locktime.to_date() - alarm = BalTimestamp(min_locktime).to_date() - self.widgets["locktime"].alarm=alarm - self.bal_window.will_settings["real_locktime"]=td.timestamp() - try: - self.widgets["locktime"].editor.label.setText(td.strftime("%Y-%m-%d")) - except Exception as _e: - pass - - - -class PercAmountEdit(BTCAmountEdit): - def __init__(self, decimal_point, is_int=False, parent=None, *, max_amount=None): - super().__init__(decimal_point, is_int, parent, max_amount=max_amount) - - def numbify(self): - text = self.text().strip() - if text == "!": - self.shortcut.emit() - return - pos = self.cursorPosition() - chars = "0123456789%" - chars += DECIMAL_POINT - - s = "".join([i for i in text if i in chars]) - - if "%" in s: - self.is_perc = True - s = s.replace("%", "") - else: - self.is_perc = False - - if DECIMAL_POINT in s: - p = s.find(DECIMAL_POINT) - s = s.replace(DECIMAL_POINT, "") - s = s[:p] + DECIMAL_POINT + s[p : p + 8] - if self.is_perc: - s += "%" - - self.setText(s) - self.setModified(self.hasFocus()) - self.setCursorPosition(pos) - - def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]: - try: - text = text.replace(DECIMAL_POINT, ".") - text = text.replace("%", "") - return (Decimal)(text) - except Exception: - return None - - def _get_text_from_amount(self, amount): - out = super()._get_text_from_amount(amount) - if self.is_perc: - out += "%" - return out - - def paintEvent(self, event): - QLineEdit.paintEvent(self, event) - if self.base_unit: - panel = QStyleOptionFrame() - self.initStyleOption(panel) - textRect = self.style().subElementRect( - QStyle.SubElement.SE_LineEditContents, panel, self - ) - textRect.adjust(2, 0, -10, 0) - painter = QPainter(self) - painter.setPen(ColorScheme.GRAY.as_color()) - if len(self.text()) == 0: - painter.drawText( - textRect, - int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter), - self.base_unit() + " or perc value", - ) - - - -class BalLineEdit(QLineEdit): - def __init__(self,variable): - QLineEdit.__init__(self) - self.setText(variable.get()) - def on_edit(): - variable.set(self.text()) - self.editingFinished.connect(on_edit) - - -class BalTextEdit(QTextEdit): - def __init__(self,variable): - QTextEdit.__init__(self) - self.setPlainText(variable.get()) - def on_edit(): - variable.set(self.toPlainText()) - self.textChanged.connect(on_edit) - - -class BalCheckBox(QCheckBox): - def __init__(self, variable, on_click=None): - QCheckBox.__init__(self) - self.setChecked(variable.get()) - self.on_click = on_click - - def on_check(v): - variable.set(v == 2) - #variable.get() - if self.on_click: - self.on_click() - - self.stateChanged.connect(on_check) - - - -class WillWidget(QWidget): - def __init__(self, father=None, parent=None): - super().__init__() - vlayout = QVBoxLayout() - self.setLayout(vlayout) - self.will = parent.bal_window.willitems - self._bal_parent = parent - for w in self.will: - if ( - self.will[w].get_status("REPLACED") - and self._bal_parent.bal_window.bal_plugin._hide_replaced - ): - continue - if ( - self.will[w].get_status("INVALIDATED") - and self._bal_parent.bal_window.bal_plugin._hide_invalidated - ): - continue - f = self.will[w].father - if father == f: - qwidget = QWidget() - # childWidget = QWidget() - hlayout = QHBoxLayout(qwidget) - qwidget.setLayout(hlayout) - vlayout.addWidget(qwidget) - detailw = QWidget() - detaillayout = QVBoxLayout() - detailw.setLayout(detaillayout) - - willpushbutton = QPushButton(w) - - willpushbutton.clicked.connect( - partial(self._bal_parent.bal_window.show_transaction, txid=w) - ) - detaillayout.addWidget(willpushbutton) - locktime = str(BalTimestamp(self.will[w].tx.locktime)) - creation = str(BalTimestamp(self.will[w].time)) - - def qlabel(title, value): - label = "" + _(str(title)) + f":\t{str(value)}" - return QLabel(label) - - detaillayout.addWidget(qlabel("Locktime", locktime)) - detaillayout.addWidget(qlabel("Creation Time", creation)) - try: - total_fees = ( - self.will[w].tx.input_value() - self.will[w].tx.output_value() - ) - except Exception: - total_fees = -1 - decoded_fees = total_fees - fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3) - fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)" - detaillayout.addWidget(qlabel("Transaction fees:", fees_str)) - detaillayout.addWidget(qlabel("Status:", self.will[w].status)) - detaillayout.addWidget(QLabel("")) - detaillayout.addWidget(QLabel("Heirs:")) - for heir in self.will[w].heirs: - if 'w!ll3x3c"' not in heir: - decoded_amount = Util.decode_amount( - self.will[w].heirs[heir][3], self._bal_parent.decimal_point - ) - detaillayout.addWidget( - qlabel( - heir, f"{decoded_amount} {self._bal_parent.base_unit_name}" - ) - ) - if self.will[w].we: - detaillayout.addWidget(QLabel("")) - detaillayout.addWidget(QLabel(_("Willexecutor: ``_setup_window``) for the same window, e.g. when - # Electrum restarts with the plugin already enabled. Calling - # ``init_menubar_tools`` twice would add the Heirs/Will tabs and the - # menu actions twice, producing the garbled/condensed menu entry. - self._menubar_initialized = False - self.bal_plugin.get_decimal_point = self.window.get_decimal_point - - if self.window.wallet: - self.wallet = self.window.wallet - if not self.will_settings: - self.will_settings = self.bal_plugin.WILL_SETTINGS.get() - Util.fix_will_settings_tx_fees(self.will_settings) - self.heirs = Heirs(self.wallet) - - self.heirs_tab = self.create_heirs_tab() - self.will_tab = self.create_will_tab() - self.heirs_tab.wallet = self.wallet - self.will_tab.wallet = self.wallet - - def init_menubar_tools(self, tools_menu): - # Idempotent: only wire the tabs + menu actions once per window. - # A second call (e.g. init_menubar hook *and* the hot-init path both - # firing) would otherwise duplicate the Heirs/Will tabs and the - # Will-Executors / toggle actions, which Qt renders as a broken, - # condensed menu entry under the Electrum logo. - if self._menubar_initialized: - _logger.info("init_menubar_tools: already initialised, skipping") - return - self._menubar_initialized = True - self.tools_menu = tools_menu - - def add_optional_tab(tabs, tab, icon, description): - tab.tab_icon = icon - tab.tab_description = description - tab.tab_pos = len(tabs) - if tab.is_shown_cv.get(): - tabs.addTab(tab, icon, description.replace("&", "")) - - def add_toggle_action(tab): - is_shown = tab.is_shown_cv.get() - tab.menu_action = self.window.view_menu.addAction( - tab.tab_description, lambda: self.window.toggle_tab(tab) - ) - tab.menu_action.setCheckable(True) - tab.menu_action.setChecked(is_shown) - - add_optional_tab( - self.window.tabs, - self.heirs_tab, - read_QIcon_from_bytes(self.bal_plugin.read_file("icons/heir.png")), - _("&Heirs"), - ) - add_optional_tab( - self.window.tabs, - self.will_tab, - read_QIcon_from_bytes(self.bal_plugin.read_file("icons/will.png")), - _("&Will"), - ) - tools_menu.addSeparator() - self.tools_menu.willexecutors_action = tools_menu.addAction( - _("&Will-Executors"), self.show_willexecutor_dialog - ) - self.window.view_menu.addSeparator() - add_toggle_action(self.heirs_tab) - add_toggle_action(self.will_tab) - - def load_willitems(self): - self.willitems = {} - for wid, w in self.will.items(): - self.willitems[wid] = WillItem(w, wallet=self.wallet) - if self.willitems: - self.will_list_widget.will = self.willitems - self.will_list_widget.update_will(self.willitems) - self.will_tab.update() - - def save_willitems(self): - keys = list(self.will.keys()) - for k in keys: - del self.will[k] - for wid, w in self.willitems.items(): - self.will[wid] = w.to_dict() - - def init_will(self): - _logger.info("********************init_____will____________**********") - if not self.willexecutors: - self.willexecutors = Willexecutors.get_willexecutors( - self.bal_plugin, update=False, bal_window=self - ) - if not self.heirs: - self.heirs = Heirs._validate(Heirs(self.wallet)) - self.heirs_tab.update() - if not self.will: - self.will = self.wallet.db.get_dict("will") - Util.fix_will_tx_fees(self.will) - if self.will: - self.willitems = {} - try: - self.load_willitems() - except Exception: - self.disable_plugin = True - self.show_warning( - _("Please restart Electrum to activate the BAL plugin"), - title=_("Success"), - ) - self.close_wallet() - return - - # if not self.will_settings: - # self.will_settings = self.wallet.db.get_dict("will_settings") - # Util.fix_will_settings_tx_fees(self.will_settings) - - # _logger.info("will_settings: {}".format(self.will_settings)) - # if not self.will_settings: - # Util.copy(self.will_settings, self.bal_plugin.default_will_settings()) - # _logger.debug("not_will_settings {}".format(self.will_settings)) - # self.bal_plugin.validate_will_settings(self.will_settings) - # self.heir_list_widget.update_will_settings() - # self.heir_list_widget.update() - - def init_wizard(self): - wizard_dialog = BalWizardDialog(self) - wizard_dialog.exec() - - def show_willexecutor_dialog(self): - self.willexecutor_dialog = WillExecutorDialog(self) - # Keep it in front of Electrum (window-modal) instead of letting it - # fall behind the main window. - show_on_top(self.willexecutor_dialog) - - def create_heirs_tab(self): - if not self.heirs: - self.heirs = Heirs(self.wallet) - self.heir_list_widget = HeirListWidget(self, self.window) - tab = self.window.create_list_tab(self.heir_list_widget) - tab.is_shown_cv = shown_cv(False) - return tab - - def create_will_tab(self): - self.will_list_widget = PreviewList(self, self.window, None) - tab = self.window.create_list_tab(self.will_list_widget) - tab.is_shown_cv = shown_cv(True) - return tab - - def new_heir_dialog(self, heir_key=None): - heir = self.heirs.get(heir_key) - title = "New heir" - if heir: - title = f"Edit: {heir_key}" - - d = BalDialog( - self.window, self.bal_plugin, self.bal_plugin.get_window_title(_(title)) - ) - - vbox = QVBoxLayout(d) - grid = QGridLayout() - - heir_name = QLineEdit() - heir_name.setFixedWidth(32 * char_width_in_lineedit()) - heir_address = QLineEdit() - heir_address.setFixedWidth(32 * char_width_in_lineedit()) - heir_amount = PercAmountEdit(self.window.get_decimal_point) - - if heir: - heir_name.setText(str(heir_key)) - heir_address.setText(str(heir[0])) - heir_amount.setText( - str(Util.decode_amount(heir[1], self.window.get_decimal_point())) - ) - self.heir_locktime = LockTimeWidget(self, self.window, heir[2]) - - # heir_is_xpub = QCheckBox() - - new_heir_button = QPushButton(_("Add another heir")) - self.add_another_heir = False - - def new_heir(): - self.add_another_heir = True - d.accept() - - new_heir_button.clicked.connect(new_heir) - new_heir_button.setDefault(True) - - grid.addWidget(QLabel(_("Name")), 1, 0) - grid.addWidget(heir_name, 1, 1) - grid.addWidget(HelpButton(_("Unique name or description about heir")), 1, 2) - - grid.addWidget(QLabel(_("Address")), 2, 0) - grid.addWidget(heir_address, 2, 1) - grid.addWidget(HelpButton(_("heir bitcoin address")), 2, 2) - - grid.addWidget(QLabel(_("Amount")), 3, 0) - grid.addWidget(heir_amount, 3, 1) - grid.addWidget(HelpButton(_("Fixed or Percentage amount if end with %")), 3, 2) - - locktime_label = QLabel(_("Locktime")) - enable_multiverse = self.bal_plugin.ENABLE_MULTIVERSE.get() - if enable_multiverse: - grid.addWidget(locktime_label, 4, 0) - grid.addWidget(self.heir_locktime, 4, 1) - grid.addWidget(HelpButton(_("locktime")), 4, 2) - - vbox.addLayout(grid) - buttons = [CancelButton(d), OkButton(d)] - if not heir: - buttons.append(new_heir_button) - vbox.addLayout(Buttons(*buttons)) - while d.exec(): - # TODO SAVE HEIR - heir = [ - heir_name.text(), - heir_address.text(), - Util.encode_amount(heir_amount.text(), self.window.get_decimal_point()), - str(self.will_settings["locktime"]), - ] - try: - self.set_heir(heir) - if self.add_another_heir: - self.new_heir_dialog() - break - except Exception as e: - self.show_error(str(e)) - - def set_heir(self, heir): - heir = list(heir) - if not self.bal_plugin.ENABLE_MULTIVERSE.get(): - heir[3] = self.will_settings["locktime"] - - h = Heirs.validate_heir(heir[0], heir[1:]) - self.heirs[heir[0]] = h - self.heir_list_widget.update() - return True - - def delete_heirs(self, heirs): - for heir in heirs: - try: - del self.heirs[heir] - except Exception as e: - _logger.debug(f"error deleting heir: {heir} {e}") - pass - self.heirs.save() - self.heir_list_widget.update() - return True - - def import_heirs(self): - import_meta_gui( - self.window, - _("heirs"), - self.heirs.import_file, - self.heir_list_widget.update, - ) - - def export_heirs(self): - export_meta_gui(self.window, "heirs.json", self.heirs.export_file) - - def prepare_will(self, ignore_duplicate=False, keep_original=False): - will = self.build_inheritance_transaction( - ignore_duplicate=ignore_duplicate, keep_original=keep_original - ) - return will - - def delete_not_valid(self, txid, s_utxo): - raise NotImplementedError() - - def update_will(self, will): - Will.update_will(self.willitems, will) - self.willitems.update(will) - Will.normalize_will(self.willitems, self.wallet) - - def build_will(self, ignore_duplicate=True, keep_original=True): - _logger.debug("building will...") - will = {} - # willtodelete = [] - # willtoappend = {} - try: - self.willexecutors = Willexecutors.get_willexecutors( - self.bal_plugin, update=False, bal_window=self - ) - if not self.no_willexecutor: - - f = False - for _u, w in self.willexecutors.items(): - if Willexecutors.is_selected(w): - f = True - if not f: - _logger.error("No Will-Executor or backup transaction selected") - raise NoWillExecutorNotPresent( - "No Will-Executor or backup transaction selected" - ) - txs = self.heirs.get_transactions( - self.bal_plugin, - self.window.wallet, - self.will_settings["baltx_fees"], - None, - self.date_to_check, - ) - - _logger.info(f"txs built: {txs}") - creation_time = time.time() - if txs: - for txid in txs: - # txtodelete = [] - _break = False - tx = {} - tx["tx"] = txs[txid] - tx["my_locktime"] = txs[txid].my_locktime - tx["heirsvalue"] = txs[txid].heirsvalue - tx["description"] = txs[txid].description - tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor) - tx["status"] = _("New") - tx["baltx_fees"] = txs[txid].tx_fees - tx["time"] = creation_time - tx["heirs"] = copy.deepcopy(txs[txid].heirs) - tx["txchildren"] = [] - will[txid] = WillItem(tx, _id=txid, wallet=self.wallet) - self.update_will(will) - else: - _logger.info("No transactions was built") - _logger.info(f"will-settings: {self.will_settings}") - _logger.info(f"date_to_check:{self.date_to_check}") - _logger.info(f"heirs: {self.heirs}") - return {} - except Exception as e: - _logger.info(f"Exception build_will: {e}") - raise e - pass - return self.willitems - - def check_will(self): - return Will.is_will_valid( - self.willitems, - self.block_to_check, - self.date_to_check, - self.will_settings["baltx_fees"], - self.window.wallet.get_utxos(), - heirs=self.heirs, - willexecutors=self.willexecutors, - self_willexecutor=self.no_willexecutor, - wallet=self.wallet, - callback_not_valid_tx=self.delete_not_valid, - ) - - def show_message(self, text): - self.window.show_message(text) - - def show_warning(self, text, parent=None): - self.window.show_warning(text, parent=None) - - def show_error(self, text): - self.window.show_error(text) - - def show_critical(self, text): - self.window.show_critical(text) - - def update_combo_setting_widgets( - self, - new_value, - field, - update_all=False, - update_will_dialog=False, - update_heirs_dialog=False, - ): - if (update_all or update_will_dialog) and hasattr(self,'will_list_widget'): - self.update_widget_combo(self.will_list_widget,field,new_value) - if update_all or update_heirs_dialog and hasattr(self,'heir_list_widget'): - self.update_widget_combo(self.heir_list_widget,field,new_value) - - - def update_widget_combo(self,widget,field,value): - try: - widget.will_settings_widget.widgets[field].set_index(value) - except Exception as _e: - pass - def update_widget_value(self, widget, field, value): - try: - widget.will_settings_widget.widgets[field].set_value(value) - except Exception as _e: - pass - - def update_setting_widgets( - self, - new_value, - field, - update_all=False, - update_will_dialog=False, - update_heirs_dialog=False, - ): - if update_all or update_heirs_dialog: - self.update_widget_value(self.heir_list_widget, field, new_value) - if update_all or update_will_dialog: - self.update_widget_value(self.will_list_widget, field, new_value) - self.will_settings[field] = new_value - self.bal_plugin.WILL_SETTINGS.set(self.will_settings) - - def init_heirs_to_locktime(self, multiverse=False): - 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: - raise NoHeirsException(_("Heirs are not defined")) - try: - self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp() - # found = False - self.locktime_blocks = self.bal_plugin.LOCKTIME_BLOCKS.get() - self.current_block = Util.get_current_height(self.wallet.network) - self.block_to_check = 0 - self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get() - self.willexecutors = Willexecutors.get_willexecutors( - self.bal_plugin, update=True, bal_window=self, task=False - ) - if self.date_to_check < datetime.now().timestamp(): - raise CheckAliveError(self.date_to_check) - - self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get()) - - except Exception as e: - log_error(e ) - _logger.error(f"init_class_variables: {e}") - - raise e - - def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True): - try: - if self.disable_plugin: - _logger.info("plugin is disabled") - return - if not self.heirs: - _logger.warning("not heirs {}".format(self.heirs)) - return - try: - self.init_class_variables() - Will.check_amounts( - self.heirs, - self.willexecutors, - self.window.wallet.get_utxos(), - self.date_to_check, - self.window.wallet.dust_threshold(), - ) - except AmountException as e: - self.show_warning( - _( - f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}" - ) - ) - except CheckAliveError: - self.show_error( - _( - "CheckAlive is in the past please update it to a date in the future but less than locktime" - ) - ) - return - locktime = Util.parse_locktime_string(self.will_settings["locktime"]) - if locktime < self.date_to_check: - self.show_error(_("locktime is lower than threshold")) - return - if not self.no_willexecutor: - f = False - for _k, we in self.willexecutors.items(): - if Willexecutors.is_selected(we): - f = True - if not f: - self.show_error( - _(" no backup transaction or willexecutor selected") - ) - return - - try: - self.check_will() - except WillExpiredException: - self.invalidate_will() - 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 - if isinstance(e, HeirChangeException): - message = "Heirs changed:" - elif isinstance(e, WillExecutorNotPresent): - message = "Will-Executor not present:" - elif isinstance(e, WillexecutorChangeException): - message = "Will-Executor changed" - elif isinstance(e, TxFeesChangedException): - message = "Txfees are changed" - elif isinstance(e, HeirNotFoundException): - message = "Heir not found" - - if message: - self.show_message( - f"{_(message)}:\n {e}\n{_('will have to be built')}" - ) - - _logger.info("build will") - self.build_will(ignore_duplicate, keep_original) - - try: - self.check_will() - for wid, _w in self.willitems.items(): - self.wallet.set_label(wid, "BAL Transaction") - except WillExpiredException as e: - self.invalidate_will() - except NotCompleteWillException as e: - self.show_error( - "Error:{}\n {}".format( - str(e), - _("Please, check your heirs, locktime and threshold!"), - ) - ) - - self.window.history_list.update() - self.window.utxo_list.update() - self.update_all() - return self.willitems - except Exception as e: - raise e - - def show_transaction_real( - self, - tx: Transaction, - *, - parent: "ElectrumWindow", - prompt_if_unsaved: bool = False, - external_keypairs: Mapping[bytes, bytes] = None, - payment_identifier: "PaymentIdentifier" = None, - ): - try: - d = TxDialog( - tx, - parent=parent, - prompt_if_unsaved=prompt_if_unsaved, - external_keypairs=external_keypairs, - # payment_identifier=payment_identifier, - ) - d.setWindowIcon( - read_QIcon_from_bytes(self.bal_plugin.read_file("icons/bal16x16.png")) - ) - except SerializationError as e: - _logger.error("unable to deserialize the transaction") - parent.show_critical( - _("Electrum was unable to deserialize the transaction:") + "\n" + str(e) - ) - else: - # Electrum's own TxDialog: keep it in front of the main window. - show_on_top(d, modal_to_window=False) - return d - - def show_transaction(self, tx=None, txid=None, parent=None): - if not parent: - parent = self.window - if txid is not None and txid in self.willitems: - tx = self.willitems[txid].tx - if not tx: - raise Exception(_("no tx")) - return self.show_transaction_real(tx, parent=parent) - - def invalidate_will(self): - def on_success(result): - if result: - self.show_message( - _( - "Please sign and broadcast this transaction to invalidate current will" - ) - ) - self.wallet.set_label(result.txid(), "BAL Invalidate") - self.show_transaction(result) - else: - self.show_message(_("No transactions to invalidate")) - - def on_failure(exec_info): - log_error(exec_info, self.bal_window) - - fee_per_byte = self.will_settings.get("baltx_fees", 1) - task = partial(Will.invalidate_will, self.willitems, self.wallet, fee_per_byte) - msg = _("Calculating Transactions") - self.waiting_dialog = BalWaitingDialog( - self, msg, task, on_success, on_failure, exe=False - ) - self.waiting_dialog.exe() - - def sign_transactions(self, password): - try: - txs = {} - signed = None - tosign = None - - def get_message(): - msg = "" - if signed: - msg = _(f"signed: {signed}\n") - return msg + _(f"signing: {tosign}") - - for txid in Will.only_valid(self.willitems): - wi = self.willitems[txid] - tx = copy.deepcopy(wi.tx) - if wi.get_status("COMPLETE"): - txs[txid] = tx - continue - tosign = txid - try: - self.waiting_dialog.update(get_message()) - except Exception: - pass - for txin in tx.inputs(): - prevout = txin.prevout.to_json() - if prevout[0] in self.willitems: - change = self.willitems[prevout[0]].tx.outputs()[prevout[1]] - txin._trusted_value_sats = change.value - try: - txin.script_descriptor = change.script_descriptor - except Exception: - pass - txin.is_mine = True - txin._TxInput__address = change.address - txin._TxInput__scriptpubkey = change.scriptpubkey - txin._TxInput__value_sats = change.value - - self.wallet.sign_transaction(tx, password, ignore_warnings=True) - signed = tosign - # is_complete = False - if tx.is_complete(): - # is_complete = True - wi.set_status("COMPLETE", True) - txs[txid] = tx - except Exception: - return None - return txs - - def get_wallet_password(self, message=None, parent=None): - parent = self.window if not parent else parent - password = None - if self.wallet.has_keystore_encryption(): - password = self.bal_plugin.password_dialog(parent=parent, msg=message) - if password is None: - return False - try: - self.wallet.check_password(password) - except Exception as e: - self.show_error(str(e)) - password = self.get_wallet_password(message) - return password - - def on_close(self): - # Wallet is closing: run the closing "build will" task and tear down - # the plugin's tabs/menu. Each step is isolated so that one failure - # does not leave the GUI half-initialised (which previously forced the - # user to restart Electrum). Errors are logged instead of silently - # swallowed. - if self.disable_plugin: - return - - # 1) Business logic: build/save the will on close (unchanged behaviour). - try: - close_window = BalBuildWillDialog(self) - close_window.build_will_task() - self.save_willitems() - except Exception as e: - _logger.error(f"on_close: build/save will failed: {e}") - - # 2) GUI teardown - each action guarded independently. - def _safe(desc, fn): - try: - fn() - except Exception as e: - _logger.error(f"on_close: {desc} failed: {e}") - - _safe("close heirs tab", lambda: self.heirs_tab.close()) - _safe("close will tab", lambda: self.will_tab.close()) - _safe( - "remove willexecutors menu action", - lambda: self.tools_menu.removeAction( - self.tools_menu.willexecutors_action - ), - ) - _safe("toggle heirs tab off", lambda: self.window.toggle_tab(self.heirs_tab)) - _safe("toggle will tab off", lambda: self.window.toggle_tab(self.will_tab)) - _safe("refresh tabs", lambda: self.window.tabs.update()) - - # 3) Reset in-memory state so re-enabling/re-opening starts clean. - self.willitems = {} - self.will = {} - self.heirs = {} - self.willexecutors = {} - self.disable_plugin = True - self.ok = False - # The tabs/menu actions were removed above; allow init_menubar_tools to - # re-wire them if this same window is reused for another wallet. - self._menubar_initialized = False - - def ask_password_and_sign_transactions(self, callback=None): - def on_success(txs): - if txs: - for txid, tx in txs.items(): - self.willitems[txid].tx = copy.deepcopy(tx) - self.will[txid] = self.willitems[txid].to_dict() - try: - self.will_list_widget.update() - except Exception: - pass - if callback: - try: - callback() - except Exception as e: - raise e - - def on_failure(exec_info): - log_error(exec_info, self.bal_window) - - password = self.get_wallet_password() - task = partial(self.sign_transactions, password) - msg = _("Signing transactions...") - self.waiting_dialog = BalWaitingDialog( - self, msg, task, on_success, on_failure, exe=False - ) - self.waiting_dialog.exe() - - def broadcast_transactions(self, force=False): - def on_success(sulcess): - self.will_list_widget.update() - if sulcess: - _logger.info("error, some transaction was not sent") - self.show_warning(_("Some transaction was not broadcasted")) - return - _logger.debug("OK, sulcess transaction was sent") - self.show_message( - _("All transactions are broadcasted to respective Will-Executors") - ) - - def on_failure(exec_info): - log_error(exec_info, self.bal_window) - # a,b,c = err - # _logger.error(f"fail to broadcast transactions:{err}") - # _logger.error(f"error: {b}") - # _logger.error("traceback ") - # tb = c - # while tb is not None: - # frame = tb.tb_frame - # _logger.error("file:", frame.f_code.co_filename) - # _logger.error("name:", frame.f_code.co_name) - # _logger.error("line:", tb.tb_lineno) - # _logger.error("lasti:", tb.tb_lasti) - # tb = tb.tb_next - - task = partial(self.push_transactions_to_willexecutors, force) - msg = _("Selecting Will-Executors") - self.waiting_dialog = BalWaitingDialog( - self, msg, task, on_success, on_failure, exe=False - ) - self.waiting_dialog.exe() - - def push_transactions_to_willexecutors(self, force=False): - willexecutors = Willexecutors.get_willexecutor_transactions(self.willitems) - - def getMsg(willexecutors): - msg = "Broadcasting Transactions to Will-Executors:\n" - for url in willexecutors: - msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n" - return msg - - # Initialise statuses + show the list immediately. - for url in willexecutors: - willexecutors[url].setdefault("broadcast_status", _("waiting...")) - try: - self.waiting_dialog.update(getMsg(willexecutors)) - except Exception: - pass - - 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): - for wid in self.willitems: - self.willitems[wid].set_status("EXPORTED", True) - self.will[wid] = self.willitems[wid].to_dict() - write_json_file(path, self.will) - - def export_will(self): - try: - export_meta_gui(self.window, "will.json", self.export_json_file) - except Exception as e: - self.show_error(str(e)) - raise e - - def import_will(self): - def sulcess(): - self.will_list_widget.update_will(self.willitems) - - import_meta_gui(self.window, _("will"), self.import_json_file, sulcess) - - def import_json_file(self, path): - try: - data = read_json_file(path) - willitems = {} - for k, v in data.items(): - data[k]["tx"] = tx_from_any(v["tx"]) - willitems[k] = WillItem(data[k], _id=k) - self.update_will(willitems) - except Exception as e: - raise e - # raise FileImportFailed(_("Invalid will file")) - - def check_transactions_task(self, will): - start = time.time() - # 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} - - 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)) - - def check_transactions(self, will): - def on_success(result): - if hasattr(self,"waiting_dialog"): - del self.waiting_dialog - self.update_all() - pass - - def on_failure(exec_info): - log_error(exec_info, self) - # _logger.error(f"error checking transactions {e}") - # pass - - task = partial(self.check_transactions_task, will) - msg = _("Check Transaction") - self.waiting_dialog = BalWaitingDialog( - self, msg, task, on_success, on_failure, exe=False - ) - self.waiting_dialog.exe() - - def update_willexecutor_list_widget(self, parent, willexecutors): - try: - parent.willexecutors_list.update(willexecutors) - parent.will_executor_list_widget.update() - except Exception as e: - _logger.error(f"impossible to update will_executor_list_widget {e}") - self.will_executors.update() - - def fetch_will_executors_list(self, old_willexecutors): - """Download the will-executor list (runs inside the TaskThread worker). - - Tries the configured server first, then the original hardcoded endpoint, - so a stale/bad config value cannot break the download. Detailed - per-attempt diagnostics are written to the Electrum log only; the user - sees a simple message. No business logic in ``bal.core`` is changed. - - Returns the downloaded dict (empty ``{}`` on failure). - """ - chainname = BalPlugin.chainname - configured = self.bal_plugin.WELIST_SERVER.get() - candidates = [] - for base in (configured, "https://welist.bitcoin-after.life/"): - if not base: - continue - base = base if base.endswith("/") else base + "/" - url = f"{base}data/{chainname}?page=0&limit=100" - if url not in candidates: - candidates.append(url) - - result = {} - net = Network.get_instance() - _logger.info(f"fetch_will_executors_list: network present = {net is not None}") - for url in candidates: - _logger.info(f"fetch_will_executors_list: trying {url}") - try: - # 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'}" - ) - if resp: - result = resp - for w in result: - if w not in ("status", "url"): - Willexecutors.initialize_willexecutor( - result[w], w, None, - old_willexecutors.get(w, None), - ) - break - _logger.warning(f"fetch_will_executors_list: {url} -> empty response") - except Exception as e: - _logger.error( - f"fetch_will_executors_list: {url} -> {type(e).__name__}: {e}" - ) - return result - - # Simple, user-facing message shown when the download fails for any reason - # (the technical cause is in the Electrum log). - DOWNLOAD_FAILED_MESSAGE = ( - "Could not download the will-executors list.\n\n" - "This is usually caused by your internet connection or a firewall, " - "not by the plugin. Please check your connection (a VPN often helps) " - "and try again." - ) - - def download_list(self, willexecutors, fn_on_success, fn_on_failure=None): - 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(): - # 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: - self.willexecutors.update(result) - fn_on_success(result) - else: - self.show_warning(_(self.DOWNLOAD_FAILED_MESSAGE)) - - def on_failure(exc_info): - _logger.error(f"download_list failed: {exc_info}") - self.show_warning(_(self.DOWNLOAD_FAILED_MESSAGE)) - - self.waiting_dialog = BalWaitingDialog( - self, base_msg, task, on_success, on_failure, exe=False - ) - self.waiting_dialog.exe() - - def ping_willexecutors_task(self, wes): - _logger.info("ping willexecutots task") - # 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() - 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]) - if url in pinged: - urlstr += _("Ok") - elif url in failed: - urlstr += _("Ko") - else: - urlstr += _("waiting...") - urlstr += "\n" - msg += urlstr - return msg - - 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 - - # Show the initial "waiting..." list immediately. - try: - self.waiting_dialog.update(get_title()) - except Exception: - pass - - # 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): - fn_on_success(result) - - def on_failure(exec_info): - fn_on_failure(exec_info) - - if not fn_on_failure: - fn_on_failure = log_error - _logger.info("ping willexecutors") - task = partial(self.ping_willexecutors_task, wes) - msg = _("Ping Will-Executors") - self.waiting_dialog = BalWaitingDialog( - self, msg, task, on_success, on_failure, exe=False - ) - self.waiting_dialog.exe() - - def preview_modal_dialog(self): - self.dw = WillDetailDialog(self) - # This dialog is meant to be modal (per its name); show it on top so it - # cannot disappear behind the Electrum window. - show_on_top(self.dw) - - def update_all(self): - try: - # Re-sync the cached "hide invalidated/replaced" flags from the - # persisted config before refreshing the list. The Settings dialog - # checkboxes write the config directly (without touching the cached - # flags), so without this the list would keep filtering with the old - # value and the invalidated/replaced rows would not appear/disappear - # until Electrum was restarted. - self.bal_plugin.sync_hide_filters() - Will.add_willtree(self.willitems) - all_utxos = self.wallet.get_utxos() - utxos_list = Will.utxos_strs(all_utxos) - Will.check_invalidated(self.willitems, utxos_list, self.wallet) - - self.will_list_widget.update_will(self.willitems) - self.heirs_tab.update() - self.will_tab.update() - self.will_list_widget.update() - except Exception as e: - _logger.error(f"error while updating window: {e}") - - diff --git a/bal/gui/qt/window_utils.py b/bal/gui/qt/window_utils.py deleted file mode 100644 index 6817ca4..0000000 --- a/bal/gui/qt/window_utils.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -bal.gui.qt.window_utils -======================= - -Centralized window/dialog presentation helpers. - -The original plugin opened dialogs inconsistently: some with ``exec()`` -(modal, stays on top) and some with ``show()`` (modeless, can fall *behind* -the main Electrum window). It also relied on a per-instance ``self.parent`` -attribute that shadows :meth:`QWidget.parent`, and it never gave non-modal -dialogs focus, so they could disappear behind Electrum. - -To fix this *without changing the business logic*, all the "how is this window -shown / focused / parented" concerns are collected here. The rest of the GUI -code just calls these helpers, so the behaviour is consistent and easy to -audit. - -None of these helpers change *what* a dialog does — only its parenting, -modality and z-order/focus. -""" - -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QWidget - - -def top_level_of(widget): - """Return the proper top-level window to use as a dialog parent. - - Electrum widgets expose ``top_level_window()``; when available we use it so - the dialog is anchored to the real top-level Electrum window (and therefore - stays in front of it). Falls back to the widget's own ``window()`` or the - widget itself. - """ - if widget is None: - return None - # Electrum's MessageBoxMixin / ElectrumWindow provide top_level_window(). - tlw = getattr(widget, "top_level_window", None) - if callable(tlw): - try: - return tlw() - except Exception: - pass - # Plain QWidget: window() returns the top-level container. - if isinstance(widget, QWidget): - try: - return widget.window() - except Exception: - pass - return widget - - -def bring_to_front(dialog): - """Make a *visible* dialog actually appear in front and take focus. - - ``raise_()`` alone is not enough on some window managers (notably Windows): - without ``activateWindow()`` the dialog can stay behind the main window. - """ - try: - dialog.raise_() - dialog.activateWindow() - except Exception: - pass - - -def stop_thread(thread): - """Safely stop and join an Electrum ``TaskThread`` if present. - - The original code commented out thread teardown, leaving background - threads running after a dialog closed (which could touch destroyed widgets - or keep network connections open until Electrum was restarted). This - stops the thread and waits for it to finish, guarding against ``None`` and - any teardown error. - """ - if thread is None: - return - try: - thread.stop() - except Exception: - pass - try: - thread.wait() - except Exception: - pass - - -def show_modal(dialog): - """Show *dialog* modally and return the result of ``exec()``. - - Modal dialogs always stay in front of their parent, which is the desired - behaviour for the plugin's editing/confirmation dialogs. - """ - try: - dialog.setWindowModality(Qt.WindowModality.WindowModal) - except Exception: - pass - bring_to_front(dialog) - return dialog.exec() - - -def show_on_top(dialog, *, modal_to_window=True): - """Show *dialog* non-modally but guaranteed in front of Electrum. - - Use this for the few dialogs that must remain non-modal (e.g. the - transaction dialog the user may want to keep open alongside the wallet). - It sets window-modality (so it stays above its parent window without - blocking the whole application) and gives it focus. - - Set ``modal_to_window=False`` for a completely modeless window. - """ - try: - if modal_to_window: - dialog.setWindowModality(Qt.WindowModality.WindowModal) - else: - dialog.setWindowModality(Qt.WindowModality.NonModal) - except Exception: - pass - dialog.show() - bring_to_front(dialog) - return dialog diff --git a/bal/icons/bal16x16.png b/bal/icons/bal16x16.png deleted file mode 100644 index cfd34e7f432cfa82368383ea312daffdf8bf8ab9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 538 zcmV+#0_FXQP)LVu6 zi(7buCZrs$mchtKf)O z9}({&;z>m8tLXI_zj3i#3?5*6hnS0T6U#A*{z_~&>bRVlYNgUxMD$hIuEbhg!cwg+ zv~2=2Fqc-Je_>JawmrlB%-n(t*inhiE0~^kfydZ|iHK;HqycP;h*9+8deNo_-B^vW zcEu*fwFYAk8b#$L$}p9;cWVt=9e1!XGqr%p8Ym>*^T7SDY{J?8P!1(wf4jm_McE)2!|)T*G^O!6)2P cmZ;PJ0=%OC<;KN)IsgCw07*qoM6N<$g3~YTJpcdz diff --git a/bal/icons/bal32x32.png b/bal/icons/bal32x32.png deleted file mode 100644 index 58b1d7096fe219592b7d0f990ab74bec961cf75d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1128 zcmV-u1eg1XP)H8p-qyH%#L9A#E{)zrJ?!+YY4Vf17#-I2Xhe|-UV0;7H{h9&@M+d|45)5b} zduBM#_`I?^o6)BtKN9btwVttrXG3`hMt9+eTJU}7?8dGNh&w`VM(pU%L7!63*mJ>r z9c|snf)`5;cVKJjdoiBE(fIx?#>=Wk9tg&I^yp?9PQ(u-rypZe%-58T=o|cq0~lB~ zZ~+cuZ!JJhq83cSYb79?A`qX!ulN-Eg17R7AdFRKTz@jo>%`Gm%*XS%4%^~rcEmAv zWC6y7^R1`qybhR0u>#ZU@V+j0N=*>_vysg zTb1<6io&z36TQ_j_Qw$nDe(*lLtDDAoi~K+)0h=~W6*AHqPM)ZIDCOQHC~INj>C4H zx6Ltrl5<|0b6$)0f;P2@-X2^W^SvdXT4P(_7f2~xlym;9fuU<+oSstZ8}l1u{GbVd z!yLbP_bY<_IDj4%;R$F2;I0_&j$Upfn*bQ9xU7g6s4-d;7Y+=o5mQR%G%$2~jB|3% zDd(INH|F11no_DRrVMUp%twU$aR9qxJ~_tqnB8PG)wWM@e0}RNQ%e2OGv|CPNbUHl z#&1;w$RV_+l-hF62U1EyQ%Wh<@}#3JiE&X%X4!ujI za}8#8!};hq^92>!L41jpMgS5%3J~jRg0Yng@iJzjR|CfvVj14Yj1qmI;t_HbQ>uEY z9Ox;fVTvL&2rpr7EwWQeLog3l2G~CokBouQ_XqG-YAKge8Wce|3HRolSE;r-E17`b zbv&y^)RF0l^AxpXWP=^KJc2IciH1kkKzXc=R6B&naaI>&U<}>}-J`g#seBVdZFo}0 zOZJH3jW-`xE6VEtj8a@u3luNmVH;;zjO@ zAP8C<5grl`&aIy|YaYmyiG}SDWbYSuAYnhaUV@+o4 zhNF$+XbmC}GCJC@#^=6=Pj6XyZ)rs~7yX%w%Gx?)9i4F;B=-T5`w&3Ij~KU4ZwMZpfE)C<5isDG9>+i$aSS735{cvK1a2U3BY|TCfswe8 zBmj&eaEinz5+p%Ugo(sW6hN3L(p+9?rU)}A6lFG%W)o#L113vPkEN&AGR(J_NQ;@W zfESCI=JRPYMO#cX=oSkDs~8w&5FJAh3~gp;3q#WkLt9bAN|ILEVr2l@YGrH;W3$pW zP^{KT8#8IMPE7)~xw$#Zh_f#k9lxCTn1hi0^04HTl+?oF;>xPqcb@QCTie>Y|6e_Q zeSPmlVz8uAsY0PpDq)R|B&p{hnC#j~h>{gn{s6(!+KwO?{qpS?0H&f9dmXMBU1@Bh?4#@7>n zy|N1-f8opWhwsU*T}$O)cenN5tf{HtG?QBfW4%VbW-F#nr?Vl>=)8jtw<AEo}WKOY+k82UOjH@Q`c1v-+PwxM zsw;DXCu`g2I&-@~l{s7d#UE9Si+M0DyYbOLT18>qS6Px2?&Zk#_erI4|D-_Wo~KhT z4b07)9}AHQyo5op5`HrIu zv}-5z+d)@SihnPCIX1nq-^$v@t>bcUGea2x4S5k~MCW2XuFmY?Ew2l8WTn=YeK_mB zEH;X0`Iue5z$o56ba?9Sv!ewwR_U}lt9#~T4dm^r_&uw9UUTd-7LT>z#W(!!=;qk! zMO*clYlqZlpf$`Xzoz%HYGRJC&0MS{itVoj=B1tb=STl|qss2f)*wxM>1sNs%U_zmY{V^erze~CD9}|XC;{X5v diff --git a/bal/icons/confirmed.png b/bal/icons/confirmed.png deleted file mode 100644 index 2023abd15e2b603b265431da2d9999e69666d793..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54212 zcmeEt^;cU<8*YM2ad#-*paqIM6!+o~tQ05|cMa~+;tp+bcXusX9Et^ZcfaX5=Uev= zxWC?6D{HUpWY3;?=6#>nX2MmKWiZf4(EtDdhMcUFIsgEK{{5gJ!S47>w z;s8KZEc&AfBJ4Z0nXI}J0N}|006>BOfP2_2$Swfj!T|v68v_6WDF6VGeMYmI5bOse zQw13**cI09BwPGo7gT#$T_@Pv=Kg+w5=`mtu*U&%QsN)n{~TtzWsvu!-uTVLzq82G z9OI`6rw*cX{D#6n#?gQ9#wHf~#(Q>z|XXc(4O|G)jeBal~QWGv(x<8f!(|CNn)7323L ztnEt|_f&A(F$wNi~l?J(3XAGNMbM|2WVC4tL!*|mh3<4 zcwnzZ!2r-fGInlHU#Aw<)_J{*T>5wLjr%)%m<(AmwuJM8iCI!4<=wGYAPk;=hJY!v z92T78j?c$J)c)HkI9I#-uP6yTI@6W|`{Mb8*3bcrIq1V`p)_~4vLboFe>&vbdvAah z(E*RL-NH=r84lv68VVbtZ2M1SlfSY}ff-R@dxZ>m38XD}sKoC~Oo>g@35mY5Ra7~U zZ3_EmYUTUbNcL2aVMjwvI&5)^(wc(UN^}IHJ`DH90?=4NE#lu7&7^CF;sR9Q`9$Y5 zqYKIX8``f4(E<=}cdq@eH`OQ;or3?JglEbAZ>mlZV0*KMA2Iu)J z{1CLWp?q!6bIaJ~rtW|7Yg2O}210oNcus~hB(&q5=-Ta&;_l)P^BFWwzbk%HftOBS zTaH(s@&V!@+Aoy~bSaYAW9^nQD)aVe>@@7JrD!hrO1g z`7?Kx{sXz<=j%-p;#lR30IpMRu&^W1;qSo8ze?a4zi*EQDF7sFH5VVKv{LeBy2e{K z@5~9uDp0lHA5BnNtnC2C-c!_HIrj-|F4`7r39>@{!rs^CWJcE<#%QY2pJfqd&=zjqPY9E8cYpD3h z9u}`}E*JP;uYyV+{*i$5qFXpJ7l5rqO!W8t?$0dkj-1LA4xjbztQD-Q><=Y`TCNwe z>VskjZ_upK|MCUMs8|xa^9azMbx<6l{c7M_Ns_VRRmv-c@AH$j1QsiP46_slbF>BF zCFHmK`>JzO1UnM{0)O}WL^Vo_1h2s7#?`EavD~{lCiMspnD%cS;UffI)wWQ?L;}oF!(}$Q`Jzb2~Y0VI~7V z=6CB|I)ANIjwPAj^W65|Vha};nKFIXfipHJFKPa?&(nC&+JgO7!YauaxuTBX#pd91 z4}Gj*1U*^O=Dqz#fl?SQ|4Q*yf^SSXUC10DYS2YB@^oIhMk-ac?Ey;!G$hx2OO#{^8$Q3+D=u+d*1iN!;Uo6TC5l zAlNT9HLMjl`J#P$&no1p3Ei4PdyIJp9rrN$&jM#^VI*tsK=!6Ih8+tHI z0{)WIP!$~Sy0>ZEj+iQ;%xfSD@Yma5^Z-Qn-S45NiA6?ov<)pSBp)y=1Fp zGuLr1kbjeXOq;BI>S?clZWTkpjo$Mn#PsZ*$m^aSGjzu5Kb5U6lST>3N3x|2dQ1p@ zBZrer_wrLLqg>z9arKw`Rhl+ut7+o^Ib~=1{D(m8rSJ9U1((?YzkC?i7I?;K-kUXv z?j&KR-=G~>$^NC2g=yBXAS}dV_`Wb+?89GbaZ zyz_HP-2fpo>x)o9aVF#R+v){yqy7ALkojKL(A8GK*)p|-f)9--VsGZt6t}6!Z{U}_XGbxoqC7EMtDO`6Ou^&_kxT10@nkjugppVgpToMbZASN#@8kXDjL)}IGU zD_->G+%`%g5l7UO#WUY}_HZH>e`rY~5Nqd$qn0P>x4#Ta`lg-Pn*hZSAt#P5AKSsn z3w1rxhK`Yh{#)-4IwyZQQ?(eWyI+?usoF>^G3w%kkNH{~AYl7zc|?np-`z)1^!xGN zp(qg(nnZA;`?|DFlQlgIlD+WZzT&x&|DZT8s$PBX3y51p75^RZ+0>O?ZggKs8vBzY zyC_i#ij)6WI8K1#As4Dbg(oWQoA?u3d$)Hjx-GLw@xwYvL)k40;Mds<s{T+794pbi&o-l6_}yCF#3{OBIOB4xGbej>sdUn~y6zU%g)vc`i^WcfiClP#qr2A+dtXN{jl zy0!o`_s?>ke-}U))+Ss+j5u+eWo^l!lT`htv%^o(a4Egf5fsYqiBG>~3P6jTfp^x_ zKR3Y48MD`9u}bSL7e^T*@p^~pLfP#lC_31e=rrZ-ypEsYUvTz$oNYe$9}~az-M2&1 zfy3YwAEF=^kS6qj6}x7Z6}rm$)TX=I^JQnnUzIw@Mp8SAiwH^}nd1G;N*)2BAX*49 zxC^ee{Vw{@fZK0q_=2M80!59-B2~u3D$nsmM^qUUOWJ!=6?p4r`k7)*z;=2I}@T9 zFF~NH(_nAUwYWTq*af%bv?6MMG+j}!uLUoA^r61MnJkQcbC}dU7=N1N_u)>I|A4X1 zmTGIGua!!N9O~{G%%}nW`fzd|?pnUdA?;dI6CaN@(`UGVj;NHSbrC-hepo+K$m&g{ z)jT=7;y6!@EEsy5u$w<^PT1~@-mRVF} z%so=tCT4!v!w@(R{euA6;%4KgJ$}{$7upF$DLSA{H70VjCgFMH=`p^DDhC)c5kVb-H{+xq04S39w;K2ecWO>oGI=jROc zfJVg5Sk=(dhn|0ZDcpT0r(bSNyR3j$IK==gV!<%H>}laAm}_(fMa0VGR>zdXP`^9C zOS(i>ShY@(K7F%1YAQ^*Hq63QeGK(9IECijdB)?${rDfUz}*YKzB%|tR7i`0p@@sX4xW z&-FCn8{JE^lRg;@gAE=}ldC?j?hM9P-~YQhWk0lj*oyy5O%f7=L;}Yux5R8}W`zRz zbdkEom78%^1+O2<3ze*~3G-si`IC2NsYXut1GPs^`j$tsKQ=tCg~Yr!INQ}eU5RNe zO+);6_8&9Kq|s(uno(@O!h_Qc+Z z#0vJeVf8w?@kHLWDeF5nc$&{T>AR7&pWpJH-sk_*?_OK?Kjb*8R*;q+i3Hd*ha*R) z)B0(e93th|th%p>I+E~Ric}(~sqL5IY;E3ofTNK6+UpF?bwz!$rT3hXU8&co!?|Qw zPDfZ{i0)0I&+nB(;lwjU*Mv$%8-dnXr81NWiT`nzOd`@_-be2^ge3pCs>wVW3NVEw zGUu9D1{^QVuF+dSEclY{)2kF3tKdl5KRAtAzPu0ol;66tgBAB8V13FoTyV`qrQP+D{ z=J%`I*VipLiX9WI)29RT8$Wzwb!o39+O8zsR85--v%;F?Oe}D!ftfm#R~el5A8K71 zHl8RzhT|@n2^0TOP7E4GYn?MN>`VyaNLYOM;NBLH;uXq{ZYsf1;z~&QJc+lfy)<6D zH1pkjOJGbFXPuzgJ8bDsbg230`quL;Y!S>S@Pkmdu|!c}tirNd-Mzs-);#I?pDf~3 z(D)VIqM>-iVdGlPxf+bMszxNLlj3C9Lp4Rhz2elzClvt4?$H_@o>_)ua{h}ms14Y+s(XMC6mT;$F`6giwRZd@R!csXncZ)QL0~w zcH;JwaEW~%?cDZ-lDr}H?Hk+9o@&{n0GL}XV9Nbi-v0DGtQ`(Y2N-#9BRDxRe;i&C z^b6-%mVKvMVkalMJmBsgbA)vW``ygYz%)JxB(pp za=p(E$U6Y{<(9YVbjLUa*ZqxSU8F3hDYj^p5R%n@e+3xp6HcPZ2N$!LChC&{-fKnm zuM!GD{PP~YypPVg@)!8E-enHZ1Xn9EZkCi9x;**jGkXNpB9~}uCDrDQ^es=c;w1|| zYT6X9Mzx0J;33qUa(VY7{S%rURL+G7zF`a`u7D%{T$YN{V0#o;4siXQIP!WM*-#h0 zF8@jp=_cz2`=0PdX(m&-qM`wRnx^SS#Kb~y@kiY-;^ixM^F5*HjC@zVWNG;-`)*x# zr(w~mjw^ttI$csQV*HYUqNxn0Ch9uH{@caq)#?waedqT zKDVijPlsNruQNB&QA3JC53Mk})qmayVMhvVox_7Ud;j`SSulypXT~I0dpz4&f64?^ z)p#^4qK-$v1<&ifg(Ipz>g0AdV@O(~c()?k@J}@CZGCAtWAqxyP!VM)g;M_(t}57I zk^ARQX78(?73<>`D=`7a2xl^Fx2xZT-<`$ky>vYh#YPM#-ZBQnROpu~h(s(z1hXbr z_SGtk&~N*fxm;grp~Nff+a~=CM7~wGKP4EJ^Gza1SkE1P4vCi;8GYP#QftJe(jM== z8@0BlXL`oMr{Ro7vd8+o{yIozMc2ureK9xBh0gNQ(5D?sf|8aRaY+G}9NEwxo^#$C z7n_Xw_IQM9?Pat`VdSi?=ffiwi7`Q3UEnN6y>e$_7YoL&I)8Z$;)=Vb4H&2DgI;=R zE1%nuRj=c`D`60t{dk{Wm&eBVuZc`le9Zom#2SaI_N`k)$O%Khm)04;F^y7Fg^`1d z#wZuS$9>vxrXgS-Xs@Sd6&?`PRFWTqeP4h;izh}O(GKPb3x^MrgejuP9oFp_r>Ol~ z_b<^J^BwAg0)bkX`o%V3tE9JIe8R7 zxLAJ2TGk^UiB0ULvMLxRZVSOEj0;SW%R}Jo^lxM`2olY9U?hxb7YkSAt@LzIF5x^x z(nl%U)G8Nr-kR6cZ<*GrX%mLPidF2Ky4q&uH1q+@pZ#kieokZ=RkGBw1cEki8PULkZLMKj)O z);?Xfc&4D3!2Brrp17=T2YS+CG_bCNmjoz`%K(1?-u&!e(G(H-_?tKWG*Qv1@liEr zyGT3|iD0{>Oi!;(H=c^6!UkPB8BWL8OHSh4u+^53bB)#XTVo$5M+i4#NkP zp!D(7=I0v=s+znZ$6{-(OH|O(@LQsq_&W4*gQ`s2hT4)o^m$-WfZe@T#hcMKDv>Zd z;k&@nE?%7gpFsAmqZXr1nJNY$3=;2+Y8S!JfE`)dlkyCFNqa=d*C7Ho0Cs>=q`m-A z?2~@<+)Xl!0W?V;8q)M?K<7v(+xD2-++sH47XI#zt3~d{Yd?rTywiygb4&3&Vsbqp zBb*vC9K!6(`@AX8TtNSORK&fA`~6;0KBS`^>_Li$&)eYvdiL65uer|u8Q?q5cj>s) zbVXC!+w8y#sg!htffV$9b=iU~w;v5qSMdq7G-;v7)NV*>UF_kWes z4C!BaP6H~s9D-^__Jg^V8c6z7h*lw@<+lo?Zn*~ZKh7qg=x4FfZe~nOwvdORZ=U$h zWpaEoxk2rY!l2RZmEPJ&}Naq?rqScRe23OlK-c+;CaU(C=1ZW@GUVpk$ z*H5cIe&_vkb^^^+p&afuB+;I6aIaY{x_!7`bU=fY2!EO)v}fblhovN$a#e71RM%`j zwO>!V#VTXrHG2;kY6hPBu;}UCP4_(xjf$1#8BZHVpc`comCLsItUgO5sM5@>?mUf-V zRQCel#YQ1PUIt*9Fi}NPOgAl=-9QI`Z$ZZP3vfpsCO*$>z+-x9nuS`Vq8aq(yw&jB zw2kjw&~&_$W3D%=13&W;v6Pw`UU-n`RdD0FKipunyyG%Pt|G`q;4~2<=Lop1Sa7-B zzC8Iz(Gc^l+QuJ~=(j-EEXbawR!J*qy6;u}0{9{D$)5;PmaV;Q8*<8=Z@LJtYGbz) zgMR$*R_pkl4|GG1nqxhm=uXi&rBBs64v4zZ4XTGY<*K=98QG~mk^~VeYgITGwMZdg z*}Q!vzA<`sbCteX1ccQl!WdRpUp%oNDG`t@Z_{wk(v)pv#}_m22{y ztbi(ud=<2ghs6+-Xgk6i^k>a}l@pzk0iGtA-jBnN6%*2MG#A}#LA>>mz+@IUUu!~0@E2k-%MA_j7gxun@s=_vFGsdMSF zNw+Ji@*3DNI|@O8a0rELrTtXO?HC)yj7*T^?7$mBc|-Z!n5uL2!Y>+6l46h7=e6g0MFJFvS~fn&tKR1imU|;(4}O7p^IVT0ofs@BvFgwkI4O+D!aL~7yFghgrC{@(S5p^#~a5d#^3Qey<-JeiRjAH z7CaT>V%bBK1Z`GF}d=FnOvUI8z&b|45!nQr3*cN0#Pj)Uke?pJsv5Wq#hi z(px^d%#t4S5xIh6=6gU&ze4=+iI-=4tiA$hF|DysMXs=9jsG)oGEO)VGX?hWeb~U9 zVlRcoYOHvkB%v$kG`-NR<)Kcqsv>Nr+c$Z+lWsh>&?YJ+tTJ>i=gli^laR?+R@Pl0~pvK_A(}e1y zc|$_i&et0w0dulBSNO zU`g2w|I|CtlgfIK{!@2-vkpjmqTm3@P*#0V5>a2@>P&r2XZor2?E&8v2o4Y&Zq)uP zxO6&HQB&XfK0E$&8^UF8*<*wqLD_w$%ltvVGu7&mf(QQf+V8jWgEm_z7FdC$_{X0b zN35bcJIyqXPhJwevyoZL6nPH1XlPBB*LZ=7M9G2p+R?)Zy%w=LVXK*sjQAlD_#DG+ ztv!obQ_HqVorZ7*iIIOOnyqwN09=`aB>6U0wzE~cwn&gM9me*Hk&cZRx0kHUNNF)- zwq7!Vso(9#TCZYU)^88-Ux}Fl55#pSd9KTSdw$SezXiT$=$ZG|I!r0~fYN|ve?nci zwT5@$M64kQgmWCPeX#DKGHN?G_Rhv*Epz66TI=FcBK7hI_%JK^>RWz>;&;AkmiM8u zj9~qBVt8&cg4JBT-Y#@TYQTH+V6fa~0Zp8FeS@8xl3=m0H(&T2VV^&*6^Szb@xyCf zN}4v-q(77S5XoX|#5usj z?-On}sp!8gP*pLhdHCKiFJ8VL*Q!8k4d;739djUR2UU)FE^8M~gTZ{X$(^am-23fA+J z-dz;}9vucl{ECpge|h>Wygh2``=*ZfC811DI`0T?k#4-JKqU*`>kpRO z-;~*{{47*o2tOO6gYYZ-OK*rFI}~mcvmMe4hxt=uXd*|=P1DGnc#{TO3h)4py)+oZ z3@oLC(uZTc2EMz%7O?Fsu6}M|q|%ErZz~T}IItu_R{bn*tpb}iPSb9m21&i}dP-2RxSYT*-O?w*11J1Jhu>=Eu09DEHk)hYqw{cOD+Ctbm=5b2x|dg14^nr!Ii?3xXn<($Ounbgwj2 zxEuF^&XnS_+rA9ZobNf4YyPu~lrFwVMjp{h7T8I`A262POPyBBO9`=_x-g-LOq5TD z%#U;51e^y<2Se_&0=5#TIL!w(=2x2ue$=QJwouAYu$jO+Q&qyi(>B72O9vFFjXfjj z{hGRfZe3uHL(QuV#D{JYM_wK(6_O2G@@Q9g)DCuM6CUmf+zw+|;JWzf?qto6&C^iA zR$BHc_B!c3=N4vQyUX?C1K(t*0mA z(*0dr6LJPCUM93vFb59eE2g?qX z;Ji&tJ85-8jD_eFPAnnqc0r+|wyQ;oyeX+^`gy`zhHgi`;_0%zeS1Mr&|5*&lRCk#nob0iMO8{xJ7JMM6a}Bp6sm-;Sv^Ru9}PqiOempA{@)f3 zWi3>m+R#*McxCpHmv0a;AQV|?*`yaXK(0hw6Uxf|7Cb*r+c%ORO>V=$OK{w_m{#xM39s;|*KG;U9llU0W1Me?$71n6l|?p`jomcO2~>0M{4$vf zm=r{njjLWw?!`n*jPk8-Hfi(ffrc5K5m$%^Ep9} z3J`}vOPx>@55Y8mY~`78R_t+cAA%#=tqdXzWc54$M$l4nHi$x<$#r}bYoNxAg_+X+ zD~e$D@m3W7UfIzm+L(dld={}zj*K;sUK_Ku#J+8PsmU+>k767U_47X&jG-!{H+ORa zj@&n}d67}yK#0XVZ6_Fn6^gyyWht&@DK|QYBu4jEY_A0T`PPL?rjBpzujcE*eB41) zP@68jKU&ACM6XlnXsw0uKGj@-!n#iXId0pr-nmV$rM%pV4Y7~Q8r<2C^+>5FpnqE~ zYU=l_H(ODr!r?oNFu}iW?cU}`Co=0CXo;}> z#KGTryqGMr+j-Elq-bychy&-2hfycmzJPIj#zw4i2q`P_`LwCL;P!&~{OZLq zSoq&fZtI-AaO97I}n;5 zscYe~Qnl{Q*Vf$rKF z^g^@8z(Q{(XXQ@dck`BLwa&>2O%72^bE7;0RR%f~pB%cKHVKNJDJ|&G%^Qa?;`S10 zVjV!Ko^O~1eVnO08ZqeA)?GH}~CdWtxFwhw?*rJXTB;AdMflD`CugUj? zHc`})eZf|goKzccPw4QIJ3P*k3NC!U)M8SN`hC_xr0?fKNU=T%t%F-CHO+1LeZ4DU zaU0y&KSge|Ub(}n@|+lxc|eclBA55(t9+zeK>25d#0Jo8_{+_Cv`*_bJt&k4h0BT= zF(PDA6TU+VSv8`XLWO#|d1i|Fh?xSR65!%4L%6Vb++P}LIJZy&T6i!9JzRc|e@_`X z)jx*EqHTe~ThF`kj78bkRexW*JR0N<$DUr1uIe~*9?8o-bU{BU@XC1-PZAjc-oFo! z4|EMXH+k%VWpJTc40I4{Mqe_ES4Nh@jC&~#YUA?_3vuaW^9rcPMVViwMsh%l@jJrP z9w@7R$E#=0V^|wS^IkFlkRKJQ69ms{xZtt(wY#oqVUHN97PZxgW*i{G6-gPzMHqa*ax>rWPzejlyIATmO z6x5d8s~hK?lF?NWLgON%Il`#D_WbFUI&`NvZkF zx`i@0Y>#QHoFzO<58ap%vw0^UgG9*CuhD;g-8TdtHj z^z2jU&PSYNShl@xSr0g_yeC!NQxN;tKBd=_^ZJPJ91SJF~G2qyM z%EV?;Wy9*%b{+?u?#xl18xqU|ftK#M4Py`-gcylXk&j^DRTfKYCyg-5fMP^}wlHj4 z(cI51+iM_rg;nX7U6PO0l#17Lgw>T+=JjRDRH2CoY7RH$I#h34R7k&%C-}+!sg#T} z=VM&+c=G%^ItJ`Pu&b^ytg|0GFSJ*bMu;DTF`D1P z{4Hmf%L_5)PcDbzHV=3xy?<(4t7~#CR6{_gX6~OgI~Bh7NNXG)XfMrZ-Qok;0OUFH zag^TS6zw}I0u)n7O8ECdQ=ygODE&8Nk;j(2uG}Z4j`V+C*J>J6#|pn|Uv8!7WY3lZ z1n~Ei7qTp!%LY~E(mH~>B@=D_uNA*6BQ5)ropyh~d+eU{IY3Jm&?HH97vmmd|5TVIaF1~cXDxuzPi7Qi2~a@cKm=fQ zvUDX2o8%g!BrA&&(S8m&_s~J%3eG=>K|&E(xFR8}@IYnYX>yJDVjindFH*cu(Pz-U zsiZO}`Dri5Iw`Z2<)x(H^Hm1ZU}~hysuc4xZ9958JO9(fxYpyyGvj(Qv#8V{3Kw(r z+{wWaL9lOxN`cTmXugfx@5lGd5@%AkOcCe!qn|mA2Z0?MV>d6KqHR9qrx(2!DOsA3 z{;?a%V<9aAWU{^`o`3j!zr%?f;Scp{1o|m4UYKA(tK6>0Y`@J9BM_s0Bx}F^I)=Cg zXTWJjgQWG!KE$dRBURG<=^4ZQ^Ndqiz7}4xGN_t@$jc_Hb2$5t#c1`s`LOcJg!fY#@qhfq0{_q4w^;|>f4hsAL!Rgpdu5*6x)S*bv|#AWE~$C zqj$~}O8(K{_v)cYseiv>$p1^sm>KvPh4Im;4 z5J&2PQ+hFJ6aMlH7s@wi!%ZUS9oY3vMTRssK9rWiVKO2f0Y*FusNnVwl(ps1x@%@1 zF8tT%U?B@hr*Q+_vaIP;1qLk~u6rRdl{o$CUjW1?!%|Z;YRjP0E4kIa(r2f}R`d(sOkmtB=q&%@1fX>%VKMw*` z3536<<+Kn5a#x(W)jO58aoE4}4T*irx?72Gx+`c+2r)1 z*%IeEqe80Auz4FC-*!H`YwPw&cj0Y^0RO1Cjb9^~`*cvN)Pm`Z!*g3WXnH}9#`V$2h z>wPiyd53lo?UwuF{j#PH0nH|5KyNzR2ijeAhj1n2TSq7^R9}Ar3#P`%(rW>|d$0Ke zDkZ^P^vko$FfsDM^};-=bM&z1EdYXDcbs~ori?`Vj>^f6Ov@;LHz^FLe9vz}=oAy< zY0%#G#K$W#F~BM6y~%$fLQ2FWP{&3_TBMQ#OHA)m2)FGGkp%3Mp9{+sVnK&YwU6|J zvs-U9xZU2B>|9kJKm^73RK*)^nrds(5jbAY0uSABEl&(p(#HWA!2%!4G(%_F__vSu zPt@Z?zixUFghM#jsoIs3PJZu;H(K7Oh9O8mt%nGMrcQ{CBc#tRi!G}193so;c-?AL zPOCk5)2R#Es*RWPm%vtK2A{hkH?}pPR%m)`kt*o@aADR8AzB2mG^=GfWF$6uR2i~ltgwXj4 zUR}|F zgX5J$OgJj=K8Sx~g1&mL>K0ENoV+#Su|7-dJFC-7#rv^ORUdJR0p4CBUR0doJK731 z`q+o~W2Q#0(fJXPArA|lNfxmhgpFF=qq8zT{8_ON!GL#bRh5`UKB$m<*y{4Z6X3+a z*(SO7s^&YTYugpNS8Dy`d@Vlbma;k!u|tOHydYi@uau6d2FTuZgcT%<@xdKVW-=D4l18dclh}ns%Jo@OyMVB zH-0A>S@H&KV?yTgC`sTP8V{sxn%aVe-|MJgp#!SdG#QZG?}wbQ0+)75O;2ZH^M@mA zc1Op6>r4=Zq;yU;A)e;|)7wz_Ittp{FND6L_BhKID2#}T2LY3`~~Jmk?8}op)Q|8J*jreVD2)@lbow>f0(Bu zBGZ;{ug+K35~C~Z1CKLiZMFLGdWVRd+X*PK)6oH61lN7q^LM1ly`(zCDIA+cd@B%7 z|K{czT>2>)J9UM0%087Ov6>lH?6;e&MZZT#;l1V~7j%A`qE10Ga$*jciK4dgDPMWu zhh;L~D#~v`w_;ZRoS}lRIsZtYwi*9gH~^2XB+d=xv*`wj$@DdLV8!xmI_@3P}4l??stl^A)iGE3T8C`7%U-nVva z_-u*2;R(8{rIXSJv41xDk}zoo%rS?ryzqcrXSv*%Qr}|Qc2YEz7iz<~4}RH7TvI*u zfE(F1-bZYYyTWMcoTKJ*>JCp4vM<$SXqiE$u@R&S?;yjp?Ov~o#Njbhl;=3D&6Z>f zzm+DRV8Oc>+u%Gj+z$b{r0$K8Tx&@@Di_*S45%30tJv^0PSlCceYyXt zCUv7}u;dyS#7PNA(}MR(g5zeyT&bN#4JdJdr#uKB@}z(atg3NQe>O%HE3gj?BaZV- zwfz~wZnqW;J9D6z8q4_Af&H@Tf+R@%wn5e}Y_dX+U{+_9e%>H$W6X9nh z@#WCYxWR*aKQBZ}%lY^s9u)X>o(rHXkJ!vo40XG7GRZ+P%D#nl2by6g`qX1%-Yrj=)W6RYj!`IjXh~Pxh{X4!VtSt0|9WuUIJII)B+}0v96UL)|^N z@0=MeP8)|W#Zyx$4vBhJ9)dG%HrcN$y8JrMMJ@Mj9R0NI)+Gt3$VndxH*2qP=&Bso zp0_%?Zr%zNEzD2?EPh~8-F(bzL;5!*R9J;CR`ywpV8bEUi`3$l5UHopzJIToYs$5t){xOn20o@p3hP2&k5JjIf-A{ygeq} z<#%!6UIFL(iVU`+1#|{pM?DZ=^zt%1p(K8NQ%I9WLA_|!DpLKpV-I2!9Ab!QOWMNJ zR|D0z2d8T-1;re*f&9|2V-yec)cD#i_^^Y-{Ds-!quZ>Xdl>*0-!aKNo7cfW=tRR! zssj9W4PYqVzB0#(XX+?;)W#F1-6qu>v9Hmx->UFUIrRKS1jS2sW3nE$Z9(X%uV#L= zdVEMaNao`&idD7_8&ew$VB zb-dQn_u>3^*scNf^Ye<~IDDw*92y)IfRD&!J~?*i0uzekHp{~C_i%w~5IZ`n3T>9A zl`%$hF*DmhFqeuXb4xjwCvDJz>%$i^syTv#{zz*gGwZ2$`I{ebS9~m;Gz0_ZwmlW; z7(4B<_%|XmFxTd)o!3C|gxCQxK)OMua-omOj0Y+HXT7lK<$T_b`lA$GUJ*w+W?bqA zD>2=y*C(;fEQ2SvmWV4wLKujBf!RVq$Ra+}CZol34$4PG)Z|jy6HR3&$R(zXaF7Lz zLvK>c<+Ky0!95-T(I+n#km%5XA^+cv9%2qyC=x716aR&;p` zWJG;eb;O}=TIBV^*4n&6H1T*)oa$8|19#sC9(>seUA2%u1%elH$Rq~8Os(oYwKbC~ zna)KVtj{s{1_vx*^cs3YhNbWrsX@lx>ky49PH)(o_$=G&#tzjQ? zqb?JVJ3)3unw10uJS`3vi@Y0kqZKXfCEBQKK@X`xRvKr)tm$$znMi<%cg6!7VlrSG z#k?#rg7V&*iPZ7;n;0-XD12r^d6ts()}(9@F%*R)b-8`a_UE%0B1eG#<;LfJH*a3aiRR$* z(mM2KF8`9CUNTV>vmb&Y^0^F3Xh%+xe1C=<5knCYd9GxvqT5>GtHWXrY)g`8MO&u5~t=d&0HSL0<^p%lb81 z(?2&I9UTU0n{$%sOgFTi77JRGA6`2LS~f)n7_{09*#YnI7!;QVQ3ipuVUh2B5jCu1 z>fp=+Z|JAe z-Ph)N@#vU0A}O9tmzz`ZKF*7IaI4zActy3;)_j}-Q_8+?*UscaFf5Hy)S`>F>AXC$ zA}`C^z^%2_JImg*JdSXtD_YPt7PA5j!i=BjX%yIf93zOA*KcgplHE6 z5U_Zi&8jkgbtxY zm_d1j)%e|G=C}1(8Ux2uo}ZLSfnD1nO72!0!(pjv3fr#opS1Hjg5vPkS!oMl}+a zxQ{fJd2L~otWcRP_&bYi5>#FbRmZs?&#n^<`}ya`Fo1-VP>>7UT1nET9pE6;fLdF4 zR}lg#_ZbD4xdu7FXAQbZN?i>a&Y!?aW73(+v1IrbRplm$2dO@d4@lf70U`cV3>1-7 zPoa9|mw=#!>*jo%9v^YOs-s_?uC8(?pI3tE4~Twn&87+z9V)Gnw60GxNc*aKi{S_# z6iv;N2PLr^o(=u<{q$Ss6_mKs11-{x`U2}(1o6pXrqNG2m}6j$!~iV{x4>Y*zuL>{ zxXM<3fV2TL)upvEbg91BBDmT|wR$fsV0y+&)mUzs7s3&pF_`HEGyg57bL%J z7_$8K)hw&;z^GH0>KQ|g^rE5kCyEK}teUg(gA7w-rkJ=ms`S$UzL(L}(qqO;vsvD|k|M>SE zWDoeyK4}R&$BLfHmr-;l$koGDO;HMZxWuvp-5Pg?hx4-(F@!L3SV*>o>f-N_&PUoA z*G6cF&L|m7hS9#Z=m5IV0&j5m(U z!!Jzi=8>GMNogsjoos`X=P_%Qy*r5>{9QvsZNiOWKB7yd!qc1o z%I$53L@=}^L=6Y&8d; z2EMOM}SaKV*j5-X@ZvPM~$JTJbOQYC4xg!x`uV>?k z@TKeU-_!LTC|UlIRd3UOkkV{1IFmo^rbjPyvrUW9_*XBRJ zpmZOrno^p>s)i-71h>36nJ%|B{O+JyCeEN9YJ*YJS6D1FMC2cN^og%7ygCNJt^|%!4tP#pMu>R62c1F=&O^DNV9>bvxmEyGa4Kh zAi_N57pjs=u8Stb9A<#;Jh4X8O@8^h-sg914{EOAO+*anoU=ufK_ri+XzJsk1+;+? z?4rO}Kx*0+scr{c@A2L>lp6Y5@9YiMv(V}*`^M^XjH*(2}-y;`GHoKAn;-mCxf34MTqW58L?ut2zZa`W9Dc_l zw_aGVG(;CoLT;j=m@NOho}?e1T^Py9 z;#;Y-Z9#Q=`!}yAyV`#C%o{760yUO+s;H_FDDA-?{y!JryJ?TU&{evdZ_q03(9Jdp zrK-$t5PBsBGTD3a$@QG^PBTEGeAy*yDDAfAHTZyp(w6rgF!>_jXUIT0_3lIZ=m?LofzJ6}ZK*U8bO8z_Jr`?Zww zL`aWYOVJR?K;#-jmv@H<2jO?_)89mz$lYyQFgy9q-O@F>Nz=-#<`fKn=6z+ncI&l- zz}4~_n?)o?>YG?9&TI#6aXKoed9=7un#x!w9av~R^r8{Ou~32vo3aDn9(x)3Gw<$_ z73s@abg3}(T^I@H51z{LR+rxh1(k;)-Q9n&i;cf^t&n;D5No77+w|1fcd^ZGuka@B z(3j3s!DS8qF6(=BwHEUp`B_U}hrNT_GL{-Z1A_rJ zb@g3p5&ufYUg`}!N$S~R6J1483NmCGD;v&k46z>Y@>2E)say2)%0I(jEugvhxFdJ> zxE-4n()AB09~(Y2fCHyY{Ki!cT-k#rR^V@~d05uyZ}mj=CN?mj^bPIvZI{+|+xla*gCBkK_TqN^>jvzU(9D^%}x#cC{4jY4H_3~%N^re~%%_)D{+M)5e;R(SGeUeoulhYHd85Aa79Y4H|Boj+b>Y@~ z-9p@!v5Kl+y8M)>&UQC~E9bUSu(9`3T0G$E(5>D$fYjtj1=?r91SeBbZE7Jes_X4SUfR`9t$H^zhBQ=uw4 z9f(m%ye(n4A2##HfT2a#^_NkvYo_}eUyEp5j8QBVLdVH&Ry&7`! z`z^@qwe?a7LJa1Ko)?A4y~8D+2s-~FLO3fl%|?}4iPtEsAD0yO?kIcrQh3etq} z`RMdrSClMAnds;bT!1qh>%U*U1Xp&DI4%w^ll!(slYpGwwDevtBCX^j+7L1BZdJur zSecel;@BpM90?(`nuAMJ(c|tVq!IX7_5J2VsYL!Ps|np@rm>I95rwGqtK!FZe{s?Y z4MbavXNT2nC1C@$t>%>^JDCAZmW;S_YK z*mjiIxYf8(Dvbs=lu=NG}&dKc_Ay zjNGagIgOtVC-;|D>Ivnsd0T=Og(5UeZVBMYegzL}USzb~RR1`q`CHW~xa%o)WmSU4 zN_p6jWukIj(JR7^ht`JVgS_mIn334p9Q2l z;n=YX|Iy$W57mkz!YrKjRs2*FezQLeDv-V})EHbcL(%U~1br-?s&Gl(OEz|J^K;JA z^>22`m`iyAk6+@DDQm(`pWvn8M9;!n$1Xg`%qr#Y2-%Yu|EClI?$>kBeSvaBp!a#l zpDu(eW7b<2!2zJms92H~??2hZu(FC<)3Hp-$QCBwa^t2Y`JA)Q4N|Aest+W?6L_E+ zpRfPLxi!wu4iriFKm^(0#uMxyb^>SjwG%iacI>hgu<_<#Gzobj>v|yZl{g91x+25Z z4b>pKic~oi0Xg8P$0=Um{i(rv>i|q+faZvILy|hL$1Dzx1QLZ9#VSU!_V5>D^*z5tdJk7ZanDT|<2W1qGm-NpegQmG<-$0#mZNu?#bGcuj?&8$*26 z=FLO3_Dl$8}sPeWm1tgK;;6%8V9?QeQkYy_Xdc{xNlmvTg=Bl z(bvz`n*E{n1nbYyUhsIwp%1vt*}>#>x*7lyg0&FeIS;S~#Wo`S&!|HdQ=LU*1cL9{ zTPFxEKF-M6+itS~B*{r?Qxs|+j@-Nt~x=vz;Y246u%!%x8mFISkU+}2b;N(d6TAl+qfhKb); z{7{p^>6GH!E?ew<^KcwfJn>qj8BV{K}zMY^R?lr-f1BtoYLlruYswz8+N&z|L7T{bsxGwf*rTVSAYl@7K8WgVs~>r0F+bJ zA#4?F7+*-?tk!BG%|71kVR?_p+ju#$gQ%Lp^(mw2tVb&|Md=i7xRfYPMfS2@F-w;ln4W+u|pMIRM3MDOKVoa+HN%<*hqT%R$SlxnBa4lwm2@ zd*9QXvNm$onfV=7!mSlo*J=(D?~M^M6_SY^shXs{l{e|FAIb74DAp$ynK0D z`5<*r(yobtTGXCr##xH*H#z6xCZ5+*69iJWZbhseCn`~#-y?A+m3 zjv$-;%ciMMHIm0Pj|Q0+68kldf9;*DJ%OV{00yGrd-pAS^Us1k>t#RqU)Re)HRVDc zzK36^m>+;9>d0&Rg#&+v<$j&$>ztl0?o`ISM?9FPmnEe$$b2~(4JYtIAkmrk#~7rp z0QnnjMsv4+?AU&5^O{h5nS#pr#dn8vsjkFGftneU0F^<`AN(6_!R@2olG`TNOPm9M zWnb$%Ku!Cm-E`y5e+OltDK7ZHIiGK50{5w>`f6#4C%8iO*e|%g>1FA5H*x5>dC3{{ zl7PM1PpMeKj}>lU|7?F|w;C0#<;9RpC5+|s!W1cU)0M>M;&*)re#s7Jf*Rf*@-A~- zmU25^`9(I*qY05-$k}5TQ~tJOZ2vk%2GcY|!UIGK4B<7t8Yp2VqnT&giQ=^#xusI_ zZUtAkoh~6wpeC-ztjGMDPw0%l`uFoP>}j>wEr$_2LfSSuXI{9S9Tykh9lEkB8%QM* zS{JoebAchG)k<8WRrP1#7Oj_;^dqR_|B`kP$uYn!ATf2l8G@pp#k?um%rJ_g~e>&*q5pK8Aj43C4tw1;R*#xu%0Yv%5 zddQ3EXabO?AzjA|t|Lb^+`BqG(i%21f>!`TT*_9~B7F*x=A=e#p8QV6q?v`sLi#Fb z=(GMB!kj#ct~1B?eto%Lmm(_J(_ucvxac&&^VLI57A`PCphhXKK~{24G!uxRp@>r_ zalF!BfH#T%9F~{iH;0j)DdU*?HoBKzEwr5QdXn^jOK=rRXV%WT-rRh_lEW1Mjm%Q) zW0WBn8j9_2OIJ$}TUi!CMxkxKK$2irYvzXNB*<>RhDr$4$iV*CzTTO(r z=8P9FN8IHVAG$A0w@8L!(_hUc_uE4Da}$=n70Fy*Fs|0P*cd+$XJ^p?-$`Ab;B3d; zR6S$V0AvVTpCq!b5jKkplgwO%ahW7jFHq4N*6IFLaSZQoDad{tilY7bq3VAb4O zjULfQy)MH*aTT2(EOva)93}8Ow>5`J;oK0I^_$Uj~Q~m8Mk#`PBB9YrRYlP$@}zG3qAn znGX305Vst|yn{|OjlMl29wfv;NaePu=)X9=kb|p8UfAdQ!7yGkE6dSBCP_fprG>yX zCLO{pgQ+p7$qy06M}cuyZJ3HBguHgeV$c1dVm@K9wt}INMSZfr-#iMSy|mr@u})hZ zw;J5X2dHw#Xb!5!omsGZ3_Ngdh>djQ%LF~uw|msl6{onDj8zH z)V*W`A|`PZnk5T%?KFfj_)Hp}!G8A>ab=Oq2``i$%1B*JicM`5X{Zn1r=3AZ~%Ijs10J$8dmo&3rGpzkoE zvwVe^sV&g!U2d<<`K{^OOE*=ne7XKtwFPih%FI$!1@ls~8Es$ypj1{ilNm%G9KvY? zQo?}OPaYC*E%^$gkT%4pLblCZ@Oj7K=OPgcYD#`uV0)C`wG%q~Wpb#F=6md-2%8v*{(e%oqH#UspC}%A(S#;NCv5P$u0=d1 zqYy2J)i{A%YPUz6Q$SzvRkm5vRqb4-b;0=99#jlw7K=>#OI@yZd6LvA$#yN7f72;(2}WT}hZaLl_-&=E%*oxjn>27b zy)tVBk7p<99T4RLN!WJDNZoS)@4b8yZ9y*k%~+2(V}1}L-8UzDf?I3ndf!Z`XA{#m z?PI29qF@8H57ATs_n(%d@uQYwu)PmTJUO0(bS4Ped~dxsyv3d-Lr322vyO$rWaS-= z7aHwtW`x?U=wJO8;aVHXk>kGr0kW4z{BP@i6`v>b*}HCM&TgSz*f9JUwHl*}b_@s= zPnrOQxk#^nFckya)R~G>xOjaC%xp=^^F1w$Hrc9Cv$ToZ$gl8a1hYzxAq6DW@curK&914q8T9{k})iyPQ0?doY6*qfA)fCjz7NL%85@`wdGXQRNUtB_c{`Vh8O=C+vdizo0J01 zYb#qUKm7-fWH00!QRL0DXCM2IIvB;wF_LuKO7_pFt!RaIP>oT5{UNjDfE50hf=%rSQ<-ekxLUKALUg# zPih!5enSIS%P4p|Zjqi@>}w|A+}e9_cghA>$;pc97v~1v+NL*`@5f7|z`jH+J1_#D zT`R2%C9G^!>@C20+bCl!x7)m<0%O34DoYr+R!&PWar(+8S~hUBbC|MIH2j|A*x^*uC=NgP(|>LL z(1m%L0|U>HYUm6!5%q)ZlUpO7x}oixe83KjyvebbP1PCiTutcyHBNWhi&Ax=M+H&4 z;%vLk)Q>}Q`Sy3Al1^`RiP%j?=~#^oB%rU><8T(fERkWx-R za7`Xcn7{_J3G05_yp*o0JByRws*u3b^ZG9fNrx6OEi8RyoDE)U`7US-6nRKUdcMq8 za@*>7&?Fx5iz%>y+*Pba%mSuEdwExL}D6Z`w|p2}4Og)&vwK_E0mvAPg@>bE->qDP@7dnw{- z$P^_5Gt6Q(PTfBhGEy*<;*Pd4U5%$y;R8Icakqmv+3?;%?R7wIDky)-7yWE038)t! zObfnRy|n2c%6>@Wc^nYA5`+LT2W^yLWzeIk&rgW{(ljAMd=@`95A{OcioZ$E5VSUV zN%>Pm7?D-~sP#3mKiHw(FSxnhcO7W0e%k_(SX+pBsIVi6VO9#z@YIZ0U0p|bWl#`rqe(mVP_wEHSG+o5_cvXOG{F^A~O z2ltL#EpI`0e*Of!MJGQ|SjZ{_6korlWHV14d%+IgXs{vB(a#`;kRceiguV<^tj0jO z`P4rO3e;_@IM3N0eC;FQdXzWr60{Z&-R#6=ghweA3Z(*-#L7z0CwH`(Xth%jwUlu!C(gX8!rD^oFxSpI>dR&ez5TX}_0( zKy3yA8zD+WrCAw@ej$gJw%TNtm1!&`OHP|G#mQ=kI%{e7vEj167-1tw#@PD%tHPPV zKGtezxt{Hx4ymw6HdBr4&{ApP)#n|SxX-$8fm*}6goCBCwdg;KcU8#3Q_B`^I zd3nt!P*yhI*tnd$6b;7e^_LJgKzg&4K^w_rA}ii+xZWkLpI%jzm)d^&l1aAof( zE|czLZPJe_ihq8#0}Q+$`04~qJ%~P&Vjd7;c}i=>uQ%_dxzVXKC(rA&(VL~ddG>x) zh990`ysHrEPVL z5oRgAkv9BuWZ3C8=OF=e_CbdsY4s#~uv>P3qr(n5GZpNmR|zSLg)M2T=hSWyu@U|m z_%BqXr#yt0t1n5uqBzs|JM@Jmb{4(hATI#GBxC?c0H2E0vnNgJ#tk8LZZRX|uBXYn z)z`J_ZQPj75wQUAa+O5=&g?cr2%=iW77?4jPv7(y{rcN)%c@0(uDRPRWCqEAqokw- zhcd41Ml06cTGFfmH3He?Eb5szYFXbg>uef8k`%l1+9y@2TEHU3)Fpe@&q?P2;sMfm%hokR0b!0cphpYE~>t$gcLx zL{gGin^-I)2*1{an%HRe(@y^2Rrc?c2vXT>L0C7{WC-DKUjF8~6v!$8V6aZEPAL8S z#$NZL790spPjVg}8?^MkE0MK=+OO#GU8mE4Nf?PMYYz4cMM!Om=UnBK1UpB>K0jzc zPA6L;WqBe~SRVr(11;b#MqnxgDW6tbZ}(_2HaWY|;@9FBZ4iIxL3if0%LrL>YtX?a;S0gZ~erN?A5 znqx7=y7B_dA>t@3E89qVv|FPhjMyq~W;Y9Xs6%(9EfBKfXtbD1d2Qmq7wKL`*8B_@B745XeEfnQmH-tH8j0Mr1eP zfs_tP;+sX%sI7Su@kYx1DdwJ72Mw1)bpzt+soQ5sg+%-$3K6D7PF2PysqB;h4duN< zKQ-yyEvyt{tEJ=SWpdtGclU5w5&z)nE_XIarq25eJ^<=5R*^R6so6KA_x=R}V8tVb z42_;0M0Q?Q*>_-#>@3a?6_Yk9yj@t9W4>K_>`PTL^wBpkR2j>P2_*0QS)WS_lKytm3P-%_eH;seo3k6%q~K78v$RJsMr0oH3Z=t`Y8 zLVS=msnK^oD0>?t1z-gU#3v6$+^^kPobA03a#bRP#F5K>BOt7_5)GrRh*|-DZoZgw zvSr~zJJ2JG{V+AE1~R3RN>) zZr-%|){6V{oSfuVSH-5As`1uM@zGp(XsW7lS~m>FV3McI-(qyOq2w#mqE>n_BOYoF zr3KJrw4ap5gom8ajcpm3DcTC{k?)bde|(K!WH6Py?%D7&x-O!fUh@|(KpPwnDj6!*mkWoP=RC+MB+ADlTwA(phRO}^Jpe$>eo zr>-2#p83(1l4z9JMkOTqhk)~BywZ9!$OGBuh0S#=edW_}VlE%ZrF}C4bn^4jwcdK* zHl1vsHFV1Gi(T0r^7hXc06X6xI@z13g~~NaW8TkJbg=bce3S6D#^h)|WZXk@ew*jn zc6f$@7BGciZ=}RkV4+XTr;~rgQ%8-xs4LilbjuVN!x zr$C>wa`2?_Qn0Oqd8LNI*(dZGp zjxdf~BK>s9c=K*8mT3wu&~DSY8k{WDs=39jA6l{AJbmJJf)^0sB>{q!i1=*fcyZEt z8a`hio~xf;jlP!Yu7br+V0)69p&lWm8mZrq;Pc7P+&(kh&n}6EEj? zOx=x6T0Ynj9X>cMve4x^U%2ifi?>pEZx@NS>Z)go2kuK4I8loNy6ag4Le7eyBxs_Y zpy?R7m6yciswamW%c%BsTVaZMo7ezxCOJJ}WfCSShiVjrwg>+}sS>1S*J-gA;UDjJ zrO$LoiPF(if1_@UlaeT=m_O+A6sHQD{d3LS>aYryp=zCe*xINTJ229l4qlDCyVd+W zRb5)_T(u0_`(ui*UO>9;_gX zVLV)c*>axWUWX)1^8})9T4N%Ar<<9Qq8~ee}XcP-5s?Z}6S;b4% zvnSUZUix;!J-R}#kd7JNv4=TT!4$)gt*THuFXTUd-n`rd>KQLC%voBihiA3%g0~_T z5_cDFm~J8Qfk8N&l!fzI~P{_S_8wc z)3mc-Gi9Xy7IqZgptGrdAeV*~oB-9w2+vwoQJ$#)6f;^~-2f|<=;~z zr$F00|1SbDaQQ2VqTpx(=!P|SS9Dry+@z4nxXK3d1El zMcaDP1U8E81!q3*?<=5nLXpcpVSCLlaGN@Ty}&)AfMwx1j=b1CNsWEB{+oIw17{Q4 zgAr-lc}vpXUmSFUqMRjP^UoV=4Y1^we)#7#8jN9k2+n*M+#-3{uTlCcKh)TH{-oN> zzKuduHa+}s!jM$AE!vXjNOGtT z?k_a(Mrl~CqxW@8nj6$nqGsv9Tz=sP7clXOy6S+P%WKjfjwqI&UIPVD`XnRBl88ug*>sklS;&;ViwB> zM&4Me`q%(+aeXHA9VN}2kqP+GA{IYjgX6WvDkqyxc$%h?V7Pg}RUB9AJNx$cIvO&~ zK(0crBmG%?$Ou0hHHVHOn9XW?Z-Yo<(2JyCAB=a8j_E;|n6@;1Lw~~WCMrLeOiV!; z$(SBetK866GqO5VD7`Ak4F3H04{u@0VyaN&fRiojln!An_weq&*;Zt;6AE)H|-C4RCxA5cDri@QFpI5(Y3ZS^?FwtEvcR=Ytf#CO_)~PZgk}^ zP~FbZ43nK=?1zM~voX8B@y+s5Zytssh!+bN*CEAP%Gmk&oi*)H8L990J@Mvv?mZ*p zkZL;BHQIT^2fMP0T4*P=kFx6bsMxqO$N1$sOF0f5a5cf@L|W31zKB-cazi^4Dq;G@ zdf#XtQk|Bkf?i3e!4&eLc3EY(UT0CJgNAHomzG+L=y1Yrt>h^|JMI7`)*jT+>nZW| zD6Q2iL@%zo6w_7` zaji4$5M3@{KQmvCypJlYZn8lb$C+4LAM&Lbulz1SudJ%p;pn_v!cIZAe+~i!8!b#$ zxF2Pjgbx~7c00yu7K}tgDjWPY|F&9Ct%l?cngL|X2j={)FCb;FZf4saP@KU*AxTM+oS_VJJiAeyroc_UeyF6RFvzc%0)k)?z zHVV8qaE&}ol}3~*tE>Hr#e`Qy z>OO}URc9kNM1;2@x}}}sM5yrDfFGsz%2xjq3x(jMHQeh*?QUX2f?OO-nHCtD z6+$<6Z;0NnXey*tCW}$^U?&=gPM%nr|M&Hw=@P!-Ob^Z=PAHiA8aBsRc96M>zBFxU zsm*}$-^xnN^^O_2y>g*$(4PH7oLnm={AAzMm8oQ*`M&%+RK|Q%GZ_xge%&=xkio`r zKAW>SE|3~P9pwe)vbD0h$k;(^^VaGT61mW>;sc?X2kuC-mm-xxj0wt9HoPb|Y>ewH z|3SI&Ib4tOwC~dctd6lStGgiodnpcZQ7unumt-?gwzx z(n2et-bm;A&o9IV%;rV2g{RS`qsZMy85%C*qhk36jpp}*`W?^$T_>bWI*)%L-JXfn zjJ5)|zV78GLLnK(CXE<69{@~u)Ds$=E!3lDMOKm_#<;KvXDE7AwBG`K>xmoLOOB!2 zsr8g<%$L;1)3Fzq$VV8TG7w{wlpG%>gzEN9!VtFvk~#h_o5D|O)imrfmV%I{jCKYD)}Wr&OGF$vv}p=NbKF_gtE;AnEXcC_&i#; zuse#<4Nwkag?U2gF8?<>hXAqRs|D3y^f7*uHGMAgJ?Yq^b!9dJodK=L0;=;Lt*&U1 z#D3b<0_(68&z9QEx!_0&S>#a_GI}YG0xY$M50lp|aDpFbd=wW47y^K8|KA*UBoPatXbKOEp=>si1X`3xNb@PvX5G$Kr|uG#N`G%_;U#1Z zYvS)cV_mIay$s$Z!gS*<00S*l1&iS~OiK>Zw#ZTt*EkMcL#W)Jq zgIcDw&Mol^Ew?{;eS|N0h!>`X5gg!?$zN0KLk#fe?@*2kQ8n^5`3#At5xTB3pk^2{bj~lq}mSM|K&HM8-?o= zr9n`Q+haXbgC7CE$O0tdcQw!-;wCw<3`fs*JpQfFKa4+G4F!e> z#RJj9Q9RA$dyXt9>izW=7@n(I%5a;yZkZZ+NDYuem08{GS7uFcd(n*ml>;tEpTmcEz3LEikS%8v`flq0{z+Hax;)Xh{g*=Lp&^SF z9V@k&v^)GshZ6?T`ZIPV`zR#;1^hIc0;77Rt**8X^8QHgSblM_O&D^maVgtkJmY^8 z0RVd?fg!nAiNT}FSS%7MC9tre2v(^b;l69Ly&#b2_$4E=o+idv%#!9y{mXB4FOXpd zpCQ2iK?@np^+Hs9k&M&%LbQ099uRzEtzg|4Ew9(5M{kppus8FZR8>axVfPnE8Vu5}>R;D{y_nCE*MsQXCT*?Y5N3F2p zA3>-9*kDBIxrd5ZZgNY@%;f0%bf#4gJ{7!j{lE8rT2B86YF;EaHEhG`3;B2tQNTxt zQPUye<+ELPQ79)4XaCh{Ef%%z>iGiA{;{2^0jgx`pfX7|D4#_BHet&tb?N|7!kw52 zGCx@{Q@}RL#s7dgMc?l($jC+p9`9k8RvbE0l2G)3u_K~C1muxSf8=L)0PQT+Lb|-K zIJMjOf*XVA*%Wwc{eVMsJx$jfYp4}{8ZA(4fPZKM<^#rn$w>{lw{D&mc26z83~n>UC z!)hE+ETh@7Z8~4Rz_ekx5%lo=q24Sh7wsRvHfEi4vexELz%U|_gVRJd-{k6{!t(CT zk8MTd%Kx~b!(bsVMifE^Kfi-mB#{=}TgNGx&^96{+8>{K6NsRH;WOATDTqAP`4HPf zWI6LW@J!GH8BdRdkCVUqD6?i_UcWYhtFTM-c5LoFe>XLDbT4KNB6cbs z_e#RTWd89W=wbPu4-jA{K~+671VTj8041e-duw2v)Y8_HE^CyZ1 z`a2{HU$-gCwbx^;{*q&W;z6+KQM~IhzAYO)-j10&vUXU9BeDS?hoTv?3+^hRo@iLu z@45P0Gq!iacF>FZ_L|2W24biP#%1o=*^zdQ?_A@q$gZiDMiCkNv7_Z|Qf)*daTnIPJNJS3M!ADYACH!V#O!G#Ni^LK-*Y%u3E$C!Ep=aJ!$Spd0eN zCsBjxUx;9YGO+$|BjuQg*;O^v?=_-Vo1a^;DWC@x0{%2GWF(?c$XAcSP7Z$b6;Jbh zO7sHI;_A2FEM}dm1ulF=-G+YVe6t*Nc>ni@4kgS`LG_EUcAMeYFhNujCQVnueR}z1 z+dbo8n@$%90rX24_`SFnMt41>_AZ_#{i;D{WrvFkDC!T3$@wXq01g-j43vbW#F88i zr!d0lr4YR+8paE^Iri3B`qwGYl%QS?Usm2pa4tz}BW_ksnrX_|I?nB!R&m6(I&IOo z{Gx-+A9$#SX2czn&vn*EeLl~cK?>BdA;ctTF#2gf?tsGy3*XEXuD|xeou%6(@0bzq zv`<_On$_YD-uZ2gVYXfD=~DJtMi6o{n=u#tcWeQoL(bo;kt$q*sy^v&mVVJ?yVSyw z`XmjVGFWXmQKTTTK4WepsTxCd+*}WV-KYU|m!76mF@7Ru;rxZra+`L2*#i?gvE%oBc_DK{NAm-HVPj;&@NQ2d+_NGXQ=JUFS>KFMAuKU{4+ML zx#_MHl5NvAgMDr0v~>Gsh1Dz@^Fno$VGP)ZBfTz4Wv((~uOT);bzl71a&r=3=29Nf z(Nc$clSAxLXvlQyV8>j&sYrrPyXl^vZ@EiGDN%h`!BI{sVKCEtdGmFUAW7^XL`Md% zTw)n#_T-q%?!fdpi{`zlwj36Jf*-9=;u;@|s)+tvr^`mi$4ly!x5eZH#b7^tpRC?@ z0k5ecuF}PtHAeVwl&^)aoV2FGny*oCjoP^`e!weK#=MWzX$=bNzq6%)!XRLi2EZfs z`a=c5Vu}EQ3D_{??-gMDMFT13wG*j+*b1Op{Thsa`W88eg&<})`*eCj2awGV&m&Hh z#LQ>!#|VxK6@9xWdE~>Uvh9_A!;L2${=$9EF@ppwo za1~bh#&$XiD=SOyz-_dYgv_r70|d&7RoBax_T0Y?$)w*QdHY1=RFmkkr}iFBBaCxg z$ZLZmgjJK=%|?PTt7Fpg%F$!M3ob9b-J5PSe!uA#gR1HfuXOg#7XnooKvFU-1wS)} z*BP;5zCJHDkam?+ti&PO;RaldnUyxqD%Q*ajuNiY4pzTU|EW=O{;in*YSfBIY~*cy z;b8IpL_@0b1_lx``>6UgCrT9>sqi5?t_=Djkdf@K*LG)$%LPmjv1N_Ch)*_GJPf9E z8@SZR+T8(w?@2P>MAgzR=JTz8bosudXYSg#)SQ2>PiIup?zAUM1y)_%Titsiux_G# z)xzD6xJO#W@S}RdK#Mwq0+lEXL=n9x|zi6mYL7_kgf*%_9t=w$0 z#6?h(8jd|qFy{^;C560dea~G=MK3TrK_{*$%IEZi zVLAT}*ea|z;!We+fzTx=)YSw%8^TwZ8V048w~ixBpl0sWiE$M63sga9jn`M;q8ryP zdbW_iO@&toOkDG&pmyK!1F|ZTtf(K+8@(@b#b?kn92;OhSC|qqMB=G^rS8A5s#f^4 zDNPsr`*3`)8^S)rf)_P2d*s(Y)8(1qv!1fAl2OSfA=X#J%#lt18cgCX3!FTp3PVsB!MjujMhS{{0=LuR?$J{Q8x7uJ`H^E2lw^EqtPp~z90#@fSD!aC1;VSKnT!0a znvC8z{8hv|QCA=M)1L z()_pNGl6-G$eVOW)enflhLRzC>K;kRMhCamZ|rhfN@`^;r8!i+1}U^(5;$soIfBY; zv}40mz%4;lJSlaM&8&f%;Ft`8U^57Tzmt23Z{m6Xv_Z}&Q?@TeiJ`ChN)v9EQ{-%l z=wWs!rRI3si`A$a_`_kXtm59YE`oHA0_#V_PW)5xOV% zJV)+ZWy%cpHiRrSn&ftN>WP_EAP4q9r-T4Z3=^CizFYCDTXKt>{Y3pA0P8>$zdHci z3t&07wMPgW2)3SNoUyu)797i+3jx9_53I(Y*FAzRlV8`_5uC2+((V`D3X`7Mc|F!b z>!?#8^CGgS$3W?8cs{TkfPwkRU|0AOFi&yjkBen2OG7Eey4^cbR#=4oP7Z6Y{2O{ra?yy#QJYd}hZ9zP29~$+E??!jA^- z`XZj5FVg@a1O_1>#6a@Zi6lPrH*vj;M80H*^_Tl(%OQmMpDM}QKa#M(3Al3N=Q4RF z7@rLL6C#nvI@kPz9E<-S^CH-vC~x$7x>&um^5*?q$Vc=q}L zh>*g4CmUz)iJ)K_m}||Z1AsU8t;B8X7a^LOn{C~Mgln7C22bhizxcd#g?(hDyEnciY`eOltu(AvZ1sf-W2qGuu^($#me#RfSTZRId8kIP#gZ*9vg9^n|7eOVjDbV~TL*OqA)i&=t%rHZ)4Yp_;t z!g>`gT_a`Tiq(yYaVct3x|0gZR#6I&Afjt7=>J_bI=6s}nP$aLJA}{;( zQCP%&tYMYc#?gZ!z3*iAh5TOZhe0158<;;3Abt2BXxt5fhqf%nliOe6p9x&M*#lEq zxqKQ((-2$Twloyabr^JE*MFcHT1T~dtp%J^@YgsEAWssY)E9h(_|zYVGU9*iU!D_2 zPQYnnW^fO4LJ2x!UGILf?~}+>({e&EVpcJfR>!wbl_{h zW@6b^#q2}avTW@ZudMIaIu0IbYHOIksd%i3662^crLbNUW08rofF*xzQ*IL5!oV!6 zk5pV0S*#1r*087kUVA#JmCMz|q>=Oqk0cXTjHZ*0qdM zb0!=Yw}z0ihCwM^{r=vM`bJ~jeWVty!rJs={mS8-CF|FZYuS%=%%oS=&9qIHhP_nY z2WAQ90hW~~T9Uk_XT@1RG88Uo#?oYC#cey5y@@KfL{~HG+7;Z+YzUROpHl;~co6fh zX%@r|ZfO>jVp^%InRTBh1Jr}6LX|G8=g1C5;tHD7mHeN9GSf9DaFe9aTCk01N=(-(WliSxSJxZ0mi)SL$wZhHvuE zlz}5~!K69y#rc_kl#=bA?EkpCHQF{pWpO&gnTMSp>(|O-FDHHLH@mK9v2Ld{+@(@M z+IEe2JC%nlWBU>w#Ugm6@O89?g>AvDo8H$;1fF{Qv(5fhS-y-_%t2TI;&>QSg4jQ- z?C%Bq^b4m0VbEaLiR85ROHgqlsC7glgj-{6`281u!xzn)T*1k<*C|~F{lb)h{(I{W z@rN{S2(2SnhYf%MpztOPi5X-mfN`nDyTR8XKK_SzNMmhJ>OTx$nmi9d65uRh5Hx;K zzjo`vj{2!y-OrAesiF)=tJP&Bw#xc-w#Fs(xEvQ>ueIX_X!deBz3hQ)2lt~QW%UaE zrqYqba`3XCP63*$U9^UUw%NA)e8t4|uoTmgD1{8j;k%Yv?GAv;>bly9NZ z)vQS88^V+#NWx9?%mtf+Br=Wv(ZT#6p%jhX&A91{JFul?7gra1DziKA$}vqB0lB8- z2eC(d+z47ni4H3O0|5Srs&JG%Pk6Xc2IO}Zr=LAj`kh1 zGj%^_GWe>etzT7}ly!0E+T#Em@tTOoE`hJy5>B>R1hN$lK9sS#CH=r`ykt*y6A{RI zf2l1{z7VAl%D~o&6-PWYhaLKc{q_s{fp7te_ROIRngb*-#wyDW;e8zk z@XOC{$KHAwMU)S z9sJpXuN1VNEMeB-6@hE-_jPnd3l_0>`#^|`ONwF17G(h8;D%OMzR5V7)hoEBWqrZ% zf2J^i3u;%hq+d8w2$#{Cb;sk3G6K>+*;*Ndx3%rUufMn*^<9k)R<1jQ+4f2IWw5q~ z9W-DrX#PIlftHp+y>=Rsqh3aL>_j{k3LB#d!6X3w;7gb^X}}i%nmSsrv0)b`_8A_x zgqcTwNvLF4O{iwYp3Xf300bP}&klpHcsP5repBTO$Lrhrl>*XRf3&SlOuhcnc!zKD zM^Jp~kdfL;g$h76KoZk~*eOZL=|x6raScN)o@4$3+3ydl9^pr$$NNOQ&ucN_gBfK0aDy0C z%pJj(1z>&aR{ZZ5x1p)~kQFa{huQj+-);8(7Y^6)Iboe_L1#azeh-c50-v_Th_!`2#+8PEI#+78Vc5Rj~u|mLY*+01lz7N?K za*#Qq#YHp}?q|V8%e#^VSF;BfG;0YvyQIAg&T_VdDWw>L>2HpplTM(ZLFKVh;KPQ` zaPzvqq08*%>Hya!Fpc5sOzpc_xr(v`OL$c@Xr9&nFWBkp!fPFsI=lg#P~jhNJwR@f zf>ZI!*A>2!U%aw>1VBwuCB89r0m>t#DflM;QEIM#x8eQb4zsFic*maJbtaCL~c$`IizS~}wSkbqYJt>{`-g;~}eZ*^Mjune$}gqge>T69Y%(!}fJTP5ok_5)#n&?$88@&On! zkL|DKAsi2R^T50K>xKsqr7`Xl+Q11FV6)RNOaZ_oZv?}!^Bb;16STBE=!gI?02KTI zUnPXx1t91bzM|DDgRfhfibyHGIqiJZ6jX2kCLn-`y(^nOY-}upbB$7#*|ec^?(i(i6ZOj|{>u?s*;m*!oz!9V+XUKcQ(u zSeBPhL!W8xq*vg_Xor@TM;$Q$27scQaRCT<7!(Dr@bw5*SsIn~ToNh5Hz%Ku0Yx>| zBb}xm`mLY0to~wm*PfY~x}P~XxwxNu-1^P>z$_Q|3jMF?Tf%nmau%@!x++&R+jdbr zpoNjboU9SRas&%|RDdiCWCe)X`Mx4lEw{9jC2VFD!XZTJ=b9Jp3?MQYsrCUPgegos zzT*Wf+VQ*^oXm;!X2DY1g2x2hB1%^1edf&2p6M#>xnpz!Ze17KOVpwooSl5Ys-UpW$&9-es05KU#>Ep&wgQw z-!%QJ`BUE)SxXB?AbP3wIYB)Ku{lT zSpH$l2PaXI;*QiJ_2+(efSqh4suXfeeJ<9o*QrqOlvWbYwc7)HZC0+~(-P(_UVgxp z9Lh|qxh)xnxqC(sRcwg%vFL1;yP@rtuM>z>VLW6d(3M)pW?B%g#=y^J(9;Hjk}50Q z4&f;6#-BFajkgc3w4FRhD_7k2>5^7Jm=cPAYy21UW@u@psUs4=08nrEODFH@# zhHnOLY4)KE0)&kazB+Cp##RhX!I$l|Y>l?O_0QI~XHl48-u*0xr`^JZ6nN~6 zpA{f>>-U9c=u((mzpy_D55@Sm$HtC7hJZ%6qY@z85$nM3*4>6rnpQJq1I+30VdcU% zgav?iQIM}ReLME3$BU|^CDjoNU;rro3;Gk&co7uSq~P=nR_8Q?qf{~s2p9o;W%Rk2 zTs4|^M<=$s&F+uhYzS>We^0N?_3R2?BTY*<*-jq3)^YJ}Y33-nEeOZ!RV-jr`!e@) z@-LM`m@-gHuiw{%JBUR~IRRn^2m?m+IP;u;mZRTXDpm?0k)9!yLmf-e(RDU`C_*qEgiHY7559y+;{snI zNdr)2a2W{`;RFX-MHb4-(X+oerc7q@2bQ1IeCceciL8#G!03%akFWfr!;>XtD&WpB^~hq1^~Q> zSl6|9BGO7~L2!&Ce8n#z-;T!IZ%QTKMtF-s7qB#O4EE z76I2Lt}qt}7t)fw^XL_OLnJbV^BG7Wys32yZd~&_G)9}a@03_&YSZ@P!5geo(T+f; zp9KQuDGlGkR%mHuQAY=W0pKyvt{3R@1@998n6HE{z(lCPD!^CrE81o(5MWL77Id32 zj4K-+e!xCLxjWwW4G{Ab~s3gfUWY$vT-+W;}EEar}}KrEC+ zm@vUywr*(2K4Gr0>c7>kRUjmg|iI8pEZAhU#|T2S zTX!HzEeNOn#bInz8P!Ak7=4y+H+KwX7zZAh&d|%2`&ntVdbNIK5O;}ZA!ev{-{JL_lKddRcIW^_Rmf3tWlq%z+q8 zm}nY;zj;NP$@bY+ux=v(!u>I7?kuqkS(;5P zJ8&r4ib-Xog@<+gU$Id+3@Ku(rUl~ZP z^TWHH?Ut|zV*zvmRFf`e4zk2x04wHY!OOHK^+qLPi~>vSO(J`2oM#mVyi!x z2efG1CPB)dH^v?&R_)dWloLw^;gNL4n`6z{GYC)!_S!RLCbyREe-(e;dT)}pzIDDXHeq>5L5#`;p@@8<>V%tT0R!v z7|tG(28}1dt3AbK-thSii|KGO~U-*Ru?`OuwrDTd9j$ z3S$xKB7AvEnrjogC3EpI=Zv>T`7QZAFRI*fS^-Od8z7(W>kua2Hy!q!Aq3NW5L9|+aJU;dtTsy$e#vp5Oz%GDNPKZ-#u`xc{{YUdO$}#fB~TDc1$pd zv6K)DN(Fj=ub2KOFKH%~j>2`rE=QPzQ`Rs0P^T1)??&I+wx8~qqU!&Zxu&hUSQoQX zoU{Ac$qmjPmtRX9U>DpFfolk0W`Swgr}_{XY%^am3$O#3J1-oN`7Q1~aQ({hYb47N zEH2e(TjL7i@{-E=zK+(e1iUT~4kFYrBX+^|Fd|dj=J>{F47Y8$1F!9WgEv~eETUmc z$J0BF1vxrFarORdV~;^gtB0fkoKVHx7)oNsQV@)l!Z!`D`f*E-E*^^S4800PMuA|7 zr80GWZhrc~hS>VqEcl$TJ)S4 zA%Wpw+9ZQ;r`dtuZulcsHhsh%t~)p#)498(6%cMACi&97YojkiOREQ^0nDiEZd3(m z-~|F455O~g({W47(hMsah#wBQ2F1Z*;US;0!su>u=koWX|D5O4|LZjUC|mAlKh|$D zK3(hAumP9fx4Ebt=LxIeD<3V**}Y7kFvL&xdT?NdZQH8^{0d=7XeL9ME!k@ZQ}A>I*+sA-(N z|Jv@4eP19gtqi3BEU4doDDLhIJOP3;fy}JhRIn%OIlfaH>5Cr?yA~CpG6sCC+moQt zYRO=KFZs=~njq84a&Fx!r zwyrY;l|$WL&L&D?XYRim8=$3?2WbGyvY~;IdSh2l%GnCrr3B1Hv`<(XeY# z6{-Yd{iXm+K+r)uR=(Q(!q^yzmL%h309L!0bL@T=tX~zJ4Gyw?En%69I#Wy7z7HXd z9lU}oT51hTF1%7~zi)ht>j_q@{cP{|OQO-PshsTat-j|M=1Hec^ zHq{==w|h_LUev9t!|skfa<~fnN(g&R=iD#65;S(s-mhSvhYO;ml@lG6^MGCT7-H>L z(Wi>KjJ^P7vcO3Tz66k$VZoJ6(_*&aqn0l)rDQxxf<<7h-{j*wL_)PAgJZYsFt>C^ z5iNlPZYsC0d*85wi#dyfuWW8*i6zUe!<@2&6_DnvY0w-_ zvaOIsxO+jwJ@57=lDWPX{H^)E{Excixlh^W#M-m&TgJ90Y?-nfo5UuVr>>}?gXdb8 zTyVKF*L!RGHvG@ppJ9L3e*5=@{R&9FR|JZtx_53u`DdOGX+>ceC4IElC~`xZFz~;`<`@yj#M&Q<}Ai`RmLfk-zXMQOO=}_44j# z`#`H?3T0og=Mm4=zRAUzAPBThikk*Ew;@DF&B0z=vG;3%UBjcZzHbAP8nR5wY4sLq3)rKo?+sjNP&@>n0D#&vm)dc0i}`ra6d48h{?M;sbm4HY)-Puj zQ*?h}e*E5g^Yas}wy=xpf+B; zFbZ3+gqIzB9e>(-3!-K>dsx+q9?Q6*!2tn;&|T)1B;*A7Et-#InN6X#Wp~$7YS9l$3D`fhZvS?EYu*x9xYqq!tY+L?3f@2VN=C#9)$t z-F;>EozT+iWzqolshayjGYx7k20@=>n9?nsig)kt_2Rk?8Ub84@G2ZrI)%4>0S*RD zv~k7q=nKbD(2xjOsa2|sWBY&Y?q_+NNB6U)CCdFgwE94r9(5mRiEFu7zL=v)u;v%g zl?HH{s<|Hn0x(_#$W*me>jGa%n3q1`05NcNze_N;@_2}S!4~icqBOSQwb(O7?X;!V z?*J^P`M;j}e|@>1CDyI0#p})b^)gt??yeT&7_hqZ3G*&!aQp0@H(5?wH_Mj=u@iJv zqn=Fx!l0ycWNgj{VOnxxs(uB8DJY^eia%`r4OTR~$^U>*S+9^=!&!pxZcuXe&da+$ z@o}=Xw0c~66m*(udkQVp7t=>gYMcmyA)eq%tTe;}d{t>Eq4>1*b3}}S_`oc|`b`4C zAV$@Qz=ZbwbkFt}X;?*00;NCEi6X?=NOS zYcN*3YM!t)t%++h*dGdlwJrM(5$deVKk;LWvA9z{2V&hb{Cz{L{~Z4bb6;880fZYz z$L4Pgp=cIYKbw}h#+_z6e!1yJyw~(jstw@kPyPV2e@6(LfK6v_qGOkhm4P_8r#J%C|ItGf>l$=qNjjK8mn04FqG7;j+awoE{#jKR(lZ-nIr&8pVX-v5bYr zI6&hNC?0BzE^H?)pES|>fmXZ&WNm%C@YpxZ1DuOv+Q!fQ72UtA1R<(TU{NvHl!^1;3b#JnsBE^qbE)DR046##oT^8Gs z#@AU(tH-4QoS_aqkM5Ss>7%8lF%ZBpZqtmU^A;r!n^b0(H0wJyp__JLLh)D_uw2pc zx>674@Bm|2|DX|AvESS^R1QXXgsg=C*y4!wd{j(7VnOKaezwEZeX5ddVGjwQ9p?!P zu4u`MSu#u5V1Uf6-j$peY+D^m_yNHEL-M+D*GRN{Q&*6?L6{$6j5Tao*VgqA=~YOp zRu7HMt}qB0n5+ftwS;xO0bpO(e*D+^f5YaEO-#AM{q5n0B$7Win0sECPF>ck9g*lc z`z}I5rr&8Tt)7wwa5nT9Xv-7yxsp|ZLIRxV2fjWYFv>I=+qa4cLK1ykJrj$j}t6wroCKWDY4wiyamewl}KRCSe9vwmR3(& zVqmDWgp+XsI$5ilZI8kQZCS(Ye>RMTrL*PBS-pw#&bs`0YnZ(W33n|4pgcC{-2vv* zaRey0U0?RzNQ7kYme=Il~pNP!+u=SZuh@I$b)?kMlacTCEC;f5^HJo zyh!FnXt5gbNazQk^k&wx1_)Ly><^ao-O|<*>G-ml_~wAGz(Bxpgmfzoz5QDBiRmVd z6?hw*rGhNuwzkC)4hFLRU%N%?#R68tSGBMQmtGpjLz)FH4_a{!G4KABy}~eg&Qs@6 z*iuP41xYdw*m~)KY^srL@Rfb)QFUze@^W+V910j*LpsSQY1QB?N^#4MRnZ@z4kom;4l`*0=RgA=34Dcu{0%@5NE5y;zxv~8h%L>u zlF9g&+HW9CLI48>3&!!SZghS2O84UfyGVPL)g}di(-?pycXO+zAMub(xshe?WdJH0 zO|7z)ofh-3L74$A+czzOHW>ykj0Z34PzJZ<<*O%~dX)I<@~S~L4ZoJN6#`0Q{bSSL zikRhdx%wO+{AtTd{Cv~@pe@$s39hNlv=-5jOjY97C;9odi}dPNT8FPRfODkUM*|B% z$)kjz5P&;;ef4`0$23!lC*nH;zJ-VpVfu6JAf}*OUynXX57CBUZc9+QLe^;jR;uq& z9MSA#@v25Ys~7Iz!R>DSs+YOs0V{j=uy;!s);VX+AHYJ{E_T%@w-vBB=sHX_qA*i) ze+fwIfat8%L0WLEdT|XN!vAb~6TjW^b99+qNT%jDlI`UV#QLLxutPfOR3(ZSq2$|J zFY3Mb~J*8aZUe^cHmF&=n!-G(T7YT;;SStKU{_{n|%Fi!N%nMLO8NSXEf++Cc=b z>?nml9wgYpKSpJ+0^*=7YnuQdv5n@cw>~ph%vD>t$$?bKDpZ5B?0v~1=;Y94EL=(d zFK^MhyPq?fwj{NL?emGfSi%5*zO6IlCdLWuu=caPFIZe|GQJ-%D?h4^jvoP_oC=V& zYGYTpGI96LTk*vHN0QCqTbD&E*S%ku?jkVG-h6)7ryfq6meye*4d5Qq@Q2VZ8V)W7 za0~!Bd^2!4JEa*A>W6kb^J-vjp&pRh;n9?m_iggpvXy z_yh>f0CE6d!u{d)t3y@zkAXiyzi>Yu5X`*LBqUntp?6<1ADtYd-9>813NZEnVD+eH z5u|Q|we0aSSG3du_O-BwWIt6G>!Oy!*lr06@U{JNV#T_mDMaM5qnw?m8c|vn9ksmB zte#6j$^UQfyyNYt%Ju)ev-dtd?WB@I5(r5kgkVAuNCHw4AOr=j1uR%l6j4C|LGfM% zFAC!IidTi7T%{=>%9UP32}vjkBtSwiq$h;*a?Z(VyUqIjvCFKPSu=aiNlwl49z6zT{TVTD$^75};MhTPPWo{hR&gWGps8fJto;--uK31=_UOEqJ~84NR^)8vWw^T!QQN zih(k#V&vi0F{?J(E3GUtRe=KBhU{pcK#4beYx%^@5lXK}dc@B)7T7C>Vg1IshzC#X z;k7T;9CSQ|WAX@c@q5H`uJG*Z67c*r)F(12++fe_SMEk6fJka!dh((&^qY~ZO6!v@ z?jprKAcCgsKKx+))p)CMNpWHiXw??KUvGQx+c|Z5+Nj}yGO!EA!r!Y}(sNc`h`LVs z10|%Bp#XO>J0C%|;UfG0`o&}@Ajg#yU;Rg`M1OB7o0Ofz;-2wQ%Yg7Oa})vgi>6Rt-M0dZjd)mpc&~;?b*qE%P zAG7A|7ldEyd)D^W<+*tO-2rS}j0-op@LkTsnR8r#AXYypJ!N$a6(oSMCO-Wv1-m)&ZUcoNq346ld;(VI^ z9t(*{uI>Wuez+P8fbFTRxNhxN@qTJU;KQ6*INRtA zN3c-}cdT2I{@T_{klp`3HbS~D6yPqT{vrG2{-2EPf`zjIh)8Mig-KXL!qO(24ANNK zv&+bfSvsj0XGcGkw*fe97eq0{G*F)MKuh;cJq~8~p=(<%cVOOo{mp!Un zV2v&`0$r@VA}kPWNUp_o>n_KRv@@@>sXN2D_ulrUH_!k(m_^vr#+8V=e%*PQA7Gxh zmzj{>2L-r`sejmhz5m7Xj)?U!N; zh(#neG&XuDYI+esL75SE09{(Jt>N?yT4ZeEN@zh8`fWe zhHPDcxVqb6p$&!h4eNGc?bzMcGfME4b?0XOrxX6{2gOyQ zFX$&jX3S1pwk(eFedaR}1R-G-QerO&GnU}};GBmey6$!V!-jjCBVgWTcevQJ?w+*6 zGweziRn5loY%6%%{`U|I1fnX^V_vGThs^*a?wg0&D8j%Cbx+}@P2WLEC4e{0$HEZp;*=In=K?Vi58?o;E^y^AwCIp*+XyOasj~_yR_;MfO`EgZvVE27^ zw(c1OE1~m4UV*A<=O(_gwMzPKjdEVTuw!ZK2xVTADEpb$eSWU)DHg?A_jo-%vZqhm z?+I&RX}#}R4{J*<>}`pAT^C`$cO7r7PEUBP4E;}adFfhM+9bk{?S2rqzyI&Zs;oKG zT;ky_iZ>h-lGbf3V522+%G%kPSE9BKLV6Pv;BLl-xv7;IJ8_a&aHiag7?v(2zF|L# z3JMSI{UaXU^B@f2XP>tiM|}LO_?M(s4tcNrXB%0S4SSl7FC?rjLHh-Zw7*WHU+fd) za8KA%CT@?{8;aJ$&6XB|jv>CMDm7uXg^H8h!tXQ)uLvsu4{ZM}ezWyfD6#z>Zdlia z=|RtA=P3^qVZpmuiO*OyJGHXijew9I4wJCDAq@|u_w~8Ne%L~6Is}Ir#aAnf)+Zd& zH>cIJI=Kpssu5GFjxB~cbDO2;=@mk-7Oa}l*5ryN)GcdJyM{a7pi^IP=Cfxx;&#im zJxb!1sZPV#;u4d9m~De+;R+rnY&jmTHrtkWRItzM)?&S)$~RVIjz|iu39i7ocpe$F zaIjKY{Pzbp<1f4K^Lw~q?J24^9MJBZw&Su{6tmK2t)7+H)){|KgmhOZz}=MkKcThq zV*8w%EUIN57e$zK6 zaF^geZg&QB(5Lv;^*Y@lZ7Du^FB7FH{kfH6A-iF&QHGPndwPKHCFAI5wYyf~p0D*( zkfR@lOg|^_^%V(J)(4$VxHGKUvCo0O;HAsNQzV`xmmGTJ)a(hn60nNnfUQg{bLf1_ z9xxLS%XxJ1im=-g-k#cmZ>_%sD_gZ(KJgs$f%1mKZIp;y``(%9Z$Y{~4njzGf&$!w zSaWXXR!i^&2r`ATQpr|PW+N(P^KR=3{CvkPXi?1pN9KfF0iXg^A387o^_|r+;Dg{_ zT;+Q;T@)1Bu#-YttlNPDUi&}ZL*dl`r~S*g))!7iVf+s%1$(xVa3 zzgvL!lk0HxhD)$Hxn93Lj+IG>wdMAExsFZ#FjD|Nz4DCY?FanQ6VeNy0QWFf%}(E| z#JUJTOQ84`iX!&+MzMJ>`7UnR_CxH;?(>>^e81e9CGl~yVqZxNvPS*Oe-=S98YGb_ zLP;qOto({$N#7dWj~wC+=e^!s*t%@Hl^(Sjh#%+l@9NzOT0NQwuWDJ2tJYtVo7dUo z;d+Hw+ZHwP${3RNL{k7X#Y8^3@{H7j2lT=d(u<$~_dwRnN>Vqia>+A2;wiiE2{16L(t=Y}^(e@j# zBeUI8sjIr;nn0?Ki_M*SOze#LMUf2Q zW{FCV7$zda-Cl1YiLGE==mX9zFKBok-`n&hG^@s9&C$pBM)QVCwvpV&2HTdCSDu!6 zwQU!Mkb@5ja1Z8Rvs3Sg9iIWg>Oxtd_-fntw@-LyW;<@#`F(89Y$+O<0iLx&|ogJ`1Wd|PvNT1w& z@CYv4BAojWJhtZn+_d@YNU4-|$+hn&1Vuk>324*@Jic9tP8p6`W{XAjI-I-muY5NU$KfS@fgZnE`NJ1Jcqi3EO zzkG2F@fPI8u7F!GRe=1OL{YB#_u!Vm5GEq=M}-(kZxzR9R~XH4F((YCY*111rcms-=QQ(0vOBoIO(!HGC}2|Mpa zs{beLKU=ZbL=hZi6yFffI9h+7_-xWh;kA|p7*{?KL*v6V0hXYtD`81|$T;ixU0c-Z zy=k=ccaq55!)uB?pxVS3#iI~0>|;o33Y^Q@Q@8$|+TC!`la0q#ZY`6IHs&dWYJq$WNLz%;-kzVQF}MjM9Y_iA;UjLKk9%R(Gh zb_9kdhWo__22m5M-&pHdRj;u}ws&*07Gm(BgX-uoQpy+-whUB^%?S0ZxFJEx>> z?wm$VNbiIK+#C53u=C#RyuqK3?}1=8P}GScrNYvV*ueE;9X2zPEjAXnzKnsfAsAbB zcwm^gC^xwTqu5aEu#tUa;QVcB)flg58zyE!;p#|Us8*+GzUj7kw$P5xFV^~UxeR@+ z661~%vHChwP&ya6D0ZF<=-t^J_|B$JVP)%Eeql8TaZzI zlH@a;^^k(^}%*++P5V7UW~gr>7o2knNO^ zgB1#JFK6dH+2w=2lz3I3E&xy-MSP1JLPE|GW&>bZatSJ|3XCtC&}Ikm83TPz_54;0{o zfL(vctQ&Hv^@3Q|1puldi7z61MaH>;0bqG*Dbgy1qbsHw1lTQn1>s7dV&s_k)YTtk zS0F3tYWIFXN#P9WhYby9hkCZ|{haF*B;0T)?0$p-##g|;y|}eLJnpqzy}FC@3FTG`F!k2 zSU4X*Ki7|}n8i0r$hnk2zn5BpMpcg~6(Zbts6S5aT0QnLIFOA*l|yG*MKjUKOBcW3xc5lG%-QxALx)! zWtc#(O}~eFdk>DSI1!S6L4856f;>$NR=?4)Dbk>Ju54Cy!=hvu2PJytj@J;H4i#p< zXY2i5lWk$SO!}@lBsM)Rh>vwVUhQ9DAL#q%)}F6_1UGHH92q-h6js**ljr*T>_fca zk`GvddGUC1)|)40c6L@9CFEd&0(_9M`}gU6gD=ZIEQrm7U{t91dix0ri-sTWP=N-p zKC>FzvLE2Mij!eU%+)U&Byu8BG2A+`)|Sj`b@rA+Lx%(9zcefi?HB9Po~==IJ+|(L z7(pLv#L}Vh50A274H7LoN)JI7o=#k$X9)hX_b%MN?c1_G7av<=a`CLla4J+=HKTvhu+ zlu0>!A!FzvR+o6Ht@f(<%d$_OntNHj1(Ey<>?OYr3zi7n+bxKQw!QGmdu=-;U+&*t z*wbWdUKZ3^M=l%~pLn7`61woZE{JQw%iOZ9Y}~c;hxp^}Upm{oGiQ3I%k<8Csg&8) zXNOQbQTWZ$#F`%foAAyE`fU1Uw78YIF2+l#_(6lAji=S5l_Vx2I*Z+z6L-f*-N z7I#pZ;$@M+&$eBSC+hAszDTJpLadZ&K5CO)Kz&MXee?L%>koKiBZT;%03Ylq$duR0 zZ-?O907{E*JG@?J(b+JuY%;DL@Do&9eSC>o=J0XuGbJrc7bfo=mz7lC_IkF3?MCm} zB)T49b}tKJC`*n_ocTt%)%O(VJWWYm-fORrQmwdY>t%Sc;j#SfaNQfPx%b{VAMNml zJtCY{z?E+vm%Othni(O)4+WS20H(ZJ_FY)=(+(3~Z(k*Vv56z_y#YT*pIHCM0xS^N zl3D$7`hn_J)iAuxo^4>rT445=^FkZ29G9}Xafvw_<5tC#uygPRq7@Wc>_*(M`4YU| z{7-#@{$W@N@m@W)Z4lukpgy;BTI$ao)4B*DCYXdp007(nH~aD-UrB5Pa2_Q0no8fK z3~-hUAEm@s&qUU31VEkIgQclgaD4epR9e-DD!?MKfn(ywHt$lKHYZi%prFJmb21iO zwqaS&ZtvCY$%+WdrO*3El+SI7A#tQRsmwn-s&G;3vO96r<~dm2`kF7auU{j*thgFx zU*e(1ket1*@SOyJ_qZP?>d)8N(ifdy@Tvw7Gdw~W}3}5OYAe= zn3j5_^IIDs#Omq;Al-|%j!!)%BIf{T01%vP&e8QA$`tha#D6{dT_lu}&$>c>P1^xAv>S5JK7w`+$j& zsY?>ml(L={UaKbZe!=F#bZSmq%~ z{sgK*zp+1Y%d=e@jx;KM;{3t!iPHsQcwvOW>n((yAz0bE1lMf20QK43xf5<{f3J6+ z5`Du#{lWrSl9c$l%ceB$==hdI2nm87Ffp=pT6#%b$w>g#L=|6Ge{Wz|+FXwSs8@S& zbKN(vIvr)oVYm9!S|ewkQhwm6aAzcqWU=qsli>rs( zR^}O4IJQ`53`<8!_$g@v0F_oHzSH+sOfEkj!5(mKNAG)m(<-_AmGpfhGAdacCCk}a zc6-9QryREuO_R$nTv3KTCz!omZ*rI03;svrUvSHg%aKuO*Lf;`yxQ%#=GR?|k-S|0 z{`t`Rqt$(rpPn}stsU8B2qDp+0F#m&`zHFwRQcloW<(L+_6}9j%gdz#SNHn~jx9UM zm{}Y&S%*NNU~gQMeh|&}p3x;|u?iC4d>Ff2YIYn`_HVmmR@ss6VMd0;EsyX*1dlb` zjbHA%HaE3o0smYX{?6ktAuchKm2X`Efc($m$t~XkXSX7x+du&(kr=lc<$asV|7a;( z1fYH58{+kvyyJumN|lUsk-QEh_|(mps0 zi9}-0uaKR7diJmN1LMOXmO zYt5Sd%%1b?nb~J%uYHs??VkQ+(i9s}m4A^}*qd)QuU6=#0d1%A-e~XB?^#DA4BpjV ze2M|K?w^KXx&BRKv;RZi>MN*CWHwb=D2pO0pT=Zburu9+Ts*Cxez*!8CeV`Sv8EBL zdY%3c@BA#<7#F}*f}3j&{~5~-^25OKH^Ui?uI_J(n%Zsu`?`9l9pmF(w=NEbUMu>+ zVhpma=g|i)jG3uTCOX`GEmD-{mZ+MhfSg|c^tJQdoWg0zpQ0MJ5Y4`3Dpf=( z`OXDn_ofzeK?wXBf?e-TpzrbY3gz4~3~E!9^09#P1|qn-OTSHQl+rrWH%M>q|2t`7 zU!bjiGwru`v2ZqLDD|ZqSS*ih-Scr+RUJ+EOQdR( zO`F9w$6cz+VyaoWiQa&{R#|`^3z7-dgVp8AsE(Hna)u=w28U*YFmMx1L>zH@;Sy>9 zO{_IdLjI(i1S<|3Joa1cY&CLyru7wPTr?W=lQi)8~ta_>3A!;aO^KzZ-4l<#gt!`61CDLBhz~!8lI+U^(Mv6vjRT?13?Nh#*C&=Z* ze&rgZ{hT=5>c^`!C^uI7O8lU$&&{QQ#AXUUnTd%xLkMc};9el#Tbj^0QBfMCc4&h( z$)Hvgs!AxWzohsw>dD_7l0AkaImVMkGUU$0T#>6cULBB6vS}*soDP$;bM>>Ez6NO< zomA7w29BZeHPSOC3&OZDHgryG*yXTX2%LqJRYCpH`(v-Je=6HCS2Ii{J(3R7fPhq| z`;RpU>mh8CaREz!aRVGrd+)hqHn@@rN9;b~!= zZh6e#`V=(Og>-$)HA^h5}0jC(-QO|DNvuL6J^ z^xK}g6#4mIxX7bt58wFf?mXEL-f1yL@@o%~#=+|=uk8up2K!nr`n}dLp`p;A?pbY= zfeWx~UXH=Nul{tr`nY#As^B2j+qI?2Wjc1kCbWfaGSX+73wz40eG9BFBRN9}6+9)< z2<^|A-^<}@sjGI0d#e3c_g*r`AazFbe^6B;sKgttftbL!mYCka#yB5`13nrp%JUH4pQiB|xJbe%j6GF^LzR-0`C138RkSo?)P9zF( z9|p*HfcA?U8`ki^V*c^8Le z5D-(wK!&J?=DWJs@fg>AUNih_uu*7h8Fy|n3;q#xpa`i-c`<32-L$ilW_&p$2n^5W zV9xNDE~zX#jBTY0=Fu68x@!9!+y-l;*CMG_rvgik=1`%y0r^$^FM3;K*?xO6y;A9O zA)?nq)49pradGsq41cbYcYE_g5&TaL$=1T}@0yF<1@dw(v_U~dUC20*kgaN(pT**I z>RCc>)tkKl*L;_84blzIHd$GFvQefWz8-r8;W+ygI56*Y2PF%MOp6-q?9FChu6=Bs+EckcY_V zyzCmXILefuY1iB{`4PM}3s8`*=y+okP8wBWG*TB?^nlLI-134ui}^TB%BsOtze==w zY_1&I?s-gg)%@!07@|nTgPtA*aW2W(8ThZ!rB+wH{^JQ zT(T39o3IGgzeDaA>s^xW#BzJ#QtmdYmH%E#Z0W-%Z=VC$7v@_YF;3OtpRe!v<7=G% z1kfIjvL6iw5@Zix^XyvJQX@Dn-6;9)$JxI)56>)FOnI3KDuZc-pKN4 zG(CeReG`wIO3@CP+ZpLgY$*WH!2tT5>z}F?F{EWL(96Jh?{f>Ofyc~vF*HA{V>JMa zEFPI0OmcnRvQLnN7dGfIh|i{d*D1}ccpH3C*GXzOLBj$PD^gEC;33v?ZBuDfBA-ZW z!K=7zq_-O2E-gQb0fw$p;z>9rF>68EbV1I$35bVcjCkwz`1T(`f&5X%%?q)6yq1Cc zB{>)85_e7T$!HcptB~uTK0BvOOs+raA=?<{@b(-A##1AV$kN>Ifs;~SmZ&$f2)MVbuDmEtalrJueaHvv`J~bB zpyD(ki5aVfR4}K{Kb6)BFG*&Ggb)YU?^_SUw!rXNE`a%a?`xP;d>ht@AZG^Gtiu|W zOEMtyw0~7d)+U>o=AIqRr?E89Gr57QF)M3f5Zu#Y4yw#KF{=ej)&Ww}N0X}8RAR{3 z1+z1M^Id9%Hdy&(N|IS91n%&SNnAT6Nv3l>(Bed(b>;ODUQBJRd_))}$U{7cE#1$# zJhA6)Z7+&4_yugT!OAXMrKfUvSk^j9;gh}KZ2s|EKU;9aFRcYT6SGe?=fkK0v;a8X z|HYpHV#X57iShHCps~}RkPuHsOj_zaymhRZi;fb>31e-~g)$ffe79g`7ixLqH>*7f za9@CL0>YyA*XbjsIpn1kHa3VYv1qAk|8yvbvQ22_#3$!4y&)M8=O^`KlTeiyX7}7* z#jYyTT^P&ke# zAyC3A4RzqHQrD^D*P5f(B()tw&1-sAbJo5e`dH@*0&q6~xXdqhrm1D+bhdDe|1R^) zrD2EfWxv@3cVjA>o#)>QOJ2qoRS%2ZlGXB{GS#urSh$ z&Wb(s7lK9FL!M&_jyeAdIp38I9p(lMgXJmL*&(vI;7V z2`L?-3RVc{{9(pl`6nRV|2|CR7Sp;dNK~YbR+cF~;H3`+?C1RI2u<_2k-R<_K=Xy7 zfU5r%|G{?vI({Kyv{&1WJ*0s^ATfV^8~-N`{!SW>eonvxgiugalT}caRaCSwz`IPmj}kt a!N*C?+s`FyTT1|F1iGzna+831g!>=FtMG*Y diff --git a/bal/icons/heir.png b/bal/icons/heir.png deleted file mode 100644 index 92490a086880b451f6819db0f212133a72bcba1e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 871 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 diff --git a/bal/icons/status_connected.png b/bal/icons/status_connected.png deleted file mode 100644 index 1fe3dace9d5564ef1ef65e69dd54e9f5753189c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 71068 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

0Lj3t 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 diff --git a/bal/icons/will.png b/bal/icons/will.png deleted file mode 100644 index 45fb8dbb14fa391742c29c4fa87896f72a20332d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 831 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=oCO|{#S9FLH$a&2SIn{^pdd@S zqpu?a!^Xav-+_~Xe1&9>AYTTCDpdxChGqtapZ|gMO9qBg0|tgy2@DKYGZ+}e^C!h0 zbz@*)+Un`z7*cU7>CgZF_J=*#7#NQnF*wBNz|i}Hfq`Rzpgho?BTgQS%F2(eO%xA0 zzEc-A`4?H!#mJO#WI00vgVaYx1_1|UJD^D&tvt5T7k}(t`9`gb>C^s;KkiP6U<1jq zGO%3e;D;-IGKKN@#UH{Kf1Jvw(c`-NgQxeuSnqw$&W8+)CXa%FUPiH+*?9VbkG`5F z`ux52y|emQfo_`5!~nFN9i~ztY6@ev=gWR!#&rS{mS+SAD>S&(aKRjJ@`#y1p&=uX z?aPH5XB{*CEX}CNHu+~+lgb9NNCf71hD5jtS2bFnF&nRnIPUpM54XmcZJg!JKp9N6R%4TbIZAj*p)&{`m94kNF%fs}!Z= zQ*FWVgzkq$JTDv`&lU`@=kJ~Gvp}|>=fIC<-A1Eic_Vi0Au}cD2A_kx|Bi-Z?()J{ z)Dr%cnfzl<;lu8B3zvm;c_wvCj9*{8=2&{+M_xwFl+qq-A>${h;J@I%zvc($$N!ZN zxaW(%QC%RHTEmFN5fYqlW#t-bn_tv2=bO|??X%%!d7AO(YQ~@CCuBfrOcNM|ZZ*J! d4oOGM3_9~19^Z=Lxdu$^44$rjF6*2Ung9X&8fgFk diff --git a/bal/icons/wizard.png b/bal/icons/wizard.png deleted file mode 100644 index e7696cd48c2b85c1987c1aa40cc42510d9246ce4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8542 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 Wn8z%y5BwAjurRSPer9w!>VE*;%qqG7 diff --git a/bal/manifest.json b/bal/manifest.json deleted file mode 100644 index 45e609c..0000000 --- a/bal/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "bal", - "fullname": "Bitcoin After Life", - "version": "0.3.2", - "description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.", - "author": "Svatantrya", - "licence": "MIT", - "available_for": ["qt"], - "icon": "icons/bal32x32.png" -} diff --git a/bal/qt.py b/bal/qt.py deleted file mode 100644 index 6bd488f..0000000 --- a/bal/qt.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -bal.qt -====== - -Compatibility shim for Electrum's plugin loader. - -Electrum loads a Qt plugin by importing the ``qt`` module of the plugin package -and looking for a ``Plugin`` class. The real implementation lives in the -well-separated ``bal.gui.qt`` sub-package, so this module re-exports the -``Plugin`` class from ``bal.gui.qt.plugin``. - -Why this file is not a one-line relative import ------------------------------------------------ -A plain ``from .gui.qt.plugin import Plugin`` works fine when the plugin is -installed as an *internal* plugin (under ``electrum/plugins/bal``). However, -when the very same code is loaded as an *external* plugin from a ``.zip``, -Electrum 4.7.x imports the package under the synthetic top-level name -``electrum_external_plugins.bal`` and only executes the package ``__init__`` and -this ``qt`` module. It never registers the intermediate parent packages -(``electrum_external_plugins`` itself, ``...bal.gui``, ``...bal.gui.qt``). As a -result, a relative import that has to walk up to those parents fails with:: - - ModuleNotFoundError: No module named 'electrum_external_plugins' - -To make the plugin work *both* as an internal package and as an external zip, -this shim resolves and imports ``Plugin`` defensively: - -1. It works out the name of the package this module lives in - (``__package__``), whatever Electrum decided to call it. -2. It makes sure every parent package in that chain exists in - ``sys.modules`` so Python's import machinery can resolve sub-modules. -3. It imports the ``.gui.qt.plugin`` sub-module via :func:`importlib.import_module` - using the resolved absolute name. - -This keeps the clean ``core`` / ``gui`` layout while staying robust to how the -plugin is loaded. -""" - -import importlib -import sys - - -def _ensure_parent_packages(pkg_name: str) -> None: - """Make sure every ancestor package of *pkg_name* is in ``sys.modules``. - - When loaded from a zip as an external plugin, Electrum only executes the - plugin package ``__init__`` and the ``qt`` module. The synthetic root - package (e.g. ``electrum_external_plugins``) and any intermediate packages - may be missing from ``sys.modules``, which breaks relative/absolute - sub-module imports. We backfill them here using this module's own loader - so that ``importlib`` can find sibling sub-packages. - """ - parts = pkg_name.split(".") - # Walk from the top-most ancestor down to (but not including) pkg_name. - for i in range(1, len(parts)): - ancestor = ".".join(parts[:i]) - if ancestor in sys.modules: - continue - try: - importlib.import_module(ancestor) - except Exception: - # The synthetic root (e.g. 'electrum_external_plugins') often has no - # real spec. Create a minimal namespace package stub so that the - # import machinery can still resolve its children. - import types - - module = types.ModuleType(ancestor) - module.__path__ = [] # mark as a (namespace) package - sys.modules[ancestor] = module - - -# The package this module belongs to. Could be 'electrum.plugins.bal' (internal) -# or 'electrum_external_plugins.bal' (external zip), depending on how Electrum -# loaded us. -_PKG = __package__ or "bal" - -_ensure_parent_packages(_PKG) - -# Import the real implementation using the fully-qualified, run-time package -# name so it works regardless of the synthetic prefix Electrum assigned. -_plugin_module = importlib.import_module(_PKG + ".gui.qt.plugin") - -Plugin = _plugin_module.Plugin # noqa: F401 (re-exported for Electrum) diff --git a/bal/wallet_util/README.md b/bal/wallet_util/README.md deleted file mode 100644 index 0d8c02f..0000000 --- a/bal/wallet_util/README.md +++ /dev/null @@ -1,50 +0,0 @@ -## README - -### Overview -This tool provides two entry points: a CLI script (bal_wallet_utils.py) and a Qt GUI script (bal_wallet_utils_qt.py) that operate against an Electrum source tree. - -### Installation / Preparation -1. Copy both files into the Electrum project root (the folder that contains the Electrum source package): - - bal_wallet_utils.py - - bal_wallet_utils_qt.py - -2. Activate the Electrum Python environment (the virtualenv used to run Electrum). Example (PowerShell, adjust path to your venv): -``` -.\env\Scripts\Activate.ps1 -``` -or (cmd): -``` -env\Scripts\activate.bat -``` - -### Running -- CLI version: -``` -python bal_wallet_utils.py -``` -- Qt GUI version: -``` -python bal_wallet_utils_qt.py -``` - -### Building a Windows executable with PyInstaller -From the project root (with the Electrum environment active), you can build the Qt executable using PyInstaller. Example command (adjust the paths if your environment path differs): -``` -pyinstaller.exe --onefile --noconsole --add-data "electrum\currencies.json;electrum" --add-data "electrum\bip39_wallet_formats.json;electrum" --add-data "electrum\lnwire\peer_wire.csv;electrum\lnwire" --add-data "electrum\lnwire\onion_wire.csv;electrum\lnwire" --add-binary "env/Lib/site-packages\electrum_ecc\libsecp256k1-6.dll;electrum_ecc" bal_wallet_utils_qt.py -``` - -Notes: -- Run the command from the project root so relative paths resolve correctly. -- On Windows the --add-data and --add-binary arguments use ";" to separate source and destination. -- If electrum expects additional data files or native DLLs, include them with additional --add-data / --add-binary flags. -- For debugging include --onedir first to inspect the created folder before using --onefile. - -### Troubleshooting -- If PyInstaller is not found, run it via Python: -``` -python -m PyInstaller -``` -- If the frozen exe fails because DLLs or JSON files are missing, add those files explicitly with --add-data or --add-binary. -- Test the build on a clean Windows VM to ensure all runtime dependencies are included. - -License and attribution: include your preferred license or attribution details here. diff --git a/bal/wallet_util/bal_wallet_utils.py b/bal/wallet_util/bal_wallet_utils.py deleted file mode 100755 index b81e0a0..0000000 --- a/bal/wallet_util/bal_wallet_utils.py +++ /dev/null @@ -1,81 +0,0 @@ -#!env/bin/python3 -import getpass -import json -import os -import sys - -from electrum.storage import WalletStorage -from electrum.util import MyEncoder - -default_fees = 100 - - -def fix_will_settings_tx_fees(json_wallet): - tx_fees = json_wallet.get("will_settings", {}).get("tx_fees", False) - have_to_update = False - if tx_fees: - json_wallet["will_settings"]["baltx_fees"] = tx_fees - del json_wallet["will_settings"]["tx_fees"] - have_to_update = True - for txid, willitem in json_wallet["will"].items(): - tx_fees = willitem.get("tx_fees", False) - if tx_fees: - json_wallet["will"][txid]["baltx_fees"] = tx_fees - del json_wallet["will"][txid]["tx_fees"] - have_to_update = True - return have_to_update - - -def uninstall_bal(json_wallet): - if "will_settings" in json_wallet: - del json_wallet["will_settings"] - if "will" in json_wallet: - del json_wallet["will"] - if "heirs" in json_wallet: - del json_wallet["heirs"] - return True - - -def save(json_wallet, storage): - human_readable = not storage.is_encrypted() - storage.write( - json.dumps( - json_wallet, - indent=4 if human_readable else None, - sort_keys=bool(human_readable), - cls=MyEncoder, - ) - ) - - -def read_wallet(path, password=False): - storage = WalletStorage(path) - if storage.is_encrypted(): - if not password: - password = getpass.getpass("Enter wallet password: ", stream=None) - storage.decrypt(password) - data = storage.read() - json_wallet = json.loads("[" + data + "]")[0] - return json_wallet, storage - - -if __name__ == "__main__": - if len(sys.argv) < 3: - print("usage: ./bal_wallet_utils ") - print("available commands: uninstall, fix") - exit(1) - if not os.path.exists(sys.argv[2]): - print("Error: wallet not found") - exit(1) - command = sys.argv[1] - path = sys.argv[2] - json_wallet, storage = read_wallet(path) - have_to_save = False - if command == "fix": - have_to_save = fix_will_settings_tx_fees(json_wallet) - if command == "uninstall": - have_to_save = uninstall_bal(json_wallet) - if have_to_save: - save(json_wallet, storage) - else: - print("nothing to do") diff --git a/bal/wallet_util/bal_wallet_utils_qt.py b/bal/wallet_util/bal_wallet_utils_qt.py deleted file mode 100755 index 4da6b57..0000000 --- a/bal/wallet_util/bal_wallet_utils_qt.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import sys - -from bal_wallet_utils import fix_will_settings_tx_fees, save, uninstall_bal -from electrum.storage import WalletStorage -from PyQt6.QtWidgets import ( - QApplication, - QFileDialog, - QGroupBox, - QHBoxLayout, - QLabel, - QLineEdit, - QMainWindow, - QPushButton, - QTextEdit, - QVBoxLayout, - QWidget, -) - - -class WalletUtilityGUI(QMainWindow): - def __init__(self): - super().__init__() - self.init_ui() - - def init_ui(self): - self.setWindowTitle("BAL Wallet Utility") - self.setFixedSize(500, 400) - - # Central widget - central_widget = QWidget() - self.setCentralWidget(central_widget) - - # Main layout - layout = QVBoxLayout(central_widget) - - # Wallet input group - wallet_group = QGroupBox("Wallet Settings") - wallet_layout = QVBoxLayout(wallet_group) - - # Wallet path - wallet_path_layout = QHBoxLayout() - wallet_path_layout.addWidget(QLabel("Wallet Path:")) - self.wallet_path_edit = QLineEdit() - self.wallet_path_edit.setPlaceholderText("Select wallet path...") - wallet_path_layout.addWidget(self.wallet_path_edit) - - self.browse_btn = QPushButton("Browse...") - self.browse_btn.clicked.connect(self.browse_wallet) - wallet_path_layout.addWidget(self.browse_btn) - - wallet_layout.addLayout(wallet_path_layout) - - # Password - password_layout = QHBoxLayout() - password_layout.addWidget(QLabel("Password:")) - self.password_edit = QLineEdit() - self.password_edit.setEchoMode(QLineEdit.EchoMode.Password) - self.password_edit.setPlaceholderText("Enter password (if encrypted)") - password_layout.addWidget(self.password_edit) - - wallet_layout.addLayout(password_layout) - - layout.addWidget(wallet_group) - - # Output area - output_group = QGroupBox("Output") - output_layout = QVBoxLayout(output_group) - - self.output_text = QTextEdit() - self.output_text.setReadOnly(True) - output_layout.addWidget(self.output_text) - - layout.addWidget(output_group) - - # Action buttons - buttons_layout = QHBoxLayout() - - self.fix_btn = QPushButton("Fix") - self.fix_btn.clicked.connect(self.fix_wallet) - self.fix_btn.setEnabled(False) - buttons_layout.addWidget(self.fix_btn) - - self.uninstall_btn = QPushButton("Uninstall") - self.uninstall_btn.clicked.connect(self.uninstall_wallet) - self.uninstall_btn.setEnabled(False) - buttons_layout.addWidget(self.uninstall_btn) - - layout.addLayout(buttons_layout) - - # Connections to enable buttons when path is entered - self.wallet_path_edit.textChanged.connect(self.check_inputs) - - def browse_wallet(self): - file_path, _ = QFileDialog.getOpenFileName( - self, "Select Wallet", "*", "Electrum Wallet (*)" - ) - if file_path: - self.wallet_path_edit.setText(file_path) - - def check_inputs(self): - wallet_path = self.wallet_path_edit.text().strip() - has_path = bool(wallet_path) and os.path.exists(wallet_path) - - self.fix_btn.setEnabled(has_path) - self.uninstall_btn.setEnabled(has_path) - - def log_message(self, message): - self.output_text.append(message) - - def fix_wallet(self): - self.process_wallet("fix") - - def uninstall_wallet(self): - self.log_message( - "WARNING: This will remove all BAL settings. This operation cannot be undone." - ) - self.process_wallet("uninstall") - - def process_wallet(self, command): - wallet_path = self.wallet_path_edit.text().strip() - password = self.password_edit.text() - - if not wallet_path: - self.log_message("ERROR: Please enter wallet path") - return - - if not os.path.exists(wallet_path): - self.log_message("ERROR: Wallet not found") - return - - try: - self.log_message(f"Processing wallet: {wallet_path}") - - storage = WalletStorage(wallet_path) - - # Decrypt if necessary - if storage.is_encrypted(): - if not password: - self.log_message( - "ERROR: Wallet is encrypted, please enter password" - ) - return - - try: - storage.decrypt(password) - self.log_message("Wallet decrypted successfully") - except Exception as e: - self.log_message(f"ERROR: Wrong password: {str(e)}") - return - - # Read wallet - data = storage.read() - json_wallet = json.loads("[" + data + "]")[0] - - have_to_save = False - message = "" - - if command == "fix": - have_to_save = fix_will_settings_tx_fees(json_wallet) - message = ( - "Fix applied successfully" if have_to_save else "No fix needed" - ) - - elif command == "uninstall": - have_to_save = uninstall_bal(json_wallet) - message = ( - "BAL uninstalled successfully" - if have_to_save - else "No BAL settings found to uninstall" - ) - - if have_to_save: - try: - save(json_wallet, storage) - self.log_message(f"SUCCESS: {message}") - except Exception as e: - self.log_message(f"Save error: {str(e)}") - else: - self.log_message(f"INFO: {message}") - - except Exception as e: - error_msg = f"ERROR: Processing failed: {str(e)}" - self.log_message(error_msg) - - -def main(): - app = QApplication(sys.argv) - - window = WalletUtilityGUI() - window.show() - - return app.exec() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/build_zip.py b/build_zip.py deleted file mode 100644 index 6c06b77..0000000 --- a/build_zip.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -"""Build a clean, zipimport-friendly distribution archive of the BAL plugin. - -Electrum loads external plugins from a ``.zip`` using Python's ``zipimport``. -``zipimport`` is picky about the archive layout, so this builder deliberately: - - * writes **only files** (no explicit directory entries) — some Electrum - portable builds choke on directory records inside the archive; - * uses standard DEFLATE compression (well supported by ``zipimport``); - * emits entries in a deterministic, sorted order so the archive is - reproducible (stable SHA-256); - * skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files. - -The archive keeps the top-level ``bal/`` directory so that the package is -importable as ``bal`` (and Electrum derives ``dirname='bal'`` from the path of -``bal/manifest.json``). - -Usage:: - - python3 build_zip.py [output.zip] - -Prints the resulting size and SHA-256 so the download can be integrity-checked. -""" - -import hashlib -import os -import sys -import zipfile - -SRC_ROOT = "bal" -DEFAULT_OUT = "bal-electrum-plugin.zip" - - -def build(out_path: str) -> None: - if os.path.exists(out_path): - os.remove(out_path) - - files = [] - for dirpath, dirnames, filenames in os.walk(SRC_ROOT): - # prune cache dirs in place so os.walk does not descend into them - dirnames[:] = [d for d in dirnames if d != "__pycache__"] - for fn in filenames: - if fn.endswith((".pyc", ".pyo")): - continue - files.append(os.path.join(dirpath, fn)) - files.sort() - - with zipfile.ZipFile( - out_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6 - ) as z: - for f in files: - arc = f.replace(os.sep, "/") # forward slashes inside the archive - z.write(f, arc) - - # Integrity + summary - with zipfile.ZipFile(out_path) as z: - bad = z.testzip() - if bad is not None: - raise SystemExit(f"ERROR: corrupt entry in archive: {bad}") - names = z.namelist() - if not any(n.endswith("manifest.json") for n in names): - raise SystemExit("ERROR: manifest.json missing from archive") - - data = open(out_path, "rb").read() - print(f"built : {out_path}") - print(f"files : {len(files)}") - print(f"size : {len(data)} bytes") - print(f"sha256: {hashlib.sha256(data).hexdigest()}") - - -if __name__ == "__main__": - out = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_OUT - build(out) diff --git a/tests/external_zip_test.py b/tests/external_zip_test.py deleted file mode 100644 index c708326..0000000 --- a/tests/external_zip_test.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Regression test: load the plugin the way Electrum loads an *external* zip. - -When a user installs the plugin via Electrum's "Plugins" dialog from a .zip, -Electrum 4.7.x imports it under the synthetic top-level package -``electrum_external_plugins.bal`` and only executes the package ``__init__`` -and the ``qt`` module. It does NOT pre-register the synthetic root package -nor the nested ``gui`` / ``gui.qt`` sub-packages. - -A naive ``from .gui.qt.plugin import Plugin`` in ``qt.py`` therefore fails with:: - - ModuleNotFoundError: No module named 'electrum_external_plugins' - -This test reproduces that exact loading sequence against the built zip and -asserts that the resilient ``qt.py`` shim resolves the ``Plugin`` class. - -Usage: - QT_QPA_PLATFORM=offscreen \ - PYTHONPATH= \ - python3 tests/external_zip_test.py -""" - -import importlib.util -import sys -import zipimport - - -def main(zip_path: str) -> int: - base = "electrum_external_plugins.bal" - gui = "qt" - dirname = "bal" # directory name inside the zip archive - - def exec_module_from_spec(spec, path): - # Mirrors electrum.plugin.PluginManager.exec_module_from_spec - module = importlib.util.module_from_spec(spec) - sys.modules[path] = module - spec.loader.exec_module(module) - return module - - zi = zipimport.zipimporter(zip_path) - - # Step 1: load the package __init__ as electrum_external_plugins.bal - init_spec = zi.find_spec(dirname) - assert init_spec is not None, "could not find package __init__ inside zip" - exec_module_from_spec(init_spec, base) - - # Step 2: load the qt entry-point as electrum_external_plugins.bal.qt - full = f"{base}.{gui}" - spec = importlib.util.find_spec(full) - assert spec is not None, f"could not find spec for {full!r}" - module = exec_module_from_spec(spec, full) - - # The loader expects a `Plugin` class to be exported. - plugin_cls = getattr(module, "Plugin", None) - assert plugin_cls is not None, "qt module did not export a Plugin class" - - print(f"[OK] external zip loads Plugin -> {plugin_cls!r}") - return 0 - - -if __name__ == "__main__": - if len(sys.argv) != 2: - print(__doc__) - sys.exit(2) - sys.exit(main(sys.argv[1])) diff --git a/tests/gui_fixes_test.py b/tests/gui_fixes_test.py deleted file mode 100644 index 27c187e..0000000 --- a/tests/gui_fixes_test.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Regression tests for the GUI window/lifecycle fixes (B1-B10). - -These tests need a QApplication but run head-less under -``QT_QPA_PLATFORM=offscreen``. They check the *behaviour* of the centralized -window helpers and assert that the known bug patterns are gone, without trying -to drive a full Electrum session. - -Usage: - QT_QPA_PLATFORM=offscreen PYTHONPATH= \ - python3 tests/gui_fixes_test.py -where is e.g. electrum.plugins.bal -""" - -import ast -import importlib -import inspect -import sys - - -def _active_source_without_strings(module) -> str: - """Return module source with docstrings/strings removed. - - Lets us assert a token is absent from *executable* code even if it still - appears inside an explanatory docstring/comment. - """ - src = inspect.getsource(module) - tree = ast.parse(src) - # collect string-constant spans to drop - class _S(ast.NodeVisitor): - def __init__(self): - self.spans = [] - def visit_Constant(self, node): - if isinstance(node.value, str) and hasattr(node, "end_lineno"): - self.spans.append((node.lineno, node.end_lineno)) - self.generic_visit(node) - s = _S(); s.visit(tree) - drop = set() - for a, b in s.spans: - drop.update(range(a, b + 1)) - lines = src.splitlines() - kept = [ln for i, ln in enumerate(lines, start=1) - if i not in drop and not ln.lstrip().startswith("#")] - return "\n".join(kept) - - -def main(pkg: str) -> int: - from PyQt6.QtWidgets import QApplication, QDialog, QWidget - app = QApplication.instance() or QApplication(sys.argv) - - wu = importlib.import_module(pkg + ".gui.qt.window_utils") - - # top_level_of: returns the top-level container of a child widget - w = QWidget(); child = QWidget(w) - assert wu.top_level_of(child) is w - assert wu.top_level_of(None) is None - print("[OK] top_level_of") - - # bring_to_front / stop_thread must never raise on edge inputs - wu.bring_to_front(QDialog()) - wu.stop_thread(None) - print("[OK] bring_to_front / stop_thread(None)") - - # _window_key: stable and unique per window - plugin_mod = importlib.import_module(pkg + ".gui.qt.plugin") - a, b = QWidget(), QWidget() - assert plugin_mod._window_key(a) == plugin_mod._window_key(a) - assert plugin_mod._window_key(a) != plugin_mod._window_key(b) - print("[OK] _window_key stable & unique") - - # B3/B4: no winId bound-method key, no 'restart Electrum' surrender in - # *executable* code (docstrings explaining the old behaviour are allowed). - active = _active_source_without_strings(plugin_mod) - assert "winId" not in active, "winId still used in executable code" - print("[OK] no winId in executable code") - - win_mod = importlib.import_module(pkg + ".gui.qt.window") - active_win = _active_source_without_strings(win_mod) - assert "restart Electrum" not in active_win - print("[OK] no 'restart Electrum' surrender in window.py code") - - # B1: BalDialog must not shadow QWidget.parent() with an attribute - dialogs_mod = importlib.import_module(pkg + ".gui.qt.dialogs") - dsrc = inspect.getsource(dialogs_mod) - assert "self.parent =" not in dsrc, "self.parent assignment still present" - print("[OK] no self.parent shadowing in dialogs.py") - - # REGRESSION: BalDialog.closeEvent / hideEvent must NOT stop the task - # thread. Electrum's TaskThread.on_done calls cb_done (often self.accept, - # which closes the dialog) BEFORE cb_result (on_success, e.g. updating the - # will-executor list). If the base closeEvent stopped/joined the thread, - # the auto-close from accept() would tear the thread down before - # on_success ran and the downloaded list would be silently dropped. - close_src = inspect.getsource(dialogs_mod.BalDialog.closeEvent) - hide_src = inspect.getsource(dialogs_mod.BalDialog.hideEvent) - assert "stop_thread" not in close_src, ( - "BalDialog.closeEvent must not stop the thread (drops download result)") - assert "stop_thread" not in hide_src, ( - "BalDialog.hideEvent must not stop the thread (drops download result)") - print("[OK] BalDialog.closeEvent/hideEvent do not kill the task thread") - - # REGRESSION: init_menubar_tools must be idempotent. Electrum can invoke - # both the init_menubar hook and the hot-init path (init_qt -> _setup_window) - # for the same window (e.g. on restart with the plugin already enabled); - # wiring the tabs/menu actions twice produces a garbled, condensed menu - # entry under the Electrum logo. Verify the guard flag is in place. - bal_window_cls = win_mod.BalWindow - menubar_src = inspect.getsource(bal_window_cls.init_menubar_tools) - assert "_menubar_initialized" in menubar_src, ( - "init_menubar_tools must guard against double initialisation") - init_src = inspect.getsource(bal_window_cls.__init__) - assert "_menubar_initialized" in init_src, ( - "_menubar_initialized must be initialised in BalWindow.__init__") - onclose_src = inspect.getsource(bal_window_cls.on_close) - assert "_menubar_initialized" in onclose_src, ( - "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 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) - csb_code = "\n".join( - line for line in csb_body.splitlines() - if not line.lstrip().startswith("#") - ) - 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 - - -if __name__ == "__main__": - if len(sys.argv) != 2: - print(__doc__) - sys.exit(2) - sys.exit(main(sys.argv[1])) diff --git a/tests/parallel_ping_test.py b/tests/parallel_ping_test.py deleted file mode 100644 index 6041f27..0000000 --- a/tests/parallel_ping_test.py +++ /dev/null @@ -1,353 +0,0 @@ -""" -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 threading -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 - - # ---- 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 - # 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)") - - # 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 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/preview_build_will_dialog.py b/tests/preview_build_will_dialog.py deleted file mode 100644 index 20d677a..0000000 --- a/tests/preview_build_will_dialog.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -"""Render a visual PREVIEW (before/after) of the "Building Will" dialog text. - -This is a throwaway, GUI-only helper used to show the user how the proposed -"bold results" formatting looks compared to the current rendering, BEFORE any -production code is changed. It does NOT import the plugin; it just reproduces -the exact rich-text the dialog builds via ``msg_set_status`` / ``msg_ok`` / -``msg_error`` so the preview is faithful. - -Run: - QT_QPA_PLATFORM=offscreen python3 tests/preview_build_will_dialog.py -It writes two PNGs in the repo root: preview_before.png and preview_after.png. -""" - -import os -import sys - -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - -from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout -from PyQt6.QtCore import Qt - -# Same colors as BalBuildWillDialog -COLOR_WARNING = "#cfa808" -COLOR_ERROR = "#ff0000" -COLOR_OK = "#05ad05" - - -# ---- current rendering (BEFORE) ------------------------------------------- -def ok_before(e="Ok"): - return "{}".format(COLOR_OK, e) - - -def error_before(e): - return "{}".format(COLOR_ERROR, e) - - -def row_before(msg, status, color=None): - if color is None: - return f"{msg}:\t{status}" - return "{}:\t{}".format(color, msg, status) - - -# ---- proposed rendering (AFTER): results in bold -------------------------- -def ok_after(e="Ok"): - return "{}".format(COLOR_OK, e) - - -def error_after(e): - return "{}".format(COLOR_ERROR, e) - - -def row_after(msg, status, color=None): - # Left state label stays normal; only the result (status) becomes bold. - if color is None: - return f"{msg}:\t{status}" - # When a color is given for the whole line, keep the label normal and bold - # only the status portion. - return "{}:\t{}".format(msg, color, status) - - -def build_rows(mode): - if mode == "before": - ok, err, row = ok_before, error_before, row_before - else: - ok, err, row = ok_after, error_after, row_after - rows = [ - row("checking variables", "Wait"), - row("Checking your will", ok()), - row("Signing your will", "Nothing to do"), - row("Broadcasting your will to executors", "Nothing to do"), - ok(), - row("Invalidating old will", err("Ko")), - "https://executor.example.org : " + ok(), - "https://other.example.org : " + err("Ko"), - "Please wait 2secs", - row("Will-Executor excluded", "Skipped", COLOR_ERROR), - ] - return rows - - -def render(mode, path): - rows = build_rows(mode) - full_text = "

".join(rows).replace("\n", "
") - w = QWidget() - w.setStyleSheet("background:#2b2b2b;") - lay = QVBoxLayout(w) - title = QLabel(f"Building Will — {mode.upper()}") - title.setStyleSheet("color:#ffffff; font-size:15px; font-weight:bold;") - lbl = QLabel(full_text) - lbl.setTextFormat(Qt.TextFormat.RichText) - lbl.setStyleSheet("color:#dddddd; font-size:13px;") - lbl_font = lbl.font() - lbl_font.setPointSize(11) - lbl.setFont(lbl_font) - lay.addWidget(title) - lay.addWidget(lbl) - w.resize(560, 420) - w.show() - app.processEvents() - pix = w.grab() - pix.save(path) - print(f"[{mode}] saved -> {path}") - - -if __name__ == "__main__": - app = QApplication(sys.argv) - here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - render("before", os.path.join(here, "preview_before.png")) - render("after", os.path.join(here, "preview_after.png")) diff --git a/tests/preview_we_rows.py b/tests/preview_we_rows.py deleted file mode 100644 index 91da189..0000000 --- a/tests/preview_we_rows.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -"""Visual PREVIEW focused on the WILL-EXECUTOR rows of the Building Will dialog. - -Reproduces faithfully the three real variants built in dialogs.py: - - 1. Broadcasting (push) result -> line 774: "{url} : {Ok|Ko}" (plain, no color today) - 2. Timeout -> line 783: "{url} : Timeout - no answer" - 3. Checking already-present -> line 825/834: - "checking {url} - {wid} : Waiting" - "checked {url} - {wid} : True/False" (plain, no color today) - -Shows BEFORE (current) vs AFTER (proposed: result in bold, keeping label as-is). - -Run: - QT_QPA_PLATFORM=offscreen python3 tests/preview_we_rows.py -Writes preview_we_before.png / preview_we_after.png in the repo root. -""" - -import os -import sys - -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - -from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout -from PyQt6.QtCore import Qt - -COLOR_ERROR = "#ff0000" -COLOR_OK = "#05ad05" - -URL1 = "https://executor.example.org" -URL2 = "https://other-executor.net" -WID = "a1b2c3" - - -def err(e): - return "{}".format(COLOR_ERROR, e) - - -# ---------------- BEFORE: exactly as the code builds today ----------------- -def rows_before(): - return [ - # 1. push results (plain text, no color/bold today) - "{} : {}".format(URL1, "Ok"), - "{} : {}".format(URL2, "Ko"), - # 2. timeout (already red, not bold) - "{} : {}".format(URL1, err("Timeout - no answer")), - # 3. already-present check - "checking {} - {} : {}".format(URL1, WID, "Waiting"), - "checked {} - {} : {}".format(URL1, WID, "True"), - "checked {} - {} : {}".format(URL2, WID, "False"), - ] - - -# ---------------- AFTER: result portion in bold, label unchanged ----------- -def err_after(e): - return "{}".format(COLOR_ERROR, e) - - -def rows_after(): - return [ - # 1. push results: color + bold the Ok / Ko outcome - "{} : {}".format(URL1, COLOR_OK, "Ok"), - "{} : {}".format(URL2, COLOR_ERROR, "Ko"), - # 2. timeout: bold the red message - "{} : {}".format(URL1, err_after("Timeout - no answer")), - # 3. already-present check: bold the result - "checking {} - {} : {}".format(URL1, WID, "Waiting"), - "checked {} - {} : {}".format( - URL1, WID, COLOR_OK, "True" - ), - "checked {} - {} : {}".format( - URL2, WID, COLOR_ERROR, "False" - ), - ] - - -def render(rows, title, path): - full_text = "

".join(rows).replace("\n", "
") - w = QWidget() - w.setStyleSheet("background:#2b2b2b;") - lay = QVBoxLayout(w) - t = QLabel(title) - t.setStyleSheet("color:#ffffff; font-size:15px; font-weight:bold;") - lbl = QLabel(full_text) - lbl.setTextFormat(Qt.TextFormat.RichText) - lbl.setStyleSheet("color:#dddddd;") - f = lbl.font() - f.setPointSize(11) - lbl.setFont(f) - lay.addWidget(t) - lay.addWidget(lbl) - w.resize(560, 320) - w.show() - app.processEvents() - w.grab().save(path) - print(f"saved -> {path}") - - -if __name__ == "__main__": - app = QApplication(sys.argv) - here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - render(rows_before(), "Will-Executor rows — BEFORE", - os.path.join(here, "preview_we_before.png")) - render(rows_after(), "Will-Executor rows — AFTER", - os.path.join(here, "preview_we_after.png")) diff --git a/tests/sim_update_flows.py b/tests/sim_update_flows.py deleted file mode 100644 index 70d324e..0000000 --- a/tests/sim_update_flows.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -Real-world simulation of the inheritance update flows. - -This script does NOT touch the GUI. It drives the core decision function -``Will.check_willexecutors_and_heirs`` (the one that decides whether a will is -still coherent or must be rebuilt) through the scenarios the user reported: - - 1. delivery date moved forward (postpone) -> must NOT stay "coherent" - 2. an heir is added -> must trigger rebuild - 3. an heir is removed -> must trigger rebuild - 4. a single heir percentage / amount is changed -> must trigger rebuild - 5. nothing changed -> stays coherent - -For each scenario we report which exception (if any) is raised, because that is -exactly what the GUI relies on to decide whether to rebuild the inheritance -transactions. If the function returns True (coherent) when something DID -change, the GUI will (correctly) show no update -- which is the symptom the -user described. - -Run: - QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.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, - NotCompleteWillException, HeirNotFoundException, NoHeirsException, - TxFeesChangedException, WillExpiredException, -) -from bal.core.util import Util - -# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0. -_VALID_TX_HEX = ( - "01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b" - "f38633b424eb4031000000006c493046022100a82bbc57a0136751e543" - "3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d" - "e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501" - "2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3" - "5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a" - "42146f11ef8414ae929feaafc388ac00000000" -) - -# A locktime far in the past (so the frozen tx.locktime is a fixed integer we -# control via monkey-patching below). We will override w.tx.locktime per test. -TX_FEES = 100 - - -def _make_will_item(heirs, tx_locktime, status_complete=False): - """Build a WillItem whose stored heirs == ``heirs`` and whose tx.locktime - is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx).""" - d = { - "tx": _VALID_TX_HEX, - "heirs": copy.deepcopy(heirs), - "willexecutor": None, - "status": "", - "description": "", - "time": 0, - "change": "", - "baltx_fees": TX_FEES, - } - item = WillItem(d, _id="willid_1") - item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) - # Force the locktime frozen "inside" the signed tx. - item.tx.locktime = tx_locktime - if status_complete: - item.set_status("COMPLETE", True) - return item - - -def _run(label, will_heirs, current_heirs, tx_locktime, - status_complete=False, check_date=0): - """Run check_willexecutors_and_heirs and report the outcome.""" - item = _make_will_item(will_heirs, tx_locktime, status_complete) - will = {"willid_1": item} - outcome = None - try: - result = Will.check_willexecutors_and_heirs( - will, - current_heirs, # the (possibly edited) heirs dict - {}, # willexecutors - False, # self_willexecutor - check_date, # check_date (timestamp) - TX_FEES, # tx_fees - ) - outcome = f"coherent (returned {result})" - except HeirNotFoundException as e: - outcome = f"HeirNotFoundException: {e}" - except NoHeirsException as e: - outcome = f"NoHeirsException: {e}" - except TxFeesChangedException as e: - outcome = f"TxFeesChangedException: {e}" - except WillExpiredException as e: - outcome = f"WillExpiredException: {e}" - except NotCompleteWillException as e: - outcome = f"{type(e).__name__}: {e}" - except Exception as e: - outcome = f"!! UNEXPECTED {type(e).__name__}: {e}" - print(f"[{label}]") - print(f" -> {outcome}") - return outcome - - -def main(): - # locktime string "0d" -> Util.parse_locktime_string returns a timestamp - # ~ now. We use explicit integer timestamps to keep things deterministic. - base_lt = 1900000000 # frozen tx.locktime (year ~2030) - later_lt = "2000000000" # a later locktime string (postpone) - same_lt = str(base_lt) - - # Scenario 0: nothing changed -> should be coherent. - heirs = {"alice": ["addr_alice", 5000, same_lt]} - _run("0. nothing changed", - will_heirs=heirs, current_heirs=copy.deepcopy(heirs), - tx_locktime=base_lt, check_date=0) - - # Scenario 1: delivery date moved forward (postpone), will NOT yet signed. - heirs_will = {"alice": ["addr_alice", 5000, same_lt]} - heirs_now = {"alice": ["addr_alice", 5000, later_lt]} - _run("1. date postponed (unsigned will)", - will_heirs=heirs_will, current_heirs=heirs_now, - tx_locktime=base_lt, check_date=0) - - # Scenario 1b: postpone on a SIGNED will (status COMPLETE). - _run("1b. date postponed (SIGNED will)", - will_heirs=heirs_will, current_heirs=heirs_now, - tx_locktime=base_lt, status_complete=True, check_date=0) - - # Scenario 2: an heir is ADDED. - heirs_will = {"alice": ["addr_alice", 5000, same_lt]} - heirs_now = { - "alice": ["addr_alice", 5000, same_lt], - "bob": ["addr_bob", 3000, same_lt], - } - _run("2. heir added (bob)", - will_heirs=heirs_will, current_heirs=heirs_now, - tx_locktime=base_lt, check_date=0) - - # Scenario 3: an heir is REMOVED. - heirs_will = { - "alice": ["addr_alice", 5000, same_lt], - "bob": ["addr_bob", 3000, same_lt], - } - heirs_now = {"alice": ["addr_alice", 5000, same_lt]} - _run("3. heir removed (bob)", - will_heirs=heirs_will, current_heirs=heirs_now, - tx_locktime=base_lt, check_date=0) - - # Scenario 4: a single heir AMOUNT/percentage changed. - heirs_will = {"alice": ["addr_alice", 5000, same_lt]} - heirs_now = {"alice": ["addr_alice", 9999, same_lt]} - _run("4. heir amount changed (5000 -> 9999)", - will_heirs=heirs_will, current_heirs=heirs_now, - tx_locktime=base_lt, check_date=0) - - # Scenario 5: heir ADDRESS changed. - heirs_will = {"alice": ["addr_alice", 5000, same_lt]} - heirs_now = {"alice": ["addr_NEW", 5000, same_lt]} - _run("5. heir address changed", - will_heirs=heirs_will, current_heirs=heirs_now, - tx_locktime=base_lt, check_date=0) - - print("\n[done] simulation finished") - - -if __name__ == "__main__": - main() diff --git a/tests/smoke_test.py b/tests/smoke_test.py deleted file mode 100644 index e21d2a0..0000000 --- a/tests/smoke_test.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Smoke test for the BAL Electrum plugin. - -Goal: after every refactor step, prove that the plugin still imports cleanly -under a real Electrum 4.7.2 + PyQt6 install, and that a handful of pure-logic -behaviours produce *exactly* the same results as before (regression guard). - -Run with: - QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py - -where is the dotted module path the plugin is reachable -at, e.g. "electrum.plugins.BAL" (original) or "electrum.plugins.bal" (new). -""" -import importlib -import sys - -PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.BAL" - - -def imp(mod): - return importlib.import_module(f"{PKG}.{mod}") - - -def main(): - # --- Qt must be initialised before importing any gui module --- - from PyQt6.QtWidgets import QApplication # noqa - _app = QApplication.instance() or QApplication([]) - - results = {} - - # 1) Core modules import (these must be GUI-free). - bal = imp_core("bal", "core.plugin_base") - util = imp_core("util", "core.util") - heirs = imp_core("heirs", "core.heirs") - will = imp_core("will", "core.will") - we = imp_core("willexecutors", "core.willexecutors") - - # 2) GUI module imports. - qt = imp_gui() - - # 3) Behaviour checks (pure logic, must be identical across versions). - BalTimestamp = bal.BalTimestamp - assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d" - assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y" - assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str" - - Util = util.Util - assert Util.is_perc("50%") is True - assert Util.is_perc("100") is False - assert Util.text_to_hex("BAL") == "42414c" - assert Util.hex_to_text("42414c") == "BAL" - assert Util.int_locktime(days=1) == 86400 - - # heirs constants must keep the same column layout (very delicate!) - assert heirs.HEIR_ADDRESS == 0 - assert heirs.HEIR_AMOUNT == 1 - assert heirs.HEIR_LOCKTIME == 2 - assert heirs.HEIR_REAL_AMOUNT == 3 - assert heirs.HEIR_DUST_AMOUNT == 4 - - # WillItem default status table must stay intact. - assert will.WillItem.STATUS_DEFAULT["VALID"][1] is True - - # 4) Plugin class wiring. - assert qt.Plugin.__bases__[0] is bal.BalPlugin - for h in ("create_status_bar", "init_menubar", "load_wallet", "close_wallet"): - assert hasattr(qt.Plugin, h), f"missing hook {h}" - - print(f"[OK] smoke test passed for package '{PKG}'") - - -def imp_core(old_name, new_name): - """Import a core module, trying the new layout first then the old flat one.""" - for candidate in (new_name, old_name): - try: - return importlib.import_module(f"{PKG}.{candidate}") - except ModuleNotFoundError: - continue - raise ModuleNotFoundError(f"cannot import {old_name}/{new_name} from {PKG}") - - -def imp_gui(): - for candidate in ("gui.qt.plugin", "qt"): - try: - return importlib.import_module(f"{PKG}.{candidate}") - except ModuleNotFoundError: - continue - raise ModuleNotFoundError(f"cannot import gui module from {PKG}") - - -if __name__ == "__main__": - main() diff --git a/tests/test_core_heirs.py b/tests/test_core_heirs.py deleted file mode 100644 index f7ea6ab..0000000 --- a/tests/test_core_heirs.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -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 deleted file mode 100644 index 79b909a..0000000 --- a/tests/test_core_heirs_extra.py +++ /dev/null @@ -1,182 +0,0 @@ -""" -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 deleted file mode 100644 index 7a83007..0000000 --- a/tests/test_core_plugin_base.py +++ /dev/null @@ -1,259 +0,0 @@ -""" -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 deleted file mode 100644 index 3284f75..0000000 --- a/tests/test_core_util.py +++ /dev/null @@ -1,470 +0,0 @@ -""" -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 deleted file mode 100644 index 7165e73..0000000 --- a/tests/test_core_will.py +++ /dev/null @@ -1,373 +0,0 @@ -""" -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 _make_will_with_heirs(heirs, tx_locktime): - """Build a single-item will whose stored heirs == ``heirs`` and whose - frozen tx.locktime == ``tx_locktime`` (what the will-executors hold).""" - item = WillItem(_make_minimal_willitem_dict(heirs=copy.deepcopy(heirs))) - item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) - item.tx.locktime = tx_locktime - return {"willid_1": item} - - -def test_check_heirs_unchanged_is_coherent(): - """No heir change -> the will stays coherent (no rebuild).""" - lt = 1900000000 - heirs = {"alice": ["addr_alice", 5000, str(lt)]} - will = _make_will_with_heirs(heirs, lt) - result = Will.check_willexecutors_and_heirs( - will, copy.deepcopy(heirs), {}, False, 0, 100 - ) - assert result is True - - -def test_check_heir_removed_triggers_rebuild(): - """Removing an heir MUST be detected (HeirNotFoundException), so the Check - button and on_close rebuild the inheritance. Regression test for the bug - where a removed heir silently stayed in the transaction.""" - from bal.core.will import HeirNotFoundException - lt = 1900000000 - will_heirs = { - "alice": ["addr_alice", 5000, str(lt)], - "bob": ["addr_bob", 3000, str(lt)], - } - current_heirs = {"alice": ["addr_alice", 5000, str(lt)]} # bob removed - will = _make_will_with_heirs(will_heirs, lt) - raised = False - try: - Will.check_willexecutors_and_heirs( - will, current_heirs, {}, False, 0, 100 - ) - except HeirNotFoundException: - raised = True - assert raised, "removing an heir must raise HeirNotFoundException" - - -def test_check_heir_added_triggers_rebuild(): - """Adding an heir must be detected (HeirNotFoundException).""" - from bal.core.will import HeirNotFoundException - lt = 1900000000 - will_heirs = {"alice": ["addr_alice", 5000, str(lt)]} - current_heirs = { - "alice": ["addr_alice", 5000, str(lt)], - "bob": ["addr_bob", 3000, str(lt)], # added - } - will = _make_will_with_heirs(will_heirs, lt) - raised = False - try: - Will.check_willexecutors_and_heirs( - will, current_heirs, {}, False, 0, 100 - ) - except HeirNotFoundException: - raised = True - assert raised, "adding an heir must raise HeirNotFoundException" - - -def test_needs_server_check(): - """Check button selection logic: a VALID will with a will-executor that is - not yet CHECKED must be queried on the server, even if it is not PUSHED - (regression for the 'New / Not sent' wills that Check ignored).""" - we = {"url": "https://we.example.com"} - - # New (not PUSHED) but has a will-executor -> must be checked. - item_new = _make_willitem_blank() - item_new.we = we - assert Will.needs_server_check(item_new) is True - - # PUSHED but not CHECKED -> must be checked (previous behaviour). - item_pushed = _make_willitem_blank() - item_pushed.we = we - item_pushed.set_status("PUSHED", True) - assert Will.needs_server_check(item_pushed) is True - - # Already CHECKED -> no need to check again. - item_checked = _make_willitem_blank() - item_checked.we = we - item_checked.set_status("CHECKED", True) - assert Will.needs_server_check(item_checked) is False - - # No will-executor assigned -> nothing to check on a server. - item_no_we = _make_willitem_blank() - item_no_we.we = None - assert Will.needs_server_check(item_no_we) is False - - # Not VALID (e.g. invalidated) -> not checked. - item_invalid = _make_willitem_blank() - item_invalid.we = we - item_invalid.set_status("INVALIDATED", True) - assert Will.needs_server_check(item_invalid) is False - - -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, - WillPostponedException, - ) - - 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) - # WillPostponedException is a NotCompleteWillException but MUST be caught - # before it in task_phase1, so it triggers an on-chain invalidation. - assert issubclass(WillPostponedException, NotCompleteWillException) - - # 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 deleted file mode 100644 index 0331f9d..0000000 --- a/tests/test_core_will_extra.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -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 deleted file mode 100644 index 0f42b82..0000000 --- a/tests/test_gui_calendar.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -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 deleted file mode 100644 index 0d12c53..0000000 --- a/tests/test_gui_common.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -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 deleted file mode 100644 index 7d20de6..0000000 --- a/tests/test_gui_theme.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -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 deleted file mode 100644 index 8debdf1..0000000 --- a/tests/test_gui_widgets.py +++ /dev/null @@ -1,255 +0,0 @@ -""" -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 deleted file mode 100644 index 8a99912..0000000 --- a/tests/test_gui_window_utils.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -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") diff --git a/tests/windows_overflow_test.py b/tests/windows_overflow_test.py deleted file mode 100644 index 8582cf9..0000000 --- a/tests/windows_overflow_test.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -Regression test for the Windows year-2038 OverflowError crash. - -Background ----------- -On Windows ``time_t`` is 32-bit, so ``datetime.fromtimestamp(ts)`` raises -``OverflowError: Python int too large to convert to C int`` for any timestamp -past 2038 (e.g. ``NLOCKTIME_MAX = 2**32 - 1``, used as the default/sentinel -locktime). On 64-bit Linux the same call succeeds, which is why the bug only -showed up on the user's Windows build: ``BalWindow.__init__`` -> -``create_heirs_tab`` -> ``WillSettingsWidget`` -> ``on_locktime_change`` -> -``BalTimestamp.to_date`` -> ``datetime.fromtimestamp(NLOCKTIME_MAX)`` crashed, -which aborted ``init_menubar`` / ``load_wallet`` and left the Will/Heirs tabs -and the menu entry half-built (the garbled/condensed element under the logo). - -This test forces ``datetime.fromtimestamp`` to behave like the Windows 32-bit -implementation, then exercises ``BalTimestamp`` with NLOCKTIME_MAX to prove the -overflow-safe conversion no longer raises and clamps to INT32_MAX. - -Run with: - QT_QPA_PLATFORM=offscreen PYTHONPATH= \ - python3 tests/windows_overflow_test.py -""" -import datetime as _datetime_mod -import importlib -import sys - -PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.bal" - -INT32_MAX = 2 ** 31 - 1 -NLOCKTIME_MAX = 2 ** 32 - 1 # 4294967295, the value seen in the crash log - -_real_datetime = _datetime_mod.datetime - - -class _WindowsLikeDatetime(_real_datetime): - """A datetime subclass whose fromtimestamp emulates Windows' 32-bit limit.""" - - @classmethod - def fromtimestamp(cls, ts, tz=None): - if tz is None and (ts > INT32_MAX or ts < 0): - raise OverflowError("Python int too large to convert to C int") - return _real_datetime.fromtimestamp(ts, tz) - - -def main(): - plugin_base = importlib.import_module(f"{PKG}.core.plugin_base") - BalTimestamp = plugin_base.BalTimestamp - - # 1) Sanity: with the real (Linux 64-bit) datetime, large ts works already. - bt = BalTimestamp(NLOCKTIME_MAX) - d = bt.to_date() - assert isinstance(d, _real_datetime), d - print("[OK] BalTimestamp(NLOCKTIME_MAX).to_date() works on this platform") - - # 2) Now emulate Windows: patch datetime in the plugin_base module so that - # fromtimestamp raises OverflowError past 2038, exactly like Windows. - original = plugin_base.datetime - plugin_base.datetime = _WindowsLikeDatetime - try: - # 2a) Absolute sentinel timestamp (the exact crash path from the log). - bt = BalTimestamp(NLOCKTIME_MAX) - d = bt.to_date() # must NOT raise OverflowError anymore - assert d.year <= 2038, f"expected clamp to <=2038, got {d!r}" - print("[OK] to_date(NLOCKTIME_MAX) no longer raises (clamped to INT32_MAX)") - - # 2b) to_timestamp must also be safe. - ts = bt.to_timestamp() - assert ts <= INT32_MAX, ts - print("[OK] to_timestamp(NLOCKTIME_MAX) clamped & safe") - - # 2c) __str__ / __repr__ must not raise either. - _ = str(bt) - _ = repr(bt) - print("[OK] str()/repr() on out-of-range timestamp are safe") - - # 2d) Relative durations that overflow when added (e.g. huge 'd'). - bt_rel = BalTimestamp(f"{10 ** 9}d") # ~2.7M years -> overflow - d2 = bt_rel.to_date() - assert d2 is not None - print("[OK] huge relative duration no longer raises") - - # 2e) Normal values are unchanged (behaviour-preserving check). - bt_norm = BalTimestamp("90d") - d3 = bt_norm.to_date() - # 90 days from now, normalised to midnight - assert d3.hour == 0 and d3.minute == 0 and d3.second == 0 - print("[OK] normal '90d' value still resolves to a midnight datetime") - finally: - plugin_base.datetime = original - - print(f"\n[OK] Windows overflow regression passed for package {PKG!r}") - return 0 - - -if __name__ == "__main__": - sys.exit(main())