BAL - Bitcoin After Life Electrum plugin (v0.2.8)

Behavior-preserving refactor of the original BAL plugin with clean separation
of business logic from the PyQt GUI.

Layout:
  bal/core/   GUI-free logic (util, plugin_base, heirs, will, willexecutors)
  bal/gui/qt/ PyQt6 presentation (theme, common, widgets, calendar, dialogs,
              lists, window, plugin)
  bal/qt.py   Qt entry-point shim (works as internal and external zip plugin)
  bal/manifest.json  standard-conforming metadata

Tooling:
  build_zip.py            deterministic, zipimport-friendly archive builder
  tests/smoke_test.py     imports + behavior regression test
  tests/external_zip_test.py  reproduces Electrum's external-zip loading

Targets Electrum 4.7.2 + PyQt6. Logic kept byte-identical where possible.
This commit is contained in:
GenSpark AI Developer
2026-06-07 09:22:34 +00:00
commit 4198a5145b
41 changed files with 8345 additions and 0 deletions

5
.gitignore vendored Normal file
View File

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

91
README.md Normal file
View File

@@ -0,0 +1,91 @@
# 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**.
## 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).

21
bal/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
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.

2
bal/README.md Normal file
View File

@@ -0,0 +1,2 @@
# BalPlugin
Bitcoin After Life Electrum Plugin

1
bal/VERSION Normal file
View File

@@ -0,0 +1 @@
0.2.8

37
bal/__init__.py Normal file
View File

@@ -0,0 +1,37 @@
"""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.2.8"

14
bal/bal_resources.py Normal file
View File

@@ -0,0 +1,14 @@
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)

21
bal/core/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
"""
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.
"""

806
bal/core/heirs.py Normal file
View File

@@ -0,0 +1,806 @@
"""
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
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):
self.db.put("heirs", 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}"

351
bal/core/plugin_base.py Normal file
View File

@@ -0,0 +1,351 @@
"""
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.2.8" # 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 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
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 datetime.fromtimestamp(self.value)
else:
if from_date is None:
from_date = datetime.now()
if isinstance(from_date, (int, float)):
from_date = datetime.fromtimestamp(from_date)
reverse = 1 if not reverse else -1
return (
from_date + (reverse * timedelta(days=self.duration_to_days()))
).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 datetime.fromtimestamp(self.value).isoformat()
else:
return f"{self.value}{self.unit}"
def __repr__(self):
if self.unit is None:
return datetime.fromtimestamp(self.value).to_date().timestamp()
else:
return f"{self.value}{self.unit}"

614
bal/core/util.py Normal file
View File

@@ -0,0 +1,614 @@
"""
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.in_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
dt = datetime.fromtimestamp(locktime)
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"

938
bal/core/will.py Normal file
View File

