Remove all files

This commit is contained in:
2026-06-28 22:42:48 -04:00
parent 3c741c5ebc
commit a62c8349e3
62 changed files with 0 additions and 14505 deletions

6
.gitignore vendored
View File

@@ -1,6 +0,0 @@
**/__pycache__/
*.pyc
*.zip
bal-electrum-plugin.zip
electrum-src/
preview_*.png

View File

@@ -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 `<br>` 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.

View File

@@ -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()``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 B1B10 esistono **identici nell'originale** — questo refactor li
> ha preservati fedelmente (era l'obiettivo della fase precedente). La Fase B/C
> li corregge.

133
README.md
View File

@@ -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=<electrum-src> \
python3 tests/smoke_test.py electrum.plugins.bal
# external-zip loading regression
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
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).

View File

@@ -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=<electrum-src> \
python3 -m pytest tests/ -q
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
python3 tests/smoke_test.py electrum.plugins.bal
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
python3 tests/external_zip_test.py bal-electrum-plugin.zip
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -1 +0,0 @@
0.3.2

View File

@@ -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"

View File

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

View File

@@ -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.
"""

View File

@@ -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 "<n>%")
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<total_fees or balance < wallet.dust_threshold():
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
willexecutors_amount = 0
willexecutors = {}
heir_list = {}
onlyfixed = False
newbalance = balance - total_fees
locktimes = self.get_locktimes(from_locktime)
if willexecutor:
for locktime in locktimes:
if int(Util.int_locktime(locktime)) > 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)}<minimum{from_locktime}"
),
heir_list.update(willexecutors)
newbalance -= willexecutors_amount
if newbalance < 0:
raise WillExecutorFeeException(willexecutor)
(
fixed_heirs,
fixed_amount,
percent_heirs,
percent_amount,
fixed_amount_with_dust,
) = self.fixed_percent_lists_amount(from_locktime, wallet.dust_threshold())
if fixed_amount > 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}"

View File

@@ -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:
* ``"<n>y"`` -> ``n`` years (unit ``"y"``)
* ``"<n>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}"

View File

@@ -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
* ``"<n>y"`` -> n years from now (as a timestamp)
* ``"<n>d"`` -> n days from now (as a timestamp)
* ``"<n>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"

File diff suppressed because it is too large Load Diff

View File

@@ -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}"

View File

View File

@@ -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)
"""

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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 (<b>, <br>) render.
help_text = (
"<b>CHECK ALIVE</b><br><br>"
"Check to ask for invalidation.<br><br>"
"When less then this time is missing, ask to invalidate.<br>"
"If you fail to invalidate during this time, your transactions will be delivered to your heirs.<br><br>"
"if you choose Raw, you can insert various options based on suffix:<br>"
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
" - y: number of years after currrent day(ex: 1y means one year from today)<br>"
)
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 (<b>, <br>) render.
help_text = (
"<b>DELIVERY TIME</b><br><br>"
"Set Locktime for transactions.<br>"
"Any time is needed transaction will be anticipated by 1day<br><br>"
"if you choose Raw, you can insert various options based on suffix:<br>"
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
" - y: number of years after currrent day(ex: 1y means one year from today)<br>"
)
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 = "<b>" + _(str(title)) + f":</b>\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("<b>Heirs:</b>"))
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(_("<b>Willexecutor:</b:")))
decoded_amount = Util.decode_amount(
self.will[w].we["base_fee"], self._bal_parent.decimal_point
)
detaillayout.addWidget(
qlabel(
self.will[w].we["url"],
f"{decoded_amount} {self._bal_parent.base_unit_name}",
)
)
detaillayout.addStretch()
pal = QPalette()
pal.setColor(
QPalette.ColorRole.Window, QColor(status_color(self.will[w]))
)
detailw.setAutoFillBackground(True)
detailw.setPalette(pal)
hlayout.addWidget(detailw)
hlayout.addWidget(WillWidget(w, parent=parent))

File diff suppressed because it is too large Load Diff

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 831 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -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"
}

View File

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

View File

@@ -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 <same arguments>
```
- 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.

View File

@@ -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 <command> <wallet path>")
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")

View File

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

View File

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

View File

@@ -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=<electrum-src> \
python3 tests/external_zip_test.py <path-to-bal-electrum-plugin.zip>
"""
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]))

View File

@@ -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=<electrum-src> \
python3 tests/gui_fixes_test.py <PKG>
where <PKG> 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]))

View File

@@ -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=<electrum-src> \
python3 tests/parallel_ping_test.py <PLUGIN_IMPORT_NAME>
"""
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())

View File

@@ -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 "<font color='{}'>{}</font>".format(COLOR_OK, e)
def error_before(e):
return "<font color='{}'>{}</font>".format(COLOR_ERROR, e)
def row_before(msg, status, color=None):
if color is None:
return f"{msg}:\t{status}"
return "<font color={}>{}:\t{}</font>".format(color, msg, status)
# ---- proposed rendering (AFTER): results in bold --------------------------
def ok_after(e="Ok"):
return "<font color='{}'><b>{}</b></font>".format(COLOR_OK, e)
def error_after(e):
return "<font color='{}'><b>{}</b></font>".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<b>{status}</b>"
# When a color is given for the whole line, keep the label normal and bold
# only the status portion.
return "{}:\t<font color={}><b>{}</b></font>".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 = "<br><br>".join(rows).replace("\n", "<br>")
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"))

View File

@@ -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} : <font red>Timeout - no answer</font>"
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 "<font color='{}'>{}</font>".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 "<font color='{}'><b>{}</b></font>".format(COLOR_ERROR, e)
def rows_after():
return [
# 1. push results: color + bold the Ok / Ko outcome
"{} : <font color='{}'><b>{}</b></font>".format(URL1, COLOR_OK, "Ok"),
"{} : <font color='{}'><b>{}</b></font>".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 {} - {} : <b>{}</b>".format(URL1, WID, "Waiting"),
"checked {} - {} : <font color='{}'><b>{}</b></font>".format(
URL1, WID, COLOR_OK, "True"
),
"checked {} - {} : <font color='{}'><b>{}</b></font>".format(
URL2, WID, COLOR_ERROR, "False"
),
]
def render(rows, title, path):
full_text = "<br><br>".join(rows).replace("\n", "<br>")
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"))

View File

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

View File

@@ -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 <PLUGIN_IMPORT_NAME>
where <PLUGIN_IMPORT_NAME> 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()

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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=<electrum-src> \
python3 tests/windows_overflow_test.py <PLUGIN_IMPORT_NAME>
"""
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())