diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39a207c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +**/__pycache__/ +*.pyc +*.zip +bal-electrum-plugin.zip +electrum-src/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d5f70c --- /dev/null +++ b/README.md @@ -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= \ + python3 tests/smoke_test.py electrum.plugins.bal + +# external-zip loading regression +QT_QPA_PLATFORM=offscreen PYTHONPATH= \ + python3 tests/external_zip_test.py bal-electrum-plugin.zip +``` + +## ⚠️ Safety + +This plugin builds real Bitcoin inheritance transactions with time-locks. Test +on **testnet** or a fund-less wallet first, and review the generated +transactions before broadcasting. + +## License + +MIT — see [`bal/LICENSE`](bal/LICENSE). diff --git a/bal/LICENSE b/bal/LICENSE new file mode 100644 index 0000000..c9bc88f --- /dev/null +++ b/bal/LICENSE @@ -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. diff --git a/bal/README.md b/bal/README.md new file mode 100644 index 0000000..5896ad7 --- /dev/null +++ b/bal/README.md @@ -0,0 +1,2 @@ +# BalPlugin +Bitcoin After Life Electrum Plugin diff --git a/bal/VERSION b/bal/VERSION new file mode 100644 index 0000000..a45be46 --- /dev/null +++ b/bal/VERSION @@ -0,0 +1 @@ +0.2.8 diff --git a/bal/__init__.py b/bal/__init__.py new file mode 100644 index 0000000..d785262 --- /dev/null +++ b/bal/__init__.py @@ -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" diff --git a/bal/bal_resources.py b/bal/bal_resources.py new file mode 100644 index 0000000..dc2924c --- /dev/null +++ b/bal/bal_resources.py @@ -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) diff --git a/bal/core/__init__.py b/bal/core/__init__.py new file mode 100644 index 0000000..6e1c1b2 --- /dev/null +++ b/bal/core/__init__.py @@ -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. +""" diff --git a/bal/core/heirs.py b/bal/core/heirs.py new file mode 100644 index 0000000..4200f54 --- /dev/null +++ b/bal/core/heirs.py @@ -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 "%") +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 int(from_locktime): + try: + base_fee = int(willexecutor["base_fee"]) + willexecutors_amount += base_fee + h = [None] * 4 + h[HEIR_AMOUNT] = base_fee + h[HEIR_REAL_AMOUNT] = base_fee + h[HEIR_LOCKTIME] = locktime + h[HEIR_ADDRESS] = willexecutor["address"] + willexecutors[ + 'w!ll3x3c"' + willexecutor["url"] + '"' + str(locktime) + ] = h + except Exception: + return [], False + else: + _logger.error( + f"heir excluded from will locktime({locktime}){Util.int_locktime(locktime)} newbalance: + fixed_amount = self.normalize_perc( + fixed_heirs, newbalance, fixed_amount, wallet + ) + onlyfixed = True + + heir_list.update(fixed_heirs) + + newbalance -= fixed_amount + if newbalance > 0: + perc_amount = self.normalize_perc( + percent_heirs, newbalance, percent_amount, wallet + ) + newbalance -= perc_amount + heir_list.update(percent_heirs) + if newbalance > 0: + newbalance += fixed_amount + fixed_amount = self.normalize_perc( + fixed_heirs, newbalance, fixed_amount_with_dust, wallet, real=True + ) + newbalance -= fixed_amount + heir_list.update(fixed_heirs) + + heir_list = sorted( + heir_list.items(), + key=lambda item: Util.parse_locktime_string(item[1][HEIR_LOCKTIME], wallet), + ) + + locktimes = {} + for key, value in heir_list: + locktime = Util.parse_locktime_string(value[HEIR_LOCKTIME]) + if locktime not in locktimes: + locktimes[locktime] = {key: value} + else: + locktimes[locktime][key] = value + return locktimes, onlyfixed + + def is_perc(self, key): + return Util.is_perc(self[key][HEIR_AMOUNT]) + + def buildTransactions( + self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0 + ): + Heirs._validate(self) + if len(self) <= 0: + _logger.info("while building transactions there was no heirs") + return + balance = 0.0 + len_utxo_set = 0 + available_utxos = [] + if not utxos: + utxos = wallet.get_utxos() + willexecutors = Willexecutors.get_willexecutors(bal_plugin) or {} + self.decimal_point = bal_plugin.get_decimal_point() + no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get() + for utxo in utxos: + if utxo.value_sats() > 0 * tx_fees: + balance += utxo.value_sats() + len_utxo_set += 1 + available_utxos.append(utxo) + if len_utxo_set == 0: + _logger.info("no usable utxos") + return + j = -2 + willexecutorsitems = list(willexecutors.items()) + willexecutorslen = len(willexecutorsitems) + alltxs = {} + while True: + j += 1 + if j >= willexecutorslen: + break + elif 0 <= j: + url, willexecutor = willexecutorsitems[j] + if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold(): + continue + else: + willexecutor["url"] = url + elif j == -1: + if not no_willexecutors: + continue + url = willexecutor = False + else: + break + fees = {} + i = 0 + while i < 10: + txs = {} + redo = False + i += 1 + total_fees = 0 + for fee in fees: + total_fees += int(fees[fee]) + # newbalance = balance + try: + locktimes, onlyfixed = self.prepare_lists( + balance, total_fees, wallet, willexecutor, from_locktime + ) + except WillExecutorFeeException: + i = 10 + continue + if locktimes: + try: + txs = prepare_transactions( + locktimes, available_utxos[:], fees, wallet + ) + if not txs: + return {} + except Exception as e: + _logger.error( + f"build transactions: error preparing transactions: {e}" + ) + try: + if "w!ll3x3c" in e.heirname: + Willexecutors.is_selected( + e.heirname[len("w!ll3x3c") :], False + ) + break + except Exception: + raise e + total_fees = 0 + total_fees_real = 0 + total_in = 0 + for txid, tx in txs.items(): + tx.willexecutor = willexecutor + fee = tx.estimated_size() * tx_fees + txs[txid].tx_fees = tx_fees + total_fees += fee + total_fees_real += tx.get_fee() + total_in += tx.input_value() + rfee = tx.input_value() - tx.output_value() + if rfee < fee or rfee > fee + wallet.dust_threshold(): + redo = True + # oldfees = fees.get(tx.my_locktime, 0) + fees[tx.my_locktime] = fee + + if balance - total_in > wallet.dust_threshold(): + redo = True + if not redo: + break + if i >= 10: + break + else: + _logger.info( + f"no locktimes for willexecutor {willexecutor} skipped" + ) + break + alltxs.update(txs) + + return alltxs + + def get_transactions( + self, bal_plugin, wallet, tx_fees, utxos=None, from_locktime=0 + ): + txs = self.buildTransactions(bal_plugin, wallet, tx_fees, utxos, from_locktime) + if txs: + temp_txs = {} + for txid in txs: + if txs[txid].available_utxos: + temp_txs.update( + self.get_transactions( + bal_plugin, + wallet, + tx_fees, + txs[txid].available_utxos, + txs[txid].locktime, + ) + ) + txs.update(temp_txs) + return txs + + def resolve(self, k): + if bitcoin.is_address(k): + return {"address": k, "type": "address"} + if k in self.keys(): + _type, addr = self[k] + if _type == "address": + return {"address": addr, "type": "heir"} + if openalias := self.resolve_openalias(k): + return openalias + raise AliasNotFoundException("Invalid Bitcoin address or alias", k) + + @classmethod + def resolve_openalias(cls, url: str) -> Dict[str, Any]: + out = cls._resolve_openalias(url) + if out: + address, name, validated = out + return { + "address": address, + "name": name, + "type": "openalias", + "validated": validated, + } + return {} + + def by_name(self, name): + for k in self.keys(): + _type, addr = self[k] + if addr.casefold() == name.casefold(): + return {"name": addr, "type": _type, "address": k} + return None + + def fetch_openalias(self, config: "SimpleConfig"): + self.alias_info = None + alias = config.OPENALIAS_ID + if alias: + alias = str(alias) + + def f(): + self.alias_info = self._resolve_openalias(alias) + trigger_callback("alias_received") + + t = threading.Thread(target=f) + t.daemon = True + t.start() + + @classmethod + def _resolve_openalias(cls, url: str) -> Optional[Tuple[str, str, bool]]: + # support email-style addresses, per the OA standard + url = url.replace("@", ".") + try: + records, validated = dnssec.query(url, dns.rdatatype.TXT) + except DNSException as e: + _logger.info(f"Error resolving openalias: {repr(e)}") + return None + prefix = "btc" + for record in records: + string = to_string(record.strings[0], "utf8") + if string.startswith("oa1:" + prefix): + address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)") + name = cls.find_regex(string, r"recipient_name=([^;]+)") + if not name: + name = address + if not address: + continue + return address, name, validated + + @staticmethod + def find_regex(haystack, needle): + regex = re.compile(needle) + try: + return regex.search(haystack).groups()[0] + except AttributeError: + return None + + def validate_address(address): + if not bitcoin.is_address(address, net=constants.net): + raise NotAnAddress(f"not an address,{address}") + return address + + def validate_amount(amount): + try: + famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount) + if famount <= 0.00000001: + raise AmountNotValid(f"amount have to be positive {famount} < 0") + except Exception as e: + raise AmountNotValid(f"amount not properly formatted, {e}") + return amount + + def validate_locktime(locktime, timestamp_to_check=False): + try: + if timestamp_to_check: + if Util.parse_locktime_string(locktime, None) < timestamp_to_check: + raise HeirExpiredException() + except Exception as e: + raise LocktimeNotValid(f"locktime string not properly formatted, {e}") + return locktime + + def validate_heir(k, v, timestamp_to_check=False): + address = Heirs.validate_address(v[HEIR_ADDRESS]) + amount = Heirs.validate_amount(v[HEIR_AMOUNT]) + locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check) + return (address, amount, locktime) + + def _validate(data, timestamp_to_check=False): + + for k, v in list(data.items()): + if k == "heirs": + return Heirs._validate(v, timestamp_to_check) + try: + Heirs.validate_heir(k, v, timestamp_to_check) + except Exception as e: + _logger.info(f"exception heir removed {e}") + data.pop(k) + return data + + +class NotAnAddress(ValueError): + pass + + +class AmountNotValid(ValueError): + pass + + +class LocktimeNotValid(ValueError): + pass + + +class HeirExpiredException(LocktimeNotValid): + pass + + +class HeirAmountIsDustException(Exception): + pass + + +class NoHeirsException(Exception): + pass + + +class WillExecutorFeeException(Exception): + def __init__(self, willexecutor): + self.willexecutor = willexecutor + + def __str__(self): + return "WillExecutorFeeException: {} fee:{}".format( + self.willexecutor["url"], self.willexecutor["base_fee"] + ) +class BalanceTooLowException(Exception): + def __init__(self,balance, dust_threshold, fees): + self.balance=balance + self.dust_threshold = dust_threshold + self.fees = fees + def __str__(self): + return f"Balance too low, balance: {self.balance}, dust threshold: {self.dust_threshold}, fees: {self.fees}" diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py new file mode 100644 index 0000000..0391beb --- /dev/null +++ b/bal/core/plugin_base.py @@ -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: + * ``"y"`` -> ``n`` years (unit ``"y"``) + * ``"d"`` -> ``n`` days (unit ``"d"``) + * an integer -> an absolute UNIX timestamp (``unit is None``) + """ + + value = None + unit = None + + def __init__(self, value): + str_value = str(value) + if str_value and str_value[-1].lower() in ("y", "d"): + self.value = int(str_value[:-1]) + self.unit = str_value[-1] + else: + try: + self.value = int(value) + except Exception as _e: + self.value = 1 + self.unit = None + + def duration_to_days(self): + """Return the duration expressed in days (years are ``*365``).""" + return self.value * 365 if self.unit == 'y' else self.value + + 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}" diff --git a/bal/core/util.py b/bal/core/util.py new file mode 100644 index 0000000..d5eff97 --- /dev/null +++ b/bal/core/util.py @@ -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 + * ``"y"`` -> n years from now (as a timestamp) + * ``"d"`` -> n days from now (as a timestamp) + * ``"b"`` -> current block height + n (needs wallet + ``w`` to read the chain height) + """ + try: + return int(locktime) + + except Exception: + pass + try: + now = datetime.now() + if locktime[-1] == "y": + locktime = str(int(locktime[:-1]) * 365) + "d" + if locktime[-1] == "d": + return int( + (now + timedelta(days=int(locktime[:-1]))) + .replace(hour=0, minute=0, second=0, microsecond=0) + .timestamp() + ) + if locktime[-1] == "b": + locktime = int(locktime[:-1]) + height = 0 + if w: + height = Util.get_current_height(w.network) + locktime += int(height) + return int(locktime) + except Exception: + pass + return 0 + + @staticmethod + def int_locktime(seconds=0, minutes=0, hours=0, days=0, blocks=0): + """Convert a human duration into seconds (blocks counted as 600s each).""" + return int( + seconds + + minutes * 60 + + hours * 60 * 60 + + days * 60 * 60 * 24 + + blocks * 600 + ) + + # ------------------------------------------------------------------ # + # Amount helpers + # ------------------------------------------------------------------ # + @staticmethod + def encode_amount(amount, decimal_point): + """Convert a displayed BTC amount into integer satoshis. + + Percentage amounts (e.g. ``"50%"``) are passed through unchanged, since + they are resolved later against the wallet balance. + """ + if Util.is_perc(amount): + return amount + else: + try: + return int(float(amount) * pow(10, decimal_point)) + except Exception: + return 0 + + @staticmethod + def decode_amount(amount, decimal_point): + """Inverse of :meth:`encode_amount`: satoshis -> displayed string.""" + if Util.is_perc(amount): + return amount + else: + basestr = "{{:0.{}f}}".format(decimal_point) + try: + return basestr.format(float(amount) / pow(10, decimal_point)) + except Exception: + return str(amount) + + @staticmethod + def is_perc(value): + """True if ``value`` is a percentage string such as ``"25%"``.""" + try: + return value[-1] == "%" + except Exception: + return False + + # ------------------------------------------------------------------ # + # Heir / will-executor comparison helpers + # ------------------------------------------------------------------ # + @staticmethod + def cmp_array(heira, heirb): + """Element-wise equality of two sequences (length-safe).""" + try: + if len(heira) != len(heirb): + return False + for h in range(0, len(heira)): + if heira[h] != heirb[h]: + return False + return True + except Exception: + return False + + @staticmethod + def cmp_heir(heira, heirb): + """Two heirs are "the same" when address (0) and amount (1) match.""" + if heira[0] == heirb[0] and heira[1] == heirb[1]: + return True + return False + + @staticmethod + def cmp_willexecutor(willexecutora, willexecutorb): + """Compare two will-executor dicts by url / address / base_fee.""" + if willexecutora == willexecutorb: + return True + try: + if ( + willexecutora["url"] == willexecutorb["url"] + and willexecutora["address"] == willexecutorb["address"] + and willexecutora["base_fee"] == willexecutorb["base_fee"] + ): + return True + except Exception: + return False + return False + + @staticmethod + def search_heir_by_values(heirs, heir, values): + """Return the key of the first heir in ``heirs`` matching ``heir`` on + every column listed in ``values`` (or ``False`` if none).""" + for h, v in heirs.items(): + found = False + for val in values: + if val in v and v[val] != heir[val]: + found = True + + if not found: + return h + return False + + @staticmethod + def cmp_heir_by_values(heira, heirb, values): + """True when two heirs agree on every column index in ``values``.""" + for v in values: + if heira[v] != heirb[v]: + return False + return True + + @staticmethod + def cmp_heirs_by_values( + heirsa, heirsb, values, exclude_willexecutors=False, reverse=True + ): + """Set-equality of two heir collections, comparing only ``values``. + + When ``exclude_willexecutors`` is set, synthetic will-executor heirs + (those whose key contains the ``w!ll3x3c"`` marker) are skipped. The + ``reverse`` flag makes the comparison symmetric by running it both ways. + """ + for heira in heirsa: + if ( + exclude_willexecutors and 'w!ll3x3c"' not in heira + ) or not exclude_willexecutors: + found = False + for heirb in heirsb: + if Util.cmp_heir_by_values(heirsa[heira], heirsb[heirb], values): + found = True + if not found: + return False + if reverse: + return Util.cmp_heirs_by_values( + heirsb, + heirsa, + values, + exclude_willexecutors=exclude_willexecutors, + reverse=False, + ) + else: + return True + + @staticmethod + def cmp_heirs( + heirsa, + heirsb, + cmp_function=lambda x, y: x[0] == y[0] and x[3] == y[3], + reverse=True, + ): + """Compare two heir collections using a custom ``cmp_function``. + + Will-executor entries are ignored. As with + :meth:`cmp_heirs_by_values`, ``reverse`` makes the relation symmetric. + """ + try: + for heir in heirsa: + if 'w!ll3x3c"' not in heir: + if heir not in heirsb or not cmp_function( + heirsa[heir], heirsb[heir] + ): + if not Util.search_heir_by_values(heirsb, heirsa[heir], [0, 3]): + return False + if reverse: + return Util.cmp_heirs(heirsb, heirsa, cmp_function, False) + else: + return True + except Exception as e: + raise e + + # ------------------------------------------------------------------ # + # Transaction input/output comparison helpers + # ------------------------------------------------------------------ # + @staticmethod + def cmp_inputs(inputsa, inputsb): + """True when both input lists reference the same set of UTXOs.""" + if len(inputsa) != len(inputsb): + return False + for inputa in inputsa: + if not Util.in_utxo(inputa, inputsb): + return False + return True + + @staticmethod + def cmp_outputs(outputsa, outputsb, willexecutor_output=None): + """True when both output lists contain the same (address, value) pairs. + + The optional ``willexecutor_output`` is treated as a wildcard match so + that the will-executor's fee output does not break the comparison. + """ + if len(outputsa) != len(outputsb): + return False + for outputa in outputsa: + if not Util.cmp_output(outputa, willexecutor_output): + if not Util.in_output(outputa, outputsb): + return False + return True + + @staticmethod + def cmp_txs(txa, txb): + """Two transactions are equivalent when their inputs and outputs match.""" + if not Util.cmp_inputs(txa.inputs(), txb.inputs()): + return False + if not Util.cmp_outputs(txa.outputs(), txb.outputs()): + return False + return True + + @staticmethod + def get_value_amount(txa, txb): + """Sum of the values of outputs that appear (same addr+value) in both + transactions. Returns ``False`` as soon as an output of ``txa`` shares + neither amount nor address with any output of ``txb``.""" + outputsa = txa.outputs() + value_amount = 0 + + for outa in outputsa: + same_amount, same_address = Util.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" diff --git a/bal/core/will.py b/bal/core/will.py new file mode 100644 index 0000000..f4a5f33 --- /dev/null +++ b/bal/core/will.py @@ -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 diff --git a/bal/core/willexecutors.py b/bal/core/willexecutors.py new file mode 100644 index 0000000..e025ac8 --- /dev/null +++ b/bal/core/willexecutors.py @@ -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}" diff --git a/bal/gui/__init__.py b/bal/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bal/gui/qt/__init__.py b/bal/gui/qt/__init__.py new file mode 100644 index 0000000..c427e8b --- /dev/null +++ b/bal/gui/qt/__init__.py @@ -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) +""" diff --git a/bal/gui/qt/calendar.py b/bal/gui/qt/calendar.py new file mode 100644 index 0000000..4874b09 --- /dev/null +++ b/bal/gui/qt/calendar.py @@ -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) diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py new file mode 100644 index 0000000..4d4a0e8 --- /dev/null +++ b/bal/gui/qt/common.py @@ -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))) + ) + + diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py new file mode 100644 index 0000000..cb18a5b --- /dev/null +++ b/bal/gui/qt/dialogs.py @@ -0,0 +1,1127 @@ +""" +bal.gui.qt.dialogs +================== + +All modal/non-modal dialogs of the plugin. + + * BalDialog - common base dialog (icon, close handling). + * BalWizard* (Dialog/Widget) - the step-by-step "create your will" wizard. + * BalWaitingDialog / + BalBlockingWaitingDialog - progress dialogs for background tasks. + * BalBuildWillDialog - the central build/sign/push/broadcast flow. + * WillDetailDialog - shows the full will tree for one wallet. + * WillExecutorDialog - manage the list of will-executor servers. + +To keep the dialogs verbatim while avoiding import cycles with the list views, +the few list classes they reference are imported lazily inside the methods that +use them (see ``lists`` imports below). +""" + +from .common import * +from .common import _, _logger # underscore names are not re-exported by "import *" +from .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget, + LockTimeWidget, PercAmountEdit, ThresholdTimeWidget, + WillSettingsWidget, WillWidget) +from .calendar import BalCalendar +# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are +# imported lazily where needed to avoid a dialogs<->lists import cycle. + + +class BalDialog(QDialog,MessageBoxMixin): + _stopping = False + def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"): + import signal + from PyQt6.QtCore import QMetaObject, Qt + from PyQt6.QtWidgets import QApplication + def handler(signum, frame): + QMetaObject.invokeMethod(self, "close", Qt.ConnectionType.QueuedConnection) + + #signal.signal(signal.SIGINT, handler) + self.parent = parent + self.thread = None + super().__init__(parent) + if title: + self.setWindowTitle(title) + # WindowModalDialog.__init__(self,parent) + self.setWindowIcon(read_QIcon_from_bytes(bal_plugin.read_file(icon))) + + def closeEvent(self, event): + self._stopping = True + #if self.thread: + # self.thread.stop() + super().closeEvent(event) + + def hideEvent(self, event): + self._stopping = True + #if self.thread: + # self.thread.stop() + super().hideEvent(event) + + +class BalWizardDialog(BalDialog): + def __init__(self, bal_window: "BalWindow"): + assert bal_window + BalDialog.__init__( + self, bal_window.window, bal_window.bal_plugin, _("Bal Wizard Setup") + ) + self.setMinimumSize(800, 400) + self.bal_window = bal_window + self.parent = bal_window.window + self.layout = QVBoxLayout(self) + self.widget = BalWizardHeirsWidget( + bal_window, self, self.on_next_heir, None, self.on_cancel_heir + ) + self.layout.addWidget(self.widget) + + def next_widget(self, widget): + self.layout.removeWidget(self.widget) + self.widget.close() + self.widget = widget + self.layout.addWidget(self.widget) + # self.update() + # self.repaint() + + def on_next_heir(self): + self.next_widget( + BalWizardLocktimeAndFeeWidget( + self.bal_window, + self, + self.on_next_locktimeandfee, + self.on_previous_heir, + self.on_cancel_heir, + ) + ) + + def on_previous_heir(self): + self.next_widget( + BalWizardHeirsWidget( + self.bal_window, self, self.on_next_heir, None, self.on_cancel_heir + ) + ) + + def on_cancel_heir(self): + pass + + def on_next_wedonwload(self): + self.next_widget( + BalWizardWEWidget( + self.bal_window, + self, + self.on_next_we, + self.on_next_locktimeandfee, + self.on_cancel_heir, + ) + ) + + def on_next_we(self): + close_window = BalBuildWillDialog(self.bal_window) + close_window.build_will_task() + self.close() + # self.next_widget(BalWizardLocktimeAndFeeWidget(self.bal_window,self,self.on_next_locktimeandfee,self.on_next_wedonwload,self.on_next_wedonwload.on_cancel_heir)) + + def on_next_locktimeandfee(self): + self.next_widget( + BalWizardWEDownloadWidget( + self.bal_window, + self, + self.on_next_wedonwload, + self.on_next_heir, + self.on_cancel_heir, + ) + ) + + def on_accept(self): + self.bal_window.update_all() + pass + + def on_reject(self): + pass + + def on_close(self): + self.bal_window.update_all() + pass + + def closeEvent(self, event): + self._stopping = True + # self.bal_window.heir_list_widget.will_settings_widget.update_will_settings() + pass + + + +class BalWizardWidget(QWidget): + title = None + message = None + + def __init__( + self, bal_window: "BalWindow", parent, on_next, on_previous, on_cancel + ): + QWidget.__init__(self, parent) + self.vbox = QVBoxLayout(self) + self.bal_window = bal_window + self.parent = parent + self.on_next = on_next + self.on_cancel = on_cancel + self.titleLabel = QLabel(self.title) + self.vbox.addWidget(self.titleLabel) + self.messageLabel = QLabel(_(self.message)) + self.vbox.addWidget(self.messageLabel) + + self.content = self.get_content() + self.content_container = QWidget() + self.containrelayout = QVBoxLayout(self.content_container) + self.containrelayout.addWidget(self.content) + + self.vbox.addWidget(self.content_container) + + spacer_widget = QWidget() + spacer_widget.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + self.vbox.addWidget(spacer_widget) + + self.buttons = [] + if on_previous: + self.on_previous = on_previous + self.previous_button = QPushButton(_("Previous")) + self.previous_button.clicked.connect(self._on_previous) + self.buttons.append(self.previous_button) + + self.next_button = QPushButton(_("Next")) + self.next_button.clicked.connect(self._on_next) + self.buttons.append(self.next_button) + + self.abort_button = QPushButton(_("Cancel")) + self.abort_button.clicked.connect(self._on_cancel) + self.buttons.append(self.abort_button) + + self.vbox.addLayout(Buttons(*self.buttons)) + + def _on_cancel(self): + self.on_cancel() + self.parent.close() + + def _on_next(self): + if self.validate(): + self.on_next() + + def _on_previous(self): + self.on_previous() + + def get_content(self): + pass + + def validate(self): + return True + + + +class BalWizardHeirsWidget(BalWizardWidget): + title = "Bitcoin After Life Heirs" + message = ( + "Please add your heirs\n remember that 100% of wallet balance will be spent" + ) + + def get_content(self): + # Lazy import to avoid a dialogs<->lists import cycle (lists imports + # BalBuildWillDialog from this module at load time). + from .lists import HeirListWidget + self.heir_list_widget = HeirListWidget(self.bal_window, self) + button_add = QPushButton(_("Add")) + button_add.clicked.connect(self.add_heir) + button_import = QPushButton(_("Import")) + button_import.clicked.connect(self.import_from_file) + button_export = QPushButton(_("Export")) + button_export.clicked.connect(self.export_to_file) + widget = QWidget() + vbox = QVBoxLayout(widget) + vbox.addWidget(self.heir_list_widget) + vbox.addLayout(Buttons(button_add, button_import, button_export)) + return widget + + def import_from_file(self): + self.bal_window.import_heirs() + self.heir_list_widget.update() + + def export_to_file(self): + self.bal_window.export_heirs() + + def add_heir(self): + self.bal_window.new_heir_dialog() + self.heir_list_widget.update() + + def validate(self): + return True + + + +class BalWizardWEDownloadWidget(BalWizardWidget): + title = _("Bitcoin After Life Will-Executors") + message = _("Choose willexecutors download method") + + def get_content(self): + # question = QLabel() + self.combo = QComboBox() + self.combo.addItems( + [ + "Automatically download and select willexecutors", + "Only download willexecutors list", + "Import willexecutor list from file", + "Manual", + ] + ) + # heir_name.setFixedWidth(32 * char_width_in_lineedit()) + return self.combo + + def validate(self): + return True + + def _on_next(self): + + index = self.combo.currentIndex() + _logger.debug(f"selected index:{index}") + if index < 3: + self.bal_window.willexecutors = Willexecutors.get_willexecutors( + self.bal_window.bal_plugin + ) + + if index == 2: + + def do_nothing(): + self.bal_window.willexecutors.update(self.willexecutors) + Willexecutors.save( + self.bal_window.bal_plugin, self.bal_window.willexecutors + ) + pass + + import_meta_gui( + self.bal_window.window, + _("willexecutors"), + self.import_json_file, + do_nothing, + ) + + if index < 2: + + def on_success(willexecutors): + def ping_on_success(result): + ping_on_done() + + def ping_on_failure(exec_info): + ping_on_done() + + def ping_on_done(): + if index < 1: + for we in self.bal_window.willexecutors: + if self.bal_window.willexecutors[we]["status"] == 200: + self.bal_window.willexecutors[we]["selected"] = True + Willexecutors.save( + self.bal_window.bal_plugin, self.bal_window.willexecutors + ) + + self.bal_window.ping_willexecutors( + self.bal_window.willexecutors, ping_on_success, ping_on_failure + ) + + self.bal_window.download_list(self.bal_window.willexecutors, on_success) + + elif index == 3: + # TODO DO NOTHING + pass + + self.bal_window.will_list_widget.update() + if self.validate(): + return self.on_next() + + def import_json_file(self, path): + data = read_json_file(path) + data = self._validate(data) + self.willexecutors = data + + def _validate(self, data): + return data + + + +class BalWizardWEWidget(BalWizardWidget): + title = "Bitcoin After Life Will-Executors" + message = _("Configure and select your willexecutors") + + def get_content(self): + # Lazy import to avoid a dialogs<->lists import cycle. + from .lists import WillExecutorWidget + widget = QWidget() + vbox = QVBoxLayout(widget) + vbox.addWidget( + WillExecutorWidget( + self, + self.bal_window, + Willexecutors.get_willexecutors(self.bal_window.bal_plugin), + ) + ) + return widget + + + +class BalWizardLocktimeAndFeeWidget(BalWizardWidget): + title = "Bitcoin After Life Will Settings" + message = _("") + + def get_content(self): + widget = QWidget() + layout = QVBoxLayout(widget) + + layout.addWidget(WillSettingsWidget(self.bal_window, self, "v")) + spacer_widget = QWidget() + spacer_widget.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + layout.addWidget(spacer_widget) + return widget + + + +class BalWaitingDialog(BalDialog): + updatemessage = pyqtSignal([str], arguments=["message"]) + + def __init__( + self, + bal_window: "BalWindow", + message: str, + task, + on_success=None, + on_error=None, + on_cancel=None, + exe=True, + ): + assert bal_window + BalDialog.__init__( + self, bal_window.window, bal_window.bal_plugin, _("Please wait") + ) + self.message_label = QLabel(message) + vbox = QVBoxLayout(self) + vbox.addWidget(self.message_label) + self.updatemessage.connect(self.update_message) + if on_cancel: + self.cancel_button = CancelButton(self) + self.cancel_button.clicked.connect(on_cancel) + vbox.addLayout(Buttons(self.cancel_button)) + self.accepted.connect(self.on_accepted) + self.task = task + self.on_success = on_success + self.on_error = on_error + self.on_cancel = on_cancel + if exe: + self.exe() + + def exe(self): + self.thread = TaskThread(self) + self.thread.finished.connect(self.deleteLater) # see #3956 + self.thread.finished.connect(self.finished) + self.thread.add(self.task, self.on_success, self.accept, self.on_error) + self.exec() + + def hello(self): + pass + + def finished(self): + pass + + + def on_accepted(self): + pass + + def update_message(self, msg): + self.message_label.setText(msg) + + def update(self, msg): + self.updatemessage.emit(msg) + + def getText(self): + return self.message_label.text() + + + + +class BalBlockingWaitingDialog(BalDialog): + def __init__(self, bal_window: "BalWindow", message: str, task: Callable[[], Any]): + BalDialog.__init__(self, bal_window, bal_window.bal_plugin, _("Please wait")) + self.message_label = QLabel(message) + vbox = QVBoxLayout(self) + vbox.addWidget(self.message_label) + self.finished.connect(self.deleteLater) # see #3956 + # show popup + self.show() + # refresh GUI; needed for popup to appear and for message_label to get drawn + # QCoreApplication.processEvents() + # QCoreApplication.processEvents() + try: + # block and run given task + task() + finally: + # close popup + self.accept() + + +class BalBuildWillDialog(BalDialog): + updatemessage = pyqtSignal() + COLOR_WARNING = "#cfa808" + COLOR_ERROR = "#ff0000" + COLOR_OK = "#05ad05" + + def __init__(self, bal_window, parent=None): + if not parent: + parent = bal_window.window + BalDialog.__init__(self, parent, bal_window.bal_plugin, _("Building Will")) + self.parent = parent + self.updatemessage.connect(self.msg_update) + self.bal_window = bal_window + self.bal_plugin = bal_window.bal_plugin + self.message_label = QLabel(_("Building Will:")) + self.vbox = QVBoxLayout(self) + self.vbox.addWidget(self.message_label, 0) + self.qwidget = QWidget(self) + self.vbox.addWidget(self.qwidget, 1) + self.labelsbox = QVBoxLayout(self.qwidget) + self.setMinimumWidth(600) + self.setMinimumHeight(100) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.labels = [] + self.check_row = None + self.inval_row = None + self.build_row = None + self.sign_row = None + self.push_row = None + self.network = Network.get_instance() + self._stopping = False + self.thread = TaskThread(self) + self.thread.finished.connect(self.task_finished) # see #3956 + + def task_finished(self): + pass + + def build_will_task(self): + _logger.debug("build will task to be started") + self.thread.add( + self.task_phase1, + on_success=self.on_success_phase1, + on_done=self.on_accept, + on_error=self.on_error_phase1, + ) + self.show() + self.exec() + + def task_phase1(self): + if self._stopping: + return + txs = None + _logger.debug("close plugin phase 1 started") + varrow = self.msg_set_status("checking variables") + try: + self.bal_window.init_class_variables() + except CheckAliveError as cae: + fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) + tx = Will.invalidate_will( + self.bal_window.willitems, self.bal_window.wallet, fee_per_byte + ) + if tx: + _logger.debug( + "during phase1 CAE: {}, Continue to invalidate".format(cae) + ) + self.msg_set_status("checking variables",varrow, "Check Alive Threshold Passed: you have to Invalidate your old Will",self.COLOR_ERROR) + else: + raise cae + return None, tx + except NoHeirsException: + self.msg_set_status("checking variables", varrow,"No Heirs",self.COLOR_ERROR) + #self.msg_set_checking("No Heirs") + return False, None + except Exception as e: + raise e + try: + _logger.debug("checking variables") + Will.check_amounts( + self.bal_window.heirs, + self.bal_window.willexecutors, + self.bal_window.window.wallet.get_utxos(), + self.bal_window.date_to_check, + self.bal_window.window.wallet.dust_threshold(), + ) + _logger.debug("variables ok") + self.msg_set_status("checking variables:", varrow, "Ok", self.COLOR_OK) + except AmountException: + self.msg_set_checking( + self.msg_warning( + "In the inheritance process, " + + "the entire wallet will always be fully emptied. \n" + + "Your settings require an adjustment of the amounts" + ) + ) + + self.msg_set_checking() + have_to_build = False + try: + self.bal_window.check_will() + self.msg_set_checking(self.msg_ok()) + except WillExpiredException: + _logger.debug("expired") + self.msg_set_checking("Expired") + fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) + return None, Will.invalidate_will( + self.bal_window.willitems, self.bal_window.wallet, fee_per_byte + ) + except NoHeirsException as e: + _logger.debug("no heirs") + self.msg_set_checking("No Heirs") + except NotCompleteWillException as e: + _logger.debug(f"not complete {e} true") + message = False + have_to_build = True + if isinstance(e, HeirChangeException): + message = _("Heirs changed:") + elif isinstance(e, WillExecutorNotPresent): + message = _("Will-Executor not present") + elif isinstance(e, WillexecutorChangeException): + message = _("Will-Executor changed") + elif isinstance(e, TxFeesChangedException): + message = _("Txfees are changed") + elif isinstance(e, HeirNotFoundException): + message = _("Heir not found") + if message: + _logger.debug(f"message: {message}") + self.msg_set_checking(message) + else: + self.msg_set_checking("New") + + if have_to_build: + self.msg_set_building() + try: + txs = self.bal_window.build_will() + if not txs: + self.msg_set_building( + _("Balance is too low, or CheckAlive is in the past.Skipped"), + color = self.COLOR_ERROR, + ) + return False, None + + self.bal_window.check_will() + for wid in Will.only_valid(self.bal_window.willitems): + self.bal_window.wallet.set_label(wid, "BAL Transaction") + self.msg_set_building(self.msg_ok()) + except WillExecutorNotPresent: + self.msg_set_status( + _("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR + ) + + except Exception as e: + self.msg_set_building(self.msg_error(e)) + return False, None + + # excluded_heirs = [] + for wid in Will.only_valid(self.bal_window.willitems): + heirs = self.bal_window.willitems[wid].heirs + for hid, heir in heirs.items(): + if "DUST" in str(heir[HEIR_REAL_AMOUNT]): + self.msg_set_status( + f"{hid},{heir[HEIR_DUST_AMOUNT]} is DUST", + None, + f"Excluded from will {wid}", + self.COLOR_WARNING, + ) + + have_to_sign = False + for wid in Will.only_valid(self.bal_window.willitems): + if not self.bal_window.willitems[wid].get_status("COMPLETE"): + have_to_sign = True + break + return have_to_sign, txs + + def on_accept(self): + self.bal_window.update_all() + pass + + def on_accept_phase2(self): + self.bal_window.update_all() + pass + + def on_error_push(self): + pass + + def wait(self, secs): + wait_row = None + for i in range(secs, 0, -1): + if self._stopping: + return + wait_row = self.msg_edit_row(_(f"Please wait {i}secs"), wait_row) + time.sleep(1) + self.msg_del_row(wait_row) + + def loop_broadcast_invalidating(self, tx): + if self._stopping: + return + self.msg_set_invalidating("Broadcasting") + try: + tx.add_info_from_wallet(self.bal_window.wallet) + self.network.run_from_another_thread(tx.add_info_from_network(self.network)) + txid = self.network.run_from_another_thread( + self.network.broadcast_transaction(tx, timeout=120), timeout=120 + ) + self.msg_set_invalidating(self.msg_ok()) + if not txid: + _logger.debug(f"should not be none txid: {txid}") + + except TxBroadcastError as e: + _logger.error(f"fail to broadcast transaction:{e}") + msg = e.get_message_for_gui() + self.msg_set_invalidating(self.msg_error(msg)) + except BestEffortRequestFailed as e: + self.msg_set_invalidating(self.msg_error(e)) + + def loop_push(self): + if self._stopping: + return + self.msg_set_pushing(_("Broadcasting")) + retry = False + try: + + willexecutors = Willexecutors.get_willexecutor_transactions( + self.bal_window.willitems + ) + for url, willexecutor in willexecutors.items(): + if self._stopping: + return + try: + if Willexecutors.is_selected( + self.bal_window.willexecutors.get(url) + ): + _logger.debug(f"{url}: {willexecutor}") + if not Willexecutors.push_transactions_to_willexecutor( + willexecutor + ): + for wid in willexecutor["txsids"]: + self.bal_window.willitems[wid].set_status( + "PUSH_FAIL", True + ) + retry = True + else: + for wid in willexecutor["txsids"]: + self.bal_window.willitems[wid].set_status( + "PUSHED", True + ) + except Willexecutors.AlreadyPresentException: + for wid in willexecutor["txsids"]: + if self._stopping: + return + row = self.msg_edit_row( + "checking {} - {} : {}".format( + self.bal_window.willitems[wid].we["url"], wid, "Waiting" + ) + ) + self.bal_plugin = self.bal_window.bal_plugin + w = self.bal_window.willitems[wid] + + w.set_check_willexecutor( + Willexecutors.check_transaction(wid, w.we["url"]) + ) + row = self.msg_edit_row( + "checked {} - {} : {}".format( + self.bal_window.willitems[wid].we["url"], + wid, + self.bal_window.willitems[wid].get_status("CHECKED"), + ), + row, + ) + + except Exception as e: + _logger.error(f"loop push error:{e}") + raise e + if retry: + raise Exception("retry") + + except Exception as e: + self.msg_set_pushing(self.msg_error(e)) + self.wait(10) + if not self._stopping: + pass + # self.loop_push() + + def invalidate_task(self, password, bal_window, tx): + if self._stopping: + return + _logger.debug(f"invalidate tx: {tx}") + # fee_per_byte = bal_window.will_settings.get("baltx_fees", 1) + tx = self.bal_window.wallet.sign_transaction(tx, password) + try: + if tx: + if tx.is_complete(): + self.loop_broadcast_invalidating(tx) + self.wait(5) + else: + raise Exception("tx not complete") + else: + raise Exception("not tx") + except Exception as e: + (f"exception:{e}") + self.msg_set_invalidating(f"Error: {e}") + raise Exception("Impossible to sign") from e + + def on_success_invalidate(self, success): + self.thread.add( + self.task_phase1, + on_success=self.on_success_phase1, + on_done=self.on_accept, + on_error=self.on_error_phase1, + ) + + def on_success_phase1(self, result): + if self._stopping: + return + self.have_to_sign, tx = list(result) + # if not tx: + # self.msg_edit_row(self.msg_error("Error, no tx was built")) + # return + _logger.debug("have to sign {}".format(self.have_to_sign)) + password = None + if self.have_to_sign is None: + _logger.debug("have to invalidate") + self.msg_set_invalidating() + # need to sign invalidate and restart phase 1 + + password = self.bal_window.get_wallet_password( + _("Invalidate your old will"), parent=self + ) + if password is False: + self.msg_set_invalidating(_("Aborted")) + self.wait(3) + self.close() + return + self.thread.add( + partial(self.invalidate_task, password, self.bal_window, tx), + on_success=self.on_success_invalidate, + on_done=self.on_accept, + on_error=self.on_error, + ) + + return + + elif self.have_to_sign: + password = self.bal_window.get_wallet_password( + _("Sign your will"), parent=self + ) + if password is False: + self.msg_set_signing(_("Aborted")) + else: + self.msg_set_signing(_("Nothing to do")) + self.thread.add( + partial(self.task_phase2, password), + on_success=self.on_success_phase2, + on_done=self.on_accept_phase2, + on_error=self.on_error_phase2, + ) + return + + def on_success_phase2(self, arg=False): + self.thread.stop() + self.bal_window.save_willitems() + self.msg_edit_row(_("Finished")) + self.close() + + def closeEvent(self, event): + self._stopping = True + self.thread.stop() + + def task_phase2(self, password): + if self._stopping: + return + if self.have_to_sign: + try: + if txs := self.bal_window.sign_transactions(password): + for txid, tx in txs.items(): + self.bal_window.willitems[txid].tx = copy.deepcopy(tx) + self.bal_window.save_willitems() + self.msg_set_signing(self.msg_ok()) + except Exception as e: + self.msg_set_signing(self.msg_error(e)) + + self.msg_set_pushing() + have_to_push = False + for wid in Will.only_valid(self.bal_window.willitems): + w = self.bal_window.willitems[wid] + if w.we and w.get_status("COMPLETE") and not w.get_status("PUSHED"): + have_to_push = True + if not have_to_push: + self.msg_set_pushing(_("Nothing to do")) + else: + try: + self.loop_push() + self.msg_set_pushing(self.msg_ok()) + + except Exception as e: + # td = traceback.format_exc() + self.msg_set_pushing(self.msg_error(e)) + self.msg_edit_row(self.msg_ok()) + self.wait(5) + + def on_error(self, error): + _logger.error(error) + pass + + def on_error_phase1(self, error): + self.bal_window.update_all() + a, b, c = error + self.msg_edit_row(self.msg_error(f"Error: {b}")) + _logger.error(f"error phase1: {b}") + button=QPushButton(_("Close")) + button.clicked.connect(self.close) + self.vbox.addWidget(button) + self.resize(self.vbox.sizeHint()+button.sizeHint()*2) + self.repaint() + def on_error_phase2(self, error): + self.bal_window.upade_all() + a, b, c = error + self.msg_edit_row(self.msg_error(f"Error: {b}")) + _logger.error(f"error phase2: {b}") + + def msg_set_checking(self, status="Waiting", row=None): + row = self.check_row if row is None else row + self.check_row = self.msg_set_status(_("Checking your will"), row, status) + + def msg_set_invalidating(self, status=None, row=None): + row = self.inval_row if row is None else row + self.inval_row = self.msg_set_status( + _("Invalidating old will"), self.inval_row, status + ) + + def msg_set_building(self, status=None, row=None,color=None): + row = self.build_row if row is None else row + self.build_row = self.msg_set_status( + "Building your will", self.build_row, status, color + ) + + def msg_set_signing(self, status=None, row=None): + row = self.sign_row if row is None else row + self.sign_row = self.msg_set_status("Signing your will", self.sign_row, status) + + def msg_set_pushing(self, status=None, row=None): + row = self.push_row if row is None else row + self.push_row = self.msg_set_status( + "Broadcasting your will to executors", self.push_row, status + ) + + def msg_set_waiting(self, status=None, row=None): + row = self.wait_row if row is None else row + self.wait_row = self.msg_edit_row(f"Please wait {status}secs", self.wait_row) + + def msg_error(self, e): + return "{}".format(self.COLOR_ERROR, e) + + def msg_ok(self, e="Ok"): + return "{}".format(self.COLOR_OK, e) + + def msg_warning(self, e): + return "{}") + # qlabel=QLabel(label) + # qlabel.setWordWrap(True) + # self.labelsbox.addWidget(qlabel) + + # self.labelsbox.activate() + # self.qwidget.setMinimumSize(self.labelsbox.sizeHint()) + # self.qwidget.adjustSize() + # from PyQt6.QtWidgets import QApplication + # QApplication.processEvents() + # + # self.adjustSize() + def msg_update(self): + full_text = "