@@ -0,0 +1,938 @@
"""
bal.core.will
=============
The "will": the set of time-locked inheritance transactions plus all the logic
to keep it coherent over time.
Two classes live here:
* :class:`Will` - a namespace of static methods operating on a *will*
dictionary (mapping ``txid -> WillItem``): building
the parent/child tree, anticipating locktimes,
detecting replaced/invalidated/confirmed entries,
validating that the will still matches the heirs and
will-executors, and building an "invalidation"
transaction.
* :class:`WillItem` - a single will transaction together with its status
flags, heirs, will-executor and fee.
Separation of concerns
-----------------------
The original ``WillItem`` carried a ``get_color()`` method returning hard-coded
hex colours for the GUI. That was pure presentation living inside the core
logic, so it has been **moved** to ``bal.gui.qt.theme.status_color(will_item)``.
The status flags themselves (the source of truth) stay here; only the mapping
"status -> colour" now lives in the GUI layer. No behaviour changed.
"""
import copy
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX
from electrum.i18n import _
from electrum.logging import Logger, get_logger
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
PartialTxOutput,
Transaction,
TxOutpoint,
tx_from_any,
)
from electrum.util import (
bfh,
)
from .util import Util
from .willexecutors import Willexecutors
MIN_LOCKTIME = 1
MIN_BLOCK = 1
_logger = get_logger(__name__)
class Will:
@staticmethod
def get_children(will, willid):
out = []
for _id in will:
inputs = will[_id].tx.inputs()
for idi in range(0, len(inputs)):
_input = inputs[idi]
if _input.prevout.txid.hex() == willid:
out.append([_id, idi, _input.prevout.out_idx])
return out
# build a tree with parent transactions
@staticmethod
def add_willtree(will):
for willid in will:
will[willid].children = Will.get_children(will, willid)
for child in will[willid].children:
if not will[child[0]].father:
will[child[0]].father = willid
# return a list of will sorted by locktime
@staticmethod
def get_sorted_will(will):
return sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
@staticmethod
def only_valid(will):
for k, v in will.items():
if v.get_status("VALID"):
yield k
@staticmethod
def search_equal_tx(will, tx, wid):
for w in will:
if w != wid and not tx.to_json() != will[w]["tx"].to_json():
if will[w]["tx"].txid() != tx.txid():
if Util.cmp_txs(will[w]["tx"], tx):
return will[w]["tx"]
return False
@staticmethod
def get_tx_from_any(x):
try:
a = str(x)
return tx_from_any(a)
except Exception as e:
raise e
return x
@staticmethod
def add_info_from_will(will, wid, wallet):
if isinstance(will[wid].tx, str):
will[wid].tx = Will.get_tx_from_any(will[wid].tx)
if wallet:
will[wid].tx.add_info_from_wallet(wallet)
for txin in will[wid].tx.inputs():
txid = txin.prevout.txid.hex()
if txid in will:
change = will[txid].tx.outputs()[txin.prevout.out_idx]
txin._trusted_value_sats = change.value
try:
txin.script_descriptor = change.script_descriptor
except Exception:
pass
txin.is_mine = True
txin._TxInput__address = change.address
txin._TxInput__scriptpubkey = change.scriptpubkey
txin._TxInput__value_sats = change.value
txin._trusted_value_sats = change.value
@staticmethod
def normalize_will(will, wallet=None, others_inputs=None):
others_input = others_inputs if others_inputs is not None else {}
to_delete = []
to_add = {}
# add info from wallet
willitems = {}
for wid in will:
Will.add_info_from_will(will, wid, wallet)
willitems[wid] = WillItem(will[wid])
will = willitems
errors = {}
for wid in will:
txid = will[wid].tx.txid()
if txid is None:
_logger.error("##########")
_logger.error(wid)
_logger.error(will[wid])
_logger.error(will[wid].tx.to_json())
_logger.error("txid is none")
will[wid].set_status("ERROR", True)
errors[wid] = will[wid]
continue
if txid != wid:
outputs = will[wid].tx.outputs()
ow = will[wid]
ow.normalize_locktime(others_inputs)
will[wid] = WillItem(ow.to_dict())
for i in range(0, len(outputs)):
Will.change_input(
will, wid, i, outputs[i], others_inputs, to_delete, to_add
)
to_delete.append(wid)
to_add[ow.tx.txid()] = ow.to_dict()
# for eid, err in errors.items():
# new_txid = err.tx.txid()
for k, w in to_add.items():
will[k] = w
for wid in to_delete:
if wid in will:
del will[wid]
@staticmethod
def new_input(txid, idx, change):
prevout = TxOutpoint(txid=bfh(txid), out_idx=idx)
inp = PartialTxInput(prevout=prevout)
inp._trusted_value_sats = change.value
inp.is_mine = True
inp._TxInput__address = change.address
inp._TxInput__scriptpubkey = change.scriptpubkey
inp._TxInput__value_sats = change.value
return inp
@staticmethod
def check_anticipate(ow: "WillItem", nw: "WillItem"):
anticipate = Util.anticipate_locktime(ow.tx.locktime, days=1)
if int(nw.tx.locktime) >= int(anticipate):
if Util.cmp_heirs_by_values(
ow.heirs, nw.heirs, [0, 1], exclude_willexecutors=True
):
if nw.we and ow.we:
if ow.we["url"] == nw.we["url"]:
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
return anticipate
else:
if int(ow.tx_fees) != int(nw.tx_fees):
return anticipate
else:
ow.tx.locktime
else:
ow.tx.locktime
else:
if nw.we == ow.we:
if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]):
return anticipate
else:
return ow.tx.locktime
else:
return ow.tx.locktime
else:
return anticipate
return 4294967295 + 1
@staticmethod
def change_input(will, otxid, idx, change, others_inputs, to_delete, to_append):
ow = will[otxid]
ntxid = ow.tx.txid()
if otxid != ntxid:
for wid in will:
w = will[wid]
inputs = w.tx.inputs()
outputs = w.tx.outputs()
found = False
old_txid = w.tx.txid()
# ntx = None
for i in range(0, len(inputs)):
if (
inputs[i].prevout.txid.hex() == otxid
and inputs[i].prevout.out_idx == idx
):
if isinstance(w.tx, Transaction):
will[wid].tx = PartialTransaction.from_tx(w.tx)
will[wid].tx.set_rbf(True)
will[wid].tx._inputs[i] = Will.new_input(wid, idx, change)
found = True
if found:
pass
new_txid = will[wid].tx.txid()
if old_txid != new_txid:
to_delete.append(old_txid)
to_append[new_txid] = will[wid]
outputs = will[wid].tx.outputs()
for i in range(0, len(outputs)):
Will.change_input(
will,
wid,
i,
outputs[i],
others_inputs,
to_delete,
to_append,
)
@staticmethod
def get_all_inputs(will, only_valid=False):
all_inputs = {}
for w, wi in will.items():
if not only_valid or wi.get_status("VALID"):
inputs = wi.tx.inputs()
for i in inputs:
prevout_str = i.prevout.to_str()
inp = [w, will[w], i]
if prevout_str not in all_inputs:
all_inputs[prevout_str] = [inp]
else:
all_inputs[prevout_str].append(inp)
return all_inputs
@staticmethod
def get_all_inputs_min_locktime(all_inputs):
all_inputs_min_locktime = {}
for i, values in all_inputs.items():
min_locktime = min(values, key=lambda x: x[1].tx.locktime)[1].tx.locktime
for w in values:
if w[1].tx.locktime == min_locktime:
if i not in all_inputs_min_locktime:
all_inputs_min_locktime[i] = [w]
else:
all_inputs_min_locktime[i].append(w)
return all_inputs_min_locktime
@staticmethod
def search_anticipate_rec(will, old_inputs):
redo = False
to_delete = []
to_append = {}
new_inputs = Will.get_all_inputs(will, only_valid=True)
for nid, nwi in will.items():
if nwi.search_anticipate(new_inputs):
if nid != nwi.tx.txid():
redo = True
to_delete.append(nid)
to_append[nwi.tx.txid()] = nwi
outputs = nwi.tx.outputs()
for i in range(0, len(outputs)):
Will.change_input(
will, nid, i, outputs[i], new_inputs, to_delete, to_append
)
if nwi.search_anticipate(old_inputs):
if nid != nwi.tx.txid():
redo = True
to_delete.append(nid)
to_append[nwi.tx.txid()] = nwi
outputs = nwi.tx.outputs()
for i in range(0, len(outputs)):
Will.change_input(
will, nid, i, outputs[i], new_inputs, to_delete, to_append
)
for w in to_delete:
try:
del will[w]
except Exception:
pass
for k, w in to_append.items():
will[k] = w
if redo:
Will.search_anticipate_rec(will, old_inputs)
@staticmethod
def update_will(old_will, new_will):
all_old_inputs = Will.get_all_inputs(old_will, only_valid=True)
# all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_old_inputs)
# all_new_inputs = Will.get_all_inputs(new_will)
# check if the new input is already spent by other transaction
# if it is use the same locktime, or anticipate.
Will.search_anticipate_rec(new_will, all_old_inputs)
other_inputs = Will.get_all_inputs(old_will, {})
try:
Will.normalize_will(new_will, others_inputs=other_inputs)
except Exception as e:
raise e
for oid in Will.only_valid(old_will):
if oid in new_will:
new_heirs = new_will[oid].heirs
new_we = new_will[oid].we
new_will[oid] = old_will[oid]
new_will[oid].heirs = new_heirs
new_will[oid].we = new_we
continue
else:
continue
@staticmethod
def get_higher_input_for_tx(will):
out = {}
for wid in will:
wtx = will[wid].tx
found = False
for inp in wtx.inputs():
if inp.prevout.txid.hex() in will:
found = True
break
if not found:
out[inp.prevout.to_str()] = inp
return out
@staticmethod
def invalidate_will(will, wallet, fees_per_byte):
will_only_valid = Will.only_valid_list(will)
inputs = Will.get_all_inputs(will_only_valid)
utxos = wallet.get_utxos()
filtered_inputs = []
prevout_to_spend = []
current_height = Util.get_current_height(wallet.network)
for prevout_str, ws in inputs.items():
for w in ws:
if w[0] not in filtered_inputs:
filtered_inputs.append(w[0])
if prevout_str not in prevout_to_spend:
prevout_to_spend.append(prevout_str)
balance = 0
utxo_to_spend = []
for utxo in utxos:
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
continue
utxo_str = utxo.prevout.to_str()
if utxo_str in prevout_to_spend:
balance += inputs[utxo_str][0][2].value_sats()
utxo_to_spend.append(utxo)
if len(utxo_to_spend) > 0:
change_addresses = wallet.get_change_addresses_for_new_transaction()
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
out.is_change = True
locktime = current_height
tx = PartialTransaction.from_io(
utxo_to_spend, [out], locktime=locktime, version=2
)
tx.set_rbf(True)
fee = tx.estimated_size() * fees_per_byte
if balance - fee > 0:
out = PartialTxOutput.from_address_and_value(
change_addresses[0], balance - fee
)
tx = PartialTransaction.from_io(
utxo_to_spend, [out], locktime=locktime, version=2
)
tx.set_rbf(True)
_logger.debug(f"invalidation tx: {tx}")
return tx
else:
_logger.debug(f"balance({balance}) - fee({fee}) <=0")
pass
else:
_logger.debug("len utxo_to_spend <=0")
pass
@staticmethod
def is_new(will):
for wid, w in will.items():
if w.get_status("VALID") and not w.get_status("COMPLETE"):
return True
@staticmethod
def search_rai(all_inputs, all_utxos, will, wallet):
# will_only_valid = Will.only_valid_or_replaced_list(will)
for inp, ws in all_inputs.items():
inutxo = Util.in_utxo(inp, all_utxos)
for w in ws:
wi = w[1]
if (
wi.get_status("VALID")
or wi.get_status("CONFIRMED")
or wi.get_status("PENDING")
):
prevout_id = w[2].prevout.txid.hex()
if not inutxo:
if prevout_id in will:
wo = will[prevout_id]
if wo.get_status("REPLACED"):
wi.set_status("REPLACED", True)
if wo.get_status("INVALIDATED"):
wi.set_status("INVALIDATED", True)
else:
if wallet.db.get_transaction(wi._id):
wi.set_status("CONFIRMED", True)
else:
wi.set_status("INVALIDATED", True)
for child in wi.search(all_inputs):
if child.tx.locktime < wi.tx.locktime:
_logger.debug("a child was found")
wi.set_status("REPLACED", True)
else:
pass
@staticmethod
def utxos_strs(utxos):
return [Util.utxo_to_str(u) for u in utxos]
@staticmethod
def set_invalidate(wid, will=None):
will = will if will is not None else {}
will[wid].set_status("INVALIDATED", True)
if will[wid].children:
for c in will[wid].children.items():
Will.set_invalidate(c[0], will)
@staticmethod
def check_tx_height(tx, wallet):
info = wallet.get_tx_info(tx)
return info.tx_mined_status.height()
# check if transactions are stil valid tecnically valid
@staticmethod
def check_invalidated(willtree, utxos_list, wallet):
for wid, w in willtree.items():
if (
not w.father
or willtree[w.father].get_status("CONFIRMED")
or willtree[w.father].get_status("PENDING")
):
for inp in w.tx.inputs():
inp_str = Util.utxo_to_str(inp)
if inp_str not in utxos_list:
if wallet:
height = Will.check_tx_height(w.tx, wallet)
if height < 0:
Will.set_invalidate(wid, willtree)
elif height == 0:
w.set_status("PENDING", True)
else:
w.set_status("CONFIRMED", True)
# def reflect_to_children(treeitem):
# if not treeitem.get_status("VALID"):
# _logger.debug(f"{tree:item._id} status not valid looking for children")
# for child in treeitem.children:
# wc = willtree[child]
# if wc.get_status("VALID"):
# if treeitem.get_status("INVALIDATED"):
# wc.set_status("INVALIDATED", True)
# if treeitem.get_status("REPLACED"):
# wc.set_status("REPLACED", True)
# if wc.children:
# Will.reflect_to_children(wc)
@staticmethod
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust):
fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = (
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
)
wallet_balance = 0
for utxo in all_utxos:
wallet_balance += utxo.value_sats()
if fixed_amount >= wallet_balance:
raise FixedAmountException(
f"Fixed amount({fixed_amount}) >= {wallet_balance}"
)
if perc_amount != 100:
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
for url, wex in willexecutors.items():
if Willexecutors.is_selected(wex):
temp_balance = wallet_balance - int(wex["base_fee"])
if fixed_amount >= temp_balance:
raise FixedAmountException(
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
)
@staticmethod
def check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check):
Will.add_willtree(will)
utxos_list = Will.utxos_strs(all_utxos)
Will.check_invalidated(will, utxos_list, wallet)
all_inputs = Will.get_all_inputs(will, only_valid=True)
all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_inputs)
Will.check_will_expired(
all_inputs_min_locktime, block_to_check, timestamp_to_check
)
all_inputs = Will.get_all_inputs(will, only_valid=True)
Will.search_rai(all_inputs, all_utxos, will, wallet)
@staticmethod
def get_min_locktime(will,default_value=None):
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
@staticmethod
def is_will_valid(
will,
block_to_check,
timestamp_to_check,
tx_fees,
all_utxos,
heirs=None,
willexecutors=None,
self_willexecutor=False,
wallet=False,
callback_not_valid_tx=None,
):
heirs = heirs if heirs is not None else {}
willexecutors= willexecutors if willexecutors is not None else {}
Will.check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check)
if heirs:
if not Will.check_willexecutors_and_heirs(
will,
heirs,
willexecutors,
self_willexecutor,
timestamp_to_check,
tx_fees,
):
raise NotCompleteWillException()
all_inputs = Will.get_all_inputs(will, only_valid=True)
_logger.info("check all utxo in wallet are spent")
if all_inputs:
for utxo in all_utxos:
if utxo.value_sats() > 68 * tx_fees:
if not Util.in_utxo(utxo, all_inputs.keys()):
_logger.info("utxo is not spent", utxo.to_json())
_logger.debug(all_inputs.keys())
raise NotCompleteWillException(
"Some utxo in the wallet is not included"
)
_logger.info("will ok")
return True
@staticmethod
def check_will_expired(all_inputs_min_locktime, block_to_check, timestamp_to_check):
_logger.info("check if some transaction is expired")
for prevout_str, wid in all_inputs_min_locktime.items():
for w in wid:
if w[1].get_status("VALID"):
locktime = int(wid[0][1].tx.locktime)
if locktime <= NLOCKTIME_BLOCKHEIGHT_MAX:
if locktime < int(block_to_check):
raise WillExpiredException(
f"Will Expired {wid[0][0]}: {locktime}<{block_to_check}"
)
else:
if locktime < int(timestamp_to_check):
raise WillExpiredException(
f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}"
)
else:
from datetime import datetime
_logger.debug(f"Will Not Expired {wid[0][0]}: {datetime.fromtimestamp(locktime).isoformat()} > {datetime.fromtimestamp(timestamp_to_check).isoformat()}")
# def check_all_input_spent_are_in_wallet():
# _logger.info("check all input spent are in wallet or valid txs")
# for inp, ws in all_inputs.items():
# if not Util.in_utxo(inp, all_utxos):
# for w in ws:
# if w[1].get_status("VALID"):
# prevout_id = w[2].prevout.txid.hex()
# parentwill = will.get(prevout_id, False)
# if not parentwill or not parentwill.get_status("VALID"):
# w[1].set_status("INVALIDATED", True)
@staticmethod
def only_valid_list(will):
out = {}
for wid, w in will.items():
if w.get_status("VALID"):
out[wid] = w
return out
@staticmethod
def only_valid_or_replaced_list(will):
out = []
for wid, w in will.items():
wi = w
if wi.get_status("VALID") or wi.get_status("REPLACED"):
out.append(wid)
return out
@staticmethod
def check_willexecutors_and_heirs(
will, heirs, willexecutors, self_willexecutor, check_date, tx_fees
):
_logger.debug("check willexecutors heirs")
no_willexecutor = 0
willexecutors_found = {}
heirs_found = {}
will_only_valid = Will.only_valid_list(will)
if len(will_only_valid) < 1:
return False
for wid in Will.only_valid_list(will):
w = will[wid]
if w.tx_fees != tx_fees:
raise TxFeesChangedException(f"{tx_fees}: {w.tx_fees}")
for wheir in w.heirs:
if not 'w!ll3x3c"' == wheir[:9]:
their = will[wid].heirs[wheir]
if heir := heirs.get(wheir, None):
if (
heir[0] == their[0]
and heir[1] == their[1]
and Util.parse_locktime_string(heir[2])
>= Util.parse_locktime_string(their[2])
):
count = heirs_found.get(wheir, 0)
heirs_found[wheir] = count + 1
else:
_logger.debug(
f"heir not present transaction is not valid:{wheir} {wid}, {w}"
)
if willexecutor := w.we:
count = willexecutors_found.get(willexecutor["url"], 0)
if Util.cmp_willexecutor(
willexecutor, willexecutors.get(willexecutor["url"], None)
):
willexecutors_found[willexecutor["url"]] = count + 1
else:
no_willexecutor += 1
count_heirs = 0
for h in heirs:
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
count_heirs += 1
if h not in heirs_found:
_logger.debug(f"heir: {h} not found")
raise HeirNotFoundException(h)
if not count_heirs:
raise NoHeirsException("there are not valid heirs")
if self_willexecutor and no_willexecutor == 0:
raise NoWillExecutorNotPresent("Backup tx")
for url, we in willexecutors.items():
if Willexecutors.is_selected(we):
if url not in willexecutors_found:
_logger.debug(f"will-executor: {url} not fount")
raise WillExecutorNotPresent(url)
_logger.info("will is coherent with heirs and will-executors")
return True
class WillItem(Logger):
STATUS_DEFAULT = {
"ANTICIPATED": ["Anticipated", False],
"BROADCASTED": ["Broadcasted", False],
"CHECKED": ["Checked", False],
"CHECK_FAIL": ["Check Failed", False],
"COMPLETE": ["Signed", False],
"CONFIRMED": ["Confirmed", False],
"ERROR": ["Error", False],
"EXPIRED": ["Expired", False],
"EXPORTED": ["Exported", False],
"IMPORTED": ["Imported", False],
"INVALIDATED": ["Invalidated", False],
"PENDING": ["Pending", False],
"PUSH_FAIL": ["Push failed", False],
"PUSHED": ["Pushed", False],
"REPLACED": ["Replaced", False],
"RESTORED": ["Restored", False],
"VALID": ["Valid", True],
}
def set_status(self, status, value=True):
# _logger.trace(
# "set status {} - {} {} -> {}".format(
# self._id, status, self.STATUS[status][1], value
# )
# )
if self.STATUS[status][1] == bool(value):
return None
self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0]))
self.STATUS[status][1] = bool(value)
if value:
if status in ["INVALIDATED", "REPLACED", "CONFIRMED", "PENDING"]:
self.STATUS["VALID"][1] = False
if status in ["CONFIRMED", "PENDING"]:
self.STATUS["INVALIDATED"][1] = False
if status in ["PUSHED"]:
self.STATUS["PUSH_FAIL"][1] = False
self.STATUS["CHECK_FAIL"][1] = False
if status in ["CHECKED"]:
self.STATUS["PUSHED"][1] = True
self.STATUS["PUSH_FAIL"][1] = False
return value
def get_status(self, status):
return self.STATUS[status][1]
def __init__(self, w, _id=None, wallet=None):
if isinstance(
w,
WillItem,
):
self.__dict__ = w.__dict__.copy()
else:
self.tx = Will.get_tx_from_any(w["tx"])
self.heirs = w.get("heirs", None)
self.we = w.get("willexecutor", None)
self.status = w.get("status", None)
self.description = w.get("description", None)
self.time = w.get("time", None)
self.change = w.get("change", None)
self.tx_fees = w.get("baltx_fees", 0)
self.father = w.get("Father", None)
self.children = w.get("Children", None)
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
for s in self.STATUS:
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
if not _id:
self._id = self.tx.txid()
else:
self._id = _id
if not self._id:
self.status += "ERROR!!!"
self.valid = False
if wallet:
self.tx.add_info_from_wallet(wallet)
def to_dict(self):
out = {
"_id": self._id,
"tx": self.tx,
"heirs": self.heirs,
"willexecutor": self.we,
"status": self.status,
"description": self.description,
"time": self.time,
"change": self.change,
"baltx_fees": self.tx_fees,
}
for key in self.STATUS:
try:
out[key] = self.STATUS[key][1]
except Exception as e:
_logger.error(f"{key},{self.STATUS[key]} {e}")
return out
def __repr__(self):
return str(self)
def __str__(self):
return str(self.to_dict())
def set_anticipate(self, ow: "WillItem"):
nl = min(ow.tx.locktime, Will.check_anticipate(ow, self))
if int(nl) < self.tx.locktime:
self.tx.locktime = int(nl)
return True
else:
return False
def search_anticipate(self, all_inputs):
anticipated = False
for ow in self.search(all_inputs):
if self.set_anticipate(ow):
anticipated = True
return anticipated
def search(self, all_inputs):
for inp in self.tx.inputs():
prevout_str = inp.prevout.to_str()
oinps = all_inputs.get(prevout_str, [])
for oinp in oinps:
ow = oinp[1]
if ow._id != self._id:
yield ow
def normalize_locktime(self, all_inputs):
outputs = self.tx.outputs()
for idx in range(0, len(outputs)):
inps = all_inputs.get(f"{self._id}:{idx}", [])
_logger.debug("****check locktime***")
for inp in inps:
if inp[0] != self._id:
iw = inp[1]
self.set_anticipate(iw)
def set_check_willexecutor(self,resp):
try:
if resp :
if "tx" in resp and resp["tx"] == str(self.tx):
self.set_status("PUSHED")
self.set_status("CHECKED")
else:
self.set_status("CHECK_FAIL")
self.set_status("PUSHED", False)
return True
else:
self.set_status("CHECK_FAIL")
self.set_status("PUSHED", False)
return False
except Exception as e:
_logger.error(f"exception checking transaction: {e}")
self.set_status("CHECK_FAIL")
# NOTE: the former ``get_color()`` method (which returned hard-coded hex
# colours for the GUI) has been moved out of the core logic to
# ``bal.gui.qt.theme.status_color``. The status flags above remain the
# single source of truth; the GUI maps them to colours.
class WillException(Exception):
def __init__(self,msg="WillException"):
self.msg=msg
Exception.__init__(self)
def __str__(self):
return self.msg
class WillExpiredException(WillException):
pass
class NotCompleteWillException(WillException):
pass
class HeirChangeException(NotCompleteWillException):
pass
class TxFeesChangedException(NotCompleteWillException):
pass
class HeirNotFoundException(NotCompleteWillException):
pass
class WillexecutorChangeException(NotCompleteWillException):
pass
class NoWillExecutorNotPresent(NotCompleteWillException):
pass
class WillExecutorNotPresent(NotCompleteWillException):
pass
class NoHeirsException(WillException):
pass
class AmountException(WillException):
pass
class PercAmountException(AmountException):
pass
class FixedAmountException(AmountException):
pass

