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 cfd34e7..0000000 Binary files a/bal/icons/bal16x16.png and /dev/null differ diff --git a/bal/icons/bal32x32.png b/bal/icons/bal32x32.png deleted file mode 100644 index 58b1d70..0000000 Binary files a/bal/icons/bal32x32.png and /dev/null differ diff --git a/bal/icons/calendar.png b/bal/icons/calendar.png deleted file mode 100755 index ccc3d0d..0000000 Binary files a/bal/icons/calendar.png and /dev/null differ diff --git a/bal/icons/confirmed.png b/bal/icons/confirmed.png deleted file mode 100644 index 2023abd..0000000 Binary files a/bal/icons/confirmed.png and /dev/null differ diff --git a/bal/icons/heir.png b/bal/icons/heir.png deleted file mode 100644 index 92490a0..0000000 Binary files a/bal/icons/heir.png and /dev/null differ diff --git a/bal/icons/reload.png b/bal/icons/reload.png deleted file mode 100644 index a3d9807..0000000 Binary files a/bal/icons/reload.png and /dev/null differ diff --git a/bal/icons/status_connected.png b/bal/icons/status_connected.png deleted file mode 100644 index 1fe3dac..0000000 Binary files a/bal/icons/status_connected.png and /dev/null differ diff --git a/bal/icons/unconfirmed.png b/bal/icons/unconfirmed.png deleted file mode 100644 index 6ebfe29..0000000 Binary files a/bal/icons/unconfirmed.png and /dev/null differ diff --git a/bal/icons/will.png b/bal/icons/will.png deleted file mode 100644 index 45fb8db..0000000 Binary files a/bal/icons/will.png and /dev/null differ diff --git a/bal/icons/wizard.png b/bal/icons/wizard.png deleted file mode 100644 index e7696cd..0000000 Binary files a/bal/icons/wizard.png and /dev/null differ 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())