BAL - Bitcoin After Life Electrum plugin (v0.2.8)
Behavior-preserving refactor of the original BAL plugin with clean separation
of business logic from the PyQt GUI.
Layout:
bal/core/ GUI-free logic (util, plugin_base, heirs, will, willexecutors)
bal/gui/qt/ PyQt6 presentation (theme, common, widgets, calendar, dialogs,
lists, window, plugin)
bal/qt.py Qt entry-point shim (works as internal and external zip plugin)
bal/manifest.json standard-conforming metadata
Tooling:
build_zip.py deterministic, zipimport-friendly archive builder
tests/smoke_test.py imports + behavior regression test
tests/external_zip_test.py reproduces Electrum's external-zip loading
Targets Electrum 4.7.2 + PyQt6. Logic kept byte-identical where possible.
21
bal/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 copronista
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
2
bal/README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# BalPlugin
|
||||
Bitcoin After Life Electrum Plugin
|
||||
1
bal/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
0.2.8
|
||||
37
bal/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""BAL - Bitcoin After Life Electrum plugin.
|
||||
|
||||
Free and decentralized Bitcoin inheritance support for the Electrum wallet.
|
||||
|
||||
This package was reorganized (Approach A: conservative, behavior-preserving)
|
||||
to cleanly separate logic from presentation. The original monolithic plugin
|
||||
mixed the business logic with the PyQt GUI; here the two concerns live in
|
||||
distinct sub-packages:
|
||||
|
||||
bal/
|
||||
core/ GUI-free business logic (importable without Qt)
|
||||
util.py Generic helpers (encoding, validation, ...)
|
||||
plugin_base.py BasePlugin subclass, config, timestamp handling
|
||||
heirs.py Heir list model + transaction building
|
||||
will.py Will / WillItem domain model
|
||||
willexecutors.py Will-executor (dead-man's switch) networking
|
||||
gui/
|
||||
qt/ PyQt6 presentation layer
|
||||
theme.py Colors / status -> color mapping (status_color)
|
||||
common.py Shared imports and small GUI helpers
|
||||
widgets.py Leaf widgets (editors, labels, checkboxes, ...)
|
||||
calendar.py BalCalendar widget
|
||||
dialogs.py Dialog windows (wizard, build-will, detail, ...)
|
||||
lists.py Tree/list views (heirs, preview, will-executors)
|
||||
window.py BalWindow controller (per-wallet GUI state)
|
||||
plugin.py Plugin class wiring Electrum @hooks to the GUI
|
||||
qt.py Thin loader shim re-exporting `Plugin` for Electrum
|
||||
|
||||
Electrum discovers the plugin through ``manifest.json`` and loads the GUI
|
||||
entry point from ``qt.py`` (the shim), which imports the real ``Plugin``
|
||||
from ``gui.qt.plugin``.
|
||||
|
||||
The plugin targets Electrum 4.7.2 (the last stable release exposing
|
||||
``json_db.register_dict``) and PyQt6.
|
||||
"""
|
||||
|
||||
__version__ = "0.2.8"
|
||||
14
bal/bal_resources.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import os
|
||||
|
||||
PLUGIN_DIR = os.path.split(os.path.realpath(__file__))[0]
|
||||
DEFAULT_ICON = "bal32x32.png"
|
||||
DEFAULT_ICON_PATH = "icons"
|
||||
|
||||
|
||||
def icon_path(icon_basename: str = DEFAULT_ICON):
|
||||
path = resource_path(DEFAULT_ICON_PATH, icon_basename)
|
||||
return path
|
||||
|
||||
|
||||
def resource_path(*parts):
|
||||
return os.path.join(PLUGIN_DIR, *parts)
|
||||
21
bal/core/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
bal.core
|
||||
========
|
||||
|
||||
Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin.
|
||||
|
||||
Everything in this sub-package MUST stay completely free of any GUI / Qt
|
||||
imports. The rule of thumb is:
|
||||
|
||||
* ``bal.core`` -> "what the plugin does" (inheritance rules, building
|
||||
and validating transactions, talking to
|
||||
will-executor servers, persistence helpers).
|
||||
* ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views).
|
||||
|
||||
Keeping the two apart is the main motivation behind this rewrite: the original
|
||||
code mixed transaction-building logic and presentation inside a single
|
||||
4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit.
|
||||
|
||||
No behaviour is changed with respect to the original plugin; the code has only
|
||||
been reorganised and documented.
|
||||
"""
|
||||
806
bal/core/heirs.py
Normal file
@@ -0,0 +1,806 @@
|
||||
"""
|
||||
bal.core.heirs
|
||||
==============
|
||||
|
||||
Heir management and inheritance-transaction building.
|
||||
|
||||
This is the heart of the plugin's Bitcoin logic and the most delicate part of
|
||||
the whole codebase, so the implementation below is kept byte-for-byte identical
|
||||
to the original ``heirs.py``; only the dead commented-out imports were removed
|
||||
and documentation was added.
|
||||
|
||||
An *heir* is stored as a small list addressed by the ``HEIR_*`` column
|
||||
constants defined below. ``Heirs`` is a ``dict`` subclass persisted inside the
|
||||
wallet DB under the ``"heirs"`` key.
|
||||
|
||||
The ``prepare_transactions`` / ``Heirs.buildTransactions`` functions turn the
|
||||
heir list plus the wallet UTXOs into a set of time-locked inheritance
|
||||
transactions (optionally including a will-executor fee output).
|
||||
|
||||
Will-executor "heirs" are synthetic entries whose key starts with the
|
||||
``w!ll3x3c"`` marker; they are skipped by most heir comparisons.
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Optional,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
import dns
|
||||
from dns.exception import DNSException
|
||||
from electrum import (
|
||||
bitcoin,
|
||||
constants,
|
||||
dnssec,
|
||||
)
|
||||
from electrum.logging import Logger, get_logger
|
||||
from electrum.transaction import (
|
||||
PartialTransaction,
|
||||
PartialTxInput,
|
||||
PartialTxOutput,
|
||||
TxOutpoint,
|
||||
)
|
||||
from electrum.util import (
|
||||
BitcoinException,
|
||||
bfh,
|
||||
read_json_file,
|
||||
to_string,
|
||||
trigger_callback,
|
||||
write_json_file,
|
||||
)
|
||||
|
||||
from .util import Util
|
||||
from .willexecutors import Willexecutors
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from electrum.simple_config import SimpleConfig
|
||||
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
# Column layout of a stored heir list. These indices are part of the on-disk
|
||||
# wallet format and are relied upon all over the codebase, so they must NEVER
|
||||
# be reordered.
|
||||
HEIR_ADDRESS = 0 # destination Bitcoin address
|
||||
HEIR_AMOUNT = 1 # requested amount (satoshis or "<n>%")
|
||||
HEIR_LOCKTIME = 2 # locktime after which the heir may claim the funds
|
||||
HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed
|
||||
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
|
||||
TRANSACTION_LABEL = "inheritance transaction"
|
||||
|
||||
|
||||
class AliasNotFoundException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def reduce_outputs(in_amount, out_amount, fee, outputs):
|
||||
if in_amount < out_amount:
|
||||
for output in outputs:
|
||||
output.value = math.floor((in_amount - fee) / out_amount * output.value)
|
||||
|
||||
|
||||
def create_op_return_script(data_hex: str) -> bytes:
|
||||
"""Crea scriptpubkey OP_RETURN in bytes"""
|
||||
data = bytes.fromhex(data_hex)
|
||||
|
||||
if len(data) > 80:
|
||||
raise ValueError("OP_RETURN data too big (max 80 bytes)")
|
||||
|
||||
# Costruzione manuale: OP_RETURN + push data
|
||||
if len(data) <= 75:
|
||||
# Formato più comune: OP_RETURN + 1-byte length + data
|
||||
script = b'\x6a' + bytes([len(data)]) + data
|
||||
else:
|
||||
# Per dati più grandi (fino a 80) si usa OP_PUSHDATA1
|
||||
script = b'\x6a\x4c' + bytes([len(data)]) + data
|
||||
|
||||
return script
|
||||
|
||||
def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
||||
available_utxos = sorted(
|
||||
available_utxos,
|
||||
key=lambda x: "{}:{}:{}".format(
|
||||
x.value_sats(), x.prevout.txid, x.prevout.out_idx
|
||||
),
|
||||
)
|
||||
# total_used_utxos = []
|
||||
txsout = {}
|
||||
locktime, _ = Util.get_lowest_locktimes(locktimes)
|
||||
if not locktime:
|
||||
_logger.info("prepare transactions, no locktime")
|
||||
return
|
||||
locktime = locktime[0]
|
||||
|
||||
heirs = locktimes[locktime]
|
||||
true = True
|
||||
while true:
|
||||
true = False
|
||||
fee = fees.get(locktime, 0)
|
||||
out_amount = fee
|
||||
description = ""
|
||||
outputs = []
|
||||
paid_heirs = {}
|
||||
for name, heir in heirs.items():
|
||||
if len(heir) > HEIR_REAL_AMOUNT and "DUST" not in str(
|
||||
heir[HEIR_REAL_AMOUNT]
|
||||
):
|
||||
try:
|
||||
real_amount = heir[HEIR_REAL_AMOUNT]
|
||||
outputs.append(
|
||||
PartialTxOutput.from_address_and_value(
|
||||
heir[HEIR_ADDRESS], real_amount
|
||||
)
|
||||
)
|
||||
out_amount += real_amount
|
||||
description += f"{name}\n"
|
||||
except BitcoinException as e:
|
||||
_logger.info("exception decoding output {} - {}".format(type(e), e))
|
||||
heir[HEIR_REAL_AMOUNT] = e
|
||||
|
||||
except Exception as e:
|
||||
heir[HEIR_REAL_AMOUNT] = e
|
||||
_logger.error(f"error preparing transactions: {e}")
|
||||
pass
|
||||
paid_heirs[name] = heir
|
||||
|
||||
in_amount = 0.0
|
||||
used_utxos = []
|
||||
try:
|
||||
while utxo := available_utxos.pop():
|
||||
value = utxo.value_sats()
|
||||
in_amount += value
|
||||
used_utxos.append(utxo)
|
||||
if in_amount >= out_amount:
|
||||
break
|
||||
|
||||
except IndexError as e:
|
||||
_logger.error(
|
||||
f"error preparing transactions index error {e} {in_amount}, {out_amount}"
|
||||
)
|
||||
pass
|
||||
if int(in_amount) < int(out_amount):
|
||||
_logger.error(
|
||||
"error preparing transactions in_amount < out_amount ({} < {}) "
|
||||
)
|
||||
continue
|
||||
heirsvalue = out_amount
|
||||
change = get_change_output(wallet, in_amount, out_amount, fee)
|
||||
if change:
|
||||
outputs.append(change)
|
||||
for i in range(0, 100):
|
||||
random.shuffle(outputs)
|
||||
|
||||
#op_return_text = "Hello Bal!"
|
||||
|
||||
## Convert text to hex
|
||||
#op_return_hex = op_return_text.encode('utf-8').hex()
|
||||
#op_return_script = create_op_return_script(op_return_hex)
|
||||
#outputs.append(PartialTxOutput(value=0, scriptpubkey=op_return_script))
|
||||
tx = PartialTransaction.from_io(
|
||||
used_utxos,
|
||||
outputs,
|
||||
locktime=Util.parse_locktime_string(locktime, wallet),
|
||||
version=2,
|
||||
)
|
||||
if len(description) > 0:
|
||||
tx.description = description[:-1]
|
||||
else:
|
||||
tx.description = ""
|
||||
tx.heirsvalue = heirsvalue
|
||||
tx.set_rbf(True)
|
||||
tx.remove_signatures()
|
||||
txid = tx.txid()
|
||||
if txid is None:
|
||||
raise Exception(f"txid is none: {tx}")
|
||||
|
||||
tx.heirs = paid_heirs
|
||||
tx.my_locktime = locktime
|
||||
txsout[txid] = tx
|
||||
|
||||
if change:
|
||||
change_idx = tx.get_output_idxs_from_address(change.address)
|
||||
prevout = TxOutpoint(txid=bfh(tx.txid()), out_idx=change_idx.pop())
|
||||
txin = PartialTxInput(prevout=prevout)
|
||||
txin._trusted_value_sats = change.value
|
||||
txin.script_descriptor = change.script_descriptor
|
||||
txin.is_mine = True
|
||||
txin._TxInput__address = change.address
|
||||
txin._TxInput__scriptpubkey = change.scriptpubkey
|
||||
txin._TxInput__value_sats = change.value
|
||||
txin.utxo = tx
|
||||
available_utxos.append(txin)
|
||||
txsout[txid].available_utxos = available_utxos[:]
|
||||
return txsout
|
||||
|
||||
|
||||
def get_utxos_from_inputs(tx_inputs, tx, utxos):
|
||||
for tx_input in tx_inputs:
|
||||
prevoutstr = tx_input.prevout.to_str()
|
||||
utxos[prevoutstr] = utxos.get(prevoutstr, {"input": tx_input, "txs": []})
|
||||
utxos[prevoutstr]["txs"].append(tx)
|
||||
return utxos
|
||||
|
||||
|
||||
# TODO calculate de minimum inputs to be invalidated
|
||||
def invalidate_inheritance_transactions(wallet):
|
||||
# listids = []
|
||||
utxos = {}
|
||||
dtxs = {}
|
||||
for k, v in wallet.get_all_labels().items():
|
||||
tx = None
|
||||
if TRANSACTION_LABEL == v:
|
||||
tx = wallet.adb.get_transaction(k)
|
||||
if tx:
|
||||
dtxs[tx.txid()] = tx
|
||||
get_utxos_from_inputs(tx.inputs(), tx, utxos)
|
||||
|
||||
for key, utxo in utxos.items():
|
||||
txid = key.split(":")[0]
|
||||
if txid in dtxs:
|
||||
for tx in utxo["txs"]:
|
||||
txid = tx.txid()
|
||||
del dtxs[txid]
|
||||
|
||||
utxos = {}
|
||||
for txid, tx in dtxs.items():
|
||||
get_utxos_from_inputs(tx.inputs(), tx, utxos)
|
||||
|
||||
utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
|
||||
|
||||
remaining = {}
|
||||
invalidated = []
|
||||
for key, value in utxos:
|
||||
for tx in value["txs"]:
|
||||
txid = tx.txid()
|
||||
if txid not in invalidated:
|
||||
invalidated.append(tx.txid())
|
||||
remaining[key] = value
|
||||
|
||||
|
||||
def print_transaction(heirs, tx, locktimes, tx_fees):
|
||||
jtx = tx.to_json()
|
||||
print(f"TX: {tx.txid()}\t-\tLocktime: {jtx['locktime']}")
|
||||
print("---")
|
||||
for inp in jtx["inputs"]:
|
||||
print(f"{inp['address']}: {inp['value_sats']}")
|
||||
print("---")
|
||||
for out in jtx["outputs"]:
|
||||
heirname = ""
|
||||
for key in heirs.keys():
|
||||
heir = heirs[key]
|
||||
if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str(
|
||||
jtx["locktime"]
|
||||
):
|
||||
heirname = key
|
||||
print(f"{heirname}\t{out['address']}: {out['value_sats']}")
|
||||
|
||||
print()
|
||||
size = tx.estimated_size()
|
||||
print(
|
||||
"fee: {}\texpected: {}\tsize: {}".format(
|
||||
tx.input_value() - tx.output_value(), size * tx_fees, size
|
||||
)
|
||||
)
|
||||
|
||||
print()
|
||||
try:
|
||||
print(tx.serialize_to_network())
|
||||
except Exception:
|
||||
print("impossible to serialize")
|
||||
print()
|
||||
|
||||
|
||||
def get_change_output(wallet, in_amount, out_amount, fee):
|
||||
change_amount = int(in_amount - out_amount - fee)
|
||||
if change_amount > wallet.dust_threshold():
|
||||
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
||||
out = PartialTxOutput.from_address_and_value(change_addresses[0], change_amount)
|
||||
out.is_change = True
|
||||
return out
|
||||
|
||||
|
||||
class Heirs(dict, Logger):
|
||||
|
||||
def __init__(self, wallet):
|
||||
Logger.__init__(self)
|
||||
self.db = wallet.db
|
||||
self.wallet = wallet
|
||||
d = self.db.get("heirs", {})
|
||||
try:
|
||||
self.update(d)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def invalidate_transactions(self, wallet):
|
||||
invalidate_inheritance_transactions(wallet)
|
||||
|
||||
def save(self):
|
||||
self.db.put("heirs", dict(self))
|
||||
|
||||
def import_file(self, path):
|
||||
data = read_json_file(path)
|
||||
data = Heirs._validate(data)
|
||||
self.update(data)
|
||||
self.save()
|
||||
|
||||
def export_file(self, path):
|
||||
write_json_file(path, self)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
dict.__setitem__(self, key, value)
|
||||
self.save()
|
||||
|
||||
def pop(self, key):
|
||||
if key in self.keys():
|
||||
res = dict.pop(self, key)
|
||||
self.save()
|
||||
return res
|
||||
|
||||
def get_locktimes(self, from_locktime, a=False):
|
||||
locktimes = {}
|
||||
for key in self.keys():
|
||||
locktime = Util.parse_locktime_string(self[key][HEIR_LOCKTIME])
|
||||
if locktime > from_locktime and not a or locktime <= from_locktime and a:
|
||||
locktimes[int(locktime)] = None
|
||||
return list(locktimes.keys())
|
||||
|
||||
def check_locktime(self):
|
||||
return False
|
||||
|
||||
def normalize_perc(
|
||||
self, heir_list, total_balance, relative_balance, wallet, real=False
|
||||
):
|
||||
amount = 0
|
||||
for key, v in heir_list.items():
|
||||
try:
|
||||
column = HEIR_AMOUNT
|
||||
if real:
|
||||
column = HEIR_REAL_AMOUNT
|
||||
if "DUST" in str(v[column]):
|
||||
column = HEIR_DUST_AMOUNT
|
||||
value = int(
|
||||
math.floor(
|
||||
total_balance
|
||||
/ relative_balance
|
||||
* self.amount_to_float(v[column])
|
||||
)
|
||||
)
|
||||
if value > wallet.dust_threshold():
|
||||
heir_list[key].insert(HEIR_REAL_AMOUNT, value)
|
||||
amount += value
|
||||
else:
|
||||
heir_list[key].insert(HEIR_REAL_AMOUNT, f"DUST: {value}")
|
||||
heir_list[key].insert(HEIR_DUST_AMOUNT, value)
|
||||
_logger.info(f"{key}, {value} is dust will be ignored")
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
return amount
|
||||
|
||||
def amount_to_float(self, amount):
|
||||
try:
|
||||
return float(amount)
|
||||
except Exception:
|
||||
try:
|
||||
return float(amount[:-1])
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def fixed_percent_lists_amount(self, from_locktime, dust_threshold, reverse=False):
|
||||
fixed_heirs = {}
|
||||
fixed_amount = 0.0
|
||||
percent_heirs = {}
|
||||
percent_amount = 0.0
|
||||
fixed_amount_with_dust = 0.0
|
||||
for key in self.keys():
|
||||
try:
|
||||
cmp = (
|
||||
Util.parse_locktime_string(self[key][HEIR_LOCKTIME]) - from_locktime
|
||||
)
|
||||
if cmp <= 0:
|
||||
_logger.debug(
|
||||
"cmp < 0 {} {} {} {}".format(
|
||||
cmp, key, self[key][HEIR_LOCKTIME], from_locktime
|
||||
)
|
||||
)
|
||||
continue
|
||||
if Util.is_perc(self[key][HEIR_AMOUNT]):
|
||||
percent_amount += float(self[key][HEIR_AMOUNT][:-1])
|
||||
percent_heirs[key] = list(self[key])
|
||||
else:
|
||||
heir_amount = int(math.floor(float(self[key][HEIR_AMOUNT])))
|
||||
fixed_amount_with_dust += heir_amount
|
||||
fixed_heirs[key] = list(self[key])
|
||||
if heir_amount > dust_threshold:
|
||||
fixed_amount += heir_amount
|
||||
fixed_heirs[key].insert(HEIR_REAL_AMOUNT, heir_amount)
|
||||
else:
|
||||
fixed_heirs[key] = list(self[key])
|
||||
fixed_heirs[key].insert(
|
||||
HEIR_REAL_AMOUNT, f"DUST: {heir_amount}"
|
||||
)
|
||||
fixed_heirs[key].insert(HEIR_DUST_AMOUNT, heir_amount)
|
||||
except Exception as e:
|
||||
_logger.error(e)
|
||||
return (
|
||||
fixed_heirs,
|
||||
fixed_amount,
|
||||
percent_heirs,
|
||||
percent_amount,
|
||||
fixed_amount_with_dust,
|
||||
)
|
||||
|
||||
def prepare_lists(
|
||||
self, balance, total_fees, wallet, willexecutor=False, from_locktime=0
|
||||
):
|
||||
if balance<total_fees or balance < wallet.dust_threshold():
|
||||
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
|
||||
willexecutors_amount = 0
|
||||
willexecutors = {}
|
||||
heir_list = {}
|
||||
onlyfixed = False
|
||||
newbalance = balance - total_fees
|
||||
locktimes = self.get_locktimes(from_locktime)
|
||||
if willexecutor:
|
||||
for locktime in locktimes:
|
||||
if int(Util.int_locktime(locktime)) > int(from_locktime):
|
||||
try:
|
||||
base_fee = int(willexecutor["base_fee"])
|
||||
willexecutors_amount += base_fee
|
||||
h = [None] * 4
|
||||
h[HEIR_AMOUNT] = base_fee
|
||||
h[HEIR_REAL_AMOUNT] = base_fee
|
||||
h[HEIR_LOCKTIME] = locktime
|
||||
h[HEIR_ADDRESS] = willexecutor["address"]
|
||||
willexecutors[
|
||||
'w!ll3x3c"' + willexecutor["url"] + '"' + str(locktime)
|
||||
] = h
|
||||
except Exception:
|
||||
return [], False
|
||||
else:
|
||||
_logger.error(
|
||||
f"heir excluded from will locktime({locktime}){Util.int_locktime(locktime)}<minimum{from_locktime}"
|
||||
),
|
||||
heir_list.update(willexecutors)
|
||||
newbalance -= willexecutors_amount
|
||||
if newbalance < 0:
|
||||
raise WillExecutorFeeException(willexecutor)
|
||||
(
|
||||
fixed_heirs,
|
||||
fixed_amount,
|
||||
percent_heirs,
|
||||
percent_amount,
|
||||
fixed_amount_with_dust,
|
||||
) = self.fixed_percent_lists_amount(from_locktime, wallet.dust_threshold())
|
||||
if fixed_amount > newbalance:
|
||||
fixed_amount = self.normalize_perc(
|
||||
fixed_heirs, newbalance, fixed_amount, wallet
|
||||
)
|
||||
onlyfixed = True
|
||||
|
||||
heir_list.update(fixed_heirs)
|
||||
|
||||
newbalance -= fixed_amount
|
||||
if newbalance > 0:
|
||||
perc_amount = self.normalize_perc(
|
||||
percent_heirs, newbalance, percent_amount, wallet
|
||||
)
|
||||
newbalance -= perc_amount
|
||||
heir_list.update(percent_heirs)
|
||||
if newbalance > 0:
|
||||
newbalance += fixed_amount
|
||||
fixed_amount = self.normalize_perc(
|
||||
fixed_heirs, newbalance, fixed_amount_with_dust, wallet, real=True
|
||||
)
|
||||
newbalance -= fixed_amount
|
||||
heir_list.update(fixed_heirs)
|
||||
|
||||
heir_list = sorted(
|
||||
heir_list.items(),
|
||||
key=lambda item: Util.parse_locktime_string(item[1][HEIR_LOCKTIME], wallet),
|
||||
)
|
||||
|
||||
locktimes = {}
|
||||
for key, value in heir_list:
|
||||
locktime = Util.parse_locktime_string(value[HEIR_LOCKTIME])
|
||||
if locktime not in locktimes:
|
||||
locktimes[locktime] = {key: value}
|
||||
else:
|
||||
locktimes[locktime][key] = value
|
||||
return locktimes, onlyfixed
|
||||
|
||||
def is_perc(self, key):
|
||||
return Util.is_perc(self[key][HEIR_AMOUNT])
|
||||
|
||||
def buildTransactions(
|
||||
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
||||
):
|
||||
Heirs._validate(self)
|
||||
if len(self) <= 0:
|
||||
_logger.info("while building transactions there was no heirs")
|
||||
return
|
||||
balance = 0.0
|
||||
len_utxo_set = 0
|
||||
available_utxos = []
|
||||
if not utxos:
|
||||
utxos = wallet.get_utxos()
|
||||
willexecutors = Willexecutors.get_willexecutors(bal_plugin) or {}
|
||||
self.decimal_point = bal_plugin.get_decimal_point()
|
||||
no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get()
|
||||
for utxo in utxos:
|
||||
if utxo.value_sats() > 0 * tx_fees:
|
||||
balance += utxo.value_sats()
|
||||
len_utxo_set += 1
|
||||
available_utxos.append(utxo)
|
||||
if len_utxo_set == 0:
|
||||
_logger.info("no usable utxos")
|
||||
return
|
||||
j = -2
|
||||
willexecutorsitems = list(willexecutors.items())
|
||||
willexecutorslen = len(willexecutorsitems)
|
||||
alltxs = {}
|
||||
while True:
|
||||
j += 1
|
||||
if j >= willexecutorslen:
|
||||
break
|
||||
elif 0 <= j:
|
||||
url, willexecutor = willexecutorsitems[j]
|
||||
if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold():
|
||||
continue
|
||||
else:
|
||||
willexecutor["url"] = url
|
||||
elif j == -1:
|
||||
if not no_willexecutors:
|
||||
continue
|
||||
url = willexecutor = False
|
||||
else:
|
||||
break
|
||||
fees = {}
|
||||
i = 0
|
||||
while i < 10:
|
||||
txs = {}
|
||||
redo = False
|
||||
i += 1
|
||||
total_fees = 0
|
||||
for fee in fees:
|
||||
total_fees += int(fees[fee])
|
||||
# newbalance = balance
|
||||
try:
|
||||
locktimes, onlyfixed = self.prepare_lists(
|
||||
balance, total_fees, wallet, willexecutor, from_locktime
|
||||
)
|
||||
except WillExecutorFeeException:
|
||||
i = 10
|
||||
continue
|
||||
if locktimes:
|
||||
try:
|
||||
txs = prepare_transactions(
|
||||
locktimes, available_utxos[:], fees, wallet
|
||||
)
|
||||
if not txs:
|
||||
return {}
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
f"build transactions: error preparing transactions: {e}"
|
||||
)
|
||||
try:
|
||||
if "w!ll3x3c" in e.heirname:
|
||||
Willexecutors.is_selected(
|
||||
e.heirname[len("w!ll3x3c") :], False
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
raise e
|
||||
total_fees = 0
|
||||
total_fees_real = 0
|
||||
total_in = 0
|
||||
for txid, tx in txs.items():
|
||||
tx.willexecutor = willexecutor
|
||||
fee = tx.estimated_size() * tx_fees
|
||||
txs[txid].tx_fees = tx_fees
|
||||
total_fees += fee
|
||||
total_fees_real += tx.get_fee()
|
||||
total_in += tx.input_value()
|
||||
rfee = tx.input_value() - tx.output_value()
|
||||
if rfee < fee or rfee > fee + wallet.dust_threshold():
|
||||
redo = True
|
||||
# oldfees = fees.get(tx.my_locktime, 0)
|
||||
fees[tx.my_locktime] = fee
|
||||
|
||||
if balance - total_in > wallet.dust_threshold():
|
||||
redo = True
|
||||
if not redo:
|
||||
break
|
||||
if i >= 10:
|
||||
break
|
||||
else:
|
||||
_logger.info(
|
||||
f"no locktimes for willexecutor {willexecutor} skipped"
|
||||
)
|
||||
break
|
||||
alltxs.update(txs)
|
||||
|
||||
return alltxs
|
||||
|
||||
def get_transactions(
|
||||
self, bal_plugin, wallet, tx_fees, utxos=None, from_locktime=0
|
||||
):
|
||||
txs = self.buildTransactions(bal_plugin, wallet, tx_fees, utxos, from_locktime)
|
||||
if txs:
|
||||
temp_txs = {}
|
||||
for txid in txs:
|
||||
if txs[txid].available_utxos:
|
||||
temp_txs.update(
|
||||
self.get_transactions(
|
||||
bal_plugin,
|
||||
wallet,
|
||||
tx_fees,
|
||||
txs[txid].available_utxos,
|
||||
txs[txid].locktime,
|
||||
)
|
||||
)
|
||||
txs.update(temp_txs)
|
||||
return txs
|
||||
|
||||
def resolve(self, k):
|
||||
if bitcoin.is_address(k):
|
||||
return {"address": k, "type": "address"}
|
||||
if k in self.keys():
|
||||
_type, addr = self[k]
|
||||
if _type == "address":
|
||||
return {"address": addr, "type": "heir"}
|
||||
if openalias := self.resolve_openalias(k):
|
||||
return openalias
|
||||
raise AliasNotFoundException("Invalid Bitcoin address or alias", k)
|
||||
|
||||
@classmethod
|
||||
def resolve_openalias(cls, url: str) -> Dict[str, Any]:
|
||||
out = cls._resolve_openalias(url)
|
||||
if out:
|
||||
address, name, validated = out
|
||||
return {
|
||||
"address": address,
|
||||
"name": name,
|
||||
"type": "openalias",
|
||||
"validated": validated,
|
||||
}
|
||||
return {}
|
||||
|
||||
def by_name(self, name):
|
||||
for k in self.keys():
|
||||
_type, addr = self[k]
|
||||
if addr.casefold() == name.casefold():
|
||||
return {"name": addr, "type": _type, "address": k}
|
||||
return None
|
||||
|
||||
def fetch_openalias(self, config: "SimpleConfig"):
|
||||
self.alias_info = None
|
||||
alias = config.OPENALIAS_ID
|
||||
if alias:
|
||||
alias = str(alias)
|
||||
|
||||
def f():
|
||||
self.alias_info = self._resolve_openalias(alias)
|
||||
trigger_callback("alias_received")
|
||||
|
||||
t = threading.Thread(target=f)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
@classmethod
|
||||
def _resolve_openalias(cls, url: str) -> Optional[Tuple[str, str, bool]]:
|
||||
# support email-style addresses, per the OA standard
|
||||
url = url.replace("@", ".")
|
||||
try:
|
||||
records, validated = dnssec.query(url, dns.rdatatype.TXT)
|
||||
except DNSException as e:
|
||||
_logger.info(f"Error resolving openalias: {repr(e)}")
|
||||
return None
|
||||
prefix = "btc"
|
||||
for record in records:
|
||||
string = to_string(record.strings[0], "utf8")
|
||||
if string.startswith("oa1:" + prefix):
|
||||
address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)")
|
||||
name = cls.find_regex(string, r"recipient_name=([^;]+)")
|
||||
if not name:
|
||||
name = address
|
||||
if not address:
|
||||
continue
|
||||
return address, name, validated
|
||||
|
||||
@staticmethod
|
||||
def find_regex(haystack, needle):
|
||||
regex = re.compile(needle)
|
||||
try:
|
||||
return regex.search(haystack).groups()[0]
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
def validate_address(address):
|
||||
if not bitcoin.is_address(address, net=constants.net):
|
||||
raise NotAnAddress(f"not an address,{address}")
|
||||
return address
|
||||
|
||||
def validate_amount(amount):
|
||||
try:
|
||||
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
|
||||
if famount <= 0.00000001:
|
||||
raise AmountNotValid(f"amount have to be positive {famount} < 0")
|
||||
except Exception as e:
|
||||
raise AmountNotValid(f"amount not properly formatted, {e}")
|
||||
return amount
|
||||
|
||||
def validate_locktime(locktime, timestamp_to_check=False):
|
||||
try:
|
||||
if timestamp_to_check:
|
||||
if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
|
||||
raise HeirExpiredException()
|
||||
except Exception as e:
|
||||
raise LocktimeNotValid(f"locktime string not properly formatted, {e}")
|
||||
return locktime
|
||||
|
||||
def validate_heir(k, v, timestamp_to_check=False):
|
||||
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
||||
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
||||
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
||||
return (address, amount, locktime)
|
||||
|
||||
def _validate(data, timestamp_to_check=False):
|
||||
|
||||
for k, v in list(data.items()):
|
||||
if k == "heirs":
|
||||
return Heirs._validate(v, timestamp_to_check)
|
||||
try:
|
||||
Heirs.validate_heir(k, v, timestamp_to_check)
|
||||
except Exception as e:
|
||||
_logger.info(f"exception heir removed {e}")
|
||||
data.pop(k)
|
||||
return data
|
||||
|
||||
|
||||
class NotAnAddress(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class AmountNotValid(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class LocktimeNotValid(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class HeirExpiredException(LocktimeNotValid):
|
||||
pass
|
||||
|
||||
|
||||
class HeirAmountIsDustException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoHeirsException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WillExecutorFeeException(Exception):
|
||||
def __init__(self, willexecutor):
|
||||
self.willexecutor = willexecutor
|
||||
|
||||
def __str__(self):
|
||||
return "WillExecutorFeeException: {} fee:{}".format(
|
||||
self.willexecutor["url"], self.willexecutor["base_fee"]
|
||||
)
|
||||
class BalanceTooLowException(Exception):
|
||||
def __init__(self,balance, dust_threshold, fees):
|
||||
self.balance=balance
|
||||
self.dust_threshold = dust_threshold
|
||||
self.fees = fees
|
||||
def __str__(self):
|
||||
return f"Balance too low, balance: {self.balance}, dust threshold: {self.dust_threshold}, fees: {self.fees}"
|
||||
351
bal/core/plugin_base.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
bal.core.plugin_base
|
||||
=====================
|
||||
|
||||
GUI-agnostic foundation of the plugin.
|
||||
|
||||
It contains:
|
||||
* :class:`BalConfig` - a thin typed wrapper around an Electrum config key
|
||||
with a default value.
|
||||
* :class:`BalPlugin` - the base plugin class (extends Electrum's
|
||||
``BasePlugin``) holding every configuration option
|
||||
and the default "will settings". The Qt-specific
|
||||
``Plugin`` subclass lives in ``bal.gui.qt.plugin``.
|
||||
* :class:`BalTimestamp`- helper to convert between relative durations
|
||||
(``"30d"``, ``"1y"``) and absolute timestamps.
|
||||
|
||||
It also registers the three custom persisted dictionaries (``heirs``,
|
||||
``will`` and ``will_settings``) with Electrum's JSON database so they are
|
||||
serialised together with the wallet file.
|
||||
|
||||
This module performs **no** GUI work and imports nothing from PyQt / electrum.gui.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from electrum import constants, json_db
|
||||
from electrum.logging import get_logger
|
||||
from electrum.plugin import BasePlugin
|
||||
from electrum.transaction import tx_from_any
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Wallet-DB registration
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Electrum needs to know how to (de)serialise the custom dictionaries the
|
||||
# plugin stores inside the wallet file. ``register_dict`` associates a key
|
||||
# name with a conversion callable applied to each value when the wallet is
|
||||
# loaded. ``will`` values run through ``get_will`` so the stored transaction
|
||||
# hex is turned back into a ``Transaction`` object.
|
||||
def get_will(x):
|
||||
"""Deserialise a stored will entry, rebuilding its ``tx`` object."""
|
||||
try:
|
||||
x["tx"] = tx_from_any(x["tx"])
|
||||
except Exception as e:
|
||||
raise e
|
||||
return x
|
||||
|
||||
|
||||
json_db.register_dict("heirs", tuple, None)
|
||||
json_db.register_dict("will", dict, None)
|
||||
json_db.register_dict("will_settings", lambda x: x, None)
|
||||
|
||||
|
||||
class BalConfig:
|
||||
"""Typed accessor for a single Electrum configuration key.
|
||||
|
||||
Wraps ``config.get`` / ``config.set_key`` and supplies a default value
|
||||
when the key is missing.
|
||||
"""
|
||||
|
||||
def __init__(self, config, name, default):
|
||||
self.config = config
|
||||
self.name = name
|
||||
self.default = default
|
||||
|
||||
def get(self, default=None):
|
||||
"""Return the stored value, falling back to ``default`` then ``self.default``."""
|
||||
v = self.config.get(self.name, default)
|
||||
if v is None:
|
||||
if default is not None:
|
||||
v = default
|
||||
else:
|
||||
v = self.default
|
||||
return v
|
||||
|
||||
def set(self, value, save=True):
|
||||
"""Persist ``value`` for this key."""
|
||||
self.config.set_key(self.name, value, save=save)
|
||||
|
||||
|
||||
class BalPlugin(BasePlugin):
|
||||
"""Base plugin: holds configuration and default inheritance settings.
|
||||
|
||||
The GUI layer subclasses this in ``bal.gui.qt.plugin.Plugin`` and adds the
|
||||
Electrum ``@hook`` methods. Keeping the configuration here means the CLI
|
||||
layer (or unit tests) can use the plugin logic without importing Qt.
|
||||
"""
|
||||
|
||||
_version = None
|
||||
__version__ = "0.2.8" # AUTOMATICALLY GENERATED DO NOT EDIT
|
||||
|
||||
# Command used to open an .ics calendar file, per operating system.
|
||||
default_app = {
|
||||
"Linux": "xdg-open",
|
||||
"Windows": "cmd /c start",
|
||||
"Darwin": "open",
|
||||
}
|
||||
|
||||
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
|
||||
chainname = (
|
||||
constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
|
||||
)
|
||||
|
||||
# Default geometry hint for some dialogs (kept from the original code).
|
||||
SIZE = (159, 97)
|
||||
|
||||
def version(self):
|
||||
"""Return the plugin version, read once from the ``VERSION`` file."""
|
||||
if not self._version:
|
||||
try:
|
||||
f = ""
|
||||
with open("{}/VERSION".format(self.plugin_dir), "r") as fi:
|
||||
f = str(fi.read())
|
||||
self._version = f.strip()
|
||||
except Exception as e:
|
||||
_logger.error(f"failed to get version: {e}")
|
||||
self._version = "unknown"
|
||||
return self._version
|
||||
|
||||
def __init__(self, parent, config, name):
|
||||
self.logger = get_logger(__name__)
|
||||
BasePlugin.__init__(self, parent, config, name)
|
||||
|
||||
# Base directory for plugin data inside the Electrum data dir.
|
||||
self.base_dir = os.path.join(config.electrum_path(), "bal")
|
||||
self.plugin_dir = os.path.split(os.path.realpath(__file__))[0]
|
||||
|
||||
# Make the plugin importable when loaded from a zip (legacy behaviour:
|
||||
# the parent directory of this file is added to ``sys.path``).
|
||||
zipfile = "/".join(self.plugin_dir.split("/")[:-1])
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, zipfile)
|
||||
|
||||
self.parent = parent
|
||||
self.config = config
|
||||
self.name = name
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Configuration options (all persisted via Electrum's config).
|
||||
# ---------------------------------------------------------------- #
|
||||
self.ASK_BROADCAST = BalConfig(config, "bal_ask_broadcast", True)
|
||||
self.BROADCAST = BalConfig(config, "bal_broadcast", True)
|
||||
self.LOCKTIME_TIME = BalConfig(config, "bal_locktime_time", 90)
|
||||
self.LOCKTIME_BLOCKS = BalConfig(config, "bal_locktime_blocks", 144 * 90)
|
||||
self.LOCKTIMEDELTA_TIME = BalConfig(config, "bal_locktimedelta_time", 7)
|
||||
self.LOCKTIMEDELTA_BLOCKS = BalConfig(
|
||||
config, "bal_locktimedelta_blocks", 144 * 7
|
||||
)
|
||||
self.ENABLE_MULTIVERSE = BalConfig(config, "bal_enable_multiverse", False)
|
||||
self.TX_FEES = BalConfig(config, "bal_tx_fees", 100)
|
||||
self.INVALIDATE = BalConfig(config, "bal_invalidate", True)
|
||||
self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True)
|
||||
self.PREVIEW = BalConfig(config, "bal_preview", True)
|
||||
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
|
||||
|
||||
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)
|
||||
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
|
||||
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
|
||||
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
|
||||
self.FIRST_EXECUTION = BalConfig(config, "bal_first_execution", True)
|
||||
self.WELIST_SERVER = BalConfig(
|
||||
config, "bal_welist_server", "https://welist.bitcoin-after.life/"
|
||||
)
|
||||
self.EVENT_DESCRIPTION = BalConfig(
|
||||
config,
|
||||
"bal_event_description",
|
||||
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
|
||||
)
|
||||
self.EVENT_SUMMARY = BalConfig(
|
||||
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
||||
)
|
||||
|
||||
# Default will-executor servers, keyed by network.
|
||||
self.WILLEXECUTORS = BalConfig(
|
||||
config,
|
||||
"bal_willexecutors",
|
||||
{
|
||||
"mainnet": {
|
||||
"https://we.bitcoin-after.life": {
|
||||
"base_fee": 100000,
|
||||
"status": "New",
|
||||
"info": "Bitcoin After Life Will Executor",
|
||||
"address": "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf",
|
||||
"selected": True,
|
||||
}
|
||||
},
|
||||
"testnet": {
|
||||
"https://we.bitcoin-after.life": {
|
||||
"base_fee": 100000,
|
||||
"status": "New",
|
||||
"info": "Bitcoin After Life Will Executor",
|
||||
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||
"selected": True,
|
||||
}
|
||||
},
|
||||
"testnet4": {
|
||||
"https://we.bitcoin-after.life": {
|
||||
"base_fee": 100000,
|
||||
"status": "New",
|
||||
"info": "Bitcoin After Life Will Executor",
|
||||
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||
"selected": True,
|
||||
}
|
||||
},
|
||||
"regtest": {
|
||||
"https://we.bitcoin-after.life": {
|
||||
"base_fee": 100000,
|
||||
"status": "New",
|
||||
"info": "Bitcoin After Life Will Executor",
|
||||
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||
"selected": True,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
self.WILL_SETTINGS = BalConfig(
|
||||
config,
|
||||
"bal_will_settings",
|
||||
BalPlugin.default_will_settings(),
|
||||
)
|
||||
|
||||
self.system = platform.system()
|
||||
self.CALENDAR_APP = BalConfig(
|
||||
config, "bal_open_app", self.default_app.get(self.system, "")
|
||||
)
|
||||
|
||||
# Cached toggles used by the GUI list filters.
|
||||
self._hide_invalidated = self.HIDE_INVALIDATED.get()
|
||||
self._hide_replaced = self.HIDE_REPLACED.get()
|
||||
|
||||
def resource_path(self, *parts):
|
||||
"""Absolute path to a file bundled inside the plugin directory."""
|
||||
return os.path.join(self.plugin_dir, *parts)
|
||||
|
||||
def hide_invalidated(self):
|
||||
"""Toggle (and persist) the "hide invalidated transactions" filter."""
|
||||
self._hide_invalidated = not self._hide_invalidated
|
||||
self.HIDE_INVALIDATED.set(self._hide_invalidated)
|
||||
|
||||
def hide_replaced(self):
|
||||
"""Toggle (and persist) the "hide replaced transactions" filter."""
|
||||
self._hide_replaced = not self._hide_replaced
|
||||
self.HIDE_REPLACED.set(self._hide_replaced)
|
||||
|
||||
def validate_will_settings(self, will_settings):
|
||||
"""Fill in any missing will-setting with its default value."""
|
||||
defaults = BalPlugin.default_will_settings()
|
||||
if not will_settings:
|
||||
will_settings = []
|
||||
if int(will_settings.get("baltx_fees", 0)) < 1:
|
||||
will_settings["baltx_fees"] = defaults['baltx_fees']
|
||||
if not will_settings.get("threshold"):
|
||||
will_settings["threshold"] = defaults['threshold']
|
||||
if not will_settings.get("locktime"):
|
||||
will_settings["locktime"] = defaults['locktime']
|
||||
return will_settings
|
||||
|
||||
@staticmethod
|
||||
def default_will_settings():
|
||||
"""Default will settings: a fee rate plus absolute threshold/locktime."""
|
||||
will_settings = {"baltx_fees": 100}
|
||||
will_settings.update(BalPlugin.default_will_settings_absolute())
|
||||
return will_settings
|
||||
|
||||
@staticmethod
|
||||
def default_will_settings_absolute():
|
||||
"""Convert the default relative dates into absolute timestamps (from today)."""
|
||||
relative_dates = BalPlugin.default_will_settings_relative()
|
||||
today = date.today()
|
||||
dt = datetime(today.year, today.month, today.day, 0, 0, 0)
|
||||
threshold = (
|
||||
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
|
||||
).timestamp()
|
||||
locktime = (
|
||||
dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days())
|
||||
).timestamp()
|
||||
return {"threshold": threshold, "locktime": locktime}
|
||||
|
||||
@staticmethod
|
||||
def default_will_settings_relative():
|
||||
"""Default relative dates: 30 days threshold, 1 year locktime."""
|
||||
return {"threshold": "30d", "locktime": "1y"}
|
||||
|
||||
|
||||
class BalTimestamp:
|
||||
"""Parse and convert relative durations / absolute timestamps.
|
||||
|
||||
A value may be:
|
||||
* ``"<n>y"`` -> ``n`` years (unit ``"y"``)
|
||||
* ``"<n>d"`` -> ``n`` days (unit ``"d"``)
|
||||
* an integer -> an absolute UNIX timestamp (``unit is None``)
|
||||
"""
|
||||
|
||||
value = None
|
||||
unit = None
|
||||
|
||||
def __init__(self, value):
|
||||
str_value = str(value)
|
||||
if str_value and str_value[-1].lower() in ("y", "d"):
|
||||
self.value = int(str_value[:-1])
|
||||
self.unit = str_value[-1]
|
||||
else:
|
||||
try:
|
||||
self.value = int(value)
|
||||
except Exception as _e:
|
||||
self.value = 1
|
||||
self.unit = None
|
||||
|
||||
def duration_to_days(self):
|
||||
"""Return the duration expressed in days (years are ``*365``)."""
|
||||
return self.value * 365 if self.unit == 'y' else self.value
|
||||
|
||||
def to_date(self, from_date=None, reverse=False):
|
||||
"""Resolve to a ``datetime``.
|
||||
|
||||
For absolute values the stored timestamp is returned; for relative ones
|
||||
the duration is added to (or, if ``reverse``, subtracted from)
|
||||
``from_date`` (defaulting to *now*), normalised to midnight.
|
||||
"""
|
||||
if self.unit is None:
|
||||
return datetime.fromtimestamp(self.value)
|
||||
else:
|
||||
if from_date is None:
|
||||
from_date = datetime.now()
|
||||
if isinstance(from_date, (int, float)):
|
||||
from_date = datetime.fromtimestamp(from_date)
|
||||
reverse = 1 if not reverse else -1
|
||||
return (
|
||||
from_date + (reverse * timedelta(days=self.duration_to_days()))
|
||||
).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
def to_timestamp(self, from_date=None, reverse=False):
|
||||
"""Same as :meth:`to_date` but returns a UNIX timestamp."""
|
||||
return self.to_date(from_date, reverse).timestamp()
|
||||
|
||||
def __str__(self):
|
||||
if self.unit is None:
|
||||
return datetime.fromtimestamp(self.value).isoformat()
|
||||
else:
|
||||
return f"{self.value}{self.unit}"
|
||||
|
||||
def __repr__(self):
|
||||
if self.unit is None:
|
||||
return datetime.fromtimestamp(self.value).to_date().timestamp()
|
||||
else:
|
||||
return f"{self.value}{self.unit}"
|
||||
614
bal/core/util.py
Normal file
@@ -0,0 +1,614 @@
|
||||
"""
|
||||
bal.core.util
|
||||
=============
|
||||
|
||||
Small, stateless helper functions shared across the whole plugin.
|
||||
|
||||
This module is intentionally GUI-free: it only deals with locktimes, amount
|
||||
encoding/decoding, and comparing transactions / inputs / outputs / heirs.
|
||||
|
||||
Historical note
|
||||
---------------
|
||||
The original ``util.py`` also contained a set of ``print_*`` debugging helpers
|
||||
(``print_var``, ``print_utxo``, ``print_prevout``) that dumped objects to
|
||||
``stdout``. Those were development-only scaffolding, never called by the
|
||||
plugin logic, so they have been removed during the rewrite. No behavioural
|
||||
function has been changed: every method below is logically identical to the
|
||||
original implementation.
|
||||
"""
|
||||
|
||||
import bisect
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from electrum.transaction import PartialTxOutput
|
||||
|
||||
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
|
||||
# interpreted as a *block height*, otherwise it is interpreted as a *UNIX
|
||||
# timestamp*. This single constant drives most of the locktime handling below.
|
||||
LOCKTIME_THRESHOLD = 500000000
|
||||
|
||||
|
||||
class Util:
|
||||
"""Namespace of static helpers (kept as a class to preserve the original
|
||||
``Util.method(...)`` call sites used throughout the plugin)."""
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Locktime helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def locktime_to_str(locktime):
|
||||
"""Render a locktime for display.
|
||||
|
||||
If the value looks like a timestamp (``> LOCKTIME_THRESHOLD``) it is
|
||||
formatted as an ISO date string; otherwise it is returned as-is.
|
||||
"""
|
||||
try:
|
||||
locktime = int(locktime)
|
||||
if locktime > LOCKTIME_THRESHOLD:
|
||||
dt = datetime.fromtimestamp(locktime).isoformat()
|
||||
return dt
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
return str(locktime)
|
||||
|
||||
@staticmethod
|
||||
def str_to_locktime(locktime):
|
||||
"""Parse a user-entered locktime string into its stored form.
|
||||
|
||||
Relative values keep their suffix (``"30d"``, ``"1y"``, ``"144b"``);
|
||||
absolute ISO dates are converted to an integer UNIX timestamp.
|
||||
"""
|
||||
try:
|
||||
if locktime[-1] in ("y", "d", "b"):
|
||||
return locktime
|
||||
else:
|
||||
return int(locktime)
|
||||
except Exception:
|
||||
pass
|
||||
dt_object = datetime.fromisoformat(locktime)
|
||||
timestamp = dt_object.timestamp()
|
||||
return int(timestamp)
|
||||
|
||||
@staticmethod
|
||||
def parse_locktime_string(locktime, w=None):
|
||||
"""Resolve a (possibly relative) locktime string into a concrete int.
|
||||
|
||||
Supported forms:
|
||||
* plain int / timestamp -> returned unchanged
|
||||
* ``"<n>y"`` -> n years from now (as a timestamp)
|
||||
* ``"<n>d"`` -> n days from now (as a timestamp)
|
||||
* ``"<n>b"`` -> current block height + n (needs wallet
|
||||
``w`` to read the chain height)
|
||||
"""
|
||||
try:
|
||||
return int(locktime)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
now = datetime.now()
|
||||
if locktime[-1] == "y":
|
||||
locktime = str(int(locktime[:-1]) * 365) + "d"
|
||||
if locktime[-1] == "d":
|
||||
return int(
|
||||
(now + timedelta(days=int(locktime[:-1])))
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.timestamp()
|
||||
)
|
||||
if locktime[-1] == "b":
|
||||
locktime = int(locktime[:-1])
|
||||
height = 0
|
||||
if w:
|
||||
height = Util.get_current_height(w.network)
|
||||
locktime += int(height)
|
||||
return int(locktime)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def int_locktime(seconds=0, minutes=0, hours=0, days=0, blocks=0):
|
||||
"""Convert a human duration into seconds (blocks counted as 600s each)."""
|
||||
return int(
|
||||
seconds
|
||||
+ minutes * 60
|
||||
+ hours * 60 * 60
|
||||
+ days * 60 * 60 * 24
|
||||
+ blocks * 600
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Amount helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def encode_amount(amount, decimal_point):
|
||||
"""Convert a displayed BTC amount into integer satoshis.
|
||||
|
||||
Percentage amounts (e.g. ``"50%"``) are passed through unchanged, since
|
||||
they are resolved later against the wallet balance.
|
||||
"""
|
||||
if Util.is_perc(amount):
|
||||
return amount
|
||||
else:
|
||||
try:
|
||||
return int(float(amount) * pow(10, decimal_point))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def decode_amount(amount, decimal_point):
|
||||
"""Inverse of :meth:`encode_amount`: satoshis -> displayed string."""
|
||||
if Util.is_perc(amount):
|
||||
return amount
|
||||
else:
|
||||
basestr = "{{:0.{}f}}".format(decimal_point)
|
||||
try:
|
||||
return basestr.format(float(amount) / pow(10, decimal_point))
|
||||
except Exception:
|
||||
return str(amount)
|
||||
|
||||
@staticmethod
|
||||
def is_perc(value):
|
||||
"""True if ``value`` is a percentage string such as ``"25%"``."""
|
||||
try:
|
||||
return value[-1] == "%"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Heir / will-executor comparison helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def cmp_array(heira, heirb):
|
||||
"""Element-wise equality of two sequences (length-safe)."""
|
||||
try:
|
||||
if len(heira) != len(heirb):
|
||||
return False
|
||||
for h in range(0, len(heira)):
|
||||
if heira[h] != heirb[h]:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def cmp_heir(heira, heirb):
|
||||
"""Two heirs are "the same" when address (0) and amount (1) match."""
|
||||
if heira[0] == heirb[0] and heira[1] == heirb[1]:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def cmp_willexecutor(willexecutora, willexecutorb):
|
||||
"""Compare two will-executor dicts by url / address / base_fee."""
|
||||
if willexecutora == willexecutorb:
|
||||
return True
|
||||
try:
|
||||
if (
|
||||
willexecutora["url"] == willexecutorb["url"]
|
||||
and willexecutora["address"] == willexecutorb["address"]
|
||||
and willexecutora["base_fee"] == willexecutorb["base_fee"]
|
||||
):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def search_heir_by_values(heirs, heir, values):
|
||||
"""Return the key of the first heir in ``heirs`` matching ``heir`` on
|
||||
every column listed in ``values`` (or ``False`` if none)."""
|
||||
for h, v in heirs.items():
|
||||
found = False
|
||||
for val in values:
|
||||
if val in v and v[val] != heir[val]:
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
return h
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def cmp_heir_by_values(heira, heirb, values):
|
||||
"""True when two heirs agree on every column index in ``values``."""
|
||||
for v in values:
|
||||
if heira[v] != heirb[v]:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def cmp_heirs_by_values(
|
||||
heirsa, heirsb, values, exclude_willexecutors=False, reverse=True
|
||||
):
|
||||
"""Set-equality of two heir collections, comparing only ``values``.
|
||||
|
||||
When ``exclude_willexecutors`` is set, synthetic will-executor heirs
|
||||
(those whose key contains the ``w!ll3x3c"`` marker) are skipped. The
|
||||
``reverse`` flag makes the comparison symmetric by running it both ways.
|
||||
"""
|
||||
for heira in heirsa:
|
||||
if (
|
||||
exclude_willexecutors and 'w!ll3x3c"' not in heira
|
||||
) or not exclude_willexecutors:
|
||||
found = False
|
||||
for heirb in heirsb:
|
||||
if Util.cmp_heir_by_values(heirsa[heira], heirsb[heirb], values):
|
||||
found = True
|
||||
if not found:
|
||||
return False
|
||||
if reverse:
|
||||
return Util.cmp_heirs_by_values(
|
||||
heirsb,
|
||||
heirsa,
|
||||
values,
|
||||
exclude_willexecutors=exclude_willexecutors,
|
||||
reverse=False,
|
||||
)
|
||||
else:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def cmp_heirs(
|
||||
heirsa,
|
||||
heirsb,
|
||||
cmp_function=lambda x, y: x[0] == y[0] and x[3] == y[3],
|
||||
reverse=True,
|
||||
):
|
||||
"""Compare two heir collections using a custom ``cmp_function``.
|
||||
|
||||
Will-executor entries are ignored. As with
|
||||
:meth:`cmp_heirs_by_values`, ``reverse`` makes the relation symmetric.
|
||||
"""
|
||||
try:
|
||||
for heir in heirsa:
|
||||
if 'w!ll3x3c"' not in heir:
|
||||
if heir not in heirsb or not cmp_function(
|
||||
heirsa[heir], heirsb[heir]
|
||||
):
|
||||
if not Util.search_heir_by_values(heirsb, heirsa[heir], [0, 3]):
|
||||
return False
|
||||
if reverse:
|
||||
return Util.cmp_heirs(heirsb, heirsa, cmp_function, False)
|
||||
else:
|
||||
return True
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Transaction input/output comparison helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def cmp_inputs(inputsa, inputsb):
|
||||
"""True when both input lists reference the same set of UTXOs."""
|
||||
if len(inputsa) != len(inputsb):
|
||||
return False
|
||||
for inputa in inputsa:
|
||||
if not Util.in_utxo(inputa, inputsb):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def cmp_outputs(outputsa, outputsb, willexecutor_output=None):
|
||||
"""True when both output lists contain the same (address, value) pairs.
|
||||
|
||||
The optional ``willexecutor_output`` is treated as a wildcard match so
|
||||
that the will-executor's fee output does not break the comparison.
|
||||
"""
|
||||
if len(outputsa) != len(outputsb):
|
||||
return False
|
||||
for outputa in outputsa:
|
||||
if not Util.cmp_output(outputa, willexecutor_output):
|
||||
if not Util.in_output(outputa, outputsb):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def cmp_txs(txa, txb):
|
||||
"""Two transactions are equivalent when their inputs and outputs match."""
|
||||
if not Util.cmp_inputs(txa.inputs(), txb.inputs()):
|
||||
return False
|
||||
if not Util.cmp_outputs(txa.outputs(), txb.outputs()):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_value_amount(txa, txb):
|
||||
"""Sum of the values of outputs that appear (same addr+value) in both
|
||||
transactions. Returns ``False`` as soon as an output of ``txa`` shares
|
||||
neither amount nor address with any output of ``txb``."""
|
||||
outputsa = txa.outputs()
|
||||
value_amount = 0
|
||||
|
||||
for outa in outputsa:
|
||||
same_amount, same_address = Util.in_output(outa, txb.outputs())
|
||||
if not (same_amount or same_address):
|
||||
return False
|
||||
if same_amount and same_address:
|
||||
value_amount += outa.value
|
||||
if same_amount:
|
||||
pass
|
||||
if same_address:
|
||||
pass
|
||||
|
||||
return value_amount
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Locktime arithmetic
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def chk_locktime(timestamp_to_check, block_height_to_check, locktime):
|
||||
"""Return True if ``locktime`` is still in the future.
|
||||
|
||||
Timestamp-style and block-height-style locktimes are compared against
|
||||
the respective "to_check" reference value.
|
||||
"""
|
||||
# TODO BUG: WHAT HAPPEN AT THRESHOLD?
|
||||
locktime = int(locktime)
|
||||
if locktime > LOCKTIME_THRESHOLD and locktime > timestamp_to_check:
|
||||
return True
|
||||
elif locktime < LOCKTIME_THRESHOLD and locktime > block_height_to_check:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def anticipate_locktime(locktime, blocks=0, hours=0, days=0):
|
||||
"""Move a locktime earlier by the given amount.
|
||||
|
||||
Works on both timestamp and block-height locktimes; never returns a
|
||||
value below 1.
|
||||
"""
|
||||
locktime = int(locktime)
|
||||
out = 0
|
||||
if locktime > LOCKTIME_THRESHOLD:
|
||||
seconds = blocks * 600 + hours * 3600 + days * 86400
|
||||
dt = datetime.fromtimestamp(locktime)
|
||||
dt -= timedelta(seconds=seconds)
|
||||
out = dt.timestamp()
|
||||
else:
|
||||
blocks -= hours * 6 + days * 144
|
||||
out = locktime + blocks
|
||||
|
||||
if out < 1:
|
||||
out = 1
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def cmp_locktime(locktimea, locktimeb):
|
||||
"""Compare two relative locktime strings sharing the same unit."""
|
||||
if locktimea == locktimeb:
|
||||
return 0
|
||||
strlocktimea = str(locktimea)
|
||||
strlocktimeb = str(locktimeb)
|
||||
if locktimea[-1] in "ydb":
|
||||
if locktimeb[-1] == locktimea[-1]:
|
||||
return int(strlocktimea[-1]) - int(strlocktimeb[-1])
|
||||
else:
|
||||
return int(locktimea) - (locktimeb)
|
||||
|
||||
@staticmethod
|
||||
def get_lowest_valid_tx(available_utxos, will):
|
||||
"""Placeholder kept from the original code (sorts the will by locktime)."""
|
||||
will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
|
||||
for txid, willitem in will.items():
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_locktimes(will):
|
||||
"""Return the distinct locktimes used by the transactions in ``will``."""
|
||||
locktimes = {}
|
||||
for txid, willitem in will.items():
|
||||
locktimes[willitem["tx"].locktime] = True
|
||||
return locktimes.keys()
|
||||
|
||||
@staticmethod
|
||||
def get_lowest_locktimes(locktimes):
|
||||
"""Split a list of locktimes into (sorted_timestamps, sorted_blocks)."""
|
||||
sorted_timestamp = []
|
||||
sorted_block = []
|
||||
for locktime in locktimes:
|
||||
locktime = Util.parse_locktime_string(locktime)
|
||||
if locktime < LOCKTIME_THRESHOLD:
|
||||
bisect.insort(sorted_block, locktime)
|
||||
else:
|
||||
bisect.insort(sorted_timestamp, locktime)
|
||||
|
||||
return sorted(sorted_timestamp), sorted(sorted_block)
|
||||
|
||||
@staticmethod
|
||||
def get_lowest_locktimes_from_will(will):
|
||||
"""Convenience wrapper: lowest locktimes directly from a will dict."""
|
||||
return Util.get_lowest_locktimes(Util.get_locktimes(will))
|
||||
|
||||
@staticmethod
|
||||
def search_willtx_per_io(will, tx):
|
||||
"""Find a will entry whose tx has the same inputs/outputs as ``tx``."""
|
||||
for wid, w in will.items():
|
||||
if Util.cmp_txs(w["tx"], tx["tx"]):
|
||||
return wid, w
|
||||
return None, None
|
||||
|
||||
@staticmethod
|
||||
def invalidate_will(will):
|
||||
raise Exception("not implemented")
|
||||
|
||||
@staticmethod
|
||||
def get_will_spent_utxos(will):
|
||||
"""Collect every input spent by any transaction in ``will``."""
|
||||
utxos = []
|
||||
for txid, willitem in will.items():
|
||||
utxos += willitem["tx"].inputs()
|
||||
|
||||
return utxos
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# UTXO helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def utxo_to_str(utxo):
|
||||
"""Best-effort conversion of a UTXO / input object to its ``txid:n`` str."""
|
||||
try:
|
||||
return utxo.to_str()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return utxo.prevout.to_str()
|
||||
except Exception:
|
||||
pass
|
||||
return str(utxo)
|
||||
|
||||
@staticmethod
|
||||
def cmp_utxo(utxoa, utxob):
|
||||
"""True when two UTXOs refer to the same outpoint."""
|
||||
utxoa = Util.utxo_to_str(utxoa)
|
||||
utxob = Util.utxo_to_str(utxob)
|
||||
if utxoa == utxob:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def in_utxo(utxo, utxos):
|
||||
"""Membership test for a UTXO inside an iterable of UTXOs."""
|
||||
for s_u in utxos:
|
||||
if Util.cmp_utxo(s_u, utxo):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def txid_in_utxo(txid, utxos):
|
||||
"""True if any UTXO in ``utxos`` is spent from transaction ``txid``."""
|
||||
for s_u in utxos:
|
||||
if s_u.prevout.txid == txid:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def cmp_output(outputa, outputb):
|
||||
"""Two outputs are equal when both address and value match."""
|
||||
return outputa.address == outputb.address and outputa.value == outputb.value
|
||||
|
||||
@staticmethod
|
||||
def in_output(output, outputs):
|
||||
"""Membership test for an output inside an iterable of outputs."""
|
||||
for s_o in outputs:
|
||||
if Util.cmp_output(s_o, output):
|
||||
return True
|
||||
return False
|
||||
|
||||
# check all output with the same amount if none have the same address it can be a change
|
||||
# return true true same address same amount
|
||||
# return true false same amount different address
|
||||
# return false false different amount, different address not found
|
||||
@staticmethod
|
||||
def din_output(out, outputs):
|
||||
"""Detailed output lookup used to tell a change output apart.
|
||||
|
||||
Returns a ``(same_amount, same_address)`` tuple:
|
||||
* ``(True, True)`` -> an output with same amount *and* address
|
||||
* ``(True, False)`` -> same amount but different address (maybe change)
|
||||
* ``(False, False)``-> no output with this amount
|
||||
"""
|
||||
same_amount = []
|
||||
for s_o in outputs:
|
||||
if int(out.value) == int(s_o.value):
|
||||
same_amount.append(s_o)
|
||||
if out.address == s_o.address:
|
||||
return True, True
|
||||
else:
|
||||
pass
|
||||
|
||||
if len(same_amount) > 0:
|
||||
return True, False
|
||||
else:
|
||||
return False, False
|
||||
|
||||
@staticmethod
|
||||
def get_change_output(wallet, in_amount, out_amount, fee):
|
||||
"""Build a change ``PartialTxOutput`` if the leftover exceeds dust."""
|
||||
change_amount = int(in_amount - out_amount - fee)
|
||||
if change_amount > wallet.dust_threshold():
|
||||
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
||||
out = PartialTxOutput.from_address_and_value(
|
||||
change_addresses[0], change_amount
|
||||
)
|
||||
out.is_change = True
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def get_current_height(network):
|
||||
"""Return a conservative current block height for locktime purposes.
|
||||
|
||||
Mirrors Electrum's own anti-fee-sniping logic: if there is no network,
|
||||
the chain tip is stale, or the main server lags too far behind the
|
||||
SPV-checked height, it gives up and returns 0.
|
||||
"""
|
||||
# if no network or not up to date, just set locktime to zero
|
||||
if not network:
|
||||
return 0
|
||||
chain = network.blockchain()
|
||||
if chain.is_tip_stale():
|
||||
return 0
|
||||
# figure out current block height
|
||||
chain_height = chain.height() # learnt from all connected servers, SPV-checked
|
||||
server_height = (
|
||||
network.get_server_height()
|
||||
) # height claimed by main server, unverified
|
||||
# note: main server might be lagging (either is slow, is malicious, or there is an SPV-invisible-hard-fork)
|
||||
# - if it's lagging too much, it is the network's job to switch away
|
||||
if server_height < chain_height - 10:
|
||||
# the diff is suspiciously large... give up and use something non-fingerprintable
|
||||
return 0
|
||||
# discourage "fee sniping"
|
||||
height = min(chain_height, server_height)
|
||||
return height
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Misc helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def copy(dicto, dictfrom):
|
||||
"""Shallow copy of ``dictfrom`` entries into ``dicto`` (in place)."""
|
||||
for k, v in dictfrom.items():
|
||||
dicto[k] = v
|
||||
|
||||
@staticmethod
|
||||
def fix_will_settings_tx_fees(will_settings):
|
||||
"""Migrate the legacy ``tx_fees`` key to ``baltx_fees`` in settings.
|
||||
|
||||
Returns True when a migration was performed (caller should persist).
|
||||
"""
|
||||
tx_fees = will_settings.get("tx_fees", False)
|
||||
have_to_update = False
|
||||
if tx_fees:
|
||||
will_settings["baltx_fees"] = tx_fees
|
||||
del will_settings["tx_fees"]
|
||||
have_to_update = True
|
||||
return have_to_update
|
||||
|
||||
@staticmethod
|
||||
def fix_will_tx_fees(will):
|
||||
"""Same legacy migration as above but applied to every will entry."""
|
||||
have_to_update = False
|
||||
for txid, willitem in will.items():
|
||||
tx_fees = willitem.get("tx_fees", False)
|
||||
if tx_fees:
|
||||
will[txid]["baltx_fees"] = tx_fees
|
||||
del will[txid]["tx_fees"]
|
||||
have_to_update = True
|
||||
return have_to_update
|
||||
|
||||
@staticmethod
|
||||
def text_to_hex(text: str) -> str:
|
||||
"""Convert text to a hexadecimal string (used for OP_RETURN payloads)."""
|
||||
hex_string = text.encode('utf-8').hex()
|
||||
return hex_string
|
||||
|
||||
@staticmethod
|
||||
def hex_to_text(hex_string: str) -> str:
|
||||
"""Convert a hexadecimal string back to text (for verification)."""
|
||||
try:
|
||||
return bytes.fromhex(hex_string).decode('utf-8')
|
||||
except Exception:
|
||||
return "Error: Invalid hex string"
|
||||
938
bal/core/will.py
Normal file
@@ -0,0 +1,938 @@
|
||||
"""
|
||||
bal.core.will
|
||||
=============
|
||||
|
||||
The "will": the set of time-locked inheritance transactions plus all the logic
|
||||
to keep it coherent over time.
|
||||
|
||||
Two classes live here:
|
||||
|
||||
* :class:`Will` - a namespace of static methods operating on a *will*
|
||||
dictionary (mapping ``txid -> WillItem``): building
|
||||
the parent/child tree, anticipating locktimes,
|
||||
detecting replaced/invalidated/confirmed entries,
|
||||
validating that the will still matches the heirs and
|
||||
will-executors, and building an "invalidation"
|
||||
transaction.
|
||||
* :class:`WillItem` - a single will transaction together with its status
|
||||
flags, heirs, will-executor and fee.
|
||||
|
||||
Separation of concerns
|
||||
-----------------------
|
||||
The original ``WillItem`` carried a ``get_color()`` method returning hard-coded
|
||||
hex colours for the GUI. That was pure presentation living inside the core
|
||||
logic, so it has been **moved** to ``bal.gui.qt.theme.status_color(will_item)``.
|
||||
The status flags themselves (the source of truth) stay here; only the mapping
|
||||
"status -> colour" now lives in the GUI layer. No behaviour changed.
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX
|
||||
from electrum.i18n import _
|
||||
from electrum.logging import Logger, get_logger
|
||||
from electrum.transaction import (
|
||||
PartialTransaction,
|
||||
PartialTxInput,
|
||||
PartialTxOutput,
|
||||
Transaction,
|
||||
TxOutpoint,
|
||||
tx_from_any,
|
||||
)
|
||||
from electrum.util import (
|
||||
bfh,
|
||||
)
|
||||
|
||||
from .util import Util
|
||||
from .willexecutors import Willexecutors
|
||||
|
||||
MIN_LOCKTIME = 1
|
||||
MIN_BLOCK = 1
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Will:
|
||||
@staticmethod
|
||||
def get_children(will, willid):
|
||||
out = []
|
||||
for _id in will:
|
||||
inputs = will[_id].tx.inputs()
|
||||
for idi in range(0, len(inputs)):
|
||||
_input = inputs[idi]
|
||||
if _input.prevout.txid.hex() == willid:
|
||||
out.append([_id, idi, _input.prevout.out_idx])
|
||||
return out
|
||||
|
||||
# build a tree with parent transactions
|
||||
@staticmethod
|
||||
def add_willtree(will):
|
||||
for willid in will:
|
||||
will[willid].children = Will.get_children(will, willid)
|
||||
for child in will[willid].children:
|
||||
if not will[child[0]].father:
|
||||
will[child[0]].father = willid
|
||||
|
||||
# return a list of will sorted by locktime
|
||||
@staticmethod
|
||||
def get_sorted_will(will):
|
||||
return sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
|
||||
|
||||
@staticmethod
|
||||
def only_valid(will):
|
||||
for k, v in will.items():
|
||||
if v.get_status("VALID"):
|
||||
yield k
|
||||
|
||||
@staticmethod
|
||||
def search_equal_tx(will, tx, wid):
|
||||
for w in will:
|
||||
if w != wid and not tx.to_json() != will[w]["tx"].to_json():
|
||||
if will[w]["tx"].txid() != tx.txid():
|
||||
if Util.cmp_txs(will[w]["tx"], tx):
|
||||
return will[w]["tx"]
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_tx_from_any(x):
|
||||
try:
|
||||
a = str(x)
|
||||
return tx_from_any(a)
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
return x
|
||||
|
||||
@staticmethod
|
||||
def add_info_from_will(will, wid, wallet):
|
||||
if isinstance(will[wid].tx, str):
|
||||
will[wid].tx = Will.get_tx_from_any(will[wid].tx)
|
||||
if wallet:
|
||||
will[wid].tx.add_info_from_wallet(wallet)
|
||||
for txin in will[wid].tx.inputs():
|
||||
txid = txin.prevout.txid.hex()
|
||||
if txid in will:
|
||||
change = will[txid].tx.outputs()[txin.prevout.out_idx]
|
||||
txin._trusted_value_sats = change.value
|
||||
try:
|
||||
txin.script_descriptor = change.script_descriptor
|
||||
except Exception:
|
||||
pass
|
||||
txin.is_mine = True
|
||||
txin._TxInput__address = change.address
|
||||
txin._TxInput__scriptpubkey = change.scriptpubkey
|
||||
txin._TxInput__value_sats = change.value
|
||||
txin._trusted_value_sats = change.value
|
||||
|
||||
@staticmethod
|
||||
def normalize_will(will, wallet=None, others_inputs=None):
|
||||
others_input = others_inputs if others_inputs is not None else {}
|
||||
to_delete = []
|
||||
to_add = {}
|
||||
# add info from wallet
|
||||
willitems = {}
|
||||
for wid in will:
|
||||
Will.add_info_from_will(will, wid, wallet)
|
||||
willitems[wid] = WillItem(will[wid])
|
||||
will = willitems
|
||||
errors = {}
|
||||
for wid in will:
|
||||
|
||||
txid = will[wid].tx.txid()
|
||||
|
||||
if txid is None:
|
||||
_logger.error("##########")
|
||||
_logger.error(wid)
|
||||
_logger.error(will[wid])
|
||||
_logger.error(will[wid].tx.to_json())
|
||||
|
||||
_logger.error("txid is none")
|
||||
will[wid].set_status("ERROR", True)
|
||||
errors[wid] = will[wid]
|
||||
continue
|
||||
|
||||
if txid != wid:
|
||||
outputs = will[wid].tx.outputs()
|
||||
ow = will[wid]
|
||||
ow.normalize_locktime(others_inputs)
|
||||
will[wid] = WillItem(ow.to_dict())
|
||||
|
||||
for i in range(0, len(outputs)):
|
||||
Will.change_input(
|
||||
will, wid, i, outputs[i], others_inputs, to_delete, to_add
|
||||
)
|
||||
|
||||
to_delete.append(wid)
|
||||
to_add[ow.tx.txid()] = ow.to_dict()
|
||||
|
||||
# for eid, err in errors.items():
|
||||
# new_txid = err.tx.txid()
|
||||
|
||||
for k, w in to_add.items():
|
||||
will[k] = w
|
||||
|
||||
for wid in to_delete:
|
||||
if wid in will:
|
||||
del will[wid]
|
||||
|
||||
@staticmethod
|
||||
def new_input(txid, idx, change):
|
||||
prevout = TxOutpoint(txid=bfh(txid), out_idx=idx)
|
||||
inp = PartialTxInput(prevout=prevout)
|
||||
inp._trusted_value_sats = change.value
|
||||
inp.is_mine = True
|
||||
inp._TxInput__address = change.address
|
||||
inp._TxInput__scriptpubkey = change.scriptpubkey
|
||||
inp._TxInput__value_sats = change.value
|
||||
return inp
|
||||
|
||||
@staticmethod
|
||||
def check_anticipate(ow: "WillItem", nw: "WillItem"):
|
||||
anticipate = Util.anticipate_locktime(ow.tx.locktime, days=1)
|
||||
if int(nw.tx.locktime) >= int(anticipate):
|
||||
if Util.cmp_heirs_by_values(
|
||||
ow.heirs, nw.heirs, [0, 1], exclude_willexecutors=True
|
||||
):
|
||||
if nw.we and ow.we:
|
||||
if ow.we["url"] == nw.we["url"]:
|
||||
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
|
||||
return anticipate
|
||||
else:
|
||||
if int(ow.tx_fees) != int(nw.tx_fees):
|
||||
return anticipate
|
||||
else:
|
||||
ow.tx.locktime
|
||||
else:
|
||||
ow.tx.locktime
|
||||
else:
|
||||
if nw.we == ow.we:
|
||||
if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]):
|
||||
return anticipate
|
||||
else:
|
||||
return ow.tx.locktime
|
||||
else:
|
||||
return ow.tx.locktime
|
||||
else:
|
||||
return anticipate
|
||||
return 4294967295 + 1
|
||||
|
||||
@staticmethod
|
||||
def change_input(will, otxid, idx, change, others_inputs, to_delete, to_append):
|
||||
ow = will[otxid]
|
||||
ntxid = ow.tx.txid()
|
||||
if otxid != ntxid:
|
||||
for wid in will:
|
||||
w = will[wid]
|
||||
inputs = w.tx.inputs()
|
||||
outputs = w.tx.outputs()
|
||||
found = False
|
||||
old_txid = w.tx.txid()
|
||||
# ntx = None
|
||||
for i in range(0, len(inputs)):
|
||||
if (
|
||||
inputs[i].prevout.txid.hex() == otxid
|
||||
and inputs[i].prevout.out_idx == idx
|
||||
):
|
||||
if isinstance(w.tx, Transaction):
|
||||
will[wid].tx = PartialTransaction.from_tx(w.tx)
|
||||
will[wid].tx.set_rbf(True)
|
||||
will[wid].tx._inputs[i] = Will.new_input(wid, idx, change)
|
||||
found = True
|
||||
if found:
|
||||
pass
|
||||
|
||||
new_txid = will[wid].tx.txid()
|
||||
if old_txid != new_txid:
|
||||
to_delete.append(old_txid)
|
||||
to_append[new_txid] = will[wid]
|
||||
outputs = will[wid].tx.outputs()
|
||||
for i in range(0, len(outputs)):
|
||||
Will.change_input(
|
||||
will,
|
||||
wid,
|
||||
i,
|
||||
outputs[i],
|
||||
others_inputs,
|
||||
to_delete,
|
||||
to_append,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_all_inputs(will, only_valid=False):
|
||||
all_inputs = {}
|
||||
for w, wi in will.items():
|
||||
if not only_valid or wi.get_status("VALID"):
|
||||
inputs = wi.tx.inputs()
|
||||
for i in inputs:
|
||||
prevout_str = i.prevout.to_str()
|
||||
inp = [w, will[w], i]
|
||||
if prevout_str not in all_inputs:
|
||||
all_inputs[prevout_str] = [inp]
|
||||
else:
|
||||
all_inputs[prevout_str].append(inp)
|
||||
return all_inputs
|
||||
|
||||
@staticmethod
|
||||
def get_all_inputs_min_locktime(all_inputs):
|
||||
all_inputs_min_locktime = {}
|
||||
|
||||
for i, values in all_inputs.items():
|
||||
min_locktime = min(values, key=lambda x: x[1].tx.locktime)[1].tx.locktime
|
||||
for w in values:
|
||||
if w[1].tx.locktime == min_locktime:
|
||||
if i not in all_inputs_min_locktime:
|
||||
all_inputs_min_locktime[i] = [w]
|
||||
else:
|
||||
all_inputs_min_locktime[i].append(w)
|
||||
|
||||
return all_inputs_min_locktime
|
||||
|
||||
@staticmethod
|
||||
def search_anticipate_rec(will, old_inputs):
|
||||
redo = False
|
||||
to_delete = []
|
||||
to_append = {}
|
||||
new_inputs = Will.get_all_inputs(will, only_valid=True)
|
||||
for nid, nwi in will.items():
|
||||
if nwi.search_anticipate(new_inputs):
|
||||
if nid != nwi.tx.txid():
|
||||
redo = True
|
||||
to_delete.append(nid)
|
||||
to_append[nwi.tx.txid()] = nwi
|
||||
outputs = nwi.tx.outputs()
|
||||
for i in range(0, len(outputs)):
|
||||
Will.change_input(
|
||||
will, nid, i, outputs[i], new_inputs, to_delete, to_append
|
||||
)
|
||||
if nwi.search_anticipate(old_inputs):
|
||||
if nid != nwi.tx.txid():
|
||||
redo = True
|
||||
|
||||
to_delete.append(nid)
|
||||
to_append[nwi.tx.txid()] = nwi
|
||||
outputs = nwi.tx.outputs()
|
||||
for i in range(0, len(outputs)):
|
||||
Will.change_input(
|
||||
will, nid, i, outputs[i], new_inputs, to_delete, to_append
|
||||
)
|
||||
|
||||
for w in to_delete:
|
||||
try:
|
||||
del will[w]
|
||||
except Exception:
|
||||
pass
|
||||
for k, w in to_append.items():
|
||||
will[k] = w
|
||||
if redo:
|
||||
|
||||
Will.search_anticipate_rec(will, old_inputs)
|
||||
|
||||
@staticmethod
|
||||
def update_will(old_will, new_will):
|
||||
all_old_inputs = Will.get_all_inputs(old_will, only_valid=True)
|
||||
# all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_old_inputs)
|
||||
# all_new_inputs = Will.get_all_inputs(new_will)
|
||||
# check if the new input is already spent by other transaction
|
||||
# if it is use the same locktime, or anticipate.
|
||||
Will.search_anticipate_rec(new_will, all_old_inputs)
|
||||
other_inputs = Will.get_all_inputs(old_will, {})
|
||||
try:
|
||||
Will.normalize_will(new_will, others_inputs=other_inputs)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
for oid in Will.only_valid(old_will):
|
||||
if oid in new_will:
|
||||
new_heirs = new_will[oid].heirs
|
||||
new_we = new_will[oid].we
|
||||
|
||||
new_will[oid] = old_will[oid]
|
||||
new_will[oid].heirs = new_heirs
|
||||
new_will[oid].we = new_we
|
||||
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def get_higher_input_for_tx(will):
|
||||
out = {}
|
||||
for wid in will:
|
||||
wtx = will[wid].tx
|
||||
found = False
|
||||
for inp in wtx.inputs():
|
||||
if inp.prevout.txid.hex() in will:
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
out[inp.prevout.to_str()] = inp
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def invalidate_will(will, wallet, fees_per_byte):
|
||||
will_only_valid = Will.only_valid_list(will)
|
||||
inputs = Will.get_all_inputs(will_only_valid)
|
||||
utxos = wallet.get_utxos()
|
||||
filtered_inputs = []
|
||||
prevout_to_spend = []
|
||||
current_height = Util.get_current_height(wallet.network)
|
||||
for prevout_str, ws in inputs.items():
|
||||
for w in ws:
|
||||
if w[0] not in filtered_inputs:
|
||||
filtered_inputs.append(w[0])
|
||||
if prevout_str not in prevout_to_spend:
|
||||
prevout_to_spend.append(prevout_str)
|
||||
balance = 0
|
||||
utxo_to_spend = []
|
||||
for utxo in utxos:
|
||||
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
|
||||
continue
|
||||
utxo_str = utxo.prevout.to_str()
|
||||
if utxo_str in prevout_to_spend:
|
||||
balance += inputs[utxo_str][0][2].value_sats()
|
||||
utxo_to_spend.append(utxo)
|
||||
if len(utxo_to_spend) > 0:
|
||||
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
||||
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
|
||||
out.is_change = True
|
||||
locktime = current_height
|
||||
tx = PartialTransaction.from_io(
|
||||
utxo_to_spend, [out], locktime=locktime, version=2
|
||||
)
|
||||
tx.set_rbf(True)
|
||||
fee = tx.estimated_size() * fees_per_byte
|
||||
if balance - fee > 0:
|
||||
out = PartialTxOutput.from_address_and_value(
|
||||
change_addresses[0], balance - fee
|
||||
)
|
||||
tx = PartialTransaction.from_io(
|
||||
utxo_to_spend, [out], locktime=locktime, version=2
|
||||
)
|
||||
tx.set_rbf(True)
|
||||
|
||||
_logger.debug(f"invalidation tx: {tx}")
|
||||
return tx
|
||||
|
||||
else:
|
||||
_logger.debug(f"balance({balance}) - fee({fee}) <=0")
|
||||
pass
|
||||
else:
|
||||
_logger.debug("len utxo_to_spend <=0")
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def is_new(will):
|
||||
for wid, w in will.items():
|
||||
if w.get_status("VALID") and not w.get_status("COMPLETE"):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def search_rai(all_inputs, all_utxos, will, wallet):
|
||||
# will_only_valid = Will.only_valid_or_replaced_list(will)
|
||||
for inp, ws in all_inputs.items():
|
||||
inutxo = Util.in_utxo(inp, all_utxos)
|
||||
for w in ws:
|
||||
wi = w[1]
|
||||
if (
|
||||
wi.get_status("VALID")
|
||||
or wi.get_status("CONFIRMED")
|
||||
or wi.get_status("PENDING")
|
||||
):
|
||||
prevout_id = w[2].prevout.txid.hex()
|
||||
if not inutxo:
|
||||
if prevout_id in will:
|
||||
wo = will[prevout_id]
|
||||
if wo.get_status("REPLACED"):
|
||||
wi.set_status("REPLACED", True)
|
||||
if wo.get_status("INVALIDATED"):
|
||||
wi.set_status("INVALIDATED", True)
|
||||
|
||||
else:
|
||||
if wallet.db.get_transaction(wi._id):
|
||||
wi.set_status("CONFIRMED", True)
|
||||
else:
|
||||
wi.set_status("INVALIDATED", True)
|
||||
|
||||
for child in wi.search(all_inputs):
|
||||
if child.tx.locktime < wi.tx.locktime:
|
||||
_logger.debug("a child was found")
|
||||
wi.set_status("REPLACED", True)
|
||||
else:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def utxos_strs(utxos):
|
||||
return [Util.utxo_to_str(u) for u in utxos]
|
||||
|
||||
@staticmethod
|
||||
def set_invalidate(wid, will=None):
|
||||
will = will if will is not None else {}
|
||||
will[wid].set_status("INVALIDATED", True)
|
||||
if will[wid].children:
|
||||
for c in will[wid].children.items():
|
||||
Will.set_invalidate(c[0], will)
|
||||
|
||||
@staticmethod
|
||||
def check_tx_height(tx, wallet):
|
||||
info = wallet.get_tx_info(tx)
|
||||
return info.tx_mined_status.height()
|
||||
|
||||
# check if transactions are stil valid tecnically valid
|
||||
@staticmethod
|
||||
def check_invalidated(willtree, utxos_list, wallet):
|
||||
for wid, w in willtree.items():
|
||||
if (
|
||||
not w.father
|
||||
or willtree[w.father].get_status("CONFIRMED")
|
||||
or willtree[w.father].get_status("PENDING")
|
||||
):
|
||||
for inp in w.tx.inputs():
|
||||
inp_str = Util.utxo_to_str(inp)
|
||||
if inp_str not in utxos_list:
|
||||
if wallet:
|
||||
height = Will.check_tx_height(w.tx, wallet)
|
||||
if height < 0:
|
||||
Will.set_invalidate(wid, willtree)
|
||||
elif height == 0:
|
||||
w.set_status("PENDING", True)
|
||||
else:
|
||||
w.set_status("CONFIRMED", True)
|
||||
|
||||
# def reflect_to_children(treeitem):
|
||||
# if not treeitem.get_status("VALID"):
|
||||
# _logger.debug(f"{tree:item._id} status not valid looking for children")
|
||||
# for child in treeitem.children:
|
||||
# wc = willtree[child]
|
||||
# if wc.get_status("VALID"):
|
||||
# if treeitem.get_status("INVALIDATED"):
|
||||
# wc.set_status("INVALIDATED", True)
|
||||
# if treeitem.get_status("REPLACED"):
|
||||
# wc.set_status("REPLACED", True)
|
||||
# if wc.children:
|
||||
# Will.reflect_to_children(wc)
|
||||
|
||||
@staticmethod
|
||||
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust):
|
||||
fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = (
|
||||
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
|
||||
)
|
||||
wallet_balance = 0
|
||||
for utxo in all_utxos:
|
||||
wallet_balance += utxo.value_sats()
|
||||
|
||||
if fixed_amount >= wallet_balance:
|
||||
raise FixedAmountException(
|
||||
f"Fixed amount({fixed_amount}) >= {wallet_balance}"
|
||||
)
|
||||
if perc_amount != 100:
|
||||
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
|
||||
|
||||
for url, wex in willexecutors.items():
|
||||
if Willexecutors.is_selected(wex):
|
||||
temp_balance = wallet_balance - int(wex["base_fee"])
|
||||
if fixed_amount >= temp_balance:
|
||||
raise FixedAmountException(
|
||||
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check):
|
||||
Will.add_willtree(will)
|
||||
utxos_list = Will.utxos_strs(all_utxos)
|
||||
|
||||
Will.check_invalidated(will, utxos_list, wallet)
|
||||
|
||||
all_inputs = Will.get_all_inputs(will, only_valid=True)
|
||||
all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_inputs)
|
||||
Will.check_will_expired(
|
||||
all_inputs_min_locktime, block_to_check, timestamp_to_check
|
||||
)
|
||||
|
||||
all_inputs = Will.get_all_inputs(will, only_valid=True)
|
||||
|
||||
Will.search_rai(all_inputs, all_utxos, will, wallet)
|
||||
|
||||
@staticmethod
|
||||
def get_min_locktime(will,default_value=None):
|
||||
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
|
||||
|
||||
|
||||
|
||||
@staticmethod
|
||||
def is_will_valid(
|
||||
will,
|
||||
block_to_check,
|
||||
timestamp_to_check,
|
||||
tx_fees,
|
||||
all_utxos,
|
||||
heirs=None,
|
||||
willexecutors=None,
|
||||
self_willexecutor=False,
|
||||
wallet=False,
|
||||
callback_not_valid_tx=None,
|
||||
):
|
||||
heirs = heirs if heirs is not None else {}
|
||||
willexecutors= willexecutors if willexecutors is not None else {}
|
||||
|
||||
Will.check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check)
|
||||
if heirs:
|
||||
if not Will.check_willexecutors_and_heirs(
|
||||
will,
|
||||
heirs,
|
||||
willexecutors,
|
||||
self_willexecutor,
|
||||
timestamp_to_check,
|
||||
tx_fees,
|
||||
):
|
||||
raise NotCompleteWillException()
|
||||
|
||||
all_inputs = Will.get_all_inputs(will, only_valid=True)
|
||||
|
||||
_logger.info("check all utxo in wallet are spent")
|
||||
if all_inputs:
|
||||
for utxo in all_utxos:
|
||||
if utxo.value_sats() > 68 * tx_fees:
|
||||
if not Util.in_utxo(utxo, all_inputs.keys()):
|
||||
_logger.info("utxo is not spent", utxo.to_json())
|
||||
_logger.debug(all_inputs.keys())
|
||||
raise NotCompleteWillException(
|
||||
"Some utxo in the wallet is not included"
|
||||
)
|
||||
|
||||
_logger.info("will ok")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def check_will_expired(all_inputs_min_locktime, block_to_check, timestamp_to_check):
|
||||
_logger.info("check if some transaction is expired")
|
||||
for prevout_str, wid in all_inputs_min_locktime.items():
|
||||
for w in wid:
|
||||
if w[1].get_status("VALID"):
|
||||
locktime = int(wid[0][1].tx.locktime)
|
||||
if locktime <= NLOCKTIME_BLOCKHEIGHT_MAX:
|
||||
if locktime < int(block_to_check):
|
||||
raise WillExpiredException(
|
||||
f"Will Expired {wid[0][0]}: {locktime}<{block_to_check}"
|
||||
)
|
||||
else:
|
||||
if locktime < int(timestamp_to_check):
|
||||
raise WillExpiredException(
|
||||
f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}"
|
||||
)
|
||||
else:
|
||||
from datetime import datetime
|
||||
_logger.debug(f"Will Not Expired {wid[0][0]}: {datetime.fromtimestamp(locktime).isoformat()} > {datetime.fromtimestamp(timestamp_to_check).isoformat()}")
|
||||
|
||||
# def check_all_input_spent_are_in_wallet():
|
||||
# _logger.info("check all input spent are in wallet or valid txs")
|
||||
# for inp, ws in all_inputs.items():
|
||||
# if not Util.in_utxo(inp, all_utxos):
|
||||
# for w in ws:
|
||||
# if w[1].get_status("VALID"):
|
||||
# prevout_id = w[2].prevout.txid.hex()
|
||||
# parentwill = will.get(prevout_id, False)
|
||||
# if not parentwill or not parentwill.get_status("VALID"):
|
||||
# w[1].set_status("INVALIDATED", True)
|
||||
|
||||
@staticmethod
|
||||
def only_valid_list(will):
|
||||
out = {}
|
||||
for wid, w in will.items():
|
||||
if w.get_status("VALID"):
|
||||
out[wid] = w
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def only_valid_or_replaced_list(will):
|
||||
out = []
|
||||
for wid, w in will.items():
|
||||
wi = w
|
||||
if wi.get_status("VALID") or wi.get_status("REPLACED"):
|
||||
out.append(wid)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def check_willexecutors_and_heirs(
|
||||
will, heirs, willexecutors, self_willexecutor, check_date, tx_fees
|
||||
):
|
||||
_logger.debug("check willexecutors heirs")
|
||||
no_willexecutor = 0
|
||||
willexecutors_found = {}
|
||||
heirs_found = {}
|
||||
will_only_valid = Will.only_valid_list(will)
|
||||
if len(will_only_valid) < 1:
|
||||
return False
|
||||
for wid in Will.only_valid_list(will):
|
||||
w = will[wid]
|
||||
if w.tx_fees != tx_fees:
|
||||
raise TxFeesChangedException(f"{tx_fees}: {w.tx_fees}")
|
||||
for wheir in w.heirs:
|
||||
if not 'w!ll3x3c"' == wheir[:9]:
|
||||
their = will[wid].heirs[wheir]
|
||||
if heir := heirs.get(wheir, None):
|
||||
|
||||
if (
|
||||
heir[0] == their[0]
|
||||
and heir[1] == their[1]
|
||||
and Util.parse_locktime_string(heir[2])
|
||||
>= Util.parse_locktime_string(their[2])
|
||||
):
|
||||
count = heirs_found.get(wheir, 0)
|
||||
heirs_found[wheir] = count + 1
|
||||
else:
|
||||
_logger.debug(
|
||||
f"heir not present transaction is not valid:{wheir} {wid}, {w}"
|
||||
)
|
||||
|
||||
if willexecutor := w.we:
|
||||
count = willexecutors_found.get(willexecutor["url"], 0)
|
||||
if Util.cmp_willexecutor(
|
||||
willexecutor, willexecutors.get(willexecutor["url"], None)
|
||||
):
|
||||
willexecutors_found[willexecutor["url"]] = count + 1
|
||||
|
||||
else:
|
||||
no_willexecutor += 1
|
||||
count_heirs = 0
|
||||
for h in heirs:
|
||||
|
||||
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
|
||||
count_heirs += 1
|
||||
if h not in heirs_found:
|
||||
_logger.debug(f"heir: {h} not found")
|
||||
raise HeirNotFoundException(h)
|
||||
if not count_heirs:
|
||||
raise NoHeirsException("there are not valid heirs")
|
||||
if self_willexecutor and no_willexecutor == 0:
|
||||
raise NoWillExecutorNotPresent("Backup tx")
|
||||
for url, we in willexecutors.items():
|
||||
if Willexecutors.is_selected(we):
|
||||
if url not in willexecutors_found:
|
||||
_logger.debug(f"will-executor: {url} not fount")
|
||||
raise WillExecutorNotPresent(url)
|
||||
_logger.info("will is coherent with heirs and will-executors")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
class WillItem(Logger):
|
||||
STATUS_DEFAULT = {
|
||||
"ANTICIPATED": ["Anticipated", False],
|
||||
"BROADCASTED": ["Broadcasted", False],
|
||||
"CHECKED": ["Checked", False],
|
||||
"CHECK_FAIL": ["Check Failed", False],
|
||||
"COMPLETE": ["Signed", False],
|
||||
"CONFIRMED": ["Confirmed", False],
|
||||
"ERROR": ["Error", False],
|
||||
"EXPIRED": ["Expired", False],
|
||||
"EXPORTED": ["Exported", False],
|
||||
"IMPORTED": ["Imported", False],
|
||||
"INVALIDATED": ["Invalidated", False],
|
||||
"PENDING": ["Pending", False],
|
||||
"PUSH_FAIL": ["Push failed", False],
|
||||
"PUSHED": ["Pushed", False],
|
||||
"REPLACED": ["Replaced", False],
|
||||
"RESTORED": ["Restored", False],
|
||||
"VALID": ["Valid", True],
|
||||
}
|
||||
|
||||
def set_status(self, status, value=True):
|
||||
# _logger.trace(
|
||||
# "set status {} - {} {} -> {}".format(
|
||||
# self._id, status, self.STATUS[status][1], value
|
||||
# )
|
||||
# )
|
||||
if self.STATUS[status][1] == bool(value):
|
||||
return None
|
||||
|
||||
self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0]))
|
||||
self.STATUS[status][1] = bool(value)
|
||||
if value:
|
||||
if status in ["INVALIDATED", "REPLACED", "CONFIRMED", "PENDING"]:
|
||||
self.STATUS["VALID"][1] = False
|
||||
|
||||
if status in ["CONFIRMED", "PENDING"]:
|
||||
self.STATUS["INVALIDATED"][1] = False
|
||||
|
||||
if status in ["PUSHED"]:
|
||||
self.STATUS["PUSH_FAIL"][1] = False
|
||||
self.STATUS["CHECK_FAIL"][1] = False
|
||||
|
||||
if status in ["CHECKED"]:
|
||||
self.STATUS["PUSHED"][1] = True
|
||||
self.STATUS["PUSH_FAIL"][1] = False
|
||||
|
||||
return value
|
||||
|
||||
def get_status(self, status):
|
||||
return self.STATUS[status][1]
|
||||
|
||||
def __init__(self, w, _id=None, wallet=None):
|
||||
if isinstance(
|
||||
w,
|
||||
WillItem,
|
||||
):
|
||||
self.__dict__ = w.__dict__.copy()
|
||||
else:
|
||||
self.tx = Will.get_tx_from_any(w["tx"])
|
||||
self.heirs = w.get("heirs", None)
|
||||
self.we = w.get("willexecutor", None)
|
||||
self.status = w.get("status", None)
|
||||
self.description = w.get("description", None)
|
||||
self.time = w.get("time", None)
|
||||
self.change = w.get("change", None)
|
||||
self.tx_fees = w.get("baltx_fees", 0)
|
||||
self.father = w.get("Father", None)
|
||||
self.children = w.get("Children", None)
|
||||
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
for s in self.STATUS:
|
||||
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
|
||||
if not _id:
|
||||
self._id = self.tx.txid()
|
||||
else:
|
||||
self._id = _id
|
||||
|
||||
if not self._id:
|
||||
self.status += "ERROR!!!"
|
||||
self.valid = False
|
||||
|
||||
if wallet:
|
||||
self.tx.add_info_from_wallet(wallet)
|
||||
|
||||
def to_dict(self):
|
||||
out = {
|
||||
"_id": self._id,
|
||||
"tx": self.tx,
|
||||
"heirs": self.heirs,
|
||||
"willexecutor": self.we,
|
||||
"status": self.status,
|
||||
"description": self.description,
|
||||
"time": self.time,
|
||||
"change": self.change,
|
||||
"baltx_fees": self.tx_fees,
|
||||
}
|
||||
for key in self.STATUS:
|
||||
try:
|
||||
out[key] = self.STATUS[key][1]
|
||||
except Exception as e:
|
||||
_logger.error(f"{key},{self.STATUS[key]} {e}")
|
||||
|
||||
return out
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.to_dict())
|
||||
|
||||
def set_anticipate(self, ow: "WillItem"):
|
||||
nl = min(ow.tx.locktime, Will.check_anticipate(ow, self))
|
||||
if int(nl) < self.tx.locktime:
|
||||
self.tx.locktime = int(nl)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def search_anticipate(self, all_inputs):
|
||||
anticipated = False
|
||||
for ow in self.search(all_inputs):
|
||||
if self.set_anticipate(ow):
|
||||
anticipated = True
|
||||
return anticipated
|
||||
|
||||
def search(self, all_inputs):
|
||||
for inp in self.tx.inputs():
|
||||
prevout_str = inp.prevout.to_str()
|
||||
oinps = all_inputs.get(prevout_str, [])
|
||||
for oinp in oinps:
|
||||
ow = oinp[1]
|
||||
if ow._id != self._id:
|
||||
yield ow
|
||||
|
||||
def normalize_locktime(self, all_inputs):
|
||||
outputs = self.tx.outputs()
|
||||
for idx in range(0, len(outputs)):
|
||||
inps = all_inputs.get(f"{self._id}:{idx}", [])
|
||||
_logger.debug("****check locktime***")
|
||||
for inp in inps:
|
||||
if inp[0] != self._id:
|
||||
iw = inp[1]
|
||||
self.set_anticipate(iw)
|
||||
|
||||
def set_check_willexecutor(self,resp):
|
||||
try:
|
||||
if resp :
|
||||
if "tx" in resp and resp["tx"] == str(self.tx):
|
||||
self.set_status("PUSHED")
|
||||
self.set_status("CHECKED")
|
||||
else:
|
||||
self.set_status("CHECK_FAIL")
|
||||
self.set_status("PUSHED", False)
|
||||
return True
|
||||
else:
|
||||
self.set_status("CHECK_FAIL")
|
||||
self.set_status("PUSHED", False)
|
||||
return False
|
||||
except Exception as e:
|
||||
_logger.error(f"exception checking transaction: {e}")
|
||||
self.set_status("CHECK_FAIL")
|
||||
|
||||
# NOTE: the former ``get_color()`` method (which returned hard-coded hex
|
||||
# colours for the GUI) has been moved out of the core logic to
|
||||
# ``bal.gui.qt.theme.status_color``. The status flags above remain the
|
||||
# single source of truth; the GUI maps them to colours.
|
||||
|
||||
|
||||
class WillException(Exception):
|
||||
def __init__(self,msg="WillException"):
|
||||
self.msg=msg
|
||||
Exception.__init__(self)
|
||||
def __str__(self):
|
||||
return self.msg
|
||||
|
||||
|
||||
|
||||
class WillExpiredException(WillException):
|
||||
pass
|
||||
|
||||
|
||||
class NotCompleteWillException(WillException):
|
||||
pass
|
||||
|
||||
|
||||
class HeirChangeException(NotCompleteWillException):
|
||||
pass
|
||||
|
||||
|
||||
class TxFeesChangedException(NotCompleteWillException):
|
||||
pass
|
||||
|
||||
|
||||
class HeirNotFoundException(NotCompleteWillException):
|
||||
pass
|
||||
|
||||
|
||||
class WillexecutorChangeException(NotCompleteWillException):
|
||||
pass
|
||||
|
||||
|
||||
class NoWillExecutorNotPresent(NotCompleteWillException):
|
||||
pass
|
||||
|
||||
|
||||
class WillExecutorNotPresent(NotCompleteWillException):
|
||||
pass
|
||||
|
||||
|
||||
class NoHeirsException(WillException):
|
||||
pass
|
||||
class AmountException(WillException):
|
||||
pass
|
||||
|
||||
|
||||
class PercAmountException(AmountException):
|
||||
pass
|
||||
|
||||
|
||||
class FixedAmountException(AmountException):
|
||||
pass
|
||||
390
bal/core/willexecutors.py
Normal file
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
bal.core.willexecutors
|
||||
=======================
|
||||
|
||||
Client logic for talking to *will-executor* servers.
|
||||
|
||||
A will-executor is an optional third-party service that, for a small fee,
|
||||
stores the signed inheritance transactions off-line and broadcasts them once
|
||||
their locktime expires (acting as a dead-man's switch backup).
|
||||
|
||||
This module only contains the networking / data-shaping logic (downloading the
|
||||
server list, pinging servers for their fee and address, pushing transactions,
|
||||
checking whether a tx is already stored). It is GUI-free: all user
|
||||
interaction is handled by the Qt layer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from aiohttp import ClientResponse
|
||||
from electrum.i18n import _
|
||||
from electrum.logging import get_logger
|
||||
from electrum.network import Network
|
||||
|
||||
from .plugin_base import BalPlugin
|
||||
|
||||
DEFAULT_TIMEOUT = 5
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
|
||||
chainname = BalPlugin.chainname
|
||||
|
||||
|
||||
class Willexecutors:
|
||||
|
||||
@staticmethod
|
||||
def save(bal_plugin, willexecutors):
|
||||
_logger.debug(f"save {willexecutors},{chainname}")
|
||||
aw = bal_plugin.WILLEXECUTORS.get()
|
||||
aw[chainname] = willexecutors
|
||||
bal_plugin.WILLEXECUTORS.set(aw)
|
||||
_logger.debug(f"saved: {aw}")
|
||||
# bal_plugin.WILLEXECUTORS.set(willexecutors)
|
||||
|
||||
@staticmethod
|
||||
def get_willexecutors(
|
||||
bal_plugin, update=False, bal_window=False, force=False, task=True
|
||||
):
|
||||
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
||||
willexecutors = willexecutors.get(chainname, {})
|
||||
to_del = []
|
||||
for w in willexecutors:
|
||||
if not isinstance(willexecutors[w], dict):
|
||||
to_del.append(w)
|
||||
continue
|
||||
Willexecutors.initialize_willexecutor(willexecutors[w], w)
|
||||
for w in to_del:
|
||||
_logger.error(
|
||||
"error Willexecutor to delete type:{} {}".format(
|
||||
type(willexecutors[w]), w
|
||||
)
|
||||
)
|
||||
del willexecutors[w]
|
||||
bal = bal_plugin.WILLEXECUTORS.default.get(chainname, {})
|
||||
for bal_url, bal_executor in bal.items():
|
||||
if bal_url not in willexecutors:
|
||||
_logger.debug(f"force add {bal_url} willexecutor")
|
||||
willexecutors[bal_url] = bal_executor
|
||||
# if update:
|
||||
# found = False
|
||||
# for url, we in willexecutors.items():
|
||||
# if Willexecutors.is_selected(we):
|
||||
# found = True
|
||||
# if found or force:
|
||||
# if bal_plugin.PING_WILLEXECUTORS.get() or force:
|
||||
# ping_willexecutors = True
|
||||
# if bal_plugin.ASK_PING_WILLEXECUTORS.get() and not force:
|
||||
# if bal_window:
|
||||
# ping_willexecutors = bal_window.window.question(
|
||||
# _(
|
||||
# "Contact willexecutors servers to update payment informations?"
|
||||
# )
|
||||
# )
|
||||
|
||||
# if ping_willexecutors:
|
||||
# if task:
|
||||
# bal_window.ping_willexecutors(willexecutors, task)
|
||||
# else:
|
||||
# bal_window.ping_willexecutors_task(willexecutors)
|
||||
w_sorted = dict(
|
||||
sorted(
|
||||
willexecutors.items(), key=lambda w: w[1].get("sort", 0), reverse=True
|
||||
)
|
||||
)
|
||||
return w_sorted
|
||||
|
||||
@staticmethod
|
||||
def is_selected(willexecutor, value=None):
|
||||
if not willexecutor:
|
||||
return False
|
||||
if value is not None:
|
||||
willexecutor["selected"] = value
|
||||
try:
|
||||
return willexecutor["selected"]
|
||||
except Exception:
|
||||
willexecutor["selected"] = False
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_willexecutor_transactions(will, force=False):
|
||||
willexecutors = {}
|
||||
for wid, willitem in will.items():
|
||||
if willitem.get_status("VALID"):
|
||||
if willitem.get_status("COMPLETE"):
|
||||
if not willitem.get_status("PUSHED") or force:
|
||||
if willexecutor := willitem.we:
|
||||
url = willexecutor["url"]
|
||||
if willexecutor and Willexecutors.is_selected(willexecutor):
|
||||
if url not in willexecutors:
|
||||
willexecutor["txs"] = ""
|
||||
willexecutor["txsids"] = []
|
||||
willexecutor["broadcast_status"] = _("Waiting...")
|
||||
willexecutors[url] = willexecutor
|
||||
willexecutors[url]["txs"] += str(willitem.tx) + "\n"
|
||||
willexecutors[url]["txsids"].append(wid)
|
||||
|
||||
return willexecutors
|
||||
|
||||
# def only_selected_list(willexecutors):
|
||||
# out = {}
|
||||
# for url, v in willexecutors.items():
|
||||
# if Willexecutors.is_selected(url):
|
||||
# out[url] = v
|
||||
|
||||
# def push_transactions_to_willexecutors(will):
|
||||
# willexecutors = Willexecutors.get_transactions_to_be_pushed()
|
||||
# for url in willexecutors:
|
||||
# willexecutor = willexecutors[url]
|
||||
# if Willexecutors.is_selected(willexecutor):
|
||||
# if "txs" in willexecutor:
|
||||
# Willexecutors.push_transactions_to_willexecutor(
|
||||
# willexecutors[url]["txs"], url
|
||||
# )
|
||||
|
||||
@staticmethod
|
||||
def send_request(
|
||||
method, url, data=None, *, timeout=10, handle_response=None, count_reply=0
|
||||
):
|
||||
network = Network.get_instance()
|
||||
if not network:
|
||||
raise Exception("You are offline.")
|
||||
_logger.debug(f"<-- {method} {url} {data}")
|
||||
headers = {}
|
||||
headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}"
|
||||
headers["Content-Type"] = "text/plain"
|
||||
if not handle_response:
|
||||
handle_response = Willexecutors.handle_response
|
||||
try:
|
||||
if method == "get":
|
||||
response = Network.send_http_on_proxy(
|
||||
method,
|
||||
url,
|
||||
params=data,
|
||||
headers=headers,
|
||||
on_finish=handle_response,
|
||||
timeout=timeout,
|
||||
)
|
||||
elif method == "post":
|
||||
response = Network.send_http_on_proxy(
|
||||
method,
|
||||
url,
|
||||
body=data,
|
||||
headers=headers,
|
||||
on_finish=handle_response,
|
||||
timeout=timeout,
|
||||
)
|
||||
else:
|
||||
raise Exception(f"unexpected {method=!r}")
|
||||
except TimeoutError:
|
||||
if count_reply < 10:
|
||||
_logger.debug(f"timeout({count_reply}) error: retry in 3 sec...")
|
||||
time.sleep(3)
|
||||
return Willexecutors.send_request(
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
timeout=timeout,
|
||||
handle_response=handle_response,
|
||||
count_reply=count_reply + 1,
|
||||
)
|
||||
else:
|
||||
_logger.debug(f"Too many timeouts: {count_reply}")
|
||||
except Exception as e:
|
||||
raise e
|
||||
else:
|
||||
_logger.debug(f"--> {response}")
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def get_we_url_from_response(resp):
|
||||
url_slices = str(resp.url).split("/")
|
||||
if len(url_slices) > 2:
|
||||
url_slices = url_slices[:-2]
|
||||
return "/".join(url_slices)
|
||||
|
||||
@staticmethod
|
||||
async def handle_response(resp: ClientResponse):
|
||||
r = await resp.text()
|
||||
try:
|
||||
|
||||
r = json.loads(r)
|
||||
# url = Willexecutors.get_we_url_from_response(resp)
|
||||
# r["url"]= url
|
||||
# r["status"]=resp.status
|
||||
except Exception as e:
|
||||
_logger.debug(f"error handling response:{e}")
|
||||
pass
|
||||
return r
|
||||
|
||||
@staticmethod
|
||||
class AlreadyPresentException(Exception):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def push_transactions_to_willexecutor(willexecutor):
|
||||
out = True
|
||||
try:
|
||||
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
|
||||
if w := Willexecutors.send_request(
|
||||
"post",
|
||||
willexecutor["url"] + "/" + chainname + "/pushtxs",
|
||||
data=willexecutor["txs"].encode("ascii"),
|
||||
):
|
||||
willexecutor["broadcast_status"] = _("Success")
|
||||
_logger.debug(f"pushed: {w}")
|
||||
if w != "thx":
|
||||
_logger.debug(f"error: {w}")
|
||||
raise Exception(w)
|
||||
else:
|
||||
raise Exception("empty reply from:{willexecutor['url']}")
|
||||
except Exception as e:
|
||||
_logger.debug(f"error:{e}")
|
||||
if str(e) == "already present":
|
||||
raise Willexecutors.AlreadyPresentException()
|
||||
out = False
|
||||
willexecutor["broadcast_status"] = _("Failed")
|
||||
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def ping_servers(willexecutors):
|
||||
for url, we in willexecutors.items():
|
||||
Willexecutors.get_info_task(url, we)
|
||||
|
||||
@staticmethod
|
||||
def get_info_task(url, willexecutor):
|
||||
w = None
|
||||
try:
|
||||
_logger.info("GETINFO_WILLEXECUTOR")
|
||||
_logger.debug(url)
|
||||
w = Willexecutors.send_request("get", url + "/" + chainname + "/info")
|
||||
if isinstance(w, dict):
|
||||
willexecutor["url"] = url
|
||||
willexecutor["status"] = 200
|
||||
willexecutor["base_fee"] = w["base_fee"]
|
||||
willexecutor["address"] = w["address"]
|
||||
willexecutor["info"] = w["info"]
|
||||
_logger.debug(f"response_data {w}")
|
||||
except Exception as e:
|
||||
_logger.error(f"error {e} contacting {url}: {w}")
|
||||
willexecutor["status"] = "KO"
|
||||
|
||||
willexecutor["last_update"] = datetime.now().timestamp()
|
||||
return willexecutor
|
||||
|
||||
@staticmethod
|
||||
def initialize_willexecutor(willexecutor, url, status=None, old_willexecutor=None):
|
||||
old_willexecutor=old_willexecutor if old_willexecutor is not None else {}
|
||||
willexecutor["url"] = url
|
||||
if status is not None:
|
||||
willexecutor["status"] = status
|
||||
else:
|
||||
willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
|
||||
willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
|
||||
willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address",""))
|
||||
willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))
|
||||
|
||||
|
||||
|
||||
@staticmethod
|
||||
def download_list(old_willexecutors,welist_server):
|
||||
try:
|
||||
welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
|
||||
willexecutors = Willexecutors.send_request(
|
||||
"get",
|
||||
f"{welist_server}data/{chainname}?page=0&limit=100",
|
||||
)
|
||||
# del willexecutors["status"]
|
||||
for w in willexecutors:
|
||||
if w not in ("status", "url"):
|
||||
Willexecutors.initialize_willexecutor(
|
||||
willexecutors[w], w, None, old_willexecutors.get(w,None)
|
||||
)
|
||||
# bal_plugin.WILLEXECUTORS.set(l)
|
||||
# bal_plugin.config.set_key(bal_plugin.WILLEXECUTORS,l,save=True)
|
||||
return willexecutors
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(f"Failed to download willexecutors list: {e}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def get_willexecutors_list_from_json():
|
||||
try:
|
||||
with open("willexecutors.json") as f:
|
||||
willexecutors = json.load(f)
|
||||
for w in willexecutors:
|
||||
willexecutor = willexecutors[w]
|
||||
Willexecutors.initialize_willexecutor(willexecutor, w, "New", False)
|
||||
# bal_plugin.WILLEXECUTORS.set(willexecutors)
|
||||
return willexecutors
|
||||
except Exception as e:
|
||||
_logger.error(f"error opening willexecutors json: {e}")
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def check_transaction(txid, url):
|
||||
_logger.debug(f"{url}:{txid}")
|
||||
try:
|
||||
w = Willexecutors.send_request(
|
||||
"post", url + "/searchtx", data=txid.encode("ascii")
|
||||
)
|
||||
return w
|
||||
except Exception as e:
|
||||
_logger.error(f"error contacting {url} for checking txs {e}")
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def compute_id(willexecutor):
|
||||
return "{}-{}".format(willexecutor.get("url"), willexecutor.get("chain"))
|
||||
|
||||
|
||||
#class WillExecutor:
|
||||
# def __init__(
|
||||
# self,
|
||||
# url,
|
||||
# base_fee,
|
||||
# chain,
|
||||
# info,
|
||||
# version,
|
||||
# status,
|
||||
# is_selected=False,
|
||||
# promo_code="",
|
||||
# ):
|
||||
# self.url = url
|
||||
# self.base_fee = base_fee
|
||||
# self.chain = chain
|
||||
# self.info = info
|
||||
# self.version = version
|
||||
# self.status = status
|
||||
# self.promo_code = promo_code
|
||||
# self.is_selected = is_selected
|
||||
# self.id = self.compute_id()
|
||||
#
|
||||
# def from_dict(d):
|
||||
# return WillExecutor(
|
||||
# url=d.get("url", "http://localhost:8000"),
|
||||
# base_fee=d.get("base_fee", 1000),
|
||||
# chain=d.get("chain", chainname),
|
||||
# info=d.get("info", ""),
|
||||
# version=d.get("version", 0),
|
||||
# status=d.get("status", "Ko"),
|
||||
# is_selected=d.get("is_selected", "False"),
|
||||
# promo_code=d.get("promo_code", ""),
|
||||
# )
|
||||
#
|
||||
# def to_dict(self):
|
||||
# return {
|
||||
# "url": self.url,
|
||||
# "base_fee": self.base_fee,
|
||||
# "chain": self.chain,
|
||||
# "info": self.info,
|
||||
# "version": self.version,
|
||||
# "promo_code": self.promo_code,
|
||||
# }
|
||||
#
|
||||
# def compute_id(self):
|
||||
# return f"{self.url}-{self.chain}"
|
||||
0
bal/gui/__init__.py
Normal file
17
bal/gui/qt/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
bal.gui.qt
|
||||
==========
|
||||
|
||||
The PyQt6 graphical interface of the Bitcoin After Life plugin.
|
||||
|
||||
Module map (was previously one 4000-line ``qt.py``):
|
||||
|
||||
common.py - shared imports + tiny helpers (shown_cv, add_widget, ...)
|
||||
theme.py - colour mapping for will-item statuses (was WillItem.get_color)
|
||||
calendar.py - .ics calendar generation
|
||||
widgets.py - reusable leaf widgets (editors, checkboxes, will box, ...)
|
||||
dialogs.py - all dialogs (settings, wizard, build-will, detail, ...)
|
||||
lists.py - tree views (heirs, preview, will-executors)
|
||||
window.py - BalWindow controller (one per wallet window)
|
||||
plugin.py - Plugin class with the Electrum @hook methods (entry point)
|
||||
"""
|
||||
80
bal/gui/qt/calendar.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
bal.gui.qt.calendar
|
||||
===================
|
||||
|
||||
iCalendar (.ics) generation and "open with default calendar app" helper.
|
||||
|
||||
When a will is built, the plugin can create a calendar event reminding the user
|
||||
to "check in" before the locktime expires. This module turns the event data
|
||||
into an RFC-5545 .ics file and opens it with the OS default application.
|
||||
"""
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
|
||||
class BalCalendar:
|
||||
@staticmethod
|
||||
def write_temp_ics(content):
|
||||
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(content.encode("utf-8"))
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def open_with_default_app(calendar_app, path):
|
||||
_logger.debug("opening calendar app")
|
||||
try:
|
||||
subprocess.check_call([calendar_app, path])
|
||||
return True
|
||||
except Exception as e:
|
||||
_logger.error(f"starting calendar app {e}")
|
||||
return False
|
||||
|
||||
|
||||
@staticmethod
|
||||
def format_time(time):
|
||||
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
|
||||
|
||||
@staticmethod
|
||||
def ical_escape(text: str) -> str:
|
||||
# escape per RFC5545: backslash, ; , newlines
|
||||
text = text.encode("utf-8")
|
||||
text = (
|
||||
text.replace(b"\\", b"\\\\")
|
||||
.replace(b";", b"\\;")
|
||||
.replace(b",", b"\\,")
|
||||
)
|
||||
out =""
|
||||
temp=text.split(b"\r\n")
|
||||
for s in temp:
|
||||
encoded= s
|
||||
cut =0
|
||||
while len(encoded) >75:
|
||||
cut+=5
|
||||
encoded=f"{s[:len(s)-cut]}"
|
||||
if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\":
|
||||
cut += 1
|
||||
encoded=f"{s[:len(s)-cut]}"
|
||||
encoded=f"{encoded}...\r\n".encode("utf-8")
|
||||
if cut>0:
|
||||
out+=str(f"{s[:len(s)-cut].decode()}...\r\n")
|
||||
else:
|
||||
out+=str(f"{s.decode()}\r\n")
|
||||
|
||||
return out[:-2]
|
||||
|
||||
@staticmethod
|
||||
def fold_ical_line(line: str, limit: int = 75) -> str:
|
||||
# ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive
|
||||
encoded = line.encode("utf-8")
|
||||
parts = []
|
||||
while len(encoded) > limit:
|
||||
# taglia senza spezzare byte UTF-8
|
||||
cut = limit
|
||||
while (encoded[cut] & 0xC0) == 0x80: # byte di continuazione UTF-8
|
||||
cut -= 1
|
||||
parts.append(encoded[:cut].decode("utf-8"))
|
||||
encoded = encoded[cut:]
|
||||
parts.append(encoded.decode("utf-8"))
|
||||
return "\r\n ".join(parts)
|
||||
155
bal/gui/qt/common.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
bal.gui.qt.common
|
||||
=================
|
||||
|
||||
Shared imports and tiny helper utilities for the Qt GUI layer.
|
||||
|
||||
Every other ``bal.gui.qt`` module does ``from .common import *`` so that the
|
||||
long list of Electrum / PyQt6 imports lives in a single place. This file also
|
||||
hosts a few GUI helpers that do not deserve a module of their own:
|
||||
|
||||
* :class:`shown_cv` - trivial mutable "is this tab shown?" holder.
|
||||
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
|
||||
* :func:`log_error` - format an exception traceback for a dialog.
|
||||
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
|
||||
* :class:`CheckAliveError`- raised when the "check alive" date is in the past.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import enum
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Mapping, Optional, Union
|
||||
|
||||
from electrum.bitcoin import (NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX,
|
||||
NLOCKTIME_MIN)
|
||||
from electrum.gui.qt.amountedit import BTCAmountEdit
|
||||
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
||||
from electrum.gui.qt.my_treeview import MyTreeView
|
||||
from electrum.gui.qt.password_dialog import PasswordDialog
|
||||
from electrum.gui.qt.transaction_dialog import TxDialog
|
||||
from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme,
|
||||
EnterButton, HelpButton, MessageBoxMixin,
|
||||
OkButton, TaskThread, WindowModalDialog,
|
||||
char_width_in_lineedit, getSaveFileName,
|
||||
import_meta_gui, read_QIcon_from_bytes,
|
||||
read_QPixmap_from_bytes)
|
||||
from electrum.i18n import _
|
||||
from electrum.logging import get_logger
|
||||
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
|
||||
from electrum.payment_identifier import PaymentIdentifier
|
||||
from electrum.plugin import hook
|
||||
from electrum.transaction import SerializationError, Transaction, tx_from_any
|
||||
from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled,
|
||||
decimal_point_to_base_unit_name, read_json_file,
|
||||
write_json_file)
|
||||
from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, Qt,
|
||||
QTimer, pyqtSignal)
|
||||
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem,
|
||||
QStandardItemModel)
|
||||
from PyQt6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox,
|
||||
QDateTimeEdit, QGridLayout, QHBoxLayout, QLabel,
|
||||
QLineEdit, QTextEdit, QMenu, QMenuBar, QPushButton,
|
||||
QScrollArea, QSizePolicy, QSpinBox,
|
||||
QStackedWidget, QStyle, QStyleOptionFrame,
|
||||
QVBoxLayout, QWidget, QDialog)
|
||||
|
||||
# --- Core (GUI-free) logic layer ---
|
||||
from ...core.plugin_base import BalPlugin, BalTimestamp
|
||||
from ...core.heirs import HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT, Heirs
|
||||
from ...core.util import Util
|
||||
from ...core.will import (AmountException, HeirChangeException,
|
||||
HeirNotFoundException, NoHeirsException,
|
||||
NotCompleteWillException, NoWillExecutorNotPresent,
|
||||
TxFeesChangedException, Will,
|
||||
WillexecutorChangeException, WillExecutorNotPresent,
|
||||
WillExpiredException, WillItem)
|
||||
from ...core.willexecutors import Willexecutors
|
||||
|
||||
# --- Presentation helpers ---
|
||||
from .theme import status_color
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
|
||||
class shown_cv:
|
||||
_type = bool
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def get(self):
|
||||
return self.value
|
||||
|
||||
def set(self, value):
|
||||
self.value = value
|
||||
|
||||
|
||||
|
||||
|
||||
def add_widget(grid, label, widget, row, help_):
|
||||
grid.addWidget(QLabel(_(label)), row, 0)
|
||||
grid.addWidget(widget, row, 1)
|
||||
grid.addWidget(HelpButton(help_), row, 2)
|
||||
|
||||
|
||||
|
||||
|
||||
class CheckAliveError(Exception):
|
||||
def __init__(self, timestamp_to_check):
|
||||
self.timestamp_to_check = timestamp_to_check
|
||||
|
||||
def __str__(self):
|
||||
return "Check alive expired please update it: {}".format(
|
||||
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def log_error(exec_info, window=None):
|
||||
_logger.error(f"LOG_ERROR: {exec_info}")
|
||||
#tb = traceback.format_exc()
|
||||
try:
|
||||
tb=exec_info[1]
|
||||
_logger.error(tb)
|
||||
except Exception:
|
||||
tb = traceback.format_exc()
|
||||
_logger.error(tb)
|
||||
|
||||
|
||||
if window is not None:
|
||||
window.show_error(exec_info)
|
||||
|
||||
|
||||
|
||||
|
||||
def export_meta_gui(electrum_window, title, exporter):
|
||||
filter_ = "All files (*)"
|
||||
filename = getSaveFileName(
|
||||
parent=electrum_window,
|
||||
title=_("Select file to save your {}".format(title)),
|
||||
filename="BALplugin_{}_{}_{}".format(
|
||||
BalPlugin.chainname, str(electrum_window.wallet), title
|
||||
),
|
||||
filter=filter_,
|
||||
config=electrum_window.config,
|
||||
)
|
||||
if not filename:
|
||||
return
|
||||
try:
|
||||
exporter(filename)
|
||||
except FileExportFailed as e:
|
||||
electrum_window.show_critical(str(e))
|
||||
else:
|
||||
electrum_window.show_message(
|
||||
_("Your {0} were exported to '{1}'".format(title, str(filename)))
|
||||
)
|
||||
|
||||
|
||||
1127
bal/gui/qt/dialogs.py
Normal file
957
bal/gui/qt/lists.py
Normal file
@@ -0,0 +1,957 @@
|
||||
"""
|
||||
bal.gui.qt.lists
|
||||
================
|
||||
|
||||
Tree/list views (subclasses of Electrum's ``MyTreeView``) and their toolbars.
|
||||
|
||||
* HeirListWidget - editable list of heirs (address / amount / locktime).
|
||||
* PreviewList - preview of the will transactions before signing.
|
||||
* WillExecutorListWidget- list of will-executor servers.
|
||||
* WillExecutorWidget - container combining the list with add/import buttons.
|
||||
|
||||
These views call back into the :class:`BalWindow` controller (passed at
|
||||
construction) for all business actions, so the heavy logic stays in ``window``
|
||||
and ``dialogs``.
|
||||
"""
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget
|
||||
from .dialogs import BalBuildWillDialog
|
||||
|
||||
|
||||
class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||
class Columns(MyTreeView.BaseColumnsEnum):
|
||||
NAME = enum.auto()
|
||||
ADDRESS = enum.auto()
|
||||
AMOUNT = enum.auto()
|
||||
|
||||
headers = {
|
||||
Columns.NAME: _("Name"),
|
||||
Columns.ADDRESS: _("Address"),
|
||||
Columns.AMOUNT: _("Amount"),
|
||||
}
|
||||
filter_columns = [Columns.NAME, Columns.ADDRESS]
|
||||
|
||||
ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1000
|
||||
|
||||
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 4000
|
||||
key_role = ROLE_HEIR_KEY
|
||||
|
||||
def createEditor(self, parent, option, index):
|
||||
return QLineEdit(parent)
|
||||
|
||||
def setEditorData(self, editor, index):
|
||||
editor.setText(index.data())
|
||||
|
||||
def setModelData(self, editor, model, index):
|
||||
model.setData(index, editor.text())
|
||||
|
||||
def __init__(self, bal_window: "BalWindow", parent):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
main_window=bal_window.window,
|
||||
stretch_column=self.Columns.NAME,
|
||||
editable_columns=[
|
||||
self.Columns.NAME,
|
||||
self.Columns.ADDRESS,
|
||||
self.Columns.AMOUNT,
|
||||
],
|
||||
)
|
||||
self.decimal_point = bal_window.window.get_decimal_point()
|
||||
self.bal_window = bal_window
|
||||
|
||||
try:
|
||||
self.setModel(QStandardItemModel(self))
|
||||
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.setSortingEnabled(True)
|
||||
self.std_model = self.model()
|
||||
|
||||
self.update()
|
||||
|
||||
def on_activated(self, idx):
|
||||
self.on_double_click(idx)
|
||||
|
||||
def on_double_click(self, idx):
|
||||
edit_key = self.get_edit_key_from_coordinate(idx.row(), idx.column())
|
||||
self.bal_window.heirs.get(edit_key)
|
||||
self.bal_window.new_heir_dialog(edit_key)
|
||||
|
||||
def on_edited(self, idx, edit_key, *, text):
|
||||
original = prior_name = self.bal_window.heirs.get(edit_key)
|
||||
if not prior_name:
|
||||
return
|
||||
col = idx.column()
|
||||
try:
|
||||
if col == 2:
|
||||
text = Util.encode_amount(text, self.decimal_point)
|
||||
elif col == 0:
|
||||
self.bal_window.delete_heirs([edit_key])
|
||||
edit_key = text
|
||||
prior_name[col - 1] = text
|
||||
prior_name.insert(0, edit_key)
|
||||
prior_name = tuple(prior_name)
|
||||
except Exception:
|
||||
prior_name = (
|
||||
(edit_key,) + prior_name[: col - 1] + (text,) + prior_name[col:]
|
||||
)
|
||||
|
||||
try:
|
||||
self.bal_window.set_heir(prior_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.bal_window.set_heir((edit_key,) + original)
|
||||
except Exception:
|
||||
self.update()
|
||||
|
||||
def delete_heirs(self, selected_keys):
|
||||
self.bal_window.delete_heirs(selected_keys)
|
||||
self.update()
|
||||
|
||||
def create_menu(self, position):
|
||||
menu = QMenu()
|
||||
idx = self.indexAt(position)
|
||||
column = idx.column() or self.Columns.NAME
|
||||
selected_keys = []
|
||||
for s_idx in self.selected_in_column(self.Columns.NAME):
|
||||
sel_key = self.model().itemFromIndex(s_idx).data(0)
|
||||
selected_keys.append(sel_key)
|
||||
if selected_keys and idx.isValid():
|
||||
column_title = self.model().horizontalHeaderItem(column).text()
|
||||
# ok
|
||||
column_data = "\n".join(
|
||||
self.model().itemFromIndex(s_idx).text()
|
||||
for s_idx in self.selected_in_column(column)
|
||||
)
|
||||
menu.addAction(
|
||||
_("Copy {}").format(column_title),
|
||||
lambda: self.place_text_on_clipboard(column_data, title=column_title),
|
||||
)
|
||||
if column in self.editable_columns:
|
||||
item = self.model().itemFromIndex(idx)
|
||||
if item.isEditable():
|
||||
persistent = QPersistentModelIndex(idx)
|
||||
menu.addAction(
|
||||
_("Edit {}").format(column_title),
|
||||
lambda p=persistent: self.edit(QModelIndex(p)),
|
||||
)
|
||||
menu.addAction(_("Delete"), lambda: self.delete_heirs(selected_keys))
|
||||
menu.exec(self.viewport().mapToGlobal(position))
|
||||
|
||||
def update(self):
|
||||
current_key = self.get_role_data_for_current_item(
|
||||
col=self.Columns.NAME, role=self.ROLE_HEIR_KEY
|
||||
)
|
||||
self.model().clear()
|
||||
self.update_headers(self.__class__.headers)
|
||||
set_current = None
|
||||
for key in sorted(self.bal_window.heirs.keys()):
|
||||
heir = self.bal_window.heirs[key]
|
||||
labels = [""] * len(self.Columns)
|
||||
labels[self.Columns.NAME] = key
|
||||
labels[self.Columns.ADDRESS] = heir[0]
|
||||
labels[self.Columns.AMOUNT] = Util.decode_amount(
|
||||
heir[1], self.decimal_point
|
||||
)
|
||||
|
||||
items = [QStandardItem(x) for x in labels]
|
||||
items[self.Columns.NAME].setEditable(True)
|
||||
items[self.Columns.ADDRESS].setEditable(True)
|
||||
items[self.Columns.AMOUNT].setEditable(True)
|
||||
items[self.Columns.NAME].setData(
|
||||
key, self.ROLE_HEIR_KEY + self.Columns.NAME
|
||||
)
|
||||
items[self.Columns.ADDRESS].setData(
|
||||
key, self.ROLE_HEIR_KEY + self.Columns.ADDRESS
|
||||
)
|
||||
items[self.Columns.AMOUNT].setData(
|
||||
key, self.ROLE_HEIR_KEY + self.Columns.AMOUNT
|
||||
)
|
||||
|
||||
row_count = self.model().rowCount()
|
||||
self.model().insertRow(row_count, items)
|
||||
|
||||
if key == current_key:
|
||||
idx = self.model().index(row_count, self.Columns.NAME)
|
||||
set_current = QPersistentModelIndex(idx)
|
||||
try:
|
||||
self.will_settings_widget.on_locktime_change()
|
||||
except Exception as e:
|
||||
pass
|
||||
self.set_current_idx(set_current)
|
||||
# FIXME refresh loses sort order; so set "default" here:
|
||||
self.filter()
|
||||
|
||||
def refresh_row(self, key, row):
|
||||
# nothing to update here
|
||||
pass
|
||||
|
||||
def get_edit_key_from_coordinate(self, row, col):
|
||||
a = self.get_role_data_from_coordinate(row, col, role=self.ROLE_HEIR_KEY + col)
|
||||
return a
|
||||
|
||||
def create_toolbar(self, config):
|
||||
toolbar, menu = self.create_toolbar_with_menu("")
|
||||
menu.addAction(_("&New Heir"), self.bal_window.new_heir_dialog)
|
||||
menu.addAction(_("Import"), self.bal_window.import_heirs)
|
||||
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
|
||||
|
||||
newHeirButton = QPushButton(_("New Heir"))
|
||||
newHeirButton.clicked.connect(self.bal_window.new_heir_dialog)
|
||||
|
||||
widget = QWidget(self)
|
||||
layout = QHBoxLayout(widget)
|
||||
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
||||
|
||||
layout.addWidget(self.will_settings_widget)
|
||||
layout.addWidget(newHeirButton)
|
||||
|
||||
toolbar.insertWidget(2, widget)
|
||||
|
||||
return toolbar
|
||||
|
||||
def build_transactions(self):
|
||||
# will = self.bal_window.prepare_will()
|
||||
self.bal_window.prepare_will()
|
||||
|
||||
|
||||
|
||||
class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
class Columns(MyTreeView.BaseColumnsEnum):
|
||||
LOCKTIME = enum.auto()
|
||||
TXID = enum.auto()
|
||||
WILLEXECUTOR = enum.auto()
|
||||
STATUS = enum.auto()
|
||||
|
||||
headers = {
|
||||
Columns.LOCKTIME: _("Locktime"),
|
||||
Columns.TXID: _("Txid"),
|
||||
Columns.WILLEXECUTOR: _("Will-Executor"),
|
||||
Columns.STATUS: _("Status"),
|
||||
}
|
||||
|
||||
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
|
||||
key_role = ROLE_HEIR_KEY
|
||||
|
||||
def createEditor(self, parent, option, index):
|
||||
return QLineEdit(parent)
|
||||
|
||||
def setEditorData(self, editor, index):
|
||||
editor.setText(index.data())
|
||||
|
||||
def setModelData(self, editor, model, index):
|
||||
model.setData(index, editor.text())
|
||||
|
||||
def __init__(self, bal_window: "BalWindow", parent, will):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
main_window=bal_window.window,
|
||||
stretch_column=self.Columns.TXID,
|
||||
)
|
||||
# self.parent = parent
|
||||
self.bal_window = bal_window
|
||||
self.decimal_point = bal_window.window.get_decimal_point
|
||||
|
||||
if will is not None:
|
||||
self.will = will
|
||||
else:
|
||||
self.will = bal_window.willitems
|
||||
|
||||
try:
|
||||
self.setModel(QStandardItemModel(self))
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
self.setSortingEnabled(True)
|
||||
self.std_model = self.model()
|
||||
|
||||
self.update()
|
||||
|
||||
def on_activated(self, idx):
|
||||
self.on_double_click(idx)
|
||||
|
||||
def on_double_click(self, idx):
|
||||
idx = self.model().index(idx.row(), self.Columns.TXID)
|
||||
sel_key = self.model().itemFromIndex(idx).data(0)
|
||||
self.show_transaction([sel_key])
|
||||
|
||||
def create_menu(self, position):
|
||||
menu = QMenu()
|
||||
idx = self.indexAt(position)
|
||||
column = idx.column() or self.Columns.TXID
|
||||
selected_keys = []
|
||||
for s_idx in self.selected_in_column(self.Columns.TXID):
|
||||
sel_key = self.model().itemFromIndex(s_idx).data(0)
|
||||
selected_keys.append(sel_key)
|
||||
if selected_keys and idx.isValid():
|
||||
column_title = self.model().horizontalHeaderItem(column).text()
|
||||
# column_data = "\n".join(
|
||||
# self.model().itemFromIndex(s_idx).text()
|
||||
# for s_idx in self.selected_in_column(column)
|
||||
# )
|
||||
|
||||
menu.addAction(
|
||||
_("details").format(column_title),
|
||||
lambda: self.show_transaction(selected_keys),
|
||||
).setEnabled(len(selected_keys) < 2)
|
||||
menu.addAction(
|
||||
_("check ").format(column_title),
|
||||
lambda: self.check_transactions(selected_keys),
|
||||
)
|
||||
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
|
||||
try:
|
||||
self.importaction = self.menu.addAction(
|
||||
_("Import"), self.import_will
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
menu.addSeparator()
|
||||
menu.addAction(
|
||||
_("delete").format(column_title), lambda: self.delete(selected_keys)
|
||||
)
|
||||
|
||||
menu.exec(self.viewport().mapToGlobal(position))
|
||||
|
||||
def delete(self, selected_keys):
|
||||
for key in selected_keys:
|
||||
del self.will[key]
|
||||
try:
|
||||
del self.bal_window.willitems[key]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
del self.bal_window.will[key]
|
||||
except Exception:
|
||||
pass
|
||||
self.update()
|
||||
|
||||
def check_transactions(self, selected_keys):
|
||||
wout = {}
|
||||
for k in selected_keys:
|
||||
wout[k] = self.will[k]
|
||||
if wout:
|
||||
self.bal_window.check_transactions(wout)
|
||||
self.update()
|
||||
|
||||
def show_transaction(self, selected_keys):
|
||||
for key in selected_keys:
|
||||
self.bal_window.show_transaction(self.will[key].tx)
|
||||
|
||||
self.update()
|
||||
|
||||
def select(self, selected_keys):
|
||||
self.selected += selected_keys
|
||||
self.update()
|
||||
|
||||
def deselect(self, selected_keys):
|
||||
for key in selected_keys:
|
||||
self.selected.remove(key)
|
||||
self.update()
|
||||
|
||||
def update_will(self, will):
|
||||
self.will.update(will)
|
||||
self.update()
|
||||
|
||||
def replace(self, set_current, current_key, txid, bal_tx):
|
||||
if self.bal_window.bal_plugin._hide_replaced and bal_tx.get_status("REPLACED"):
|
||||
return False
|
||||
if self.bal_window.bal_plugin._hide_invalidated and bal_tx.get_status(
|
||||
"INVALIDATED"
|
||||
):
|
||||
return False
|
||||
|
||||
if not isinstance(bal_tx, WillItem):
|
||||
bal_tx = WillItem(bal_tx)
|
||||
|
||||
tx = bal_tx.tx
|
||||
|
||||
labels = [""] * len(self.Columns)
|
||||
labels[self.Columns.LOCKTIME] = str(BalTimestamp(tx.locktime))
|
||||
labels[self.Columns.TXID] = txid
|
||||
we = "None"
|
||||
if bal_tx.we:
|
||||
we = bal_tx.we["url"]
|
||||
labels[self.Columns.WILLEXECUTOR] = we
|
||||
status = bal_tx.status
|
||||
if len(bal_tx.status) > 53:
|
||||
status = "...{}".format(status[-50:])
|
||||
labels[self.Columns.STATUS] = status
|
||||
|
||||
items = []
|
||||
for e in labels:
|
||||
if isinstance(e, list):
|
||||
try:
|
||||
items.append(QStandardItem(*e))
|
||||
except Exception as e:
|
||||
pass
|
||||
else:
|
||||
items.append(QStandardItem(str(e)))
|
||||
|
||||
items[-1].setBackground(QColor(status_color(bal_tx)))
|
||||
|
||||
row_count = self.model().rowCount()
|
||||
self.model().insertRow(row_count, items)
|
||||
if txid == current_key:
|
||||
idx = self.model().index(row_count, self.Columns.TXID)
|
||||
set_current = QPersistentModelIndex(idx)
|
||||
self.set_current_idx(set_current)
|
||||
return set_current
|
||||
|
||||
def update(self):
|
||||
try:
|
||||
self.menu.removeAction(self.importaction)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.will is None:
|
||||
return
|
||||
|
||||
current_key = self.get_role_data_for_current_item(
|
||||
col=self.Columns.TXID, role=self.ROLE_HEIR_KEY
|
||||
)
|
||||
self.model().clear()
|
||||
self.update_headers(self.__class__.headers)
|
||||
|
||||
set_current = None
|
||||
for txid, bal_tx in self.will.items():
|
||||
tmp = self.replace(set_current, current_key, txid, bal_tx)
|
||||
if tmp:
|
||||
set_current = tmp
|
||||
self.sortByColumn(self.Columns.LOCKTIME, Qt.SortOrder.AscendingOrder)
|
||||
self.setSortingEnabled(True)
|
||||
try:
|
||||
self.will_settings_widget.on_locktime_change()
|
||||
except Exception as _e:
|
||||
pass
|
||||
|
||||
def create_toolbar(self, config):
|
||||
toolbar, menu = self.create_toolbar_with_menu("")
|
||||
menu.addAction(_("Prepare"), self.build_transactions)
|
||||
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
||||
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
||||
menu.addAction(_("Export"), self.export_will)
|
||||
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
|
||||
self.importaction = menu.addAction(_("Import"), self.import_will)
|
||||
menu.addAction(_("Broadcast"), self.broadcast)
|
||||
menu.addAction(_("Check"), self.check)
|
||||
menu.addAction(_("Invalidate"), self.invalidate_will)
|
||||
|
||||
wizard = QPushButton()
|
||||
wizard.setIcon(
|
||||
read_QIcon_from_bytes(
|
||||
self.bal_window.bal_plugin.read_file("icons/wizard.png")
|
||||
)
|
||||
)
|
||||
wizard.clicked.connect(self.bal_window.init_wizard)
|
||||
# display = QPushButton(_("Display"))
|
||||
# display.clicked.connect(self.bal_window.preview_modal_dialog)
|
||||
|
||||
refresh = QPushButton()
|
||||
refresh.setIcon(
|
||||
read_QIcon_from_bytes(
|
||||
self.bal_window.bal_plugin.read_file("icons/reload.png")
|
||||
)
|
||||
)
|
||||
refresh.clicked.connect(self.check)
|
||||
|
||||
widget = QWidget(self)
|
||||
hlayout = QHBoxLayout(widget)
|
||||
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
||||
hlayout.addWidget(self.will_settings_widget)
|
||||
hlayout.addWidget(wizard)
|
||||
hlayout.addWidget(refresh)
|
||||
toolbar.insertWidget(2, widget)
|
||||
|
||||
self.menu = menu
|
||||
self.toolbar = toolbar
|
||||
return toolbar
|
||||
|
||||
def hide_replaced(self):
|
||||
self.bal_window.bal_plugin.hide_replaced()
|
||||
self.update()
|
||||
|
||||
def hide_invalidated(self):
|
||||
self.bal_window.bal_plugin.hide_invalidated()
|
||||
self.update()
|
||||
|
||||
def build_transactions(self):
|
||||
will = self.bal_window.prepare_will()
|
||||
if will:
|
||||
self.update_will(will)
|
||||
|
||||
def export_json_file(self, path):
|
||||
write_json_file(path, self.will)
|
||||
|
||||
def export_will(self):
|
||||
self.bal_window.export_will()
|
||||
self.update()
|
||||
|
||||
def import_will(self):
|
||||
self.bal_window.import_will()
|
||||
|
||||
def ask_password_and_sign_transactions(self):
|
||||
self.bal_window.ask_password_and_sign_transactions(callback=self.update)
|
||||
|
||||
def broadcast(self):
|
||||
self.bal_window.broadcast_transactions()
|
||||
self.update()
|
||||
|
||||
def check(self):
|
||||
close_window = BalBuildWillDialog(self.bal_window)
|
||||
close_window.build_will_task()
|
||||
|
||||
will = {}
|
||||
for wid, w in self.bal_window.willitems.items():
|
||||
if (
|
||||
w.get_status("VALID")
|
||||
and w.get_status("PUSHED")
|
||||
and not w.get_status("CHECKED")
|
||||
):
|
||||
will[wid] = w
|
||||
if will:
|
||||
self.bal_window.check_transactions(will)
|
||||
self.update()
|
||||
|
||||
def invalidate_will(self):
|
||||
self.bal_window.invalidate_will()
|
||||
self.update()
|
||||
|
||||
|
||||
# class PreviewDialog(BalDialog, MessageBoxMixin):
|
||||
# def __init__(self, bal_window, will):
|
||||
# self.parent = bal_window.window
|
||||
# BalDialog.__init__(
|
||||
# self, bal_window=bal_window, bal_plugin=bal_window.bal_plugin
|
||||
# )
|
||||
# self.bal_plugin = bal_window.bal_plugin
|
||||
# self.gui_object = self.bal_plugin.gui_object
|
||||
# self.config = self.bal_plugin.config
|
||||
# self.bal_window = bal_window
|
||||
# self.wallet = bal_window.window.wallet
|
||||
# self.format_amount = bal_window.window.format_amount
|
||||
# self.base_unit = bal_window.window.base_unit
|
||||
# self.format_fiat_and_units = bal_window.window.format_fiat_and_units
|
||||
# self.fx = bal_window.window.fx
|
||||
# self.format_fee_rate = bal_window.window.format_fee_rate
|
||||
# self.show_address = bal_window.window.show_address
|
||||
# if not will:
|
||||
# self.will = bal_window.willitems
|
||||
# else:
|
||||
# self.will = will
|
||||
# self.setWindowTitle(_("Transactions Preview"))
|
||||
# self.setMinimumSize(1000, 200)
|
||||
# self.size_label = QLabel()
|
||||
# self.transactions_list = PreviewList(self.bal_window,self, self.will)
|
||||
#
|
||||
# try:
|
||||
# self.bal_window.init_class_variables()
|
||||
# except Exception as e:
|
||||
# _logger.error(f"PreviewDialog Exception: {e}")
|
||||
# self.check_will()
|
||||
#
|
||||
# vbox = QVBoxLayout(self)
|
||||
# vbox.addWidget(self.size_label)
|
||||
# vbox.addWidget(self.transactions_list)
|
||||
# buttonbox = QHBoxLayout()
|
||||
#
|
||||
# b = QPushButton(_("Sign"))
|
||||
# b.clicked.connect(self.transactions_list.ask_password_and_sign_transactions)
|
||||
# buttonbox.addWidget(b)
|
||||
#
|
||||
# b = QPushButton(_("Export Will"))
|
||||
# b.clicked.connect(self.transactions_list.export_will)
|
||||
# buttonbox.addWidget(b)
|
||||
#
|
||||
# b = QPushButton(_("Broadcast"))
|
||||
# b.clicked.connect(self.transactions_list.broadcast)
|
||||
# buttonbox.addWidget(b)
|
||||
#
|
||||
# b = QPushButton(_("Invalidate will"))
|
||||
# b.clicked.connect(self.transactions_list.invalidate_will)
|
||||
# buttonbox.addWidget(b)
|
||||
#
|
||||
# vbox.addLayout(buttonbox)
|
||||
#
|
||||
# self.update()
|
||||
#
|
||||
# def update_will(self, will):
|
||||
# self.will.update(will)
|
||||
# self.transactions_list.update_will(will)
|
||||
# self.update()
|
||||
#
|
||||
# def update(self):
|
||||
# self.transactions_list.update()
|
||||
#
|
||||
# def is_hidden(self):
|
||||
# return self.isMinimized() or self.isHidden()
|
||||
#
|
||||
# def show_or_hide(self):
|
||||
# if self.is_hidden():
|
||||
# self.bring_to_top()
|
||||
# else:
|
||||
# self.hide()
|
||||
#
|
||||
# def bring_to_top(self):
|
||||
# self.show()
|
||||
# self.raise_()
|
||||
#
|
||||
# def closeEvent(self, event):
|
||||
# event.accept()
|
||||
|
||||
|
||||
|
||||
class WillExecutorListWidget(MyTreeView):
|
||||
class Columns(MyTreeView.BaseColumnsEnum):
|
||||
SELECTED = enum.auto()
|
||||
URL = enum.auto()
|
||||
STATUS = enum.auto()
|
||||
BASE_FEE = enum.auto()
|
||||
INFO = enum.auto()
|
||||
ADDRESS = enum.auto()
|
||||
|
||||
headers = {
|
||||
Columns.SELECTED: _(""),
|
||||
Columns.URL: _("Url"),
|
||||
Columns.STATUS: _("S"),
|
||||
Columns.BASE_FEE: _("Base fee"),
|
||||
Columns.INFO: _("Info"),
|
||||
Columns.ADDRESS: _("Default Address"),
|
||||
}
|
||||
|
||||
filter_columns = [Columns.URL]
|
||||
|
||||
ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 3000
|
||||
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 3001
|
||||
key_role = ROLE_HEIR_KEY
|
||||
|
||||
def __init__(self, parent: "WillExecutorWidget"):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
stretch_column=self.Columns.ADDRESS,
|
||||
editable_columns=[
|
||||
self.Columns.URL,
|
||||
self.Columns.BASE_FEE,
|
||||
self.Columns.ADDRESS,
|
||||
self.Columns.INFO,
|
||||
],
|
||||
)
|
||||
self.parent = parent
|
||||
try:
|
||||
self.setModel(QStandardItemModel(self))
|
||||
self.sortByColumn(self.Columns.SELECTED, Qt.SortOrder.AscendingOrder)
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
except Exception:
|
||||
pass
|
||||
self.setSortingEnabled(True)
|
||||
self.std_model = self.model()
|
||||
self.config = parent.bal_plugin.config
|
||||
self.get_decimal_point = parent.bal_plugin.get_decimal_point
|
||||
|
||||
self.update()
|
||||
|
||||
def create_menu(self, position):
|
||||
menu = QMenu()
|
||||
idx = self.indexAt(position)
|
||||
column = idx.column() or self.Columns.URL
|
||||
selected_keys = []
|
||||
for s_idx in self.selected_in_column(self.Columns.URL):
|
||||
sel_key = self.model().itemFromIndex(s_idx).data(0)
|
||||
selected_keys.append(sel_key)
|
||||
if selected_keys and idx.isValid():
|
||||
column_title = self.model().horizontalHeaderItem(column).text()
|
||||
# column_data = "\n".join(
|
||||
# self.model().itemFromIndex(s_idx).text()
|
||||
# for s_idx in self.selected_in_column(column)
|
||||
# )
|
||||
if Willexecutors.is_selected(self.parent.willexecutors_list[sel_key]):
|
||||
menu.addAction(
|
||||
_("deselect").format(column_title),
|
||||
lambda: self.deselect(selected_keys),
|
||||
)
|
||||
else:
|
||||
menu.addAction(
|
||||
_("select").format(column_title), lambda: self.select(selected_keys)
|
||||
)
|
||||
if column in self.editable_columns:
|
||||
item = self.model().itemFromIndex(idx)
|
||||
if item.isEditable():
|
||||
persistent = QPersistentModelIndex(idx)
|
||||
menu.addAction(
|
||||
_("Edit {}").format(column_title),
|
||||
lambda p=persistent: self.edit(QModelIndex(p)),
|
||||
)
|
||||
|
||||
menu.addAction(
|
||||
_("Ping").format(column_title),
|
||||
lambda: self.ping_willexecutors(selected_keys),
|
||||
)
|
||||
menu.addSeparator()
|
||||
menu.addAction(
|
||||
_("delete").format(column_title), lambda: self.delete(selected_keys)
|
||||
)
|
||||
|
||||
menu.exec(self.viewport().mapToGlobal(position))
|
||||
|
||||
def ping_willexecutors(self, selected_keys):
|
||||
wout = {}
|
||||
for k in selected_keys:
|
||||
wout[k] = self.parent.willexecutors_list[k]
|
||||
self.parent.update_willexecutors(wout)
|
||||
|
||||
self.parent.save_willexecutors()
|
||||
self.update()
|
||||
|
||||
def get_edit_key_from_coordinate(self, row, col):
|
||||
role = self.ROLE_HEIR_KEY + col
|
||||
a = self.get_role_data_from_coordinate(row, col, role=role)
|
||||
return a
|
||||
|
||||
def delete(self, selected_keys):
|
||||
for key in selected_keys:
|
||||
del self.parent.willexecutors_list[key]
|
||||
|
||||
self.parent.save_willexecutors()
|
||||
self.update()
|
||||
|
||||
def select(self, selected_keys):
|
||||
for wid, w in self.parent.willexecutors_list.items():
|
||||
if wid in selected_keys:
|
||||
w["selected"] = True
|
||||
self.parent.save_willexecutors()
|
||||
self.update()
|
||||
|
||||
def deselect(self, selected_keys):
|
||||
for wid, w in self.parent.willexecutors_list.items():
|
||||
if wid in selected_keys:
|
||||
w["selected"] = False
|
||||
self.parent.save_willexecutors()
|
||||
self.update()
|
||||
|
||||
def on_edited(self, idx, edit_key, *, text):
|
||||
# prior_name = self.parent.willexecutors_list[edit_key]
|
||||
col = idx.column()
|
||||
try:
|
||||
if col == self.Columns.URL:
|
||||
self.parent.willexecutors_list[text] = self.parent.willexecutors_list[
|
||||
edit_key
|
||||
]
|
||||
del self.parent.willexecutors_list[edit_key]
|
||||
if col == self.Columns.BASE_FEE:
|
||||
self.parent.willexecutors_list[edit_key]["base_fee"] = (
|
||||
Util.encode_amount(text, self.get_decimal_point())
|
||||
)
|
||||
if col == self.Columns.ADDRESS:
|
||||
self.parent.willexecutors_list[edit_key]["address"] = text
|
||||
if col == self.Columns.INFO:
|
||||
self.parent.willexecutors_list[edit_key]["info"] = text
|
||||
self.parent.save_willexecutors()
|
||||
self.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update(self):
|
||||
if self.parent.willexecutors_list is None:
|
||||
return
|
||||
try:
|
||||
current_key = self.get_role_data_for_current_item(
|
||||
col=self.Columns.URL, role=self.ROLE_HEIR_KEY
|
||||
)
|
||||
self.model().clear()
|
||||
self.update_headers(self.__class__.headers)
|
||||
|
||||
set_current = None
|
||||
|
||||
for url, value in self.parent.willexecutors_list.items():
|
||||
labels = [""] * len(self.Columns)
|
||||
labels[self.Columns.URL] = url
|
||||
if Willexecutors.is_selected(value):
|
||||
|
||||
labels[self.Columns.SELECTED] = [
|
||||
read_QIcon_from_bytes(
|
||||
self.parent.bal_plugin.read_file("icons/confirmed.png")
|
||||
),
|
||||
"",
|
||||
]
|
||||
else:
|
||||
labels[self.Columns.SELECTED] = ""
|
||||
labels[self.Columns.BASE_FEE] = Util.decode_amount(
|
||||
value.get("base_fee", 0), self.get_decimal_point()
|
||||
)
|
||||
if str(value.get("status", 0)) == "200":
|
||||
labels[self.Columns.STATUS] = [
|
||||
read_QIcon_from_bytes(
|
||||
self.parent.bal_plugin.read_file(
|
||||
"icons/status_connected.png"
|
||||
)
|
||||
),
|
||||
"",
|
||||
]
|
||||
else:
|
||||
labels[self.Columns.STATUS] = [
|
||||
read_QIcon_from_bytes(
|
||||
self.parent.bal_plugin.read_file("icons/unconfirmed.png")
|
||||
),
|
||||
"",
|
||||
]
|
||||
labels[self.Columns.ADDRESS] = str(value.get("address", ""))
|
||||
labels[self.Columns.INFO] = str(value.get("info", ""))
|
||||
|
||||
items = []
|
||||
for e in labels:
|
||||
if isinstance(e, list):
|
||||
try:
|
||||
items.append(QStandardItem(*e))
|
||||
except Exception as e:
|
||||
pass
|
||||
else:
|
||||
items.append(QStandardItem(e))
|
||||
items[self.Columns.SELECTED].setEditable(False)
|
||||
items[self.Columns.URL].setEditable(True)
|
||||
items[self.Columns.ADDRESS].setEditable(True)
|
||||
items[self.Columns.INFO].setEditable(True)
|
||||
items[self.Columns.BASE_FEE].setEditable(True)
|
||||
items[self.Columns.STATUS].setEditable(False)
|
||||
|
||||
items[self.Columns.URL].setData(
|
||||
url, self.ROLE_HEIR_KEY + self.Columns.URL
|
||||
)
|
||||
items[self.Columns.BASE_FEE].setData(
|
||||
url, self.ROLE_HEIR_KEY + self.Columns.BASE_FEE
|
||||
)
|
||||
items[self.Columns.INFO].setData(
|
||||
url, self.ROLE_HEIR_KEY + self.Columns.INFO
|
||||
)
|
||||
items[self.Columns.ADDRESS].setData(
|
||||
url, self.ROLE_HEIR_KEY + self.Columns.ADDRESS
|
||||
)
|
||||
row_count = self.model().rowCount()
|
||||
self.model().insertRow(row_count, items)
|
||||
if url == current_key:
|
||||
idx = self.model().index(row_count, self.Columns.URL)
|
||||
set_current = QPersistentModelIndex(idx)
|
||||
self.set_current_idx(set_current)
|
||||
self.filter()
|
||||
except Exception as e:
|
||||
_logger.error(f"error updating willexcutor {e}")
|
||||
raise e
|
||||
|
||||
|
||||
|
||||
class WillExecutorWidget(QWidget, MessageBoxMixin):
|
||||
def __init__(self, parent, bal_window, willexecutors=None):
|
||||
self.bal_window = bal_window
|
||||
self.bal_plugin = bal_window.bal_plugin
|
||||
self.parent = parent
|
||||
MessageBoxMixin.__init__(self)
|
||||
QWidget.__init__(self, parent)
|
||||
if willexecutors:
|
||||
self.willexecutors_list = willexecutors
|
||||
else:
|
||||
self.willexecutors_list = Willexecutors.get_willexecutors(self.bal_plugin)
|
||||
|
||||
self.size_label = QLabel()
|
||||
self.will_executor_list_widget = WillExecutorListWidget(self)
|
||||
|
||||
vbox = QVBoxLayout(self)
|
||||
vbox.addWidget(self.size_label)
|
||||
|
||||
widget = QWidget()
|
||||
hbox = QHBoxLayout(widget)
|
||||
hbox.addWidget(QLabel(_("Add transactions without willexecutor")))
|
||||
heir_no_willexecutor = BalCheckBox(self.bal_plugin.NO_WILLEXECUTOR)
|
||||
hbox.addWidget(heir_no_willexecutor)
|
||||
spacer_widget = QWidget()
|
||||
spacer_widget.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
hbox.addWidget(spacer_widget)
|
||||
vbox.addWidget(widget)
|
||||
|
||||
vbox.addWidget(self.will_executor_list_widget)
|
||||
buttonbox = QHBoxLayout()
|
||||
|
||||
b = QPushButton(_("Add"))
|
||||
b.clicked.connect(self.add)
|
||||
buttonbox.addWidget(b)
|
||||
|
||||
b = QPushButton(_("Download List"))
|
||||
b.clicked.connect(self.download_list)
|
||||
buttonbox.addWidget(b)
|
||||
|
||||
b = QPushButton(_("Import"))
|
||||
b.clicked.connect(self.import_file)
|
||||
buttonbox.addWidget(b)
|
||||
|
||||
b = QPushButton(_("Export"))
|
||||
b.clicked.connect(self.export_file)
|
||||
buttonbox.addWidget(b)
|
||||
|
||||
b = QPushButton(_("Ping All"))
|
||||
b.clicked.connect(self.update_willexecutors)
|
||||
buttonbox.addWidget(b)
|
||||
|
||||
vbox.addLayout(buttonbox)
|
||||
# self.will_executor_list_widget.update()
|
||||
|
||||
def add(self):
|
||||
self.willexecutors_list["http://localhost:8080"] = {
|
||||
"info": "New Will Executor",
|
||||
"base_fee": 0,
|
||||
"status": "-1",
|
||||
}
|
||||
self.will_executor_list_widget.update()
|
||||
|
||||
def download_list(self, wes=None):
|
||||
if not wes:
|
||||
wes = self.willexecutors_list
|
||||
self.bal_window.download_list(wes, self.save_willexecutors)
|
||||
self.update()
|
||||
|
||||
def export_file(self, path):
|
||||
export_meta_gui(
|
||||
self.bal_window.window, "willexecutors.json", self.export_json_file
|
||||
)
|
||||
|
||||
def export_json_file(self, path):
|
||||
write_json_file(path, self.willexecutors_list)
|
||||
|
||||
def import_file(self):
|
||||
import_meta_gui(
|
||||
self.bal_window.window,
|
||||
_("willexecutors"),
|
||||
self.import_json_file,
|
||||
self.willexecutors_list.update,
|
||||
)
|
||||
|
||||
def update_willexecutors(self, wes=None):
|
||||
if not wes:
|
||||
wes = self.willexecutors_list
|
||||
self.bal_window.ping_willexecutors(wes, self.save_willexecutors)
|
||||
|
||||
def import_json_file(self, path):
|
||||
data = read_json_file(path)
|
||||
data = self._validate(data)
|
||||
self.willexecutors_list.update(data)
|
||||
self.will_executor_list_widget.update()
|
||||
|
||||
# TODO validate willexecutor json import file
|
||||
def _validate(self, data):
|
||||
return data
|
||||
|
||||
def save_willexecutors(self, wes=None):
|
||||
if not wes:
|
||||
wes = self.willexecutors_list
|
||||
self.willexecutors_list.update(wes)
|
||||
self.will_executor_list_widget.update()
|
||||
Willexecutors.save(self.bal_window.bal_plugin, self.willexecutors_list)
|
||||
|
||||
|
||||
273
bal/gui/qt/plugin.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
bal.gui.qt.plugin
|
||||
=================
|
||||
|
||||
The Qt entry point of the plugin.
|
||||
|
||||
:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` and adds the
|
||||
Electrum ``@hook`` methods that wire the plugin into the Qt GUI (status-bar
|
||||
button, Tools menu, wallet load/close, settings dialog). Electrum instantiates
|
||||
this class because the package ``manifest.json`` declares ``available_for:
|
||||
["qt"]`` and the loader imports ``qt.py`` (a thin shim re-exporting this class).
|
||||
|
||||
One :class:`bal.gui.qt.window.BalWindow` is created per top-level wallet window
|
||||
and cached in ``self.bal_windows``.
|
||||
"""
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .widgets import BalCheckBox, BalLineEdit, BalTextEdit
|
||||
from .window import BalWindow
|
||||
from .dialogs import BalDialog
|
||||
|
||||
|
||||
class Plugin(BalPlugin):
|
||||
def __init__(self, parent, config, name):
|
||||
_logger.info("INIT BALPLUGIN")
|
||||
BalPlugin.__init__(self, parent, config, name)
|
||||
self.bal_windows = {}
|
||||
|
||||
@hook
|
||||
def init_qt(self, gui_object):
|
||||
_logger.info("HOOK bal init qt")
|
||||
try:
|
||||
self.gui_object = gui_object
|
||||
for window in gui_object.windows:
|
||||
wallet = window.wallet
|
||||
if wallet:
|
||||
window.show_warning(
|
||||
_("Please restart Electrum to activate the BAL plugin"),
|
||||
title=_("Success"),
|
||||
)
|
||||
return
|
||||
top_level_window=window.top_level_window()
|
||||
w = BalWindow(self, top_level_window)
|
||||
self.bal_windows[top_level_window.winId] = w
|
||||
for child in window.children():
|
||||
if isinstance(child, QMenuBar):
|
||||
for menu_child in child.children():
|
||||
if isinstance(menu_child, QMenu):
|
||||
try:
|
||||
if menu_child.title() == _("&Tools"):
|
||||
w.init_menubar_tools(menu_child)
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
("init_qt except:", menu_child.text())
|
||||
)
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Error loading plugini {}".format(e))
|
||||
raise e
|
||||
|
||||
@hook
|
||||
def create_status_bar(self, sb):
|
||||
_logger.info("HOOK create status bar")
|
||||
b = StatusBarButton(
|
||||
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
|
||||
"Bal " + _("Bitcoin After Life"),
|
||||
partial(self.settings_dialog, sb),
|
||||
sb.height(),
|
||||
)
|
||||
sb.addPermanentWidget(b)
|
||||
|
||||
@hook
|
||||
def init_menubar(self, window):
|
||||
_logger.info("HOOK init_menubar")
|
||||
w = self.get_window(window)
|
||||
w.init_menubar_tools(window.tools_menu)
|
||||
|
||||
@hook
|
||||
def load_wallet(self, wallet, main_window):
|
||||
_logger.debug("HOOK load wallet")
|
||||
w = self.get_window(main_window)
|
||||
# havetoupdate = Util.fix_will_settings_tx_fees(wallet.db)
|
||||
w.wallet = wallet
|
||||
w.init_will()
|
||||
w.willexecutors = Willexecutors.get_willexecutors(
|
||||
self, update=False, bal_window=w
|
||||
)
|
||||
w.disable_plugin = False
|
||||
w.ok = True
|
||||
|
||||
@hook
|
||||
def close_wallet(self, wallet):
|
||||
_logger.debug("HOOK close wallet")
|
||||
for _winid, win in self.bal_windows.items():
|
||||
if win.wallet == wallet:
|
||||
win.on_close()
|
||||
|
||||
@hook
|
||||
def init_keystore(self):
|
||||
_logger.debug("init keystore")
|
||||
|
||||
@hook
|
||||
def daemon_wallet_loaded(self, boh, wallet):
|
||||
_logger.debug("daemon wallet loaded")
|
||||
|
||||
def get_window(self, window):
|
||||
window=window.top_level_window()
|
||||
w = self.bal_windows.get(window.winId, None)
|
||||
if w is None:
|
||||
w = BalWindow(self, window)
|
||||
self.bal_windows[window.winId] = w
|
||||
return w
|
||||
|
||||
def requires_settings(self):
|
||||
return True
|
||||
|
||||
def settings_widget(self, window):
|
||||
|
||||
w = self.get_window(window.window)
|
||||
widget = QWidget()
|
||||
enterbutton = EnterButton(_("Settings"), partial(w.settings_dialog, window))
|
||||
|
||||
widget.setLayout(Buttons(enterbutton, widget))
|
||||
return widget
|
||||
|
||||
def password_dialog(self, msg=None, parent=None):
|
||||
parent = parent or self
|
||||
d = PasswordDialog(parent, msg)
|
||||
return d.run()
|
||||
|
||||
def get_seed(self):
|
||||
password = None
|
||||
if self.wallet.has_keystore_encryption():
|
||||
password = self.password_dialog(parent=self.d.parent())
|
||||
if not password:
|
||||
raise UserCancelled()
|
||||
|
||||
keystore = self.wallet.get_keystore()
|
||||
if not keystore or not keystore.has_seed():
|
||||
return
|
||||
self.extension = bool(keystore.get_passphrase(password))
|
||||
return keystore.get_seed(password)
|
||||
|
||||
def settings_dialog(self, window=None, wallet=None):
|
||||
|
||||
d = BalDialog(window, self, self.get_window_title("Settings"))
|
||||
d.setMinimumSize(100, 200)
|
||||
qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png"))
|
||||
lbl_logo = QLabel()
|
||||
lbl_logo.setPixmap(qicon)
|
||||
|
||||
# heir_ping_willexecutors = BalCheckBox(self.PING_WILLEXECUTORS)
|
||||
# heir_ask_ping_willexecutors = BalCheckBox(self.ASK_PING_WILLEXECUTORS)
|
||||
# heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)
|
||||
|
||||
def on_multiverse_change():
|
||||
self.update_all()
|
||||
|
||||
# heir_enable_multiverse = BalCheckBox(self.ENABLE_MULTIVERSE,on_multiverse_change)
|
||||
|
||||
heir_hide_replaced = BalCheckBox(self.HIDE_REPLACED, on_multiverse_change)
|
||||
|
||||
heir_hide_invalidated = BalCheckBox(self.HIDE_INVALIDATED, on_multiverse_change)
|
||||
heir_repush = QPushButton("Rebroadcast transactions")
|
||||
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
|
||||
bal_mode = QComboBox()
|
||||
options = ["Easy", "Advanced", "Experimental"]
|
||||
bal_mode.addItems(options)
|
||||
|
||||
grid = QGridLayout(d)
|
||||
add_widget(
|
||||
grid,
|
||||
"Hide Replaced",
|
||||
heir_hide_replaced,
|
||||
1,
|
||||
"Hide replaced transactions from will detail and list",
|
||||
)
|
||||
add_widget(
|
||||
grid,
|
||||
"Hide Invalidated",
|
||||
heir_hide_invalidated,
|
||||
2,
|
||||
"Hide invalidated transactions from will detail and list",
|
||||
)
|
||||
add_widget(
|
||||
grid,
|
||||
"Calendar App",
|
||||
BalLineEdit(self.CALENDAR_APP),
|
||||
3,
|
||||
"Default app used to open calendar",
|
||||
)
|
||||
add_widget(
|
||||
grid,
|
||||
"Event summary",
|
||||
BalLineEdit(self.EVENT_SUMMARY),
|
||||
4,
|
||||
(
|
||||
"Default message to be used in event summary\n"
|
||||
"Variables:\n"
|
||||
" $wallet_name: name of wallet\n"
|
||||
" $heirs_complete: list of heirs name,address,amount\n"
|
||||
#" $will_details_complete: will details(id transaction, mining fees, willexecutor, willexecutor fees, locktime)\n"
|
||||
)
|
||||
)
|
||||
add_widget(
|
||||
grid,
|
||||
"Event sescription",
|
||||
BalTextEdit(self.EVENT_DESCRIPTION),
|
||||
5,
|
||||
(
|
||||
"Default message to be used in event description\n"
|
||||
"Variables:\n"
|
||||
" $wallet_name: name of wallet\n"
|
||||
" $heirs_complete: list of heirs name,address,amount\n"
|
||||
#" $will_details_complete: will details(id transaction, mining fees, willexecutor, willexecutor fees, locktime)\n"
|
||||
)
|
||||
)
|
||||
#add_widget(grid, "Bal Mode", bal_mode, 4, "choose bal mode")
|
||||
|
||||
# add_widget(
|
||||
# grid,
|
||||
# "Ping Willexecutors",
|
||||
# heir_ping_willexecutors,
|
||||
# 3,
|
||||
# "Ping willexecutors to get payment info before compiling will",
|
||||
# )
|
||||
# add_widget(
|
||||
# grid,
|
||||
# " - Ask before",
|
||||
# heir_ask_ping_willexecutors,
|
||||
# 4,
|
||||
# "Ask before to ping willexecutor",
|
||||
# )
|
||||
# add_widget(
|
||||
# grid,
|
||||
# "Backup Transaction",
|
||||
# heir_no_willexecutor,
|
||||
# 5,
|
||||
# "Add transactions without willexecutor",
|
||||
# )
|
||||
# add_widget(grid,"Enable Multiverse(EXPERIMENTAL/BROKEN)",heir_enable_multiverse,6,"enable multiple locktimes, will import.... ")
|
||||
grid.addWidget(heir_repush, 7, 0)
|
||||
grid.addWidget(
|
||||
HelpButton(
|
||||
"Broadcast all transactions to willexecutors including those already pushed"
|
||||
),
|
||||
7,
|
||||
2,
|
||||
)
|
||||
|
||||
if ret := bool(d.exec()):
|
||||
try:
|
||||
self.update_all()
|
||||
return ret
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def broadcast_transactions(self, force):
|
||||
for _k, w in self.bal_windows.items():
|
||||
w.broadcast_transactions(force)
|
||||
|
||||
def update_all(self):
|
||||
for _k, w in self.bal_windows.items():
|
||||
w.update_all()
|
||||
|
||||
def get_window_title(self, title):
|
||||
return _("BAL - ") + _(title)
|
||||
|
||||
|
||||
59
bal/gui/qt/theme.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
bal.gui.qt.theme
|
||||
================
|
||||
|
||||
Pure presentation helpers for the Qt layer.
|
||||
|
||||
This is where colours and other look-and-feel decisions live, kept apart from
|
||||
the core inheritance logic. In particular it hosts :func:`status_color`, which
|
||||
used to be ``WillItem.get_color()`` inside ``will.py``.
|
||||
|
||||
The status flags themselves are computed by the core layer
|
||||
(:class:`bal.core.will.WillItem`); this module only translates a will item's
|
||||
status into a colour for the transaction list / detail views.
|
||||
"""
|
||||
|
||||
# Status -> hex colour. The first matching status (checked in priority order)
|
||||
# wins. These are exactly the colours the original ``WillItem.get_color`` used,
|
||||
# so the GUI looks identical after the refactor.
|
||||
#
|
||||
# The order matters: e.g. an INVALIDATED tx must show orange even if it also
|
||||
# carries other flags, so INVALIDATED is checked before everything else.
|
||||
_STATUS_COLOR_PRIORITY = (
|
||||
("INVALIDATED", "#f87838"), # orange - tx can no longer be mined
|
||||
("REPLACED", "#ff97e9"), # pink - superseded by another tx
|
||||
("CONFIRMED", "#bfbfbf"), # grey - already mined
|
||||
("PENDING", "#ffce30"), # yellow - in mempool, waiting
|
||||
)
|
||||
|
||||
# Default colour used when no status in the priority list matches.
|
||||
_DEFAULT_COLOR = "#ffffff"
|
||||
|
||||
|
||||
def status_color(will_item) -> str:
|
||||
"""Return the display colour (``"#rrggbb"``) for a :class:`WillItem`.
|
||||
|
||||
This is a faithful, behaviour-preserving port of the old
|
||||
``WillItem.get_color()`` method. The slightly irregular handling of the
|
||||
push/check states (which is not a simple priority list) is reproduced
|
||||
exactly as in the original code.
|
||||
"""
|
||||
# First, the simple priority-ordered statuses.
|
||||
for status, color in _STATUS_COLOR_PRIORITY:
|
||||
if will_item.get_status(status):
|
||||
return color
|
||||
|
||||
# The remaining states need the original branching because of the
|
||||
# CHECK_FAIL / CHECKED interaction.
|
||||
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
|
||||
return "#e83845" # red - server check failed
|
||||
elif will_item.get_status("CHECKED"):
|
||||
return "#8afa6c" # green - server confirmed it stored the tx
|
||||
elif will_item.get_status("PUSH_FAIL"):
|
||||
return "#e83845" # red - failed to push to will-executor
|
||||
elif will_item.get_status("PUSHED"):
|
||||
return "#73f3c8" # teal - pushed to will-executor
|
||||
elif will_item.get_status("COMPLETE"):
|
||||
return "#2bc8ed" # blue - signed
|
||||
else:
|
||||
return _DEFAULT_COLOR
|
||||
782
bal/gui/qt/widgets.py
Normal file
@@ -0,0 +1,782 @@
|
||||
"""
|
||||
bal.gui.qt.widgets
|
||||
==================
|
||||
|
||||
Reusable, self-contained Qt widgets used to build the BAL tabs and dialogs.
|
||||
|
||||
These are "leaf" widgets: they receive the :class:`BalWindow` controller (and
|
||||
any data they need) as constructor arguments at runtime, so this module does
|
||||
not import ``window``/``dialogs`` and therefore introduces no import cycles.
|
||||
|
||||
Contents:
|
||||
* ClickableLabel, BalLineEdit, BalTextEdit, BalCheckBox - thin Qt wrappers
|
||||
* BalTxFeesWidget - fee-rate editor
|
||||
* _LockTimeEditor + BalTimeEditWidget + raw/date editors - locktime editing
|
||||
* ThresholdTimeWidget / LockTimeWidget - threshold & locktime
|
||||
* WillSettingsWidget - the settings panel
|
||||
* PercAmountEdit - amount-or-percentage editor
|
||||
* WillWidget - single will-tx box
|
||||
"""
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .calendar import BalCalendar
|
||||
|
||||
|
||||
class ClickableLabel(QLabel):
|
||||
doubleClicked = pyqtSignal()
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
self.doubleClicked.emit()
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
|
||||
|
||||
class BalTxFeesWidget(QWidget):
|
||||
valueChanged = pyqtSignal()
|
||||
current_value = None
|
||||
|
||||
def __init__(self, bal_window, parent, value=None):
|
||||
super().__init__(parent)
|
||||
self.bal_window = bal_window
|
||||
layout = QHBoxLayout(self)
|
||||
self.txfee_widget = QSpinBox(self)
|
||||
self.txfee_widget.setMinimum(1)
|
||||
self.txfee_widget.setMaximum(10000)
|
||||
value = (
|
||||
value
|
||||
if value
|
||||
else self.bal_window.bal_plugin.WILL_SETTINGS.get()["baltx_fees"]
|
||||
)
|
||||
self.set_value(value)
|
||||
self.default_value = self.bal_window.bal_plugin.default_will_settings()[
|
||||
"baltx_fees"
|
||||
]
|
||||
self.txfee_widget.valueChanged.connect(self.on_heir_tx_fees)
|
||||
#label = ClickableLabel("$")
|
||||
#label.doubleClicked.connect(self.doubleclick)
|
||||
#layout.addWidget(label)
|
||||
button = HelpButton(_("mining fees expressed in sats/vbyte to be used in the Bitcoin transaction.\nHigher value ensure your transaction will be confirmed"))
|
||||
button.setText("丰")
|
||||
button.setStyleSheet("font-size: 16px;")
|
||||
layout.addWidget(button)
|
||||
layout.addWidget(self.txfee_widget)
|
||||
|
||||
def doubleclick(self, event=None):
|
||||
pass
|
||||
def get_value(self):
|
||||
return self.txfee_widget.value()
|
||||
|
||||
def set_value(self, value, emit=True):
|
||||
value = int(value) if value is not None else 20
|
||||
if getattr(self, "_updating", False):
|
||||
return
|
||||
|
||||
self._updating = True
|
||||
try:
|
||||
self.current_value = value
|
||||
spin = self.txfee_widget
|
||||
spin.blockSignals(True)
|
||||
spin.setValue(value)
|
||||
spin.blockSignals(False)
|
||||
|
||||
finally:
|
||||
self._updating = False
|
||||
|
||||
if emit:
|
||||
spin.valueChanged.emit(value)
|
||||
|
||||
def on_heir_tx_fees(self, value=None, update_all=True):
|
||||
if value != self.current_value:
|
||||
try:
|
||||
self.set_value(value)
|
||||
if update_all:
|
||||
self.bal_window.update_setting_widgets(
|
||||
self.get_value(), "baltx_fees", True
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(f"error while trying to update txfees{e}")
|
||||
log_error(e)
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class _LockTimeEditor:
|
||||
min_allowed_value = NLOCKTIME_MIN
|
||||
max_allowed_value = NLOCKTIME_MAX
|
||||
alarm = None
|
||||
|
||||
def get_value(self) -> Optional[int]:
|
||||
raise NotImplementedError()
|
||||
|
||||
def set_value(self, x: Any, force=True) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def is_acceptable_locktime(cls, x: Any) -> bool:
|
||||
if not x: # e.g. empty string
|
||||
return True
|
||||
try:
|
||||
x = int(x)
|
||||
except Exception as _e:
|
||||
return False
|
||||
return cls.min_allowed_value <= x <= cls.max_allowed_value
|
||||
|
||||
@staticmethod
|
||||
def get_max_allowed_timestamp() -> int:
|
||||
ts = NLOCKTIME_MAX
|
||||
# Test if this value is within the valid timestamp limits (which is platform-dependent).
|
||||
# see #6170
|
||||
try:
|
||||
datetime.fromtimestamp(ts)
|
||||
except (OSError, OverflowError):
|
||||
ts = 2**31 - 1 # INT32_MAX
|
||||
datetime.fromtimestamp(ts) # test if raises
|
||||
return ts
|
||||
|
||||
|
||||
|
||||
class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||
valueEdited = pyqtSignal()
|
||||
_setting_locktime = False
|
||||
current_value = None
|
||||
current_index = None
|
||||
default_value = None
|
||||
|
||||
help_text = (
|
||||
"if you choose Raw, you can insert various options based on suffix:\n"
|
||||
+ " - d: number of days after current day(ex: 1d means tomorrow)\n"
|
||||
+ " - y: number of years after currrent day(ex: 1y means one year from today)\n"
|
||||
)
|
||||
label_text = None
|
||||
base_field = None
|
||||
|
||||
def __init__(self, bal_window, parent, default_locktime=None):
|
||||
super().__init__(parent)
|
||||
self.bal_window = bal_window
|
||||
|
||||
hbox = QHBoxLayout()
|
||||
self.setLayout(hbox)
|
||||
hbox.setContentsMargins(0, 0, 0, 0)
|
||||
hbox.setSpacing(0)
|
||||
self.setMinimumWidth(40 * char_width_in_lineedit())
|
||||
self.locktime_raw_e = TimeRawEditWidget(self, time_edit=self)
|
||||
self.locktime_date_e = LockTimeDateEdit(self, time_edit=self)
|
||||
self.editors = [self.locktime_raw_e, self.locktime_date_e]
|
||||
self.combo = QComboBox()
|
||||
options = [_("Raw"), _("Date")]
|
||||
self.option_index_to_editor_map = {
|
||||
0: self.locktime_raw_e,
|
||||
1: self.locktime_date_e,
|
||||
}
|
||||
self.combo.addItems(options)
|
||||
default_index = 0
|
||||
if not default_locktime:
|
||||
default_locktime = self.bal_window.bal_plugin.WILL_SETTINGS.get()[self.base_field]
|
||||
try:
|
||||
int(default_locktime)
|
||||
default_index = 1
|
||||
except Exception:
|
||||
default_index = 0
|
||||
#hbox.addWidget(QLabel(self.label_text))
|
||||
help_button=HelpButton(self.help_text)
|
||||
help_button.setText(self.label_text)
|
||||
#help_button.setStyleSheet("font-size: 155555);
|
||||
hbox.addWidget(help_button)
|
||||
self.combo.currentIndexChanged.connect(self.on_current_index_changed)
|
||||
|
||||
for w in self.editors:
|
||||
w.setVisible(False)
|
||||
w.setEnabled(False)
|
||||
|
||||
self.editor = self.option_index_to_editor_map[default_index]
|
||||
self.editor.setVisible(True)
|
||||
self.editor.setEnabled(True)
|
||||
self.set_index(default_index)
|
||||
#self.on_current_index_changed(default_index)
|
||||
self.set_value(default_locktime)
|
||||
self.current_value=default_locktime
|
||||
hbox.addWidget(self.combo)
|
||||
for w in self.editors:
|
||||
hbox.addWidget(w)
|
||||
|
||||
hbox.addStretch(1)
|
||||
# spssscer_widget = QWidget()
|
||||
# spacer_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
# hbox.addWidget(spacer_widget)
|
||||
self.valueEdited.connect(lambda: self.update_will_settings(True))
|
||||
self.locktime_raw_e.editingFinished.connect(self.valueEdited.emit)
|
||||
self.locktime_date_e.dateTimeChanged.connect(self.valueEdited.emit)
|
||||
#self.combo.currentIndexChanged.connect(self.valueEdited.emit)
|
||||
|
||||
def update_will_settings(
|
||||
self,
|
||||
update_all=False,
|
||||
update_will_dialog=False,
|
||||
update_heirs_dialog=False,
|
||||
):
|
||||
self.bal_window.update_setting_widgets(
|
||||
self.get_value(),
|
||||
self.base_field,
|
||||
update_all,
|
||||
update_will_dialog,
|
||||
update_heirs_dialog,
|
||||
)
|
||||
|
||||
def on_current_index_changed(self, i):
|
||||
self.current_index = i
|
||||
for w in self.editors:
|
||||
w.setVisible(False)
|
||||
w.setEnabled(False)
|
||||
# prev_locktime = self.editor.get_value()
|
||||
self.editor = self.option_index_to_editor_map[i]
|
||||
if i==0:
|
||||
self.editor.set_value(self.bal_window.bal_plugin.default_will_settings_relative()[self.base_field])
|
||||
else:
|
||||
self.editor.set_value(self.bal_window.bal_plugin.default_will_settings_absolute()[self.base_field])
|
||||
self.valueEdited.emit()
|
||||
# if self.editor.is_acceptable_locktime(prev_locktime):
|
||||
# self.editor.set_value(prev_locktime, force=False)
|
||||
self.editor.setVisible(True)
|
||||
self.editor.setEnabled(True)
|
||||
self.bal_window.update_combo_setting_widgets(i, self.base_field,True)
|
||||
|
||||
def get_value(self) -> Optional[str]:
|
||||
val = self.editor.get_value()
|
||||
#return self.current_value
|
||||
return val
|
||||
|
||||
def set_index(self, index):
|
||||
if self.current_index != index:
|
||||
self.combo.setCurrentIndex(index)
|
||||
#self.on_current_index_changed(index, force)
|
||||
|
||||
def set_value(
|
||||
self,
|
||||
x: Any,
|
||||
force=None,
|
||||
update_all=False,
|
||||
update_will_dialog=False,
|
||||
update_heirs_dialog=False,
|
||||
) -> None:
|
||||
if not x:
|
||||
if self.current_index == 0:
|
||||
x = self.bal_window.bal_plugin.default_will_settings_relative()[self.base_field]
|
||||
elif self.current_index == 1:
|
||||
x = self.bal_window.bal_plugin.default_will_settings_absolute()[self.base_field]
|
||||
if x != self.get_value():
|
||||
self.editor.set_value(x)
|
||||
self.current_value = x
|
||||
self.bal_window.update_setting_widgets(x, self.base_field)
|
||||
|
||||
|
||||
|
||||
class TimeRawEditWidget(QWidget):
|
||||
editingFinished = pyqtSignal()
|
||||
|
||||
def is_acceptable_locktime(self, value):
|
||||
return True
|
||||
|
||||
def __init__(self, parent, time_edit=None):
|
||||
super().__init__(parent)
|
||||
self.editor = LockTimeRawEdit(parent, time_edit)
|
||||
self.label = QLabel("")
|
||||
self.label.setFixedWidth(10 * char_width_in_lineedit())
|
||||
self.layout = QHBoxLayout(self)
|
||||
self.layout.addWidget(self.editor)
|
||||
self.layout.addWidget(self.label)
|
||||
self.editor.editingFinished.connect(self.editingFinished.emit)
|
||||
self.get_value = self.editor.get_value
|
||||
self.set_value = self.editor.set_value
|
||||
|
||||
|
||||
|
||||
class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
||||
def __init__(self, parent=None, time_edit=None):
|
||||
QLineEdit.__init__(self, parent)
|
||||
self.setFixedWidth(12 * char_width_in_lineedit())
|
||||
self.textChanged.connect(self.numbify)
|
||||
self.isdays = False
|
||||
self.isyears = False
|
||||
self.isblocks = False
|
||||
self.time_edit = time_edit
|
||||
|
||||
@staticmethod
|
||||
def replace_str(text):
|
||||
return str(text).replace("d", "").replace("y", "").replace("b", "")
|
||||
|
||||
def checkbdy(self, s, pos, appendix):
|
||||
try:
|
||||
charpos = pos - 1
|
||||
charpos = max(0, charpos)
|
||||
charpos = min(len(s) - 1, charpos)
|
||||
if appendix == s[charpos]:
|
||||
s = self.replace_str(s) + appendix
|
||||
pos = charpos
|
||||
except Exception:
|
||||
pass
|
||||
return pos, s
|
||||
|
||||
def numbify(self):
|
||||
text = self.text().strip()
|
||||
# chars = '0123456789bdy' removed the option to choose locktime by block
|
||||
chars = "0123456789dy"
|
||||
pos = self.cursorPosition()
|
||||
pos = len("".join([i for i in text[:pos] if i in chars]))
|
||||
s = "".join([i for i in text if i in chars])
|
||||
self.isdays = False
|
||||
self.isyears = False
|
||||
self.isblocks = False
|
||||
|
||||
pos, s = self.checkbdy(s, pos, "d")
|
||||
pos, s = self.checkbdy(s, pos, "y")
|
||||
pos, s = self.checkbdy(s, pos, "b")
|
||||
|
||||
if "d" in s:
|
||||
self.isdays = True
|
||||
if "y" in s:
|
||||
self.isyears = True
|
||||
if "b" in s:
|
||||
self.isblocks = True
|
||||
|
||||
if self.isdays:
|
||||
s = self.replace_str(s) + "d"
|
||||
if self.isyears:
|
||||
s = self.replace_str(s) + "y"
|
||||
if self.isblocks:
|
||||
s = self.replace_str(s) + "b"
|
||||
self.blockSignals(True)
|
||||
self.setText(s)
|
||||
self.blockSignals(False)
|
||||
# self.set_value(s, force=False)
|
||||
self.current_value = s
|
||||
# setText sets Modified to False. Instead we want to remember
|
||||
# if updates were because of user modification.
|
||||
self.setModified(self.hasFocus())
|
||||
self.setCursorPosition(pos)
|
||||
|
||||
def get_value(self) -> Optional[str]:
|
||||
try:
|
||||
return str(self.text())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def set_value(self, x: Any, force=True) -> None:
|
||||
if x != self.get_value():
|
||||
self.blockSignals(True)
|
||||
self.setText(str(x))
|
||||
self.blockSignals(False)
|
||||
self.numbify()
|
||||
|
||||
|
||||
|
||||
class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
||||
min_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX + 1
|
||||
max_allowed_value = _LockTimeEditor.get_max_allowed_timestamp()
|
||||
|
||||
def __init__(self, parent=None, time_edit=None):
|
||||
QDateTimeEdit.__init__(self, parent)
|
||||
self.setMinimumDateTime(datetime.fromtimestamp(self.min_allowed_value))
|
||||
self.setMaximumDateTime(datetime.fromtimestamp(self.max_allowed_value))
|
||||
#self.setDateTime(QDateTime.currentDateTime())
|
||||
self.time_edit = time_edit
|
||||
|
||||
def get_value(self) -> Optional[int]:
|
||||
#dt = self.dateTime().toPyDateTime()
|
||||
#locktime = int(time.mktime(dt.timetuple()))
|
||||
#p#
|
||||
#dt = dt_edit.dateTime()
|
||||
## QDateTimets = dt.toSecsSinceEpoch()
|
||||
|
||||
dt = self.dateTime()
|
||||
_ts = dt.toSecsSinceEpoch()
|
||||
|
||||
return _ts
|
||||
|
||||
|
||||
def set_value(self, x: Any, force=False) -> None:
|
||||
if not self.is_acceptable_locktime(x):
|
||||
self.setDateTime(QDateTime.currentDateTime())
|
||||
return
|
||||
try:
|
||||
x = int(x)
|
||||
except Exception as e:
|
||||
x = QDateTime.currentDateTime().timestamp()
|
||||
finally:
|
||||
_dt = datetime.fromtimestamp(x)
|
||||
#if self.alarm != dt:
|
||||
self.setDateTime(_dt)
|
||||
self.alarm = _dt
|
||||
|
||||
|
||||
|
||||
class ThresholdTimeWidget(BalTimeEditWidget):
|
||||
help_text = (
|
||||
"Check to ask for invalidation.\n\n"
|
||||
"When less then this time is missing, ask to invalidate.\n"
|
||||
"If you fail to invalidate during this time, your transactions will be delivered to your heirs.\n\n"
|
||||
f"{BalTimeEditWidget.help_text}"
|
||||
)
|
||||
label_text = "🚨"
|
||||
#label_text = "Check Alive"
|
||||
base_field = "threshold"
|
||||
|
||||
def __init__(self, bal_window, parent, init_value=None):
|
||||
if init_value is None:
|
||||
init_value = bal_window.bal_plugin.WILL_SETTINGS.get()["threshold"]
|
||||
super().__init__(bal_window, parent, init_value)
|
||||
self.default_value = self.bal_window.bal_plugin.default_will_settings()[
|
||||
"threshold"
|
||||
]
|
||||
|
||||
|
||||
|
||||
class LockTimeWidget(BalTimeEditWidget):
|
||||
help_text = (
|
||||
"Set Locktime for transactions.\n"
|
||||
"Any time is needed transaction will be anticipated by 1day\n"
|
||||
f"{BalTimeEditWidget.help_text}"
|
||||
)
|
||||
label_text = "🚛"
|
||||
#label_text = "Locktime"
|
||||
base_field = "locktime"
|
||||
|
||||
def __init__(self, bal_window, parent, init_value=None):
|
||||
if init_value is None:
|
||||
init_value = bal_window.bal_plugin.WILL_SETTINGS.get()["locktime"]
|
||||
super().__init__(bal_window, parent, init_value)
|
||||
self.default_value = self.bal_window.bal_plugin.default_will_settings()[
|
||||
"locktime"
|
||||
]
|
||||
|
||||
|
||||
|
||||
class WillSettingsWidget(QWidget):
|
||||
|
||||
def __init__(self, bal_window: "BalWindow", parent, layout_type="h"):
|
||||
self.widgets = {}
|
||||
QWidget.__init__(self, parent)
|
||||
self.bal_window = bal_window
|
||||
box = QHBoxLayout(self) if layout_type == "h" else QVBoxLayout(self)
|
||||
|
||||
self.calendar_button = QPushButton()
|
||||
self.calendar_button.setIcon(
|
||||
read_QIcon_from_bytes(
|
||||
self.bal_window.bal_plugin.read_file("icons/calendar.png")
|
||||
)
|
||||
)
|
||||
self.calendar_button.clicked.connect(self.open_or_save_calendar)
|
||||
self.widgets["locktime"] = LockTimeWidget(bal_window, self)
|
||||
self.widgets["threshold"] = ThresholdTimeWidget(bal_window, self)
|
||||
self.widgets["locktime"].valueEdited.connect(self.on_locktime_change)
|
||||
self.widgets["threshold"].valueEdited.connect(self.on_locktime_change)
|
||||
# self.widgets['baltx_fees'].valueChange.connect(self.bal_window.update_setting_widgets)
|
||||
self.on_locktime_change()
|
||||
self.widgets["baltx_fees"] = BalTxFeesWidget(bal_window, self)
|
||||
if not hasattr(bal_window, "txfee_widgets"):
|
||||
bal_window.txfee_widgets = []
|
||||
|
||||
w = self.widgets["baltx_fees"]
|
||||
if w not in bal_window.txfee_widgets:
|
||||
bal_window.txfee_widgets.append(w)
|
||||
box.addWidget(self.widgets["locktime"])
|
||||
box.addWidget(self.widgets["threshold"])
|
||||
box.addWidget(self.calendar_button)
|
||||
box.addWidget(self.widgets["baltx_fees"])
|
||||
|
||||
def create_alarms(self, alarm_start, alarm_end):
|
||||
days = (alarm_end - alarm_start).days+1
|
||||
lines = []
|
||||
for i in range(1, days):
|
||||
lines.extend(
|
||||
[
|
||||
"BEGIN:VALARM",
|
||||
f"TRIGGER;RELATED=END:-P{i}D",
|
||||
"ACTION:DISPLAY",
|
||||
# f"DESCRIPTION:{self.bal_window.bal_plugin.ALARM_DESCRIPTION.get()}",
|
||||
"END:VALARM",
|
||||
]
|
||||
)
|
||||
return lines
|
||||
|
||||
def open_or_save_calendar(self):
|
||||
now = BalCalendar.format_time(datetime.now())
|
||||
|
||||
locktime = self.widgets["locktime"].alarm
|
||||
threshold = self.widgets["threshold"].alarm
|
||||
alarm_end = BalCalendar.format_time(locktime)
|
||||
alarm_start = BalCalendar.format_time(threshold)
|
||||
days_difference = (locktime - threshold).days
|
||||
|
||||
heirs_details = "\r\n".join(f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}" for heir in self.bal_window.heirs)
|
||||
event_description = BalCalendar.ical_escape(
|
||||
f"{self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()}".replace("$wallet_name",str(self.bal_window.wallet)).replace("$heirs_complete",heirs_details)
|
||||
)
|
||||
#event_description =f"{event_description}{heirs_details}"
|
||||
uid = f"bal-{str(self.bal_window.wallet)}"
|
||||
summary = BalCalendar.ical_escape(
|
||||
f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}".replace("$wallet_name",str(self.bal_window.wallet))
|
||||
)
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
|
||||
"BEGIN:VEVENT",
|
||||
f"UID:{uid}",
|
||||
f"DTSTAMP:{now}",
|
||||
f"DTSTART:{alarm_end}",
|
||||
f"DTEND:{alarm_end}",
|
||||
f"SUMMARY:{summary}",
|
||||
f"DESCRIPTION:{event_description}",
|
||||
]
|
||||
lines.extend(self.create_alarms(threshold, locktime))
|
||||
lines.extend([
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
])
|
||||
|
||||
lines = [s.rstrip("\r\n") for s in lines]
|
||||
ics_content = "\r\n".join(lines) + "\r\n"
|
||||
self.temp_path = BalCalendar.write_temp_ics(ics_content)
|
||||
opened = BalCalendar.open_with_default_app(
|
||||
self.bal_window.bal_plugin.CALENDAR_APP.get(), self.temp_path
|
||||
)
|
||||
if opened:
|
||||
_logger.info(f"File opened with default app: {self.temp_path}")
|
||||
else:
|
||||
export_meta_gui(
|
||||
self.bal_window.window, f"will_event.ics",self.save_to_cwd
|
||||
|
||||
)
|
||||
|
||||
|
||||
def save_to_cwd(self,filename="event.ics"):
|
||||
target = os.path.abspath(filename)
|
||||
# se il file esiste, sovrascrive
|
||||
_logger.debug(f"save_to_cwd {self.temp_path},{filename}")
|
||||
with open(self.temp_path, "rb") as src, open(target, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
return target
|
||||
|
||||
def on_locktime_change(self):
|
||||
locktime = self.widgets["locktime"].get_value()
|
||||
threshold = self.widgets["threshold"].get_value()
|
||||
locktime = BalTimestamp(locktime)
|
||||
threshold = BalTimestamp(threshold)
|
||||
|
||||
min_locktime = min(
|
||||
Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX),
|
||||
locktime.to_timestamp(),
|
||||
)
|
||||
td = threshold.to_date(min_locktime, True)
|
||||
self.widgets["threshold"].alarm=td
|
||||
self.bal_window.will_settings["real_threshold"]=td.timestamp()
|
||||
try:
|
||||
self.widgets["threshold"].editor.label.setText(td.strftime("%Y-%m-%d"))
|
||||
except Exception as _e:
|
||||
pass
|
||||
|
||||
td = locktime.to_date()
|
||||
alarm = BalTimestamp(min_locktime).to_date()
|
||||
self.widgets["locktime"].alarm=alarm
|
||||
self.bal_window.will_settings["real_locktime"]=td.timestamp()
|
||||
try:
|
||||
self.widgets["locktime"].editor.label.setText(td.strftime("%Y-%m-%d"))
|
||||
except Exception as _e:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class PercAmountEdit(BTCAmountEdit):
|
||||
def __init__(self, decimal_point, is_int=False, parent=None, *, max_amount=None):
|
||||
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
|
||||
|
||||
def numbify(self):
|
||||
text = self.text().strip()
|
||||
if text == "!":
|
||||
self.shortcut.emit()
|
||||
return
|
||||
pos = self.cursorPosition()
|
||||
chars = "0123456789%"
|
||||
chars += DECIMAL_POINT
|
||||
|
||||
s = "".join([i for i in text if i in chars])
|
||||
|
||||
if "%" in s:
|
||||
self.is_perc = True
|
||||
s = s.replace("%", "")
|
||||
else:
|
||||
self.is_perc = False
|
||||
|
||||
if DECIMAL_POINT in s:
|
||||
p = s.find(DECIMAL_POINT)
|
||||
s = s.replace(DECIMAL_POINT, "")
|
||||
s = s[:p] + DECIMAL_POINT + s[p : p + 8]
|
||||
if self.is_perc:
|
||||
s += "%"
|
||||
|
||||
self.setText(s)
|
||||
self.setModified(self.hasFocus())
|
||||
self.setCursorPosition(pos)
|
||||
|
||||
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
|
||||
try:
|
||||
text = text.replace(DECIMAL_POINT, ".")
|
||||
text = text.replace("%", "")
|
||||
return (Decimal)(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_text_from_amount(self, amount):
|
||||
out = super()._get_text_from_amount(amount)
|
||||
if self.is_perc:
|
||||
out += "%"
|
||||
return out
|
||||
|
||||
def paintEvent(self, event):
|
||||
QLineEdit.paintEvent(self, event)
|
||||
if self.base_unit:
|
||||
panel = QStyleOptionFrame()
|
||||
self.initStyleOption(panel)
|
||||
textRect = self.style().subElementRect(
|
||||
QStyle.SubElement.SE_LineEditContents, panel, self
|
||||
)
|
||||
textRect.adjust(2, 0, -10, 0)
|
||||
painter = QPainter(self)
|
||||
painter.setPen(ColorScheme.GRAY.as_color())
|
||||
if len(self.text()) == 0:
|
||||
painter.drawText(
|
||||
textRect,
|
||||
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
||||
self.base_unit() + " or perc value",
|
||||
)
|
||||
|
||||
|
||||
|
||||
class BalLineEdit(QLineEdit):
|
||||
def __init__(self,variable):
|
||||
QLineEdit.__init__(self)
|
||||
self.setText(variable.get())
|
||||
def on_edit():
|
||||
variable.set(self.text())
|
||||
self.editingFinished.connect(on_edit)
|
||||
|
||||
|
||||
class BalTextEdit(QTextEdit):
|
||||
def __init__(self,variable):
|
||||
QTextEdit.__init__(self)
|
||||
self.setPlainText(variable.get())
|
||||
def on_edit():
|
||||
variable.set(self.toPlainText())
|
||||
self.textChanged.connect(on_edit)
|
||||
|
||||
|
||||
class BalCheckBox(QCheckBox):
|
||||
def __init__(self, variable, on_click=None):
|
||||
QCheckBox.__init__(self)
|
||||
self.setChecked(variable.get())
|
||||
self.on_click = on_click
|
||||
|
||||
def on_check(v):
|
||||
variable.set(v == 2)
|
||||
#variable.get()
|
||||
if self.on_click:
|
||||
self.on_click()
|
||||
|
||||
self.stateChanged.connect(on_check)
|
||||
|
||||
|
||||
|
||||
class WillWidget(QWidget):
|
||||
def __init__(self, father=None, parent=None):
|
||||
super().__init__()
|
||||
vlayout = QVBoxLayout()
|
||||
self.setLayout(vlayout)
|
||||
self.will = parent.bal_window.willitems
|
||||
self.parent = parent
|
||||
for w in self.will:
|
||||
if (
|
||||
self.will[w].get_status("REPLACED")
|
||||
and self.parent.bal_window.bal_plugin._hide_replaced
|
||||
):
|
||||
continue
|
||||
if (
|
||||
self.will[w].get_status("INVALIDATED")
|
||||
and self.parent.bal_window.bal_plugin._hide_invalidated
|
||||
):
|
||||
continue
|
||||
f = self.will[w].father
|
||||
if father == f:
|
||||
qwidget = QWidget()
|
||||
# childWidget = QWidget()
|
||||
hlayout = QHBoxLayout(qwidget)
|
||||
qwidget.setLayout(hlayout)
|
||||
vlayout.addWidget(qwidget)
|
||||
detailw = QWidget()
|
||||
detaillayout = QVBoxLayout()
|
||||
detailw.setLayout(detaillayout)
|
||||
|
||||
willpushbutton = QPushButton(w)
|
||||
|
||||
willpushbutton.clicked.connect(
|
||||
partial(self.parent.bal_window.show_transaction, txid=w)
|
||||
)
|
||||
detaillayout.addWidget(willpushbutton)
|
||||
locktime = str(BalTimestamp(self.will[w].tx.locktime))
|
||||
creation = str(BalTimestamp(self.will[w].time))
|
||||
|
||||
def qlabel(title, value):
|
||||
label = "<b>" + _(str(title)) + f":</b>\t{str(value)}"
|
||||
return QLabel(label)
|
||||
|
||||
detaillayout.addWidget(qlabel("Locktime", locktime))
|
||||
detaillayout.addWidget(qlabel("Creation Time", creation))
|
||||
try:
|
||||
total_fees = (
|
||||
self.will[w].tx.input_value() - self.will[w].tx.output_value()
|
||||
)
|
||||
except Exception:
|
||||
total_fees = -1
|
||||
decoded_fees = total_fees
|
||||
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
|
||||
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
|
||||
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
|
||||
detaillayout.addWidget(qlabel("Status:", self.will[w].status))
|
||||
detaillayout.addWidget(QLabel(""))
|
||||
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
||||
for heir in self.will[w].heirs:
|
||||
if 'w!ll3x3c"' not in heir:
|
||||
decoded_amount = Util.decode_amount(
|
||||
self.will[w].heirs[heir][3], self.parent.decimal_point
|
||||
)
|
||||
detaillayout.addWidget(
|
||||
qlabel(
|
||||
heir, f"{decoded_amount} {self.parent.base_unit_name}"
|
||||
)
|
||||
)
|
||||
if self.will[w].we:
|
||||
detaillayout.addWidget(QLabel(""))
|
||||
detaillayout.addWidget(QLabel(_("<b>Willexecutor:</b:")))
|
||||
decoded_amount = Util.decode_amount(
|
||||
self.will[w].we["base_fee"], self.parent.decimal_point
|
||||
)
|
||||
|
||||
detaillayout.addWidget(
|
||||
qlabel(
|
||||
self.will[w].we["url"],
|
||||
f"{decoded_amount} {self.parent.base_unit_name}",
|
||||
)
|
||||
)
|
||||
detaillayout.addStretch()
|
||||
pal = QPalette()
|
||||
pal.setColor(
|
||||
QPalette.ColorRole.Window, QColor(status_color(self.will[w]))
|
||||
)
|
||||
detailw.setAutoFillBackground(True)
|
||||
detailw.setPalette(pal)
|
||||
|
||||
hlayout.addWidget(detailw)
|
||||
hlayout.addWidget(WillWidget(w, parent=parent))
|
||||
|
||||
|
||||
952
bal/gui/qt/window.py
Normal file
@@ -0,0 +1,952 @@
|
||||
"""
|
||||
bal.gui.qt.window
|
||||
=================
|
||||
|
||||
The :class:`BalWindow` controller: one instance per Electrum wallet window.
|
||||
|
||||
This is the orchestration layer that ties together the heirs list, the will
|
||||
preview, the will-executors and the various dialogs. It owns the per-wallet
|
||||
state (``heirs``, ``will``, ``willitems``, ``will_settings``) and exposes the
|
||||
high-level actions (build / check / sign / broadcast / invalidate the will,
|
||||
import/export, etc.) that the menus, tabs and dialogs invoke.
|
||||
|
||||
The actual Bitcoin logic lives in :mod:`bal.core`; this class only coordinates
|
||||
it with the GUI.
|
||||
"""
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .widgets import LockTimeWidget, PercAmountEdit, WillSettingsWidget
|
||||
from .dialogs import (BalBlockingWaitingDialog, BalBuildWillDialog, BalDialog,
|
||||
BalWaitingDialog, BalWizardDialog, WillDetailDialog,
|
||||
WillExecutorDialog)
|
||||
from .lists import HeirListWidget, PreviewList, WillExecutorWidget
|
||||
|
||||
|
||||
class BalWindow:
|
||||
def __init__(self, bal_plugin: "BalPlugin", window: "ElectrumWindow"):
|
||||
self.bal_plugin = bal_plugin
|
||||
self.window = window
|
||||
self.heirs = {}
|
||||
self.will = {}
|
||||
self.willitems = {}
|
||||
self.willexecutors = {}
|
||||
self.will_settings = None
|
||||
self.ok = False
|
||||
self.disable_plugin = True
|
||||
self.bal_plugin.get_decimal_point = self.window.get_decimal_point
|
||||
|
||||
if self.window.wallet:
|
||||
self.wallet = self.window.wallet
|
||||
if not self.will_settings:
|
||||
self.will_settings = self.bal_plugin.WILL_SETTINGS.get()
|
||||
Util.fix_will_settings_tx_fees(self.will_settings)
|
||||
self.heirs = Heirs(self.wallet)
|
||||
|
||||
self.heirs_tab = self.create_heirs_tab()
|
||||
self.will_tab = self.create_will_tab()
|
||||
self.heirs_tab.wallet = self.wallet
|
||||
self.will_tab.wallet = self.wallet
|
||||
|
||||
def init_menubar_tools(self, tools_menu):
|
||||
self.tools_menu = tools_menu
|
||||
|
||||
def add_optional_tab(tabs, tab, icon, description):
|
||||
tab.tab_icon = icon
|
||||
tab.tab_description = description
|
||||
tab.tab_pos = len(tabs)
|
||||
if tab.is_shown_cv.get():
|
||||
tabs.addTab(tab, icon, description.replace("&", ""))
|
||||
|
||||
def add_toggle_action(tab):
|
||||
is_shown = tab.is_shown_cv.get()
|
||||
tab.menu_action = self.window.view_menu.addAction(
|
||||
tab.tab_description, lambda: self.window.toggle_tab(tab)
|
||||
)
|
||||
tab.menu_action.setCheckable(True)
|
||||
tab.menu_action.setChecked(is_shown)
|
||||
|
||||
add_optional_tab(
|
||||
self.window.tabs,
|
||||
self.heirs_tab,
|
||||
read_QIcon_from_bytes(self.bal_plugin.read_file("icons/heir.png")),
|
||||
_("&Heirs"),
|
||||
)
|
||||
add_optional_tab(
|
||||
self.window.tabs,
|
||||
self.will_tab,
|
||||
read_QIcon_from_bytes(self.bal_plugin.read_file("icons/will.png")),
|
||||
_("&Will"),
|
||||
)
|
||||
tools_menu.addSeparator()
|
||||
self.tools_menu.willexecutors_action = tools_menu.addAction(
|
||||
_("&Will-Executors"), self.show_willexecutor_dialog
|
||||
)
|
||||
self.window.view_menu.addSeparator()
|
||||
add_toggle_action(self.heirs_tab)
|
||||
add_toggle_action(self.will_tab)
|
||||
|
||||
def load_willitems(self):
|
||||
self.willitems = {}
|
||||
for wid, w in self.will.items():
|
||||
self.willitems[wid] = WillItem(w, wallet=self.wallet)
|
||||
if self.willitems:
|
||||
self.will_list_widget.will = self.willitems
|
||||
self.will_list_widget.update_will(self.willitems)
|
||||
self.will_tab.update()
|
||||
|
||||
def save_willitems(self):
|
||||
keys = list(self.will.keys())
|
||||
for k in keys:
|
||||
del self.will[k]
|
||||
for wid, w in self.willitems.items():
|
||||
self.will[wid] = w.to_dict()
|
||||
|
||||
def init_will(self):
|
||||
_logger.info("********************init_____will____________**********")
|
||||
if not self.willexecutors:
|
||||
self.willexecutors = Willexecutors.get_willexecutors(
|
||||
self.bal_plugin, update=False, bal_window=self
|
||||
)
|
||||
if not self.heirs:
|
||||
self.heirs = Heirs._validate(Heirs(self.wallet))
|
||||
self.heirs_tab.update()
|
||||
if not self.will:
|
||||
self.will = self.wallet.db.get_dict("will")
|
||||
Util.fix_will_tx_fees(self.will)
|
||||
if self.will:
|
||||
self.willitems = {}
|
||||
try:
|
||||
self.load_willitems()
|
||||
except Exception:
|
||||
self.disable_plugin = True
|
||||
self.show_warning(
|
||||
_("Please restart Electrum to activate the BAL plugin"),
|
||||
title=_("Success"),
|
||||
)
|
||||
self.close_wallet()
|
||||
return
|
||||
|
||||
# if not self.will_settings:
|
||||
# self.will_settings = self.wallet.db.get_dict("will_settings")
|
||||
# Util.fix_will_settings_tx_fees(self.will_settings)
|
||||
|
||||
# _logger.info("will_settings: {}".format(self.will_settings))
|
||||
# if not self.will_settings:
|
||||
# Util.copy(self.will_settings, self.bal_plugin.default_will_settings())
|
||||
# _logger.debug("not_will_settings {}".format(self.will_settings))
|
||||
# self.bal_plugin.validate_will_settings(self.will_settings)
|
||||
# self.heir_list_widget.update_will_settings()
|
||||
# self.heir_list_widget.update()
|
||||
|
||||
def init_wizard(self):
|
||||
wizard_dialog = BalWizardDialog(self)
|
||||
wizard_dialog.exec()
|
||||
|
||||
def show_willexecutor_dialog(self):
|
||||
self.willexecutor_dialog = WillExecutorDialog(self)
|
||||
self.willexecutor_dialog.show()
|
||||
|
||||
def create_heirs_tab(self):
|
||||
if not self.heirs:
|
||||
self.heirs = Heirs(self.wallet)
|
||||
self.heir_list_widget = HeirListWidget(self, self.window)
|
||||
tab = self.window.create_list_tab(self.heir_list_widget)
|
||||
tab.is_shown_cv = shown_cv(False)
|
||||
return tab
|
||||
|
||||
def create_will_tab(self):
|
||||
self.will_list_widget = PreviewList(self, self.window, None)
|
||||
tab = self.window.create_list_tab(self.will_list_widget)
|
||||
tab.is_shown_cv = shown_cv(True)
|
||||
return tab
|
||||
|
||||
def new_heir_dialog(self, heir_key=None):
|
||||
heir = self.heirs.get(heir_key)
|
||||
title = "New heir"
|
||||
if heir:
|
||||
title = f"Edit: {heir_key}"
|
||||
|
||||
d = BalDialog(
|
||||
self.window, self.bal_plugin, self.bal_plugin.get_window_title(_(title))
|
||||
)
|
||||
|
||||
vbox = QVBoxLayout(d)
|
||||
grid = QGridLayout()
|
||||
|
||||
heir_name = QLineEdit()
|
||||
heir_name.setFixedWidth(32 * char_width_in_lineedit())
|
||||
heir_address = QLineEdit()
|
||||
heir_address.setFixedWidth(32 * char_width_in_lineedit())
|
||||
heir_amount = PercAmountEdit(self.window.get_decimal_point)
|
||||
|
||||
if heir:
|
||||
heir_name.setText(str(heir_key))
|
||||
heir_address.setText(str(heir[0]))
|
||||
heir_amount.setText(
|
||||
str(Util.decode_amount(heir[1], self.window.get_decimal_point()))
|
||||
)
|
||||
self.heir_locktime = LockTimeWidget(self, self.window, heir[2])
|
||||
|
||||
# heir_is_xpub = QCheckBox()
|
||||
|
||||
new_heir_button = QPushButton(_("Add another heir"))
|
||||
self.add_another_heir = False
|
||||
|
||||
def new_heir():
|
||||
self.add_another_heir = True
|
||||
d.accept()
|
||||
|
||||
new_heir_button.clicked.connect(new_heir)
|
||||
new_heir_button.setDefault(True)
|
||||
|
||||
grid.addWidget(QLabel(_("Name")), 1, 0)
|
||||
grid.addWidget(heir_name, 1, 1)
|
||||
grid.addWidget(HelpButton(_("Unique name or description about heir")), 1, 2)
|
||||
|
||||
grid.addWidget(QLabel(_("Address")), 2, 0)
|
||||
grid.addWidget(heir_address, 2, 1)
|
||||
grid.addWidget(HelpButton(_("heir bitcoin address")), 2, 2)
|
||||
|
||||
grid.addWidget(QLabel(_("Amount")), 3, 0)
|
||||
grid.addWidget(heir_amount, 3, 1)
|
||||
grid.addWidget(HelpButton(_("Fixed or Percentage amount if end with %")), 3, 2)
|
||||
|
||||
locktime_label = QLabel(_("Locktime"))
|
||||
enable_multiverse = self.bal_plugin.ENABLE_MULTIVERSE.get()
|
||||
if enable_multiverse:
|
||||
grid.addWidget(locktime_label, 4, 0)
|
||||
grid.addWidget(self.heir_locktime, 4, 1)
|
||||
grid.addWidget(HelpButton(_("locktime")), 4, 2)
|
||||
|
||||
vbox.addLayout(grid)
|
||||
buttons = [CancelButton(d), OkButton(d)]
|
||||
if not heir:
|
||||
buttons.append(new_heir_button)
|
||||
vbox.addLayout(Buttons(*buttons))
|
||||
while d.exec():
|
||||
# TODO SAVE HEIR
|
||||
heir = [
|
||||
heir_name.text(),
|
||||
heir_address.text(),
|
||||
Util.encode_amount(heir_amount.text(), self.window.get_decimal_point()),
|
||||
str(self.will_settings["locktime"]),
|
||||
]
|
||||
try:
|
||||
self.set_heir(heir)
|
||||
if self.add_another_heir:
|
||||
self.new_heir_dialog()
|
||||
break
|
||||
except Exception as e:
|
||||
self.show_error(str(e))
|
||||
|
||||
def set_heir(self, heir):
|
||||
heir = list(heir)
|
||||
if not self.bal_plugin.ENABLE_MULTIVERSE.get():
|
||||
heir[3] = self.will_settings["locktime"]
|
||||
|
||||
h = Heirs.validate_heir(heir[0], heir[1:])
|
||||
self.heirs[heir[0]] = h
|
||||
self.heir_list_widget.update()
|
||||
return True
|
||||
|
||||
def delete_heirs(self, heirs):
|
||||
for heir in heirs:
|
||||
try:
|
||||
del self.heirs[heir]
|
||||
except Exception as e:
|
||||
_logger.debug(f"error deleting heir: {heir} {e}")
|
||||
pass
|
||||
self.heirs.save()
|
||||
self.heir_list_widget.update()
|
||||
return True
|
||||
|
||||
def import_heirs(self):
|
||||
import_meta_gui(
|
||||
self.window,
|
||||
_("heirs"),
|
||||
self.heirs.import_file,
|
||||
self.heir_list_widget.update,
|
||||
)
|
||||
|
||||
def export_heirs(self):
|
||||
export_meta_gui(self.window, "heirs.json", self.heirs.export_file)
|
||||
|
||||
def prepare_will(self, ignore_duplicate=False, keep_original=False):
|
||||
will = self.build_inheritance_transaction(
|
||||
ignore_duplicate=ignore_duplicate, keep_original=keep_original
|
||||
)
|
||||
return will
|
||||
|
||||
def delete_not_valid(self, txid, s_utxo):
|
||||
raise NotImplementedError()
|
||||
|
||||
def update_will(self, will):
|
||||
Will.update_will(self.willitems, will)
|
||||
self.willitems.update(will)
|
||||
Will.normalize_will(self.willitems, self.wallet)
|
||||
|
||||
def build_will(self, ignore_duplicate=True, keep_original=True):
|
||||
_logger.debug("building will...")
|
||||
will = {}
|
||||
# willtodelete = []
|
||||
# willtoappend = {}
|
||||
try:
|
||||
self.willexecutors = Willexecutors.get_willexecutors(
|
||||
self.bal_plugin, update=False, bal_window=self
|
||||
)
|
||||
if not self.no_willexecutor:
|
||||
|
||||
f = False
|
||||
for _u, w in self.willexecutors.items():
|
||||
if Willexecutors.is_selected(w):
|
||||
f = True
|
||||
if not f:
|
||||
_logger.error("No Will-Executor or backup transaction selected")
|
||||
raise NoWillExecutorNotPresent(
|
||||
"No Will-Executor or backup transaction selected"
|
||||
)
|
||||
txs = self.heirs.get_transactions(
|
||||
self.bal_plugin,
|
||||
self.window.wallet,
|
||||
self.will_settings["baltx_fees"],
|
||||
None,
|
||||
self.date_to_check,
|
||||
)
|
||||
|
||||
_logger.info(f"txs built: {txs}")
|
||||
creation_time = time.time()
|
||||
if txs:
|
||||
for txid in txs:
|
||||
# txtodelete = []
|
||||
_break = False
|
||||
tx = {}
|
||||
tx["tx"] = txs[txid]
|
||||
tx["my_locktime"] = txs[txid].my_locktime
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
||||
tx["status"] = _("New")
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
||||
tx["txchildren"] = []
|
||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||
self.update_will(will)
|
||||
else:
|
||||
_logger.info("No transactions was built")
|
||||
_logger.info(f"will-settings: {self.will_settings}")
|
||||
_logger.info(f"date_to_check:{self.date_to_check}")
|
||||
_logger.info(f"heirs: {self.heirs}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
_logger.info(f"Exception build_will: {e}")
|
||||
raise e
|
||||
pass
|
||||
return self.willitems
|
||||
|
||||
def check_will(self):
|
||||
return Will.is_will_valid(
|
||||
self.willitems,
|
||||
self.block_to_check,
|
||||
self.date_to_check,
|
||||
self.will_settings["baltx_fees"],
|
||||
self.window.wallet.get_utxos(),
|
||||
heirs=self.heirs,
|
||||
willexecutors=self.willexecutors,
|
||||
self_willexecutor=self.no_willexecutor,
|
||||
wallet=self.wallet,
|
||||
callback_not_valid_tx=self.delete_not_valid,
|
||||
)
|
||||
|
||||
def show_message(self, text):
|
||||
self.window.show_message(text)
|
||||
|
||||
def show_warning(self, text, parent=None):
|
||||
self.window.show_warning(text, parent=None)
|
||||
|
||||
def show_error(self, text):
|
||||
self.window.show_error(text)
|
||||
|
||||
def show_critical(self, text):
|
||||
self.window.show_critical(text)
|
||||
|
||||
def update_combo_setting_widgets(
|
||||
self,
|
||||
new_value,
|
||||
field,
|
||||
update_all=False,
|
||||
update_will_dialog=False,
|
||||
update_heirs_dialog=False,
|
||||
):
|
||||
if (update_all or update_will_dialog) and hasattr(self,'will_list_widget'):
|
||||
self.update_widget_combo(self.will_list_widget,field,new_value)
|
||||
if update_all or update_heirs_dialog and hasattr(self,'heir_list_widget'):
|
||||
self.update_widget_combo(self.heir_list_widget,field,new_value)
|
||||
|
||||
|
||||
def update_widget_combo(self,widget,field,value):
|
||||
try:
|
||||
widget.will_settings_widget.widgets[field].set_index(value)
|
||||
except Exception as _e:
|
||||
pass
|
||||
def update_widget_value(self, widget, field, value):
|
||||
try:
|
||||
widget.will_settings_widget.widgets[field].set_value(value)
|
||||
except Exception as _e:
|
||||
pass
|
||||
|
||||
def update_setting_widgets(
|
||||
self,
|
||||
new_value,
|
||||
field,
|
||||
update_all=False,
|
||||
update_will_dialog=False,
|
||||
update_heirs_dialog=False,
|
||||
):
|
||||
if update_all or update_heirs_dialog:
|
||||
self.update_widget_value(self.heir_list_widget, field, new_value)
|
||||
if update_all or update_will_dialog:
|
||||
self.update_widget_value(self.will_list_widget, field, new_value)
|
||||
self.will_settings[field] = new_value
|
||||
self.bal_plugin.WILL_SETTINGS.set(self.will_settings)
|
||||
|
||||
def init_heirs_to_locktime(self, multiverse=False):
|
||||
#pass
|
||||
for heir in self.heirs:
|
||||
h = self.heirs[heir]
|
||||
if not multiverse:
|
||||
self.heirs[heir] = [h[0], h[1], self.will_settings["locktime"]]
|
||||
|
||||
def init_class_variables(self):
|
||||
if not self.heirs:
|
||||
raise NoHeirsException(_("Heirs are not defined"))
|
||||
try:
|
||||
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
|
||||
# found = False
|
||||
self.locktime_blocks = self.bal_plugin.LOCKTIME_BLOCKS.get()
|
||||
self.current_block = Util.get_current_height(self.wallet.network)
|
||||
self.block_to_check = 0
|
||||
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
|
||||
self.willexecutors = Willexecutors.get_willexecutors(
|
||||
self.bal_plugin, update=True, bal_window=self, task=False
|
||||
)
|
||||
if self.date_to_check < datetime.now().timestamp():
|
||||
raise CheckAliveError(self.date_to_check)
|
||||
|
||||
self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get())
|
||||
|
||||
except Exception as e:
|
||||
log_error(e )
|
||||
_logger.error(f"init_class_variables: {e}")
|
||||
|
||||
raise e
|
||||
|
||||
def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True):
|
||||
try:
|
||||
if self.disable_plugin:
|
||||
_logger.info("plugin is disabled")
|
||||
return
|
||||
if not self.heirs:
|
||||
_logger.warning("not heirs {}".format(self.heirs))
|
||||
return
|
||||
try:
|
||||
self.init_class_variables()
|
||||
Will.check_amounts(
|
||||
self.heirs,
|
||||
self.willexecutors,
|
||||
self.window.wallet.get_utxos(),
|
||||
self.date_to_check,
|
||||
self.window.wallet.dust_threshold(),
|
||||
)
|
||||
except AmountException as e:
|
||||
self.show_warning(
|
||||
_(
|
||||
f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}"
|
||||
)
|
||||
)
|
||||
except CheckAliveError:
|
||||
self.show_error(
|
||||
_(
|
||||
"CheckAlive is in the past please update it to a date in the future but less than locktime"
|
||||
)
|
||||
)
|
||||
return
|
||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||
if locktime < self.date_to_check:
|
||||
self.show_error(_("locktime is lower than threshold"))
|
||||
return
|
||||
if not self.no_willexecutor:
|
||||
f = False
|
||||
for _k, we in self.willexecutors.items():
|
||||
if Willexecutors.is_selected(we):
|
||||
f = True
|
||||
if not f:
|
||||
self.show_error(
|
||||
_(" no backup transaction or willexecutor selected")
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
self.check_will()
|
||||
except WillExpiredException:
|
||||
self.invalidate_will()
|
||||
return
|
||||
except NoHeirsException:
|
||||
return
|
||||
except NotCompleteWillException as e:
|
||||
_logger.info("{}:{}".format(type(e), e))
|
||||
message = False
|
||||
if isinstance(e, HeirChangeException):
|
||||
message = "Heirs changed:"
|
||||
elif isinstance(e, WillExecutorNotPresent):
|
||||
message = "Will-Executor not present:"
|
||||
elif isinstance(e, WillexecutorChangeException):
|
||||
message = "Will-Executor changed"
|
||||
elif isinstance(e, TxFeesChangedException):
|
||||
message = "Txfees are changed"
|
||||
elif isinstance(e, HeirNotFoundException):
|
||||
message = "Heir not found"
|
||||
|
||||
if message:
|
||||
self.show_message(
|
||||
f"{_(message)}:\n {e}\n{_('will have to be built')}"
|
||||
)
|
||||
|
||||
_logger.info("build will")
|
||||
self.build_will(ignore_duplicate, keep_original)
|
||||
|
||||
try:
|
||||
self.check_will()
|
||||
for wid, _w in self.willitems.items():
|
||||
self.wallet.set_label(wid, "BAL Transaction")
|
||||
except WillExpiredException as e:
|
||||
self.invalidate_will()
|
||||
except NotCompleteWillException as e:
|
||||
self.show_error(
|
||||
"Error:{}\n {}".format(
|
||||
str(e),
|
||||
_("Please, check your heirs, locktime and threshold!"),
|
||||
)
|
||||
)
|
||||
|
||||
self.window.history_list.update()
|
||||
self.window.utxo_list.update()
|
||||
self.update_all()
|
||||
return self.willitems
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
def show_transaction_real(
|
||||
self,
|
||||
tx: Transaction,
|
||||
*,
|
||||
parent: "ElectrumWindow",
|
||||
prompt_if_unsaved: bool = False,
|
||||
external_keypairs: Mapping[bytes, bytes] = None,
|
||||
payment_identifier: "PaymentIdentifier" = None,
|
||||
):
|
||||
try:
|
||||
d = TxDialog(
|
||||
tx,
|
||||
parent=parent,
|
||||
prompt_if_unsaved=prompt_if_unsaved,
|
||||
external_keypairs=external_keypairs,
|
||||
# payment_identifier=payment_identifier,
|
||||
)
|
||||
d.setWindowIcon(
|
||||
read_QIcon_from_bytes(self.bal_plugin.read_file("icons/bal16x16.png"))
|
||||
)
|
||||
except SerializationError as e:
|
||||
_logger.error("unable to deserialize the transaction")
|
||||
parent.show_critical(
|
||||
_("Electrum was unable to deserialize the transaction:") + "\n" + str(e)
|
||||
)
|
||||
else:
|
||||
d.show()
|
||||
return d
|
||||
|
||||
def show_transaction(self, tx=None, txid=None, parent=None):
|
||||
if not parent:
|
||||
parent = self.window
|
||||
if txid is not None and txid in self.willitems:
|
||||
tx = self.willitems[txid].tx
|
||||
if not tx:
|
||||
raise Exception(_("no tx"))
|
||||
return self.show_transaction_real(tx, parent=parent)
|
||||
|
||||
def invalidate_will(self):
|
||||
def on_success(result):
|
||||
if result:
|
||||
self.show_message(
|
||||
_(
|
||||
"Please sign and broadcast this transaction to invalidate current will"
|
||||
)
|
||||
)
|
||||
self.wallet.set_label(result.txid(), "BAL Invalidate")
|
||||
self.show_transaction(result)
|
||||
else:
|
||||
self.show_message(_("No transactions to invalidate"))
|
||||
|
||||
def on_failure(exec_info):
|
||||
log_error(exec_info, self.bal_window)
|
||||
|
||||
fee_per_byte = self.will_settings.get("baltx_fees", 1)
|
||||
task = partial(Will.invalidate_will, self.willitems, self.wallet, fee_per_byte)
|
||||
msg = _("Calculating Transactions")
|
||||
self.waiting_dialog = BalWaitingDialog(
|
||||
self, msg, task, on_success, on_failure, exe=False
|
||||
)
|
||||
self.waiting_dialog.exe()
|
||||
|
||||
def sign_transactions(self, password):
|
||||
try:
|
||||
txs = {}
|
||||
signed = None
|
||||
tosign = None
|
||||
|
||||
def get_message():
|
||||
msg = ""
|
||||
if signed:
|
||||
msg = _(f"signed: {signed}\n")
|
||||
return msg + _(f"signing: {tosign}")
|
||||
|
||||
for txid in Will.only_valid(self.willitems):
|
||||
wi = self.willitems[txid]
|
||||
tx = copy.deepcopy(wi.tx)
|
||||
if wi.get_status("COMPLETE"):
|
||||
txs[txid] = tx
|
||||
continue
|
||||
tosign = txid
|
||||
try:
|
||||
self.waiting_dialog.update(get_message())
|
||||
except Exception:
|
||||
pass
|
||||
for txin in tx.inputs():
|
||||
prevout = txin.prevout.to_json()
|
||||
if prevout[0] in self.willitems:
|
||||
change = self.willitems[prevout[0]].tx.outputs()[prevout[1]]
|
||||
txin._trusted_value_sats = change.value
|
||||
try:
|
||||
txin.script_descriptor = change.script_descriptor
|
||||
except Exception:
|
||||
pass
|
||||
txin.is_mine = True
|
||||
txin._TxInput__address = change.address
|
||||
txin._TxInput__scriptpubkey = change.scriptpubkey
|
||||
txin._TxInput__value_sats = change.value
|
||||
|
||||
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
|
||||
signed = tosign
|
||||
# is_complete = False
|
||||
if tx.is_complete():
|
||||
# is_complete = True
|
||||
wi.set_status("COMPLETE", True)
|
||||
txs[txid] = tx
|
||||
except Exception:
|
||||
return None
|
||||
return txs
|
||||
|
||||
def get_wallet_password(self, message=None, parent=None):
|
||||
parent = self.window if not parent else parent
|
||||
password = None
|
||||
if self.wallet.has_keystore_encryption():
|
||||
password = self.bal_plugin.password_dialog(parent=parent, msg=message)
|
||||
if password is None:
|
||||
return False
|
||||
try:
|
||||
self.wallet.check_password(password)
|
||||
except Exception as e:
|
||||
self.show_error(str(e))
|
||||
password = self.get_wallet_password(message)
|
||||
return password
|
||||
|
||||
def on_close(self):
|
||||
try:
|
||||
if not self.disable_plugin:
|
||||
close_window = BalBuildWillDialog(self)
|
||||
close_window.build_will_task()
|
||||
self.save_willitems()
|
||||
self.heirs_tab.close()
|
||||
self.will_tab.close()
|
||||
self.tools_menu.removeAction(self.tools_menu.willexecutors_action)
|
||||
self.window.toggle_tab(self.heirs_tab)
|
||||
self.window.toggle_tab(self.will_tab)
|
||||
self.window.tabs.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def ask_password_and_sign_transactions(self, callback=None):
|
||||
def on_success(txs):
|
||||
if txs:
|
||||
for txid, tx in txs.items():
|
||||
self.willitems[txid].tx = copy.deepcopy(tx)
|
||||
self.will[txid] = self.willitems[txid].to_dict()
|
||||
try:
|
||||
self.will_list_widget.update()
|
||||
except Exception:
|
||||
pass
|
||||
if callback:
|
||||
try:
|
||||
callback()
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
def on_failure(exec_info):
|
||||
log_error(exec_info, self.bal_window)
|
||||
|
||||
password = self.get_wallet_password()
|
||||
task = partial(self.sign_transactions, password)
|
||||
msg = _("Signing transactions...")
|
||||
self.waiting_dialog = BalWaitingDialog(
|
||||
self, msg, task, on_success, on_failure, exe=False
|
||||
)
|
||||
self.waiting_dialog.exe()
|
||||
|
||||
def broadcast_transactions(self, force=False):
|
||||
def on_success(sulcess):
|
||||
self.will_list_widget.update()
|
||||
if sulcess:
|
||||
_logger.info("error, some transaction was not sent")
|
||||
self.show_warning(_("Some transaction was not broadcasted"))
|
||||
return
|
||||
_logger.debug("OK, sulcess transaction was sent")
|
||||
self.show_message(
|
||||
_("All transactions are broadcasted to respective Will-Executors")
|
||||
)
|
||||
|
||||
def on_failure(exec_info):
|
||||
log_error(exec_info, self.bal_window)
|
||||
# a,b,c = err
|
||||
# _logger.error(f"fail to broadcast transactions:{err}")
|
||||
# _logger.error(f"error: {b}")
|
||||
# _logger.error("traceback ")
|
||||
# tb = c
|
||||
# while tb is not None:
|
||||
# frame = tb.tb_frame
|
||||
# _logger.error("file:", frame.f_code.co_filename)
|
||||
# _logger.error("name:", frame.f_code.co_name)
|
||||
# _logger.error("line:", tb.tb_lineno)
|
||||
# _logger.error("lasti:", tb.tb_lasti)
|
||||
# tb = tb.tb_next
|
||||
|
||||
task = partial(self.push_transactions_to_willexecutors, force)
|
||||
msg = _("Selecting Will-Executors")
|
||||
self.waiting_dialog = BalWaitingDialog(
|
||||
self, msg, task, on_success, on_failure, exe=False
|
||||
)
|
||||
self.waiting_dialog.exe()
|
||||
|
||||
def push_transactions_to_willexecutors(self, force=False):
|
||||
willexecutors = Willexecutors.get_willexecutor_transactions(self.willitems)
|
||||
|
||||
def getMsg(willexecutors):
|
||||
msg = "Broadcasting Transactions to Will-Executors:\n"
|
||||
for url in willexecutors:
|
||||
msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n"
|
||||
return msg
|
||||
|
||||
error = False
|
||||
for url in willexecutors:
|
||||
if self.waiting_dialog._stopping:
|
||||
return
|
||||
willexecutor = willexecutors[url]
|
||||
self.waiting_dialog.update(getMsg(willexecutors))
|
||||
if "txs" in willexecutor:
|
||||
try:
|
||||
if Willexecutors.push_transactions_to_willexecutor(
|
||||
willexecutors[url]
|
||||
):
|
||||
for wid in willexecutors[url]["txsids"]:
|
||||
self.willitems[wid].set_status("PUSHED", True)
|
||||
willexecutors[url]["broadcast_status"] = _("Success")
|
||||
else:
|
||||
for wid in willexecutors[url]["txsids"]:
|
||||
self.willitems[wid].set_status("PUSH_FAIL", True)
|
||||
error = True
|
||||
willexecutors[url]["broadcast_status"] = _("Failed")
|
||||
del willexecutor["txs"]
|
||||
except Willexecutors.AlreadyPresentException:
|
||||
for wid in willexecutor["txsids"]:
|
||||
if self.waiting_dialog._stopping:
|
||||
return
|
||||
self.waiting_dialog.update(
|
||||
"checking {} - {} : {}".format(
|
||||
self.willitems[wid].we["url"], wid, "Waiting"
|
||||
)
|
||||
)
|
||||
w = self.willitems[wid]
|
||||
w.set_check_willexecutor(
|
||||
Willexecutors.check_transaction(wid, w.we["url"])
|
||||
)
|
||||
self.waiting_dialog.update(
|
||||
"checked {} - {} : {}".format(
|
||||
self.willitems[wid].we["url"],
|
||||
wid,
|
||||
self.willitems[wid].get_status("CHECKED"),
|
||||
)
|
||||
)
|
||||
|
||||
if error:
|
||||
return True
|
||||
|
||||
def export_json_file(self, path):
|
||||
for wid in self.willitems:
|
||||
self.willitems[wid].set_status("EXPORTED", True)
|
||||
self.will[wid] = self.willitems[wid].to_dict()
|
||||
write_json_file(path, self.will)
|
||||
|
||||
def export_will(self):
|
||||
try:
|
||||
export_meta_gui(self.window, "will.json", self.export_json_file)
|
||||
except Exception as e:
|
||||
self.show_error(str(e))
|
||||
raise e
|
||||
|
||||
def import_will(self):
|
||||
def sulcess():
|
||||
self.will_list_widget.update_will(self.willitems)
|
||||
|
||||
import_meta_gui(self.window, _("will"), self.import_json_file, sulcess)
|
||||
|
||||
def import_json_file(self, path):
|
||||
try:
|
||||
data = read_json_file(path)
|
||||
willitems = {}
|
||||
for k, v in data.items():
|
||||
data[k]["tx"] = tx_from_any(v["tx"])
|
||||
willitems[k] = WillItem(data[k], _id=k)
|
||||
self.update_will(willitems)
|
||||
except Exception as e:
|
||||
raise e
|
||||
# raise FileImportFailed(_("Invalid will file"))
|
||||
|
||||
def check_transactions_task(self, will):
|
||||
start = time.time()
|
||||
for wid, w in will.items():
|
||||
if self.waiting_dialog._stopping:
|
||||
return
|
||||
if w.we:
|
||||
self.waiting_dialog.update(
|
||||
"checking transaction: {}\n willexecutor: {}".format(wid, w.we["url"])
|
||||
)
|
||||
|
||||
w.set_check_willexecutor(Willexecutors.check_transaction(wid, w.we["url"]))
|
||||
|
||||
if time.time() - start < 3:
|
||||
time.sleep(3 - (time.time() - start))
|
||||
|
||||
def check_transactions(self, will):
|
||||
def on_success(result):
|
||||
if hasattr(self,"waiting_dialog"):
|
||||
del self.waiting_dialog
|
||||
self.update_all()
|
||||
pass
|
||||
|
||||
def on_failure(exec_info):
|
||||
log_error(exec_info, self)
|
||||
# _logger.error(f"error checking transactions {e}")
|
||||
# pass
|
||||
|
||||
task = partial(self.check_transactions_task, will)
|
||||
msg = _("Check Transaction")
|
||||
self.waiting_dialog = BalWaitingDialog(
|
||||
self, msg, task, on_success, on_failure, exe=False
|
||||
)
|
||||
self.waiting_dialog.exe()
|
||||
|
||||
def update_willexecutor_list_widget(self, parent, willexecutors):
|
||||
try:
|
||||
parent.willexecutors_list.update(willexecutors)
|
||||
parent.will_executor_list_widget.update()
|
||||
except Exception as e:
|
||||
_logger.error(f"impossible to update will_executor_list_widget {e}")
|
||||
self.will_executors.update()
|
||||
|
||||
def download_list(self, willexecutors, fn_on_success, fn_on_failure=None):
|
||||
|
||||
def on_success(result):
|
||||
self.willexecutors.update(result)
|
||||
fn_on_success(result)
|
||||
|
||||
def on_failure(exec_info):
|
||||
fn_on_failure(exec_info)
|
||||
|
||||
if fn_on_failure is None:
|
||||
fn_on_failure = log_error
|
||||
welist_server = self.bal_plugin.WELIST_SERVER.get()
|
||||
task = partial(Willexecutors.download_list, willexecutors, welist_server)
|
||||
msg = _(f"Downloading willexecutors list from {welist_server}")
|
||||
self.waiting_dialog = BalWaitingDialog(
|
||||
self, msg, task, on_success, on_failure, exe=False
|
||||
)
|
||||
self.waiting_dialog.exe()
|
||||
|
||||
def ping_willexecutors_task(self, wes):
|
||||
_logger.info("ping willexecutots task")
|
||||
pinged = []
|
||||
failed = []
|
||||
|
||||
def get_title():
|
||||
msg = _("Ping Will-Executors:")
|
||||
msg += "\n\n"
|
||||
for url in wes:
|
||||
urlstr = "{:<50}: ".format(url[:50])
|
||||
if url in pinged:
|
||||
urlstr += "Ok"
|
||||
elif url in failed:
|
||||
urlstr += "Ko"
|
||||
else:
|
||||
urlstr += "--"
|
||||
urlstr += "\n"
|
||||
msg += urlstr
|
||||
|
||||
return msg
|
||||
|
||||
for url, we in wes.items():
|
||||
try:
|
||||
self.waiting_dialog.update(get_title())
|
||||
except Exception:
|
||||
pass
|
||||
wes[url] = Willexecutors.get_info_task(url, we)
|
||||
if wes[url]["status"] == "KO":
|
||||
failed.append(url)
|
||||
else:
|
||||
pinged.append(url)
|
||||
|
||||
def ping_willexecutors(self, wes, fn_on_success, fn_on_failure=None):
|
||||
def on_success(result):
|
||||
fn_on_success(result)
|
||||
|
||||
def on_failure(exec_info):
|
||||
fn_on_failure(exec_info)
|
||||
|
||||
if not fn_on_failure:
|
||||
fn_on_failure = log_error
|
||||
_logger.info("ping willexecutors")
|
||||
task = partial(self.ping_willexecutors_task, wes)
|
||||
msg = _("Ping Will-Executors")
|
||||
self.waiting_dialog = BalWaitingDialog(
|
||||
self, msg, task, on_success, on_failure, exe=False
|
||||
)
|
||||
self.waiting_dialog.exe()
|
||||
|
||||
def preview_modal_dialog(self):
|
||||
self.dw = WillDetailDialog(self)
|
||||
self.dw.show()
|
||||
|
||||
def update_all(self):
|
||||
try:
|
||||
Will.add_willtree(self.willitems)
|
||||
all_utxos = self.wallet.get_utxos()
|
||||
utxos_list = Will.utxos_strs(all_utxos)
|
||||
Will.check_invalidated(self.willitems, utxos_list, self.wallet)
|
||||
|
||||
self.will_list_widget.update_will(self.willitems)
|
||||
self.heirs_tab.update()
|
||||
self.will_tab.update()
|
||||
self.will_list_widget.update()
|
||||
except Exception as e:
|
||||
_logger.error(f"error while updating window: {e}")
|
||||
|
||||
|
||||
BIN
bal/icons/bal16x16.png
Normal file
|
After Width: | Height: | Size: 538 B |
BIN
bal/icons/bal32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
bal/icons/calendar.png
Executable file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
bal/icons/confirmed.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
bal/icons/heir.png
Normal file
|
After Width: | Height: | Size: 871 B |
BIN
bal/icons/reload.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
bal/icons/status_connected.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
bal/icons/unconfirmed.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
bal/icons/will.png
Normal file
|
After Width: | Height: | Size: 831 B |
BIN
bal/icons/wizard.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
10
bal/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "bal",
|
||||
"fullname": "Bitcoin After Life",
|
||||
"version": "0.2.8",
|
||||
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
|
||||
"author": "Svatantrya",
|
||||
"licence": "MIT",
|
||||
"available_for": ["qt"],
|
||||
"icon": "icons/bal32x32.png"
|
||||
}
|
||||
83
bal/qt.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
bal.qt
|
||||
======
|
||||
|
||||
Compatibility shim for Electrum's plugin loader.
|
||||
|
||||
Electrum loads a Qt plugin by importing the ``qt`` module of the plugin package
|
||||
and looking for a ``Plugin`` class. The real implementation lives in the
|
||||
well-separated ``bal.gui.qt`` sub-package, so this module re-exports the
|
||||
``Plugin`` class from ``bal.gui.qt.plugin``.
|
||||
|
||||
Why this file is not a one-line relative import
|
||||
-----------------------------------------------
|
||||
A plain ``from .gui.qt.plugin import Plugin`` works fine when the plugin is
|
||||
installed as an *internal* plugin (under ``electrum/plugins/bal``). However,
|
||||
when the very same code is loaded as an *external* plugin from a ``.zip``,
|
||||
Electrum 4.7.x imports the package under the synthetic top-level name
|
||||
``electrum_external_plugins.bal`` and only executes the package ``__init__`` and
|
||||
this ``qt`` module. It never registers the intermediate parent packages
|
||||
(``electrum_external_plugins`` itself, ``...bal.gui``, ``...bal.gui.qt``). As a
|
||||
result, a relative import that has to walk up to those parents fails with::
|
||||
|
||||
ModuleNotFoundError: No module named 'electrum_external_plugins'
|
||||
|
||||
To make the plugin work *both* as an internal package and as an external zip,
|
||||
this shim resolves and imports ``Plugin`` defensively:
|
||||
|
||||
1. It works out the name of the package this module lives in
|
||||
(``__package__``), whatever Electrum decided to call it.
|
||||
2. It makes sure every parent package in that chain exists in
|
||||
``sys.modules`` so Python's import machinery can resolve sub-modules.
|
||||
3. It imports the ``.gui.qt.plugin`` sub-module via :func:`importlib.import_module`
|
||||
using the resolved absolute name.
|
||||
|
||||
This keeps the clean ``core`` / ``gui`` layout while staying robust to how the
|
||||
plugin is loaded.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
|
||||
def _ensure_parent_packages(pkg_name: str) -> None:
|
||||
"""Make sure every ancestor package of *pkg_name* is in ``sys.modules``.
|
||||
|
||||
When loaded from a zip as an external plugin, Electrum only executes the
|
||||
plugin package ``__init__`` and the ``qt`` module. The synthetic root
|
||||
package (e.g. ``electrum_external_plugins``) and any intermediate packages
|
||||
may be missing from ``sys.modules``, which breaks relative/absolute
|
||||
sub-module imports. We backfill them here using this module's own loader
|
||||
so that ``importlib`` can find sibling sub-packages.
|
||||
"""
|
||||
parts = pkg_name.split(".")
|
||||
# Walk from the top-most ancestor down to (but not including) pkg_name.
|
||||
for i in range(1, len(parts)):
|
||||
ancestor = ".".join(parts[:i])
|
||||
if ancestor in sys.modules:
|
||||
continue
|
||||
try:
|
||||
importlib.import_module(ancestor)
|
||||
except Exception:
|
||||
# The synthetic root (e.g. 'electrum_external_plugins') often has no
|
||||
# real spec. Create a minimal namespace package stub so that the
|
||||
# import machinery can still resolve its children.
|
||||
import types
|
||||
|
||||
module = types.ModuleType(ancestor)
|
||||
module.__path__ = [] # mark as a (namespace) package
|
||||
sys.modules[ancestor] = module
|
||||
|
||||
|
||||
# The package this module belongs to. Could be 'electrum.plugins.bal' (internal)
|
||||
# or 'electrum_external_plugins.bal' (external zip), depending on how Electrum
|
||||
# loaded us.
|
||||
_PKG = __package__ or "bal"
|
||||
|
||||
_ensure_parent_packages(_PKG)
|
||||
|
||||
# Import the real implementation using the fully-qualified, run-time package
|
||||
# name so it works regardless of the synthetic prefix Electrum assigned.
|
||||
_plugin_module = importlib.import_module(_PKG + ".gui.qt.plugin")
|
||||
|
||||
Plugin = _plugin_module.Plugin # noqa: F401 (re-exported for Electrum)
|
||||
50
bal/wallet_util/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
## README
|
||||
|
||||
### Overview
|
||||
This tool provides two entry points: a CLI script (bal_wallet_utils.py) and a Qt GUI script (bal_wallet_utils_qt.py) that operate against an Electrum source tree.
|
||||
|
||||
### Installation / Preparation
|
||||
1. Copy both files into the Electrum project root (the folder that contains the Electrum source package):
|
||||
- bal_wallet_utils.py
|
||||
- bal_wallet_utils_qt.py
|
||||
|
||||
2. Activate the Electrum Python environment (the virtualenv used to run Electrum). Example (PowerShell, adjust path to your venv):
|
||||
```
|
||||
.\env\Scripts\Activate.ps1
|
||||
```
|
||||
or (cmd):
|
||||
```
|
||||
env\Scripts\activate.bat
|
||||
```
|
||||
|
||||
### Running
|
||||
- CLI version:
|
||||
```
|
||||
python bal_wallet_utils.py
|
||||
```
|
||||
- Qt GUI version:
|
||||
```
|
||||
python bal_wallet_utils_qt.py
|
||||
```
|
||||
|
||||
### Building a Windows executable with PyInstaller
|
||||
From the project root (with the Electrum environment active), you can build the Qt executable using PyInstaller. Example command (adjust the paths if your environment path differs):
|
||||
```
|
||||
pyinstaller.exe --onefile --noconsole --add-data "electrum\currencies.json;electrum" --add-data "electrum\bip39_wallet_formats.json;electrum" --add-data "electrum\lnwire\peer_wire.csv;electrum\lnwire" --add-data "electrum\lnwire\onion_wire.csv;electrum\lnwire" --add-binary "env/Lib/site-packages\electrum_ecc\libsecp256k1-6.dll;electrum_ecc" bal_wallet_utils_qt.py
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Run the command from the project root so relative paths resolve correctly.
|
||||
- On Windows the --add-data and --add-binary arguments use ";" to separate source and destination.
|
||||
- If electrum expects additional data files or native DLLs, include them with additional --add-data / --add-binary flags.
|
||||
- For debugging include --onedir first to inspect the created folder before using --onefile.
|
||||
|
||||
### Troubleshooting
|
||||
- If PyInstaller is not found, run it via Python:
|
||||
```
|
||||
python -m PyInstaller <same arguments>
|
||||
```
|
||||
- If the frozen exe fails because DLLs or JSON files are missing, add those files explicitly with --add-data or --add-binary.
|
||||
- Test the build on a clean Windows VM to ensure all runtime dependencies are included.
|
||||
|
||||
License and attribution: include your preferred license or attribution details here.
|
||||
81
bal/wallet_util/bal_wallet_utils.py
Executable file
@@ -0,0 +1,81 @@
|
||||
#!env/bin/python3
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from electrum.storage import WalletStorage
|
||||
from electrum.util import MyEncoder
|
||||
|
||||
default_fees = 100
|
||||
|
||||
|
||||
def fix_will_settings_tx_fees(json_wallet):
|
||||
tx_fees = json_wallet.get("will_settings", {}).get("tx_fees", False)
|
||||
have_to_update = False
|
||||
if tx_fees:
|
||||
json_wallet["will_settings"]["baltx_fees"] = tx_fees
|
||||
del json_wallet["will_settings"]["tx_fees"]
|
||||
have_to_update = True
|
||||
for txid, willitem in json_wallet["will"].items():
|
||||
tx_fees = willitem.get("tx_fees", False)
|
||||
if tx_fees:
|
||||
json_wallet["will"][txid]["baltx_fees"] = tx_fees
|
||||
del json_wallet["will"][txid]["tx_fees"]
|
||||
have_to_update = True
|
||||
return have_to_update
|
||||
|
||||
|
||||
def uninstall_bal(json_wallet):
|
||||
if "will_settings" in json_wallet:
|
||||
del json_wallet["will_settings"]
|
||||
if "will" in json_wallet:
|
||||
del json_wallet["will"]
|
||||
if "heirs" in json_wallet:
|
||||
del json_wallet["heirs"]
|
||||
return True
|
||||
|
||||
|
||||
def save(json_wallet, storage):
|
||||
human_readable = not storage.is_encrypted()
|
||||
storage.write(
|
||||
json.dumps(
|
||||
json_wallet,
|
||||
indent=4 if human_readable else None,
|
||||
sort_keys=bool(human_readable),
|
||||
cls=MyEncoder,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def read_wallet(path, password=False):
|
||||
storage = WalletStorage(path)
|
||||
if storage.is_encrypted():
|
||||
if not password:
|
||||
password = getpass.getpass("Enter wallet password: ", stream=None)
|
||||
storage.decrypt(password)
|
||||
data = storage.read()
|
||||
json_wallet = json.loads("[" + data + "]")[0]
|
||||
return json_wallet, storage
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print("usage: ./bal_wallet_utils <command> <wallet path>")
|
||||
print("available commands: uninstall, fix")
|
||||
exit(1)
|
||||
if not os.path.exists(sys.argv[2]):
|
||||
print("Error: wallet not found")
|
||||
exit(1)
|
||||
command = sys.argv[1]
|
||||
path = sys.argv[2]
|
||||
json_wallet, storage = read_wallet(path)
|
||||
have_to_save = False
|
||||
if command == "fix":
|
||||
have_to_save = fix_will_settings_tx_fees(json_wallet)
|
||||
if command == "uninstall":
|
||||
have_to_save = uninstall_bal(json_wallet)
|
||||
if have_to_save:
|
||||
save(json_wallet, storage)
|
||||
else:
|
||||
print("nothing to do")
|
||||
199
bal/wallet_util/bal_wallet_utils_qt.py
Executable file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from bal_wallet_utils import fix_will_settings_tx_fees, save, uninstall_bal
|
||||
from electrum.storage import WalletStorage
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class WalletUtilityGUI(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
self.setWindowTitle("BAL Wallet Utility")
|
||||
self.setFixedSize(500, 400)
|
||||
|
||||
# Central widget
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
# Main layout
|
||||
layout = QVBoxLayout(central_widget)
|
||||
|
||||
# Wallet input group
|
||||
wallet_group = QGroupBox("Wallet Settings")
|
||||
wallet_layout = QVBoxLayout(wallet_group)
|
||||
|
||||
# Wallet path
|
||||
wallet_path_layout = QHBoxLayout()
|
||||
wallet_path_layout.addWidget(QLabel("Wallet Path:"))
|
||||
self.wallet_path_edit = QLineEdit()
|
||||
self.wallet_path_edit.setPlaceholderText("Select wallet path...")
|
||||
wallet_path_layout.addWidget(self.wallet_path_edit)
|
||||
|
||||
self.browse_btn = QPushButton("Browse...")
|
||||
self.browse_btn.clicked.connect(self.browse_wallet)
|
||||
wallet_path_layout.addWidget(self.browse_btn)
|
||||
|
||||
wallet_layout.addLayout(wallet_path_layout)
|
||||
|
||||
# Password
|
||||
password_layout = QHBoxLayout()
|
||||
password_layout.addWidget(QLabel("Password:"))
|
||||
self.password_edit = QLineEdit()
|
||||
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.password_edit.setPlaceholderText("Enter password (if encrypted)")
|
||||
password_layout.addWidget(self.password_edit)
|
||||
|
||||
wallet_layout.addLayout(password_layout)
|
||||
|
||||
layout.addWidget(wallet_group)
|
||||
|
||||
# Output area
|
||||
output_group = QGroupBox("Output")
|
||||
output_layout = QVBoxLayout(output_group)
|
||||
|
||||
self.output_text = QTextEdit()
|
||||
self.output_text.setReadOnly(True)
|
||||
output_layout.addWidget(self.output_text)
|
||||
|
||||
layout.addWidget(output_group)
|
||||
|
||||
# Action buttons
|
||||
buttons_layout = QHBoxLayout()
|
||||
|
||||
self.fix_btn = QPushButton("Fix")
|
||||
self.fix_btn.clicked.connect(self.fix_wallet)
|
||||
self.fix_btn.setEnabled(False)
|
||||
buttons_layout.addWidget(self.fix_btn)
|
||||
|
||||
self.uninstall_btn = QPushButton("Uninstall")
|
||||
self.uninstall_btn.clicked.connect(self.uninstall_wallet)
|
||||
self.uninstall_btn.setEnabled(False)
|
||||
buttons_layout.addWidget(self.uninstall_btn)
|
||||
|
||||
layout.addLayout(buttons_layout)
|
||||
|
||||
# Connections to enable buttons when path is entered
|
||||
self.wallet_path_edit.textChanged.connect(self.check_inputs)
|
||||
|
||||
def browse_wallet(self):
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Wallet", "*", "Electrum Wallet (*)"
|
||||
)
|
||||
if file_path:
|
||||
self.wallet_path_edit.setText(file_path)
|
||||
|
||||
def check_inputs(self):
|
||||
wallet_path = self.wallet_path_edit.text().strip()
|
||||
has_path = bool(wallet_path) and os.path.exists(wallet_path)
|
||||
|
||||
self.fix_btn.setEnabled(has_path)
|
||||
self.uninstall_btn.setEnabled(has_path)
|
||||
|
||||
def log_message(self, message):
|
||||
self.output_text.append(message)
|
||||
|
||||
def fix_wallet(self):
|
||||
self.process_wallet("fix")
|
||||
|
||||
def uninstall_wallet(self):
|
||||
self.log_message(
|
||||
"WARNING: This will remove all BAL settings. This operation cannot be undone."
|
||||
)
|
||||
self.process_wallet("uninstall")
|
||||
|
||||
def process_wallet(self, command):
|
||||
wallet_path = self.wallet_path_edit.text().strip()
|
||||
password = self.password_edit.text()
|
||||
|
||||
if not wallet_path:
|
||||
self.log_message("ERROR: Please enter wallet path")
|
||||
return
|
||||
|
||||
if not os.path.exists(wallet_path):
|
||||
self.log_message("ERROR: Wallet not found")
|
||||
return
|
||||
|
||||
try:
|
||||
self.log_message(f"Processing wallet: {wallet_path}")
|
||||
|
||||
storage = WalletStorage(wallet_path)
|
||||
|
||||
# Decrypt if necessary
|
||||
if storage.is_encrypted():
|
||||
if not password:
|
||||
self.log_message(
|
||||
"ERROR: Wallet is encrypted, please enter password"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
storage.decrypt(password)
|
||||
self.log_message("Wallet decrypted successfully")
|
||||
except Exception as e:
|
||||
self.log_message(f"ERROR: Wrong password: {str(e)}")
|
||||
return
|
||||
|
||||
# Read wallet
|
||||
data = storage.read()
|
||||
json_wallet = json.loads("[" + data + "]")[0]
|
||||
|
||||
have_to_save = False
|
||||
message = ""
|
||||
|
||||
if command == "fix":
|
||||
have_to_save = fix_will_settings_tx_fees(json_wallet)
|
||||
message = (
|
||||
"Fix applied successfully" if have_to_save else "No fix needed"
|
||||
)
|
||||
|
||||
elif command == "uninstall":
|
||||
have_to_save = uninstall_bal(json_wallet)
|
||||
message = (
|
||||
"BAL uninstalled successfully"
|
||||
if have_to_save
|
||||
else "No BAL settings found to uninstall"
|
||||
)
|
||||
|
||||
if have_to_save:
|
||||
try:
|
||||
save(json_wallet, storage)
|
||||
self.log_message(f"SUCCESS: {message}")
|
||||
except Exception as e:
|
||||
self.log_message(f"Save error: {str(e)}")
|
||||
else:
|
||||
self.log_message(f"INFO: {message}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"ERROR: Processing failed: {str(e)}"
|
||||
self.log_message(error_msg)
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
window = WalletUtilityGUI()
|
||||
window.show()
|
||||
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||