390
bal/core/willexecutors.py Normal file
View File

@@ -0,0 +1,390 @@
"""
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
DEFAULT_TIMEOUT = 5
_logger = get_logger(__name__)
chainname = BalPlugin.chainname
class Willexecutors:
@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
):
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 < 10:
_logger.debug(f"timeout({count_reply}) error: retry in 3 sec...")
time.sleep(3)
return Willexecutors.send_request(
method,
url,
data,
timeout=timeout,
handle_response=handle_response,
count_reply=count_reply + 1,
)
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):
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"),
):
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):
w = None
try:
_logger.info("GETINFO_WILLEXECUTOR")
_logger.debug(url)
w = Willexecutors.send_request("get", url + "/" + chainname + "/info")
if isinstance(w, dict):
willexecutor["url"] = url
willexecutor["status"] = 200
willexecutor["base_fee"] = w["base_fee"]
willexecutor["address"] = w["address"]
willexecutor["info"] = w["info"]
_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 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):
_logger.debug(f"{url}:{txid}")
try:
w = Willexecutors.send_request(
"post", url + "/searchtx", data=txid.encode("ascii")
)
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}"

0
bal/gui/__init__.py Normal file
View File

17
bal/gui/qt/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""
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)
"""

80
bal/gui/qt/calendar.py Normal file
View File

