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())