diff --git a/bal/core/__init__.py b/bal/core/__init__.py new file mode 100644 index 0000000..6e1c1b2 --- /dev/null +++ b/bal/core/__init__.py @@ -0,0 +1,21 @@ +""" +bal.core +======== + +Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin. + +Everything in this sub-package MUST stay completely free of any GUI / Qt +imports. The rule of thumb is: + + * ``bal.core`` -> "what the plugin does" (inheritance rules, building + and validating transactions, talking to + will-executor servers, persistence helpers). + * ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views). + +Keeping the two apart is the main motivation behind this rewrite: the original +code mixed transaction-building logic and presentation inside a single +4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit. + +No behaviour is changed with respect to the original plugin; the code has only +been reorganised and documented. +""" diff --git a/bal/core/__pycache__/__init__.cpython-311.pyc b/bal/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..5edf22c Binary files /dev/null and b/bal/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/bal/core/__pycache__/heirs.cpython-311.pyc b/bal/core/__pycache__/heirs.cpython-311.pyc new file mode 100644 index 0000000..a086569 Binary files /dev/null and b/bal/core/__pycache__/heirs.cpython-311.pyc differ diff --git a/bal/core/__pycache__/plugin_base.cpython-311.pyc b/bal/core/__pycache__/plugin_base.cpython-311.pyc new file mode 100644 index 0000000..9110e83 Binary files /dev/null and b/bal/core/__pycache__/plugin_base.cpython-311.pyc differ diff --git a/bal/core/__pycache__/util.cpython-311.pyc b/bal/core/__pycache__/util.cpython-311.pyc new file mode 100644 index 0000000..94cc75d Binary files /dev/null and b/bal/core/__pycache__/util.cpython-311.pyc differ diff --git a/bal/core/__pycache__/will.cpython-311.pyc b/bal/core/__pycache__/will.cpython-311.pyc new file mode 100644 index 0000000..37c5989 Binary files /dev/null and b/bal/core/__pycache__/will.cpython-311.pyc differ diff --git a/bal/core/__pycache__/willexecutors.cpython-311.pyc b/bal/core/__pycache__/willexecutors.cpython-311.pyc new file mode 100644 index 0000000..067e7b0 Binary files /dev/null and b/bal/core/__pycache__/willexecutors.cpython-311.pyc differ diff --git a/bal/core/heirs.py b/bal/core/heirs.py new file mode 100644 index 0000000..f8d93e4 --- /dev/null +++ b/bal/core/heirs.py @@ -0,0 +1,850 @@ +""" +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 new file mode 100644 index 0000000..211a08c --- /dev/null +++ b/bal/core/plugin_base.py @@ -0,0 +1,400 @@ +""" +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 new file mode 100644 index 0000000..3629f5a --- /dev/null +++ b/bal/core/util.py @@ -0,0 +1,551 @@ +""" +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 new file mode 100644 index 0000000..2e36d9f --- /dev/null +++ b/bal/core/will.py @@ -0,0 +1,1149 @@ +""" +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 new file mode 100644 index 0000000..b1bd643 --- /dev/null +++ b/bal/core/willexecutors.py @@ -0,0 +1,788 @@ +""" +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}"