@@ -0,0 +1,80 @@
"""
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)

155
bal/gui/qt/common.py Normal file
View File

@@ -0,0 +1,155 @@
"""
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, 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)
from ...core.willexecutors import Willexecutors
# --- Presentation helpers ---
from .theme import status_color
_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):
_logger.error(f"LOG_ERROR: {exec_info}")
#tb = traceback.format_exc()
try:
tb=exec_info[1]
_logger.error(tb)
except Exception:
tb = traceback.format_exc()
_logger.error(tb)
if window is not None:
window.show_error(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)))
)

1127
bal/gui/qt/dialogs.py Normal file

File diff suppressed because it is too large Load Diff

957
bal/gui/qt/lists.py Normal file
View File

@@ -0,0 +1,957 @@
"""
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()
headers = {
Columns.LOCKTIME: _("Locktime"),
Columns.TXID: _("Txid"),
Columns.WILLEXECUTOR: _("Will-Executor"),
Columns.STATUS: _("Status"),
}
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.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
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)))
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")
)
)
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")
)
)
refresh.clicked.connect(self.check)
widget = QWidget(self)
hlayout = QHBoxLayout(widget)
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
hlayout.addWidget(self.will_settings_widget)
hlayout.addWidget(wizard)
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():
if (
w.get_status("VALID")
and w.get_status("PUSHED")
and not w.get_status("CHECKED")
):
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.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.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.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.parent.willexecutors_list[k]
self.parent.update_willexecutors(wout)
self.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.parent.willexecutors_list[key]
self.parent.save_willexecutors()
self.update()
def select(self, selected_keys):
for wid, w in self.parent.willexecutors_list.items():
if wid in selected_keys:
w["selected"] = True
self.parent.save_willexecutors()
self.update()
def deselect(self, selected_keys):
for wid, w in self.parent.willexecutors_list.items():
if wid in selected_keys:
w["selected"] = False
self.parent.save_willexecutors()
self.update()
def on_edited(self, idx, edit_key, *, text):
# prior_name = self.parent.willexecutors_list[edit_key]
col = idx.column()
try:
if col == self.Columns.URL:
self.parent.willexecutors_list[text] = self.parent.willexecutors_list[
edit_key
]
del self.parent.willexecutors_list[edit_key]
if col == self.Columns.BASE_FEE:
self.parent.willexecutors_list[edit_key]["base_fee"] = (
Util.encode_amount(text, self.get_decimal_point())
)
if col == self.Columns.ADDRESS:
self.parent.willexecutors_list[edit_key]["address"] = text
if col == self.Columns.INFO:
self.parent.willexecutors_list[edit_key]["info"] = text
self.parent.save_willexecutors()
self.update()
except Exception:
pass
def update(self):
if self.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.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.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.parent.bal_plugin.read_file(
"icons/status_connected.png"
)
),
"",
]
else:
labels[self.Columns.STATUS] = [
read_QIcon_from_bytes(
self.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.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):
if not wes:
wes = self.willexecutors_list
self.bal_window.download_list(wes, self.save_willexecutors)
self.update()
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)

273
bal/gui/qt/plugin.py Normal file
View File

@@ -0,0 +1,273 @@
"""
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 .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import BalCheckBox, BalLineEdit, BalTextEdit
from .window import BalWindow
from .dialogs import BalDialog
class Plugin(BalPlugin):
def __init__(self, parent, config, name):
_logger.info("INIT BALPLUGIN")
BalPlugin.__init__(self, parent, config, name)
self.bal_windows = {}
@hook
def init_qt(self, gui_object):
_logger.info("HOOK bal init qt")
try:
self.gui_object = gui_object
for window in gui_object.windows:
wallet = window.wallet
if wallet:
window.show_warning(
_("Please restart Electrum to activate the BAL plugin"),
title=_("Success"),
)
return
top_level_window=window.top_level_window()
w = BalWindow(self, top_level_window)
self.bal_windows[top_level_window.winId] = w
for child in window.children():
if isinstance(child, QMenuBar):
for menu_child in child.children():
if isinstance(menu_child, QMenu):
try:
if menu_child.title() == _("&Tools"):
w.init_menubar_tools(menu_child)
except Exception as e:
_logger.error(
("init_qt except:", menu_child.text())
)
raise e
except Exception as e:
_logger.error("Error loading plugini {}".format(e))
raise e
@hook
def create_status_bar(self, sb):
_logger.info("HOOK create status bar")
b = StatusBarButton(
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
"Bal " + _("Bitcoin After Life"),
partial(self.settings_dialog, sb),
sb.height(),
)
sb.addPermanentWidget(b)
@hook
def init_menubar(self, window):
_logger.info("HOOK init_menubar")
w = self.get_window(window)
w.init_menubar_tools(window.tools_menu)
@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
@hook
def close_wallet(self, wallet):
_logger.debug("HOOK close wallet")
for _winid, win in self.bal_windows.items():
if win.wallet == wallet:
win.on_close()
@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()
w = self.bal_windows.get(window.winId, None)
if w is None:
w = BalWindow(self, window)
self.bal_windows[window.winId] = 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(d.exec()):
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)

59
bal/gui/qt/theme.py Normal file
View File

