Remove all files
6
.gitignore
vendored
@@ -1,6 +0,0 @@
|
|||||||
**/__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.zip
|
|
||||||
bal-electrum-plugin.zip
|
|
||||||
electrum-src/
|
|
||||||
preview_*.png
|
|
||||||
@@ -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.
|
|
||||||
319
DIAGNOSI_GUI.md
@@ -1,319 +0,0 @@
|
|||||||
# BAL — Diagnosi dei problemi GUI (Fase A) → ✅ RISOLTI (Fase B)
|
|
||||||
|
|
||||||
> **STATO: tutti i bug B1-B10 sono stati CORRETTI** e mergiati in `main`
|
|
||||||
> (PR #2, squash `dd6f677`). La logica di business resta **byte-identica**
|
|
||||||
> (nessuna modifica a `bal/core/*`): sono cambiati solo presentazione, parent,
|
|
||||||
> modalità, ciclo di vita e cleanup delle finestre.
|
|
||||||
>
|
|
||||||
> | ID | Stato | Fix applicato |
|
|
||||||
> |----|-------|---------------|
|
|
||||||
> | B1 | ✅ FIXED | `self.parent` → `self._bal_parent` (dialogs/lists/widgets); parent = `top_level_of(parent)` |
|
|
||||||
> | B2 | ✅ FIXED | `.show()` → `show_on_top()` / `show_modal()` con parent corretto |
|
|
||||||
> | B3 | ✅ FIXED | init a caldo: `_setup_window()` replica `load_wallet`, niente "restart Electrum" |
|
|
||||||
> | B4 | ✅ FIXED | chiave finestra stabile `_window_key()` = `id(window)` |
|
|
||||||
> | B5 | ✅ FIXED | `on_close` riscritto: niente `except:pass`, log per-step, reset stato |
|
|
||||||
> | B6 | ✅ FIXED | `BalBlockingWaitingDialog`: `processEvents()` ripristinato |
|
|
||||||
> | B7 | ✅ FIXED | `closeEvent/hideEvent`: `stop_thread()` + `super()` |
|
|
||||||
> | B8 | ✅ FIXED | `closeEvent`: `stop_thread()` (stop+wait) + `super()` |
|
|
||||||
> | B9 | ✅ FIXED | `bring_to_front()` = `raise_()` + `activateWindow()` |
|
|
||||||
> | B10| ✅ FIXED | uso di `window.tools_menu` (API ufficiale), niente ricerca per titolo `&Tools` |
|
|
||||||
>
|
|
||||||
> Helper centralizzati in `bal/gui/qt/window_utils.py`:
|
|
||||||
> `top_level_of`, `bring_to_front`, `stop_thread`, `show_modal`, `show_on_top`.
|
|
||||||
> Test di regressione: `tests/gui_fixes_test.py` (oltre a smoke + external_zip).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## (Storico) Diagnosi originale
|
|
||||||
|
|
||||||
Documento di sola **diagnosi**: nessuna riga di codice funzionale era stata
|
|
||||||
modificata in Fase A. Elenca i problemi grafici/di ciclo di vita riscontrati nel
|
|
||||||
codice, la loro **causa tecnica** e il **fix proposto**, con riferimenti riga.
|
|
||||||
|
|
||||||
I due sintomi che hai segnalato:
|
|
||||||
- **(S1)** Le finestre del plugin spariscono dietro la finestra di Electrum.
|
|
||||||
- **(S2)** Alcuni meccanismi funzionano solo dopo aver chiuso e "ripulito"
|
|
||||||
Electrum.
|
|
||||||
|
|
||||||
Sono entrambi spiegati dai bug qui sotto.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Riepilogo (tabella)
|
|
||||||
|
|
||||||
| ID | Gravità | Sintomo | File:riga | Causa breve |
|
|
||||||
|----|---------|---------|-----------|-------------|
|
|
||||||
| B1 | 🔴 Alta | S1 | `dialogs.py:40,69,475` | `self.parent = parent` sovrascrive il metodo `QWidget.parent()` |
|
|
||||||
| B2 | 🔴 Alta | S1 | `window.py:148,936`, `window.py:566` | dialoghi aperti con `.show()` (non-modali, senza stare in primo piano) |
|
|
||||||
| B3 | 🔴 Alta | S2 | `plugin.py:38-42` | messaggio "Please restart Electrum" = init a caldo non gestito |
|
|
||||||
| B4 | 🔴 Alta | S2 | `plugin.py:45,111` | chiave dizionario `winId` (metodo) invece di `winId()` (valore) |
|
|
||||||
| B5 | 🟠 Media | S2 | `window.py:664-677` | `on_close` con `except: pass` che nasconde errori di cleanup |
|
|
||||||
| B6 | 🟠 Media | S1/S2 | `dialogs.py:445-462` | `BalBlockingWaitingDialog` blocca il thread GUI, `processEvents` commentato |
|
|
||||||
| B7 | 🟠 Media | S2 | `dialogs.py:48-58` | `closeEvent/hideEvent` con cleanup thread commentato |
|
|
||||||
| B8 | 🟠 Media | S2 | `dialogs.py:828-830` | `closeEvent` chiama `thread.stop()` ma non `thread.wait()` né `super()` |
|
|
||||||
| B9 | 🟡 Bassa | S1 | `dialogs.py:1121-1122` | `show()+raise_()` senza `activateWindow()` né modalità |
|
|
||||||
| B10| 🟡 Bassa | — | `plugin.py:36` (init), vari | gestione finestre multiple/ multi-wallet fragile |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dettaglio dei problemi
|
|
||||||
|
|
||||||
### B1 — `self.parent = parent` rompe il sistema di finestre di Qt 🔴
|
|
||||||
**Dove:** `dialogs.py:40` (in `BalDialog.__init__`), ripetuto a `:69` e `:475`;
|
|
||||||
analoghi in altri dialoghi.
|
|
||||||
|
|
||||||
```python
|
|
||||||
self.parent = parent # <-- PROBLEMA
|
|
||||||
super().__init__(parent)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Causa:** in Qt, `parent()` è un **metodo** di `QWidget` che restituisce il
|
|
||||||
widget genitore. Assegnando un **attributo** `self.parent`, lo si maschera: da
|
|
||||||
quel punto `self.parent` non è più il metodo ma il valore salvato. Qualunque
|
|
||||||
codice (anche interno a Qt o di Electrum) che si aspetta `widget.parent()` come
|
|
||||||
metodo può comportarsi in modo imprevisto. Inoltre il `parent` passato non è
|
|
||||||
sempre la **top-level window** corretta, quindi il dialogo non viene agganciato
|
|
||||||
gerarchicamente alla finestra di Electrum e finisce **dietro** (S1).
|
|
||||||
|
|
||||||
**Fix proposto:**
|
|
||||||
- Non sovrascrivere `parent`: rinominare l'attributo (es. `self._bal_parent`).
|
|
||||||
- Passare sempre come `parent` la **top-level window** di Electrum
|
|
||||||
(`window.top_level_window()`), così il dialogo resta in primo piano rispetto
|
|
||||||
ad essa.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B2 — Dialoghi aperti con `.show()` invece che modali 🔴
|
|
||||||
**Dove:**
|
|
||||||
- `window.py:148` `show_willexecutor_dialog` → `self.willexecutor_dialog.show()`
|
|
||||||
- `window.py:936` `preview_modal_dialog` → `self.dw.show()` (il nome dice
|
|
||||||
"modal" ma usa `show()`!)
|
|
||||||
- `window.py:566` `show_transaction_real` → `d.show()`
|
|
||||||
|
|
||||||
**Causa:** `show()` apre una finestra **non-modale e indipendente**: se il
|
|
||||||
`parent` non è impostato correttamente (vedi B1), la finestra non resta sopra
|
|
||||||
Electrum e ci "sparisce dietro" (S1). Si nota l'incoerenza: altrove si usa
|
|
||||||
correttamente `.exec()` (es. `init_wizard` a `window.py:144`, `settings_dialog`
|
|
||||||
a `plugin.py:254`), che è modale e resta in primo piano.
|
|
||||||
|
|
||||||
**Fix proposto:**
|
|
||||||
- Per i dialoghi che devono restare in primo piano: usare `exec()` (modale) **o**
|
|
||||||
`show()` + parent corretto + `setWindowModality(Qt.WindowModal)` +
|
|
||||||
`raise_()` + `activateWindow()`.
|
|
||||||
- Mantenere la stessa logica di "cosa fa il dialogo" (nessun cambio di
|
|
||||||
comportamento funzionale, solo z-order/modalità).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B3 — "Please restart Electrum to activate the BAL plugin" 🔴
|
|
||||||
**Dove:** `plugin.py:38-42` (hook `init_qt`).
|
|
||||||
|
|
||||||
```python
|
|
||||||
if wallet:
|
|
||||||
window.show_warning(_("Please restart Electrum to activate the BAL plugin"), ...)
|
|
||||||
return
|
|
||||||
```
|
|
||||||
|
|
||||||
**Causa:** quando il plugin viene **abilitato a caldo** (wallet già aperto),
|
|
||||||
l'hook `init_qt` si arrende e chiede il riavvio invece di inizializzare le tab
|
|
||||||
e i menu sul wallet già caricato. È **la causa diretta del sintomo S2**: "devi
|
|
||||||
chiudere/riavviare Electrum perché funzioni".
|
|
||||||
|
|
||||||
**Fix proposto:**
|
|
||||||
- In `init_qt`, se c'è già un wallet aperto, eseguire la stessa inizializzazione
|
|
||||||
che normalmente avviene in `load_wallet` (creare `BalWindow`, tab, menu,
|
|
||||||
caricare il will) **senza** richiedere il riavvio.
|
|
||||||
- Simmetricamente, gestire bene `close_wallet` per smontare tab/menu, così
|
|
||||||
ri-abilitare/ricaricare non lascia stato sporco.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B4 — Chiave del dizionario `winId` (metodo) invece di `winId()` 🔴
|
|
||||||
**Dove:** `plugin.py:45` (scrittura) e `plugin.py:111` (lettura).
|
|
||||||
|
|
||||||
```python
|
|
||||||
self.bal_windows[top_level_window.winId] = w # scrive con la *funzione* winId
|
|
||||||
...
|
|
||||||
w = self.bal_windows.get(window.winId, None) # legge con la *funzione* winId
|
|
||||||
```
|
|
||||||
|
|
||||||
**Causa:** `winId` senza parentesi è il **metodo legato** (bound method), non
|
|
||||||
l'identificatore della finestra. Usato come chiave "funziona per caso" perché
|
|
||||||
lo stesso oggetto-finestra produce lo stesso bound method; ma è fragile e
|
|
||||||
semanticamente errato: con più finestre/wallet o dopo riaperture la
|
|
||||||
corrispondenza può saltare, creando `BalWindow` duplicati o non trovando quello
|
|
||||||
giusto → stato incoerente (contribuisce a S2).
|
|
||||||
|
|
||||||
**Fix proposto:**
|
|
||||||
- Usare una chiave stabile e corretta, es. `int(window.winId())` oppure
|
|
||||||
`id(window)`, in modo **coerente** sia in scrittura sia in lettura.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B5 — `on_close` ingoia tutti gli errori 🟠
|
|
||||||
**Dove:** `window.py:664-677`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def on_close(self):
|
|
||||||
try:
|
|
||||||
if not self.disable_plugin:
|
|
||||||
close_window = BalBuildWillDialog(self)
|
|
||||||
close_window.build_will_task()
|
|
||||||
self.save_willitems()
|
|
||||||
self.heirs_tab.close()
|
|
||||||
...
|
|
||||||
except Exception:
|
|
||||||
pass # <-- nasconde qualsiasi errore di cleanup
|
|
||||||
```
|
|
||||||
|
|
||||||
**Causa:** se una qualsiasi di queste operazioni fallisce, l'eccezione viene
|
|
||||||
silenziata: tab/menu non vengono rimossi, lo stato (`willitems`, `heirs`, tab)
|
|
||||||
resta in memoria e "sporco" finché non si riavvia Electrum (S2).
|
|
||||||
|
|
||||||
**Fix proposto:**
|
|
||||||
- Non silenziare: loggare l'errore con `_logger`.
|
|
||||||
- Rendere il cleanup **robusto e idempotente** (ogni passo in un try/except
|
|
||||||
separato con log), così un fallimento parziale non blocca gli altri passi.
|
|
||||||
- Azzerare esplicitamente lo stato (`willitems={}`, riferimenti a tab/menu a
|
|
||||||
`None`) a fine `on_close`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B6 — `BalBlockingWaitingDialog` blocca il thread della GUI 🟠
|
|
||||||
**Dove:** `dialogs.py:445-462`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
self.show()
|
|
||||||
# QCoreApplication.processEvents() # <-- commentato
|
|
||||||
# QCoreApplication.processEvents()
|
|
||||||
try:
|
|
||||||
task() # esegue il task SUL thread GUI -> finestra "congelata"
|
|
||||||
finally:
|
|
||||||
self.accept()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Causa:** dopo `show()` non si dà alla GUI il tempo di disegnarsi
|
|
||||||
(`processEvents` è commentato) e poi si esegue `task()` **bloccando** il thread
|
|
||||||
dell'interfaccia. Risultato: la finestra "Please wait" può apparire vuota,
|
|
||||||
non ridisegnarsi, e l'app sembra bloccata (contribuisce a S1/percezione di
|
|
||||||
freeze).
|
|
||||||
|
|
||||||
**Fix proposto:**
|
|
||||||
- O eseguire il task in un `TaskThread` (come fa già `BalWaitingDialog`),
|
|
||||||
- oppure, se deve restare bloccante, ripristinare un `processEvents()` dopo
|
|
||||||
`show()` per far disegnare la finestra prima del task.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B7 — `closeEvent`/`hideEvent` con cleanup thread commentato 🟠
|
|
||||||
**Dove:** `dialogs.py:48-58` (`BalDialog`).
|
|
||||||
|
|
||||||
```python
|
|
||||||
def closeEvent(self, event):
|
|
||||||
self._stopping = True
|
|
||||||
#if self.thread:
|
|
||||||
# self.thread.stop() # <-- disattivato
|
|
||||||
super().closeEvent(event)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Causa:** alla chiusura del dialogo i thread eventualmente attivi **non**
|
|
||||||
vengono fermati. Restano in esecuzione in background, possono scrivere su widget
|
|
||||||
già distrutti o tenere risorse/connessioni → comportamenti erratici finché non
|
|
||||||
si riavvia (S2).
|
|
||||||
|
|
||||||
**Fix proposto:**
|
|
||||||
- Ripristinare in modo sicuro lo stop dei thread: `if self.thread:
|
|
||||||
self.thread.stop(); self.thread.wait()` con guardia su `None`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B8 — `BalBuildWillDialog.closeEvent` incompleto 🟠
|
|
||||||
**Dove:** `dialogs.py:828-830`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def closeEvent(self, event):
|
|
||||||
self._stopping = True
|
|
||||||
self.thread.stop()
|
|
||||||
# manca self.thread.wait() e manca super().closeEvent(event)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Causa:** `stop()` segnala lo stop ma non attende la fine del thread
|
|
||||||
(`wait()`), e non viene chiamato `super().closeEvent(event)`: l'evento di
|
|
||||||
chiusura non è propagato correttamente. Possibili thread orfani e finestre che
|
|
||||||
non si chiudono pulite.
|
|
||||||
|
|
||||||
**Fix proposto:**
|
|
||||||
- `self.thread.stop(); self.thread.wait(); super().closeEvent(event)` con
|
|
||||||
guardia su `self.thread is None`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B9 — `show()+raise_()` senza `activateWindow()`/modalità 🟡
|
|
||||||
**Dove:** `dialogs.py:1121-1122` (es. `WillExecutorDialog`/dettaglio).
|
|
||||||
|
|
||||||
```python
|
|
||||||
self.show()
|
|
||||||
self.raise_()
|
|
||||||
# manca self.activateWindow(); nessuna modalità impostata
|
|
||||||
```
|
|
||||||
|
|
||||||
**Causa:** `raise_()` alza la finestra nello stack ma su alcuni window manager
|
|
||||||
(incluso Windows) senza `activateWindow()` non riceve il focus e può comunque
|
|
||||||
finire dietro. Senza modalità, l'utente può tornare alla finestra principale
|
|
||||||
lasciando il dialogo nascosto.
|
|
||||||
|
|
||||||
**Fix proposto:**
|
|
||||||
- Aggiungere `self.activateWindow()` dopo `raise_()`, e valutare
|
|
||||||
`setWindowModality(Qt.WindowModal)` dove ha senso.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### B10 — Gestione finestre multiple / multi-wallet fragile 🟡
|
|
||||||
**Dove:** `plugin.py:30-62` (`init_qt`), `get_window` (`plugin.py:109-115`).
|
|
||||||
|
|
||||||
**Causa:** la mappa `bal_windows` e l'aggancio ai menu si basano su assunzioni
|
|
||||||
(B4) e sull'iterazione dei figli del menubar per nome (`"&Tools"`), che è
|
|
||||||
sensibile alla **localizzazione** (tu usi `Locale: Italian_Italy`!). Se il menu
|
|
||||||
non si chiama esattamente `&Tools` nella lingua corrente, l'aggancio può
|
|
||||||
fallire silenziosamente.
|
|
||||||
|
|
||||||
**Fix proposto:**
|
|
||||||
- Usare l'API ufficiale `window.tools_menu` (già usata in `init_menubar`,
|
|
||||||
`plugin.py:79`) invece di cercare il menu per titolo tradotto.
|
|
||||||
- Unificare la creazione/lookup di `BalWindow` su una chiave stabile (B4).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Strategia di correzione proposta (per la Fase B/C)
|
|
||||||
|
|
||||||
Per **non cambiare la logica di funzionamento** e ridurre i rischi, propongo di
|
|
||||||
introdurre un **unico punto centralizzato** di gestione finestre (un piccolo
|
|
||||||
helper, es. `gui/qt/window_utils.py`) con funzioni tipo:
|
|
||||||
|
|
||||||
- `show_modal(dialog)` → imposta parent corretto, modalità, `exec()`.
|
|
||||||
- `show_on_top(dialog)` → `show()` + `raise_()` + `activateWindow()` per i
|
|
||||||
pochi casi che devono restare non-modali.
|
|
||||||
|
|
||||||
E poi sostituire i `.show()`/`.exec()` sparsi con queste funzioni. Vantaggi:
|
|
||||||
- la **logica di business resta intatta** (cosa fa il dialogo non cambia);
|
|
||||||
- si tocca **solo** il "come" viene mostrato/chiuso;
|
|
||||||
- più facile da testare e da revisionare (diff piccolo e localizzato).
|
|
||||||
|
|
||||||
### Ordine consigliato
|
|
||||||
1. **B3 + B4** (init a caldo + chiave finestre): risolvono la radice di S2.
|
|
||||||
2. **B1 + B2 + B9** (parent/modalità/z-order): risolvono S1.
|
|
||||||
3. **B5 + B7 + B8** (cleanup robusto + thread): chiudono i residui di S2.
|
|
||||||
4. **B6 + B10** (waiting dialog + menu localizzati): rifiniture.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cosa serve da te per la Fase B/C
|
|
||||||
- Conferma che posso modificare il **comportamento della GUI** (parent,
|
|
||||||
modalità, cleanup, init a caldo) mantenendo invariata la logica di business.
|
|
||||||
- Test su **Electrum portable Windows** dopo ogni gruppo di fix, con descrizione
|
|
||||||
/screenshot di cosa succede (apertura dialoghi, abilitazione a caldo,
|
|
||||||
chiusura wallet).
|
|
||||||
|
|
||||||
> Nota: i bug B1–B10 esistono **identici nell'originale** — questo refactor li
|
|
||||||
> ha preservati fedelmente (era l'obiettivo della fase precedente). La Fase B/C
|
|
||||||
> li corregge.
|
|
||||||
133
README.md
@@ -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).
|
|
||||||
@@ -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).
|
|
||||||
21
bal/LICENSE
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
0.3.2
|
|
||||||
@@ -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"
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
|
|
||||||
"""
|
|
||||||
@@ -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}"
|
|
||||||
@@ -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}"
|
|
||||||
619
bal/core/util.py
@@ -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"
|
|
||||||
1008
bal/core/will.py
@@ -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}"
|
|
||||||
@@ -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)
|
|
||||||
"""
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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))
|
|
||||||
|
|
||||||
|
|
||||||
1249
bal/gui/qt/window.py
@@ -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
|
|
||||||
|
Before Width: | Height: | Size: 538 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 871 B |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 831 B |
|
Before Width: | Height: | Size: 8.3 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
83
bal/qt.py
@@ -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)
|
|
||||||
@@ -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.
|
|
||||||
@@ -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")
|
|
||||||
@@ -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())
|
|
||||||
73
build_zip.py
@@ -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)
|
|
||||||
@@ -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]))
|
|
||||||
@@ -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]))
|
|
||||||
@@ -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())
|
|
||||||
@@ -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"))
|
|
||||||
@@ -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"))
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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")
|
|
||||||
@@ -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())
|
|
||||||