".join(self.labels).replace("\n", "
") + self.message_label.setText(full_text) + self.message_label.adjustSize() + # self.setMinimumHeight(len(self.labels)*40) + self.resize(self.sizeHint()) + + def get_text(self): + return self.message_label.text() + + pass + + + +class WillDetailDialog(BalDialog): + def __init__(self, bal_window): + + self.will = bal_window.willitems + self.threshold = bal_window.will_settings["real_threshold"] + + self.bal_window = bal_window + Will.add_willtree(self.will) + super().__init__(bal_window.window, bal_window.bal_plugin) + self.config = bal_window.window.config + self.wallet = bal_window.wallet + self.format_amount = bal_window.window.format_amount + self.base_unit = bal_window.window.base_unit + self.format_fiat_and_units = bal_window.window.format_fiat_and_units + self.fx = bal_window.window.fx + self.format_fee_rate = bal_window.window.format_fee_rate + self.decimal_point = bal_window.window.get_decimal_point() + self.base_unit_name = decimal_point_to_base_unit_name(self.decimal_point) + self.setWindowTitle(_("Will Details")) + self.setMinimumSize(670, 700) + self.vlayout = QVBoxLayout() + w = QWidget() + hlayout = QHBoxLayout(w) + + b = QPushButton(_("Sign")) + b.clicked.connect(self.ask_password_and_sign_transactions) + hlayout.addWidget(b) + + b = QPushButton(_("Broadcast")) + b.clicked.connect(self.broadcast_transactions) + hlayout.addWidget(b) + + b = QPushButton(_("Export")) + b.clicked.connect(self.export_will) + hlayout.addWidget(b) + b = QPushButton(_("Invalidate")) + b.clicked.connect(bal_window.invalidate_will) + hlayout.addWidget(b) + self.vlayout.addWidget(w) + + self.paint_scroll_area() + self.vlayout.addWidget( + QLabel(_("Expiration date: ") + str(BalTimestamp(self.threshold))) + ) + self.vlayout.addWidget(self.scrollbox) + w = QWidget() + hlayout = QHBoxLayout(w) + hlayout.addWidget( + QLabel(_("Valid Txs:") + str(len(Will.only_valid_list(self.will)))) + ) + hlayout.addWidget(QLabel(_("Total Txs:") + str(len(self.will)))) + self.vlayout.addWidget(w) + self.setLayout(self.vlayout) + + def paint_scroll_area(self): + self.scrollbox = QScrollArea() + viewport = QWidget(self.scrollbox) + self.willlayout = QVBoxLayout(viewport) + self.detailsWidget = WillWidget(parent=self) + self.willlayout.addWidget(self.detailsWidget) + + self.scrollbox.setWidget(viewport) + viewport.setLayout(self.willlayout) + + def ask_password_and_sign_transactions(self): + self.bal_window.ask_password_and_sign_transactions(callback=self.update) + self.update() + + def broadcast_transactions(self): + self.bal_window.broadcast_transactions() + self.update() + + def export_will(self): + self.bal_window.export_will() + + def toggle_replaced(self): + self.bal_window.bal_plugin.hide_replaced() + toggle = _("Hide") + if self.bal_window.bal_plugin._hide_replaced: + toggle = _("Unhide") + self.toggle_replace_button.setText(f"{toggle} {_('replaced')}") + self.update() + + def toggle_invalidated(self): + self.bal_window.bal_plugin.hide_invalidated() + toggle = _("Hide") + if self.bal_window.bal_plugin._hide_invalidated: + toggle = _("Unhide") + self.toggle_invalidate_button.setText(_(f"{toggle} {_('invalidated')}")) + self.update() + + def update(self): + self.will = self.bal_window.willitems + pos = self.vlayout.indexOf(self.scrollbox) + self.vlayout.removeWidget(self.scrollbox) + self.paint_scroll_area() + self.vlayout.insertWidget(pos, self.scrollbox) + super().update() + + + +class WillExecutorDialog(BalDialog, MessageBoxMixin): + def __init__(self, bal_window, parent=None): + if not parent: + parent = bal_window.window + BalDialog.__init__(self, parent, bal_window.bal_plugin) + self.bal_plugin = bal_window.bal_plugin + self.config = self.bal_plugin.config + self.bal_window = bal_window + self.willexecutors_list = Willexecutors.get_willexecutors(self.bal_plugin) + + self.setWindowTitle(_("Will-Executor Service List")) + self.setMinimumSize(1000, 200) + + # Lazy import to avoid a dialogs<->lists import cycle. + from .lists import WillExecutorWidget + vbox = QVBoxLayout(self) + self.will_executor_list_widget = WillExecutorWidget( + self, self.bal_window, self.willexecutors_list + ) + vbox.addWidget(self.will_executor_list_widget) + + def is_hidden(self): + return self.isMinimized() or self.isHidden() + + def show_or_hide(self): + if self.is_hidden(): + self.bring_to_top() + else: + self.hide() + + def bring_to_top(self): + self.show() + self.raise_() + + def closeEvent(self, event): + event.accept() + + diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py new file mode 100644 index 0000000..16babb5 --- /dev/null +++ b/bal/gui/qt/lists.py @@ -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) + + diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py new file mode 100644 index 0000000..dc1b8a2 --- /dev/null +++ b/bal/gui/qt/plugin.py @@ -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) + + diff --git a/bal/gui/qt/theme.py b/bal/gui/qt/theme.py new file mode 100644 index 0000000..cea29a4 --- /dev/null +++ b/bal/gui/qt/theme.py @@ -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 diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py new file mode 100644 index 0000000..1e03480 --- /dev/null +++ b/bal/gui/qt/widgets.py @@ -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 = "" + _(str(title)) + f":\t{str(value)}" + return QLabel(label) + + detaillayout.addWidget(qlabel("Locktime", locktime)) + detaillayout.addWidget(qlabel("Creation Time", creation)) + try: + total_fees = ( + self.will[w].tx.input_value() - self.will[w].tx.output_value() + ) + except Exception: + total_fees = -1 + decoded_fees = total_fees + fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3) + fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)" + detaillayout.addWidget(qlabel("Transaction fees:", fees_str)) + detaillayout.addWidget(qlabel("Status:", self.will[w].status)) + detaillayout.addWidget(QLabel("")) + detaillayout.addWidget(QLabel("Heirs:")) + for heir in self.will[w].heirs: + if 'w!ll3x3c"' not in heir: + decoded_amount = Util.decode_amount( + self.will[w].heirs[heir][3], self.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(_("Willexecutor: None: + """Make sure every ancestor package of *pkg_name* is in ``sys.modules``. + + When loaded from a zip as an external plugin, Electrum only executes the + plugin package ``__init__`` and the ``qt`` module. The synthetic root + package (e.g. ``electrum_external_plugins``) and any intermediate packages + may be missing from ``sys.modules``, which breaks relative/absolute + sub-module imports. We backfill them here using this module's own loader + so that ``importlib`` can find sibling sub-packages. + """ + parts = pkg_name.split(".") + # Walk from the top-most ancestor down to (but not including) pkg_name. + for i in range(1, len(parts)): + ancestor = ".".join(parts[:i]) + if ancestor in sys.modules: + continue + try: + importlib.import_module(ancestor) + except Exception: + # The synthetic root (e.g. 'electrum_external_plugins') often has no + # real spec. Create a minimal namespace package stub so that the + # import machinery can still resolve its children. + import types + + module = types.ModuleType(ancestor) + module.__path__ = [] # mark as a (namespace) package + sys.modules[ancestor] = module + + +# The package this module belongs to. Could be 'electrum.plugins.bal' (internal) +# or 'electrum_external_plugins.bal' (external zip), depending on how Electrum +# loaded us. +_PKG = __package__ or "bal" + +_ensure_parent_packages(_PKG) + +# Import the real implementation using the fully-qualified, run-time package +# name so it works regardless of the synthetic prefix Electrum assigned. +_plugin_module = importlib.import_module(_PKG + ".gui.qt.plugin") + +Plugin = _plugin_module.Plugin # noqa: F401 (re-exported for Electrum) diff --git a/bal/wallet_util/README.md b/bal/wallet_util/README.md new file mode 100644 index 0000000..0d8c02f --- /dev/null +++ b/bal/wallet_util/README.md @@ -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 +``` +- If the frozen exe fails because DLLs or JSON files are missing, add those files explicitly with --add-data or --add-binary. +- Test the build on a clean Windows VM to ensure all runtime dependencies are included. + +License and attribution: include your preferred license or attribution details here. diff --git a/bal/wallet_util/bal_wallet_utils.py b/bal/wallet_util/bal_wallet_utils.py new file mode 100755 index 0000000..b81e0a0 --- /dev/null +++ b/bal/wallet_util/bal_wallet_utils.py @@ -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 ") + print("available commands: uninstall, fix") + exit(1) + if not os.path.exists(sys.argv[2]): + print("Error: wallet not found") + exit(1) + command = sys.argv[1] + path = sys.argv[2] + json_wallet, storage = read_wallet(path) + have_to_save = False + if command == "fix": + have_to_save = fix_will_settings_tx_fees(json_wallet) + if command == "uninstall": + have_to_save = uninstall_bal(json_wallet) + if have_to_save: + save(json_wallet, storage) + else: + print("nothing to do") diff --git a/bal/wallet_util/bal_wallet_utils_qt.py b/bal/wallet_util/bal_wallet_utils_qt.py new file mode 100755 index 0000000..4da6b57 --- /dev/null +++ b/bal/wallet_util/bal_wallet_utils_qt.py @@ -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()) diff --git a/build_zip.py b/build_zip.py new file mode 100644 index 0000000..6c06b77 --- /dev/null +++ b/build_zip.py @@ -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) diff --git a/tests/external_zip_test.py b/tests/external_zip_test.py new file mode 100644 index 0000000..c708326 --- /dev/null +++ b/tests/external_zip_test.py @@ -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= \ + python3 tests/external_zip_test.py +""" + +import importlib.util +import sys +import zipimport + + +def main(zip_path: str) -> int: + base = "electrum_external_plugins.bal" + gui = "qt" + dirname = "bal" # directory name inside the zip archive + + def exec_module_from_spec(spec, path): + # Mirrors electrum.plugin.PluginManager.exec_module_from_spec + module = importlib.util.module_from_spec(spec) + sys.modules[path] = module + spec.loader.exec_module(module) + return module + + zi = zipimport.zipimporter(zip_path) + + # Step 1: load the package __init__ as electrum_external_plugins.bal + init_spec = zi.find_spec(dirname) + assert init_spec is not None, "could not find package __init__ inside zip" + exec_module_from_spec(init_spec, base) + + # Step 2: load the qt entry-point as electrum_external_plugins.bal.qt + full = f"{base}.{gui}" + spec = importlib.util.find_spec(full) + assert spec is not None, f"could not find spec for {full!r}" + module = exec_module_from_spec(spec, full) + + # The loader expects a `Plugin` class to be exported. + plugin_cls = getattr(module, "Plugin", None) + assert plugin_cls is not None, "qt module did not export a Plugin class" + + print(f"[OK] external zip loads Plugin -> {plugin_cls!r}") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(__doc__) + sys.exit(2) + sys.exit(main(sys.argv[1])) diff --git a/tests/smoke_test.py b/tests/smoke_test.py new file mode 100644 index 0000000..e21d2a0 --- /dev/null +++ b/tests/smoke_test.py @@ -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 + +where is the dotted module path the plugin is reachable +at, e.g. "electrum.plugins.BAL" (original) or "electrum.plugins.bal" (new). +""" +import importlib +import sys + +PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.BAL" + + +def imp(mod): + return importlib.import_module(f"{PKG}.{mod}") + + +def main(): + # --- Qt must be initialised before importing any gui module --- + from PyQt6.QtWidgets import QApplication # noqa + _app = QApplication.instance() or QApplication([]) + + results = {} + + # 1) Core modules import (these must be GUI-free). + bal = imp_core("bal", "core.plugin_base") + util = imp_core("util", "core.util") + heirs = imp_core("heirs", "core.heirs") + will = imp_core("will", "core.will") + we = imp_core("willexecutors", "core.willexecutors") + + # 2) GUI module imports. + qt = imp_gui() + + # 3) Behaviour checks (pure logic, must be identical across versions). + BalTimestamp = bal.BalTimestamp + assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d" + assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y" + assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str" + + Util = util.Util + assert Util.is_perc("50%") is True + assert Util.is_perc("100") is False + assert Util.text_to_hex("BAL") == "42414c" + assert Util.hex_to_text("42414c") == "BAL" + assert Util.int_locktime(days=1) == 86400 + + # heirs constants must keep the same column layout (very delicate!) + assert heirs.HEIR_ADDRESS == 0 + assert heirs.HEIR_AMOUNT == 1 + assert heirs.HEIR_LOCKTIME == 2 + assert heirs.HEIR_REAL_AMOUNT == 3 + assert heirs.HEIR_DUST_AMOUNT == 4 + + # WillItem default status table must stay intact. + assert will.WillItem.STATUS_DEFAULT["VALID"][1] is True + + # 4) Plugin class wiring. + assert qt.Plugin.__bases__[0] is bal.BalPlugin + for h in ("create_status_bar", "init_menubar", "load_wallet", "close_wallet"): + assert hasattr(qt.Plugin, h), f"missing hook {h}" + + print(f"[OK] smoke test passed for package '{PKG}'") + + +def imp_core(old_name, new_name): + """Import a core module, trying the new layout first then the old flat one.""" + for candidate in (new_name, old_name): + try: + return importlib.import_module(f"{PKG}.{candidate}") + except ModuleNotFoundError: + continue + raise ModuleNotFoundError(f"cannot import {old_name}/{new_name} from {PKG}") + + +def imp_gui(): + for candidate in ("gui.qt.plugin", "qt"): + try: + return importlib.import_module(f"{PKG}.{candidate}") + except ModuleNotFoundError: + continue + raise ModuleNotFoundError(f"cannot import gui module from {PKG}") + + +if __name__ == "__main__": + main()