From bdcf1929f55680bea1bde8db75ea23e708c69184 Mon Sep 17 00:00:00 2001 From: bitcoinafterlife Date: Sun, 23 Mar 2025 13:53:10 -0400 Subject: [PATCH] init --- LICENSE | 21 + README.md | 2 + __init__.py | 20 + bal.py | 131 +++++ bal_resources.py | 15 + balqt/amountedit.py | 89 ++++ balqt/baldialog.py | 119 +++++ balqt/closedialog.py | 384 ++++++++++++++ balqt/heir_list.py | 283 +++++++++++ balqt/locktimeedit.py | 255 ++++++++++ balqt/preview_dialog.py | 331 +++++++++++++ balqt/qt_resources.py | 16 + balqt/willdetail.py | 199 ++++++++ balqt/willexecutor_dialog.py | 292 +++++++++++ heirs.py | 609 +++++++++++++++++++++++ icons/bal16x16.png | Bin 0 -> 538 bytes icons/bal32x32.png | Bin 0 -> 1128 bytes icons/heir.png | Bin 0 -> 871 bytes icons/will.png | Bin 0 -> 831 bytes qt.py | 937 +++++++++++++++++++++++++++++++++++ util.py | 458 +++++++++++++++++ will.py | 802 ++++++++++++++++++++++++++++++ willexecutors.py | 203 ++++++++ 23 files changed, 5166 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 bal.py create mode 100644 bal_resources.py create mode 100644 balqt/amountedit.py create mode 100644 balqt/baldialog.py create mode 100644 balqt/closedialog.py create mode 100644 balqt/heir_list.py create mode 100644 balqt/locktimeedit.py create mode 100644 balqt/preview_dialog.py create mode 100644 balqt/qt_resources.py create mode 100644 balqt/willdetail.py create mode 100644 balqt/willexecutor_dialog.py create mode 100644 heirs.py create mode 100644 icons/bal16x16.png create mode 100644 icons/bal32x32.png create mode 100644 icons/heir.png create mode 100644 icons/will.png create mode 100644 qt.py create mode 100644 util.py create mode 100644 will.py create mode 100644 willexecutors.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c9bc88f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 copronista + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5896ad7 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# BalPlugin +Bitcoin After Life Electrum Plugin diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9f2815e --- /dev/null +++ b/__init__.py @@ -0,0 +1,20 @@ +from electrum.i18n import _ +import subprocess +from . import bal_resources +BUILD_NUMBER = 0 +REVISION_NUMBER = 1 +VERSION_NUMBER = 0 +def _version(): + return f'{VERSION_NUMBER}.{REVISION_NUMBER}-{BUILD_NUMBER}' + +version = _version() +author = "Bal Enterprise inc." +fullname = _('B.A.L.') +description = ''.join([ + "", _("Bitcoin After Life"), '
', + _("For more information, visit"), + " https://bitcoin-after.life/
", + "

Version: ", _version(),"

