From 1516a181d7bdfc372ace0ab12e050d2406b3c848 Mon Sep 17 00:00:00 2001 From: bot Date: Sat, 20 Jun 2026 09:48:42 -0400 Subject: [PATCH] remove bal with pycache --- bal/core/__init__.py | 21 - bal/core/__pycache__/__init__.cpython-311.pyc | Bin 1038 -> 0 bytes bal/core/__pycache__/heirs.cpython-311.pyc | Bin 41929 -> 0 bytes .../__pycache__/plugin_base.cpython-311.pyc | Bin 20111 -> 0 bytes bal/core/__pycache__/util.cpython-311.pyc | Bin 25440 -> 0 bytes bal/core/__pycache__/will.cpython-311.pyc | Bin 57069 -> 0 bytes .../__pycache__/willexecutors.cpython-311.pyc | Bin 35142 -> 0 bytes bal/core/heirs.py | 850 ------------ bal/core/plugin_base.py | 400 ------ bal/core/util.py | 551 -------- bal/core/will.py | 1149 ----------------- bal/core/willexecutors.py | 788 ----------- 12 files changed, 3759 deletions(-) delete mode 100644 bal/core/__init__.py delete mode 100644 bal/core/__pycache__/__init__.cpython-311.pyc delete mode 100644 bal/core/__pycache__/heirs.cpython-311.pyc delete mode 100644 bal/core/__pycache__/plugin_base.cpython-311.pyc delete mode 100644 bal/core/__pycache__/util.cpython-311.pyc delete mode 100644 bal/core/__pycache__/will.cpython-311.pyc delete mode 100644 bal/core/__pycache__/willexecutors.cpython-311.pyc delete mode 100644 bal/core/heirs.py delete mode 100644 bal/core/plugin_base.py delete mode 100644 bal/core/util.py delete mode 100644 bal/core/will.py delete mode 100644 bal/core/willexecutors.py diff --git a/bal/core/__init__.py b/bal/core/__init__.py deleted file mode 100644 index 6e1c1b2..0000000 --- a/bal/core/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -bal.core -======== - -Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin. - -Everything in this sub-package MUST stay completely free of any GUI / Qt -imports. The rule of thumb is: - - * ``bal.core`` -> "what the plugin does" (inheritance rules, building - and validating transactions, talking to - will-executor servers, persistence helpers). - * ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views). - -Keeping the two apart is the main motivation behind this rewrite: the original -code mixed transaction-building logic and presentation inside a single -4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit. - -No behaviour is changed with respect to the original plugin; the code has only -been reorganised and documented. -""" diff --git a/bal/core/__pycache__/__init__.cpython-311.pyc b/bal/core/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 5edf22c5c26e1032e61bb2b65b785025f77797c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1038 zcmaJ=v2N5r5cMSyAWP|J=tik>C}*cY6agY7QiOmI5hZD8e0$!BJ!HLWcE{&j$6xRX zh+m@R2O_0Q#jGzU6d|$2YIi*I{N9_{ADf$LB5Up2%P*(vN%A{hR%>*BxQ!{_6P2xJ%I*k#4$v$lL zwc>&Kk{4))VGFj&*64|qEtiO4M`V-H!ghdL0{pWpt6{DTX~j_&du4+vnVb~>R_1Dm zy8l7+WXuGwvFzA+2=WMfPe=uJch|Su0x!%bvIf zq0-8z#{3AmQgc7IuZG~DtX2V>HH&nHXr;{gN?8hm6D4U^c8zF4ouwz^(XYovA)%u9 zncT)#cekI{uEAV7s!XnN?u^c5KJ!M|4$XvA&KNl4SvG{@JIsrMEOAkY2X{|iFFn}z dj|48h*!+fk{R7N)Mdttj diff --git a/bal/core/__pycache__/heirs.cpython-311.pyc b/bal/core/__pycache__/heirs.cpython-311.pyc deleted file mode 100644 index a0865696b3e7807f017d7ea3ca41b0ec0ff76711..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41929 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 diff --git a/bal/core/__pycache__/plugin_base.cpython-311.pyc b/bal/core/__pycache__/plugin_base.cpython-311.pyc deleted file mode 100644 index 9110e83bcce06cba5e73aa8148f2cd60fec94002..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20111 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 J19BP7{a=_tA?^SG diff --git a/bal/core/__pycache__/util.cpython-311.pyc b/bal/core/__pycache__/util.cpython-311.pyc deleted file mode 100644 index 94cc75df1829692ab5f8bf82ae70dd6c3eb76cae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25440 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 V*GrW)+rg%xVM9UK8*oqyB;)6s=Jmd$^lGtF{ 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
!(Zbu0T@Y#fankA=SeM>6~6wATu{I4C!-GL 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%0DxK=BATPboy!qb3Xa<5#< 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 NpL%oJj3A@={{k!z)}{ae diff --git a/bal/core/__pycache__/willexecutors.cpython-311.pyc b/bal/core/__pycache__/willexecutors.cpython-311.pyc deleted file mode 100644 index 067e7b09f9282ddddfd2f795750cc03cbda3d50c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35142 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 Q{_&yo?pL4Ugcarg14UiSQUCw| diff --git a/bal/core/heirs.py b/bal/core/heirs.py deleted file mode 100644 index f8d93e4..0000000 --- a/bal/core/heirs.py +++ /dev/null @@ -1,850 +0,0 @@ -""" -bal.core.heirs -============== - -Heir management and inheritance-transaction building. - -This is the heart of the plugin's Bitcoin logic and the most delicate part of -the whole codebase, so the implementation below is kept byte-for-byte identical -to the original ``heirs.py``; only the dead commented-out imports were removed -and documentation was added. - -An *heir* is stored as a small list addressed by the ``HEIR_*`` column -constants defined below. ``Heirs`` is a ``dict`` subclass persisted inside the -wallet DB under the ``"heirs"`` key. - -The ``prepare_transactions`` / ``Heirs.buildTransactions`` functions turn the -heir list plus the wallet UTXOs into a set of time-locked inheritance -transactions (optionally including a will-executor fee output). - -Will-executor "heirs" are synthetic entries whose key starts with the -``w!ll3x3c"`` marker; they are skipped by most heir comparisons. -""" - -import math -import random -import re -import threading -from typing import ( - TYPE_CHECKING, - Any, - Dict, - Optional, - Tuple, -) - -import dns -from dns.exception import DNSException -from electrum import ( - bitcoin, - constants, - dnssec, -) -from electrum.logging import Logger, get_logger -from electrum.transaction import ( - PartialTransaction, - PartialTxInput, - PartialTxOutput, - TxOutpoint, -) -from electrum.util import ( - BitcoinException, - bfh, - read_json_file, - to_string, - trigger_callback, - write_json_file, -) - -from .util import Util -from .willexecutors import Willexecutors - -if TYPE_CHECKING: - from electrum.simple_config import SimpleConfig - - -_logger = get_logger(__name__) - -# Column layout of a stored heir list. These indices are part of the on-disk -# wallet format and are relied upon all over the codebase, so they must NEVER -# be reordered. -HEIR_ADDRESS = 0 # destination Bitcoin address -HEIR_AMOUNT = 1 # requested amount (satoshis or "%") -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: - """Build an OP_RETURN scriptPubKey (as raw bytes) from hex-encoded data. - - Used to embed a small data payload (max 80 bytes) into a transaction - output. Raises ``ValueError`` when the payload exceeds the 80-byte limit. - """ - data = bytes.fromhex(data_hex) - - if len(data) > 80: - raise ValueError("OP_RETURN data too big (max 80 bytes)") - - # Manual construction: OP_RETURN opcode followed by the data push. - if len(data) <= 75: - # Most common form: OP_RETURN + 1-byte length prefix + data. - script = b'\x6a' + bytes([len(data)]) + data - else: - # For larger payloads (up to 80 bytes) use 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 = {} - locktimes_list = Util.get_lowest_locktimes(locktimes) - if not locktimes_list: - _logger.info("prepare transactions, no locktime") - return - locktime = locktimes_list[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), - version=2, - ) - if len(description) > 0: - tx.description = description[:-1] - else: - tx.description = "" - tx.heirsvalue = heirsvalue - tx.set_rbf(True) - tx.remove_signatures() - txid = tx.txid() - if txid is None: - raise Exception(f"txid is none: {tx}") - - tx.heirs = paid_heirs - tx.my_locktime = locktime - txsout[txid] = tx - - if change: - change_idx = tx.get_output_idxs_from_address(change.address) - prevout = TxOutpoint(txid=bfh(tx.txid()), out_idx=change_idx.pop()) - txin = PartialTxInput(prevout=prevout) - txin._trusted_value_sats = change.value - txin.script_descriptor = change.script_descriptor - txin.is_mine = True - txin._TxInput__address = change.address - txin._TxInput__scriptpubkey = change.scriptpubkey - txin._TxInput__value_sats = change.value - txin.utxo = tx - available_utxos.append(txin) - txsout[txid].available_utxos = available_utxos[:] - return txsout - - -def get_utxos_from_inputs(tx_inputs, tx, utxos): - for tx_input in tx_inputs: - prevoutstr = tx_input.prevout.to_str() - utxos[prevoutstr] = utxos.get(prevoutstr, {"input": tx_input, "txs": []}) - utxos[prevoutstr]["txs"].append(tx) - return utxos - - -# TODO calculate de minimum inputs to be invalidated -def invalidate_inheritance_transactions(wallet): - # listids = [] - utxos = {} - dtxs = {} - for k, v in wallet.get_all_labels().items(): - tx = None - if TRANSACTION_LABEL == v: - tx = wallet.adb.get_transaction(k) - if tx: - dtxs[tx.txid()] = tx - get_utxos_from_inputs(tx.inputs(), tx, utxos) - - for key, utxo in utxos.items(): - txid = key.split(":")[0] - if txid in dtxs: - for tx in utxo["txs"]: - txid = tx.txid() - del dtxs[txid] - - utxos = {} - for txid, tx in dtxs.items(): - get_utxos_from_inputs(tx.inputs(), tx, utxos) - - utxos = sorted(utxos.items(), key=lambda item: len(item[1])) - - remaining = {} - invalidated = [] - for key, value in utxos: - for tx in value["txs"]: - txid = tx.txid() - if txid not in invalidated: - invalidated.append(tx.txid()) - remaining[key] = value - - -def print_transaction(heirs, tx, locktimes, tx_fees): - jtx = tx.to_json() - print(f"TX: {tx.txid()}\t-\tLocktime: {jtx['locktime']}") - print("---") - for inp in jtx["inputs"]: - print(f"{inp['address']}: {inp['value_sats']}") - print("---") - for out in jtx["outputs"]: - heirname = "" - for key in heirs.keys(): - heir = heirs[key] - if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str( - jtx["locktime"] - ): - heirname = key - print(f"{heirname}\t{out['address']}: {out['value_sats']}") - - print() - size = tx.estimated_size() - print( - "fee: {}\texpected: {}\tsize: {}".format( - tx.input_value() - tx.output_value(), size * tx_fees, size - ) - ) - - print() - try: - print(tx.serialize_to_network()) - except Exception: - print("impossible to serialize") - print() - - -def get_change_output(wallet, in_amount, out_amount, fee): - change_amount = int(in_amount - out_amount - fee) - if change_amount > wallet.dust_threshold(): - change_addresses = wallet.get_change_addresses_for_new_transaction() - out = PartialTxOutput.from_address_and_value(change_addresses[0], change_amount) - out.is_change = True - return out - - -def _json_safe(value, _path="heirs", _depth=0): - """Return a JSON-serializable deep copy of *value*. - - The wallet DB persists the heirs dict via ``json_db.put``, which calls - ``copy.deepcopy`` on the value. If any nested element is a live runtime - object (e.g. one holding a ``threading.RLock``), deepcopy raises - ``TypeError: cannot pickle '_thread.RLock' object`` and the whole - "Build will" task fails. - - To make persistence robust we coerce the structure to plain - JSON-compatible types (dict / list / str / int / float / bool / None). - Anything else is converted to ``str(value)`` and logged with its path so - the offending field can be identified, instead of crashing the task. - """ - # Primitive JSON scalars are kept as-is. - if value is None or isinstance(value, (bool, int, float, str)): - return value - if isinstance(value, dict): - return { - str(k): _json_safe(v, "{}[{!r}]".format(_path, k), _depth + 1) - for k, v in value.items() - } - if isinstance(value, (list, tuple)): - return [ - _json_safe(v, "{}[{}]".format(_path, i), _depth + 1) - for i, v in enumerate(value) - ] - # Unexpected runtime object: do not let it reach deepcopy. Log where it - # was found so the real source can be fixed, then store a safe string. - _logger.error( - "heirs.save: non-serializable value at {} (type={}); coercing to str. " - "value={!r}".format(_path, type(value).__name__, value) - ) - return str(value) - - -class Heirs(dict, Logger): - - def __init__(self, wallet): - Logger.__init__(self) - self.db = wallet.db - self.wallet = wallet - d = self.db.get("heirs", {}) - try: - self.update(d) - except Exception: - return - - def invalidate_transactions(self, wallet): - invalidate_inheritance_transactions(wallet) - - def save(self): - # Sanitise the heirs mapping before handing it to the wallet DB: this - # guarantees only JSON-serializable values are stored and prevents the - # "cannot pickle '_thread.RLock' object" failure that aborted the - # Build-will task when a runtime object slipped into an heir value. - self.db.put("heirs", _json_safe(dict(self))) - - def import_file(self, path): - data = read_json_file(path) - data = Heirs._validate(data) - self.update(data) - self.save() - - def export_file(self, path): - write_json_file(path, self) - - def __setitem__(self, key, value): - dict.__setitem__(self, key, value) - self.save() - - def pop(self, key): - if key in self.keys(): - res = dict.pop(self, key) - self.save() - return res - - def get_locktimes(self, from_locktime, a=False): - locktimes = {} - for key in self.keys(): - locktime = Util.parse_locktime_string(self[key][HEIR_LOCKTIME]) - if locktime > from_locktime and not a or locktime <= from_locktime and a: - locktimes[int(locktime)] = None - return list(locktimes.keys()) - - def check_locktime(self): - return False - - def normalize_perc( - self, heir_list, total_balance, relative_balance, wallet, real=False - ): - amount = 0 - for key, v in heir_list.items(): - try: - column = HEIR_AMOUNT - if real: - column = HEIR_REAL_AMOUNT - if "DUST" in str(v[column]): - column = HEIR_DUST_AMOUNT - value = int( - math.floor( - total_balance - / relative_balance - * self.amount_to_float(v[column]) - ) - ) - if value > wallet.dust_threshold(): - heir_list[key].insert(HEIR_REAL_AMOUNT, value) - amount += value - else: - heir_list[key].insert(HEIR_REAL_AMOUNT, f"DUST: {value}") - heir_list[key].insert(HEIR_DUST_AMOUNT, value) - _logger.info(f"{key}, {value} is dust will be ignored") - - except Exception as e: - raise e - return amount - - def amount_to_float(self, amount): - try: - return float(amount) - except Exception: - try: - return float(amount[:-1]) - except Exception: - return 0.0 - - def fixed_percent_lists_amount(self, from_locktime, dust_threshold, reverse=False): - fixed_heirs = {} - fixed_amount = 0.0 - percent_heirs = {} - percent_amount = 0.0 - fixed_amount_with_dust = 0.0 - for key in self.keys(): - try: - cmp = ( - Util.parse_locktime_string(self[key][HEIR_LOCKTIME]) - from_locktime - ) - if cmp <= 0: - _logger.debug( - "cmp < 0 {} {} {} {}".format( - cmp, key, self[key][HEIR_LOCKTIME], from_locktime - ) - ) - continue - if Util.is_perc(self[key][HEIR_AMOUNT]): - percent_amount += float(self[key][HEIR_AMOUNT][:-1]) - percent_heirs[key] = list(self[key]) - else: - heir_amount = int(math.floor(float(self[key][HEIR_AMOUNT]))) - fixed_amount_with_dust += heir_amount - fixed_heirs[key] = list(self[key]) - if heir_amount > dust_threshold: - fixed_amount += heir_amount - fixed_heirs[key].insert(HEIR_REAL_AMOUNT, heir_amount) - else: - fixed_heirs[key] = list(self[key]) - fixed_heirs[key].insert( - HEIR_REAL_AMOUNT, f"DUST: {heir_amount}" - ) - fixed_heirs[key].insert(HEIR_DUST_AMOUNT, heir_amount) - except Exception as e: - _logger.error(e) - return ( - fixed_heirs, - fixed_amount, - percent_heirs, - percent_amount, - fixed_amount_with_dust, - ) - - def prepare_lists( - self, balance, total_fees, wallet, willexecutor=False, from_locktime=0 - ): - if balance int(from_locktime): - try: - base_fee = int(willexecutor["base_fee"]) - willexecutors_amount += base_fee - h = [None] * 4 - h[HEIR_AMOUNT] = base_fee - h[HEIR_REAL_AMOUNT] = base_fee - h[HEIR_LOCKTIME] = locktime - h[HEIR_ADDRESS] = willexecutor["address"] - willexecutors[ - 'w!ll3x3c"' + willexecutor["url"] + '"' + str(locktime) - ] = h - except Exception: - return [], False - else: - _logger.error( - f"heir excluded from will locktime({locktime}){Util.int_locktime(locktime)} newbalance: - fixed_amount = self.normalize_perc( - fixed_heirs, newbalance, fixed_amount, wallet - ) - onlyfixed = True - - heir_list.update(fixed_heirs) - - newbalance -= fixed_amount - if newbalance > 0: - perc_amount = self.normalize_perc( - percent_heirs, newbalance, percent_amount, wallet - ) - newbalance -= perc_amount - heir_list.update(percent_heirs) - if newbalance > 0: - newbalance += fixed_amount - fixed_amount = self.normalize_perc( - fixed_heirs, newbalance, fixed_amount_with_dust, wallet, real=True - ) - newbalance -= fixed_amount - heir_list.update(fixed_heirs) - - heir_list = sorted( - heir_list.items(), - key=lambda item: Util.parse_locktime_string(item[1][HEIR_LOCKTIME]), - ) - - locktimes = {} - for key, value in heir_list: - locktime = Util.parse_locktime_string(value[HEIR_LOCKTIME]) - if locktime not in locktimes: - locktimes[locktime] = {key: value} - else: - locktimes[locktime][key] = value - return locktimes, onlyfixed - - def is_perc(self, key): - return Util.is_perc(self[key][HEIR_AMOUNT]) - - def buildTransactions( - self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0 - ): - Heirs._validate(self) - if len(self) <= 0: - _logger.info("while building transactions there was no heirs") - return - balance = 0.0 - len_utxo_set = 0 - available_utxos = [] - if not utxos: - utxos = wallet.get_utxos() - willexecutors = Willexecutors.get_willexecutors(bal_plugin) or {} - self.decimal_point = bal_plugin.get_decimal_point() - no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get() - for utxo in utxos: - if utxo.value_sats() > 0 * tx_fees: - balance += utxo.value_sats() - len_utxo_set += 1 - available_utxos.append(utxo) - if len_utxo_set == 0: - _logger.info("no usable utxos") - return - j = -2 - willexecutorsitems = list(willexecutors.items()) - willexecutorslen = len(willexecutorsitems) - alltxs = {} - while True: - j += 1 - if j >= willexecutorslen: - break - elif 0 <= j: - url, willexecutor = willexecutorsitems[j] - if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold(): - continue - else: - willexecutor["url"] = url - elif j == -1: - if not no_willexecutors: - continue - url = willexecutor = False - else: - break - fees = {} - i = 0 - while i < 10: - txs = {} - redo = False - i += 1 - total_fees = 0 - for fee in fees: - total_fees += int(fees[fee]) - # newbalance = balance - try: - locktimes, onlyfixed = self.prepare_lists( - balance, total_fees, wallet, willexecutor, from_locktime - ) - except WillExecutorFeeException: - i = 10 - continue - if locktimes: - try: - txs = prepare_transactions( - locktimes, available_utxos[:], fees, wallet - ) - if not txs: - return {} - except Exception as e: - _logger.error( - f"build transactions: error preparing transactions: {e}" - ) - try: - if "w!ll3x3c" in e.heirname: - Willexecutors.is_selected( - e.heirname[len("w!ll3x3c") :], False - ) - break - except Exception: - raise e - total_fees = 0 - total_fees_real = 0 - total_in = 0 - for txid, tx in txs.items(): - tx.willexecutor = willexecutor - fee = tx.estimated_size() * tx_fees - txs[txid].tx_fees = tx_fees - total_fees += fee - total_fees_real += tx.get_fee() - total_in += tx.input_value() - rfee = tx.input_value() - tx.output_value() - if rfee < fee or rfee > fee + wallet.dust_threshold(): - redo = True - # oldfees = fees.get(tx.my_locktime, 0) - fees[tx.my_locktime] = fee - - if balance - total_in > wallet.dust_threshold(): - redo = True - if not redo: - break - if i >= 10: - break - else: - _logger.info( - f"no locktimes for willexecutor {willexecutor} skipped" - ) - break - alltxs.update(txs) - - return alltxs - - def get_transactions( - self, bal_plugin, wallet, tx_fees, utxos=None, from_locktime=0 - ): - txs = self.buildTransactions(bal_plugin, wallet, tx_fees, utxos, from_locktime) - if txs: - temp_txs = {} - for txid in txs: - if txs[txid].available_utxos: - temp_txs.update( - self.get_transactions( - bal_plugin, - wallet, - tx_fees, - txs[txid].available_utxos, - txs[txid].locktime, - ) - ) - txs.update(temp_txs) - return txs - - def resolve(self, k): - if bitcoin.is_address(k): - return {"address": k, "type": "address"} - if k in self.keys(): - _type, addr = self[k] - if _type == "address": - return {"address": addr, "type": "heir"} - if openalias := self.resolve_openalias(k): - return openalias - raise AliasNotFoundException("Invalid Bitcoin address or alias", k) - - @classmethod - def resolve_openalias(cls, url: str) -> Dict[str, Any]: - out = cls._resolve_openalias(url) - if out: - address, name, validated = out - return { - "address": address, - "name": name, - "type": "openalias", - "validated": validated, - } - return {} - - def by_name(self, name): - for k in self.keys(): - _type, addr = self[k] - if addr.casefold() == name.casefold(): - return {"name": addr, "type": _type, "address": k} - return None - - def fetch_openalias(self, config: "SimpleConfig"): - self.alias_info = None - alias = config.OPENALIAS_ID - if alias: - alias = str(alias) - - def f(): - self.alias_info = self._resolve_openalias(alias) - trigger_callback("alias_received") - - t = threading.Thread(target=f) - t.daemon = True - t.start() - - @classmethod - def _resolve_openalias(cls, url: str) -> Optional[Tuple[str, str, bool]]: - # support email-style addresses, per the OA standard - url = url.replace("@", ".") - try: - records, validated = dnssec.query(url, dns.rdatatype.TXT) - except DNSException as e: - _logger.info(f"Error resolving openalias: {repr(e)}") - return None - prefix = "btc" - for record in records: - string = to_string(record.strings[0], "utf8") - if string.startswith("oa1:" + prefix): - address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)") - name = cls.find_regex(string, r"recipient_name=([^;]+)") - if not name: - name = address - if not address: - continue - return address, name, validated - - @staticmethod - def find_regex(haystack, needle): - regex = re.compile(needle) - try: - return regex.search(haystack).groups()[0] - except AttributeError: - return None - - def validate_address(address): - if not bitcoin.is_address(address, net=constants.net): - raise NotAnAddress(f"not an address,{address}") - return address - - def validate_amount(amount): - try: - famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount) - if famount <= 0.00000001: - raise AmountNotValid(f"amount have to be positive {famount} < 0") - except Exception as e: - raise AmountNotValid(f"amount not properly formatted, {e}") - return amount - - def validate_locktime(locktime, timestamp_to_check=False): - try: - if timestamp_to_check: - if Util.parse_locktime_string(locktime, None) < timestamp_to_check: - raise HeirExpiredException() - except Exception as e: - raise LocktimeNotValid(f"locktime string not properly formatted, {e}") - return locktime - - def validate_heir(k, v, timestamp_to_check=False): - address = Heirs.validate_address(v[HEIR_ADDRESS]) - amount = Heirs.validate_amount(v[HEIR_AMOUNT]) - locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check) - return (address, amount, locktime) - - def _validate(data, timestamp_to_check=False): - - for k, v in list(data.items()): - if k == "heirs": - return Heirs._validate(v, timestamp_to_check) - try: - Heirs.validate_heir(k, v, timestamp_to_check) - except Exception as e: - _logger.info(f"exception heir removed {e}") - data.pop(k) - return data - - -class NotAnAddress(ValueError): - pass - - -class AmountNotValid(ValueError): - pass - - -class LocktimeNotValid(ValueError): - pass - - -class HeirExpiredException(LocktimeNotValid): - pass - - -class HeirAmountIsDustException(Exception): - pass - - -class NoHeirsException(Exception): - pass - - -class WillExecutorFeeException(Exception): - def __init__(self, willexecutor): - self.willexecutor = willexecutor - - def __str__(self): - return "WillExecutorFeeException: {} fee:{}".format( - self.willexecutor["url"], self.willexecutor["base_fee"] - ) -class BalanceTooLowException(Exception): - def __init__(self,balance, dust_threshold, fees): - self.balance=balance - self.dust_threshold = dust_threshold - self.fees = fees - def __str__(self): - return f"Balance too low, balance: {self.balance}, dust threshold: {self.dust_threshold}, fees: {self.fees}" diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py deleted file mode 100644 index 211a08c..0000000 --- a/bal/core/plugin_base.py +++ /dev/null @@ -1,400 +0,0 @@ -""" -bal.core.plugin_base -===================== - -GUI-agnostic foundation of the plugin. - -It contains: - * :class:`BalConfig` - a thin typed wrapper around an Electrum config key - with a default value. - * :class:`BalPlugin` - the base plugin class (extends Electrum's - ``BasePlugin``) holding every configuration option - and the default "will settings". The Qt-specific - ``Plugin`` subclass lives in ``bal.gui.qt.plugin``. - * :class:`BalTimestamp`- helper to convert between relative durations - (``"30d"``, ``"1y"``) and absolute timestamps. - -It also registers the three custom persisted dictionaries (``heirs``, -``will`` and ``will_settings``) with Electrum's JSON database so they are -serialised together with the wallet file. - -This module performs **no** GUI work and imports nothing from PyQt / electrum.gui. -""" - -import os -import platform -from datetime import date, datetime, timedelta - -from electrum import constants, json_db -from electrum.logging import get_logger -from electrum.plugin import BasePlugin -from electrum.transaction import tx_from_any - -_logger = get_logger(__name__) - - -# --------------------------------------------------------------------------- # -# Wallet-DB registration -# --------------------------------------------------------------------------- # -# Electrum needs to know how to (de)serialise the custom dictionaries the -# plugin stores inside the wallet file. ``register_dict`` associates a key -# name with a conversion callable applied to each value when the wallet is -# loaded. ``will`` values run through ``get_will`` so the stored transaction -# hex is turned back into a ``Transaction`` object. -def get_will(x): - """Deserialise a stored will entry, rebuilding its ``tx`` object.""" - try: - x["tx"] = tx_from_any(x["tx"]) - except Exception as e: - raise e - return x - - -json_db.register_dict("heirs", tuple, None) -json_db.register_dict("will", dict, None) -json_db.register_dict("will_settings", lambda x: x, None) - - -class BalConfig: - """Typed accessor for a single Electrum configuration key. - - Wraps ``config.get`` / ``config.set_key`` and supplies a default value - when the key is missing. - """ - - def __init__(self, config, name, default): - self.config = config - self.name = name - self.default = default - - def get(self, default=None): - """Return the stored value, falling back to ``default`` then ``self.default``.""" - v = self.config.get(self.name, default) - if v is None: - if default is not None: - v = default - else: - v = self.default - return v - - def set(self, value, save=True): - """Persist ``value`` for this key.""" - self.config.set_key(self.name, value, save=save) - - -class BalPlugin(BasePlugin): - """Base plugin: holds configuration and default inheritance settings. - - The GUI layer subclasses this in ``bal.gui.qt.plugin.Plugin`` and adds the - Electrum ``@hook`` methods. Keeping the configuration here means the CLI - layer (or unit tests) can use the plugin logic without importing Qt. - """ - - _version = None - __version__ = "0.3.3" # 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.LOCKTIMEDELTA_TIME = BalConfig(config, "bal_locktimedelta_time", 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.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True) - self.ALARM_NUMBER = BalConfig(config, "bal_alarm_number", 3) - self.WELIST_SERVER = BalConfig( - config, "bal_welist_server", "https://welist.bitcoin-after.life/" - ) - self.EVENT_DESCRIPTION = BalConfig( - config, - "bal_event_description", - "BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete", - ) - self.EVENT_SUMMARY = BalConfig( - config, "bal_event_summary", "BAL -Will execution of $wallet_name" - ) - - # Default will-executor servers, keyed by network. - self.WILLEXECUTORS = BalConfig( - config, - "bal_willexecutors", - { - "mainnet": { - "https://we.bitcoin-after.life": { - "base_fee": 100000, - "status": "New", - "info": "Bitcoin After Life Will Executor", - "address": "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf", - "selected": True, - } - }, - "testnet": { - "https://we.bitcoin-after.life": { - "base_fee": 100000, - "status": "New", - "info": "Bitcoin After Life Will Executor", - "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", - "selected": True, - } - }, - "testnet4": { - "https://we.bitcoin-after.life": { - "base_fee": 100000, - "status": "New", - "info": "Bitcoin After Life Will Executor", - "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", - "selected": True, - } - }, - "regtest": { - "https://we.bitcoin-after.life": { - "base_fee": 100000, - "status": "New", - "info": "Bitcoin After Life Will Executor", - "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", - "selected": True, - } - }, - }, - ) - self.WILL_SETTINGS = BalConfig( - config, - "bal_will_settings", - BalPlugin.default_will_settings(), - ) - - self.system = platform.system() - self.CALENDAR_APP = BalConfig( - config, "bal_open_app", self.default_app.get(self.system, "") - ) - - # Cached toggles used by the GUI list filters. - self._hide_invalidated = self.HIDE_INVALIDATED.get() - self._hide_replaced = self.HIDE_REPLACED.get() - - def resource_path(self, *parts): - """Absolute path to a file bundled inside the plugin directory.""" - return os.path.join(self.plugin_dir, *parts) - - def sync_hide_filters(self): - """Re-read the "hide" filter flags from the persisted config. - - The cached ``_hide_invalidated`` / ``_hide_replaced`` flags are used by - the GUI list to decide which rows to skip. They can be changed from two - different places: - - * the list toolbar buttons, which call :meth:`hide_invalidated` / - :meth:`hide_replaced` (a toggle that updates both the cache and the - config), and - * the Settings dialog checkboxes, which write the config directly - (``BalConfig.set``) without touching the cached flags. - - In the second case the cache and the config would drift apart and the - transaction list would keep filtering with the *old* value, so the - toggled rows never appear/disappear until Electrum is restarted. - Re-syncing the cache from the config here (called by ``update_all``) - keeps every code path coherent regardless of where the change came - from. - """ - self._hide_invalidated = self.HIDE_INVALIDATED.get() - self._hide_replaced = self.HIDE_REPLACED.get() - - def hide_invalidated(self): - """Toggle (and persist) the "hide invalidated transactions" filter.""" - self._hide_invalidated = not self._hide_invalidated - self.HIDE_INVALIDATED.set(self._hide_invalidated) - - def hide_replaced(self): - """Toggle (and persist) the "hide replaced transactions" filter.""" - self._hide_replaced = not self._hide_replaced - self.HIDE_REPLACED.set(self._hide_replaced) - - def validate_will_settings(self, will_settings): - """Fill in any missing will-setting with its default value.""" - defaults = BalPlugin.default_will_settings() - if not will_settings: - will_settings = [] - if int(will_settings.get("baltx_fees", 0)) < 1: - will_settings["baltx_fees"] = defaults['baltx_fees'] - if not will_settings.get("threshold"): - will_settings["threshold"] = defaults['threshold'] - if not will_settings.get("locktime"): - will_settings["locktime"] = defaults['locktime'] - return will_settings - - @staticmethod - def default_will_settings(): - """Default will settings: a fee rate plus absolute threshold/locktime.""" - will_settings = {"baltx_fees": 100} - will_settings.update(BalPlugin.default_will_settings_absolute()) - return will_settings - - @staticmethod - def default_will_settings_absolute(): - """Convert the default relative dates into absolute timestamps (from today).""" - relative_dates = BalPlugin.default_will_settings_relative() - today = date.today() - dt = datetime(today.year, today.month, today.day, 0, 0, 0) - threshold = ( - dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days()) - ).timestamp() - locktime = ( - dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days()) - ).timestamp() - return {"threshold": threshold, "locktime": locktime} - - @staticmethod - def default_will_settings_relative(): - """Default relative dates: 30 days threshold, 1 year locktime.""" - return {"threshold": "30d", "locktime": "1y"} - - -class BalTimestamp: - """Parse and convert relative durations / absolute timestamps. - - A value may be: - * ``"y"`` -> ``n`` years (unit ``"y"``) - * ``"d"`` -> ``n`` days (unit ``"d"``) - * an integer -> an absolute UNIX timestamp (``unit is None``) - """ - - value = None - unit = None - - def __init__(self, value): - str_value = str(value) - if str_value and str_value[-1].lower() in ("y", "d"): - self.value = int(str_value[:-1]) - self.unit = str_value[-1] - else: - try: - self.value = int(value) - except Exception as _e: - self.value = 1 - self.unit = None - - def duration_to_days(self): - """Return the duration expressed in days (years are ``*365``).""" - return self.value * 365 if self.unit == 'y' else self.value - - @staticmethod - def _safe_fromtimestamp(ts): - """``datetime.fromtimestamp`` that never raises ``OverflowError``. - - On Windows ``time_t`` is 32-bit, so ``datetime.fromtimestamp`` raises - ``OverflowError: Python int too large to convert to C int`` for any - timestamp past the year-2038 limit (e.g. ``NLOCKTIME_MAX = 2**32 - 1``, - used as the default/sentinel locktime). On 64-bit Linux the same call - succeeds, which is why this only crashed on the user's Windows build. - - We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own - ``get_max_allowed_timestamp`` workaround (see Electrum issue #6170). - """ - INT32_MAX = 2 ** 31 - 1 - try: - return datetime.fromtimestamp(ts) - except (OSError, OverflowError, ValueError): - try: - return datetime.fromtimestamp(min(int(ts), INT32_MAX)) - except (OSError, OverflowError, ValueError): - return datetime.fromtimestamp(INT32_MAX) - - def to_date(self, from_date=None, reverse=False): - """Resolve to a ``datetime``. - - For absolute values the stored timestamp is returned; for relative ones - the duration is added to (or, if ``reverse``, subtracted from) - ``from_date`` (defaulting to *now*), normalised to midnight. - """ - if self.unit is None: - return self._safe_fromtimestamp(self.value) - else: - if from_date is None: - from_date = datetime.now() - if isinstance(from_date, (int, float)): - from_date = self._safe_fromtimestamp(from_date) - reverse = 1 if not reverse else -1 - try: - return ( - from_date + (reverse * timedelta(days=self.duration_to_days())) - ).replace(hour=0, minute=0, second=0, microsecond=0) - except (OverflowError, OSError, ValueError): - # Duration overflowed datetime's range; clamp to INT32_MAX. - return self._safe_fromtimestamp(2 ** 31 - 1).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - - def to_timestamp(self, from_date=None, reverse=False): - """Same as :meth:`to_date` but returns a UNIX timestamp.""" - return self.to_date(from_date, reverse).timestamp() - - def __str__(self): - if self.unit is None: - return self._safe_fromtimestamp(self.value).isoformat() - else: - return f"{self.value}{self.unit}" - - def __repr__(self): - if self.unit is None: - return self._safe_fromtimestamp(self.value).isoformat() - else: - return f"{self.value}{self.unit}" diff --git a/bal/core/util.py b/bal/core/util.py deleted file mode 100644 index 3629f5a..0000000 --- a/bal/core/util.py +++ /dev/null @@ -1,551 +0,0 @@ -""" -bal.core.util -============= - -Small, stateless helper functions shared across the whole plugin. - -This module is intentionally GUI-free: it only deals with locktimes, amount -encoding/decoding, and comparing transactions / inputs / outputs / heirs. - -Only UNIX timestamps are used for locktimes; block-height locktimes have been -removed. A locktime is always an absolute UNIX timestamp (seconds since epoch) -or a relative string like "30d" (30 days) or "1y" (1 year). -""" - -from datetime import datetime, timedelta - - -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 as an ISO date string.""" - try: - locktime = int(locktime) - 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"``); - absolute ISO dates are converted to an integer UNIX timestamp. - """ - try: - if locktime[-1] in ("y", "d"): - 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, now=None): - """Resolve a (possibly relative) locktime string into a concrete int. - - Supported forms: - * plain int / timestamp -> returned unchanged - * ``"y"`` -> n years from now (as a timestamp) - * ``"d"`` -> n days from now (as a timestamp) - - When *now* is provided (a ``datetime``), relative strings are resolved - relative to that instant instead of ``datetime.now()``. This is used - when checking a signed will so that ``"30d"`` always resolves to the - *original* creation-time + 30 days, preventing spurious postpone - detections on every check. - """ - try: - return int(locktime) - except Exception: - pass - try: - if now is None: - 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() - ) - return int(locktime) - except Exception: - pass - return 0 - - @staticmethod - def int_locktime(seconds=0, minutes=0, hours=0, days=0): - """Convert a human duration into seconds.""" - return int( - seconds - + minutes * 60 - + hours * 60 * 60 - + days * 60 * 60 * 24 - ) - - # ------------------------------------------------------------------ # - # Amount helpers - # ------------------------------------------------------------------ # - @staticmethod - def encode_amount(amount, decimal_point): - """Convert a displayed BTC amount into integer satoshis. - - Percentage amounts (e.g. ``"50%"``) are passed through unchanged, since - they are resolved later against the wallet balance. - """ - if Util.is_perc(amount): - return amount - else: - try: - return int(float(amount) * pow(10, decimal_point)) - except Exception: - return 0 - - @staticmethod - def decode_amount(amount, decimal_point): - """Inverse of :meth:`encode_amount`: satoshis -> displayed string.""" - if Util.is_perc(amount): - return amount - else: - basestr = "{{:0.{}f}}".format(decimal_point) - try: - return basestr.format(float(amount) / pow(10, decimal_point)) - except Exception: - return str(amount) - - @staticmethod - def is_perc(value): - """True if ``value`` is a percentage string such as ``"25%"``.""" - try: - return value[-1] == "%" - except Exception: - return False - - # ------------------------------------------------------------------ # - # Heir / will-executor comparison helpers - # ------------------------------------------------------------------ # - @staticmethod - def cmp_array(heira, heirb): - """Element-wise equality of two sequences (length-safe).""" - try: - if len(heira) != len(heirb): - return False - for h in range(0, len(heira)): - if heira[h] != heirb[h]: - return False - return True - except Exception: - return False - - @staticmethod - def cmp_heir(heira, heirb): - """Two heirs are "the same" when address (0) and amount (1) match.""" - if heira[0] == heirb[0] and heira[1] == heirb[1]: - return True - return False - - @staticmethod - def cmp_willexecutor(willexecutora, willexecutorb): - """Compare two will-executor dicts by url / address / base_fee.""" - if willexecutora == willexecutorb: - return True - try: - if ( - willexecutora["url"] == willexecutorb["url"] - and willexecutora["address"] == willexecutorb["address"] - and willexecutora["base_fee"] == willexecutorb["base_fee"] - ): - return True - except Exception: - return False - return False - - @staticmethod - def search_heir_by_values(heirs, heir, values): - """Return the key of the first heir in ``heirs`` matching ``heir`` on - every column listed in ``values`` (or ``False`` if none).""" - for h, v in heirs.items(): - found = False - for val in values: - if val in v and v[val] != heir[val]: - found = True - - if not found: - return h - return False - - @staticmethod - def cmp_heir_by_values(heira, heirb, values): - """True when two heirs agree on every column index in ``values``.""" - for v in values: - if heira[v] != heirb[v]: - return False - return True - - @staticmethod - def cmp_heirs_by_values( - heirsa, heirsb, values, exclude_willexecutors=False, reverse=True - ): - """Set-equality of two heir collections, comparing only ``values``. - - When ``exclude_willexecutors`` is set, synthetic will-executor heirs - (those whose key contains the ``w!ll3x3c"`` marker) are skipped. The - ``reverse`` flag makes the comparison symmetric by running it both ways. - """ - for heira in heirsa: - if ( - exclude_willexecutors and 'w!ll3x3c"' not in heira - ) or not exclude_willexecutors: - found = False - for heirb in heirsb: - if Util.cmp_heir_by_values(heirsa[heira], heirsb[heirb], values): - found = True - if not found: - return False - if reverse: - return Util.cmp_heirs_by_values( - heirsb, - heirsa, - values, - exclude_willexecutors=exclude_willexecutors, - reverse=False, - ) - else: - return True - - @staticmethod - def cmp_heirs( - heirsa, - heirsb, - cmp_function=lambda x, y: x[0] == y[0] and x[3] == y[3], - reverse=True, - ): - """Compare two heir collections using a custom ``cmp_function``. - - Will-executor entries are ignored. As with - :meth:`cmp_heirs_by_values`, ``reverse`` makes the relation symmetric. - """ - try: - for heir in heirsa: - if 'w!ll3x3c"' not in heir: - if heir not in heirsb or not cmp_function( - heirsa[heir], heirsb[heir] - ): - if not Util.search_heir_by_values(heirsb, heirsa[heir], [0, 3]): - return False - if reverse: - return Util.cmp_heirs(heirsb, heirsa, cmp_function, False) - else: - return True - except Exception as e: - raise e - - # ------------------------------------------------------------------ # - # Transaction input/output comparison helpers - # ------------------------------------------------------------------ # - @staticmethod - def cmp_inputs(inputsa, inputsb): - """True when both input lists reference the same set of UTXOs.""" - if len(inputsa) != len(inputsb): - return False - for inputa in inputsa: - if not Util.in_utxo(inputa, inputsb): - return False - return True - - @staticmethod - def cmp_outputs(outputsa, outputsb, willexecutor_output=None): - """True when both output lists contain the same (address, value) pairs. - - The optional ``willexecutor_output`` is treated as a wildcard match so - that the will-executor's fee output does not break the comparison. - """ - if len(outputsa) != len(outputsb): - return False - for outputa in outputsa: - if not Util.cmp_output(outputa, willexecutor_output): - if not Util.in_output(outputa, outputsb): - return False - return True - - @staticmethod - def cmp_txs(txa, txb): - """Two transactions are equivalent when their inputs and outputs match.""" - if not Util.cmp_inputs(txa.inputs(), txb.inputs()): - return False - if not Util.cmp_outputs(txa.outputs(), txb.outputs()): - return False - return True - - @staticmethod - def get_value_amount(txa, txb): - """Sum of the values of outputs that appear (same addr+value) in both - transactions. Returns ``False`` as soon as an output of ``txa`` shares - neither amount nor address with any output of ``txb``.""" - outputsa = txa.outputs() - value_amount = 0 - - for outa in outputsa: - same_amount, same_address = Util.din_output(outa, txb.outputs()) - if not (same_amount or same_address): - return False - if same_amount and same_address: - value_amount += outa.value - if same_amount: - pass - if same_address: - pass - - return value_amount - - # ------------------------------------------------------------------ # - # Locktime arithmetic - # ------------------------------------------------------------------ # - @staticmethod - def chk_locktime(timestamp_to_check, locktime): - """Return True if ``locktime`` is still in the future.""" - locktime = int(locktime) - return locktime > timestamp_to_check - - @staticmethod - def anticipate_locktime(locktime, hours=0, days=0): - """Move a locktime earlier by the given amount (only timestamp locktimes). - - Never returns a value below 1. - """ - locktime = int(locktime) - try: - dt = datetime.fromtimestamp(locktime) - except (OverflowError, OSError, ValueError): - dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1)) - dt -= timedelta(seconds=hours * 3600 + days * 86400) - out = dt.timestamp() - 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 "yd": - if locktimeb[-1] == locktimea[-1]: - return int(strlocktimea[-1]) - int(strlocktimeb[-1]) - else: - return int(locktimea) - (locktimeb) - - @staticmethod - def is_locktime_increased(old, new): - """True when *new* locktime spec is longer/greater than *old*.""" - def _to_days(v): - if isinstance(v, str) and v[-1:] in ("d", "y"): - n = int(v[:-1]) - return n * 365 if v[-1] == "y" else n - return int(v) - return _to_days(new) > _to_days(old) - - @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): - """Return sorted list of timestamp locktimes.""" - sorted_timestamps = [] - for locktime in locktimes: - locktime = Util.parse_locktime_string(locktime) - if locktime is not None: - sorted_timestamps.append(locktime) - return sorted(sorted_timestamps) - - @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 the current UNIX timestamp for locktime purposes. - - Returns time.time() as the reference timestamp. - """ - return int(datetime.now().timestamp()) - - # ------------------------------------------------------------------ # - # Misc helpers - # ------------------------------------------------------------------ # - @staticmethod - def copy(dicto, dictfrom): - """Shallow copy of ``dictfrom`` entries into ``dicto`` (in place).""" - for k, v in dictfrom.items(): - dicto[k] = v - - @staticmethod - def fix_will_settings_tx_fees(will_settings): - """Migrate the legacy ``tx_fees`` key to ``baltx_fees`` in settings. - - Returns True when a migration was performed (caller should persist). - """ - tx_fees = will_settings.get("tx_fees", False) - have_to_update = False - if tx_fees: - will_settings["baltx_fees"] = tx_fees - del will_settings["tx_fees"] - have_to_update = True - return have_to_update - - @staticmethod - def fix_will_tx_fees(will): - """Same legacy migration as above but applied to every will entry.""" - have_to_update = False - for txid, willitem in will.items(): - tx_fees = willitem.get("tx_fees", False) - if tx_fees: - will[txid]["baltx_fees"] = tx_fees - del will[txid]["tx_fees"] - have_to_update = True - return have_to_update - - @staticmethod - def text_to_hex(text: str) -> str: - """Convert text to a hexadecimal string (used for OP_RETURN payloads).""" - hex_string = text.encode('utf-8').hex() - return hex_string - - @staticmethod - def hex_to_text(hex_string: str) -> str: - """Convert a hexadecimal string back to text (for verification).""" - try: - return bytes.fromhex(hex_string).decode('utf-8') - except Exception: - return "Error: Invalid hex string" diff --git a/bal/core/will.py b/bal/core/will.py deleted file mode 100644 index 2e36d9f..0000000 --- a/bal/core/will.py +++ /dev/null @@ -1,1149 +0,0 @@ -""" -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. - -Transaction states: - * ANTICIPATED - Transaction was generated with a locktime 1-day earlier - than a pre-existing transaction sharing the same heirs. - Remains VALID. - * REPLACED - Transaction was replaced because at least one of its - inputs is spent by a new transaction with a lower - locktime. Loses VALID status. Propagates to children. - * INVALIDATED - Transaction can no longer be spent because at least one - of its inputs has been spent by a mempool/confirmed - transaction and the previous transaction no longer - exists in the will. Loses VALID status. - * UPDATED - Transaction is spendable and valid, but a new - transaction replaces it with the same locktime and - heirs. - * PENDING - Transaction is pending in the mempool (unconfirmed). - * CONFIRMED - Transaction is confirmed in the blockchain. - -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 -import time -from datetime import datetime - -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 -_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 needs_server_check(w): - """Return True if ``w`` should be queried on its will-executor server - when the user presses Check (or on Electrum close). - - A will is queried only when it is VALID, has a will-executor assigned, - was actually PUSHED (sent), and is not yet CHECKED. The ``PUSHED`` - condition is essential: querying the server for a will that was *never* - sent would make the server (correctly) answer "I don't have this tx", - which ``WillItem.set_check_willexecutor`` then records as CHECK_FAIL. - A freshly signed-but-not-sent will would therefore turn red, even though - it is merely "signed, not sent" (which must stay blue / #2bc8ed, as in - the original BAL behaviour). Restricting the check to PUSHED wills - matches the original ``check()`` logic and avoids that false failure. - """ - return bool( - w.get_status("VALID") - and w.we - and w.get_status("PUSHED") - and not w.get_status("CHECKED") - ) - - @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_input) - will[wid] = WillItem(ow.to_dict()) - - for i in range(0, len(outputs)): - Will.change_input( - will, wid, i, outputs[i], others_input, 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 - - # Sentinel returned by ``check_anticipate`` meaning "do not anticipate": - # it is larger than any valid 32-bit locktime, so ``set_anticipate``'s - # ``min(old_locktime, sentinel)`` keeps the old locktime untouched. - NO_ANTICIPATE = 4294967295 + 1 - - @staticmethod - def check_anticipate(ow: "WillItem", nw: "WillItem"): - """Decide the locktime the *new* will item should take w.r.t. an old one. - - Both ``ow`` (old) and ``nw`` (new) spend (at least) one common input, so - only one of them can ever be mined. When the new will differs in a way - that affects the heirs or *increases* the amount they must wait for, the - new transaction is given a locktime ONE DAY EARLIER than the old one - (``anticipate``), so it would be mined first and supersede the old one. - - The decision tree: - - * Heirs (address + requested amount) differ -> anticipate - * Same heirs, both have a will-executor: - - same will-executor url: - * old base_fee > new base_fee -> anticipate - (heirs effectively receive more -> bring it forward) - * the per-byte ``tx_fees`` changed -> anticipate - * otherwise (e.g. base_fee merely *increased*, nothing else - changed) -> keep the old locktime - (this is the UPDATED case: a new tx with the SAME - locktime and SAME heirs replaces the old one) - - different will-executor url -> keep the old locktime - * Same heirs, no will-executor change: - - the resolved heir amounts (column 3) differ -> anticipate - - otherwise -> keep the locktime - - Returns the chosen locktime, or :data:`NO_ANTICIPATE` when the new - locktime is already earlier than ``anticipate`` (nothing to do). - """ - anticipate = Util.anticipate_locktime(ow.tx.locktime, days=1) - if int(nw.tx.locktime) < int(anticipate): - # The new will is already earlier than one day before the old one; - # there is nothing to anticipate. - return Will.NO_ANTICIPATE - - if not Util.cmp_heirs_by_values( - ow.heirs, nw.heirs, [0, 1], exclude_willexecutors=True - ): - # Heirs (address / requested amount) changed -> bring it forward. - return anticipate - - if nw.we and ow.we: - if ow.we["url"] == nw.we["url"]: - if int(ow.we["base_fee"]) > int(nw.we["base_fee"]): - # Will-executor now takes a SMALLER fee -> heirs get more, - # so anticipate. - return anticipate - if int(ow.tx_fees) != int(nw.tx_fees): - # Mining fee rate changed -> anticipate. - return anticipate - # Same url, base_fee not lowered, same tx_fees: this is a plain - # update (e.g. base_fee increased). Keep the same locktime. - return ow.tx.locktime - # Different will-executor URL: keep the same locktime. - return ow.tx.locktime - - # No will-executor on at least one side. - if nw.we == ow.we: - if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]): - # Resolved heir amounts differ -> anticipate. - return anticipate - return ow.tx.locktime - # One has a will-executor, the other doesn't: keep the same locktime. - return ow.tx.locktime - - @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_timestamp = int(time.time()) - 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: - 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_timestamp - 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 - Will.search_updated(all_inputs) - - @staticmethod - def search_updated(all_inputs): - """Mark superseded-but-still-valid transactions as ``UPDATED``. - - When the user changes something that does NOT move the deadline nor the - money paid to the heirs -- the classic case being a will-executor's - ``base_fee`` increase -- the plugin rebuilds the inheritance keeping the - SAME locktime and the SAME heirs (same destination address and amount). - The result is two transactions that: - - * spend the same wallet UTXO(s), - * share the exact same ``tx.locktime``, - * pay the same heirs the same amounts, - - but have different txids (because the will-executor fee output changed). - - Both remain technically spendable, so neither must lose ``VALID``. The - newer transaction (the one created later, i.e. the larger ``time``) - becomes the active one, and the older transaction it superseded is - flagged ``UPDATED`` while keeping ``VALID`` (per the state spec). - - Detection is symmetric over every shared input: for each pair of valid - will items sharing an input we compare locktime and heirs; the older of - the two (by creation ``time``) is the one that was updated. - """ - seen_pairs = set() - for inp, ws in all_inputs.items(): - # Only valid transactions can be "updated"; replaced/invalidated - # ones already lost their VALID status and are handled elsewhere. - valid_ws = [w for w in ws if w[1].get_status("VALID")] - for i in range(len(valid_ws)): - for j in range(i + 1, len(valid_ws)): - wa = valid_ws[i][1] - wb = valid_ws[j][1] - if wa._id == wb._id: - continue - # Avoid processing the same pair twice (it may share more - # than one input). - pair_key = tuple(sorted((wa._id, wb._id))) - if pair_key in seen_pairs: - continue - seen_pairs.add(pair_key) - if int(wa.tx.locktime) != int(wb.tx.locktime): - # Different deadlines -> this is an anticipate/replace - # case, not an update. - continue - if not Util.cmp_heirs_by_values( - wa.heirs or {}, - wb.heirs or {}, - [0, 1], - exclude_willexecutors=True, - ): - # Heirs (address + requested amount) differ -> not a - # plain update. - continue - # Same locktime and same heirs: the older transaction was - # superseded by the newer one. Pick the older by creation - # time (fall back to a stable order when time is missing). - ta = wa.time if wa.time is not None else 0 - tb = wb.time if wb.time is not None else 0 - if ta == tb: - older = wa if wa._id <= wb._id else wb - else: - older = wa if ta < tb else wb - older.set_status("UPDATED", True) - - @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 still technically valid - @staticmethod - def check_invalidated(willtree, utxos_list, wallet): - """Reconcile each will transaction against the wallet's view of its inputs. - - For every will item whose parent is gone/confirmed/pending, we look at - its inputs. If an input is no longer an unspent output of the wallet - (it is not in ``utxos_list``), then *something* has spent it, and we ask - Electrum what happened to OUR transaction via its mined-status height: - - * ``height > 0`` -> confirmed in a block -> CONFIRMED - * ``height == 0`` (UNCONFIRMED) -> seen in the mempool -> PENDING - * ``height == -1`` (UNCONF_PARENT)-> mempool (unconf parent)-> PENDING - * anything else (LOCAL / FUTURE / unknown) -> the input was spent by - a different transaction, so ours can no longer be broadcast -> - INVALIDATED (cascades to children). - - Electrum's height sentinels (from ``electrum.address_synchronizer``): - ``TX_HEIGHT_LOCAL = -2``, ``TX_HEIGHT_UNCONFIRMED = 0``, - ``TX_HEIGHT_UNCONF_PARENT = -1``, ``TX_HEIGHT_FUTURE = -3``. - """ - 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: - # Mined in a block. - w.set_status("CONFIRMED", True) - elif height in (0, -1): - # Seen in the mempool (unconfirmed, possibly with - # an unconfirmed parent). - w.set_status("PENDING", True) - else: - # LOCAL / FUTURE / unknown: the spent input was - # taken by some other transaction, so this will - # can no longer be broadcast. - Will.set_invalidate(wid, willtree) - - # 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, 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, 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, - 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, 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, 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 < int(timestamp_to_check): - raise WillExpiredException( - f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}" - ) - else: - _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]: - # The requested (possibly new) locktime for this heir. - _base = ( - datetime.fromtimestamp(w.time) - if w.time and isinstance(heir[2], str) - and heir[2][-1:] in ("d", "y") - else None - ) - new_locktime = Util.parse_locktime_string( - heir[2], now=_base - ) - tx_locktime = int(w.tx.locktime) - # Count the heir as found when the resolved locktime - # matches OR when the raw locktime spec is unchanged - # (the latter covers anticipation, which reduces - # tx.locktime but leaves the stored spec intact). - if ( - new_locktime == tx_locktime - or their[2] == heir[2] - ): - count = heirs_found.get(wheir, 0) - heirs_found[wheir] = count + 1 - elif new_locktime > tx_locktime and ( - w.get_status("COMPLETE") or w.get_status("PUSHED") - ): - # POSTPONE: compare raw specs to avoid false - # positives from anticipation. - if Util.is_locktime_increased(their[2], heir[2]): - raise WillPostponedException( - f"{wheir}: locktime postponed " - f"{their[2]}->{heir[2]} " - f"on a signed/sent will" - ) - # new_locktime < tx_locktime (anticipate) is left to - # check_will_expired -> WillExpiredException. - else: - # The will still carries this heir, but the heir is no - # longer present in the current heirs set: the user - # removed it. This must trigger a rebuild exactly like - # "heir added" does, otherwise the removed heir would - # silently stay in the inheritance transaction. Raising - # HeirNotFoundException reuses the same rebuild path used - # by the Check button and by on_close (Electrum quit). - _logger.debug( - f"heir removed, transaction is not valid:" - f"{wheir} {wid}, {w}" - ) - raise HeirNotFoundException(wheir) - - 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], - "UPDATED": ["Updated", False], - "VALID": ["Valid", True], - } - - def set_status(self, status, value=True): - 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: - # ANITICIPATED: valid remains, no other changes - # UPDATED: valid remains, no other changes - 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) - self.set_status("ANTICIPATED", True) - 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 WillPostponedException(NotCompleteWillException): - """An already signed/sent will is being postponed. - - When a will that has already been signed (``COMPLETE``) and/or pushed to - will-executors (``PUSHED``) gets its locktime moved to a LATER date, the - previously committed coins must be invalidated on-chain BEFORE rebuilding - the new inheritance. Otherwise a will-executor could broadcast the old - (earlier-locktime) transaction and execute the inheritance too early to - collect the fees. Invalidating spends the same UTXOs now, permanently - voiding the old pre-signed transaction. - """ - - pass - - -class WillexecutorChangeException(NotCompleteWillException): - pass - - -class NoWillExecutorNotPresent(NotCompleteWillException): - pass - - -class WillExecutorNotPresent(NotCompleteWillException): - pass - - -class NoHeirsException(WillException): - pass -class AmountException(WillException): - pass - - -class PercAmountException(AmountException): - pass - - -class FixedAmountException(AmountException): - pass diff --git a/bal/core/willexecutors.py b/bal/core/willexecutors.py deleted file mode 100644 index b1bd643..0000000 --- a/bal/core/willexecutors.py +++ /dev/null @@ -1,788 +0,0 @@ -""" -bal.core.willexecutors -======================= - -Client logic for talking to *will-executor* servers. - -A will-executor is an optional third-party service that, for a small fee, -stores the signed inheritance transactions off-line and broadcasts them once -their locktime expires (acting as a dead-man's switch backup). - -This module only contains the networking / data-shaping logic (downloading the -server list, pinging servers for their fee and address, pushing transactions, -checking whether a tx is already stored). It is GUI-free: all user -interaction is handled by the Qt layer. -""" - -import json -import time -from datetime import datetime - -from aiohttp import ClientResponse -from electrum.i18n import _ -from electrum.logging import get_logger -from electrum.network import Network - -from .plugin_base import BalPlugin - -# Per-request timeout (seconds) for interactive operations (ping / info / -# list download). These fail fast (no retries) so a dead server does not -# block the UI. -DEFAULT_TIMEOUT = 5 - -# Broadcast (pushtxs) timeouts. Broadcasting a will is important, so we keep a -# couple of quick retries to survive a transient hiccup -- but far from the old -# 10s x 10 retries + 30s sleeps (~140s) that froze the wizard on a dead server. -# Worst case per server is now ~ PUSH_TIMEOUT * (1 + PUSH_MAX_RETRIES) -# + PUSH_RETRY_SLEEP * PUSH_MAX_RETRIES = 8 * 3 + 1 * 2 = ~26s, and the wizard -# also enforces a global deadline on top of this (see push_transactions_parallel). -PUSH_TIMEOUT = 8 -PUSH_MAX_RETRIES = 2 -PUSH_RETRY_SLEEP = 1 - -# Global wall-clock deadline (seconds) for the whole parallel broadcast. Once -# it elapses we stop waiting for the still-pending servers, mark them as -# "Timeout" and let the wizard proceed instead of appearing stuck. -PUSH_GLOBAL_DEADLINE = 30 - -# Check (searchtx) timeouts. Used when the user presses "Check" to verify that -# each will-executor still holds the transaction. Like the broadcast path, the -# old defaults (10s x 10 retries + 30s sleeps ~= 140s per server) froze the -# "checking transaction" dialog on a single dead server. Fail fast with one -# quick retry, and cap the whole batch with a global deadline. -CHECK_TIMEOUT = 8 -CHECK_MAX_RETRIES = 1 -CHECK_RETRY_SLEEP = 1 -CHECK_GLOBAL_DEADLINE = 30 - -_logger = get_logger(__name__) - - -chainname = BalPlugin.chainname - - -class Willexecutors: - - # Expose the networking constants as class attributes so the GUI layer can - # reference them (e.g. to show the "Xs / DEADLINEs" countdown) without - # importing module-level names. Single source of truth: the module - # constants defined above. - DEFAULT_TIMEOUT = DEFAULT_TIMEOUT - PUSH_TIMEOUT = PUSH_TIMEOUT - PUSH_MAX_RETRIES = PUSH_MAX_RETRIES - PUSH_RETRY_SLEEP = PUSH_RETRY_SLEEP - PUSH_GLOBAL_DEADLINE = PUSH_GLOBAL_DEADLINE - CHECK_TIMEOUT = CHECK_TIMEOUT - CHECK_MAX_RETRIES = CHECK_MAX_RETRIES - CHECK_RETRY_SLEEP = CHECK_RETRY_SLEEP - CHECK_GLOBAL_DEADLINE = CHECK_GLOBAL_DEADLINE - - @staticmethod - def save(bal_plugin, willexecutors): - _logger.debug(f"save {willexecutors},{chainname}") - aw = bal_plugin.WILLEXECUTORS.get() - aw[chainname] = willexecutors - bal_plugin.WILLEXECUTORS.set(aw) - _logger.debug(f"saved: {aw}") - # bal_plugin.WILLEXECUTORS.set(willexecutors) - - @staticmethod - def get_willexecutors( - bal_plugin, update=False, bal_window=False, force=False, task=True - ): - willexecutors = bal_plugin.WILLEXECUTORS.get() - willexecutors = willexecutors.get(chainname, {}) - to_del = [] - for w in willexecutors: - if not isinstance(willexecutors[w], dict): - to_del.append(w) - continue - Willexecutors.initialize_willexecutor(willexecutors[w], w) - for w in to_del: - _logger.error( - "error Willexecutor to delete type:{} {}".format( - type(willexecutors[w]), w - ) - ) - del willexecutors[w] - bal = bal_plugin.WILLEXECUTORS.default.get(chainname, {}) - for bal_url, bal_executor in bal.items(): - if bal_url not in willexecutors: - _logger.debug(f"force add {bal_url} willexecutor") - willexecutors[bal_url] = bal_executor - # if update: - # found = False - # for url, we in willexecutors.items(): - # if Willexecutors.is_selected(we): - # found = True - # if found or force: - # if bal_plugin.PING_WILLEXECUTORS.get() or force: - # ping_willexecutors = True - # if bal_plugin.ASK_PING_WILLEXECUTORS.get() and not force: - # if bal_window: - # ping_willexecutors = bal_window.window.question( - # _( - # "Contact willexecutors servers to update payment informations?" - # ) - # ) - - # if ping_willexecutors: - # if task: - # bal_window.ping_willexecutors(willexecutors, task) - # else: - # bal_window.ping_willexecutors_task(willexecutors) - w_sorted = dict( - sorted( - willexecutors.items(), key=lambda w: w[1].get("sort", 0), reverse=True - ) - ) - return w_sorted - - @staticmethod - def is_selected(willexecutor, value=None): - if not willexecutor: - return False - if value is not None: - willexecutor["selected"] = value - try: - return willexecutor["selected"] - except Exception: - willexecutor["selected"] = False - return False - - @staticmethod - def get_willexecutor_transactions(will, force=False): - willexecutors = {} - for wid, willitem in will.items(): - if willitem.get_status("VALID"): - if willitem.get_status("COMPLETE"): - if not willitem.get_status("PUSHED") or force: - if willexecutor := willitem.we: - url = willexecutor["url"] - if willexecutor and Willexecutors.is_selected(willexecutor): - if url not in willexecutors: - willexecutor["txs"] = "" - willexecutor["txsids"] = [] - willexecutor["broadcast_status"] = _("Waiting...") - willexecutors[url] = willexecutor - willexecutors[url]["txs"] += str(willitem.tx) + "\n" - willexecutors[url]["txsids"].append(wid) - - return willexecutors - - # def only_selected_list(willexecutors): - # out = {} - # for url, v in willexecutors.items(): - # if Willexecutors.is_selected(url): - # out[url] = v - - # def push_transactions_to_willexecutors(will): - # willexecutors = Willexecutors.get_transactions_to_be_pushed() - # for url in willexecutors: - # willexecutor = willexecutors[url] - # if Willexecutors.is_selected(willexecutor): - # if "txs" in willexecutor: - # Willexecutors.push_transactions_to_willexecutor( - # willexecutors[url]["txs"], url - # ) - - @staticmethod - def send_request( - method, url, data=None, *, timeout=10, handle_response=None, count_reply=0, - max_retries=10, retry_sleep=3, - ): - """Send an HTTP request to a will-executor server. - - ``max_retries`` / ``retry_sleep`` control the timeout-retry behaviour: - - * For *critical* operations (pushing inheritance transactions) the - historical default of up to 10 retries with a 3s back-off is kept, so - a transient network hiccup does not lose a transaction. - * For *interactive* operations (ping / info / list download) callers - should pass ``max_retries=0`` so a dead server fails fast (one short - timeout) instead of blocking the UI for minutes. See - :meth:`ping_servers_parallel`. - """ - network = Network.get_instance() - if not network: - raise Exception("You are offline.") - _logger.debug(f"<-- {method} {url} {data}") - headers = {} - headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}" - headers["Content-Type"] = "text/plain" - if not handle_response: - handle_response = Willexecutors.handle_response - try: - if method == "get": - response = Network.send_http_on_proxy( - method, - url, - params=data, - headers=headers, - on_finish=handle_response, - timeout=timeout, - ) - elif method == "post": - response = Network.send_http_on_proxy( - method, - url, - body=data, - headers=headers, - on_finish=handle_response, - timeout=timeout, - ) - else: - raise Exception(f"unexpected {method=!r}") - except TimeoutError: - if count_reply < max_retries: - _logger.debug( - f"timeout({count_reply}) error: retry in {retry_sleep} sec..." - ) - if retry_sleep: - time.sleep(retry_sleep) - return Willexecutors.send_request( - method, - url, - data, - timeout=timeout, - handle_response=handle_response, - count_reply=count_reply + 1, - max_retries=max_retries, - retry_sleep=retry_sleep, - ) - else: - _logger.debug(f"Too many timeouts: {count_reply}") - except Exception as e: - raise e - else: - _logger.debug(f"--> {response}") - return response - - @staticmethod - def get_we_url_from_response(resp): - url_slices = str(resp.url).split("/") - if len(url_slices) > 2: - url_slices = url_slices[:-2] - return "/".join(url_slices) - - @staticmethod - async def handle_response(resp: ClientResponse): - r = await resp.text() - try: - - r = json.loads(r) - # url = Willexecutors.get_we_url_from_response(resp) - # r["url"]= url - # r["status"]=resp.status - except Exception as e: - _logger.debug(f"error handling response:{e}") - pass - return r - - @staticmethod - class AlreadyPresentException(Exception): - pass - - @staticmethod - def push_transactions_to_willexecutor( - willexecutor, *, timeout=PUSH_TIMEOUT, max_retries=PUSH_MAX_RETRIES, - retry_sleep=PUSH_RETRY_SLEEP, - ): - # ``timeout`` / ``max_retries`` / ``retry_sleep`` are forwarded to - # send_request so the broadcast fails fast on a dead/slow server instead - # of hanging for ~140s (the old default was 10s timeout x 10 retries + - # 30s of sleeps). A small number of quick retries still protects - # against a transient hiccup without freezing the wizard. - out = True - try: - _logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}") - if w := Willexecutors.send_request( - "post", - willexecutor["url"] + "/" + chainname + "/pushtxs", - data=willexecutor["txs"].encode("ascii"), - timeout=timeout, - max_retries=max_retries, - retry_sleep=retry_sleep, - ): - willexecutor["broadcast_status"] = _("Success") - _logger.debug(f"pushed: {w}") - if w != "thx": - _logger.debug(f"error: {w}") - raise Exception(w) - else: - raise Exception("empty reply from:{willexecutor['url']}") - except Exception as e: - _logger.debug(f"error:{e}") - if str(e) == "already present": - raise Willexecutors.AlreadyPresentException() - out = False - willexecutor["broadcast_status"] = _("Failed") - - return out - - @staticmethod - def ping_servers(willexecutors): - for url, we in willexecutors.items(): - Willexecutors.get_info_task(url, we) - - @staticmethod - def get_info_task(url, willexecutor, *, timeout=DEFAULT_TIMEOUT, - max_retries=0, retry_sleep=0): - w = None - try: - _logger.info("GETINFO_WILLEXECUTOR") - _logger.debug(url) - # Fast-fail by default (max_retries=0): a dead server returns after a - # single short timeout instead of retrying 10x with sleeps, which - # used to freeze the UI for minutes per unreachable server. - w = Willexecutors.send_request( - "get", url + "/" + chainname + "/info", - timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, - ) - if isinstance(w, dict): - willexecutor["url"] = url - willexecutor["status"] = 200 - willexecutor["base_fee"] = w["base_fee"] - willexecutor["address"] = w["address"] - willexecutor["info"] = w["info"] - else: - # No dict reply (timeout / empty) -> mark as unreachable. - willexecutor["status"] = "KO" - _logger.debug(f"response_data {w}") - except Exception as e: - _logger.error(f"error {e} contacting {url}: {w}") - willexecutor["status"] = "KO" - - willexecutor["last_update"] = datetime.now().timestamp() - return willexecutor - - @staticmethod - def ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8, - timeout=DEFAULT_TIMEOUT, on_tick=None, - tick_interval=1.0): - """Ping every will-executor concurrently and report results as they - arrive. - - Network requests run in a thread pool: each ``send_http_on_proxy`` call - schedules its coroutine on Electrum's shared asyncio loop and blocks - only its *own* worker thread, so the total wall-clock time is roughly - that of the slowest server rather than the *sum* of all of them. A - single dead server can no longer stall the whole batch. - - Args: - willexecutors: ``{url: we_dict}`` mapping (mutated in place with the - ping result, exactly like the old sequential ``ping_servers``). - on_each: optional ``callback(url, we_dict, ok: bool)`` invoked from a - worker thread each time a server answers (or fails), so the GUI - can update its list live. Must be thread-safe / marshalled to - the GUI thread by the caller. - max_workers: maximum number of concurrent pings. - timeout: per-request timeout in seconds (fast-fail, no retries). - - on_tick: optional ``callback()`` invoked periodically (every - ``tick_interval`` seconds) **from the calling thread** while - waiting for servers, so a Qt caller can refresh an elapsed-time - counter from the same thread that drives ``on_each``. - - Returns: - The same ``willexecutors`` mapping, updated in place. - """ - from concurrent.futures import ThreadPoolExecutor, wait - from concurrent.futures import FIRST_COMPLETED - - items = list(willexecutors.items()) - if not items: - return willexecutors - - def _ping_one(url, we): - we = Willexecutors.get_info_task( - url, we, timeout=timeout, max_retries=0, retry_sleep=0 - ) - ok = we.get("status") == 200 - return url, we, ok - - def _fire_tick(): - if on_tick is not None: - try: - on_tick() - except Exception as cb_err: - _logger.error(f"ping on_tick callback error: {cb_err}") - - workers = max(1, min(max_workers, len(items))) - # Manual pool (no ``with``) so we can poll futures in short slices and - # drive ``on_tick`` from THIS thread between waits (reliable Qt repaint). - pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-ping") - futures = {pool.submit(_ping_one, url, we) for url, we in items} - try: - pending = set(futures) - while pending: - done, pending = wait( - pending, timeout=tick_interval, return_when=FIRST_COMPLETED - ) - for fut in done: - try: - url, we, ok = fut.result() - except Exception as e: # defensive: one server never crashes all - _logger.error(f"ping_servers_parallel worker error: {e}") - continue - willexecutors[url] = we - if on_each is not None: - try: - on_each(url, we, ok) - except Exception as cb_err: - _logger.error(f"ping on_each callback error: {cb_err}") - # Drive the elapsed-time counter from the calling thread. - _fire_tick() - finally: - try: - pool.shutdown(wait=False, cancel_futures=True) - except TypeError: - pool.shutdown(wait=False) - return willexecutors - - @staticmethod - def push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8, - deadline=PUSH_GLOBAL_DEADLINE, on_timeout=None, - on_tick=None, tick_interval=1.0): - """Push transactions to multiple will-executors concurrently. - - Like :meth:`ping_servers_parallel` but for the ``pushtxs`` operation. - Each server keeps a short retry behaviour - (:meth:`push_transactions_to_willexecutor`) so a real transaction is not - lost to a transient hiccup, but servers are contacted in parallel and - results are reported via ``on_each(url, we_dict, ok, exc)`` as they - complete. - - A global wall-clock ``deadline`` (seconds) caps the whole operation: if - some servers are still pending when it elapses, we stop waiting, mark - them via ``on_timeout(url, we_dict)`` and return, so the caller (the - wizard) is never stuck behind one unresponsive server. Pass - ``deadline=None`` to wait indefinitely (old behaviour). - - ``on_tick()`` is invoked periodically (every ``tick_interval`` seconds) - **from the calling thread** while waiting for workers. This lets a Qt - caller refresh an elapsed-time counter from the same thread that drives - ``on_each`` (so its pyqtSignal repaints reliably), instead of relying on - a separate heartbeat thread whose signal emissions are not marshalled. - - Returns ``{url: (ok, exception_or_None)}`` for the servers that - answered in time (timed-out servers are reported via ``on_timeout``). - """ - from concurrent.futures import ThreadPoolExecutor, wait - from concurrent.futures import FIRST_COMPLETED - - targets = [(url, we) for url, we in willexecutors.items() if "txs" in we] - results = {} - if not targets: - return results - - def _push_one(url, we): - try: - ok = Willexecutors.push_transactions_to_willexecutor(we) - return url, we, ok, None - except Willexecutors.AlreadyPresentException as ape: - return url, we, False, ape - except Exception as e: - return url, we, False, e - - def _fire_tick(): - if on_tick is not None: - try: - on_tick() - except Exception as cb_err: - _logger.error(f"push on_tick callback error: {cb_err}") - - workers = max(1, min(max_workers, len(targets))) - # NOTE: we do not use ``with ThreadPoolExecutor(...)`` here because its - # __exit__ calls shutdown(wait=True), which would block on a hung worker - # and defeat the whole point of the global deadline. We shut the pool - # down without waiting once the deadline elapses; the daemon worker(s) - # stuck on a dead socket will be torn down when their request finally - # times out (PUSH_TIMEOUT), without holding up the wizard. - pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-push") - fut_to_url = {pool.submit(_push_one, url, we): (url, we) - for url, we in targets} - start = time.time() - try: - # Poll the futures in short slices so we can call ``on_tick`` from - # THIS thread between waits. ``wait(..., timeout=tick_interval)`` - # returns as soon as a future completes OR the slice elapses, - # whichever comes first, so the counter advances ~once per second - # while the parallel push runs. - pending = set(fut_to_url.keys()) - while pending: - if deadline is not None and (time.time() - start) >= deadline: - break - slice_timeout = tick_interval - if deadline is not None: - remaining = deadline - (time.time() - start) - slice_timeout = max(0.0, min(tick_interval, remaining)) - done, pending = wait( - pending, timeout=slice_timeout, return_when=FIRST_COMPLETED - ) - for fut in done: - try: - url, we, ok, exc = fut.result() - except Exception as e: - _logger.error( - f"push_transactions_parallel worker error: {e}" - ) - continue - results[url] = (ok, exc) - if on_each is not None: - try: - on_each(url, we, ok, exc) - except Exception as cb_err: - _logger.error(f"push on_each callback error: {cb_err}") - # Drive the elapsed-time counter from the calling thread. - _fire_tick() - # Any server still pending here hit the global deadline. - if pending: - elapsed = time.time() - start - _logger.warning( - f"push global deadline ({deadline}s) reached after " - f"{elapsed:.1f}s; {len(pending)} server(s) " - f"did not answer in time" - ) - for fut in pending: - url, we = fut_to_url[fut] - if url in results: - continue - if on_timeout is not None: - try: - on_timeout(url, we) - except Exception as cb_err: - _logger.error( - f"push on_timeout callback error: {cb_err}" - ) - finally: - # Do not block on still-running workers (Python 3.9+: cancel queued). - try: - pool.shutdown(wait=False, cancel_futures=True) - except TypeError: - pool.shutdown(wait=False) - return results - - @staticmethod - def check_transactions_parallel(items, *, on_each=None, max_workers=8, - deadline=CHECK_GLOBAL_DEADLINE, - on_timeout=None, on_tick=None, - tick_interval=1.0): - """Check (searchtx) several will-executors concurrently. - - Same design as :meth:`push_transactions_parallel`, but for the "Check" - operation: it verifies that each will-executor still holds its - transaction. ``items`` is an iterable of ``(wid, url)`` pairs (one per - will-item that has a will-executor). - - Each server is contacted in parallel with a short fail-fast retry - (:meth:`check_transaction`), results are reported via - ``on_each(wid, url, result_or_None, exc)`` as they arrive, ``on_tick()`` - is called periodically from the calling thread to refresh a counter, and - a global ``deadline`` guarantees the dialog never freezes behind one - unresponsive server (pending servers are reported via - ``on_timeout(wid, url)``). - - Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers - that answered in time. - """ - from concurrent.futures import ThreadPoolExecutor, wait - from concurrent.futures import FIRST_COMPLETED - - targets = [(wid, url) for wid, url in items if url] - results = {} - if not targets: - return results - - def _check_one(wid, url): - try: - res = Willexecutors.check_transaction(wid, url) - return wid, url, res, None - except Exception as e: - return wid, url, None, e - - def _fire_tick(): - if on_tick is not None: - try: - on_tick() - except Exception as cb_err: - _logger.error(f"check on_tick callback error: {cb_err}") - - workers = max(1, min(max_workers, len(targets))) - # Manual pool (no ``with``): we must not block on a hung worker when the - # global deadline elapses (see push_transactions_parallel for details). - pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-check") - fut_to_target = {pool.submit(_check_one, wid, url): (wid, url) - for wid, url in targets} - start = time.time() - try: - pending = set(fut_to_target.keys()) - while pending: - if deadline is not None and (time.time() - start) >= deadline: - break - slice_timeout = tick_interval - if deadline is not None: - remaining = deadline - (time.time() - start) - slice_timeout = max(0.0, min(tick_interval, remaining)) - done, pending = wait( - pending, timeout=slice_timeout, return_when=FIRST_COMPLETED - ) - for fut in done: - try: - wid, url, res, exc = fut.result() - except Exception as e: - _logger.error( - f"check_transactions_parallel worker error: {e}" - ) - continue - results[wid] = (res, exc) - if on_each is not None: - try: - on_each(wid, url, res, exc) - except Exception as cb_err: - _logger.error(f"check on_each callback error: {cb_err}") - # Drive the elapsed-time counter from the calling thread. - _fire_tick() - # Any server still pending here hit the global deadline. - if pending: - elapsed = time.time() - start - _logger.warning( - f"check global deadline ({deadline}s) reached after " - f"{elapsed:.1f}s; {len(pending)} server(s) " - f"did not answer in time" - ) - for fut in pending: - wid, url = fut_to_target[fut] - if wid in results: - continue - if on_timeout is not None: - try: - on_timeout(wid, url) - except Exception as cb_err: - _logger.error( - f"check on_timeout callback error: {cb_err}" - ) - finally: - try: - pool.shutdown(wait=False, cancel_futures=True) - except TypeError: - pool.shutdown(wait=False) - return results - - @staticmethod - def initialize_willexecutor(willexecutor, url, status=None, old_willexecutor=None): - old_willexecutor=old_willexecutor if old_willexecutor is not None else {} - willexecutor["url"] = url - if status is not None: - willexecutor["status"] = status - else: - willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko")) - willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False) - willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address","")) - willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code")) - - - - @staticmethod - def download_list(old_willexecutors,welist_server): - try: - welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/' - willexecutors = Willexecutors.send_request( - "get", - f"{welist_server}data/{chainname}?page=0&limit=100", - ) - # del willexecutors["status"] - for w in willexecutors: - if w not in ("status", "url"): - Willexecutors.initialize_willexecutor( - willexecutors[w], w, None, old_willexecutors.get(w,None) - ) - # bal_plugin.WILLEXECUTORS.set(l) - # bal_plugin.config.set_key(bal_plugin.WILLEXECUTORS,l,save=True) - return willexecutors - - except Exception as e: - _logger.error(f"Failed to download willexecutors list: {e}") - return {} - - @staticmethod - def get_willexecutors_list_from_json(): - try: - with open("willexecutors.json") as f: - willexecutors = json.load(f) - for w in willexecutors: - willexecutor = willexecutors[w] - Willexecutors.initialize_willexecutor(willexecutor, w, "New", False) - # bal_plugin.WILLEXECUTORS.set(willexecutors) - return willexecutors - except Exception as e: - _logger.error(f"error opening willexecutors json: {e}") - - return {} - - @staticmethod - def check_transaction(txid, url, *, timeout=CHECK_TIMEOUT, - max_retries=CHECK_MAX_RETRIES, - retry_sleep=CHECK_RETRY_SLEEP): - _logger.debug(f"{url}:{txid}") - try: - w = Willexecutors.send_request( - "post", url + "/searchtx", data=txid.encode("ascii"), - timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, - ) - return w - except Exception as e: - _logger.error(f"error contacting {url} for checking txs {e}") - raise e - - @staticmethod - def compute_id(willexecutor): - return "{}-{}".format(willexecutor.get("url"), willexecutor.get("chain")) - - -#class WillExecutor: -# def __init__( -# self, -# url, -# base_fee, -# chain, -# info, -# version, -# status, -# is_selected=False, -# promo_code="", -# ): -# self.url = url -# self.base_fee = base_fee -# self.chain = chain -# self.info = info -# self.version = version -# self.status = status -# self.promo_code = promo_code -# self.is_selected = is_selected -# self.id = self.compute_id() -# -# def from_dict(d): -# return WillExecutor( -# url=d.get("url", "http://localhost:8000"), -# base_fee=d.get("base_fee", 1000), -# chain=d.get("chain", chainname), -# info=d.get("info", ""), -# version=d.get("version", 0), -# status=d.get("status", "Ko"), -# is_selected=d.get("is_selected", "False"), -# promo_code=d.get("promo_code", ""), -# ) -# -# def to_dict(self): -# return { -# "url": self.url, -# "base_fee": self.base_fee, -# "chain": self.chain, -# "info": self.info, -# "version": self.version, -# "promo_code": self.promo_code, -# } -# -# def compute_id(self): -# return f"{self.url}-{self.chain}"