@@ -0,0 +1,59 @@
"""
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

782
bal/gui/qt/widgets.py Normal file
View File

@@ -0,0 +1,782 @@
"""
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 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
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)
#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)
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
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 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:
_dt = datetime.fromtimestamp(x)
#if self.alarm != dt:
self.setDateTime(_dt)
self.alarm = _dt
class ThresholdTimeWidget(BalTimeEditWidget):
help_text = (
"Check to ask for invalidation.\n\n"
"When less then this time is missing, ask to invalidate.\n"
"If you fail to invalidate during this time, your transactions will be delivered to your heirs.\n\n"
f"{BalTimeEditWidget.help_text}"
)
label_text = "🚨"
#label_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):
help_text = (
"Set Locktime for transactions.\n"
"Any time is needed transaction will be anticipated by 1day\n"
f"{BalTimeEditWidget.help_text}"
)
label_text = "🚛"
#label_text = "Locktime"
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"):
self.widgets = {}
QWidget.__init__(self, parent)
self.bal_window = bal_window
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")
)
)
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"])
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.parent = parent
for w in self.will:
if (
self.will[w].get_status("REPLACED")
and self.parent.bal_window.bal_plugin._hide_replaced
):
continue
if (
self.will[w].get_status("INVALIDATED")
and self.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.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.parent.decimal_point
)
detaillayout.addWidget(
qlabel(
heir, f"{decoded_amount} {self.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.parent.decimal_point
)
detaillayout.addWidget(
qlabel(
self.will[w].we["url"],
f"{decoded_amount} {self.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))

952
bal/gui/qt/window.py Normal file
View File

@@ -0,0 +1,952 @@
"""
bal.gui.qt.window
=================
The :class:`BalWindow` controller: one instance per Electrum wallet window.
This is the orchestration layer that ties together the heirs list, the will
preview, the will-executors and the various dialogs. It owns the per-wallet
state (``heirs``, ``will``, ``willitems``, ``will_settings``) and exposes the
high-level actions (build / check / sign / broadcast / invalidate the will,
import/export, etc.) that the menus, tabs and dialogs invoke.
The actual Bitcoin logic lives in :mod:`bal.core`; this class only coordinates
it with the GUI.
"""
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import LockTimeWidget, PercAmountEdit, WillSettingsWidget
from .dialogs import (BalBlockingWaitingDialog, BalBuildWillDialog, BalDialog,
BalWaitingDialog, BalWizardDialog, WillDetailDialog,
WillExecutorDialog)
from .lists import HeirListWidget, PreviewList, WillExecutorWidget
class BalWindow:
def __init__(self, bal_plugin: "BalPlugin", window: "ElectrumWindow"):
self.bal_plugin = bal_plugin
self.window = window
self.heirs = {}
self.will = {}
self.willitems = {}
self.willexecutors = {}
self.will_settings = None
self.ok = False
self.disable_plugin = True
self.bal_plugin.get_decimal_point = self.window.get_decimal_point
if self.window.wallet:
self.wallet = self.window.wallet
if not self.will_settings:
self.will_settings = self.bal_plugin.WILL_SETTINGS.get()
Util.fix_will_settings_tx_fees(self.will_settings)
self.heirs = Heirs(self.wallet)
self.heirs_tab = self.create_heirs_tab()
self.will_tab = self.create_will_tab()
self.heirs_tab.wallet = self.wallet
self.will_tab.wallet = self.wallet
def init_menubar_tools(self, tools_menu):
self.tools_menu = tools_menu
def add_optional_tab(tabs, tab, icon, description):
tab.tab_icon = icon
tab.tab_description = description
tab.tab_pos = len(tabs)
if tab.is_shown_cv.get():
tabs.addTab(tab, icon, description.replace("&", ""))
def add_toggle_action(tab):
is_shown = tab.is_shown_cv.get()
tab.menu_action = self.window.view_menu.addAction(
tab.tab_description, lambda: self.window.toggle_tab(tab)
)
tab.menu_action.setCheckable(True)
tab.menu_action.setChecked(is_shown)
add_optional_tab(
self.window.tabs,
self.heirs_tab,
read_QIcon_from_bytes(self.bal_plugin.read_file("icons/heir.png")),
_("&Heirs"),
)
add_optional_tab(
self.window.tabs,
self.will_tab,
read_QIcon_from_bytes(self.bal_plugin.read_file("icons/will.png")),
_("&Will"),
)
tools_menu.addSeparator()
self.tools_menu.willexecutors_action = tools_menu.addAction(
_("&Will-Executors"), self.show_willexecutor_dialog
)
self.window.view_menu.addSeparator()
add_toggle_action(self.heirs_tab)
add_toggle_action(self.will_tab)
def load_willitems(self):
self.willitems = {}
for wid, w in self.will.items():
self.willitems[wid] = WillItem(w, wallet=self.wallet)
if self.willitems:
self.will_list_widget.will = self.willitems
self.will_list_widget.update_will(self.willitems)
self.will_tab.update()
def save_willitems(self):
keys = list(self.will.keys())
for k in keys:
del self.will[k]
for wid, w in self.willitems.items():
self.will[wid] = w.to_dict()
def init_will(self):
_logger.info("********************init_____will____________**********")
if not self.willexecutors:
self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=False, bal_window=self
)
if not self.heirs:
self.heirs = Heirs._validate(Heirs(self.wallet))
self.heirs_tab.update()
if not self.will:
self.will = self.wallet.db.get_dict("will")
Util.fix_will_tx_fees(self.will)
if self.will:
self.willitems = {}
try:
self.load_willitems()
except Exception:
self.disable_plugin = True
self.show_warning(
_("Please restart Electrum to activate the BAL plugin"),
title=_("Success"),
)
self.close_wallet()
return
# if not self.will_settings:
# self.will_settings = self.wallet.db.get_dict("will_settings")
# Util.fix_will_settings_tx_fees(self.will_settings)
# _logger.info("will_settings: {}".format(self.will_settings))
# if not self.will_settings:
# Util.copy(self.will_settings, self.bal_plugin.default_will_settings())
# _logger.debug("not_will_settings {}".format(self.will_settings))
# self.bal_plugin.validate_will_settings(self.will_settings)
# self.heir_list_widget.update_will_settings()
# self.heir_list_widget.update()
def init_wizard(self):
wizard_dialog = BalWizardDialog(self)
wizard_dialog.exec()
def show_willexecutor_dialog(self):
self.willexecutor_dialog = WillExecutorDialog(self)
self.willexecutor_dialog.show()
def create_heirs_tab(self):
if not self.heirs:
self.heirs = Heirs(self.wallet)
self.heir_list_widget = HeirListWidget(self, self.window)
tab = self.window.create_list_tab(self.heir_list_widget)
tab.is_shown_cv = shown_cv(False)
return tab
def create_will_tab(self):
self.will_list_widget = PreviewList(self, self.window, None)
tab = self.window.create_list_tab(self.will_list_widget)
tab.is_shown_cv = shown_cv(True)
return tab
def new_heir_dialog(self, heir_key=None):
heir = self.heirs.get(heir_key)
title = "New heir"
if heir:
title = f"Edit: {heir_key}"
d = BalDialog(
self.window, self.bal_plugin, self.bal_plugin.get_window_title(_(title))
)
vbox = QVBoxLayout(d)
grid = QGridLayout()
heir_name = QLineEdit()
heir_name.setFixedWidth(32 * char_width_in_lineedit())
heir_address = QLineEdit()
heir_address.setFixedWidth(32 * char_width_in_lineedit())
heir_amount = PercAmountEdit(self.window.get_decimal_point)
if heir:
heir_name.setText(str(heir_key))
heir_address.setText(str(heir[0]))
heir_amount.setText(
str(Util.decode_amount(heir[1], self.window.get_decimal_point()))
)
self.heir_locktime = LockTimeWidget(self, self.window, heir[2])
# heir_is_xpub = QCheckBox()
new_heir_button = QPushButton(_("Add another heir"))
self.add_another_heir = False
def new_heir():
self.add_another_heir = True
d.accept()
new_heir_button.clicked.connect(new_heir)
new_heir_button.setDefault(True)
grid.addWidget(QLabel(_("Name")), 1, 0)
grid.addWidget(heir_name, 1, 1)
grid.addWidget(HelpButton(_("Unique name or description about heir")), 1, 2)
grid.addWidget(QLabel(_("Address")), 2, 0)
grid.addWidget(heir_address, 2, 1)
grid.addWidget(HelpButton(_("heir bitcoin address")), 2, 2)
grid.addWidget(QLabel(_("Amount")), 3, 0)
grid.addWidget(heir_amount, 3, 1)
grid.addWidget(HelpButton(_("Fixed or Percentage amount if end with %")), 3, 2)
locktime_label = QLabel(_("Locktime"))
enable_multiverse = self.bal_plugin.ENABLE_MULTIVERSE.get()
if enable_multiverse:
grid.addWidget(locktime_label, 4, 0)
grid.addWidget(self.heir_locktime, 4, 1)
grid.addWidget(HelpButton(_("locktime")), 4, 2)
vbox.addLayout(grid)
buttons = [CancelButton(d), OkButton(d)]
if not heir:
buttons.append(new_heir_button)
vbox.addLayout(Buttons(*buttons))
while d.exec():
# TODO SAVE HEIR
heir = [
heir_name.text(),
heir_address.text(),
Util.encode_amount(heir_amount.text(), self.window.get_decimal_point()),
str(self.will_settings["locktime"]),
]
try:
self.set_heir(heir)
if self.add_another_heir:
self.new_heir_dialog()
break
except Exception as e:
self.show_error(str(e))
def set_heir(self, heir):
heir = list(heir)
if not self.bal_plugin.ENABLE_MULTIVERSE.get():
heir[3] = self.will_settings["locktime"]
h = Heirs.validate_heir(heir[0], heir[1:])
self.heirs[heir[0]] = h
self.heir_list_widget.update()
return True
def delete_heirs(self, heirs):
for heir in heirs:
try:
del self.heirs[heir]
except Exception as e:
_logger.debug(f"error deleting heir: {heir} {e}")
pass
self.heirs.save()
self.heir_list_widget.update()
return True
def import_heirs(self):
import_meta_gui(
self.window,
_("heirs"),
self.heirs.import_file,
self.heir_list_widget.update,
)
def export_heirs(self):
export_meta_gui(self.window, "heirs.json", self.heirs.export_file)
def prepare_will(self, ignore_duplicate=False, keep_original=False):
will = self.build_inheritance_transaction(
ignore_duplicate=ignore_duplicate, keep_original=keep_original
)
return will
def delete_not_valid(self, txid, s_utxo):
raise NotImplementedError()
def update_will(self, will):
Will.update_will(self.willitems, will)
self.willitems.update(will)
Will.normalize_will(self.willitems, self.wallet)
def build_will(self, ignore_duplicate=True, keep_original=True):
_logger.debug("building will...")
will = {}
# willtodelete = []
# willtoappend = {}
try:
self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=False, bal_window=self
)
if not self.no_willexecutor:
f = False
for _u, w in self.willexecutors.items():
if Willexecutors.is_selected(w):
f = True
if not f:
_logger.error("No Will-Executor or backup transaction selected")
raise NoWillExecutorNotPresent(
"No Will-Executor or backup transaction selected"
)
txs = self.heirs.get_transactions(
self.bal_plugin,
self.window.wallet,
self.will_settings["baltx_fees"],
None,
self.date_to_check,
)
_logger.info(f"txs built: {txs}")
creation_time = time.time()
if txs:
for txid in txs:
# txtodelete = []
_break = False
tx = {}
tx["tx"] = txs[txid]
tx["my_locktime"] = txs[txid].my_locktime
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
tx["status"] = _("New")
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
tx["txchildren"] = []
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
self.update_will(will)
else:
_logger.info("No transactions was built")
_logger.info(f"will-settings: {self.will_settings}")
_logger.info(f"date_to_check:{self.date_to_check}")
_logger.info(f"heirs: {self.heirs}")
return {}
except Exception as e:
_logger.info(f"Exception build_will: {e}")
raise e
pass
return self.willitems
def check_will(self):
return Will.is_will_valid(
self.willitems,
self.block_to_check,
self.date_to_check,
self.will_settings["baltx_fees"],
self.window.wallet.get_utxos(),
heirs=self.heirs,
willexecutors=self.willexecutors,
self_willexecutor=self.no_willexecutor,
wallet=self.wallet,
callback_not_valid_tx=self.delete_not_valid,
)
def show_message(self, text):
self.window.show_message(text)
def show_warning(self, text, parent=None):
self.window.show_warning(text, parent=None)
def show_error(self, text):
self.window.show_error(text)
def show_critical(self, text):
self.window.show_critical(text)
def update_combo_setting_widgets(
self,
new_value,
field,
update_all=False,
update_will_dialog=False,
update_heirs_dialog=False,
):
if (update_all or update_will_dialog) and hasattr(self,'will_list_widget'):
self.update_widget_combo(self.will_list_widget,field,new_value)
if update_all or update_heirs_dialog and hasattr(self,'heir_list_widget'):
self.update_widget_combo(self.heir_list_widget,field,new_value)
def update_widget_combo(self,widget,field,value):
try:
widget.will_settings_widget.widgets[field].set_index(value)
except Exception as _e:
pass
def update_widget_value(self, widget, field, value):
try:
widget.will_settings_widget.widgets[field].set_value(value)
except Exception as _e:
pass
def update_setting_widgets(
self,
new_value,
field,
update_all=False,
update_will_dialog=False,
update_heirs_dialog=False,
):
if update_all or update_heirs_dialog:
self.update_widget_value(self.heir_list_widget, field, new_value)
if update_all or update_will_dialog:
self.update_widget_value(self.will_list_widget, field, new_value)
self.will_settings[field] = new_value
self.bal_plugin.WILL_SETTINGS.set(self.will_settings)
def init_heirs_to_locktime(self, multiverse=False):
#pass
for heir in self.heirs:
h = self.heirs[heir]
if not multiverse:
self.heirs[heir] = [h[0], h[1], self.will_settings["locktime"]]
def init_class_variables(self):
if not self.heirs:
raise NoHeirsException(_("Heirs are not defined"))
try:
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
# found = False
self.locktime_blocks = self.bal_plugin.LOCKTIME_BLOCKS.get()
self.current_block = Util.get_current_height(self.wallet.network)
self.block_to_check = 0
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=True, bal_window=self, task=False
)
if self.date_to_check < datetime.now().timestamp():
raise CheckAliveError(self.date_to_check)
self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get())
except Exception as e:
log_error(e )
_logger.error(f"init_class_variables: {e}")
raise e
def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True):
try:
if self.disable_plugin:
_logger.info("plugin is disabled")
return
if not self.heirs:
_logger.warning("not heirs {}".format(self.heirs))
return
try:
self.init_class_variables()
Will.check_amounts(
self.heirs,
self.willexecutors,
self.window.wallet.get_utxos(),
self.date_to_check,
self.window.wallet.dust_threshold(),
)
except AmountException as e:
self.show_warning(
_(
f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}"
)
)
except CheckAliveError:
self.show_error(
_(
"CheckAlive is in the past please update it to a date in the future but less than locktime"
)
)
return
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
if locktime < self.date_to_check:
self.show_error(_("locktime is lower than threshold"))
return
if not self.no_willexecutor:
f = False
for _k, we in self.willexecutors.items():
if Willexecutors.is_selected(we):
f = True
if not f:
self.show_error(
_(" no backup transaction or willexecutor selected")
)
return
try:
self.check_will()
except WillExpiredException:
self.invalidate_will()
return
except NoHeirsException:
return
except NotCompleteWillException as e:
_logger.info("{}:{}".format(type(e), e))
message = False
if isinstance(e, HeirChangeException):
message = "Heirs changed:"
elif isinstance(e, WillExecutorNotPresent):
message = "Will-Executor not present:"
elif isinstance(e, WillexecutorChangeException):
message = "Will-Executor changed"
elif isinstance(e, TxFeesChangedException):
message = "Txfees are changed"
elif isinstance(e, HeirNotFoundException):
message = "Heir not found"
if message:
self.show_message(
f"{_(message)}:\n {e}\n{_('will have to be built')}"
)
_logger.info("build will")
self.build_will(ignore_duplicate, keep_original)
try:
self.check_will()
for wid, _w in self.willitems.items():
self.wallet.set_label(wid, "BAL Transaction")
except WillExpiredException as e:
self.invalidate_will()
except NotCompleteWillException as e:
self.show_error(
"Error:{}\n {}".format(
str(e),
_("Please, check your heirs, locktime and threshold!"),
)
)
self.window.history_list.update()
self.window.utxo_list.update()
self.update_all()
return self.willitems
except Exception as e:
raise e
def show_transaction_real(
self,
tx: Transaction,
*,
parent: "ElectrumWindow",
prompt_if_unsaved: bool = False,
external_keypairs: Mapping[bytes, bytes] = None,
payment_identifier: "PaymentIdentifier" = None,
):
try:
d = TxDialog(
tx,
parent=parent,
prompt_if_unsaved=prompt_if_unsaved,
external_keypairs=external_keypairs,
# payment_identifier=payment_identifier,
)
d.setWindowIcon(
read_QIcon_from_bytes(self.bal_plugin.read_file("icons/bal16x16.png"))
)
except SerializationError as e:
_logger.error("unable to deserialize the transaction")
parent.show_critical(
_("Electrum was unable to deserialize the transaction:") + "\n" + str(e)
)
else:
d.show()
return d
def show_transaction(self, tx=None, txid=None, parent=None):
if not parent:
parent = self.window
if txid is not None and txid in self.willitems:
tx = self.willitems[txid].tx
if not tx:
raise Exception(_("no tx"))
return self.show_transaction_real(tx, parent=parent)
def invalidate_will(self):
def on_success(result):
if result:
self.show_message(
_(
"Please sign and broadcast this transaction to invalidate current will"
)
)
self.wallet.set_label(result.txid(), "BAL Invalidate")
self.show_transaction(result)
else:
self.show_message(_("No transactions to invalidate"))
def on_failure(exec_info):
log_error(exec_info, self.bal_window)
fee_per_byte = self.will_settings.get("baltx_fees", 1)
task = partial(Will.invalidate_will, self.willitems, self.wallet, fee_per_byte)
msg = _("Calculating Transactions")
self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False
)
self.waiting_dialog.exe()
def sign_transactions(self, password):
try:
txs = {}
signed = None
tosign = None
def get_message():
msg = ""
if signed:
msg = _(f"signed: {signed}\n")
return msg + _(f"signing: {tosign}")
for txid in Will.only_valid(self.willitems):
wi = self.willitems[txid]
tx = copy.deepcopy(wi.tx)
if wi.get_status("COMPLETE"):
txs[txid] = tx
continue
tosign = txid
try:
self.waiting_dialog.update(get_message())
except Exception:
pass
for txin in tx.inputs():
prevout = txin.prevout.to_json()
if prevout[0] in self.willitems:
change = self.willitems[prevout[0]].tx.outputs()[prevout[1]]
txin._trusted_value_sats = change.value
try:
txin.script_descriptor = change.script_descriptor
except Exception:
pass
txin.is_mine = True
txin._TxInput__address = change.address
txin._TxInput__scriptpubkey = change.scriptpubkey
txin._TxInput__value_sats = change.value
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
signed = tosign
# is_complete = False
if tx.is_complete():
# is_complete = True
wi.set_status("COMPLETE", True)
txs[txid] = tx
except Exception:
return None
return txs
def get_wallet_password(self, message=None, parent=None):
parent = self.window if not parent else parent
password = None
if self.wallet.has_keystore_encryption():
password = self.bal_plugin.password_dialog(parent=parent, msg=message)
if password is None:
return False
try:
self.wallet.check_password(password)
except Exception as e:
self.show_error(str(e))
password = self.get_wallet_password(message)
return password
def on_close(self):
try:
if not self.disable_plugin:
close_window = BalBuildWillDialog(self)
close_window.build_will_task()
self.save_willitems()
self.heirs_tab.close()
self.will_tab.close()
self.tools_menu.removeAction(self.tools_menu.willexecutors_action)
self.window.toggle_tab(self.heirs_tab)
self.window.toggle_tab(self.will_tab)
self.window.tabs.update()
except Exception:
pass
def ask_password_and_sign_transactions(self, callback=None):
def on_success(txs):
if txs:
for txid, tx in txs.items():
self.willitems[txid].tx = copy.deepcopy(tx)
self.will[txid] = self.willitems[txid].to_dict()
try:
self.will_list_widget.update()
except Exception:
pass
if callback:
try:
callback()
except Exception as e:
raise e
def on_failure(exec_info):
log_error(exec_info, self.bal_window)
password = self.get_wallet_password()
task = partial(self.sign_transactions, password)
msg = _("Signing transactions...")
self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False
)
self.waiting_dialog.exe()
def broadcast_transactions(self, force=False):
def on_success(sulcess):
self.will_list_widget.update()
if sulcess:
_logger.info("error, some transaction was not sent")
self.show_warning(_("Some transaction was not broadcasted"))
return
_logger.debug("OK, sulcess transaction was sent")
self.show_message(
_("All transactions are broadcasted to respective Will-Executors")
)
def on_failure(exec_info):
log_error(exec_info, self.bal_window)
# a,b,c = err
# _logger.error(f"fail to broadcast transactions:{err}")
# _logger.error(f"error: {b}")
# _logger.error("traceback ")
# tb = c
# while tb is not None:
# frame = tb.tb_frame
# _logger.error("file:", frame.f_code.co_filename)
# _logger.error("name:", frame.f_code.co_name)
# _logger.error("line:", tb.tb_lineno)
# _logger.error("lasti:", tb.tb_lasti)
# tb = tb.tb_next
task = partial(self.push_transactions_to_willexecutors, force)
msg = _("Selecting Will-Executors")
self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False
)
self.waiting_dialog.exe()
def push_transactions_to_willexecutors(self, force=False):
willexecutors = Willexecutors.get_willexecutor_transactions(self.willitems)
def getMsg(willexecutors):
msg = "Broadcasting Transactions to Will-Executors:\n"
for url in willexecutors:
msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n"
return msg
error = False
for url in willexecutors:
if self.waiting_dialog._stopping:
return
willexecutor = willexecutors[url]
self.waiting_dialog.update(getMsg(willexecutors))
if "txs" in willexecutor:
try:
if Willexecutors.push_transactions_to_willexecutor(
willexecutors[url]
):
for wid in willexecutors[url]["txsids"]:
self.willitems[wid].set_status("PUSHED", True)
willexecutors[url]["broadcast_status"] = _("Success")
else:
for wid in willexecutors[url]["txsids"]:
self.willitems[wid].set_status("PUSH_FAIL", True)
error = True
willexecutors[url]["broadcast_status"] = _("Failed")
del willexecutor["txs"]
except Willexecutors.AlreadyPresentException:
for wid in willexecutor["txsids"]:
if self.waiting_dialog._stopping:
return
self.waiting_dialog.update(
"checking {} - {} : {}".format(
self.willitems[wid].we["url"], wid, "Waiting"
)
)
w = self.willitems[wid]
w.set_check_willexecutor(
Willexecutors.check_transaction(wid, w.we["url"])
)
self.waiting_dialog.update(
"checked {} - {} : {}".format(
self.willitems[wid].we["url"],
wid,
self.willitems[wid].get_status("CHECKED"),
)
)
if error:
return True
def export_json_file(self, path):
for wid in self.willitems:
self.willitems[wid].set_status("EXPORTED", True)
self.will[wid] = self.willitems[wid].to_dict()
write_json_file(path, self.will)
def export_will(self):
try:
export_meta_gui(self.window, "will.json", self.export_json_file)
except Exception as e:
self.show_error(str(e))
raise e
def import_will(self):
def sulcess():
self.will_list_widget.update_will(self.willitems)
import_meta_gui(self.window, _("will"), self.import_json_file, sulcess)
def import_json_file(self, path):
try:
data = read_json_file(path)
willitems = {}
for k, v in data.items():
data[k]["tx"] = tx_from_any(v["tx"])
willitems[k] = WillItem(data[k], _id=k)
self.update_will(willitems)
except Exception as e:
raise e
# raise FileImportFailed(_("Invalid will file"))
def check_transactions_task(self, will):
start = time.time()
for wid, w in will.items():
if self.waiting_dialog._stopping:
return
if w.we:
self.waiting_dialog.update(
"checking transaction: {}\n willexecutor: {}".format(wid, w.we["url"])
)
w.set_check_willexecutor(Willexecutors.check_transaction(wid, w.we["url"]))
if time.time() - start < 3:
time.sleep(3 - (time.time() - start))
def check_transactions(self, will):
def on_success(result):
if hasattr(self,"waiting_dialog"):
del self.waiting_dialog
self.update_all()
pass
def on_failure(exec_info):
log_error(exec_info, self)
# _logger.error(f"error checking transactions {e}")
# pass
task = partial(self.check_transactions_task, will)
msg = _("Check Transaction")
self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False
)
self.waiting_dialog.exe()
def update_willexecutor_list_widget(self, parent, willexecutors):
try:
parent.willexecutors_list.update(willexecutors)
parent.will_executor_list_widget.update()
except Exception as e:
_logger.error(f"impossible to update will_executor_list_widget {e}")
self.will_executors.update()
def download_list(self, willexecutors, fn_on_success, fn_on_failure=None):
def on_success(result):
self.willexecutors.update(result)
fn_on_success(result)
def on_failure(exec_info):
fn_on_failure(exec_info)
if fn_on_failure is None:
fn_on_failure = log_error
welist_server = self.bal_plugin.WELIST_SERVER.get()
task = partial(Willexecutors.download_list, willexecutors, welist_server)
msg = _(f"Downloading willexecutors list from {welist_server}")
self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False
)
self.waiting_dialog.exe()
def ping_willexecutors_task(self, wes):
_logger.info("ping willexecutots task")
pinged = []
failed = []
def get_title():
msg = _("Ping Will-Executors:")
msg += "\n\n"
for url in wes:
urlstr = "{:<50}: ".format(url[:50])
if url in pinged:
urlstr += "Ok"
elif url in failed:
urlstr += "Ko"
else:
urlstr += "--"
urlstr += "\n"
msg += urlstr
return msg
for url, we in wes.items():
try:
self.waiting_dialog.update(get_title())
except Exception:
pass
wes[url] = Willexecutors.get_info_task(url, we)
if wes[url]["status"] == "KO":
failed.append(url)
else:
pinged.append(url)
def ping_willexecutors(self, wes, fn_on_success, fn_on_failure=None):
def on_success(result):
fn_on_success(result)
def on_failure(exec_info):
fn_on_failure(exec_info)
if not fn_on_failure:
fn_on_failure = log_error
_logger.info("ping willexecutors")
task = partial(self.ping_willexecutors_task, wes)
msg = _("Ping Will-Executors")
self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False
)
self.waiting_dialog.exe()
def preview_modal_dialog(self):
self.dw = WillDetailDialog(self)
self.dw.show()
def update_all(self):
try:
Will.add_willtree(self.willitems)
all_utxos = self.wallet.get_utxos()
utxos_list = Will.utxos_strs(all_utxos)
Will.check_invalidated(self.willitems, utxos_list, self.wallet)
self.will_list_widget.update_will(self.willitems)
self.heirs_tab.update()
self.will_tab.update()
self.will_list_widget.update()
except Exception as e:
_logger.error(f"error while updating window: {e}")

