import datetime import json import math import random import re import threading import urllib.parse import urllib.request from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple import dns from dns.exception import DNSException from electrum import bitcoin, constants, descriptor, dnssec from electrum.logging import Logger, get_logger from electrum.transaction import ( PartialTransaction, PartialTxInput, PartialTxOutput, TxOutpoint, TxOutput, ) from electrum.util import ( bfh, read_json_file, to_string, trigger_callback, write_json_file, ) from .util import Util from .willexecutors import Willexecutors if TYPE_CHECKING: from .simple_config import SimpleConfig from .wallet_db import WalletDB _logger = get_logger(__name__) HEIR_ADDRESS = 0 HEIR_AMOUNT = 1 HEIR_LOCKTIME = 2 HEIR_REAL_AMOUNT = 3 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) """ #TODO: put this method inside wallet.db to replace or complete get_locktime_for_new_transaction def get_current_height(network:'Network'): #if no network or not up to date, just set locktime to zero if not network: return 0 chain = network.blockchain() if chain.is_tip_stale(): return 0 # figure out current block height chain_height = chain.height() # learnt from all connected servers, SPV-checked server_height = network.get_server_height() # height claimed by main server, unverified # note: main server might be lagging (either is slow, is malicious, or there is an SPV-invisible-hard-fork) # - if it's lagging too much, it is the network's job to switch away if server_height < chain_height - 10: # the diff is suspiciously large... give up and use something non-fingerprintable return 0 # discourage "fee sniping" height = min(chain_height, server_height) return height """ def prepare_transactions(locktimes, available_utxos, fees, wallet): available_utxos = sorted( available_utxos, key=lambda x: "{}:{}:{}".format( x.value_sats(), x.prevout.txid, x.prevout.out_idx ), ) total_used_utxos = [] txsout = {} locktime, _ = Util.get_lowest_locktimes(locktimes) if not locktime: return locktime = locktime[0] heirs = locktimes[locktime] vero = True while vero: vero = False fee = fees.get(locktime, 0) out_amount = fee description = "" outputs = [] paid_heirs = {} for name, heir in heirs.items(): try: if len(heir) > HEIR_REAL_AMOUNT: real_amount = heir[HEIR_REAL_AMOUNT] out_amount += real_amount description += f"{name}\n" paid_heirs[name] = heir outputs.append( PartialTxOutput.from_address_and_value( heir[HEIR_ADDRESS], real_amount ) ) else: pass except Exception as e: pass 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: pass if int(in_amount) < int(out_amount): break 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) tx = PartialTransaction.from_io( used_utxos, outputs, locktime=Util.parse_locktime_string(locktime, wallet), version=2, ) if len(description) > 0: tx.description = description[:-1] else: tx.description = "" tx.heirsvalue = heirsvalue tx.set_rbf(True) tx.remove_signatures() txid = tx.txid() if txid is None: raise Exception("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 not txid 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(f"---") for inp in jtx["inputs"]: print(f"{inp['address']}: {inp['value_sats']}") print(f"---") 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: print("impossible to serialize") print() def get_change_output(wallet, in_amount, out_amount, fee): change_amount = int(in_amount - out_amount - fee) if change_amount > wallet.dust_threshold(): change_addresses = wallet.get_change_addresses_for_new_transaction() out = PartialTxOutput.from_address_and_value(change_addresses[0], change_amount) out.is_change = True return out class Heirs(dict, Logger): def __init__(self, db: "WalletDB"): Logger.__init__(self) self.db = db d = self.db.get("heirs", {}) try: self.update(d) except e as Exception: return def invalidate_transactions(self, wallet): invalidate_inheritance_transactions(wallet) def save(self): self.db.put("heirs", dict(self)) def import_file(self, path): data = read_json_file(path) data = Heirs._validate(data) self.update(data) self.save() def export_file(self, path): write_json_file(path, self) def __setitem__(self, key, value): dict.__setitem__(self, key, value) self.save() def pop(self, key): if key in self.keys(): res = dict.pop(self, key) self.save() return res def get_locktimes(self, from_locktime, a=False): locktimes = {} for key in self.keys(): locktime = Util.parse_locktime_string(self[key][HEIR_LOCKTIME]) if locktime > from_locktime and not a or locktime <= from_locktime and a: locktimes[int(locktime)] = None return 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 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 except Exception as e: raise e return amount def amount_to_float(self, amount): try: return float(amount) except: try: return float(amount[:-1]) except: 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 for key in self.keys(): try: cmp = ( Util.parse_locktime_string(self[key][HEIR_LOCKTIME]) - from_locktime ) if cmp <= 0: 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]))) if heir_amount > dust_threshold: fixed_amount += heir_amount fixed_heirs[key] = list(self[key]) fixed_heirs[key].insert(HEIR_REAL_AMOUNT, heir_amount) else: pass except Exception as e: _logger.error(e) return fixed_heirs, fixed_amount, percent_heirs, percent_amount def prepare_lists( self, balance, total_fees, wallet, willexecutor=False, from_locktime=0 ): willexecutors_amount = 0 willexecutors = {} heir_list = {} onlyfixed = False newbalance = balance - total_fees locktimes = self.get_locktimes(from_locktime) if willexecutor: for locktime in locktimes: if int(Util.int_locktime(locktime)) > int(from_locktime): try: base_fee = int(willexecutor["base_fee"]) willexecutors_amount += base_fee h = [None] * 4 h[HEIR_AMOUNT] = base_fee h[HEIR_REAL_AMOUNT] = base_fee h[HEIR_LOCKTIME] = locktime h[HEIR_ADDRESS] = willexecutor["address"] willexecutors[ 'w!ll3x3c"' + willexecutor["url"] + '"' + str(locktime) ] = h except Exception as e: 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, wallet, real=True ) newbalance -= fixed_amount heir_list.update(fixed_heirs) heir_list = sorted( heir_list.items(), key=lambda item: Util.parse_locktime_string(item[1][HEIR_LOCKTIME], wallet), ) locktimes = {} for key, value in heir_list: locktime = Util.parse_locktime_string(value[HEIR_LOCKTIME]) if not locktime 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: 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: 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): continue else: willexecutor["url"] = url elif j == -1: if not no_willexecutors: continue url = willexecutor = False else: break fees = {} i = 0 while True: txs = {} redo = False i += 1 total_fees = 0 for fee in fees: total_fees += int(fees[fee]) newbalance = balance locktimes, onlyfixed = self.prepare_lists( balance, total_fees, wallet, willexecutor, from_locktime ) try: txs = prepare_transactions( locktimes, available_utxos[:], fees, wallet ) if not txs: return {} except Exception as e: try: if "w!ll3x3c" in e.heirname: Willexecutors.is_selected(willexecutors[w], False) break except: 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 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): 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) try: Heirs.validate_heir(k, v) except Exception as e: data.pop(k) return data class NotAnAddress(ValueError): pass class AmountNotValid(ValueError): pass class LocktimeNotValid(ValueError): pass class HeirExpiredException(LocktimeNotValid): pass