" +]) +#available_for = ['qt', 'cmdline', 'qml'] +available_for = ['qt'] diff --git a/bal.py b/bal.py new file mode 100644 index 0000000..0ec92b7 --- /dev/null +++ b/bal.py @@ -0,0 +1,131 @@ +import random +import os +from hashlib import sha256 +from typing import NamedTuple, Optional, Dict, Tuple + +from electrum.plugin import BasePlugin +from electrum.util import to_bytes, bfh +from electrum import json_db +from electrum.transaction import tx_from_any + +from . import util as Util +from . import willexecutors as Willexecutors +import os +json_db.register_dict('heirs', tuple, None) +json_db.register_dict('will', lambda x: get_will(x), None) +json_db.register_dict('will_settings', lambda x:x, None) +from electrum.logging import get_logger +def get_will(x): + try: + #print("______________________________________________________________________________________________________") + #print(x) + x['tx']=tx_from_any(x['tx']) + except Exception as e: + #Util.print_var(x) + raise e + + return x + + + +class BalPlugin(BasePlugin): + LOCKTIME_TIME = "bal_locktime_time" + LOCKTIME_BLOCKS = "bal_locktime_blocks" + LOCKTIMEDELTA_TIME = "bal_locktimedelta_time" + LOCKTIMEDELTA_BLOCKS = "bal_locktimedelta_blocks" + TX_FEES = "bal_tx_fees" + BROADCAST = "bal_broadcast" + ASK_BROADCAST = "bal_ask_broadcast" + INVALIDATE = "bal_invalidate" + ASK_INVALIDATE = "bal_ask_invalidate" + PREVIEW = "bal_preview" + SAVE_TXS = "bal_save_txs" + WILLEXECUTORS = "bal_willexecutors" + PING_WILLEXECUTORS = "bal_ping_willexecutors" + ASK_PING_WILLEXECUTORS = "bal_ask_ping_willexecutors" + NO_WILLEXECUTOR = "bal_no_willexecutor" + HIDE_REPLACED = "bal_hide_replaced" + HIDE_INVALIDATED = "bal_hide_invalidated" + ALLOW_REPUSH = "bal_allow_repush" + + + + DEFAULT_SETTINGS={ + LOCKTIME_TIME: 90, + LOCKTIME_BLOCKS: 144*90, + LOCKTIMEDELTA_TIME: 7, + LOCKTIMEDELTA_BLOCKS:144*7, + TX_FEES: 100, + BROADCAST: True, + ASK_BROADCAST: True, + INVALIDATE: True, + ASK_INVALIDATE: True, + PREVIEW: True, + SAVE_TXS: True, + PING_WILLEXECUTORS: False, + ASK_PING_WILLEXECUTORS: False, + NO_WILLEXECUTOR: False, + HIDE_REPLACED:True, + HIDE_INVALIDATED:True, + ALLOW_REPUSH: False, + WILLEXECUTORS: { + 'http://bitcoin-after.life:9137': { + "base_fee": 100000, + "status": "New", + "info":"Bitcoin After Life Will Executor", + "address":"bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7" + } + }, + } + + LATEST_VERSION = '1' + KNOWN_VERSIONS = ('0', '1') + assert LATEST_VERSION in KNOWN_VERSIONS + + SIZE = (159, 97) + + def __init__(self, parent, config, name): + self.logger= get_logger(__name__) + BasePlugin.__init__(self, parent, config, name) + self.base_dir = os.path.join(config.electrum_path(), 'bal') + self.logger.info(self.base_dir) + self.parent = parent + self.config = config + self.name = name + self._hide_invalidated= self.config_get(self.HIDE_INVALIDATED) + self._hide_replaced= self.config_get(self.HIDE_REPLACED) + self.plugin_dir = os.path.split(os.path.realpath(__file__))[0] + + def resource_path(self,*parts): + return os.path.join(self.plugin_dir, *parts) + + def config_get(self,key): + v = self.config.get(key,None) + if v is None: + self.config.set_key(key,self.DEFAULT_SETTINGS[key],save=True) + v = self.DEFAULT_SETTINGS[key] + return v + + def hide_invalidated(self): + self._hide_invalidated = not self._hide_invalidated + self.config.set_key(BalPlugin.HIDE_INVALIDATED,self.hide_invalidated,save=True) + + def hide_replaced(self): + self._hide_replaced = not self._hide_replaced + self.config.set_key(BalPlugin.HIDE_REPLACED,self.hide_invalidated,save=True) + + def default_will_settings(self): + return { + 'tx_fees':100, + 'threshold':'180d', + 'locktime':'1y', + } + def validate_will_settings(self,will_settings): + if int(will_settings.get('tx_fees',1))<1: + will_settings['tx_fees']=1 + if not will_settings.get('threshold'): + will_settings['threshold']='180d' + if not will_settings.get('locktime')=='': + will_settings['locktime']='1y' + return will_settings + diff --git a/bal_resources.py b/bal_resources.py new file mode 100644 index 0000000..aac569a --- /dev/null +++ b/bal_resources.py @@ -0,0 +1,15 @@ +import os + +PLUGIN_DIR = os.path.split(os.path.realpath(__file__))[0] +DEFAULT_ICON = 'bal32x32.png' +DEFAULT_ICON_PATH = 'icons' + + +def icon_path(icon_basename: str = DEFAULT_ICON): + path = resource_path(DEFAULT_ICON_PATH,icon_basename) + return path + +def resource_path(*parts): + return os.path.join(PLUGIN_DIR, *parts) + + diff --git a/balqt/amountedit.py b/balqt/amountedit.py new file mode 100644 index 0000000..cc77ba6 --- /dev/null +++ b/balqt/amountedit.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- + +from typing import Union +from decimal import Decimal + +from . import qt_resources +if qt_resources.QT_VERSION == 5: + from PyQt5.QtWidgets import (QLineEdit, QStyle, QStyleOptionFrame, QSizePolicy) + from PyQt5.QtGui import QPalette, QPainter + from PyQt5.QtCore import pyqtSignal, Qt, QSize +else: + from PyQt6.QtWidgets import (QLineEdit, QStyle, QStyleOptionFrame, QSizePolicy) + from PyQt6.QtGui import QPalette, QPainter + from PyQt6.QtCore import pyqtSignal, Qt, QSize + + +from electrum.util import (format_satoshis_plain, decimal_point_to_base_unit_name, + FEERATE_PRECISION, quantize_feerate, DECIMAL_POINT, UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE) + +from electrum.gui.qt.amountedit import BTCAmountEdit, char_width_in_lineedit, ColorScheme + +_NOT_GIVEN = object() # sentinel value + + +class PercAmountEdit(BTCAmountEdit): + def __init__(self, decimal_point, is_int=False, parent=None, *, max_amount=_NOT_GIVEN): + super().__init__(decimal_point, is_int, parent, max_amount=max_amount) + + def numbify(self): + text = self.text().strip() + if text == '!': + self.shortcut.emit() + return + pos = self.cursorPosition() + chars = '0123456789%' + chars += DECIMAL_POINT + + s = ''.join([i for i in text if i in chars]) + + if '%' in s: + self.is_perc=True + s=s.replace('%','') + else: + self.is_perc=False + + if DECIMAL_POINT in s: + p = s.find(DECIMAL_POINT) + s = s.replace(DECIMAL_POINT, '') + s = s[:p] + DECIMAL_POINT + s[p:p+8] + if self.is_perc: + s+='%' + + + #if self.max_amount: + # if (amt := self._get_amount_from_text(s)) and amt >= self.max_amount: + # s = self._get_text_from_amount(self.max_amount) + self.setText(s) + # setText sets Modified to False. Instead we want to remember + # if updates were because of user modification. + self.setModified(self.hasFocus()) + self.setCursorPosition(pos) + #if len(s>0) + # self.drawText("") + + def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]: + try: + text = text.replace(DECIMAL_POINT, '.') + text = text.replace('%', '') + return (Decimal)(text) + except Exception: + return None + + def _get_text_from_amount(self, amount): + out = super()._get_text_from_amount(amount) + if self.is_perc: out+='%' + return out + + def paintEvent(self, event): + QLineEdit.paintEvent(self, event) + if self.base_unit: + panel = QStyleOptionFrame() + self.initStyleOption(panel) + textRect = self.style().subElementRect(QStyle.SubElement.SE_LineEditContents, panel, self) + textRect.adjust(2, 0, -10, 0) + painter = QPainter(self) + painter.setPen(ColorScheme.GRAY.as_color()) + if len(self.text())==0: + painter.drawText(textRect, int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter), self.base_unit() + " or perc value") + diff --git a/balqt/baldialog.py b/balqt/baldialog.py new file mode 100644 index 0000000..eeadd2a --- /dev/null +++ b/balqt/baldialog.py @@ -0,0 +1,119 @@ +from typing import Callable,Any + +from . import qt_resources +if qt_resources.QT_VERSION == 5: + from PyQt5.QtCore import Qt,pyqtSignal + from PyQt5.QtWidgets import QLabel, QVBoxLayout, QCheckBox +else: + from PyQt6.QtCore import Qt,pyqtSignal + from PyQt6.QtWidgets import QLabel, QVBoxLayout, QCheckBox + +from electrum.gui.qt.util import WindowModalDialog, TaskThread +from electrum.i18n import _ +from electrum.logging import get_logger + +_logger = get_logger(__name__) + +class BalDialog(WindowModalDialog): + + def __init__(self,parent,title=None, icon = 'bal32x32.png'): + self.parent=parent + WindowModalDialog.__init__(self,self.parent,title) + self.setWindowIcon(qt_resources.read_QIcon(icon)) + +class BalWaitingDialog(BalDialog): + updatemessage=pyqtSignal([str], arguments=['message']) + def __init__(self, bal_window: 'BalWindow', message: str, task, on_success=None, on_error=None, on_cancel=None,exe=True): + assert bal_window + BalDialog.__init__(self, bal_window.window, _("Please wait")) + self.message_label = QLabel(message) + vbox = QVBoxLayout(self) + vbox.addWidget(self.message_label) + self.updatemessage.connect(self.update_message) + if on_cancel: + self.cancel_button = CancelButton(self) + self.cancel_button.clicked.connect(on_cancel) + vbox.addLayout(Buttons(self.cancel_button)) + self.accepted.connect(self.on_accepted) + self.task=task + self.on_success = on_success + self.on_error = on_error + self.on_cancel = on_cancel + if exe: + self.exe() + + def exe(self): + self.thread = TaskThread(self) + self.thread.finished.connect(self.deleteLater) # see #3956 + self.thread.finished.connect(self.finished) + self.thread.add(self.task, self.on_success, self.accept, self.on_error) + self.exec() + + def hello(self): + pass + def finished(self): + _logger.info("finished") + def wait(self): + self.thread.wait() + + def on_accepted(self): + self.thread.stop() + def update_message(self,msg): + self.message_label.setText(msg) + + def update(self, msg): + self.updatemessage.emit(msg) + + def getText(self): + return self.message_label.text() + + def closeEvent(self,event): + self.thread.stop() + + + +class BalBlockingWaitingDialog(BalDialog): + def __init__(self, bal_window: 'BalWindow', message: str, task: Callable[[], Any]): + BalDialog.__init__(self, bal_window, _("Please wait")) + self.message_label = QLabel(message) + vbox = QVBoxLayout(self) + vbox.addWidget(self.message_label) + self.finished.connect(self.deleteLater) # see #3956 + # show popup + self.show() + # refresh GUI; needed for popup to appear and for message_label to get drawn + QCoreApplication.processEvents() + QCoreApplication.processEvents() + try: + # block and run given task + task() + finally: + # close popup + self.accept() + +class bal_checkbox(QCheckBox): + def __init__(self, plugin,variable,window=None): + QCheckBox.__init__(self) + self.setChecked(plugin.config_get(variable)) + window=window + def on_check(v): + plugin.config.set_key(variable, v == Qt.CheckState.Checked, save=True) + if window: + plugin._hide_invalidated= plugin.config_get(plugin.HIDE_INVALIDATED) + plugin._hide_replaced= plugin.config_get(plugin.HIDE_REPLACED) + + window.update_all() + self.stateChanged.connect(on_check) + + #TODO IMPLEMENT PREVIEW DIALOG + #tx list display txid, willexecutor, qrcode, button to sign + # :def preview_dialog(self, txs): + def preview_dialog(self, txs): + w=PreviewDialog(self,txs) + w.exec() + return w + def add_info_from_will(self,tx): + for input in tx.inputs(): + pass + + diff --git a/balqt/closedialog.py b/balqt/closedialog.py new file mode 100644 index 0000000..ebd356e --- /dev/null +++ b/balqt/closedialog.py @@ -0,0 +1,384 @@ +from .baldialog import BalDialog +from . import qt_resources + +if qt_resources.QT_VERSION == 5: + from PyQt5.QtCore import Qt + from PyQt5.QtWidgets import QLabel, QVBoxLayout, QCheckBox,QWidget + from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject,QEventLoop +else: + from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject,QEventLoop + from PyQt6.QtCore import Qt + from PyQt6.QtWidgets import QLabel, QVBoxLayout, QCheckBox, QWidget +import time +from electrum.i18n import _ +from electrum.gui.qt.util import WindowModalDialog, TaskThread +from electrum.network import Network,TxBroadcastError, BestEffortRequestFailed +from electrum.logging import get_logger + + +from functools import partial +import copy + +from .. import util as Util +from .. import will as Will + +from .. import willexecutors as Willexecutors +_logger = get_logger(__name__) + + +class BalCloseDialog(BalDialog): + updatemessage=pyqtSignal() + def __init__(self,bal_window): + BalDialog.__init__(self,bal_window.window,"Closing BAL") + self.updatemessage.connect(self.update) + self.bal_window=bal_window + self.message_label = QLabel("Closing BAL:") + self.vbox = QVBoxLayout(self) + self.vbox.addWidget(self.message_label) + self.qwidget=QWidget() + self.vbox.addWidget(self.qwidget) + self.labels=[] + #self.checking.connect(self.msg_set_checking) + #self.invalidating.connect(self.msg_set_invalidating) + #self.building.connect(self.msg_set_building) + #self.signing.connect(self.msg_set_signing) + #self.pushing.connect(self.msg_set_pushing) + + #self.askpassword.connect(self.ask_password) + #self.passworddone.connect(self.password_done) + self.check_row = None + self.inval_row = None + self.build_row = None + self.sign_row = None + self.push_row = None + self.network = Network.get_instance() + self._stopping = False + self.thread = TaskThread(self) + self.thread.finished.connect(self.task_finished) # see #3956 + def task_finished(self): + pass + #_logger.trace("task finished") + + def close_plugin_task(self): + _logger.debug("close task to be started") + self.thread.add(self.task_phase1,on_success=self.on_success_phase1,on_done=self.on_accept,on_error=self.on_error_phase1) + self.show() + self.exec() + + def task_phase1(self): + _logger.debug("close plugin phase 1 started") + try: + self.bal_window.init_class_variables() + except Will.NoHeirsException: + return False, None + self.msg_set_status("checking variables","Waiting") + try: + Will.check_amounts(self.bal_window.heirs,self.bal_window.willexecutors,self.bal_window.window.wallet.get_utxos(),self.bal_window.date_to_check,self.bal_window.window.wallet.dust_threshold()) + except Will.AmountException: + self.msg_edit_row(''+_("In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts"+"")) + #self.bal_window.show_warning(_("In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts"),parent=self) + + self.msg_set_checking() + have_to_build=False + try: + self.bal_window.check_will() + self.msg_set_checking('Ok') + except Will.WillExpiredException as e: + self.msg_set_checking("Expired") + fee_per_byte=self.bal_window.will_settings.get('tx_fees',1) + return None, Will.invalidate_will(self.bal_window.willitems,self.bal_window.wallet,fee_per_byte) + except Will.NoHeirsException: + self.msg_set_checking("No Heirs") + except Will.NotCompleteWillException as e: + message = False + have_to_build=True + if isinstance(e,Will.HeirChangeException): + message ="Heirs changed:" + elif isinstance(e,Will.WillExecutorNotPresent): + message = "Will-Executor not present" + elif isinstance(e,Will.WillexecutorChangeException): + message = "Will-Executor changed" + elif isinstance(e,Will.TxFeesChangedException): + message = "Txfees are changed" + elif isinstance(e,Will.HeirNotFoundException): + message = "Heir not found" + if message: + self.msg_set_checking(message) + else: + self.msg_set_checking("New") + + if have_to_build: + self.msg_set_building() + try: + self.bal_window.build_will() + self.bal_window.check_will() + for wid in Will.only_valid(self.bal_window.willitems): + self.bal_window.wallet.set_label(wid,"BAL Transaction") + self.msg_set_building("Ok") + except Exception as e: + self.msg_set_building(self.msg_error(e)) + return False,None + have_to_sign = False + for wid in Will.only_valid(self.bal_window.willitems): + if not self.bal_window.willitems[wid].get_status("COMPLETE"): + have_to_sign = True + break + return have_to_sign, None + + def on_accept(self): + pass + + def on_accept_phase2(self): + pass + + def on_error_push(self): + pass + + def wait(self,secs): + wait_row=None + for i in range(secs,0,-1): + if self._stopping: + return + wait_row = self.msg_edit_row(f"Please wait {i}secs", wait_row) + time.sleep(1) + self.msg_del_row(wait_row) + + def loop_broadcast_invalidating(self,tx): + self.msg_set_invalidating("Broadcasting") + try: + tx.add_info_from_wallet(self.bal_window.wallet) + self.network.run_from_another_thread(tx.add_info_from_network(self.network)) + txid = self.network.run_from_another_thread(self.network.broadcast_transaction(tx,timeout=120),timeout=120) + self.msg_set_invalidating("Ok") + if not txid: + _logger.debug(f"should not be none txid: {txid}") + + + except TxBroadcastError as e: + _logger.error(e) + msg = e.get_message_for_gui() + self.msg_set_invalidating(self.msg_error(msg)) + except BestEffortRequestFailed as e: + self.msg_set_invalidating(self.msg_error(e)) + # self.loop_broadcast_invalidating(tx) + + def loop_push(self): + self.msg_set_pushing("Broadcasting") + retry = False + try: + willexecutors=Willexecutors.get_willexecutor_transactions(self.bal_window.willitems) + for url,willexecutor in willexecutors.items(): + try: + if Willexecutors.is_selected(self.bal_window.willexecutors.get(url)): + _logger.debug(f"{url}: {willexecutor}") + if not Willexecutors.push_transactions_to_willexecutor(willexecutor): + for wid in willexecutor['txsids']: + self.bal_window.willitems[wid].set_status('PUSH_FAIL',True) + retry=True + else: + for wid in willexecutor['txsids']: + self.bal_window.willitems[wid].set_status('PUSHED',True) + except Willexecutors.AlreadyPresentException: + for wid in willexecutor['txsids']: + row = self.msg_edit_row("checking {} - {} : {}".format(self.bal_window.willitems[wid].we['url'],wid, "Waiting")) + self.bal_window.willitems[wid].check_willexecutor() + row = self.msg_edit_row("checked {} - {} : {}".format(self.bal_window.willitems[wid].we['url'],wid,self.bal_window.willitems[wid].get_status("CHECKED" )),row) + + + except Exception as e: + + _logger.error(e) + raise e + if retry: + raise Exception("retry") + + except Exception as e: + self.msg_set_pushing(self.msg_error(e)) + self.wait(10) + if not self._stopping: + self.loop_push() + + + def invalidate_task(self,tx,password): + _logger.debug(f"invalidate tx: {tx}") + tx = self.bal_window.wallet.sign_transaction(tx,password) + try: + if tx: + if tx.is_complete(): + self.loop_broadcast_invalidating(tx) + self.wait(5) + else: + raise + else: + raise + except Exception as e: + self.msg_set_invalidating("Error") + raise Exception("Impossible to sign") + def on_success_invalidate(self,success): + self.thread.add(self.task_phase1,on_success=self.on_success_phase1,on_done=self.on_accept,on_error=self.on_error_phase1) + def on_error(self,error): + _logger.error(error) + pass + def on_success_phase1(self,result): + self.have_to_sign,tx = list(result) + #if have_to_sign is False and tx is None: + #self._stopping=True + #self.on_success_phase2() + # return + print("have to sign",self.have_to_sign) + password=None + if self.have_to_sign is None: + self.msg_set_invalidating() + #need to sign invalidate and restart phase 1 + + password = self.bal_window.get_wallet_password("Invalidate your old will",parent=self.bal_window.window) + if password is False: + self.msg_set_invalidating("Aborted") + self.wait(3) + self.close() + return + self.thread.add(partial(self.invalidate_task,tx,password),on_success=self.on_success_invalidate, on_done=self.on_accept, on_error=self.on_error) + + return + + elif self.have_to_sign: + password = self.bal_window.get_wallet_password("Sign your will",parent=self.bal_window.window) + if password is False: + self.msg_set_signing('Aborted') + else: + self.msg_set_signing('Nothing to do') + self.thread.add(partial(self.task_phase2,password),on_success=self.on_success_phase2,on_done=self.on_accept_phase2,on_error=self.on_error_phase2) + return + + def on_success_phase2(self,arg=False): + self.thread.stop() + self.bal_window.save_willitems() + self.msg_edit_row("Finished") + self.close() + + def closeEvent(self,event): + self._stopping=True + self.thread.stop() + + def task_phase2(self,password): + if self.have_to_sign: + try: + if txs:=self.bal_window.sign_transactions(password): + for txid,tx in txs.items(): + self.bal_window.willitems[txid].tx = copy.deepcopy(tx) + self.bal_window.save_willitems() + self.msg_set_signing("Ok") + except Exception as e: + self.msg_set_signing(self.msg_error(e)) + + self.msg_set_pushing() + have_to_push = False + for wid in Will.only_valid(self.bal_window.willitems): + w=self.bal_window.willitems[wid] + if w.we and w.get_status("COMPLETE") and not w.get_status("PUSHED"): + have_to_push = True + if not have_to_push: + self.msg_set_pushing("Nothing to do") + else: + try: + self.loop_push() + self.msg_set_pushing("Ok") + + except Exception as e: + self.msg_set_pushing(self.msg_error(e)) + self.msg_edit_row("Ok") + self.wait(5) + + def on_error_phase1(self,error): + _logger.error(f"error phase1: {error}") + + def on_error_phase2(self,error): + _logger.error("error phase2: { error}") + + + def msg_set_checking(self, status = None, row = None): + row = self.check_row if row is None else row + self.check_row = self.msg_set_status("Checking your will", row, status) + + def msg_set_invalidating(self, status = None, row = None): + row = self.inval_row if row is None else row + self.inval_row = self.msg_set_status("Invalidating old will", self.inval_row, status) + + def msg_set_building(self, status = None, row = None): + row = self.build_row if row is None else row + self.build_row = self.msg_set_status("Building your will", self.build_row, status) + + def msg_set_signing(self, status = None, row = None): + row = self.sign_row if row is None else row + self.sign_row = self.msg_set_status("Signing your will", self.sign_row, status) + + def msg_set_pushing(self, status = None, row = None): + row = self.push_row if row is None else row + self.push_row = self.msg_set_status("Broadcasting your will to executors", self.push_row, status) + + def msg_set_waiting(self, status = None, row = None): + row = self.wait_row if row is None else row + self.wait_row = self.msg_edit_row(f"Please wait {status}secs", self.wait_row) + + def msg_error(self,e): + return "Error: {}".format(e) + + def msg_set_status(self,msg,row,status=None): + status= "Wait" if status is None else status + line="{}:\t{}".format(_(msg), status) + return self.msg_edit_row(line,row) + + #return v$msg_edit_row("{}:\t{}".format(_(msg), status), row) + + def ask_password(self,msg=None): + self.password=self.bal_window.get_wallet_password(msg,parent=self) + + def msg_edit_row(self,line,row=None): + _logger.debug(f"{row},{line}") + + #msg=self.get_text() + #rows=msg.split("\n") + #try: + # rows[row]=line + #except Exception as e: + # rows.append(line) + #row=len(rows)-1 + #self.update("\n".join(rows)) + + #return row + + #def msg_edit_label(self,line,row=None): + #_logger.trace(f"{row},{line}") + + #msg=self.get_text() + #rows=msg.split("\n") + try: + self.labels[row]=line + except Exception as e: + self.labels.append(line) + row=len(self.labels)-1 + + self.updatemessage.emit() + + return row + + def msg_del_row(self,row): + #_logger.trace(f"del row: {row}") + try: + del self.labels[row] + except Exception as e: + pass + self.updatemessage.emit() + + def update(self): + self.vbox.removeWidget(self.qwidget) + self.qwidget=QWidget(self) + labelsbox = QVBoxLayout(self.qwidget) + for label in self.labels: + labelsbox.addWidget(QLabel(label)) + self.vbox.addWidget(self.qwidget) + + def get_text(self): + return self.message_label.text() +def ThreadStopped(Exception): + pass diff --git a/balqt/heir_list.py b/balqt/heir_list.py new file mode 100644 index 0000000..cb2fa50 --- /dev/null +++ b/balqt/heir_list.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2015 Thomas Voegtlin +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import enum +from typing import TYPE_CHECKING +from datetime import datetime + +from . import qt_resources +if qt_resources.QT_VERSION == 5: + from PyQt5.QtGui import QStandardItemModel, QStandardItem + from PyQt5.QtCore import Qt, QPersistentModelIndex, QModelIndex + from PyQt5.QtWidgets import (QAbstractItemView, QMenu,QWidget,QHBoxLayout,QLabel,QSpinBox,QPushButton) +else: + from PyQt6.QtGui import QStandardItemModel, QStandardItem + from PyQt6.QtCore import Qt, QPersistentModelIndex, QModelIndex + from PyQt6.QtWidgets import (QAbstractItemView, QMenu,QWidget,QHBoxLayout,QLabel,QSpinBox,QPushButton) + +from electrum.i18n import _ +from electrum.bitcoin import is_address +from electrum.util import block_explorer_URL +from electrum.plugin import run_hook +from electrum.gui.qt.util import webopen, MessageBoxMixin,HelpButton +from electrum.gui.qt.my_treeview import MyTreeView, MySortModel + +from .. import util as Util +from .locktimeedit import HeirsLockTimeEdit +if TYPE_CHECKING: + from electrum.gui.qt.main_window import ElectrumWindow + + +class HeirList(MyTreeView,MessageBoxMixin): + + class Columns(MyTreeView.BaseColumnsEnum): + NAME = enum.auto() + ADDRESS = enum.auto() + AMOUNT = enum.auto() + + headers = { + Columns.NAME: _('Name'), + Columns.ADDRESS: _('Address'), + Columns.AMOUNT: _('Amount'), + #Columns.LOCKTIME:_('LockTime'), + } + filter_columns = [Columns.NAME, Columns.ADDRESS] + + ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1000 + + ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 1001 + key_role = ROLE_HEIR_KEY + + def __init__(self, bal_window: 'BalWindow'): + super().__init__( + parent=bal_window.window, + main_window=bal_window.window, + stretch_column=self.Columns.NAME, + editable_columns=[self.Columns.NAME,self.Columns.ADDRESS,self.Columns.AMOUNT], + ) + self.decimal_point = bal_window.bal_plugin.config.get_decimal_point() + self.bal_window = bal_window + + try: + self.setModel(QStandardItemModel(self)) + self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + except: + pass + #self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder) + #self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + + self.setSortingEnabled(True) + self.std_model = self.model() + + self.update() + + + def on_edited(self, idx, edit_key, *, text): + original = prior_name = self.bal_window.heirs.get(edit_key) + if not prior_name: + return + col = idx.column() + try: + #if col == 3: + # try: + # text = Util.str_to_locktime(text) + # except: + # print("not a valid locktime") + # pass + if col == 2: + text = Util.encode_amount(text,self.decimal_point) + elif col == 0: + self.bal_window.delete_heirs([edit_key]) + edit_key = text + prior_name[col-1] = text + prior_name.insert(0,edit_key) + prior_name = tuple(prior_name) + except Exception as e: + #print("eccezione tupla",e) + prior_name = (edit_key,)+prior_name[:col-1]+(text,)+prior_name[col:] + #print("prior_name",prior_name,original) + + try: + self.bal_window.set_heir(prior_name) + #print("setheir") + except Exception as e: + pass + + #print("heir non valido ripristino l'originale",e) + try: + #print("setup_original",(edit_key,)+original) + self.bal_window.set_heir((edit_key,)+original) + except Exception as e: + #print("errore nellimpostare original",e,original) + self.update() + + def create_menu(self, position): + menu = QMenu() + idx = self.indexAt(position) + column = idx.column() or self.Columns.NAME + selected_keys = [] + for s_idx in self.selected_in_column(self.Columns.NAME): + #print(s_idx) + sel_key = self.model().itemFromIndex(s_idx).data(0) + selected_keys.append(sel_key) + if selected_keys and idx.isValid(): + column_title = self.model().horizontalHeaderItem(column).text() + column_data = '\n'.join(self.model().itemFromIndex(s_idx).text() + for s_idx in self.selected_in_column(column)) + menu.addAction(_("Copy {}").format(column_title), lambda: self.place_text_on_clipboard(column_data, title=column_title)) + if column in self.editable_columns: + item = self.model().itemFromIndex(idx) + if item.isEditable(): + # would not be editable if openalias + persistent = QPersistentModelIndex(idx) + menu.addAction(_("Edit {}").format(column_title), lambda p=persistent: self.edit(QModelIndex(p))) + menu.addAction(_("Delete"), lambda: self.bal_window.delete_heirs(selected_keys)) + menu.exec(self.viewport().mapToGlobal(position)) + + def update(self): + if self.maybe_defer_update(): + return + current_key = self.get_role_data_for_current_item(col=self.Columns.NAME, role=self.ROLE_HEIR_KEY) + self.model().clear() + self.update_headers(self.__class__.headers) + set_current = None + for key in sorted(self.bal_window.heirs.keys()): + heir = self.bal_window.heirs[key] + labels = [""] * len(self.Columns) + labels[self.Columns.NAME] = key + labels[self.Columns.ADDRESS] = heir[0] + labels[self.Columns.AMOUNT] = Util.decode_amount(heir[1],self.decimal_point) + #labels[self.Columns.LOCKTIME] = str(Util.locktime_to_str(heir[2])) + + items = [QStandardItem(x) for x in labels] + items[self.Columns.NAME].setEditable(True) + items[self.Columns.ADDRESS].setEditable(True) + items[self.Columns.AMOUNT].setEditable(True) + #items[self.Columns.LOCKTIME].setEditable(True) + items[self.Columns.NAME].setData(key, self.ROLE_HEIR_KEY+1) + items[self.Columns.ADDRESS].setData(key, self.ROLE_HEIR_KEY+2) + items[self.Columns.AMOUNT].setData(key, self.ROLE_HEIR_KEY+3) + #items[self.Columns.LOCKTIME].setData(key, self.ROLE_HEIR_KEY+4) + + self.model().insertRow(self.model().rowCount(), items) + + if key == current_key: + idx = self.model().index(row_count, self.Columns.NAME) + set_current = QPersistentModelIndex(idx) + self.set_current_idx(set_current) + # FIXME refresh loses sort order; so set "default" here: + self.filter() + run_hook('update_heirs_tab', self) + + def refresh_row(self, key, row): + # nothing to update here + pass + + def get_edit_key_from_coordinate(self, row, col): + #print("role_data",self.get_role_data_from_coordinate(row, col, role=self.ROLE_HEIR_KEY)) + #print(col) + return self.get_role_data_from_coordinate(row, col, role=self.ROLE_HEIR_KEY+col+1) + return col + + def create_toolbar(self, config): + toolbar, menu = self.create_toolbar_with_menu('') + menu.addAction(_("&New Heir"), self.bal_window.new_heir_dialog) + menu.addAction(_("Import"), self.bal_window.import_heirs) + menu.addAction(_("Export"), lambda: self.bal_window.export_heirs()) + #menu.addAction(_("Build Traonsactions"), self.build_transactions) + + self.heir_locktime = HeirsLockTimeEdit(self.window(),0) + def on_heir_locktime(): + if not self.heir_locktime.get_locktime(): + self.heir_locktime.set_locktime('1y') + self.bal_window.will_settings['locktime'] = self.heir_locktime.get_locktime() if self.heir_locktime.get_locktime() else "1y" + self.bal_window.bal_plugin.config.set_key('will_settings',self.bal_window.will_settings,save = True) + self.heir_locktime.valueEdited.connect(on_heir_locktime) + + self.heir_threshold = HeirsLockTimeEdit(self,0) + def on_heir_threshold(): + if not self.heir_threshold.get_locktime(): + self.heir_threshold.set_locktime('180d') + + self.bal_window.will_settings['threshold'] = self.heir_threshold.get_locktime() + self.bal_window.bal_plugin.config.set_key('will_settings',self.bal_window.will_settings,save = True) + self.heir_threshold.valueEdited.connect(on_heir_threshold) + + self.heir_tx_fees = QSpinBox() + self.heir_tx_fees.setMinimum(1) + self.heir_tx_fees.setMaximum(10000) + def on_heir_tx_fees(): + if not self.heir_tx_fees.value(): + self.heir_tx_fees.set_value(1) + self.bal_window.will_settings['tx_fees'] = self.heir_tx_fees.value() + self.bal_window.bal_plugin.config.set_key('will_settings',self.bal_window.will_settings,save = True) + self.heir_tx_fees.valueChanged.connect(on_heir_tx_fees) + + + self.heirs_widget = QWidget() + layout = QHBoxLayout() + self.heirs_widget.setLayout(layout) + + layout.addWidget(QLabel(_("Delivery Time:"))) + layout.addWidget(self.heir_locktime) + layout.addWidget(HelpButton(_("Locktime* to be used in the transaction\n" + +"if you choose Raw, you can insert various options based on suffix:\n" + #+" - b: number of blocks after current block(ex: 144b means tomorrow)\n" + +" - d: number of days after current day(ex: 1d means tomorrow)\n" + +" - y: number of years after currrent day(ex: 1y means one year from today)\n" + +"* locktime can be anticipated to update will\n"))) + + layout.addWidget(QLabel(" ")) + layout.addWidget(QLabel(_("Check Alive:"))) + layout.addWidget(self.heir_threshold) + layout.addWidget(HelpButton(_("Check to ask for invalidation.\n" + +"When less then this time is missing, ask to invalidate.\n" + +"If you fail to invalidate during this time, your transactions will be delivered to your heirs.\n" + +"if you choose Raw, you can insert various options based on suffix:\n" + #+" - b: number of blocks after current block(ex: 144b means tomorrow)\n" + +" - d: number of days after current day(ex: 1d means tomorrow).\n" + +" - y: number of years after currrent day(ex: 1y means one year from today).\n\n"))) + layout.addWidget(QLabel(" ")) + layout.addWidget(QLabel(_("Fees:"))) + layout.addWidget(self.heir_tx_fees) + layout.addWidget(HelpButton(_("Fee to be used in the transaction"))) + layout.addWidget(QLabel("sats/vbyte")) + layout.addWidget(QLabel(" ")) + newHeirButton = QPushButton(_("New Heir")) + newHeirButton.clicked.connect(self.bal_window.new_heir_dialog) + layout.addWidget(newHeirButton) + + toolbar.insertWidget(2, self.heirs_widget) + + return toolbar + def update_will_settings(self): + self.heir_threshold.set_locktime(self.bal_window.will_settings['threshold']) + self.heir_locktime.set_locktime(self.bal_window.will_settings['locktime']) + self.heir_tx_fees.setValue(int(self.bal_window.will_settings['tx_fees'])) + + def build_transactions(self): + will = self.bal_window.prepare_will() + diff --git a/balqt/locktimeedit.py b/balqt/locktimeedit.py new file mode 100644 index 0000000..cfc1ff2 --- /dev/null +++ b/balqt/locktimeedit.py @@ -0,0 +1,255 @@ +# Copyright (C) 2020 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file LICENCE or http://www.opensource.org/licenses/mit-license.php + +import time +from datetime import datetime +from typing import Optional, Any + +from . import qt_resources +if qt_resources.QT_VERSION == 5: + from PyQt5.QtCore import Qt, QDateTime, pyqtSignal + from PyQt5.QtGui import QPalette, QPainter + from PyQt5.QtWidgets import (QWidget, QLineEdit, QStyle, QStyleOptionFrame, QComboBox, QHBoxLayout, QDateTimeEdit) +else: + from PyQt6.QtCore import Qt, QDateTime, pyqtSignal + from PyQt6.QtGui import QPalette, QPainter + from PyQt6.QtWidgets import (QWidget, QLineEdit, QStyle, QStyleOptionFrame, QComboBox, QHBoxLayout, QDateTimeEdit) + +from electrum.i18n import _ +from electrum.bitcoin import NLOCKTIME_MIN, NLOCKTIME_MAX, NLOCKTIME_BLOCKHEIGHT_MAX + +from electrum.gui.qt.util import char_width_in_lineedit, ColorScheme + + +class HeirsLockTimeEdit(QWidget): + + valueEdited = pyqtSignal() + locktime_threshold = 50000000 + def __init__(self, parent=None,default_index = 1): + QWidget.__init__(self, parent) + + hbox = QHBoxLayout() + self.setLayout(hbox) + hbox.setContentsMargins(0, 0, 0, 0) + hbox.setSpacing(0) + + self.locktime_raw_e = LockTimeRawEdit(self,time_edit = self) + #self.locktime_height_e = LockTimeHeightEdit(self) + self.locktime_date_e = LockTimeDateEdit(self,time_edit = self) + #self.editors = [self.locktime_raw_e, self.locktime_height_e, self.locktime_date_e] + self.editors = [self.locktime_raw_e, self.locktime_date_e] + + self.combo = QComboBox() + #options = [_("Raw"), _("Block height"), _("Date")] + options = [_("Raw"),_("Date")] + self.option_index_to_editor_map = { + 0: self.locktime_raw_e, + #1: self.locktime_height_e, + 1: self.locktime_date_e, + #2: self.locktime_date_e, + } + self.combo.addItems(options) + + + self.editor = self.option_index_to_editor_map[default_index] + self.combo.currentIndexChanged.connect(self.on_current_index_changed) + self.combo.setCurrentIndex(default_index) + self.on_current_index_changed(default_index) + + hbox.addWidget(self.combo) + for w in self.editors: + hbox.addWidget(w) + hbox.addStretch(1) + + #self.locktime_height_e.textEdited.connect(self.valueEdited.emit) + self.locktime_raw_e.editingFinished.connect(self.valueEdited.emit) + self.locktime_date_e.dateTimeChanged.connect(self.valueEdited.emit) + self.combo.currentIndexChanged.connect(self.valueEdited.emit) + + def on_current_index_changed(self,i): + for w in self.editors: + w.setVisible(False) + w.setEnabled(False) + prev_locktime = self.editor.get_locktime() + self.editor = self.option_index_to_editor_map[i] + if self.editor.is_acceptable_locktime(prev_locktime): + self.editor.set_locktime(prev_locktime,force=True) + self.editor.setVisible(True) + self.editor.setEnabled(True) + + def get_locktime(self) -> Optional[str]: + return self.editor.get_locktime() + + def set_index(self,index): + self.combo.setCurrentIndex(index) + self.on_current_index_changed(index) + + def set_locktime(self, x: Any,force=True) -> None: + self.editor.set_locktime(x,force) + + +class _LockTimeEditor: + min_allowed_value = NLOCKTIME_MIN + max_allowed_value = NLOCKTIME_MAX + + def get_locktime(self) -> Optional[int]: + raise NotImplementedError() + + def set_locktime(self, x: Any,force=True) -> None: + raise NotImplementedError() + + @classmethod + def is_acceptable_locktime(cls, x: Any) -> bool: + if not x: # e.g. empty string + return True + try: + x = int(x) + except Exception as e: + return False + return cls.min_allowed_value <= x <= cls.max_allowed_value + + +class LockTimeRawEdit(QLineEdit, _LockTimeEditor): + + def __init__(self, parent=None,time_edit=None): + QLineEdit.__init__(self, parent) + self.setFixedWidth(14 * char_width_in_lineedit()) + self.textChanged.connect(self.numbify) + self.isdays = False + self.isyears = False + self.isblocks = False + self.time_edit=time_edit + + def replace_str(self,text): + return str(text).replace('d','').replace('y','').replace('b','') + + def checkbdy(self,s,pos,appendix): + try: + charpos = pos-1 + charpos = max(0,charpos) + charpos = min(len(s)-1,charpos) + if appendix == s[charpos]: + s=self.replace_str(s)+appendix + pos = charpos + except Exception as e: + pass + return pos, s + + def numbify(self): + text = self.text().strip() + #chars = '0123456789bdy' removed the option to choose locktime by block + chars = '0123456789dy' + pos = posx = self.cursorPosition() + pos = len(''.join([i for i in text[:pos] if i in chars])) + s = ''.join([i for i in text if i in chars]) + self.isdays = False + self.isyears = False + self.isblocks = False + + pos,s = self.checkbdy(s,pos,'d') + pos,s = self.checkbdy(s,pos,'y') + pos,s = self.checkbdy(s,pos,'b') + + if 'd' in s: self.isdays = True + if 'y' in s: self.isyears = True + if 'b' in s: self.isblocks = True + + + if self.isdays: s= self.replace_str(s) + 'd' + if self.isyears: s = self.replace_str(s) + 'y' + if self.isblocks: s= self.replace_str(s) + 'b' + + self.set_locktime(s,force=False) + # setText sets Modified to False. Instead we want to remember + # if updates were because of user modification. + self.setModified(self.hasFocus()) + self.setCursorPosition(pos) + + def get_locktime(self) -> Optional[str]: + try: + return str(self.text()) + except Exception as e: + return None + + def set_locktime(self, x: Any,force=True) -> None: + out = str(x) + if 'd' in out: + out = self.replace_str(x)+'d' + elif 'y' in out: + out = self.replace_str(x)+'y' + elif 'b' in out: + out = self.replace_str(x)+'b' + else: + try: + out = int(x) + except Exception as e: + self.setText('') + return + out = max(out, self.min_allowed_value) + out = min(out, self.max_allowed_value) + self.setText(str(out)) + #try: + # if self.time_edit and int(out)>self.time_edit.locktime_threshold and not force: + # self.time_edit.set_index(1) + #except: + # pass + +class LockTimeHeightEdit(LockTimeRawEdit): + max_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX + + def __init__(self, parent=None,time_edit=None): + LockTimeRawEdit.__init__(self, parent) + self.setFixedWidth(20 * char_width_in_lineedit()) + self.time_edit = time_edit + + def paintEvent(self, event): + super().paintEvent(event) + panel = QStyleOptionFrame() + self.initStyleOption(panel) + textRect = self.style().subElementRect(QStyle.SE_LineEditContents, panel, self) + textRect.adjust(2, 0, -10, 0) + painter = QPainter(self) + painter.setPen(ColorScheme.GRAY.as_color()) + painter.drawText(textRect, int(Qt.AlignRight | Qt.AlignVCenter), "height") + + +def get_max_allowed_timestamp() -> int: + ts = NLOCKTIME_MAX + # Test if this value is within the valid timestamp limits (which is platform-dependent). + # see #6170 + try: + datetime.fromtimestamp(ts) + except (OSError, OverflowError): + ts = 2 ** 31 - 1 # INT32_MAX + datetime.fromtimestamp(ts) # test if raises + return ts + + +class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor): + min_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX + 1 + max_allowed_value = get_max_allowed_timestamp() + + def __init__(self, parent=None,time_edit=None): + QDateTimeEdit.__init__(self, parent) + self.setMinimumDateTime(datetime.fromtimestamp(self.min_allowed_value)) + self.setMaximumDateTime(datetime.fromtimestamp(self.max_allowed_value)) + self.setDateTime(QDateTime.currentDateTime()) + self.time_edit = time_edit + + def get_locktime(self) -> Optional[int]: + dt = self.dateTime().toPyDateTime() + locktime = int(time.mktime(dt.timetuple())) + return locktime + + def set_locktime(self, x: Any,force = False) -> None: + if not self.is_acceptable_locktime(x): + self.setDateTime(QDateTime.currentDateTime()) + return + try: + x = int(x) + except Exception: + self.setDateTime(QDateTime.currentDateTime()) + return + dt = datetime.fromtimestamp(x) + self.setDateTime(dt) diff --git a/balqt/preview_dialog.py b/balqt/preview_dialog.py new file mode 100644 index 0000000..92a7688 --- /dev/null +++ b/balqt/preview_dialog.py @@ -0,0 +1,331 @@ + +import enum +import copy +import json +import urllib.request +import urllib.parse +from functools import partial + +from . import qt_resources +if qt_resources.QT_VERSION == 5: + from PyQt5.QtGui import QStandardItemModel, QStandardItem, QPalette, QColor + from PyQt5.QtCore import Qt,QPersistentModelIndex, QModelIndex + from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,QMenu,QAbstractItemView,QWidget) +else: + from PyQt6.QtGui import QStandardItemModel, QStandardItem, QPalette, QColor + from PyQt6.QtCore import Qt,QPersistentModelIndex, QModelIndex + from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,QMenu,QAbstractItemView,QWidget) + +from electrum.i18n import _ +from electrum.gui.qt.util import (Buttons,read_QIcon, import_meta_gui, export_meta_gui,MessageBoxMixin) +from electrum.util import write_json_file,read_json_file,FileImportFailed +from electrum.gui.qt.my_treeview import MyTreeView +from electrum.transaction import tx_from_any +from electrum.network import Network + + +from ..bal import BalPlugin +from .. import willexecutors as Willexecutors +from .. import util as Util +from .. import will as Will +from .baldialog import BalDialog + +class PreviewList(MyTreeView): + class Columns(MyTreeView.BaseColumnsEnum): + LOCKTIME = enum.auto() + TXID = enum.auto() + WILLEXECUTOR = enum.auto() + STATUS = enum.auto() + + headers = { + Columns.LOCKTIME: _('Locktime'), + Columns.TXID: _('Txid'), + Columns.WILLEXECUTOR: _('Will-Executor'), + Columns.STATUS: _('Status'), + } + + ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000 + key_role = ROLE_HEIR_KEY + + def __init__(self, parent: 'BalWindow',will): + super().__init__( + parent=parent.window, + stretch_column=self.Columns.TXID, + ) + self.decimal_point=parent.bal_plugin.config.get_decimal_point + self.setModel(QStandardItemModel(self)) + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + + + if not will is None: + self.will = will + else: + self.will = parent.willitems + + self.bal_window = parent + self.wallet=parent.window.wallet + self.setModel(QStandardItemModel(self)) + self.setSortingEnabled(True) + self.std_model = self.model() + self.config = parent.bal_plugin.config + self.bal_plugin=self.bal_window.bal_plugin + + self.update() + + def create_menu(self, position): + menu = QMenu() + idx = self.indexAt(position) + column = idx.column() or self.Columns.TXID + selected_keys = [] + for s_idx in self.selected_in_column(self.Columns.TXID): + sel_key = self.model().itemFromIndex(s_idx).data(0) + selected_keys.append(sel_key) + if selected_keys and idx.isValid(): + column_title = self.model().horizontalHeaderItem(column).text() + column_data = '\n'.join(self.model().itemFromIndex(s_idx).text() + for s_idx in self.selected_in_column(column)) + + menu.addAction(_("details").format(column_title), lambda: self.show_transaction(selected_keys)).setEnabled(len(selected_keys)<2) + menu.addAction(_("check ").format(column_title), lambda: self.check_transactions(selected_keys)) + + menu.addSeparator() + menu.addAction(_("delete").format(column_title), lambda: self.delete(selected_keys)) + + menu.exec(self.viewport().mapToGlobal(position)) + + def delete(self,selected_keys): + for key in selected_keys: + del self.will[key] + try: + del self.bal_window.willitems[key] + except: + pass + try: + del self.bal_window.will[key] + except: + pass + self.update() + + def check_transactions(self,selected_keys): + wout = {} + for k in selected_keys: + wout[k] = self.will[k] + if wout: + self.bal_window.check_transactions(wout) + self.update() + + def show_transaction(self,selected_keys): + for key in selected_keys: + self.bal_window.show_transaction(self.will[key].tx) + + self.update() + + def select(self,selected_keys): + self.selected += selected_keys + self.update() + + def deselect(self,selected_keys): + for key in selected_keys: + self.selected.remove(key) + self.update() + + def update_will(self,will): + self.will.update(will) + self.update() + + def update(self): + if self.will is None: + return + + current_key = self.get_role_data_for_current_item(col=self.Columns.TXID, role=self.ROLE_HEIR_KEY) + self.model().clear() + self.update_headers(self.__class__.headers) + + + + + set_current = None + for txid,bal_tx in self.will.items(): + if self.bal_window.bal_plugin._hide_replaced and bal_tx.get_status('REPLACED'): + continue + if self.bal_window.bal_plugin._hide_invalidated and bal_tx.get_status('INVALIDATED'): + continue + + + tx=bal_tx.tx + labels = [""] * len(self.Columns) + labels[self.Columns.LOCKTIME] = Util.locktime_to_str(tx.locktime) + labels[self.Columns.TXID] = txid + we = 'None' + if bal_tx.we: + we = bal_tx.we['url'] + labels[self.Columns.WILLEXECUTOR]=we + status = bal_tx.status + if len(bal_tx.status) > 53: + status = "...{}".format(status[-50:]) + labels[self.Columns.STATUS] = status + + + + items=[] + for e in labels: + if type(e)== list: + try: + items.append(QStandardItem(*e)) + except Exception as e: + pass + else: + items.append(QStandardItem(str(e))) + + #pal = QPalette() + #pal.setColor(QPalette.ColorRole.Window, QColor(bal_tx.get_color())) + #items[-1].setAutoFillBackground(True) + #items[-1o].setPalette(pal) + items[-1].setBackground(QColor(bal_tx.get_color())) + + + self.model().insertRow(self.model().rowCount(), items) + if txid == current_key: + idx = self.model().index(row_count, self.Columns.TXID) + set_current = QPersistentModelIndex(idx) + self.set_current_idx(set_current) + + def create_toolbar(self, config): + toolbar, menu = self.create_toolbar_with_menu('') + menu.addAction(_("Prepare"), self.build_transactions) + menu.addAction(_("Display"), self.bal_window.preview_modal_dialog) + menu.addAction(_("Sign"), self.ask_password_and_sign_transactions) + menu.addAction(_("Export"), self.export_will) + #menu.addAction(_("Import"), self.import_will) + menu.addAction(_("Broadcast"), self.broadcast) + menu.addAction(_("Check"), self.check) + menu.addAction(_("Invalidate"), self.invalidate_will) + prepareButton = QPushButton(_("Prepare")) + prepareButton.clicked.connect(self.build_transactions) + signButton = QPushButton(_("Sign")) + signButton.clicked.connect(self.ask_password_and_sign_transactions) + pushButton = QPushButton(_("Broadcast")) + pushButton.clicked.connect(self.broadcast) + displayButton = QPushButton(_("Display")) + displayButton.clicked.connect(self.bal_window.preview_modal_dialog) + hlayout = QHBoxLayout() + widget = QWidget() + hlayout.addWidget(prepareButton) + hlayout.addWidget(signButton) + hlayout.addWidget(pushButton) + hlayout.addWidget(displayButton) + widget.setLayout(hlayout) + toolbar.insertWidget(2,widget) + + return toolbar + + def hide_replaced(self): + self.bal_window.bal_plugin.hide_replaced() + self.update() + + def hide_invalidated(self): + f.bal_window.bal_plugin.hide_invalidated() + self.update() + + def build_transactions(self): + will = self.bal_window.prepare_will() + if will: + self.update_will(will) + + def export_json_file(self,path): + write_json_file(path, self.will) + + def export_will(self): + self.bal_window.export_will() + self.update() + + def import_will(self): + self.bal_window.import_will() + + def ask_password_and_sign_transactions(self): + self.bal_window.ask_password_and_sign_transactions(callback=self.update) + + def broadcast(self): + self.bal_window.broadcast_transactions() + self.update() + + def check(self): + self.bal_window.check_transactions(self.bal_window.willitems) + self.update() + + def invalidate_will(self): + self.bal_window.invalidate_will() + self.update() + +class PreviewDialog(BalDialog,MessageBoxMixin): + def __init__(self, bal_window, will): + self.parent = bal_window.window + BalDialog.__init__(self,bal_window = bal_window) + self.bal_plugin = bal_window.bal_plugin + self.gui_object = self.bal_plugin.gui_object + self.config = self.bal_plugin.config + self.bal_window = bal_window + self.wallet = bal_window.window.wallet + self.format_amount = bal_window.window.format_amount + self.base_unit = bal_window.window.base_unit + self.format_fiat_and_units = bal_window.window.format_fiat_and_units + self.fx = bal_window.window.fx + self.format_fee_rate = bal_window.window.format_fee_rate + self.show_address = bal_window.window.show_address + if not will: + self.will = bal_window.willitems + else: + self.will = will + self.setWindowTitle(_('Transactions Preview')) + self.setMinimumSize(1000, 200) + self.size_label = QLabel() + self.transactions_list = PreviewList(self.bal_window,self.will) + vbox = QVBoxLayout(self) + vbox.addWidget(self.size_label) + vbox.addWidget(self.transactions_list) + buttonbox = QHBoxLayout() + + b = QPushButton(_('Sign')) + b.clicked.connect(self.transactions_list.ask_password_and_sign_transactions) + buttonbox.addWidget(b) + + b = QPushButton(_('Export Will')) + b.clicked.connect(self.transactions_list.export_will) + buttonbox.addWidget(b) + + b = QPushButton(_('Broadcast')) + b.clicked.connect(self.transactions_list.broadcast) + buttonbox.addWidget(b) + + b = QPushButton(_('Invalidate will')) + b.clicked.connect(self.transactions_list.invalidate_will) + buttonbox.addWidget(b) + + vbox.addLayout(buttonbox) + + self.update() + + + def update_will(self,will): + self.will.update(will) + self.transactions_list.update_will(will) + self.update() + + def update(self): + self.transactions_list.update() + + def is_hidden(self): + return self.isMinimized() or self.isHidden() + + def show_or_hide(self): + if self.is_hidden(): + self.bring_to_top() + else: + self.hide() + + def bring_to_top(self): + self.show() + self.raise_() + + def closeEvent(self, event): + event.accept() diff --git a/balqt/qt_resources.py b/balqt/qt_resources.py new file mode 100644 index 0000000..5b05954 --- /dev/null +++ b/balqt/qt_resources.py @@ -0,0 +1,16 @@ +from .. import bal_resources +import sys +try: + QT_VERSION=sys._GUI_QT_VERSION +except: + QT_VERSION=6 +if QT_VERSION == 5: + from PyQt5.QtGui import QIcon,QPixmap +else: + from PyQt6.QtGui import QIcon,QPixmap + +def read_QIcon(icon_basename: str=bal_resources.DEFAULT_ICON) -> QIcon: + return QIcon(bal_resources.icon_path(icon_basename)) +def read_QPixmap(icon_basename: str=bal_resources.DEFAULT_ICON) -> QPixmap: + return QPixmap(bal_resources.icon_path(icon_basename)) + diff --git a/balqt/willdetail.py b/balqt/willdetail.py new file mode 100644 index 0000000..11bc42c --- /dev/null +++ b/balqt/willdetail.py @@ -0,0 +1,199 @@ +from functools import partial + +from . import qt_resources +if qt_resources.QT_VERSION == 5: + from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,QWidget,QScrollArea) + from PyQt5.QtGui import (QPixmap, QImage, QBitmap, QPainter, QFontDatabase, QPen, QFont, + QColor, QDesktopServices, qRgba, QPainterPath,QPalette) +else: + from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,QWidget,QScrollArea) + from PyQt6.QtGui import (QPixmap, QImage, QBitmap, QPainter, QFontDatabase, QPen, QFont, + QColor, QDesktopServices, qRgba, QPainterPath,QPalette) + + +from electrum.util import decimal_point_to_base_unit_name +from electrum.i18n import _ + +from ..bal import BalPlugin +from .. import will as Will +from .. import util as Util +from .baldialog import BalDialog + + + + + + +class WillDetailDialog(BalDialog): + + + def __init__(self, bal_window): + + self.will = bal_window.willitems + self.threshold = Util.parse_locktime_string(bal_window.will_settings['threshold']) + self.bal_window = bal_window + Will.add_willtree(self.will) + super().__init__(bal_window.window) + self.config = bal_window.window.config + self.wallet = bal_window.wallet + self.format_amount = bal_window.window.format_amount + self.base_unit = bal_window.window.base_unit + self.format_fiat_and_units = bal_window.window.format_fiat_and_units + self.fx = bal_window.window.fx + self.format_fee_rate = bal_window.window.format_fee_rate + self.decimal_point = bal_window.bal_plugin.config.get_decimal_point() + self.base_unit_name = decimal_point_to_base_unit_name(self.decimal_point) + self.setWindowTitle(_('Will Details')) + self.setMinimumSize(670,700) + self.vlayout= QVBoxLayout() + w=QWidget() + hlayout = QHBoxLayout(w) + + b = QPushButton(_('Sign')) + b.clicked.connect(self.ask_password_and_sign_transactions) + hlayout.addWidget(b) + + b = QPushButton(_('Broadcast')) + b.clicked.connect(self.broadcast_transactions) + hlayout.addWidget(b) + + b = QPushButton(_('Export')) + b.clicked.connect(self.export_will) + hlayout.addWidget(b) + """ + toggle = "Hide" + if self.bal_window.bal_plugin._hide_replaced: + toggle = "Unhide" + self.toggle_replace_button = QPushButton(_(f"{toggle} replaced")) + self.toggle_replace_button.clicked.connect(self.toggle_replaced) + hlayout.addWidget(self.toggle_replace_button) + + toggle = "Hide" + if self.bal_window.bal_plugin._hide_invalidated: + toggle = "Unhide" + + self.toggle_invalidate_button = QPushButton(_(f"{toggle} invalidated")) + self.toggle_invalidate_button.clicked.connect(self.toggle_invalidated) + hlayout.addWidget(self.toggle_invalidate_button) + """ + b = QPushButton(_('Invalidate')) + b.clicked.connect(bal_window.invalidate_will) + hlayout.addWidget(b) + self.vlayout.addWidget(w) + + self.paint_scroll_area() + #vlayout.addWidget(QLabel(_("DON'T PANIC !!! everything is fine, all possible futures are covered"))) + self.vlayout.addWidget(QLabel(_("Expiration date: ")+Util.locktime_to_str(self.threshold))) + self.vlayout.addWidget(self.scrollbox) + w=QWidget() + hlayout = QHBoxLayout(w) + hlayout.addWidget(QLabel(_("Valid Txs:")+ str(len(Will.only_valid_list(self.will))))) + hlayout.addWidget(QLabel(_("Total Txs:")+ str(len(self.will)))) + self.vlayout.addWidget(w) + self.setLayout(self.vlayout) + + def paint_scroll_area(self): + #self.scrollbox.deleteLater() + #self.willlayout.deleteLater() + #self.detailsWidget.deleteLater() + self.scrollbox = QScrollArea() + viewport = QWidget(self.scrollbox) + self.willlayout = QVBoxLayout(viewport) + self.detailsWidget = WillWidget(parent=self) + self.willlayout.addWidget(self.detailsWidget) + + self.scrollbox.setWidget(viewport) + viewport.setLayout(self.willlayout) + def ask_password_and_sign_transactions(self): + self.bal_window.ask_password_and_sign_transactions(callback=self.update) + self.update() + def broadcast_transactions(self): + self.bal_window.broadcast_transactions() + self.update() + def export_will(self): + self.bal_window.export_will() + def toggle_replaced(self): + self.bal_window.bal_plugin.hide_replaced() + toggle = _("Hide") + if self.bal_window.bal_plugin._hide_replaced: + toggle = _("Unhide") + self.toggle_replace_button.setText(f"{toggle} {_('replaced')}") + self.update() + + def toggle_invalidated(self): + self.bal_window.bal_plugin.hide_invalidated() + toggle = _("Hide") + if self.bal_window.bal_plugin._hide_invalidated: + toggle = _("Unhide") + self.toggle_invalidate_button.setText(_(f"{toggle} {_('invalidated')}")) + self.update() + + def update(self): + self.will = self.bal_window.willitems + pos = self.vlayout.indexOf(self.scrollbox) + self.vlayout.removeWidget(self.scrollbox) + self.paint_scroll_area() + self.vlayout.insertWidget(pos,self.scrollbox) + super().update() + +class WillWidget(QWidget): + def __init__(self,father=None,parent = None): + super().__init__() + vlayout = QVBoxLayout() + self.setLayout(vlayout) + self.will = parent.bal_window.willitems + self.parent = parent + for w in self.will: + if self.will[w].get_status('REPLACED') and self.parent.bal_window.bal_plugin._hide_replaced: + continue + if self.will[w].get_status('INVALIDATED') and self.parent.bal_window.bal_plugin._hide_invalidated: + continue + f = self.will[w].father + if father == f: + qwidget = QWidget() + childWidget = QWidget() + hlayout=QHBoxLayout(qwidget) + qwidget.setLayout(hlayout) + vlayout.addWidget(qwidget) + detailw=QWidget() + detaillayout=QVBoxLayout() + detailw.setLayout(detaillayout) + + willpushbutton = QPushButton(w) + + willpushbutton.clicked.connect(partial(self.parent.bal_window.show_transaction,txid=w)) + detaillayout.addWidget(willpushbutton) + locktime = Util.locktime_to_str(self.will[w].tx.locktime) + creation = Util.locktime_to_str(self.will[w].time) + def qlabel(title,value): + label = ""+_(str(title)) + f":\t{str(value)}" + return QLabel(label) + detaillayout.addWidget(qlabel("Locktime",locktime)) + detaillayout.addWidget(qlabel("Creation Time",creation)) + total_fees = self.will[w].tx.input_value() - self.will[w].tx.output_value() + decoded_fees = total_fees #Util.decode_amount(total_fees,self.parent.decimal_point) + fee_per_byte = round(total_fees/self.will[w].tx.estimated_size(),3) + fees_str = str(decoded_fees) + " ("+ str(fee_per_byte) + " sats/vbyte)" + detaillayout.addWidget(qlabel("Transaction fees:",fees_str)) + detaillayout.addWidget(qlabel("Status:",self.will[w].status)) + detaillayout.addWidget(QLabel("")) + detaillayout.addWidget(QLabel("Heirs:")) + for heir in self.will[w].heirs: + if "w!ll3x3c\"" not in heir: + decoded_amount = Util.decode_amount(self.will[w].heirs[heir][3],self.parent.decimal_point) + detaillayout.addWidget(qlabel(heir,f"{decoded_amount} {self.parent.base_unit_name}")) + if self.will[w].we: + detaillayout.addWidget(QLabel("")) + detaillayout.addWidget(QLabel(_("Willexecutor: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 diff --git a/icons/bal16x16.png b/icons/bal16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..cfd34e7f432cfa82368383ea312daffdf8bf8ab9 GIT binary patch literal 538 zcmV+#0_FXQP)LVu6 zi(7buCZrs$mchtKf)O z9}({&;z>m8tLXI_zj3i#3?5*6hnS0T6U#A*{z_~&>bRVlYNgUxMD$hIuEbhg!cwg+ zv~2=2Fqc-Je_>JawmrlB%-n(t*inhiE0~^kfydZ|iHK;HqycP;h*9+8deNo_-B^vW zcEu*fwFYAk8b#$L$}p9;cWVt=9e1!XGqr%p8Ym>*^T7SDY{J?8P!1(wf4jm_McE)2!|)T*G^O!6)2P cmZ;PJ0=%OC<;KN)IsgCw07*qoM6N<$g3~YTJpcdz literal 0 HcmV?d00001 diff --git a/icons/bal32x32.png b/icons/bal32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..58b1d7096fe219592b7d0f990ab74bec961cf75d GIT binary patch literal 1128 zcmV-u1eg1XP)H8p-qyH%#L9A#E{)zrJ?!+YY4Vf17#-I2Xhe|-UV0;7H{h9&@M+d|45)5b} zduBM#_`I?^o6)BtKN9btwVttrXG3`hMt9+eTJU}7?8dGNh&w`VM(pU%L7!63*mJ>r z9c|snf)`5;cVKJjdoiBE(fIx?#>=Wk9tg&I^yp?9PQ(u-rypZe%-58T=o|cq0~lB~ zZ~+cuZ!JJhq83cSYb79?A`qX!ulN-Eg17R7AdFRKTz@jo>%`Gm%*XS%4%^~rcEmAv zWC6y7^R1`qybhR0u>#ZU@V+j0N=*>_vysg zTb1<6io&z36TQ_j_Qw$nDe(*lLtDDAoi~K+)0h=~W6*AHqPM)ZIDCOQHC~INj>C4H zx6Ltrl5<|0b6$)0f;P2@-X2^W^SvdXT4P(_7f2~xlym;9fuU<+oSstZ8}l1u{GbVd z!yLbP_bY<_IDj4%;R$F2;I0_&j$Upfn*bQ9xU7g6s4-d;7Y+=o5mQR%G%$2~jB|3% zDd(INH|F11no_DRrVMUp%twU$aR9qxJ~_tqnB8PG)wWM@e0}RNQ%e2OGv|CPNbUHl z#&1;w$RV_+l-hF62U1EyQ%Wh<@}#3JiE&X%X4!ujI za}8#8!};hq^92>!L41jpMgS5%3J~jRg0Yng@iJzjR|CfvVj14Yj1qmI;t_HbQ>uEY z9Ox;fVTvL&2rpr7EwWQeLog3l2G~CokBouQ_XqG-YAKge8Wce|3HRolSE;r-E17`b zbv&y^)RF0l^AxpXWP=^KJc2IciH1kkKzXc=R6B&naaI>&U<}>}-J`g#seBVdZFo}0 zOZJH3jW-`xE6VEtj8a@u3luNmV?0v z(HBT>?5p`5I2p)SNcITwWnidMWngG%W?=aFA4tDsU???UV0e|lz+g3lfk8ZfQv6Xj z1_q|Po-U3d6}OW9{Qqx%*n^FM@yHQ_LyQg#y+0TjI2H)XGXOBix_se> zyGDufuD_cv{`jxCQC^|Jt%eJx%;XU>gF?fGsf*5`{u!It$v0Nbo$vJf;@{1p zGnqk(SQ%I@bnwG<-MGc*#d3>Lhvm#2*9CIt6&L)Mkg8{w+TYpm&mJiLeS#$eqsgOS zV8|i)phegIzVqYbf+w0^r0F<3)>pph85$TFx??ONzI5Ii@zOG9XMx2h_|L$E&Mor!N+8d7yEmTJ$HM2TJS=@ zUVi7X=dRB{9`R=cIS&+2ZZ*J&6Ij9HvG4-V7ekvbhWqVI{)L;^{jzZ|mx}LisLK<& z+x;jW6k3`v<&c12`pSIa_=OpsCW|F|*Eb&HUwWbU`Gp_AxH0DF@n6uq|AY({O-Vsvo7Rkv0=@d~ z>GmfUf0UoNND@m(MyyD1)0Dc;+MC~YY`W9q&kH`AYTTCDpdxChGqtapZ|gMO9qBg0|tgy2@DKYGZ+}e^C!h0 zbz@*)+Un`z7*cU7>CgZF_J=*#7#NQnF*wBNz|i}Hfq`Rzpgho?BTgQS%F2(eO%xA0 zzEc-A`4?H!#mJO#WI00vgVaYx1_1|UJD^D&tvt5T7k}(t`9`gb>C^s;KkiP6U<1jq zGO%3e;D;-IGKKN@#UH{Kf1Jvw(c`-NgQxeuSnqw$&W8+)CXa%FUPiH+*?9VbkG`5F z`ux52y|emQfo_`5!~nFN9i~ztY6@ev=gWR!#&rS{mS+SAD>S&(aKRjJ@`#y1p&=uX z?aPH5XB{*CEX}CNHu+~+lgb9NNCf71hD5jtS2bFnF&nRnIPUpM54XmcZJg!JKp9N6R%4TbIZAj*p)&{`m94kNF%fs}!Z= zQ*FWVgzkq$JTDv`&lU`@=kJ~Gvp}|>=fIC<-A1Eic_Vi0Au}cD2A_kx|Bi-Z?()J{ z)Dr%cnfzl<;lu8B3zvm;c_wvCj9*{8=2&{+M_xwFl+qq-A>${h;J@I%zvc($$N!ZN zxaW(%QC%RHTEmFN5fYqlW#t-bn_tv2=bO|??X%%!d7AO(YQ~@CCuBfrOcNM|ZZ*J! d4oOGM3_9~19^Z=Lxdu$^44$rjF6*2Ung9X&8fgFk literal 0 HcmV?d00001 diff --git a/qt.py b/qt.py new file mode 100644 index 0000000..da68c1e --- /dev/null +++ b/qt.py @@ -0,0 +1,937 @@ +''' + +Bal + +Bitcoin after life + + +''' + +import os +import random +import traceback +from functools import partial +import sys +import copy + +import sys + +from electrum.plugin import hook +from electrum.i18n import _ +from electrum.util import make_dir, InvalidPassword, UserCancelled,resource_path +from electrum.util import bfh, read_json_file,write_json_file,decimal_point_to_base_unit_name,FileImportFailed,FileExportFailed + +from electrum.gui.qt.util import (EnterButton, WWLabel, + Buttons, CloseButton, OkButton,import_meta_gui,export_meta_gui,char_width_in_lineedit,CancelButton,HelpButton) + +from electrum.gui.qt.qrtextedit import ScanQRTextEdit +from electrum.gui.qt.main_window import StatusBarButton +from electrum.gui.qt.password_dialog import PasswordDialog +from electrum.gui.qt.transaction_dialog import TxDialog +from electrum import constants +from electrum.transaction import Transaction +from .bal import BalPlugin +from .heirs import Heirs +from . import util as Util +from . import will as Will + +from .balqt.locktimeedit import HeirsLockTimeEdit +from .balqt.willexecutor_dialog import WillExecutorDialog +from .balqt.preview_dialog import PreviewDialog,PreviewList +from .balqt.heir_list import HeirList +from .balqt.amountedit import PercAmountEdit +from .balqt.willdetail import WillDetailDialog +from .balqt.closedialog import BalCloseDialog +from .balqt import qt_resources +from . import willexecutors as Willexecutors +from electrum.transaction import tx_from_any +import time +from electrum import json_db +from electrum.json_db import StoredDict +import datetime +import urllib.parse +import urllib.request +from typing import TYPE_CHECKING, Callable, Optional, List, Union, Tuple, Mapping +from .balqt.baldialog import BalDialog,BalWaitingDialog,BalBlockingWaitingDialog,bal_checkbox + +from electrum.logging import Logger +if qt_resources.QT_VERSION == 5: + from PyQt5.QtCore import Qt, QRectF, QRect, QSizeF, QUrl, QPoint, QSize + from PyQt5.QtGui import (QPixmap, QImage, QBitmap, QPainter, QFontDatabase, QPen, QFont,QIcon, + QColor, QDesktopServices, qRgba, QPainterPath,QPalette) + + from PyQt5.QtWidgets import (QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QLineEdit,QCheckBox,QSpinBox,QMenuBar,QMenu,QLineEdit,QScrollArea,QWidget,QSpacerItem,QSizePolicy) +else: + from PyQt6.QtCore import Qt, QRectF, QRect, QSizeF, QUrl, QPoint, QSize + from PyQt6.QtGui import (QPixmap, QImage, QBitmap, QPainter, QFontDatabase, QPen, QFont,QIcon, + QColor, QDesktopServices, qRgba, QPainterPath,QPalette) + + from PyQt6.QtWidgets import (QGridLayout, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QLineEdit,QCheckBox,QSpinBox,QMenuBar,QMenu,QLineEdit,QScrollArea,QWidget,QSpacerItem,QSizePolicy) + +class Plugin(BalPlugin,Logger): + + def __init__(self, parent, config, name): + Logger.__init__(self) + self.logger.info("INIT BALPLUGIN") + BalPlugin.__init__(self, parent, config, name) + self.bal_windows={} + + + @hook + def init_qt(self,gui_object): + self.logger.info("HOOK init qt") + try: + self.gui_object=gui_object + for window in gui_object.windows: + wallet = window.wallet + if wallet: + window.show_warning(_('Please restart Electrum to activate the BAL plugin'), title=_('Success')) + return + w = BalWindow(self,window) + self.bal_windows[window.winId]= w + for child in window.children(): + if isinstance(child,QMenuBar): + for menu_child in child.children(): + if isinstance(menu_child,QMenu): + try: + if menu_child.title()==_("&Tools"): + w.init_menubar_tools(menu_child) + + except Exception as e: + raise e + self.logger.error(("except:",menu_child.text())) + + except Exception as e: + raise e + self.logger.error("Error loading plugini {}".format(e)) + + + + @hook + def create_status_bar(self, sb): + self.logger.info("HOOK create status bar") + return + b = StatusBarButton(qt_resources.read_QIcon('bal32x32.png'), "Bal "+_("Bitcoin After Life"), + partial(self.setup_dialog, sb), sb.height()) + sb.addPermanentWidget(b) + + @hook + def init_menubar_tools(self,window,tools_menu): + self.logger.info("HOOK init_menubar") + w = self.get_window(window) + w.init_menubar_tools(tools_menu) + + @hook + def load_wallet(self,wallet, main_window): + self.logger.info("HOOK load wallet") + w = self.get_window(main_window) + w.wallet = wallet + w.init_will() + w.willexecutors = Willexecutors.get_willexecutors(self, update=False, bal_window=w) + w.disable_plugin = False + w.ok=True + @hook + def close_wallet(self,wallet): + for winid,win in self.bal_windows.items(): + if win.wallet == wallet: + win.on_close() + + def get_window(self,window): + w = self.bal_windows.get(window.winId,None) + if w is None: + w=BalWindow(self,window) + self.bal_windows[window.winId]=w + return w + + def requires_settings(self): + return True + + def settings_widget(self, window): + + w=self.get_window(window.window) + return EnterButton(_('Settings'), partial(w.settings_dialog,window)) + + def password_dialog(self, msg=None, parent=None): + parent = parent or self + d = PasswordDialog(parent, msg) + return d.run() + + def get_seed(self): + password = None + if self.wallet.has_keystore_encryption(): + password = self.password_dialog(parent=self.d.parent()) + if not password: + raise UserCancelled() + + keystore = self.wallet.get_keystore() + if not keystore or not keystore.has_seed(): + return + self.extension = bool(keystore.get_passphrase(password)) + return keystore.get_seed(password) + + +class shown_cv(): + _type= bool + def __init__(self,value): + self.value=value + def get(self): + return self.value + def set(self,value): + self.value=value + +class BalWindow(Logger): + def __init__(self,bal_plugin: 'BalPlugin',window: 'ElectrumWindow'): + Logger.__init__(self) + self.logger.info("loggo tutto") + self.bal_plugin = bal_plugin + self.window = window + self.heirs = {} + self.will = {} + self.willitems = {} + self.willexecutors = {} + self.will_settings = None + self.heirs_tab = self.create_heirs_tab() + self.will_tab = self.create_will_tab() + self.ok= False + self.disable_plugin = True + + if self.window.wallet: + self.wallet = self.window.wallet + self.heirs_tab.wallet = self.wallet + self.will_tab.wallet = self.wallet + + + def init_menubar_tools(self,tools_menu): + self.tools_menu=tools_menu + + def add_optional_tab(tabs, tab, icon, description): + tab.tab_icon = icon + tab.tab_description = description + tab.tab_pos = len(tabs) + if tab.is_shown_cv: + tabs.addTab(tab, icon, description.replace("&", "")) + + add_optional_tab(self.window.tabs, self.heirs_tab, qt_resources.read_QIcon("heir.png"), _("&Heirs")) + add_optional_tab(self.window.tabs, self.will_tab, qt_resources.read_QIcon("will.png"), _("&Will")) + tools_menu.addSeparator() + self.tools_menu.willexecutors_action = tools_menu.addAction(_("&Will-Executors"), self.show_willexecutor_dialog) + + def load_willitems(self): + self.willitems={} + for wid,w in self.will.items(): + self.willitems[wid]=Will.WillItem(w,wallet=self.wallet) + if self.willitems: + self.will_list.will=self.willitems + self.will_list.update_will(self.willitems) + self.will_tab.update() + + def save_willitems(self): + keys = list(self.will.keys()) + for k in keys: + del self.will[k] + for wid,w in self.willitems.items(): + self.will[wid]=w.to_dict() + + def init_will(self): + self.logger.info("********************init_____will____________**********") + if not self.willexecutors: + self.willexecutors = Willexecutors.get_willexecutors(self.bal_plugin, update=False, bal_window=self) + if not self.heirs: + self.heirs = Heirs._validate(Heirs(self.wallet.db)) + if not self.will: + self.will=self.wallet.db.get_dict("will") + if self.will: + self.willitems = {} + try: + self.load_willitems() + except: + self.disable_plugin=True + self.show_warning(_('Please restart Electrum to activate the BAL plugin'), title=_('Success')) + self.close_wallet() + return + + if not self.will_settings: + self.will_settings=self.wallet.db.get_dict("will_settings") + self.logger.info("will_settings: {}".format(self.will_settings)) + if not self.will_settings: + Util.copy(self.will_settings,self.bal_plugin.default_will_settings()) + self.logger.debug("not_will_settings {}".format(self.will_settings)) + + self.bal_plugin.validate_will_settings(self.will_settings) + self.heir_list.update_will_settings() + + def get_window_title(self,title): + return _('BAL - ') + _(title) + + def show_willexecutor_dialog(self): + self.willexecutor_dialog = WillExecutorDialog(self) + self.willexecutor_dialog.show() + + def create_heirs_tab(self): + self.heir_list = l = HeirList(self) + tab = self.window.create_list_tab(l) + tab.is_shown_cv = shown_cv(True) + return tab + + def create_will_tab(self): + self.will_list = l = PreviewList(self,None) + tab = self.window.create_list_tab(l) + tab.is_shown_cv = shown_cv(True) + return tab + + def new_heir_dialog(self): + d = BalDialog(self.window, self.get_window_title("New heir")) + vbox = QVBoxLayout(d) + grid = QGridLayout() + + heir_name = QLineEdit() + heir_name.setFixedWidth(32 * char_width_in_lineedit()) + heir_address = QLineEdit() + heir_address.setFixedWidth(32 * char_width_in_lineedit()) + heir_amount = PercAmountEdit(self.window.get_decimal_point) + heir_locktime = HeirsLockTimeEdit(self.window,0) + heir_is_xpub = QCheckBox() + + grid.addWidget(QLabel(_("Name")), 1, 0) + grid.addWidget(heir_name, 1, 1) + grid.addWidget(HelpButton("Unique name or description about heir"),1,2) + + grid.addWidget(QLabel(_("Address")), 2, 0) + grid.addWidget(heir_address, 2, 1) + grid.addWidget(HelpButton("heir bitcoin address"),2,2) + + #grid.addWidget(QLabel(_("xPub")), 2, 2) + grid.addWidget(QLabel(_("Amount")),3,0) + grid.addWidget(heir_amount,3,1) + grid.addWidget(HelpButton("Fixed or Percentage amount if end with %"),3,2) + + #grid.addWidget(QLabel(_("LockTime")), 4, 0) + #grid.addWidget(heir_locktime, 4, 1) + #grid.addWidget(HelpButton("if you choose Raw, you can insert various options based on suffix:\n " + # +" - b: number of blocks after current block(ex: 144b means tomorrow)\n" + # +" - d: number of days after current day(ex: 1d means tomorrow)\n" + # +" - y: number of years after currrent day(ex: 1y means one year from today)\n\n" + # +"when using d or y time will be set to 00:00 for privacy reasons\n" + # +"when used without suffix it can be used to indicate:\n" + # +" - exact block(if value is less than 500,000,000)\n" + # +" - exact block timestamp(if value greater than 500,000,000"),4,2) + + vbox.addLayout(grid) + vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) + while d.exec(): + #TODO SAVE HEIR + heir = [ + heir_name.text(), + heir_address.text(), + Util.encode_amount(heir_amount.text(),self.bal_plugin.config.get_decimal_point()), + str(heir_locktime.get_locktime()), + ] + try: + self.set_heir(heir) + break + except Exception as e: + self.show_error(str(e)) + + def export_inheritance_handler(self,path): + txs = self.build_inheritance_transaction(ignore_duplicate=True, keep_original=False) + with open(path,"w") as f: + for tx in txs: + tx['status']+="."+BalPlugin.STATUS_EXPORTED + f.write(str(tx['tx'])) + f.write('\n') + + def set_heir(self,heir): + heir=list(heir) + heir[3]=self.will_settings['locktime'] + + h=Heirs.validate_heir(heir[0],heir[1:]) + self.heirs[heir[0]]=h + self.heir_list.update() + return True + + def delete_heirs(self,heirs): + for heir in heirs: + del self.heirs[heir] + self.heirs.save() + self.heir_list.update() + return True + + def import_heirs(self,): + import_meta_gui(self.window, _('heirs'), self.heirs.import_file, self.heir_list.update) + + def export_heirs(self): + Util.export_meta_gui(self.window, _('heirs'), self.heirs.export_file) + + def prepare_will(self, ignore_duplicate = False, keep_original = False): + will = self.build_inheritance_transaction(ignore_duplicate = ignore_duplicate, keep_original=keep_original) + return will + + def delete_not_valid(self,txid,s_utxo): + raise NotImplementedError() + + def update_will(self,will): + Will.update_will(self.willitems,will) + self.willitems.update(will) + Will.normalize_will(self.willitems,self.wallet) + + def build_will(self, ignore_duplicate = True, keep_original = True ): + + will = {} + willtodelete=[] + willtoappend={} + try: + self.init_class_variables() + self.willexecutors = Willexecutors.get_willexecutors(self.bal_plugin, update=False, bal_window=self) + + if not self.no_willexecutor: + f=False + for u,w in self.willexecutors.items(): + if Willexecutors.is_selected(w): + f=True + if not f: + raise Will.NoWillExecutorNotPresent("No Will-Executor or backup transaction selected") + txs = self.heirs.get_transactions(self.bal_plugin,self.window.wallet,self.will_settings['tx_fees'],None,self.date_to_check) + self.logger.info(txs) + creation_time = time.time() + if txs: + for txid in txs: + txtodelete=[] + _break = False + tx = {} + tx['tx'] = txs[txid] + tx['my_locktime'] = txs[txid].my_locktime + tx['heirsvalue'] = txs[txid].heirsvalue + tx['description'] = txs[txid].description + tx['willexecutor'] = copy.deepcopy(txs[txid].willexecutor) + tx['status'] = _("New") + tx['tx_fees'] = txs[txid].tx_fees + tx['time'] = creation_time + tx['heirs'] = copy.deepcopy(txs[txid].heirs) + tx['txchildren'] = [] + will[txid]=Will.WillItem(tx,_id=txid,wallet=self.wallet) + self.update_will(will) + except Exception as e: + raise e + pass + return self.willitems + + def check_will(self): + return Will.is_will_valid(self.willitems, self.block_to_check, self.date_to_check, self.will_settings['tx_fees'],self.window.wallet.get_utxos(),heirs=self.heirs,willexecutors=self.willexecutors ,self_willexecutor=self.no_willexecutor, wallet = self.wallet, callback_not_valid_tx=self.delete_not_valid) + def show_message(self,text): + self.window.show_message(text) + def show_warning(self,text,parent =None): + self.window.show_warning(text, parent= None) + def show_error(self,text): + self.window.show_error(text) + def show_critical(self,text): + self.window.show_critical(text) + + + + def init_heirs_to_locktime(self): + for heir in self.heirs: + h=self.heirs[heir] + self.heirs[heir]=[h[0],h[1],self.will_settings['locktime']] + + + def init_class_variables(self): + if not self.heirs: + raise Will.NoHeirsException() + return + try: + self.date_to_check = Util.parse_locktime_string(self.will_settings['threshold']) + found = False + self.locktime_blocks=self.bal_plugin.config_get(BalPlugin.LOCKTIME_BLOCKS) + self.current_block = Util.get_current_height(self.wallet.network) + self.block_to_check = self.current_block + self.locktime_blocks + self.no_willexecutor = self.bal_plugin.config_get(BalPlugin.NO_WILLEXECUTOR) + self.willexecutors = Willexecutors.get_willexecutors(self.bal_plugin,update=True,bal_window=self,task=False) + self.init_heirs_to_locktime() + + except Exception as e: + self.logger.error(e) + raise e + + def build_inheritance_transaction(self,ignore_duplicate = True, keep_original = True): + try: + if self.disable_plugin: + self.logger.info("plugin is disabled") + return + if not self.heirs: + self.logger.warning("not heirs {}".format(self.heirs)) + return + self.init_class_variables() + try: + Will.check_amounts(self.heirs,self.willexecutors,self.window.wallet.get_utxos(),self.date_to_check,self.window.wallet.dust_threshold()) + except Will.AmountException as e: + self.show_warning(_(f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.\n{e}")) + locktime = Util.parse_locktime_string(self.will_settings['locktime']) + if locktime < self.date_to_check: + self.show_error(_("locktime is lower than threshold")) + return + if not self.no_willexecutor: + f=False + for k,we in self.willexecutors.items(): + if Willexecutors.is_selected(we): + f=True + if not f: + self.show_error(_(" no backup transaction or willexecutor selected")) + return + + try: + self.check_will() + except Will.WillExpiredException as e: + self.invalidate_will() + return + except Will.NoHeirsException: + return + except Will.NotCompleteWillException as e: + self.logger.info("{}:{}".format(type(e),e)) + message = False + if isinstance(e,Will.HeirChangeException): + message ="Heirs changed:" + elif isinstance(e,Will.WillExecutorNotPresent): + message = "Will-Executor not present:" + elif isinstance(e,Will.WillexecutorChangeException): + message = "Will-Executor changed" + elif isinstance(e,Will.TxFeesChangedException): + message = "Txfees are changed" + elif isinstance(e,Will.HeirNotFoundException): + message = "Heir not found" + + if message: + self.show_message(f"{_(message)}:\n {e}\n{_('will have to be built')}") + + self.logger.info("build will") + self.build_will(ignore_duplicate,keep_original) + + try: + self.check_will() + for wid,w in self.willitems.items(): + self.wallet.set_label(wid,"BAL Transaction") + except Will.WillExpiredException as e: + self.invalidate_will() + except Will.NotCompleteWillException as e: + self.show_error("Error:{}\n {}".format(str(e),_("Please, check your heirs, locktime and threshold!"))) + + self.window.history_list.update() + self.window.utxo_list.update() + self.update_all() + return self.willitems + except Exception as e: + raise e + def show_transaction_real( + self, + tx: Transaction, + *, + parent: 'ElectrumWindow', + prompt_if_unsaved: bool = False, + external_keypairs: Mapping[bytes, bytes] = None, + payment_identifier: 'PaymentIdentifier' = None, + ): + try: + d = TxDialog( + tx, + parent=parent, + prompt_if_unsaved=prompt_if_unsaved, + external_keypairs=external_keypairs, + payment_identifier=payment_identifier, + ) + d.setWindowIcon(qt_resources.read_QIcon("bal32x32.png")) + except SerializationError as e: + self.logger.error('unable to deserialize the transaction') + parent.show_critical(_("Electrum was unable to deserialize the transaction:") + "\n" + str(e)) + else: + d.show() + return d + + def show_transaction(self,tx=None,txid=None,parent = None): + if not parent: + parent = self.window + if txid !=None and txid in self.willitems: + tx=self.willitems[txid].tx + if not tx: + raise Exception(_("no tx")) + return self.show_transaction_real(tx,parent=parent) + + def invalidate_will(self): + def on_success(result): + if result: + self.show_message(_("Please sign and broadcast this transaction to invalidate current will")) + self.wallet.set_label(result.txid(),"BAL Invalidate") + a=self.show_transaction(result) + else: + self.show_message(_("No transactions to invalidate")) + def on_failure(exec_info): + self.show_error(f"ERROR:{exec_info}") + + fee_per_byte=self.will_settings.get('tx_fees',1) + task = partial(Will.invalidate_will,self.willitems,self.wallet,fee_per_byte) + msg = _("Calculating Transactions") + self.waiting_dialog = BalWaitingDialog(self, msg, task, on_success, on_failure,exe=False) + self.waiting_dialog.exe() + + def sign_transactions(self,password): + try: + txs={} + signed = None + tosign = None + def get_message(): + msg = "" + if signed: + msg=_(f"signed: {signed}\n") + return msg + _(f"signing: {tosign}") + for txid in Will.only_valid(self.willitems): + wi = self.willitems[txid] + tx = copy.deepcopy(wi.tx) + if wi.get_status('COMPLETE'): + txs[txid]=tx + continue + tosign=txid + try: + self.waiting_dialog.update(get_message()) + except:pass + for txin in tx.inputs(): + prevout = txin.prevout.to_json() + if prevout[0] in self.willitems: + change = self.willitems[prevout[0]].tx.outputs()[prevout[1]] + txin._trusted_value_sats = change.value + try: + txin.script_descriptor = change.script_descriptor + except: + pass + txin.is_mine=True + txin._TxInput__address=change.address + txin._TxInput__scriptpubkey = change.scriptpubkey + txin._TxInput__value_sats = change.value + + self.wallet.sign_transaction(tx, password,ignore_warnings=True) + signed=tosign + is_complete=False + if tx.is_complete(): + is_complete=True + wi.set_status('COMPLETE',True) + txs[txid]=tx + except Exception as e: + return None + return txs + + def get_wallet_password(self,message=None,parent=None): + parent =self.window if not parent else parent + password = None + if self.wallet.has_keystore_encryption(): + password = self.bal_plugin.password_dialog(parent=parent,msg=message) + if password is None: + return False + try: + self.wallet.check_password(password) + except Exception as e: + self.show_error(str(e)) + password = self.get_wallet_password(message) + return password + + + def on_close(self): + try: + if not self.disable_plugin: + close_window=BalCloseDialog(self) + close_window.close_plugin_task() + self.save_willitems() + self.heirs_tab.close() + self.will_tab.close() + self.tools_menu.removeAction(self.tools_menu.willexecutors_action) + self.window.toggle_tab(self.heirs_tab) + self.window.toggle_tab(self.will_tab) + self.window.tabs.update() + except Exception as e: + pass + + def ask_password_and_sign_transactions(self,callback=None): + def on_success(txs): + if txs: + for txid,tx in txs.items(): + self.willitems[txid].tx=copy.deepcopy(tx) + self.will[txid]=self.willitems[txid].to_dict() + try: + self.will_list.update() + except: + pass + if callback: + try: + callback() + except Exception as e: + raise e + + def on_failure(exc_info): + self.logger.info("sign fail: {}".format(exc_info)) + self.show_error(exc_info) + password= self.get_wallet_password() + task = partial(self.sign_transactions,password) + msg = _('Signing transactions...') + self.waiting_dialog = BalWaitingDialog(self, msg, task, on_success, on_failure,exe=False) + self.waiting_dialog.exe() + + def broadcast_transactions(self,force=False): + def on_success(sulcess): + self.will_list.update() + if sulcess: + self.logger.info("error, some transaction was not sent"); + self.show_warning(_("Some transaction was not broadcasted")) + return + self.logger.debug("OK, sulcess transaction was sent") + self.show_message(_("All transactions are broadcasted to respective Will-Executors")) + + def on_failure(err): + self.logger.error(err) + + task = partial(self.push_transactions_to_willexecutors,force) + msg = _('Selecting Will-Executors') + self.waiting_dialog = BalWaitingDialog(self,msg,task,on_success,on_failure,exe=False) + self.waiting_dialog.exe() + + def push_transactions_to_willexecutors(self,force=False): + willexecutors = Willexecutors.get_willexecutor_transactions(self.willitems) + def getMsg(willexecutors): + msg = "Broadcasting Transactions to Will-Executors:\n" + for url in willexecutors: + msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n" + return msg + error=False + for url in willexecutors: + willexecutor = willexecutors[url] + self.waiting_dialog.update(getMsg(willexecutors)) + if 'txs' in willexecutor: + try: + if Willexecutors.push_transactions_to_willexecutor(willexecutors[url]): + for wid in willexecutors[url]['txsids']: + self.willitems[wid].set_status('PUSHED', True) + willexecutors[url]['broadcast_stauts'] = _("Success") + else: + for wid in willexecutors[url]['txsids']: + self.willitems[wid].set_status('PUSH_FAIL', True) + error=True + willexecutors[url]['broadcast_stauts'] = _("Failed") + del willexecutor['txs'] + except Willexecutors.AlreadyPresentException: + for wid in willexecutor['txsids']: + row = self.waiting_dialog.update("checking {} - {} : {}".format(self.willitems[wid].we['url'],wid, "Waiting")) + self.willitems[wid].check_willexecutor() + row = self.waiting_dialog.update("checked {} - {} : {}".format(self.willitems[wid].we['url'],wid,self.willitems[wid].get_status("CHECKED" ))) + + if error: + return True + + + def export_json_file(self,path): + for wid in self.willitems: + self.willitems[wid].set_status('EXPORTED', True) + self.will[wid]=self.willitems[wid].to_dict() + write_json_file(path, self.will) + + def export_will(self): + try: + Util.export_meta_gui(self.window, _('will.json'), self.export_json_file) + except Exception as e: + self.show_error(str(e)) + raise e + + def import_will(self): + def sulcess(): + self.will_list.update_will(self.willitems) + import_meta_gui(self.window, _('will'), self.import_json_file,sulcess) + + def import_json_file(self,path): + try: + data = read_json_file(path) + willitems = {} + for k,v in data.items(): + data[k]['tx']=tx_from_any(v['tx']) + willitems[k]=Will.WillItem(data[k],_id=k) + self.update_will(willitems) + except Exception as e: + raise e + raise FileImportFailed(_("Invalid will file")) + + def check_transactions_task(self,will): + start = time.time() + for wid,w in will.items(): + if w.we: + self.waiting_dialog.update("checking transaction: {}\n willexecutor: {}".format(wid,w.we['url'])) + w.check_willexecutor() + + if time.time()-start < 3: + time.sleep(3-(time.time()-start)) + + def check_transactions(self,will): + def on_success(result): + del self.waiting_dialog + self.update_all() + pass + def on_failure(e): + self.logger.error(f"error checking transactions {e}") + pass + + task = partial(self.check_transactions_task,will) + msg = _('Check Transaction') + self.waiting_dialog = BalWaitingDialog(self,msg,task,on_success,on_failure,exe=False) + self.waiting_dialog.exe() + + def ping_willexecutors_task(self,wes): + self.logger.info("ping willexecutots task") + pinged = [] + failed = [] + def get_title(): + msg = _('Ping Will-Executors:') + msg += "\n\n" + for url in wes: + urlstr = "{:<50}: ".format(url[:50]) + if url in pinged: + urlstr += "Ok" + elif url in failed: + urlstr +="Ko" + else: + urlstr += "--" + urlstr+="\n" + msg+=urlstr + + return msg + for url,we in wes.items(): + try: + self.waiting_dialog.update(get_title()) + except: + pass + wes[url]=Willexecutors.get_info_task(url,we) + if wes[url]['status']=='KO': + failed.append(url) + else: + pinged.append(url) + + def ping_willexecutors(self,wes): + def on_success(result): + del self.waiting_dialog + try: + self.willexecutor_dialog.willexecutor_list.update() + except Exception as e: + _logger.error("error updating willexecutors {e}") + pass + def on_failure(e): + self.logger.error(e) + pass + self.logger.info("ping willexecutors") + task = partial(self.ping_willexecutors_task,wes) + msg = _('Ping Will-Executors') + self.waiting_dialog = BalWaitingDialog(self,msg,task,on_success,on_failure,exe=False) + self.waiting_dialog.exe() + + def preview_modal_dialog(self): + self.dw=WillDetailDialog(self) + self.dw.show() + + def settings_dialog(self,window): + d = BalDialog(window, self.get_window_title("Settings")) + d.setMinimumSize(100, 200) + qicon=qt_resources.read_QPixmap("bal32x32.png") + lbl_logo = QLabel() + lbl_logo.setPixmap(qicon) + + heir_locktime_time = QSpinBox() + heir_locktime_time.setMinimum(0) + heir_locktime_time.setMaximum(3650) + heir_locktime_time.setValue(int(self.bal_plugin.config_get(BalPlugin.LOCKTIME_TIME))) + def on_heir_locktime_time(): + value = heir_locktime_time.value() + self.bal_plugin.config.set_key(BalPlugin.LOCKTIME_TIME,value,save=True) + heir_locktime_time.valueChanged.connect(on_heir_locktime_time) + + heir_locktimedelta_time = QSpinBox() + heir_locktimedelta_time.setMinimum(0) + heir_locktimedelta_time.setMaximum(3650) + heir_locktimedelta_time.setValue(int(self.bal_plugin.config_get(BalPlugin.LOCKTIMEDELTA_TIME))) + def on_heir_locktime_time(): + value = heir_locktime_time.value + self.bal_plugin.config.set_key(BalPlugin.LOCKTIME_TIME,value,save=True) + heir_locktime_time.valueChanged.connect(on_heir_locktime_time) + + heir_locktime_blocks = QSpinBox() + heir_locktime_blocks.setMinimum(0) + heir_locktime_blocks.setMaximum(144*3650) + heir_locktime_blocks.setValue(int(self.bal_plugin.config_get(BalPlugin.LOCKTIME_BLOCKS))) + def on_heir_locktime_blocks(): + value = heir_locktime_blocks.value() + self.bal_plugin.config.set_key(BalPlugin.LOCKTIME_BLOCKS,value,save=True) + heir_locktime_blocks.valueChanged.connect(on_heir_locktime_blocks) + + heir_locktimedelta_blocks = QSpinBox() + heir_locktimedelta_blocks.setMinimum(0) + heir_locktimedelta_blocks.setMaximum(144*3650) + heir_locktimedelta_blocks.setValue(int(self.bal_plugin.config_get(BalPlugin.LOCKTIMEDELTA_BLOCKS))) + def on_heir_locktimedelta_blocks(): + value = heir_locktimedelta_blocks.value() + self.bal_plugin.config.set_key(BalPlugin.LOCKTIMEDELTA_TIME,value,save=True) + heir_locktimedelta_blocks.valueChanged.connect(on_heir_locktimedelta_blocks) + + heir_tx_fees = QSpinBox() + heir_tx_fees.setMinimum(1) + heir_tx_fees.setMaximum(10000) + heir_tx_fees.setValue(int(self.bal_plugin.config_get(BalPlugin.TX_FEES))) + def on_heir_tx_fees(): + value = heir_tx_fees.value() + self.bal_plugin.config.set_key(BalPlugin.TX_FEES,value,save=True) + heir_tx_fees.valueChanged.connect(on_heir_tx_fees) + + heir_broadcast = bal_checkbox(self.bal_plugin, BalPlugin.BROADCAST) + heir_ask_broadcast = bal_checkbox(self.bal_plugin, BalPlugin.ASK_BROADCAST) + heir_invalidate = bal_checkbox(self.bal_plugin, BalPlugin.INVALIDATE) + heir_ask_invalidate = bal_checkbox(self.bal_plugin, BalPlugin.ASK_INVALIDATE) + heir_preview = bal_checkbox(self.bal_plugin, BalPlugin.PREVIEW) + heir_ping_willexecutors = bal_checkbox(self.bal_plugin, BalPlugin.PING_WILLEXECUTORS) + heir_ask_ping_willexecutors = bal_checkbox(self.bal_plugin, BalPlugin.ASK_PING_WILLEXECUTORS) + heir_no_willexecutor = bal_checkbox(self.bal_plugin, BalPlugin.NO_WILLEXECUTOR) + + + heir_hide_replaced = bal_checkbox(self.bal_plugin,BalPlugin.HIDE_REPLACED,self) + heir_hide_invalidated = bal_checkbox(self.bal_plugin,BalPlugin.HIDE_INVALIDATED,self) + heir_allow_repush = bal_checkbox(self.bal_plugin,BalPlugin.ALLOW_REPUSH,self) + heir_repush = QPushButton("Rebroadcast transactions") + heir_repush.clicked.connect(partial(self.broadcast_transactions,True)) + grid=QGridLayout(d) + #add_widget(grid,"Refresh Time Days",heir_locktime_time,0,"Delta days for inputs to be invalidated and transactions resubmitted") + #add_widget(grid,"Refresh Blocks",heir_locktime_blocks,1,"Delta blocks for inputs to be invalidated and transaction resubmitted") + #add_widget(grid,"Transaction fees",heir_tx_fees,1,"Default transaction fees") + #add_widget(grid,"Broadcast transactions",heir_broadcast,3,"") + #add_widget(grid," - Ask before",heir_ask_broadcast,4,"") + #add_widget(grid,"Invalidate transactions",heir_invalidate,5,"") + #add_widget(grid," - Ask before",heir_ask_invalidate,6,"") + #add_widget(grid,"Show preview before sign",heir_preview,7,"") + + #grid.addWidget(lbl_logo,0,0) + add_widget(grid,"Hide Replaced",heir_hide_replaced, 1, "Hide replaced transactions from will detail and list") + add_widget(grid,"Hide Invalidated",heir_hide_invalidated ,2,"Hide invalidated transactions from will detail and list") + add_widget(grid,"Ping Willexecutors",heir_ping_willexecutors,3,"Ping willexecutors to get payment info before compiling will") + add_widget(grid," - Ask before",heir_ask_ping_willexecutors,4,"Ask before to ping willexecutor") + add_widget(grid,"Backup Transaction",heir_no_willexecutor,5,"Add transactions without willexecutor") + grid.addWidget(heir_repush,6,0) + grid.addWidget(HelpButton("Broadcast all transactions to willexecutors including those already pushed"),6,2) + #add_widget(grid,"Max Allowed TimeDelta Days",heir_locktimedelta_time,8,"") + #add_widget(grid,"Max Allowed BlocksDelta",heir_locktimedelta_blocks,9,"") + + if ret := bool(d.exec()): + try: + self.update_all() + return ret + except: + pass + return False + + def update_all(self): + self.will_list.update_will(self.willitems) + self.heirs_tab.update() + self.will_tab.update() + self.will_list.update() + +def add_widget(grid,label,widget,row,help_): + grid.addWidget(QLabel(_(label)),row,0) + grid.addWidget(widget,row,1) + grid.addWidget(HelpButton(help_),row,2) diff --git a/util.py b/util.py new file mode 100644 index 0000000..51a5b33 --- /dev/null +++ b/util.py @@ -0,0 +1,458 @@ +from datetime import datetime,timedelta +import bisect +from electrum.gui.qt.util import getSaveFileName +from electrum.i18n import _ +from electrum.transaction import PartialTxOutput +import urllib.request +import urllib.parse +from electrum.util import write_json_file,FileImportFailed,FileExportFailed + +LOCKTIME_THRESHOLD = 500000000 +def locktime_to_str(locktime): + try: + locktime=int(locktime) + if locktime > LOCKTIME_THRESHOLD: + dt = datetime.fromtimestamp(locktime).isoformat() + return dt + + except Exception as e: + #print(e) + pass + return str(locktime) + +def str_to_locktime(locktime): + try: + if locktime[-1] in ('y','d','b'): + return locktime + else: return int(locktime) + except Exception as e: + pass + #print(e) + dt_object = datetime.fromisoformat(locktime) + timestamp = dt_object.timestamp() + return int(timestamp) + +def parse_locktime_string(locktime,w=None): + try: + return int(locktime) + + except Exception as e: + pass + #print("parse_locktime_string",e) + try: + 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()) + if locktime[-1] == 'b': + locktime = int(locktime[:-1]) + height = 0 + if w: + height = get_current_height(w.network) + locktime+=int(height) + return int(locktime) + except Exception as e: + print("parse_locktime_string",e) + #raise e + return 0 + + +def int_locktime(seconds=0,minutes=0,hours=0, days=0, blocks = 0): + return int(seconds + minutes*60 + hours*60*60 + days*60*60*24 + blocks * 600) + +def encode_amount(amount, decimal_point): + if is_perc(amount): + return amount + else: + try: + return int(float(amount)*pow(10,decimal_point)) + except: + return 0 + +def decode_amount(amount,decimal_point): + if is_perc(amount): + return amount + else: + num=8-decimal_point + basestr="{{:0{}.{}f}}".format(num,num) + return "{:08.8f}".format(float(amount)/pow(10,decimal_point)) + +def is_perc(value): + try: + return value[-1] == '%' + except: + return False + +def cmp_array(heira,heirb): + try: + if not len(heira) == len(heirb): + return False + for h in range(0,len(heira)): + if not heira[h] == heirb[h]: + return False + return True + except: + return False + +def cmp_heir(heira,heirb): + if heira[0] == heirb[0] and heira[1] == heirb[1]: + return True + return False + +def cmp_willexecutor(willexecutora,willexecutorb): + 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: + return False + return False + +def search_heir_by_values(heirs,heir,values): + #print() + 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 + +def cmp_heir_by_values(heira,heirb,values): + for v in values: + if heira[v] != heirb[v]: + return False + return True + +def cmp_heirs_by_values(heirsa,heirsb,values,exclude_willexecutors=False,reverse = True): + for heira in heirsa: + if (exclude_willexecutors and not "w!ll3x3c\"" in heira) or not exclude_willexecutors: + found = False + for heirb in heirsb: + if cmp_heir_by_values(heirsa[heira],heirsb[heirb],values): + found=True + if not found: + #print(f"not_found {heira}--{heirsa[heira]}") + return False + if reverse: + return cmp_heirs_by_values(heirsb,heirsa,values,exclude_willexecutors=exclude_willexecutors,reverse=False) + else: + return True + +def cmp_heirs(heirsa,heirsb,cmp_function = lambda x,y: x[0]==y[0] and x[3]==y[3],reverse=True): + try: + for heir in heirsa: + if not "w!ll3x3c\"" in heir: + if not heir in heirsb or not cmp_function(heirsa[heir],heirsb[heir]): + if not search_heir_by_values(heirsb,heirsa[heir],[0,3]): + return False + if reverse: + return cmp_heirs(heirsb,heirsa,cmp_function,False) + else: + return True + except Exception as e: + raise e + return False + +def cmp_inputs(inputsa,inputsb): + if len(inputsa) != len(inputsb): + return False + for inputa in inputsa: + if not in_utxo(inputa,inputsb): + return False + return True + +def cmp_outputs(outputsa,outputsb,willexecutor_output = None): + if len(outputsa) != len(outputsb): + return False + for outputa in outputsa: + if not cmp_output(outputa,willexecutor_output): + if not in_output(outputa,outputsb): + return False + return True + +def cmp_txs(txa,txb): + if not cmp_inputs(txa.inputs(),txb.inputs()): + return False + if not cmp_outputs(txa.outputs(),txb.outputs()): + return False + return True + +def get_value_amount(txa,txb): + outputsa=txa.outputs() + outputsb=txb.outputs() + value_amount = 0 + #if len(outputsa) != len(outputsb): + # print("outputlen is different") + # return False + + for outa in outputsa: + same_amount,same_address = in_output(outa,txb.outputs()) + if not (same_amount or same_address): + #print("outa notin txb", same_amount,same_address) + return False + if same_amount and same_address: + value_amount+=outa.value + if same_amount: + pass + #print("same amount") + if same_address: + pass + #print("same address") + + return value_amount + #not needed + #for outb in outputsb: + # if not in_output(outb,txa.outputs()): + # print("outb notin txb") + # return False + + + +def chk_locktime(timestamp_to_check,block_height_to_check,locktime): + #TODO BUG: WHAT HAPPEN AT THRESHOLD? + locktime=int(locktime) + if locktime > LOCKTIME_THRESHOLD and locktime > timestamp_to_check: + return True + elif locktime < LOCKTIME_THRESHOLD and locktime > block_height_to_check: + return True + else: + return False + +def anticipate_locktime(locktime,blocks=0,hours=0,days=0): + locktime = int(locktime) + out=0 + if locktime> LOCKTIME_THRESHOLD: + seconds = blocks*600 + hours*3600 + days*86400 + dt = datetime.fromtimestamp(locktime) + dt -= timedelta(seconds=seconds) + out = dt.timestamp() + else: + blocks -= hours*6 + days*144 + out = locktime + blocks + + if out < 1: + out = 1 + return out + +def cmp_locktime(locktimea,locktimeb): + if locktimea==locktimeb: + return 0 + strlocktime = str(locktimea) + strlocktimeb = str(locktimeb) + intlocktimea = str_to_locktime(strlocktimea) + intlocktimeb = str_to_locktime(strlocktimeb) + if locktimea[-1] in "ydb": + if locktimeb[-1] == locktimea[-1]: + return int(strlocktimea[-1])-int(strlocktimeb[-1]) + else: + return int(locktimea)-(locktimeb) + + +def get_lowest_valid_tx(available_utxos,will): + will = sorted(will.items(),key = lambda x: x[1]['tx'].locktime) + for txid,willitem in will.items(): + pass + +def get_locktimes(will): + locktimes = {} + for txid,willitem in will.items(): + locktimes[willitem['tx'].locktime]=True + return locktimes.keys() + +def get_lowest_locktimes(locktimes): + sorted_timestamp=[] + sorted_block=[] + for l in locktimes: + #print("locktime:",parse_locktime_string(l)) + l=parse_locktime_string(l) + if l < LOCKTIME_THRESHOLD: + bisect.insort(sorted_block,l) + else: + bisect.insort(sorted_timestamp,l) + + return sorted(sorted_timestamp), sorted(sorted_block) + +def get_lowest_locktimes_from_will(will): + return get_lowest_locktimes(get_locktimes(will)) + +def search_willtx_per_io(will,tx): + for wid, w in will.items(): + if cmp_txs(w['tx'],tx['tx']): + return wid,w + return None, None + +def invalidate_will(will): + raise Exception("not implemented") + +def get_will_spent_utxos(will): + utxos=[] + for txid,willitem in will.items(): + utxos+=willitem['tx'].inputs() + + return utxos + +def utxo_to_str(utxo): + try: return utxo.to_str() + except Exception as e: pass + try: return utxo.prevout.to_str() + except Exception as e: pass + return str(utxo) + +def cmp_utxo(utxoa,utxob): + utxoa=utxo_to_str(utxoa) + utxob=utxo_to_str(utxob) + if utxoa == utxob: + #if utxoa.prevout.txid==utxob.prevout.txid and utxoa.prevout.out_idx == utxob.prevout.out_idx: + return True + else: + return False + +def in_utxo(utxo, utxos): + for s_u in utxos: + if cmp_utxo(s_u,utxo): + return True + return False + +def txid_in_utxo(txid,utxos): + for s_u in utxos: + if s_u.prevout.txid == txid: + return True + return False + +def cmp_output(outputa,outputb): + return outputa.address == outputb.address and outputa.value == outputb.value + +def in_output(output,outputs): + for s_o in outputs: + if 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 + + +def din_output(out,outputs): + 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: + #print("SAME_:",out.address,s_o.address) + return True, True + else: + pass + #print("NOT SAME_:",out.address,s_o.address) + + if len(same_amount)>0: + return True, False + else:return False, False + + +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 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 print_var(var,name = "",veryverbose=False): + print(f"---{name}---") + if not var is None: + try: + print("doc:",doc(var)) + except: pass + try: + print("str:",str(var)) + except: pass + try: + print("repr",repr(var)) + except:pass + try: + print("dict",dict(var)) + except:pass + try: + print("dir",dir(var)) + except:pass + try: + print("type",type(var)) + except:pass + try: + print("to_json",var.to_json()) + except: pass + try: + print("__slotnames__",var.__slotnames__) + except:pass + + print(f"---end {name}---") + +def print_utxo(utxo, name = ""): + print(f"---utxo-{name}---") + print_var(utxo,name) + print_prevout(utxo.prevout,name) + print_var(utxo.script_sig,f"{name}-script-sig") + print_var(utxo.witness,f"{name}-witness") + #print("madonnamaiala_TXInput__scriptpubkey:",utxo._TXInput__scriptpubkey) + print("_TxInput__address:",utxo._TxInput__address) + print("_TxInput__scriptpubkey:",utxo._TxInput__scriptpubkey) + print("_TxInput__value_sats:",utxo._TxInput__value_sats) + print(f"---utxo-end {name}---") + +def print_prevout(prevout, name = ""): + print(f"---prevout-{name}---") + print_var(prevout,f"{name}-prevout") + print_var(prevout._asdict()) + print(f"---prevout-end {name}---") + +def export_meta_gui(electrum_window: 'ElectrumWindow', title, exporter): + filter_ = "All files (*)" + filename = getSaveFileName( + parent=electrum_window, + title=_("Select file to save your {}").format(title), + filename='BALplugin_{}'.format(title), + filter=filter_, + config=electrum_window.config, + ) + if not filename: + return + try: + exporter(filename) + except FileExportFailed as e: + electrum_window.show_critical(str(e)) + else: + electrum_window.show_message(_("Your {0} were exported to '{1}'") + .format(title, str(filename))) + + +def copy(dicto,dictfrom): + for k,v in dictfrom.items(): + dicto[k]=v diff --git a/will.py b/will.py new file mode 100644 index 0000000..b651806 --- /dev/null +++ b/will.py @@ -0,0 +1,802 @@ +import copy + +from . import willexecutors as Willexecutors +from . import util as Util + +from electrum.i18n import _ + +from electrum.transaction import TxOutpoint,PartialTxInput,tx_from_any,PartialTransaction,PartialTxOutput,Transaction +from electrum.util import bfh, decimal_point_to_base_unit_name +from electrum.util import write_json_file,read_json_file,FileImportFailed +from electrum.logging import get_logger,Logger +from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX + +MIN_LOCKTIME = 1 +MIN_BLOCK = 1 +_logger = get_logger(__name__) + +#return an array with the list of children +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 +def add_willtree(will): + for willid in will: + will[willid].children = 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 +def get_sorted_will(will): + return sorted(will.items(),key = lambda x: x[1]['tx'].locktime) + + +def only_valid(will): + for k,v in will.items(): + if v.get_status('VALID'): + yield k + +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 + +def get_tx_from_any(x): + try: + a=str(x) + return tx_from_any(a) + + except Exception as e: + raise e + + return x + +def add_info_from_will(will,wid,wallet): + if isinstance(will[wid].tx,str): + will[wid].tx = 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: + 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 + +def normalize_will(will,wallet = None,others_inputs = {}): + to_delete = [] + to_add = {} + #add info from wallet + for wid in will: + add_info_from_will(will,wid,wallet) + 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_inputs) + will[wid]=ow.to_dict() + + for i in range(0,len(outputs)): + change_input(will,wid,i,outputs[i],others_inputs,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] + +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 + +def check_anticipate(ow:'WillItem',nw:'WillItem'): + anticipate = Util.anticipate_locktime(ow.tx.locktime,days=1) + if int(nw.tx.locktime) >= int(anticipate): + if Util.cmp_heirs_by_values(ow.heirs,nw.heirs,[0,1],exclude_willexecutors = True): + print("same heirs",ow._id,nw._id) + if nw.we and ow.we: + if ow.we['url'] == nw.we['url']: + print("same willexecutors", ow.we['url'],nw.we['url']) + if int(ow.we['base_fee'])>int(nw.we['base_fee']): + print("anticipate") + return anticipate + else: + if int(ow.tx_fees) != int(nw.tx_fees): + return anticipate + else: + print("keep the same") + #_logger.debug("ow,base fee > nw.base_fee") + ow.tx.locktime + else: + #_logger.debug("ow.we['url']({ow.we['url']}) == nw.we['url']({nw.we['url']})") + print("keep the same") + ow.tx.locktime + else: + if nw.we == ow.we: + if not Util.cmp_heirs_by_values(ow.heirs,nw.heirs,[0,3]): + return anticipate + else: + return ow.tx.locktime + else: + return ow.tx.locktime + else: + return anticipate + return 4294967295+1 + + +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]=new_input(wid,idx,change) + found = True + if found == True: + 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)): + change_input(will, wid, i, outputs[i],others_inputs,to_delete,to_append) + +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 not prevout_str in all_inputs: + all_inputs[prevout_str] = [inp] + else: + all_inputs[prevout_str].append(inp) + return all_inputs + +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 not i in all_inputs_min_locktime: + all_inputs_min_locktime[i]=[w] + else: + all_inputs_min_locktime[i].append(w) + + return all_inputs_min_locktime + + +def search_anticipate_rec(will,old_inputs): + redo = False + to_delete = [] + to_append = {} + new_inputs = get_all_inputs(will,only_valid = True) + for nid,nwi in will.items(): + if nwi.search_anticipate(new_inputs) or 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)): + change_input(will,nid,i,outputs[i],new_inputs,to_delete,to_append) + + + for w in to_delete: + try: + del will[w] + except: + pass + for k,w in to_append.items(): + will[k]=w + if redo: + search_anticipate_rec(will,old_inputs) + + +def update_will(old_will,new_will): + all_old_inputs = get_all_inputs(old_will,only_valid=True) + all_inputs_min_locktime = get_all_inputs_min_locktime(all_old_inputs) + all_new_inputs = 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. + search_anticipate_rec(new_will,all_old_inputs) + + other_inputs = get_all_inputs(old_will,{}) + try: + normalize_will(new_will,others_inputs=other_inputs) + except Exception as e: + raise e + + + for oid in 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 + print(f"found {oid}") + + continue + else: + print(f"not found {oid}") + continue + +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 + +def invalidate_will(will,wallet,fees_per_byte): + will_only_valid = only_valid_list(will) + inputs = get_all_inputs(will_only_valid) + utxos = wallet.get_utxos() + filtered_inputs = [] + prevout_to_spend = [] + for prevout_str,ws in inputs.items(): + for w in ws: + if not w[0] in filtered_inputs: + filtered_inputs.append(w[0]) + if not prevout_str 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 = Util.get_current_height(wallet.network) + 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("balance - fee <=0") + pass + else: + _logger.debug("len utxo_to_spend <=0") + pass + + +def is_new(will): + for wid,w in will.items(): + if w.get_status('VALID') and not w.get_status('COMPLETE'): + return True + +def search_rai (all_inputs,all_utxos,will,wallet): + will_only_valid = 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) + #else: + # if prevout_id in will: + # wo = will[prevout_id] + # ttx= wallet.db.get_transaction(prevout_id) + # if ttx: + # _logger.error("transaction in wallet should be early detected") + # #wi.set_status('CONFIRMED',True) + # #else: + # # _logger.error("transaction not in will or utxo") + # # 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 + +def utxos_strs(utxos): + return [Util.utxo_to_str(u) for u in utxos] + + +def set_invalidate(wid,will=[]): + will[wid].set_status("INVALIDATED",True) + if will[wid].children: + for c in self.children.items(): + set_invalidate(c[0],will) + +def check_tx_height(tx, wallet): + info=wallet.get_tx_info(tx) + return info.tx_mined_status.height + +#check if transactions are stil valid tecnically valid +def check_invalidated(willtree,utxos_list,wallet): + for wid,w in willtree.items(): + if not w.father: + for inp in w.tx.inputs(): + inp_str = Util.utxo_to_str(inp) + #print(utxos_list) + #print(inp_str) + #print(inp_str in utxos_list) + #print("notin: ",not inp_str in utxos_list) + if not inp_str in utxos_list: + #print("quindi qua non ci arrivo?") + if wallet: + height= check_tx_height(w.tx,wallet) + + if height < 0: + #_logger.debug(f"heigth {height}") + set_invalidate(wid,willtree) + elif height == 0: + w.set_status("PENDING",True) + else: + w.set_status('CONFIRMED',True) + +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: + reflect_to_children(wc) + +def check_amounts(heirs,willexecutors,all_utxos,timestamp_to_check,dust): + fixed_heirs,fixed_amount,perc_heirs,perc_amount = 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}") + + +def check_will(will,all_utxos,wallet,block_to_check,timestamp_to_check): + add_willtree(will) + utxos_list= utxos_strs(all_utxos) + + check_invalidated(will,utxos_list,wallet) + #from pprint import pprint + #for wid,w in will.items(): + # pprint(w.to_dict()) + + all_inputs=get_all_inputs(will,only_valid = True) + + all_inputs_min_locktime = get_all_inputs_min_locktime(all_inputs) + + check_will_expired(all_inputs_min_locktime,block_to_check,timestamp_to_check) + + all_inputs=get_all_inputs(will,only_valid = True) + + search_rai(all_inputs,all_utxos,will,wallet) + +def is_will_valid(will, block_to_check, timestamp_to_check, tx_fees, all_utxos,heirs={},willexecutors={},self_willexecutor=False, wallet=False, callback_not_valid_tx=None): + + check_will(will,all_utxos,wallet,block_to_check,timestamp_to_check) + + + if heirs: + if not check_willexecutors_and_heirs(will,heirs,willexecutors,self_willexecutor,timestamp_to_check,tx_fees): + raise NotCompleteWillException() + + + all_inputs=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 + +def check_will_expired(all_inputs_min_locktime,block_to_check,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 <= NLOCKTIME_BLOCKHEIGHT_MAX: + if locktime < int(block_to_check): + raise WillExpiredException(f"Will Expired {wid[0][0]}: {locktime}<{block_to_check}") + else: + if locktime < int(timestamp_to_check): + raise WillExpiredException(f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}") + +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) + + +def only_valid_list(will): + out={} + for wid,w in will.items(): + if w.get_status('VALID'): + out[wid]=w + return out + +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 + +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 = only_valid_list(will) + if len(will_only_valid)<1: + return False + for wid in only_valid_list(will): + w = will[wid] + if w.tx_fees != tx_fees: + #w.set_status('VALID',False) + 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] and Util.parse_locktime_string(heir[2]) >= Util.parse_locktime_string(their[2]): + count = heirs_found.get(wheir,0) + heirs_found[wheir]=count + 1 + else: + _logger.debug("heir not present transaction is not valid:",wid,w) + continue + 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 not h 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 not url 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], + 'VALID': ['Valid', True], + } + def set_status(self,status,value=True): + _logger.debug("set status {} - {} {} -> {}".format(self._id,status,self.STATUS[status][1],value)) + 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: + 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 ['CHECK_FAIL']: + # self.STATUS['PUSHED'][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 = 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('tx_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, + 'tx_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,check_anticipate(ow,self)) + if int(nl) < self.tx.locktime: + #_logger.debug("actually anticipating") + self.tx.locktime = int(nl) + return True + else: + #_logger.debug("keeping the same locktime") + 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 check_willexecutor(self): + try: + if resp:=Willexecutors.check_transaction(self._id,self.we['url']): + 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') + def get_color(self): + if self.get_status("INVALIDATED"): + return "#f87838" + elif self.get_status("REPLACED"): + return "#ff97e9" + elif self.get_status("CONFIRMED"): + return "#bfbfbf" + elif self.get_status("PENDING"): + return "#ffce30" + elif self.get_status("CHECK_FAIL") and not self.get_status("CHECKED"): + return "#e83845" + elif self.get_status("CHECKED"): + return "#8afa6c" + elif self.get_status("PUSH_FAIL"): + return "#e83845" + elif self.get_status("PUSHED"): + return "#73f3c8" + elif self.get_status("COMPLETE"): + return "#2bc8ed" + else: + return "#ffffff" + + +class WillExpiredException(Exception): + pass +class NotCompleteWillException(Exception): + pass +class HeirChangeException(NotCompleteWillException): + pass +class TxFeesChangedException(NotCompleteWillException): + pass +class HeirNotFoundException(NotCompleteWillException): + pass +class WillexecutorChangeException(NotCompleteWillException): + pass +class NoWillExecutorNotPresent(NotCompleteWillException): + pass +class WillExecutorNotPresent(NotCompleteWillException): + pass +class NoHeirsException(Exception): + pass +class AmountException(Exception): + pass +class PercAmountException(AmountException): + pass +class FixedAmountException(AmountException): + pass diff --git a/willexecutors.py b/willexecutors.py new file mode 100644 index 0000000..f209501 --- /dev/null +++ b/willexecutors.py @@ -0,0 +1,203 @@ +import json +from datetime import datetime +from functools import partial +from aiohttp import ClientResponse + +from electrum.network import Network +from electrum import constants +from electrum.logging import get_logger +from electrum.gui.qt.util import WaitingDialog +from electrum.i18n import _ + +from .balqt.baldialog import BalWaitingDialog +from . import util as Util + +DEFAULT_TIMEOUT = 5 +_logger = get_logger(__name__) + +def get_willexecutors(bal_plugin, update = False,bal_window=False,force=False,task=True): + willexecutors = bal_plugin.config_get(bal_plugin.WILLEXECUTORS) + for w in willexecutors: + initialize_willexecutor(willexecutors[w],w) + + bal=bal_plugin.DEFAULT_SETTINGS[bal_plugin.WILLEXECUTORS] + for bal_url,bal_executor in bal.items(): + if not bal_url in willexecutors: + _logger.debug("replace bal") + willexecutors[bal_url]=bal_executor + if update: + found = False + for url,we in willexecutors.items(): + if is_selected(we): + found = True + if found or force: + if bal_plugin.config_get(bal_plugin.PING_WILLEXECUTORS) or force: + ping_willexecutors = True + if bal_plugin.config_get(bal_plugin.ASK_PING_WILLEXECUTORS) and not force: + ping_willexecutors = bal_window.window.question(_("Contact willexecutors servers to update payment informations?")) + if ping_willexecutors: + if task: + bal_window.ping_willexecutors(willexecutors) + else: + bal_window.ping_willexecutors_task(willexecutors) + return willexecutors + +def is_selected(willexecutor,value=None): + if not willexecutor: + return False + if not value is None: + willexecutor['selected']=value + try: + return willexecutor['selected'] + except: + willexecutor['selected']=False + return False + +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 is_selected(willexecutor): + if not url 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 willexectors.items(): + if is_selected(willexecutor): + out[url]=v +def push_transactions_to_willexecutors(will): + willexecutors = get_transactions_to_be_pushed() + for url in willexecutors: + willexecutor = willexecutors[url] + if is_selected(willexecutor): + if 'txs' in willexecutor: + push_transactions_to_willexecutor(willexecutors[url]['txs'],url) + +def send_request(method, url, data=None, *, timeout=10): + network = Network.get_instance() + if not network: + raise ErrorConnectingServer('You are offline.') + _logger.debug(f'<-- {method} {url} {data}') + headers = {} + headers['user-agent'] = 'BalPlugin' + headers['Content-Type']='text/plain' + + 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 Exception as e: + _logger.error(f"exception sending request {e}") + raise e + else: + _logger.debug(f'--> {response}') + return response +async def handle_response(resp:ClientResponse): + r=await resp.text() + try: + r=json.loads(r) + r['status'] = resp.status + r['selected']=is_selected(willexecutor) + r['url']=url + except: + pass + return r + +class AlreadyPresentException(Exception): + pass +def push_transactions_to_willexecutor(willexecutor): + out=True + try: + _logger.debug(f"willexecutor['txs']") + if w:=send_request('post', willexecutor['url']+"/"+constants.net.NET_NAME+"/pushtxs", data=willexecutor['txs'].encode('ascii')): + willexecutor['broadcast_stauts'] = _("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 AlreadyPresentException() + out=False + willexecutor['broadcast_stauts'] = _("Failed") + + return out + +def ping_servers(willexecutors): + for url,we in willexecutors.items(): + get_info_task(url,we) + + +def get_info_task(url,willexecutor): + w=None + try: + _logger.info("GETINFO_WILLEXECUTOR") + _logger.debug(url) + w = send_request('get',url+"/"+constants.net.NET_NAME+"/info") + willexecutor['url']=url + willexecutor['status'] = w['status'] + willexecutor['base_fee'] = w['base_fee'] + willexecutor['address'] = w['address'] + if not willexecutor['info']: + willexecutor['info'] = w['info'] + _logger.debug(f"response_data {w['address']}") + except Exception as e: + _logger.error(f"error {e} contacting {url}: {w}") + willexecutor['status']="KO" + + willexecutor['last_update'] = datetime.now().timestamp() + return willexecutor + +def initialize_willexecutor(willexecutor,url,status=None,selected=None): + willexecutor['url']=url + if not status is None: + willexecutor['status'] = status + willexecutor['selected'] = is_selected(willexecutor,selected) + +def get_willexecutors_list_from_json(bal_plugin): + 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.config.set_key(bal_plugin.WILLEXECUTORS,willexecutors,save=True) + return h + except Exception as e: + _logger.error(f"errore aprendo willexecutors.json: {e}") + return {} + +def check_transaction(txid,url): + _logger.debug(f"{url}:{txid}") + try: + w = send_request('post',url+"/searchtx",data=txid.encode('ascii')) + return w + except Exception as e: + _logger.error(f"error contacting {url} for checking txs {e}") + raise e