BIN
bal/icons/bal16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

BIN
bal/icons/bal32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
bal/icons/calendar.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
bal/icons/confirmed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
bal/icons/heir.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 B

BIN
bal/icons/reload.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
bal/icons/unconfirmed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
bal/icons/will.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 B

BIN
bal/icons/wizard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

10
bal/manifest.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "bal",
"fullname": "Bitcoin After Life",
"version": "0.2.8",
"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 Normal file
View File

@@ -0,0 +1,83 @@
"""
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)

50
bal/wallet_util/README.md Normal file
View File

@@ -0,0 +1,50 @@
## README
### Overview
This tool provides two entry points: a CLI script (bal_wallet_utils.py) and a Qt GUI script (bal_wallet_utils_qt.py) that operate against an Electrum source tree.
### Installation / Preparation
1. Copy both files into the Electrum project root (the folder that contains the Electrum source package):
- bal_wallet_utils.py
- bal_wallet_utils_qt.py
2. Activate the Electrum Python environment (the virtualenv used to run Electrum). Example (PowerShell, adjust path to your venv):
```
.\env\Scripts\Activate.ps1
```
or (cmd):
```
env\Scripts\activate.bat
```
### Running
- CLI version:
```
python bal_wallet_utils.py
```
- Qt GUI version:
```
python bal_wallet_utils_qt.py
```
### Building a Windows executable with PyInstaller
From the project root (with the Electrum environment active), you can build the Qt executable using PyInstaller. Example command (adjust the paths if your environment path differs):
```
pyinstaller.exe --onefile --noconsole --add-data "electrum\currencies.json;electrum" --add-data "electrum\bip39_wallet_formats.json;electrum" --add-data "electrum\lnwire\peer_wire.csv;electrum\lnwire" --add-data "electrum\lnwire\onion_wire.csv;electrum\lnwire" --add-binary "env/Lib/site-packages\electrum_ecc\libsecp256k1-6.dll;electrum_ecc" bal_wallet_utils_qt.py
```
Notes:
- Run the command from the project root so relative paths resolve correctly.
- On Windows the --add-data and --add-binary arguments use ";" to separate source and destination.
- If electrum expects additional data files or native DLLs, include them with additional --add-data / --add-binary flags.
- For debugging include --onedir first to inspect the created folder before using --onefile.
### Troubleshooting
- If PyInstaller is not found, run it via Python:
```
python -m PyInstaller <same arguments>
```
- If the frozen exe fails because DLLs or JSON files are missing, add those files explicitly with --add-data or --add-binary.
- Test the build on a clean Windows VM to ensure all runtime dependencies are included.
License and attribution: include your preferred license or attribution details here.

View File

@@ -0,0 +1,81 @@
#!env/bin/python3
import getpass
import json
import os
import sys
from electrum.storage import WalletStorage
from electrum.util import MyEncoder
default_fees = 100
def fix_will_settings_tx_fees(json_wallet):
tx_fees = json_wallet.get("will_settings", {}).get("tx_fees", False)
have_to_update = False
if tx_fees:
json_wallet["will_settings"]["baltx_fees"] = tx_fees
del json_wallet["will_settings"]["tx_fees"]
have_to_update = True
for txid, willitem in json_wallet["will"].items():
tx_fees = willitem.get("tx_fees", False)
if tx_fees:
json_wallet["will"][txid]["baltx_fees"] = tx_fees
del json_wallet["will"][txid]["tx_fees"]
have_to_update = True
return have_to_update
def uninstall_bal(json_wallet):
if "will_settings" in json_wallet:
del json_wallet["will_settings"]
if "will" in json_wallet:
del json_wallet["will"]
if "heirs" in json_wallet:
del json_wallet["heirs"]
return True
def save(json_wallet, storage):
human_readable = not storage.is_encrypted()
storage.write(
json.dumps(
json_wallet,
indent=4 if human_readable else None,
sort_keys=bool(human_readable),
cls=MyEncoder,
)
)
def read_wallet(path, password=False):
storage = WalletStorage(path)
if storage.is_encrypted():
if not password:
password = getpass.getpass("Enter wallet password: ", stream=None)
storage.decrypt(password)
data = storage.read()
json_wallet = json.loads("[" + data + "]")[0]
return json_wallet, storage
if __name__ == "__main__":
if len(sys.argv) < 3:
print("usage: ./bal_wallet_utils <command> <wallet path>")
print("available commands: uninstall, fix")
exit(1)
if not os.path.exists(sys.argv[2]):
print("Error: wallet not found")
exit(1)
command = sys.argv[1]
path = sys.argv[2]
json_wallet, storage = read_wallet(path)
have_to_save = False
if command == "fix":
have_to_save = fix_will_settings_tx_fees(json_wallet)
if command == "uninstall":
have_to_save = uninstall_bal(json_wallet)
if have_to_save:
save(json_wallet, storage)
else:
print("nothing to do")

View File

@@ -0,0 +1,199 @@
#!/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 Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Build a clean, zipimport-friendly distribution archive of the BAL plugin.
Electrum loads external plugins from a ``.zip`` using Python's ``zipimport``.
``zipimport`` is picky about the archive layout, so this builder deliberately:
* writes **only files** (no explicit directory entries) — some Electrum
portable builds choke on directory records inside the archive;
* uses standard DEFLATE compression (well supported by ``zipimport``);
* emits entries in a deterministic, sorted order so the archive is
reproducible (stable SHA-256);
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files.
The archive keeps the top-level ``bal/`` directory so that the package is
importable as ``bal`` (and Electrum derives ``dirname='bal'`` from the path of
``bal/manifest.json``).
Usage::
python3 build_zip.py [output.zip]
Prints the resulting size and SHA-256 so the download can be integrity-checked.
"""
import hashlib
import os
import sys
import zipfile
SRC_ROOT = "bal"
DEFAULT_OUT = "bal-electrum-plugin.zip"
def build(out_path: str) -> None:
if os.path.exists(out_path):
os.remove(out_path)
files = []
for dirpath, dirnames, filenames in os.walk(SRC_ROOT):
# prune cache dirs in place so os.walk does not descend into them
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
for fn in filenames:
if fn.endswith((".pyc", ".pyo")):
continue
files.append(os.path.join(dirpath, fn))
files.sort()
with zipfile.ZipFile(
out_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
) as z:
for f in files:
arc = f.replace(os.sep, "/") # forward slashes inside the archive
z.write(f, arc)
# Integrity + summary
with zipfile.ZipFile(out_path) as z:
bad = z.testzip()
if bad is not None:
raise SystemExit(f"ERROR: corrupt entry in archive: {bad}")
names = z.namelist()
if not any(n.endswith("manifest.json") for n in names):
raise SystemExit("ERROR: manifest.json missing from archive")
data = open(out_path, "rb").read()
print(f"built : {out_path}")
print(f"files : {len(files)}")
print(f"size : {len(data)} bytes")
print(f"sha256: {hashlib.sha256(data).hexdigest()}")
if __name__ == "__main__":
out = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_OUT
build(out)

View File

@@ -0,0 +1,64 @@
"""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]))

92
tests/smoke_test.py Normal file
View File

@@ -0,0 +1,92 @@
"""
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()