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 5edf22c..0000000 Binary files a/bal/core/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/bal/core/__pycache__/heirs.cpython-311.pyc b/bal/core/__pycache__/heirs.cpython-311.pyc deleted file mode 100644 index a086569..0000000 Binary files a/bal/core/__pycache__/heirs.cpython-311.pyc and /dev/null differ 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 9110e83..0000000 Binary files a/bal/core/__pycache__/plugin_base.cpython-311.pyc and /dev/null differ diff --git a/bal/core/__pycache__/util.cpython-311.pyc b/bal/core/__pycache__/util.cpython-311.pyc deleted file mode 100644 index 94cc75d..0000000 Binary files a/bal/core/__pycache__/util.cpython-311.pyc and /dev/null differ diff --git a/bal/core/__pycache__/will.cpython-311.pyc b/bal/core/__pycache__/will.cpython-311.pyc deleted file mode 100644 index 37c5989..0000000 Binary files a/bal/core/__pycache__/will.cpython-311.pyc and /dev/null differ diff --git a/bal/core/__pycache__/willexecutors.cpython-311.pyc b/bal/core/__pycache__/willexecutors.cpython-311.pyc deleted file mode 100644 index 067e7b0..0000000 Binary files a/bal/core/__pycache__/willexecutors.cpython-311.pyc and /dev/null differ 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}"