import re import json from typing import Optional, Tuple, Dict, Any, TYPE_CHECKING,Sequence,List import dns import threading import math from dns.exception import DNSException from electrum import bitcoin,dnssec,descriptor,constants from electrum.util import read_json_file, write_json_file, to_string,bfh,trigger_callback from electrum.logging import Logger, get_logger from electrum.transaction import PartialTxInput, PartialTxOutput,TxOutpoint,PartialTransaction,TxOutput import datetime import urllib.request import urllib.parse from .bal import BalPlugin from . import util as Util from . import willexecutors as Willexecutors if TYPE_CHECKING: from .wallet_db import WalletDB from .simple_config import SimpleConfig _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) 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.config.get_decimal_point() no_willexecutors = bal_plugin.config_get(BalPlugin.NO_WILLEXECUTOR) 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: if Util.is_perc(amount): famount = float(amount[:-1]) else: famount = 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: locktime = Util.parse_locktime_string(locktime,None) if timestamp_to_check: if locktime < 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