Electron Cash
" + _("Version") + f" {self.wallet.electrum_version}" + "
" + '' + - _("Copyright © {year_start}-{year_end} Electron Cash LLC and the Electron Cash developers.").format(year_start=2017, year_end=2021) + + _("Copyright © {year_start}-{year_end} Electron Cash LLC and the Electron Cash developers.").format(year_start=2017, year_end=2022) + "
" + _("darkdetect for macOS © 2019 Alberto Sottile") + "
" "" + '' +
@@ -968,7 +944,6 @@ def update_status(self):
_("Balance: {amount_and_unit}").format(
amount_and_unit=self.format_amount_and_units(c))
]
-
if u:
text_items.append(_("[{amount} unconfirmed]").format(
amount=self.format_amount(u, True).strip()))
@@ -1006,11 +981,10 @@ def update_status(self):
self.tray.setToolTip("%s (%s)" % (text, self.wallet.basename()))
self.balance_label.setText(text)
- self.status_button.setIcon( icon )
+ self.status_button.setIcon(icon)
self.status_button.setStatusTip( status_tip )
run_hook('window_update_status', self)
-
def update_wallet(self):
self.need_update.set() # will enqueue an _update_wallet() call in at most 0.5 seconds from now.
@@ -1632,9 +1606,9 @@ def create_send_tab(self):
self.max_button.setFixedWidth(140)
self.max_button.setCheckable(True)
grid.addWidget(self.max_button, 5, 3)
- hbox = QHBoxLayout()
+ hbox = self.send_tab_extra_plugin_controls_hbox = QHBoxLayout()
hbox.addStretch(1)
- grid.addLayout(hbox, 5, 4)
+ grid.addLayout(hbox, 5, 4, 1, -1)
msg = _('Bitcoin Cash transactions are in general not free. A transaction fee is paid by the sender of the funds.') + '\n\n'\
+ _('The amount of fee can be decided freely by the sender. However, transactions with low fees take more time to be processed.') + '\n\n'\
@@ -2876,7 +2850,7 @@ def create_status_bar(self):
self.update_available_button.setVisible(bool(self.gui_object.new_version_available)) # if hidden now gets unhidden by on_update_available when a new version comes in
self.lock_icon = QIcon()
- self.password_button = StatusBarButton(self.lock_icon, _("Password"), self.change_password_dialog )
+ self.password_button = StatusBarButton(self.lock_icon, _("Password"), self.change_password_dialog)
sb.addPermanentWidget(self.password_button)
self.addr_converter_button = StatusBarButton(
@@ -2889,8 +2863,10 @@ def create_status_bar(self):
self.addr_converter_button.setHidden(self.gui_object.is_cashaddr_status_button_hidden())
self.gui_object.cashaddr_status_button_hidden_signal.connect(self.addr_converter_button.setHidden)
- sb.addPermanentWidget(StatusBarButton(QIcon(":icons/preferences.svg"), _("Preferences"), self.settings_dialog ) )
- self.seed_button = StatusBarButton(QIcon(":icons/seed.png"), _("Seed"), self.show_seed_dialog )
+ q_icon_prefs = QIcon(":icons/preferences.svg"), _("Preferences"), self.settings_dialog
+ sb.addPermanentWidget(StatusBarButton(*q_icon_prefs))
+ q_icon_seed = QIcon(":icons/seed.png"), _("Seed"), self.show_seed_dialog
+ self.seed_button = StatusBarButton(*q_icon_seed)
sb.addPermanentWidget(self.seed_button)
weakSelf = Weak.ref(self)
gui_object = self.gui_object
@@ -4421,7 +4397,7 @@ def on_usechange(x):
if self.wallet.use_change != usechange_result:
self.wallet.use_change = usechange_result
self.wallet.storage.put('use_change', self.wallet.use_change)
- multiple_cb.setEnabled(self.wallet.use_change)
+ multiple_cb.setEnabled(bool(self.wallet.use_change))
usechange_cb.stateChanged.connect(on_usechange)
per_wallet_tx_widgets.append((usechange_cb, None))
@@ -4433,9 +4409,9 @@ def on_usechange(x):
if isinstance(self.force_use_single_change_addr, str):
multiple_cb.setToolTip(self.force_use_single_change_addr)
else:
- multuple_cb.setToolTip('')
+ multiple_cb.setToolTip('')
else:
- multiple_cb.setEnabled(self.wallet.use_change)
+ multiple_cb.setEnabled(bool(self.wallet.use_change))
multiple_cb.setToolTip('\n'.join([
_('In some cases, use up to 3 change addresses in order to break '
'up large coin amounts and obfuscate the recipient address.'),
@@ -4597,7 +4573,7 @@ def on_fiat_address(checked):
fiat_widgets.append((fiat_address_checkbox, None))
else:
- # For testnet(s) and for --taxcoin we do not support Fiat display
+ # For testnet(s) where we do not support Fiat display
lbl = QLabel(_("Fiat display is not supported on this chain."))
lbl.setAlignment(Qt.AlignHCenter|Qt.AlignVCenter)
f = lbl.font()
@@ -4868,10 +4844,10 @@ def do_toggle(weakCb, name, i):
cb = QCheckBox(descr['fullname'])
weakCb = Weak.ref(cb)
plugin_is_loaded = p is not None
- cb_enabled = (not plugin_is_loaded and plugins.is_internal_plugin_available(name, self.wallet)
- or plugin_is_loaded and p.can_user_disable())
+ cb_enabled = bool(not plugin_is_loaded and plugins.is_internal_plugin_available(name, self.wallet)
+ or plugin_is_loaded and p.can_user_disable())
cb.setEnabled(cb_enabled)
- cb.setChecked(plugin_is_loaded and p.is_enabled())
+ cb.setChecked(bool(plugin_is_loaded and p.is_enabled()))
grid.addWidget(cb, i, 0)
enable_settings_widget(p, name, i)
cb.clicked.connect(partial(do_toggle, weakCb, name, i))
@@ -4913,62 +4889,6 @@ def hardware_wallet_support(self):
d.exec_()
self.hardwarewalletdialog = None # allow python to GC
- def cpfp(self, parent_tx, new_tx):
- total_size = parent_tx.estimated_size() + new_tx.estimated_size()
- d = WindowModalDialog(self.top_level_window(), _('Child Pays for Parent'))
- vbox = QVBoxLayout(d)
- msg = (
- "A CPFP is a transaction that sends an unconfirmed output back to "
- "yourself, with a high fee. The goal is to have miners confirm "
- "the parent transaction in order to get the fee attached to the "
- "child transaction.")
- vbox.addWidget(WWLabel(_(msg)))
- msg2 = ("The proposed fee is computed using your "
- "fee/kB settings, applied to the total size of both child and "
- "parent transactions. After you broadcast a CPFP transaction, "
- "it is normal to see a new unconfirmed transaction in your history.")
- vbox.addWidget(WWLabel(_(msg2)))
- grid = QGridLayout()
- grid.addWidget(QLabel(_('Total size') + ':'), 0, 0)
- grid.addWidget(QLabel(_('{total_size} bytes').format(total_size=total_size)), 0, 1)
- max_fee = new_tx.output_value()
- grid.addWidget(QLabel(_('Input amount') + ':'), 1, 0)
- grid.addWidget(QLabel(self.format_amount(max_fee) + ' ' + self.base_unit()), 1, 1)
- output_amount = QLabel('')
- grid.addWidget(QLabel(_('Output amount') + ':'), 2, 0)
- grid.addWidget(output_amount, 2, 1)
- fee_e = BTCAmountEdit(self.get_decimal_point)
- def f(x):
- a = max_fee - fee_e.get_amount()
- output_amount.setText((self.format_amount(a) + ' ' + self.base_unit()) if a else '')
- fee_e.textChanged.connect(f)
- fee = self.config.fee_per_kb() * total_size / 1000
- fee_e.setAmount(fee)
- grid.addWidget(QLabel(_('Fee' + ':')), 3, 0)
- grid.addWidget(fee_e, 3, 1)
- def on_rate(dyn, pos, fee_rate):
- fee = fee_rate * total_size / 1000
- fee = min(max_fee, fee)
- fee_e.setAmount(fee)
- fee_slider = FeeSlider(self, self.config, on_rate)
- fee_slider.update()
- grid.addWidget(fee_slider, 4, 1)
- vbox.addLayout(grid)
- vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
- result = d.exec_()
- d.setParent(None) # So Python can GC
- if not result:
- return
- fee = fee_e.get_amount()
- if fee > max_fee:
- self.show_error(_('Max fee exceeded'))
- return
- new_tx = self.wallet.cpfp(parent_tx, fee)
- if new_tx is None:
- self.show_error(_('CPFP no longer valid'))
- return
- self.show_transaction(new_tx)
-
def rebuild_history(self):
if self.gui_object.warn_if_no_network(self):
# Don't allow if offline mode.
@@ -5375,6 +5295,3 @@ def process_notifs(self):
.format(n_cashacct, ca_text))
else:
parent.notify(_("New transaction: {}").format(ca_text))
- # Play the sound effect ('ard moné edition only)
- if parent.tx_sound:
- parent.tx_sound.play()
diff --git a/electroncash_gui/qt/network_dialog.py b/electroncash_gui/qt/network_dialog.py
index 342cde824950..76ce439a4669 100644
--- a/electroncash_gui/qt/network_dialog.py
+++ b/electroncash_gui/qt/network_dialog.py
@@ -28,6 +28,7 @@
import queue
import socket
+import weakref
from functools import partial
from PyQt5.QtGui import *
@@ -49,11 +50,14 @@
protocol_names = ['TCP', 'SSL']
protocol_letters = 'ts'
-class NetworkDialog(MessageBoxMixin, QDialog):
+
+class NetworkDialog(MessageBoxMixin, OnDestroyedMixin, QDialog):
network_updated_signal = pyqtSignal()
def __init__(self, network, config):
QDialog.__init__(self)
+ OnDestroyedMixin.__init__(self)
+ self.weak_network = network and weakref.ref(network)
self.setWindowTitle(_('Network'))
self.setMinimumSize(500, 350)
self.nlayout = NetworkChoiceLayout(self, network, config)
@@ -74,13 +78,23 @@ def __init__(self, network, config):
self.refresh_timer.timeout.connect(self.network_updated_signal.emit)
self.refresh_timer.setInterval(500)
+ def on_destroyed(self, obj):
+ if self.is_destroyed:
+ return
+ OnDestroyedMixin.on_destroyed(self, obj)
+ network = self.weak_network and self.weak_network()
+ if network:
+ network.unregister_callback(self.on_network)
+ print_error("NetworkDialog: unregistered callback")
+
def jumpto(self, location : str):
self.nlayout.jumpto(location)
def on_network(self, event, *args):
''' This may run in network thread '''
#print_error("[NetworkDialog] on_network:",event,*args)
- self.network_updated_signal.emit() # this enqueues call to on_update in GUI thread
+ if not self.is_destroyed:
+ self.network_updated_signal.emit() # this enqueues call to on_update in GUI thread
@rate_limited(0.333) # limit network window updates to max 3 per second. More frequent isn't that useful anyway -- and on large wallets/big synchs the network spams us with events which we would rather collapse into 1
def on_update(self):
@@ -159,7 +173,7 @@ def create_menu(self, position):
menu.exec_(self.viewport().mapToGlobal(position))
def keyPressEvent(self, event):
- if event.key() in [ Qt.Key_F2, Qt.Key_Return ]:
+ if event.key() in {Qt.Key_F2, Qt.Key_Return}:
item, col = self.currentItem(), self.currentColumn()
if item and col > -1:
self.on_activated(item, col)
@@ -390,10 +404,11 @@ def update(self, network, servers, protocol, use_tor):
self.setAutoScroll(val)
-class NetworkChoiceLayout(QObject, PrintError):
+class NetworkChoiceLayout(QObject, OnDestroyedMixin, PrintError):
def __init__(self, parent, network, config, wizard=False):
- super().__init__(parent)
+ QObject.__init__(self, parent)
+ OnDestroyedMixin.__init__(self)
self.network = network
self.config = config
self.protocol = None
@@ -723,7 +738,7 @@ def jumpto(self, location : str):
@in_main_thread
def on_tor_port_changed(self, controller: TorController):
- if not controller.active_socks_port or not controller.is_enabled() or not self.tor_use:
+ if self.is_destroyed or not controller.active_socks_port or not controller.is_enabled() or not self.tor_use:
return
# The Network class handles actually changing the port, we just
@@ -741,7 +756,7 @@ def check_disable_proxy(self, b):
# Disallow changing the proxy settings when Tor is in use
b = False
for w in [self.proxy_mode, self.proxy_host, self.proxy_port, self.proxy_user, self.proxy_password]:
- w.setEnabled(b)
+ w.setEnabled(bool(b))
def get_set_server_flags(self):
return (self.config.is_modifiable('server'),
@@ -927,11 +942,11 @@ def set_server(self, onion_hack=False):
def set_proxy(self):
host, port, protocol, proxy, auto_connect = self.network.get_parameters()
if self.proxy_cb.isChecked():
- proxy = { 'mode':str(self.proxy_mode.currentText()).lower(),
- 'host':str(self.proxy_host.text()),
- 'port':str(self.proxy_port.text()),
- 'user':str(self.proxy_user.text()),
- 'password':str(self.proxy_password.text())}
+ proxy = {'mode':str(self.proxy_mode.currentText()).lower(),
+ 'host':str(self.proxy_host.text()),
+ 'port':str(self.proxy_port.text()),
+ 'user':str(self.proxy_user.text()),
+ 'password':str(self.proxy_password.text())}
else:
proxy = None
self.network.set_parameters(host, port, protocol, proxy, auto_connect)
@@ -980,6 +995,8 @@ def set_tor_enabled(self, enabled: bool):
@in_main_thread
def on_tor_status_changed(self, controller):
+ if self.is_destroyed:
+ return
if controller.status == TorController.Status.ERRORED and self.tabs.isVisible():
tbname = self._tor_client_names[self.network.tor_controller.tor_binary_type]
msg = _("The {tor_binary_name} client experienced an error or could not be started.").format(tor_binary_name=tbname)
@@ -990,7 +1007,7 @@ def set_tor_socks_port(self):
self.network.tor_controller.set_socks_port(socks_port)
def on_custom_port_cb_click(self, b):
- self.tor_socks_port.setEnabled(b)
+ self.tor_socks_port.setEnabled(bool(b))
if not b:
self.tor_socks_port.setText("0")
self.set_tor_socks_port()
@@ -1059,15 +1076,18 @@ def on_clear_blacklist(self):
return False
-class TorDetector(QThread):
+class TorDetector(QThread, OnDestroyedMixin):
found_proxy = pyqtSignal(object)
def __init__(self, parent, network):
- super().__init__(parent)
+ QThread.__init__(self, parent)
+ OnDestroyedMixin.__init__(self)
self.network = network
self.network.tor_controller.active_port_changed.append_weak(self.on_tor_port_changed)
def on_tor_port_changed(self, controller: TorController):
+ if self.is_destroyed:
+ return
if controller.active_socks_port and self.isRunning():
self.stopQ.put('kick')
diff --git a/electroncash_gui/qt/password_dialog.py b/electroncash_gui/qt/password_dialog.py
index d47c42297775..fa07786c90e1 100644
--- a/electroncash_gui/qt/password_dialog.py
+++ b/electroncash_gui/qt/password_dialog.py
@@ -48,7 +48,7 @@ def check_password_strength(password):
num = re.search("[0-9]", password) is not None and re.match("^[0-9]*$", password) is None
caps = password != password.upper() and password != password.lower()
extra = re.match("^[a-zA-Z0-9]*$", password) is None
- score = len(password)*( n + caps + num + extra)/20
+ score = len(password)*(n + caps + num + extra)/20
password_strength = {0:"Weak",1:"Medium",2:"Strong",3:"Very Strong"}
return password_strength[min(3, int(score))]
diff --git a/electroncash_gui/qt/paytoedit.py b/electroncash_gui/qt/paytoedit.py
index a64f8042fff0..a63adf0267b7 100644
--- a/electroncash_gui/qt/paytoedit.py
+++ b/electroncash_gui/qt/paytoedit.py
@@ -68,7 +68,7 @@ def __init__(self, win):
documentMargin = document.documentMargin()
self.verticalMargins = margins.top() + margins.bottom()
self.verticalMargins += self.frameWidth() * 2
- self.verticalMargins += documentMargin * 2
+ self.verticalMargins += int(documentMargin * 2)
self.heightMin = self.fontSpacing + self.verticalMargins
self.heightMax = (self.fontSpacing * 10) + self.verticalMargins
diff --git a/electroncash_gui/qt/popup_widget.py b/electroncash_gui/qt/popup_widget.py
index a49969b2e19a..11c567b503d5 100644
--- a/electroncash_gui/qt/popup_widget.py
+++ b/electroncash_gui/qt/popup_widget.py
@@ -40,7 +40,7 @@ def __init__(self, parent = None, timeout = None, delete_on_hide = True,
self.pointerPos = self.LeftSide
self._timer = None
self.activation_hides = activation_hides
- self.dark_mode = dark_mode
+ self.dark_mode = dark_mode and sys.platform.lower() != "darwin"
#self.resize(200, 50)
@@ -115,41 +115,45 @@ def drawPopupPointer(self, p):
r = QRectF(self.rect())
if self.pointerPos == self.LeftSide:
- PPIX_X = self.LR_MARGIN; PPIX_Y = PPIX_X*2.0
+ PPIX_X = self.LR_MARGIN
+ PPIX_Y = PPIX_X * 2.0
points = [
- QPointF(QPoint(r.x()+PPIX_X, r.height()/2.0 - PPIX_Y/2.0)),
- QPointF(QPoint(r.x()+PPIX_X, r.height()/2.0 + PPIX_Y/2.0)),
- QPointF(QPoint(r.x(), r.height() / 2.0))
+ QPointF(r.x() + PPIX_X, r.height() / 2.0 - PPIX_Y / 2.0),
+ QPointF(r.x() + PPIX_X, r.height() / 2.0 + PPIX_Y / 2.0),
+ QPointF(r.x(), r.height() / 2.0),
]
p.drawPolygon(*points)
if self.pointerPos == self.RightSide:
- PPIX_X = self.LR_MARGIN; PPIX_Y = PPIX_X*2.0
+ PPIX_X = self.LR_MARGIN
+ PPIX_Y = PPIX_X * 2.0
points = [
- QPointF(QPoint(r.right()-PPIX_X, r.height()/2.0 - PPIX_Y/2.0)),
- QPointF(QPoint(r.right()-PPIX_X, r.height()/2.0 + PPIX_Y/2.0)),
- QPointF(QPoint(r.right(), r.height() / 2.0))
+ QPointF(r.right()-PPIX_X, r.height()/2.0 - PPIX_Y/2.0),
+ QPointF(r.right()-PPIX_X, r.height()/2.0 + PPIX_Y/2.0),
+ QPointF(r.right(), r.height() / 2.0),
]
p.drawPolygon(*points)
if self.pointerPos == self.TopSide:
- PPIX_Y = self.TB_MARGIN; PPIX_X = PPIX_Y*2.0
+ PPIX_Y = self.TB_MARGIN
+ PPIX_X = PPIX_Y*2.0
points = [
- QPointF(QPoint(r.x()+r.width()/2.0 - PPIX_X/2.0, r.top() + PPIX_Y)),
- QPointF(QPoint(r.x()+r.width()/2.0 + PPIX_X/2.0, r.top() + PPIX_Y)),
- QPointF(QPoint(r.x()+r.width()/2.0, r.top()))
+ QPointF(r.x()+r.width()/2.0 - PPIX_X/2.0, r.top() + PPIX_Y),
+ QPointF(r.x()+r.width()/2.0 + PPIX_X/2.0, r.top() + PPIX_Y),
+ QPointF(r.x()+r.width()/2.0, r.top()),
]
p.drawPolygon(*points)
if self.pointerPos == self.BottomSide:
- PPIX_Y = self.TB_MARGIN; PPIX_X = PPIX_Y*2.0
+ PPIX_Y = self.TB_MARGIN
+ PPIX_X = PPIX_Y*2.0
points = [
- QPointF(QPoint(r.x()+r.width()/2.0 - PPIX_X/2.0, r.bottom() - PPIX_Y)),
- QPointF(QPoint(r.x()+r.width()/2.0 + PPIX_X/2.0, r.bottom() - PPIX_Y)),
- QPointF(QPoint(r.x()+r.width()/2.0, r.bottom()))
+ QPointF(r.x()+r.width()/2.0 - PPIX_X/2.0, r.bottom() - PPIX_Y),
+ QPointF(r.x()+r.width()/2.0 + PPIX_X/2.0, r.bottom() - PPIX_Y),
+ QPointF(r.x()+r.width()/2.0, r.bottom()),
]
p.drawPolygon(*points)
diff --git a/electroncash_gui/qt/qrcodewidget.py b/electroncash_gui/qt/qrcodewidget.py
index 7c5968430084..2bc1ae829683 100644
--- a/electroncash_gui/qt/qrcodewidget.py
+++ b/electroncash_gui/qt/qrcodewidget.py
@@ -58,8 +58,8 @@ def _bad_data(self, data):
_black_brush = QBrush(QColor(0, 0, 0, 255))
_white_brush = QBrush(QColor(255, 255, 255, 255))
- _black_pen = QPen(_black_brush, 1.0, join = Qt.MiterJoin)
- _white_pen = QPen(_white_brush, 1.0, join = Qt.MiterJoin)
+ _black_pen = QPen(_black_brush, 1.0, join=Qt.MiterJoin)
+ _white_pen = QPen(_white_brush, 1.0, join=Qt.MiterJoin)
def paintEvent(self, e):
matrix = None
@@ -80,15 +80,15 @@ def paintEvent(self, e):
margin = 5
framesize = min(r.width(), r.height())
- boxsize = int( (framesize - 2*margin)/k )
- size = k*boxsize
- left = (r.width() - size)/2
- top = (r.height() - size)/2
+ boxsize = (framesize - 2 * margin) // k
+ size = k * boxsize
+ left = (r.width() - size) // 2
+ top = (r.height() - size) // 2
# Make a white margin around the QR in case of dark theme use
qp.setBrush(self._white_brush)
qp.setPen(self._white_pen)
- qp.drawRect(left-margin, top-margin, size+(margin*2), size+(margin*2))
+ qp.drawRect(left-margin, top-margin, size + (margin * 2), size + (margin * 2))
qp.setBrush(self._black_brush)
qp.setPen(self._black_pen)
diff --git a/electroncash_gui/qt/qrreader/camera_dialog.py b/electroncash_gui/qt/qrreader/camera_dialog.py
index dd571fdbc5f9..69d533362ed3 100644
--- a/electroncash_gui/qt/qrreader/camera_dialog.py
+++ b/electroncash_gui/qt/qrreader/camera_dialog.py
@@ -212,8 +212,8 @@ def _get_crop(resolution: QSize, scan_size: int) -> QRect:
"""
Returns a QRect that is scan_size x scan_size in the middle of the resolution
"""
- scan_pos_x = (resolution.width() - scan_size) / 2
- scan_pos_y = (resolution.height() - scan_size) / 2
+ scan_pos_x = int((resolution.width() - scan_size) / 2)
+ scan_pos_y = int((resolution.height() - scan_size) / 2)
return QRect(scan_pos_x, scan_pos_y, scan_size, scan_size)
@staticmethod
diff --git a/electroncash_gui/qt/qrreader/video_overlay.py b/electroncash_gui/qt/qrreader/video_overlay.py
index d345d4b805fc..1e5b5437811c 100644
--- a/electroncash_gui/qt/qrreader/video_overlay.py
+++ b/electroncash_gui/qt/qrreader/video_overlay.py
@@ -66,7 +66,7 @@ def __init__(self, parent: QWidget = None):
self.bg_rect_pen = QPen()
self.bg_rect_pen.setColor(Qt.black)
self.bg_rect_pen.setStyle(Qt.DotLine)
- self.bg_rect_fill = QColor(255, 255, 255, 255 * self.BG_RECT_OPACITY)
+ self.bg_rect_fill = QColor(255, 255, 255, int(255 * self.BG_RECT_OPACITY))
self.qr_finder = QSvgRenderer(":icons/qr_finder.svg")
@@ -99,12 +99,12 @@ def paintEvent(self, _event: QPaintEvent):
# Compute the transform to flip the coordinate system on the x axis
transform_flip = QTransform()
if self.flip_x:
- transform_flip = transform_flip.translate(self.resolution.width(), 0.0)
+ transform_flip = transform_flip.translate(float(self.resolution.width()), 0.0)
transform_flip = transform_flip.scale(-1.0, 1.0)
# Small helper for tuple to QPoint
def toqp(point):
- return QPoint(point[0], point[1])
+ return QPoint(int(point[0]), int(point[1]))
# Starting from here we care about AA
painter.setRenderHint(QPainter.Antialiasing)
diff --git a/electroncash_gui/qt/seed_dialog.py b/electroncash_gui/qt/seed_dialog.py
index 2f77c07bf72d..63188f132348 100644
--- a/electroncash_gui/qt/seed_dialog.py
+++ b/electroncash_gui/qt/seed_dialog.py
@@ -181,7 +181,7 @@ def on_edit(self):
status = ('checksum: ' + ('ok' if is_checksum else 'failed')) if is_wordlist else 'unknown wordlist'
label = 'BIP39' + ' (%s)'%status
self.seed_type_label.setText(label)
- self.parent.next_button.setEnabled(b)
+ self.parent.next_button.setEnabled(bool(b))
if may_clear_warning:
self.seed_warning.setText('')
@@ -200,7 +200,7 @@ def get_text(self):
return self.text_e.text()
def on_edit(self):
- b = self.is_valid(self.get_text())
+ b = bool(self.is_valid(self.get_text()))
self.parent.next_button.setEnabled(b)
diff --git a/electroncash_gui/qt/transaction_dialog.py b/electroncash_gui/qt/transaction_dialog.py
index e86078889fc0..9b6b9b565800 100644
--- a/electroncash_gui/qt/transaction_dialog.py
+++ b/electroncash_gui/qt/transaction_dialog.py
@@ -268,8 +268,10 @@ def broadcast_done(success):
# 5 second cooldown period on broadcast_button after successful
# broadcast
self.last_broadcast_time = time.time()
- self.update() # disables the broadcast button if last_broadcast_time is < BROADCAST_COOLDOWN_SECS seconds ago
- QTimer.singleShot(self.BROADCAST_COOLDOWN_SECS*1e3+100, self.update) # broadcast button will re-enable if we got nothing from server and >= BROADCAST_COOLDOWN_SECS elapsed
+ # disables the broadcast button if last_broadcast_time is < BROADCAST_COOLDOWN_SECS seconds ago
+ self.update()
+ # broadcast button will re-enable if we got nothing from server and >= BROADCAST_COOLDOWN_SECS elapsed
+ QTimer.singleShot(int(self.BROADCAST_COOLDOWN_SECS * 1e3 + 100), self.update)
self.main_window.push_top_level_window(self)
try:
self.main_window.broadcast_transaction(self.tx, self.desc, callback=broadcast_done)
@@ -427,7 +429,8 @@ def update(self):
# Set the proper text (plural / singular form)
self.freeze_button.setText(self._make_freeze_button_text(op, len(spends_coins_mine)))
# Freeze/Unfreeze enabled only for signed transactions or transactions with frozen coins
- self.freeze_button.setEnabled(has_frozen or status_enum in (StatusEnum.Signed, StatusEnum.PartiallySigned))
+ self.freeze_button.setEnabled(
+ bool(has_frozen or status_enum in (StatusEnum.Signed, StatusEnum.PartiallySigned)))
else:
self.freeze_button.setEnabled(False)
self.freeze_button.setText(self._make_freeze_button_text())
@@ -441,12 +444,11 @@ def update(self):
# the "Broadcast" button for a time after a successful broadcast.
# This prevents the user from being able to spam the broadcast
# button. See #1483.
- self.broadcast_button.setEnabled(can_broadcast
- and time.time() - self.last_broadcast_time
- >= self.BROADCAST_COOLDOWN_SECS)
+ self.broadcast_button.setEnabled(
+ bool(can_broadcast and time.time() - self.last_broadcast_time >= self.BROADCAST_COOLDOWN_SECS))
- can_sign = not self.tx.is_complete() and \
- (self.wallet.can_sign(self.tx) or bool(self.main_window.tx_external_keypairs))
+ can_sign = bool(not self.tx.is_complete() and
+ (self.wallet.can_sign(self.tx) or bool(self.main_window.tx_external_keypairs)))
self.sign_button.setEnabled(can_sign)
self.tx_hash_e.setText(tx_hash or _('Unknown'))
if fee is None:
diff --git a/electroncash_gui/qt/util.py b/electroncash_gui/qt/util.py
index 1e9da39ffac1..e4cc7efef2dc 100644
--- a/electroncash_gui/qt/util.py
+++ b/electroncash_gui/qt/util.py
@@ -5,6 +5,7 @@
import queue
import threading
import os
+import weakref
import webbrowser
from collections import namedtuple
from functools import partial, wraps
@@ -27,12 +28,13 @@
dialogs = []
-from electroncash.paymentrequest import PR_UNPAID, PR_PAID, PR_EXPIRED
+from electroncash.paymentrequest import PR_UNCONFIRMED, PR_UNPAID, PR_PAID, PR_EXPIRED
pr_icons = {
PR_UNPAID:":icons/unpaid.svg",
PR_PAID:":icons/confirmed.svg",
- PR_EXPIRED:":icons/expired.svg"
+ PR_EXPIRED:":icons/expired.svg",
+ PR_UNCONFIRMED: ":icons/unconfirmed.svg"
}
def _(message): return message
@@ -522,16 +524,19 @@ def filename_field(config, defaultname, select_msg):
gb.setLayout(vbox)
b1 = QRadioButton()
b1.setText(_("CSV"))
- b1.setChecked(True)
b2 = QRadioButton()
b2.setText(_("JSON"))
+ if defaultname.endswith(".json"):
+ b2.setChecked(True)
+ else:
+ b1.setChecked(True)
vbox.addWidget(b1)
vbox.addWidget(b2)
hbox = QHBoxLayout()
directory = config.get('io_dir', os.path.expanduser('~'))
- path = os.path.join( directory, defaultname )
+ path = os.path.join(directory, defaultname)
filename_e = QLineEdit()
filename_e.setText(path)
@@ -660,7 +665,7 @@ def editItem(self, item, column):
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
def keyPressEvent(self, event):
- if event.key() in [ Qt.Key_F2, Qt.Key_Return ] and self.editor is None:
+ if event.key() in {Qt.Key_F2, Qt.Key_Return} and self.editor is None:
item, col = self.currentItem(), self.currentColumn()
if item and col > -1:
self.on_activated(item, col)
@@ -867,7 +872,7 @@ def _updateOverlayPos(self):
if hasattr(self, 'verticalScrollBar') and self.verticalScrollBar().isVisible():
scrollbar_width = self.style().pixelMetric(QStyle.PM_ScrollBarExtent)
x -= scrollbar_width
- self.overlay_widget.move(x, y)
+ self.overlay_widget.move(int(x), int(y))
def addWidget(self, widget: QWidget, index: int = None):
if index is not None:
@@ -1152,7 +1157,7 @@ def _invoke(self, args, kwargs):
self.timer.timeout.connect(self._doIt)
#self.timer.destroyed.connect(lambda x=None,qn=self.qn: print(qn,"Timer deallocated"))
self.timer.setSingleShot(True)
- self.timer.start(diff*1e3)
+ self.timer.start(int(diff*1e3))
#self.print_error("deferring")
else:
# We had a timer active, which means as future call will occur. So return early and let that call happenin the future.
@@ -1332,6 +1337,7 @@ def webopen(url: str):
else:
webbrowser.open(url)
+
class TextBrowserKeyboardFocusFilter(QTextBrowser):
"""
This is a QTextBrowser that only enables keyboard text selection when the focus reason is
@@ -1354,6 +1360,29 @@ def keyPressEvent(self, e: QKeyEvent):
self.setTextInteractionFlags(self.textInteractionFlags() | Qt.TextSelectableByKeyboard)
super().keyPressEvent(e)
+
+class OnDestroyedMixin:
+ """A mixin class designed to be used with any QObject. It will call the on_destroyed method (which can be
+ overridden), and it offers the property is_destroyed. Used in network_dialog.py. """
+ def __init__(self):
+ assert isinstance(self, QObject)
+ self.is_destroyed = False
+ weak_self = weakref.ref(self)
+
+ def handler(obj):
+ strong_self = weak_self()
+ if strong_self:
+ strong_self.on_destroyed(obj)
+
+ self.destroyed.connect(lambda obj: handler(obj))
+
+ def on_destroyed(self, obj):
+ if self.is_destroyed:
+ return
+ self.is_destroyed = True
+ print_error(f"OnDestroyedMixin, object destroyed: {self!r}")
+
+
if __name__ == "__main__":
app = QApplication([])
t = WaitingDialog(None, 'testing ...', lambda: [time.sleep(1)], lambda x: QMessageBox.information(None, 'done', "done"))
diff --git a/electroncash_gui/qt/utils/aspect_layout.py b/electroncash_gui/qt/utils/aspect_layout.py
index c0c6d9d01372..9debe3c97815 100644
--- a/electroncash_gui/qt/utils/aspect_layout.py
+++ b/electroncash_gui/qt/utils/aspect_layout.py
@@ -70,8 +70,8 @@ def setGeometry(self, rect: QRect):
c_aratio = 1
s_aratio = self.aspect_ratio
item_rect = QRect(QPoint(0, 0), QSize(
- contents.width() if c_aratio < s_aratio else contents.height() * s_aratio,
- contents.height() if c_aratio > s_aratio else contents.width() / s_aratio
+ contents.width() if c_aratio < s_aratio else int(contents.height() * s_aratio),
+ contents.height() if c_aratio > s_aratio else int(contents.width() / s_aratio)
))
content_margins = self.contentsMargins()
@@ -82,7 +82,7 @@ def setGeometry(self, rect: QRect):
if item.alignment() & Qt.AlignRight:
item_rect.moveRight(contents.width() + content_margins.right())
else:
- item_rect.moveLeft(content_margins.left() + (free_space.width() / 2))
+ item_rect.moveLeft(content_margins.left() + (free_space.width() // 2))
else:
item_rect.moveLeft(content_margins.left())
@@ -90,7 +90,7 @@ def setGeometry(self, rect: QRect):
if item.alignment() & Qt.AlignBottom:
item_rect.moveBottom(contents.height() + content_margins.bottom())
else:
- item_rect.moveTop(content_margins.top() + (free_space.height() / 2))
+ item_rect.moveTop(content_margins.top() + (free_space.height() // 2))
else:
item_rect.moveTop(content_margins.top())
diff --git a/electroncash_gui/qt/utils/aspect_svg_widget.py b/electroncash_gui/qt/utils/aspect_svg_widget.py
index 0042e3ee1718..22fbe0a87aaf 100644
--- a/electroncash_gui/qt/utils/aspect_svg_widget.py
+++ b/electroncash_gui/qt/utils/aspect_svg_widget.py
@@ -37,5 +37,5 @@ def __init__(self, width: int, file: str = None, parent: QObject = None):
def sizeHint(self) -> QSize:
svg_size = super().sizeHint()
aspect_ratio = svg_size.width() / svg_size.height()
- size_hint = QSize(self._width, self._width / aspect_ratio)
+ size_hint = QSize(int(self._width), int(self._width / aspect_ratio))
return size_hint
diff --git a/electroncash_gui/qt/utils/color_utils.py b/electroncash_gui/qt/utils/color_utils.py
index 212b46dfacf0..eae5206100f8 100644
--- a/electroncash_gui/qt/utils/color_utils.py
+++ b/electroncash_gui/qt/utils/color_utils.py
@@ -32,8 +32,8 @@ def QColorLerp(a: QColor, b: QColor, t: float):
t = max(min(t, 1.0), 0.0)
i_t = 1.0 - t
return QColor(
- (a.red() * i_t) + (b.red() * t),
- (a.green() * i_t) + (b.green() * t),
- (a.blue() * i_t) + (b.blue() * t),
- (a.alpha() * i_t) + (b.alpha() * t),
+ int((a.red() * i_t) + (b.red() * t)),
+ int((a.green() * i_t) + (b.green() * t)),
+ int((a.blue() * i_t) + (b.blue() * t)),
+ int((a.alpha() * i_t) + (b.alpha() * t)),
)
diff --git a/electroncash_gui/stdio.py b/electroncash_gui/stdio.py
index 3e4bcaa47c2b..90b7bb48fd4c 100644
--- a/electroncash_gui/stdio.py
+++ b/electroncash_gui/stdio.py
@@ -46,7 +46,7 @@ def __init__(self, config, daemon, plugins):
_("[r] - show own receipt addresses"), \
_("[c] - display contacts"), \
_("[b] - print server banner"), \
- _("[q] - quit") ]
+ _("[q] - quit")]
self.num_commands = len(self.commands)
def on_network(self, event, *args):
@@ -101,7 +101,7 @@ def print_history(self):
label = self.wallet.get_label(tx_hash)
messages.append( format_str%( time_str, label, format_satoshis(delta, whitespaces=True), format_satoshis(balance, whitespaces=True) ) )
- self.print_list(messages[::-1], format_str%( _("Date"), _("Description"), _("Amount"), _("Balance")))
+ self.print_list(messages[::-1], format_str%(_("Date"), _("Description"), _("Amount"), _("Balance")))
def print_balance(self):
@@ -110,7 +110,7 @@ def print_balance(self):
def get_balance(self):
if self.wallet.network.is_connected():
if not self.wallet.up_to_date:
- msg = _( "Synchronizing..." )
+ msg = _("Synchronizing...")
else:
c, u, x = self.wallet.get_balance()
msg = _("Balance")+": %f "%(PyDecimal(c) / COIN)
@@ -119,7 +119,7 @@ def get_balance(self):
if x:
msg += " [%f unmatured]"%(PyDecimal(x) / COIN)
else:
- msg = _( "Not connected" )
+ msg = _("Not connected")
return(msg)
@@ -148,8 +148,8 @@ def send_order(self):
self.do_send()
def print_banner(self):
- for i, x in enumerate( self.wallet.network.banner.split('\n') ):
- print( x )
+ for i, x in enumerate(self.wallet.network.banner.split('\n')):
+ print(x)
def print_list(self, lst, firstline):
lst = list(lst)
diff --git a/electroncash_gui/text.py b/electroncash_gui/text.py
index 58c817b67d04..57b354f71980 100644
--- a/electroncash_gui/text.py
+++ b/electroncash_gui/text.py
@@ -79,7 +79,7 @@ def verify_seed(self):
def get_string(self, y, x):
self.set_cursor(1)
curses.echo()
- self.stdscr.addstr( y, x, " "*20, curses.A_REVERSE)
+ self.stdscr.addstr(y, x, " "*20, curses.A_REVERSE)
s = self.stdscr.getstr(y,x)
curses.noecho()
self.set_cursor(0)
@@ -100,7 +100,7 @@ def print_history(self):
if self.history is None:
self.update_history()
- self.print_list(self.history[::-1], format_str%( _("Date"), _("Description"), _("Amount"), _("Balance")))
+ self.print_list(self.history[::-1], format_str%(_("Date"), _("Description"), _("Amount"), _("Balance")))
def update_history(self):
width = [20, 40, 14, 14]
@@ -141,10 +141,10 @@ def print_balance(self):
else:
msg = _("Not connected")
- self.stdscr.addstr( self.maxy -1, 3, msg)
+ self.stdscr.addstr(self.maxy -1, 3, msg)
for i in range(self.num_tabs):
- self.stdscr.addstr( 0, 2 + 2*i + len(''.join(self.tab_names[0:i])), ' '+self.tab_names[i]+' ', curses.A_BOLD if self.tab == i else 0)
+ self.stdscr.addstr(0, 2 + 2*i + len(''.join(self.tab_names[0:i])), ' '+self.tab_names[i]+' ', curses.A_BOLD if self.tab == i else 0)
self.stdscr.addstr(self.maxy -1, self.maxx-30, ' '.join([_("Settings"), _("Network"), _("Quit")]))
@@ -165,9 +165,9 @@ def print_addresses(self):
self.print_list(messages, fmt % ("Address", "Label"))
def print_edit_line(self, y, label, text, index, size):
- text += " "*(size - len(text) )
- self.stdscr.addstr( y, 2, label)
- self.stdscr.addstr( y, 15, text, curses.A_REVERSE if self.pos%6==index else curses.color_pair(1))
+ text += " "*(size - len(text))
+ self.stdscr.addstr(y, 2, label)
+ self.stdscr.addstr(y, 15, text, curses.A_REVERSE if self.pos%6==index else curses.color_pair(1))
def print_send_tab(self):
self.stdscr.clear()
@@ -175,8 +175,8 @@ def print_send_tab(self):
self.print_edit_line(5, _("Description"), self.str_description, 1, 40)
self.print_edit_line(7, _("Amount"), self.str_amount, 2, 15)
self.print_edit_line(9, _("Fee"), self.str_fee, 3, 15)
- self.stdscr.addstr( 12, 15, _("[Send]"), curses.A_REVERSE if self.pos%6==4 else curses.color_pair(2))
- self.stdscr.addstr( 12, 25, _("[Clear]"), curses.A_REVERSE if self.pos%6==5 else curses.color_pair(2))
+ self.stdscr.addstr(12, 15, _("[Send]"), curses.A_REVERSE if self.pos%6==4 else curses.color_pair(2))
+ self.stdscr.addstr(12, 25, _("[Clear]"), curses.A_REVERSE if self.pos%6==5 else curses.color_pair(2))
self.maxpos = 6
def print_banner(self):
@@ -206,13 +206,13 @@ def print_list(self, lst, firstline = None):
if not self.maxpos: return
if firstline:
firstline += " "*(self.maxx -2 - len(firstline))
- self.stdscr.addstr( 1, 1, firstline )
+ self.stdscr.addstr(1, 1, firstline)
for i in range(self.maxy-4):
msg = lst[i] if i < len(lst) else ""
msg += " "*(self.maxx - 2 - len(msg))
m = msg[0:self.maxx - 2]
m = m.encode(self.encoding)
- self.stdscr.addstr( i+2, 1, m, curses.A_REVERSE if i == (self.pos % self.maxpos) else 0)
+ self.stdscr.addstr(i+2, 1, m, curses.A_REVERSE if i == (self.pos % self.maxpos) else 0)
def refresh(self):
if self.tab == -1: return
@@ -401,7 +401,7 @@ def network_dialog(self):
def settings_dialog(self):
fee = str(PyDecimal(self.config.fee_per_kb()) / COIN)
out = self.run_dialog('Settings', [
- {'label':'Default fee', 'type':'satoshis', 'value': fee }
+ {'label':'Default fee', 'type':'satoshis', 'value': fee}
], buttons = 1)
if out:
if out.get('Default fee'):
@@ -419,13 +419,13 @@ def password_dialog(self):
def run_dialog(self, title, items, interval=2, buttons=None, y_pos=3):
self.popup_pos = 0
- self.w = curses.newwin( 5 + len(list(items))*interval + (2 if buttons else 0), 50, y_pos, 5)
+ self.w = curses.newwin(5 + len(list(items))*interval + (2 if buttons else 0), 50, y_pos, 5)
w = self.w
out = {}
while True:
w.clear()
w.border(0)
- w.addstr( 0, 2, title)
+ w.addstr(0, 2, title)
num = len(list(items))
@@ -451,14 +451,14 @@ def run_dialog(self, title, items, interval=2, buttons=None, y_pos=3):
value += ' '*(20-len(value))
if 'value' in item:
- w.addstr( 2+interval*i, 2, label)
- w.addstr( 2+interval*i, 15, value, curses.A_REVERSE if self.popup_pos%numpos==i else curses.color_pair(1) )
+ w.addstr(2+interval*i, 2, label)
+ w.addstr(2+interval*i, 15, value, curses.A_REVERSE if self.popup_pos%numpos==i else curses.color_pair(1))
else:
- w.addstr( 2+interval*i, 2, label, curses.A_REVERSE if self.popup_pos%numpos==i else 0)
+ w.addstr(2+interval*i, 2, label, curses.A_REVERSE if self.popup_pos%numpos==i else 0)
if buttons:
- w.addstr( 5+interval*i, 10, "[ ok ]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-2) else curses.color_pair(2))
- w.addstr( 5+interval*i, 25, "[cancel]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-1) else curses.color_pair(2))
+ w.addstr(5+interval*i, 10, "[ ok ]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-2) else curses.color_pair(2))
+ w.addstr(5+interval*i, 25, "[cancel]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-1) else curses.color_pair(2))
w.refresh()
diff --git a/electroncash_plugins/fusion/conf.py b/electroncash_plugins/fusion/conf.py
index d3804be9b59c..ba1bac53aac4 100644
--- a/electroncash_plugins/fusion/conf.py
+++ b/electroncash_plugins/fusion/conf.py
@@ -46,8 +46,10 @@ class Defaults:
CoinbaseSeenLatch = False
FusionMode = 'normal'
QueudAutofuse = 4
+ FuseDepth = 0 # Fuse forever by default
Selector = ('fraction', 0.1) # coin selector options
SelfFusePlayers = 1 # self-fusing control (1 = just self, more than 1 = self fuse up to N times)
+ SpendOnlyFusedCoins = False # spendable_coin_filter @hook
def __init__(self, wallet):
@@ -113,6 +115,16 @@ def queued_autofuse(self, i : Optional[int]):
i = int(i)
self.wallet.storage.put('cashfusion_queued_autofuse', i)
+ @property
+ def fuse_depth(self) -> int:
+ return int(self.wallet.storage.get('cashfusion_fuse_depth', self.Defaults.FuseDepth))
+ @fuse_depth.setter
+ def fuse_depth(self, i : Optional[int]):
+ if i is not None:
+ assert i >= 0
+ i = int(i)
+ self.wallet.storage.put('cashfusion_fuse_depth', i)
+
@property
def selector(self) -> Tuple[str, Union[int,float]]:
return tuple(self.wallet.storage.get('cashfusion_selector', self.Defaults.Selector))
@@ -132,6 +144,13 @@ def self_fuse_players(self, i : Optional[int]):
i = int(i)
return self.wallet.storage.put('cashfusion_self_fuse_players', i)
+ @property
+ def spend_only_fused_coins(self) -> bool:
+ return bool(self.wallet.storage.get('cashfusion_spend_only_fused_coins', self.Defaults.SpendOnlyFusedCoins))
+ @spend_only_fused_coins.setter
+ def spend_only_fused_coins(self, b: bool):
+ return self.wallet.storage.put('cashfusion_spend_only_fused_coins', bool(b))
+
CashFusionServer = namedtuple("CashFusionServer", ('hostname', 'port', 'ssl'))
diff --git a/electroncash_plugins/fusion/fusion.py b/electroncash_plugins/fusion/fusion.py
index 0d3449f40f1d..3fd2128160c6 100644
--- a/electroncash_plugins/fusion/fusion.py
+++ b/electroncash_plugins/fusion/fusion.py
@@ -86,13 +86,12 @@
def can_fuse_from(wallet):
"""We can only fuse from wallets that are p2pkh, and where we are able
to extract the private key."""
- return (not (wallet.is_watching_only() or wallet.is_hardware() or isinstance(wallet, Multisig_Wallet))
- and networks.net is not networks.TaxCoinNet)
+ return not (wallet.is_watching_only() or wallet.is_hardware() or isinstance(wallet, Multisig_Wallet))
def can_fuse_to(wallet):
"""We can only fuse to wallets that are p2pkh with HD generation. We do
*not* need the private keys."""
- return isinstance(wallet, Standard_Wallet) and networks.net is not networks.TaxCoinNet
+ return isinstance(wallet, Standard_Wallet)
@@ -601,9 +600,9 @@ def allocate_outputs(self,):
# linkage somehow, which means throwing away some sats as extra fees beyond
# the minimum requirement.
- # For now, just throw on a few unobtrusive extra sats at the higher tiers, at most 9.
- # TODO: smarter selection for high tiers (how much will users be comfortable paying?)
- fuzz_fee_max = min(9, scale // 1000000)
+ # Just use (tier / 10^6) as fuzzing range. For a 10 BCH tier this means
+ # randomly overpaying fees of 0 to 1000 sats.
+ fuzz_fee_max = scale // 1000000
### End fuzzing fee range selection ###
@@ -617,9 +616,6 @@ def allocate_outputs(self,):
fuzz_fee = secrets.randbelow(fuzz_fee_max_reduced + 1)
assert fuzz_fee <= fuzz_fee_max_reduced and fuzz_fee_max_reduced <= fuzz_fee_max
- # TODO: this can be removed when the above is updated
- assert fuzz_fee < 100, 'sanity check: example fuzz fee should be small'
-
reduced_avail_for_outputs = avail_for_outputs - fuzz_fee
if reduced_avail_for_outputs < offset_per_output:
continue
diff --git a/electroncash_plugins/fusion/plugin.py b/electroncash_plugins/fusion/plugin.py
index a720dc0aad11..d46926e52e96 100644
--- a/electroncash_plugins/fusion/plugin.py
+++ b/electroncash_plugins/fusion/plugin.py
@@ -34,17 +34,19 @@
from typing import Optional, Tuple
-from electroncash.address import Address
-from electroncash.bitcoin import COINBASE_MATURITY
+from electroncash.address import Address, OpCodes
+from electroncash.bitcoin import COINBASE_MATURITY, TYPE_SCRIPT
from electroncash.plugins import BasePlugin, hook, daemon_command
from electroncash.i18n import _, ngettext, pgettext
from electroncash.util import profiler, PrintError, InvalidPassword
-from electroncash import Network, networks
+from electroncash import Network, networks, Transaction
from .conf import Conf, Global
from .fusion import Fusion, can_fuse_from, can_fuse_to, is_tor_port, MIN_TX_COMPONENTS
from .server import FusionServer
from .covert import limiter
+from .protocol import Protocol
+from .util import get_coin_name
import random # only used to select random coins
@@ -71,6 +73,12 @@
CONSOLIDATE_MAX_OUTPUTS = MIN_TX_COMPONENTS // 3
+# Threshold for the amount (sats) for a wallet to be fully fused. This is to avoid refuse when dusted.
+FUSE_DEPTH_THRESHOLD = 0.95
+
+# We don't allow a fuse depth beyond this in the wallet UI
+MAX_LIMIT_FUSE_DEPTH = 10
+
pnp = None
def get_upnp():
""" return an initialized UPnP singleton """
@@ -293,6 +301,7 @@ def __init__(self, *args, **kwargs):
self.fusions = weakref.WeakKeyDictionary()
self.autofusing_wallets = weakref.WeakKeyDictionary() # wallet -> password
+ self.registered_network_callback = False
self.t_last_net_ok = time.monotonic()
@@ -310,6 +319,11 @@ def __init__(self, *args, **kwargs):
def on_close(self,):
super().on_close()
self.stop_fusion_server()
+ if self.registered_network_callback:
+ self.registered_network_callback = False
+ network = Network.get_instance()
+ if network:
+ network.unregister_callback(self.on_wallet_transaction)
self.active = False
def fullname(self):
@@ -319,7 +333,7 @@ def description(self):
return _("CashFusion Protocol")
def is_available(self):
- return networks.net is not networks.TaxCoinNet
+ return True
def set_remote_donation_address(self, address : str):
self.remote_donation_address = ((isinstance(address, str) and address) or '')[:100]
@@ -466,6 +480,10 @@ def add_wallet(self, wallet, password=None):
wallet._fusions = weakref.WeakSet()
# fusions that were auto-started.
wallet._fusions_auto = weakref.WeakSet()
+ # caache: stores a map of txid -> fusion_depth (or False if txid is not a fuz tx)
+ wallet._cashfusion_is_fuz_txid_cache = dict()
+ # cache: stores a map of address -> fusion_depth if the address has fuz utxos
+ wallet._cashfusion_address_cache = dict()
# all accesses to the above must be protected by wallet.lock
if Conf(wallet).autofuse:
@@ -473,6 +491,9 @@ def add_wallet(self, wallet, password=None):
self.enable_autofusing(wallet, password)
except InvalidPassword:
self.disable_autofusing(wallet)
+ if not self.registered_network_callback and wallet.network:
+ wallet.network.register_callback(self.on_wallet_transaction, ['new_transaction'])
+ self.registered_network_callback = True
def remove_wallet(self, wallet):
''' Detach the provided wallet; returns list of active fusion threads. '''
@@ -484,6 +505,8 @@ def remove_wallet(self, wallet):
fusions = list(wallet._fusions)
del wallet._fusions
del wallet._fusions_auto
+ del wallet._cashfusion_is_fuz_txid_cache
+ del wallet._cashfusion_address_cache
except AttributeError:
pass
return [f for f in fusions if f.is_alive()]
@@ -596,6 +619,19 @@ def run(self, ):
for f in list(wallet._fusions_auto):
f.stop('Wallet has unconfirmed coins... waiting.', not_if_running = True)
continue
+
+ fuse_depth = Conf(wallet).fuse_depth
+ if fuse_depth > 0:
+ sum_eligible_values = 0
+ sum_fuz_values = 0
+ for eaddr, ecoins in eligible:
+ ecoins_value = sum(ecoin['value'] for ecoin in ecoins)
+ sum_eligible_values += ecoins_value
+ if self.is_fuz_address(wallet, eaddr, require_depth=fuse_depth-1):
+ sum_fuz_values += ecoins_value
+ if sum_eligible_values != 0 and sum_fuz_values / sum_eligible_values >= FUSE_DEPTH_THRESHOLD:
+ continue
+
if not dont_start_fusions and num_auto < min(target_num_auto, MAX_AUTOFUSIONS_PER_WALLET):
# we don't have enough auto-fusions running, so start one
fraction = get_target_params_2(wallet_conf, sum_value)
@@ -653,6 +689,162 @@ def donation_address(self, window) -> Optional[Tuple[str,Address]]:
if self.remote_donation_address and Address.is_valid(self.remote_donation_address):
return (self.fullname() + " " + _("Server") + ": " + self.get_server()[0], Address.from_string(self.remote_donation_address))
+ @staticmethod
+ def wallet_can_fuse(wallet) -> bool:
+ return can_fuse_from(wallet) and can_fuse_to(wallet)
+
+ @staticmethod
+ def is_fuz_coin(wallet, coin, *, require_depth=0) -> Optional[bool]:
+ """ Returns True if the coin in question is definitely a CashFusion coin (uses heuristic matching),
+ or False if the coin in question is not from a CashFusion tx. Returns None if the tx for the coin
+ is not (yet) known to the wallet (None == inconclusive answer, caller may wish to try again later).
+ If require_depth is > 0, check recursively; will return True if all ancestors of the coin
+ up to require_depth are also CashFusion transactions belonging to this wallet.
+
+ Precondition: wallet must be a fusion wallet. """
+
+ require_depth = min(max(0, require_depth), 900) # paranoia: clamp to [0, 900]
+
+ cache = wallet._cashfusion_is_fuz_txid_cache
+ assert isinstance(cache, dict)
+ txid = coin['prevout_hash']
+ # check cache, if cache hit, return answer and avoid the lookup below
+ cached_val = cache.get(txid, None)
+ if cached_val is not None:
+ # cache stores either False, or a depth for which the predicate is true
+ if cached_val is False:
+ return False
+ elif cached_val >= require_depth:
+ return True
+
+ my_addresses_seen = set()
+
+ def check_is_fuz_tx():
+ tx = wallet.transactions.get(txid, None)
+ if tx is None:
+ # Not found in wallet.transactions so its fuz status is as yet "unknown". Indicate this.
+ return None
+ inputs = tx.inputs()
+ outputs = tx.outputs()
+ # We expect: OP_RETURN (4) FUZ\x00
+ fuz_prefix = bytes((OpCodes.OP_RETURN, len(Protocol.FUSE_ID))) + Protocol.FUSE_ID
+ # Step 1 - does it have the proper OP_RETURN lokad prefix?
+ for typ, dest, amt in outputs:
+ if amt == 0 and typ == TYPE_SCRIPT and dest.script.startswith(fuz_prefix):
+ break # lokad found, proceed to Step 2 below
+ else:
+ # Nope, lokad prefix not found
+ return False
+ # Step 2 - are at least 1 of the inputs from me? (DoS prevention measure)
+ for inp in inputs:
+ inp_addr = inp.get('address', None)
+ if inp_addr is not None and (inp_addr in my_addresses_seen or wallet.is_mine(inp_addr)):
+ my_addresses_seen.add(inp_addr)
+ if require_depth == 0:
+ return True # This transaction is a CashFusion tx
+ # [Optional] Step 3 - Check if all ancestors up to required_depth are also fusions
+ if not FusionPlugin.is_fuz_coin(wallet, inp, require_depth=require_depth-1):
+ # require_depth specified and not all required_depth parents were CashFusion
+ return False
+ if my_addresses_seen:
+ # require_depth > 0: This tx + all wallet ancestors were CashFusion transactions up to require_depth
+ return True
+ # Failure -- this tx has the lokad but no inputs are "from me".
+ wallet.print_error(f"CashFusion: txid \"{txid}\" has a CashFusion-style OP_RETURN but none of the "
+ f"inputs are from this wallet. This is UNEXPECTED!")
+ return False
+ # /check_is_fuz_tx
+
+ answer = check_is_fuz_tx()
+ if isinstance(answer, bool):
+ # maybe cache the answer if it's a definitive answer True/False
+ if require_depth == 0:
+ # we got an answer for this coin's tx itself
+ if not answer:
+ cache[txid] = False
+ elif not cached_val:
+ # only set the cached val if it was missing previously, to avoid overwriting higher values
+ cache[txid] = 0
+ elif answer and (cached_val is None or cached_val < require_depth):
+ # indicate true up to the depth we just checked
+ cache[txid] = require_depth
+ elif not answer and isinstance(cached_val, int) and cached_val >= require_depth:
+ # this should never happen
+ wallet.print_error(f"CashFusion: WARNING txid \"{txid}\" has inconsistent state in "
+ f"the _cashfusion_is_fuz_txid_cache")
+ if answer:
+ # remember this address as being a "fuzed" address and cache the positive reply
+ cache2 = wallet._cashfusion_address_cache
+ assert isinstance(cache2, dict)
+ addr = coin.get('address', None)
+ if addr:
+ my_addresses_seen.add(addr)
+ for addr in my_addresses_seen:
+ depth = cache2.get(addr, None)
+ if depth is None or depth < require_depth:
+ cache2[addr] = require_depth
+ return answer
+
+ @classmethod
+ def get_coin_fuz_count(cls, wallet, coin, *, require_depth=0):
+ """ Will return a fuz count for a coin. Unfused or unknown coins have count 0, coins
+ that appear in a fuz tx have count 1, coins whose wallet parent txs are all fuz are 2, 3, etc
+ depending on how far back the fuz perdicate is satisfied.
+
+ This function only checks up to 10 ancestors deep so tha maximum return value is 10.
+
+ Precondition: wallet must be a fusion wallet. """
+
+ require_depth = min(max(require_depth, 0), MAX_LIMIT_FUSE_DEPTH - 1)
+ cached_ct = wallet._cashfusion_is_fuz_txid_cache.get(coin['prevout_hash'])
+ if isinstance(cached_ct, int) and cached_ct >= require_depth:
+ return cached_ct + 1
+ ret = 0
+ for i in range(cached_ct or 0, require_depth + 1, 1):
+ ret = i
+ if not cls.is_fuz_coin(wallet, coin, require_depth=i):
+ break
+ return ret
+
+ @classmethod
+ def is_fuz_address(cls, wallet, address, *, require_depth=0):
+ """ Returns True if address contains any fused UTXOs.
+ Optionally, specify require_depth, in which case True is returned
+ if any UTXOs for this address are sufficiently fused to the
+ specified depth.
+
+ If you want thread safety, caller must hold wallet locks.
+
+ Precondition: wallet must be a fusion wallet. """
+
+ assert isinstance(address, Address)
+ require_depth = max(require_depth, 0)
+
+ cache = wallet._cashfusion_address_cache
+ assert isinstance(cache, dict)
+ cached_val = cache.get(address, None)
+ if cached_val is not None and cached_val >= require_depth:
+ return True
+
+ utxos = wallet.get_addr_utxo(address)
+ for coin in utxos.values():
+ if cls.is_fuz_coin(wallet, coin, require_depth=require_depth):
+ if cached_val is None or cached_val < require_depth:
+ cache[address] = require_depth
+ return True
+ return False
+
+ @staticmethod
+ def on_wallet_transaction(event, *args):
+ """ Network object callback. Always called in the Network object's thread. """
+ if event == 'new_transaction':
+ # if this is a fusion wallet, clear the is_fuz_address() cache when new transactions arrive
+ # since we may have spent some utxos and so the cache needs to be invalidated
+ wallet = args[1]
+ if hasattr(wallet, '_cashfusion_address_cache'):
+ with wallet.lock:
+ wallet._cashfusion_address_cache.clear()
+
@daemon_command
def fusion_server_start(self, daemon, config):
# Usage:
diff --git a/electroncash_plugins/fusion/qt.py b/electroncash_plugins/fusion/qt.py
index d8858a27a058..a76062d04dfd 100644
--- a/electroncash_plugins/fusion/qt.py
+++ b/electroncash_plugins/fusion/qt.py
@@ -28,12 +28,14 @@
import weakref
from functools import partial
+from typing import Optional
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from electroncash import networks
+from electroncash.address import Address
from electroncash.i18n import _, ngettext, pgettext
from electroncash.plugins import hook, run_hook
from electroncash.util import (
@@ -50,7 +52,8 @@
from .conf import Conf, Global
from .fusion import can_fuse_from, can_fuse_to
from .server import Params
-from .plugin import FusionPlugin, TOR_PORTS, COIN_FRACTION_FUDGE_FACTOR, select_coins
+from .plugin import FusionPlugin, TOR_PORTS, COIN_FRACTION_FUDGE_FACTOR, select_coins, MAX_LIMIT_FUSE_DEPTH
+from .util import get_coin_name
from pathlib import Path
heredir = Path(__file__).parent
@@ -157,24 +160,42 @@ def do_it(password):
do_it(password)
if coins:
- menu.addAction(ngettext("Input one coin to CashFusion", "Input {count} coins to CashFusion", len(coins)).format(count = len(coins)),
+ menu.addAction(ngettext("Input one coin to CashFusion",
+ "Input {count} coins to CashFusion",
+ len(coins)).format(count=len(coins)),
start_fusion)
+ @staticmethod
+ def get_spend_only_fused_coins_checkbox_attributes(wallet):
+ fuse_depth = Conf(wallet).fuse_depth
+ if fuse_depth > 0:
+ label = ngettext("Spend only fused coins, minimum {min} fusion",
+ "Spend only fused coins, minimum {min} fusions",
+ fuse_depth).format(min=fuse_depth)
+ tooltip = ngettext("If checked, only spend coins that have been anonymized by\n"
+ "CashFusion, after having been fused at least {min} time.",
+ "If checked, only spend coins that have been anonymized by\n"
+ "CashFusion, after having been fused at least {min} times.",
+ fuse_depth).format(min=fuse_depth)
+ else:
+ label = _("Spend only fused coins")
+ tooltip = _("If checked, only spend coins that have been\n"
+ "anonymized by CashFusion at least once.")
+ return label, tooltip
+
@hook
def on_new_window(self, window):
# Called on initial plugin load (if enabled) and every new window; only once per window.
wallet = window.wallet
- can_fuse = can_fuse_from(wallet) and can_fuse_to(wallet)
+ can_fuse = self.wallet_can_fuse(wallet)
if can_fuse:
sbbtn = FusionButton(self, wallet)
self.server_status_changed_signal.connect(sbbtn.update_server_error)
- elif networks.net is networks.TaxCoinNet:
- sbmsg = _('CashFusion is not available on ABC TaxCoin')
- sbbtn = DisabledFusionButton(wallet, sbmsg)
else:
# If we can not fuse we create a dummy fusion button that just displays a message
- sbmsg = _('This wallet type ({wtype}) cannot be used with CashFusion.\n\nPlease use a standard deterministic spending wallet with CashFusion.').format(wtype=wallet.wallet_type)
+ sbmsg = _('This wallet type ({wtype}) cannot be used with CashFusion.\n\n'
+ 'Please use a standard deterministic spending wallet with CashFusion.').format(wtype=wallet.wallet_type)
sbbtn = DisabledFusionButton(wallet, sbmsg)
# bit of a dirty hack, to insert our status bar icon (always using index 4, should put us just after the password-changer icon)
@@ -188,10 +209,32 @@ def on_new_window(self, window):
# (if inter-wallet fusing is added, this should change.)
return
+ # NEW! Set up the send tab "Spend only fused coins" checkbox/control
+ if hasattr(window, 'send_tab_extra_plugin_controls_hbox'):
+ hbox = window.send_tab_extra_plugin_controls_hbox
+ label, tooltip = self.get_spend_only_fused_coins_checkbox_attributes(wallet)
+ spend_only_fused_chk = QCheckBox(label)
+ spend_only_fused_chk.setObjectName('spend_only_fused_chk')
+ spend_only_fused_chk.setToolTip(tooltip)
+ hbox.insertWidget(0, spend_only_fused_chk)
+ spend_only_fused_chk.setChecked(Conf(wallet).spend_only_fused_coins)
+ weak_window = weakref.ref(window)
+ def on_chk(b):
+ window = weak_window()
+ if window:
+ wallet = window.wallet
+ Conf(wallet).spend_only_fused_coins = b
+ window.do_update_fee() # trigger send tab to re-calculate things
+ spend_only_fused_chk.toggled.connect(on_chk)
+ self.widgets.add(spend_only_fused_chk)
+
want_autofuse = Conf(wallet).autofuse
self.add_wallet(wallet, window.gui_object.get_cached_password(wallet))
sbbtn.update_state()
+ # Set up the utxo_list column
+ self.patch_utxo_list(window.utxo_list)
+
# prompt for password if auto-fuse was enabled
if want_autofuse and not self.is_autofusing(wallet):
def callback(password):
@@ -203,8 +246,160 @@ def callback(password):
d.show()
self.widgets.add(d)
+ @staticmethod
+ def patch_utxo_list(utxo_list):
+ if getattr(utxo_list, '_fusion_patched_', None) is not None:
+ return
+ header = utxo_list.headerItem()
+ header_labels = [header.text(i) for i in range(header.columnCount())]
+ header_labels.append(_("Fusion Status"))
+ utxo_list.update_headers(header_labels)
+ utxo_list._fusion_patched_ = header_labels[-1] # save the text to be able to find the column later
+ utxo_list.wallet.print_error("[fusion] Patched utxo_list")
+
+ @staticmethod
+ def find_utxo_list_fusion_column(utxo_list, *, just_column=False):
+ label_text = getattr(utxo_list, '_fusion_patched_', None)
+ if label_text is None:
+ return
+ header = utxo_list.headerItem()
+ if just_column:
+ # fast path, query just the column
+ col_ct = header.columnCount()
+ if not col_ct:
+ return None
+ # iterate in reverse (it's likely last)
+ for i in range(col_ct - 1, -1, -1):
+ if header.text(i) == label_text:
+ return i
+ else:
+ header_labels = [header.text(i) for i in range(header.columnCount())]
+ col = len(header_labels) - 1
+ # find the column, iterate in reverse since it's likely last
+ for i, lbl in enumerate(reversed(header_labels)):
+ if lbl == label_text:
+ col = len(header_labels) - 1 - i
+ break
+ return col, header, header_labels
+
+ @staticmethod
+ def unpatch_utxo_list(utxo_list):
+ tup = Plugin.find_utxo_list_fusion_column(utxo_list)
+ if tup is None:
+ return
+ col, header, header_labels = tup
+ del header_labels[col]
+ utxo_list.update_headers(header_labels)
+ delattr(utxo_list, '_fusion_patched_')
+ utxo_list.wallet.print_error("[fusion] Unpatched utxo_list")
+
+ @hook
+ def utxo_list_item_setup(self, utxo_list, item, utxo, name):
+ col = self.find_utxo_list_fusion_column(utxo_list, just_column=True)
+ if col is None:
+ return
+
+ wallet = utxo_list.wallet
+ fuse_depth = Conf(wallet).fuse_depth
+ frozenstring = item.data(0, utxo_list.DataRoles.frozen_flags) or ""
+ is_slp = 's' in frozenstring
+ is_fused = self.is_fuz_coin(wallet, utxo, require_depth=fuse_depth-1)
+ is_partially_fused = is_fused if fuse_depth <= 1 else self.is_fuz_coin(wallet, utxo)
+
+ item.setIcon(col, QIcon())
+ if is_slp:
+ item.setText(col, _("SLP Token"))
+ elif is_fused:
+ item.setText(col, _("Fused"))
+ item.setIcon(col, icon_fusion_logo)
+ elif is_partially_fused:
+ count = self.get_coin_fuz_count(wallet, utxo, require_depth=fuse_depth-1)
+ item.setText(col, _("Partial {count}/{total}").format(count=count, total=fuse_depth))
+ item.setIcon(col, icon_fusion_logo_gray)
+ elif self.is_fuz_address(wallet, utxo['address'], require_depth=fuse_depth-1):
+ item.setText(col, _("Fuse Addr"))
+ item.setIcon(col, icon_fusion_logo)
+ item.setToolTip(col, _("This coin shares an address with a fused coin. Do not spend separately."))
+ elif utxo['height'] <= 0:
+ item.setText(col, _("Unconfirmed"))
+ elif utxo['coinbase']:
+ # we disallow coinbase coins unconditionally -- due to miner feedback (they don't like shuffling these)
+ item.setText(col, _("Coinbase"))
+ else:
+ item.setText(col, _("Unfused"))
+
+ @hook
+ def spendable_coin_filter(self, window, coins):
+ """ Invoked by the send tab to filter out coins that aren't fused if the wallet has
+ 'spend only fused coins' enabled. """
+ if not coins or not hasattr(window, 'wallet'):
+ return
+
+ wallet = window.wallet
+ if not Conf(wallet).spend_only_fused_coins or not self.wallet_can_fuse(wallet):
+ return
+
+ # external_coins_addresses is only ever used if they are doing a sweep. in which case we always allow the coins
+ # involved in the sweep
+ external_coin_addresses = set()
+ if hasattr(window, 'tx_external_keypairs'):
+ for pubkey in window.tx_external_keypairs:
+ a = Address.from_pubkey(pubkey)
+ external_coin_addresses.add(a)
+
+ # we can ONLY spend fused coins + ununfused living on a fused coin address
+ fuz_adrs_seen = set()
+ fuz_coins_seen = set()
+ with wallet.lock:
+ for coin in coins.copy():
+ if coin['address'] in external_coin_addresses:
+ # completely bypass this filter for external keypair dict
+ # which is only used for sweep dialog in send tab
+ continue
+ fuse_depth = Conf(wallet).fuse_depth
+ is_fuz_adr = self.is_fuz_address(wallet, coin['address'], require_depth=fuse_depth-1)
+ if is_fuz_adr:
+ fuz_adrs_seen.add(coin['address'])
+ # we allow coins sitting on a fused address to be "spent as fused"
+ if not self.is_fuz_coin(wallet, coin, require_depth=fuse_depth-1) and not is_fuz_adr:
+ coins.remove(coin)
+ else:
+ fuz_coins_seen.add(get_coin_name(coin))
+ # Force co-spending of other coins sitting on a fuzed address
+ for adr in fuz_adrs_seen:
+ adr_coins = wallet.get_addr_utxo(adr)
+ for name, adr_coin in adr_coins.items():
+ if (name not in fuz_coins_seen
+ and not adr_coin['is_frozen_coin']
+ and adr_coin.get('slp_token') is None
+ and not adr_coin.get('coinbase')):
+ coins.append(adr_coin)
+ fuz_coins_seen.add(name)
+
+ @hook
+ def not_enough_funds_extra(self, window) -> Optional[str]:
+ """ Called by the Qt UI if there is a "not enough funds" error in the send tab """
+ wallet = window.wallet
+ if not self.wallet_can_fuse(wallet):
+ return
+ conf = Conf(wallet)
+ if not conf.spend_only_fused_coins:
+ return
+ needs_fuz = [coin for coin in wallet.get_utxos(exclude_frozen=True, mature=True,
+ confirmed_only=bool(window.config.get('confirmed_only', False)))
+ if not self.is_fuz_coin(wallet, coin, require_depth=conf.fuse_depth-1)]
+ total = sum(c['value'] for c in needs_fuz)
+ n_coins = len(needs_fuz)
+ if total and needs_fuz:
+ return ngettext("{total_bch} in {n_coins} unfused coin",
+ "{total_bch} in {n_coins} unfused coins", n_coins).format(
+ total_bch=window.format_amount(total) + ' ' + window.base_unit(),
+ n_coins=n_coins
+ )
+
@hook
def on_close_window(self, window):
+ self.unpatch_utxo_list(window.utxo_list)
# Invoked when closing wallet or entire application
# Also called by on_close, above.
wallet = window.wallet
@@ -928,6 +1123,8 @@ def scan_torport_loop(self, ):
class WalletSettingsDialog(WindowModalDialog):
+ GUI_DEFAULT_FUSE_DEPTH = 3 # This what the fuse depth spinbox defaults to, if checked (on new installs)
+
def __init__(self, parent, plugin, wallet):
super().__init__(parent=parent, title=_("CashFusion - Wallet Settings"))
self.setWindowIcon(icon_fusion_logo)
@@ -953,15 +1150,32 @@ def __init__(self, parent, plugin, wallet):
main_layout.addLayout(hbox)
+ self.gb_fuse_depth = gb = QGroupBox(_("Fusion Rounds"))
+ gb.setToolTip(_("If checked, CashFusion will fuse each coin this many times.\n"
+ "If unchecked, Cashfusion will fuse indefinitely until paused."))
+ hbox = QHBoxLayout(gb)
+ self.chk_fuse_depth = chk = QCheckBox(_("Fuse coins this many times"))
+ hbox.addWidget(chk, 1)
+ self.sb_fuse_depth = sb = QSpinBox()
+ sb.setRange(1, MAX_LIMIT_FUSE_DEPTH)
+ if self.conf.fuse_depth <= 0:
+ # Default it to this if unchecked
+ self.sb_fuse_depth.setValue(self.GUI_DEFAULT_FUSE_DEPTH)
+ sb.setMinimumWidth(75)
+ hbox.addWidget(sb)
+ chk.toggled.connect(self.edited_fuse_depth)
+ sb.valueChanged.connect(self.edited_fuse_depth)
+ main_layout.addWidget(gb)
+
self.gb_coinbase = gb = QGroupBox(_("Coinbase Coins"))
vbox = QVBoxLayout(gb)
self.cb_coinbase = QCheckBox(_('Auto-fuse coinbase coins (if mature)'))
self.cb_coinbase.clicked.connect(self._on_cb_coinbase)
vbox.addWidget(self.cb_coinbase)
- # The coinbase-related group box is hidden by default. It becomes
- # visible permanently when the wallet settings dialog has seen at least
- # one coinbase coin, indicating a miner's wallet. For most users the
- # coinbase checkbox is confusing, which is why we prefer to hide it.
+ # The coinbase-related group box is hidden by default. It becomes
+ # visible permanently when the wallet settings dialog has seen at least
+ # one coinbase coin, indicating a miner's wallet. For most users the
+ # coinbase checkbox is confusing, which is why we prefer to hide it.
gb.setHidden(True)
main_layout.addWidget(gb)
@@ -1160,7 +1374,7 @@ def refresh(self):
edit_widgets = [self.amt_selector_size, self.sb_selector_fraction, self.sb_selector_count, self.sb_queued_autofuse,
self.cb_autofuse_only_all_confirmed, self.combo_self_fuse, self.stacked_layout, self.mode_cb,
- self.cb_coinbase]
+ self.cb_coinbase, self.sb_fuse_depth, self.chk_fuse_depth]
try:
for w in edit_widgets:
# Block spurious editingFinished signals and valueChanged signals as
@@ -1188,6 +1402,11 @@ def refresh(self):
self.combo_self_fuse.setCurrentIndex(idx)
del idx
+ if self.conf.fuse_depth > 0:
+ self.sb_fuse_depth.setValue(self.conf.fuse_depth)
+ self.chk_fuse_depth.setChecked(self.conf.fuse_depth > 0)
+ self.sb_fuse_depth.setEnabled(self.conf.fuse_depth > 0)
+
if is_custom_page:
self.amt_selector_size.setEnabled(select_type == 'size')
self.sb_selector_count.setEnabled(select_type == 'count')
@@ -1210,7 +1429,7 @@ def refresh(self):
else:
self.conf.selector = None
return self.refresh()
- sel_count = COIN_FRACTION_FUDGE_FACTOR / max(sel_fraction, 0.001)
+ sel_count = round(COIN_FRACTION_FUDGE_FACTOR / max(sel_fraction, 0.001))
self.amt_selector_size.setAmount(round(sel_size))
self.sb_selector_fraction.setValue(max(min(sel_fraction, 1.0), 0.001) * 100.0)
self.sb_selector_count.setValue(sel_count)
@@ -1250,6 +1469,26 @@ def edited_queued_autofuse(self,):
f.stop('User decreased queued-fuse limit', not_if_running = True)
self.refresh()
+ def edited_fuse_depth(self,):
+ prevval = self.conf.fuse_depth
+ newval = self.sb_fuse_depth.value() if self.chk_fuse_depth.isChecked() else 0
+ self.conf.fuse_depth = newval
+ if prevval == 0 or (prevval > newval and newval != 0):
+ for f in list(self.wallet._fusions_auto):
+ f.stop('User decreased fuse depth limit', not_if_running = False)
+ # update the send tab label for the "spend only confirmed coins" checkbox
+ main_window = self.wallet.weak_window and self.wallet.weak_window()
+ if main_window:
+ chk = main_window.findChild(QCheckBox, 'spend_only_fused_chk', Qt.FindChildrenRecursively)
+ if chk:
+ label, tooltip = self.plugin.get_spend_only_fused_coins_checkbox_attributes(self.wallet)
+ chk.setText(label)
+ chk.setToolTip(tooltip)
+ # Coins tab may need redisplay if we changed these settings
+ if prevval != newval:
+ main_window.utxo_list.update()
+ self.refresh()
+
def clicked_confirmed_only(self, checked):
self.conf.autofuse_confirmed_only = checked
self.refresh()
diff --git a/electroncash_plugins/fusion/server.py b/electroncash_plugins/fusion/server.py
index e72a2f42f6ca..1873ef386045 100644
--- a/electroncash_plugins/fusion/server.py
+++ b/electroncash_plugins/fusion/server.py
@@ -39,6 +39,7 @@
import electroncash.schnorr as schnorr
from electroncash.address import Address
+from electroncash import networks
from electroncash.util import PrintError, ServerError, TimeoutException
from . import fusion_pb2 as pb
from . import compatibility
@@ -232,6 +233,7 @@ def __init__(self, config, network, bindhost, port, upnp = None, announcehost =
super().__init__(bindhost, port, ClientThread, upnp = upnp)
self.config = config
self.network = network
+ self.is_testnet = networks.net.TESTNET
self.announcehost = announcehost
self.donation_address = donation_address
self.waiting_pools = {t: WaitingPool(Params.min_clients, Params.max_tier_client_tags) for t in Params.tiers}
@@ -343,8 +345,9 @@ def new_client_job(self, client):
start_ev = threading.Event()
client.start_ev = start_ev
- if client_ip.startswith('127.'):
+ if self.is_testnet or client_ip.startswith('127.'):
# localhost is whitelisted to allow unlimited access
+ # we also allow unlimited access for testnets
client.tags = []
else:
# Default tag: this IP cannot be present in too many fuses.
diff --git a/electroncash_plugins/fusion/util.py b/electroncash_plugins/fusion/util.py
index 16a497514697..5eb6706f6f9d 100644
--- a/electroncash_plugins/fusion/util.py
+++ b/electroncash_plugins/fusion/util.py
@@ -36,6 +36,7 @@
import hashlib
import ecdsa
+from typing import Union, Tuple
# Internally used exceptions, shouldn't leak out of this plugin.
class FusionError(Exception):
@@ -165,3 +166,11 @@ def rand_position(seed, num_positions, counter):
"""
int64 = int.from_bytes(sha256(seed + counter.to_bytes(4, 'big'))[:8], 'big')
return (int64 * num_positions) >> 64
+
+
+def get_coin_name(coin: dict, also_return_txid_n=False) -> Union[str, Tuple[str, str, int]]:
+ tx_id, n = coin['prevout_hash'], coin['prevout_n']
+ name = "{}:{}".format(tx_id, n)
+ if not also_return_txid_n:
+ return name
+ return name, tx_id, n
diff --git a/electroncash_plugins/hw_wallet/cmdline.py b/electroncash_plugins/hw_wallet/cmdline.py
index 83c1929217a5..114348bfbd51 100644
--- a/electroncash_plugins/hw_wallet/cmdline.py
+++ b/electroncash_plugins/hw_wallet/cmdline.py
@@ -8,7 +8,7 @@ def get_passphrase(self, msg, confirm):
return getpass.getpass('')
def get_pin(self, msg):
- t = { 'a':'7', 'b':'8', 'c':'9', 'd':'4', 'e':'5', 'f':'6', 'g':'1', 'h':'2', 'i':'3'}
+ t = {'a':'7', 'b':'8', 'c':'9', 'd':'4', 'e':'5', 'f':'6', 'g':'1', 'h':'2', 'i':'3'}
print_msg(msg)
print_msg("a b c\nd e f\ng h i\n-----")
o = raw_input()
diff --git a/electroncash_plugins/keepkey/keepkey.py b/electroncash_plugins/keepkey/keepkey.py
index cb631271cf73..9abdcb1dbf51 100644
--- a/electroncash_plugins/keepkey/keepkey.py
+++ b/electroncash_plugins/keepkey/keepkey.py
@@ -312,16 +312,16 @@ def get_xpub(self, device_id, derivation, xtype, wizard):
return xpub
def get_keepkey_input_script_type(self, electrum_txin_type: str):
- if electrum_txin_type in ('p2pkh', ):
+ if electrum_txin_type in ('p2pkh',):
return self.types.SPENDADDRESS
- if electrum_txin_type in ('p2sh', ):
+ if electrum_txin_type in ('p2sh',):
return self.types.SPENDMULTISIG
raise ValueError('unexpected txin type: {}'.format(electrum_txin_type))
def get_keepkey_output_script_type(self, electrum_txin_type: str):
- if electrum_txin_type in ('p2pkh', ):
+ if electrum_txin_type in ('p2pkh',):
return self.types.PAYTOADDRESS
- if electrum_txin_type in ('p2sh', ):
+ if electrum_txin_type in ('p2sh',):
return self.types.PAYTOMULTISIG
raise ValueError('unexpected txin type: {}'.format(electrum_txin_type))
diff --git a/electroncash_plugins/ledger/auth2fa.py b/electroncash_plugins/ledger/auth2fa.py
index d7a8a5f0d892..e2bc13786e30 100644
--- a/electroncash_plugins/ledger/auth2fa.py
+++ b/electroncash_plugins/ledger/auth2fa.py
@@ -15,7 +15,7 @@
"put your cursor into it, and plug your device into that computer. " \
"It will output a summary of the transaction being signed and a one-time PIN.
" \
"Verify the transaction summary and type the PIN code here.
" \
- "Before pressing enter, plug the device back into this computer.
" ),
+ "Before pressing enter, plug the device back into this computer.
"),
_("Verify the address below.
Type the character from your security card corresponding to the BOLD character.")
]
@@ -150,7 +150,7 @@ def update_dlg(self):
def getDevice2FAMode(self):
apdu = [0xe0, 0x24, 0x01, 0x00, 0x00, 0x01] # get 2fa mode
try:
- mode = self.dongle.exchange( bytearray(apdu) )
+ mode = self.dongle.exchange(bytearray(apdu))
return mode
except BTChipException as e:
print_error('Device getMode Failed')
diff --git a/electroncash_plugins/ledger/ledger.py b/electroncash_plugins/ledger/ledger.py
index 5d2b5a7e3dec..6ab90db4164c 100644
--- a/electroncash_plugins/ledger/ledger.py
+++ b/electroncash_plugins/ledger/ledger.py
@@ -180,7 +180,7 @@ def perform_hw1_preflight(self):
except BTChipException as e:
if (e.sw == 0x6985):
self.dongleObject.dongle.close()
- self.handler.get_setup( )
+ self.handler.get_setup()
# Acquire the new client on the next run
else:
raise e
diff --git a/electroncash_plugins/shuffle_deprecated/qt.py b/electroncash_plugins/shuffle_deprecated/qt.py
index 6e6d51e20d55..e019d8a5eeca 100644
--- a/electroncash_plugins/shuffle_deprecated/qt.py
+++ b/electroncash_plugins/shuffle_deprecated/qt.py
@@ -66,8 +66,33 @@ def network_callback(window, event, *args):
if len(args) == 2 and hasattr(window, 'wallet') and args[1] is window.wallet and args[0]:
window._shuffle_sigs.tx.emit(window, args[0])
+def find_utxo_list_shuffle_column(utxo_list, *, just_column=False):
+ label_text = getattr(utxo_list, '_shuffle_patched_', None)
+ if label_text is None:
+ return
+ header = utxo_list.headerItem()
+ if just_column:
+ # fast path, query just the column
+ col_ct = header.columnCount()
+ if not col_ct:
+ return
+ # iterate in reverse (it's likely last)
+ for i in range(col_ct - 1, -1, -1):
+ if header.text(i) == label_text:
+ return i
+ else:
+ header_labels = [header.text(i) for i in range(header.columnCount())]
+ col = len(header_labels) - 1
+ # find the column, iterate in reverse since it's likely last
+ for i, lbl in enumerate(reversed(header_labels)):
+ if lbl == label_text:
+ col = len(header_labels) - 1 - i
+ break
+ return col, header, header_labels
+
def my_custom_item_setup(utxo_list, item, utxo, name):
- if not hasattr(utxo_list.wallet, 'is_coin_shuffled'):
+ col = find_utxo_list_shuffle_column(utxo_list, just_column=True)
+ if col is None:
return
prog = utxo_list.in_progress.get(name, "")
@@ -78,51 +103,51 @@ def my_custom_item_setup(utxo_list, item, utxo, name):
u_value = utxo['value']
if is_slp:
- item.setText(5, _("SLP Token"))
+ item.setText(col, _("SLP Token"))
elif not is_reshuffle and utxo_list.wallet.is_coin_shuffled(utxo): # already shuffled
- item.setText(5, _("Shuffled"))
+ item.setText(col, _("Shuffled"))
elif not is_reshuffle and utxo['address'] in utxo_list.wallet._shuffled_address_cache: # we hit the cache directly as a performance hack. we don't really need a super-accurate reply as this is for UI and the cache will eventually be accurate
- item.setText(5, _("Shuffled Addr"))
+ item.setText(col, _("Shuffled Addr"))
elif not prog and ("a" in frozenstring or "c" in frozenstring):
- item.setText(5, _("Frozen"))
+ item.setText(col, _("Frozen"))
elif u_value >= BackgroundShufflingThread.UPPER_BOUND: # too big
- item.setText(5, _("Too big"))
+ item.setText(col, _("Too big"))
elif u_value < BackgroundShufflingThread.LOWER_BOUND: # too small
- item.setText(5, _("Too small"))
+ item.setText(col, _("Too small"))
elif utxo['height'] <= 0: # not_confirmed
if is_reshuffle:
- item.setText(5, _("Unconfirmed (reshuf)"))
+ item.setText(col, _("Unconfirmed (reshuf)"))
else:
- item.setText(5, _("Unconfirmed"))
+ item.setText(col, _("Unconfirmed"))
elif utxo['coinbase']: # we disallow coinbase coins unconditionally -- due to miner feedback (they don't like shuffling these)
- item.setText(5, _("Coinbase"))
+ item.setText(col, _("Coinbase"))
elif (u_value >= BackgroundShufflingThread.LOWER_BOUND
and u_value < BackgroundShufflingThread.UPPER_BOUND): # queued_labels
window = utxo_list.parent
if (window and window.background_process and utxo_list.wallet.network
and utxo_list.wallet.network.is_connected()):
if window.background_process.get_paused():
- item.setText(5, _("Paused"))
+ item.setText(col, _("Paused"))
else:
if is_reshuffle:
- item.setText(5, _("In queue (reshuf)"))
+ item.setText(col, _("In queue (reshuf)"))
else:
- item.setText(5, _("In queue"))
+ item.setText(col, _("In queue"))
else:
- item.setText(5, _("Offline"))
+ item.setText(col, _("Offline"))
if prog == 'in progress': # in progress
- item.setText(5, _("In progress"))
+ item.setText(col, _("In progress"))
elif prog.startswith('phase '):
- item.setText(5, _("Phase {}").format(prog.split()[-1]))
+ item.setText(col, _("Phase {}").format(prog.split()[-1]))
elif prog == 'wait for others': # wait for others
- item.setText(5, _("Wait for others"))
+ item.setText(col, _("Wait for others"))
elif prog.startswith("got players"): # got players > 1
num, tot = (int(x) for x in prog.rsplit(' ', 2)[-2:])
txt = "{} ({}/{})".format(_("Players"), num, tot)
- item.setText(5, txt)
+ item.setText(col, txt)
elif prog == "completed":
- item.setText(5, _("Done"))
+ item.setText(col, _("Done"))
def my_custom_utxo_context_menu_setup(window, utxo_list, menu, selected):
''' Adds CashShuffle related actions to the utxo_list context (right-click)
@@ -429,14 +454,14 @@ class Sigs(QObject):
print_error("[shuffle] Patched window")
def patch_utxo_list(utxo_list):
- if getattr(utxo_list, '_shuffle_patched_', None):
+ if getattr(utxo_list, '_shuffle_patched_', None) is not None:
return
header = utxo_list.headerItem()
header_labels = [header.text(i) for i in range(header.columnCount())]
header_labels.append(_("Shuffle status"))
utxo_list.update_headers(header_labels)
utxo_list.in_progress = dict()
- utxo_list._shuffle_patched_ = True
+ utxo_list._shuffle_patched_ = header_labels[-1] # save the text to be able to find the column later
print_error("[shuffle] Patched utxo_list")
def patch_wallet(wallet):
@@ -494,11 +519,11 @@ def restore_window(window):
# Note that at this point an additional monkey patch: 'window.__disabled_sendtab_extra__' may stick around until the plugin is unloaded altogether
def restore_utxo_list(utxo_list):
- if not getattr(utxo_list, '_shuffle_patched_', None):
+ tup = find_utxo_list_shuffle_column(utxo_list)
+ if not tup:
return
- header = utxo_list.headerItem()
- header_labels = [header.text(i) for i in range(header.columnCount())]
- del header_labels[-1]
+ col, header, header_labels = tup
+ del header_labels[col]
utxo_list.update_headers(header_labels)
utxo_list.in_progress = None
delattr(window.utxo_list, "in_progress")
@@ -545,7 +570,7 @@ def description(self):
return _("CashShuffle Protocol")
def is_available(self):
- return networks.net is not networks.TaxCoinNet
+ return True
def __init__(self, parent, config, name):
BasePlugin.__init__(self, parent, config, name)
diff --git a/ios/CustomCode/ViewsForIB.h b/ios/CustomCode/ViewsForIB.h
index a1031d39fac7..e903818628df 100644
--- a/ios/CustomCode/ViewsForIB.h
+++ b/ios/CustomCode/ViewsForIB.h
@@ -20,6 +20,8 @@
@end
@interface CustomNavController : UINavigationController
+@property (class, nonatomic, copy, nullable) UIColor *topNavBGColor;
+@property (class, nonatomic, copy, nullable) UIColor *topNavTextColor;
@end
@interface AddrConvBase : CustomViewController
@@ -76,6 +78,7 @@
@property (nonatomic, weak) IBOutlet UIButton *contactBut;
@property (nonatomic, weak) IBOutlet UILabel *descTit;
@property (nonatomic, weak) IBOutlet UITextView *desc;
+@property (nonatomic, weak) IBOutlet UITextView *opReturn;
@property (nonatomic, weak) IBOutlet UILabel *amtTit;
@property (nonatomic, weak) IBOutlet BTCAmountEdit *amt;
@property (nonatomic, weak) IBOutlet UIButton *maxBut;
@@ -87,14 +90,17 @@
@property (nonatomic, weak) IBOutlet UIBarButtonItem *clearBut;
@property (nonatomic, weak) IBOutlet UIBarButtonItem *previewBut;
@property (nonatomic, weak) IBOutlet UIButton *sendBut; // actually a subview of a UIBarButtonItem
+@property (nonatomic, weak) IBOutlet UIButton *opReturnToggle;
@property (nonatomic, weak) IBOutlet UILabel *message;
@property (nonatomic, weak) IBOutlet NSLayoutConstraint *csFeeTop, *csTvHeight, *csPayToTop, *csContentHeight;
@property (nonatomic, weak) IBOutlet UITableView *tv;
@property (nonatomic, weak) IBOutlet UIView *bottomView, *messageView;
@property (nonatomic, strong) IBOutlet ECTextViewDelegate *descDel;
+@property (nonatomic, strong) IBOutlet ECTextViewDelegate *opReturnDel;
@end
@interface SendVC : SendBase
+-(IBAction)onToggleRawOpReturn; // implemented in python send.py
-(IBAction)onQRBut:(id)sender; // implemented in python send.py
-(IBAction)onContactBut:(id)sender; // implemented in python send.py
-(IBAction)clear; // implemented in python send.py
diff --git a/ios/CustomCode/ViewsForIB.m b/ios/CustomCode/ViewsForIB.m
index 6d13b9405aed..94d1d89762ff 100644
--- a/ios/CustomCode/ViewsForIB.m
+++ b/ios/CustomCode/ViewsForIB.m
@@ -24,6 +24,35 @@ static void applyWorkaround(UIViewController *vc) {
vc.modalPresentationStyle = UIModalPresentationFullScreen;
NSLog(@"iOS 13+ workaround: forcing presentation style to fullscreen for %@", [vc description]);
}
+ // Another workaround for iOS 15+ breaking stuff. :(
+ // Ugh. see: https://developer.apple.com/forums/thread/682420
+ if (@available(iOS 15, *)) {
+ if ([vc isKindOfClass:[UINavigationController class]]) {
+ UINavigationController *nav = (UINavigationController *)vc;
+ UINavigationBar *bar = nav.navigationBar;
+ if (bar) {
+ UINavigationBarAppearance *appearance = [UINavigationBarAppearance new];
+ [appearance configureWithOpaqueBackground];
+ // HACK!!
+ if (CustomNavController.topNavBGColor) {
+ appearance.backgroundColor = CustomNavController.topNavBGColor;
+ }
+ if (CustomNavController.topNavTextColor) {
+ NSMutableDictionary *dict = nil;
+ if (appearance.titleTextAttributes)
+ dict = [NSMutableDictionary dictionaryWithDictionary:appearance.titleTextAttributes];
+ else
+ dict = [NSMutableDictionary new];
+ [dict setValue:[CustomNavController.topNavTextColor copy] forKey:NSForegroundColorAttributeName];
+ appearance.titleTextAttributes = dict;
+ }
+ bar.standardAppearance = appearance;
+ bar.scrollEdgeAppearance = bar.standardAppearance;
+ NSLog(@"iOS 15+ workaround: applying navBar standardAppearance workaround for %@",
+ [vc description]);
+ }
+ }
+ }
}
@implementation CustomViewController
@@ -82,6 +111,14 @@ - (void)presentViewController:(UIViewController *)viewControllerToPresent
applyWorkaround(viewControllerToPresent);
[super presentViewController:viewControllerToPresent animated:flag completion:completion];
}
+// Class properties
+static UIColor *s_topNavBGColor = nil;
++ (void) setTopNavBGColor:(UIColor *)c { s_topNavBGColor = [c copy]; }
++ (UIColor *) topNavBGColor { return s_topNavBGColor; }
+
+static UIColor *s_topNavTextColor = nil;
++ (void) setTopNavTextColor:(UIColor *)c { s_topNavTextColor = [c copy]; }
++ (UIColor *) topNavTextColor { return s_topNavTextColor; }
@end
@implementation AddrConvBase
diff --git a/ios/ElectronCash/app.py b/ios/ElectronCash/app.py
index 6658e0a0ed80..ab6775112c75 100644
--- a/ios/ElectronCash/app.py
+++ b/ios/ElectronCash/app.py
@@ -5,7 +5,7 @@
# MIT License
#
import os
-from electroncash_gui.ios_native.monkeypatches import MonkeyPatches
+from electroncash_gui.ios_native.monkeypatches import MonkeyPatches, PatchedSimpleConfig
from electroncash.util import set_verbosity
from electroncash_gui.ios_native import ElectrumGui
from electroncash_gui.ios_native.utils import call_later, get_user_dir, cleanup_tmp_dir, is_debug_build, NSLogSuppress, NSLog
@@ -21,7 +21,6 @@ def main():
'cmd': 'gui',
'gui': 'ios_native',
'cwd': os.getcwd(),
- 'whitelist_servers_only' : True, # on iOS we force only the whitelist ('preferred') servers only for now as a security measure
'testnet': 'EC_TESTNET' in os.environ, # You can set the env when testing using Xcode "Scheme" editor
}
@@ -35,7 +34,7 @@ def main():
MonkeyPatches.patch()
- config = SimpleConfig(config_options, read_user_dir_function = get_user_dir)
+ config = PatchedSimpleConfig(config_options, read_user_dir_function=get_user_dir)
gui = ElectrumGui(config)
call_later(0.010, gui.main) # this is required for the activity indicator to actually animate. Switch to a direct call if not using activity indicator on Splash2
diff --git a/ios/ElectronCash/electroncash_gui/ios_native/amountedit.py b/ios/ElectronCash/electroncash_gui/ios_native/amountedit.py
index 5cca2c245eac..7e69ccd1d96e 100644
--- a/ios/ElectronCash/electroncash_gui/ios_native/amountedit.py
+++ b/ios/ElectronCash/electroncash_gui/ios_native/amountedit.py
@@ -117,13 +117,13 @@ def autosizeUnitLabel(self) -> None:
self.unitLabel.frame = f
supf = f
supf.size.width += 10.0 # 10 pix padding on right
- self.unitLabel.superview().frame = supf
+ utils.boilerplate.get_superview(self.unitLabel).frame = supf
spacf = CGRectMake(f.size.width,0.0,10.0,f.size.height)
- self.unitLabel.superview().viewWithTag_(2).frame = spacf
+ utils.boilerplate.get_superview(self.unitLabel).viewWithTag_(2).frame = spacf
else:
# unit label has a fixed size, with a 10 pix padding
w = py_from_ns(self.fixedUnitLabelWidth)
- sup = self.unitLabel.superview()
+ sup = utils.boilerplate.get_superview(self.unitLabel)
spac = sup.viewWithTag_(2)
sz = self.unitLabel.attributedText.size()
sz.width = w
@@ -136,7 +136,7 @@ def autosizeUnitLabel(self) -> None:
def leftViewRectForBounds_(self, bounds : CGRect) -> CGRect:
r = send_super(__class__, self, 'leftViewRectForBounds:', bounds, argtypes=[CGRect], restype=CGRect)
if self.unitLabel:
- sz = self.unitLabel.superview().bounds.size
+ sz = utils.boilerplate.get_superview(self.unitLabel).bounds.size
return CGRectOffset(r, bounds.size.width - sz.width, 0)
return r
@@ -144,7 +144,7 @@ def leftViewRectForBounds_(self, bounds : CGRect) -> CGRect:
def clearButtonRectForBounds_(self, bounds : CGRect) -> CGRect:
r = send_super(__class__, self, 'clearButtonRectForBounds:', bounds, argtypes=[CGRect], restype=CGRect)
if self.unitLabel:
- sz = self.unitLabel.superview().bounds.size
+ sz = utils.boilerplate.get_superview(self.unitLabel).bounds.size
return CGRectOffset(r, -sz.width, 0)
return r
@@ -153,7 +153,7 @@ def editingRectForBounds_(self, bounds : CGRect) -> CGRect:
r = bounds
r.origin.x = 0
if self.unitLabel:
- sz = self.unitLabel.superview().bounds.size
+ sz = utils.boilerplate.get_superview(self.unitLabel).bounds.size
r.size.width -= (20 + sz.width)
return r
@@ -162,7 +162,7 @@ def textRectForBounds_(self, bounds : CGRect) -> CGRect:
rect = bounds
rect.origin.x = 0
if self.unitLabel:
- sz = self.unitLabel.superview().bounds.size
+ sz = utils.boilerplate.get_superview(self.unitLabel).bounds.size
rect.size.width -= (20 + sz.width)
return rect
diff --git a/ios/ElectronCash/electroncash_gui/ios_native/gui.py b/ios/ElectronCash/electroncash_gui/ios_native/gui.py
index 0530f2c84c99..a6d0816dc4b6 100644
--- a/ios/ElectronCash/electroncash_gui/ios_native/gui.py
+++ b/ios/ElectronCash/electroncash_gui/ios_native/gui.py
@@ -281,6 +281,10 @@ def __init__(self, config):
utils.NSLog("GUI instance created, splash screen 2 presented")
def createAndShowUI(self):
+ # First we set-up the iOS 15+ work-around colors..
+ CustomNavController.topNavBGColor = utils.uicolor_custom('nav')
+ CustomNavController.topNavTextColor = UIColor.whiteColor
+
self.helper = GuiHelper.alloc().init()
self.tabController = MyTabBarController.alloc().init().autorelease()
@@ -1238,9 +1242,15 @@ def pay_to_URI(self, URI, showErr : bool = True) -> bool:
amount = out.get('amount')
label = out.get('label')
message = out.get('message')
+ op_return = out.get('op_return')
+ op_return_raw = out.get('op_return_raw')
+ op_return_is_raw = False
+ if op_return_raw is not None:
+ op_return_is_raw = True
+ op_return = op_return_raw
# use label as description (not BIP21 compliant)
if self.sendVC:
- self.sendVC.onPayTo_message_amount_(address,message,amount)
+ self.sendVC.onPayTo_message_amount_opReturn_isRaw_(address,message,amount,op_return,op_return_is_raw)
return True
else:
self.show_error("Oops! Something went wrong! Email the developers!")
@@ -1474,9 +1484,13 @@ def stop_daemon(self):
self.daemon = None
self.dismiss_downloading_notif()
utils.cleanup_tmp_dir()
+ wd = wallets.WalletsMgr.wallets_dir()
+ if wd: utils.cleanup_wallet_dir(wd) # on newer iOS for some reason *.tmp.PID remain..
def start_daemon(self):
if self.daemon_is_running(): return
+ wd = wallets.WalletsMgr.wallets_dir()
+ if wd: utils.cleanup_wallet_dir(wd) # on newer iOS for some reason *.tmp.PID remain..
import electroncash.daemon as ed
try:
# Force remove of lock file so the code below cuts to the chase and starts a new daemon without
diff --git a/ios/ElectronCash/electroncash_gui/ios_native/monkeypatches.py b/ios/ElectronCash/electroncash_gui/ios_native/monkeypatches.py
index 713adffde101..572f19914040 100644
--- a/ios/ElectronCash/electroncash_gui/ios_native/monkeypatches.py
+++ b/ios/ElectronCash/electroncash_gui/ios_native/monkeypatches.py
@@ -12,11 +12,14 @@
Monkey Patches -- mostly to modify electroncash.* package to suit our needs.
Don't hate me. (This was needed to keep the iOS stuff self-contained.)
'''
+import ssl
+import sys
from .uikit_bindings import *
from electroncash.util import (InvalidPassword, profiler)
import electroncash.bitcoin as ec_bitcoin
+from electroncash.simple_config import SimpleConfig
from electroncash_gui.ios_native.utils import NSLog
-import sys, ssl
+
class MonkeyPatches:
@@ -156,3 +159,14 @@ def TEST(cls):
data2 = olddec(key,iv,cypher)
print("data=",data,"data2=",data2,'cypher=',cypher)
'''
+
+
+class PatchedSimpleConfig(SimpleConfig):
+ """ We restore the "max_fee_rate" method to its original in this patched version, to allow the prefs
+ "Max Static Fee" widget to actually do something. """
+
+ def max_fee_rate(self):
+ f = self.get('max_fee_rate', ec_bitcoin.MAX_FEE_RATE)
+ if f == 0:
+ f = ec_bitcoin.MAX_FEE_RATE
+ return f
diff --git a/ios/ElectronCash/electroncash_gui/ios_native/network_dialog.py b/ios/ElectronCash/electroncash_gui/ios_native/network_dialog.py
index 5acde81e6ed0..7cf72648330a 100644
--- a/ios/ElectronCash/electroncash_gui/ios_native/network_dialog.py
+++ b/ios/ElectronCash/electroncash_gui/ios_native/network_dialog.py
@@ -146,16 +146,16 @@ def onTfChg(oid : objc_id) -> None:
onTfChgBlock = Block(onTfChg)
for v in views:
tag = v.tag
- if isinstance(v, UIButton) and tag in BUTTON_TAGS:
+ if isinstance(v, UIButton) and tag == BUTTON_TAGS:
v.handleControlEvent_withBlock_(UIControlEventPrimaryActionTriggered, showHelpBlock)
- elif isinstance(v, UISwitch) and tag is TAG_AUTOSERVER_SW:
+ elif isinstance(v, UISwitch) and tag == TAG_AUTOSERVER_SW:
self.autoServerSW = v
v.handleControlEvent_withBlock_(UIControlEventPrimaryActionTriggered, onAutoServerSW)
elif isinstance(v, UITextField) and tag in (TAG_HOST_TF, TAG_PORT_TF):
v.delegate = self
- if tag is TAG_HOST_TF: self.hostTF = v
- elif tag is TAG_PORT_TF: self.portTF = v
- v.handleControlEvent_withBlock_(UIControlEventEditingChanged,onTfChgBlock)
+ if tag == TAG_HOST_TF: self.hostTF = v
+ elif tag == TAG_PORT_TF: self.portTF = v
+ v.handleControlEvent_withBlock_(UIControlEventEditingChanged, onTfChgBlock)
# assign views we are interested in to our properties
if tag == TAG_SERVER_LBL: self.serverLbl = v
elif tag == TAG_STATUS_LBL: self.statusLbl = v
@@ -472,7 +472,8 @@ def doSetServer(self) -> None:
host, port, protocol, proxy, auto_connect = network.get_parameters()
host = str(self.hostTF.text)
port = str(self.portTF.text)
- auto_connect = self.autoServerSW.isOn()
+ auto_connect = bool(self.autoServerSW.isOn())
+ network.set_whitelist_only(auto_connect)
network.set_parameters(host, port, protocol, proxy, auto_connect)
@objc_method
@@ -488,16 +489,16 @@ def setServer_(self, s : ObjCInstance) -> None:
def showHelpForButton(oid : objc_id) -> None:
tag = int(ObjCInstance(oid).tag)
msg = _("Unknown")
- if tag is TAG_HELP_STATUS:
+ if tag == TAG_HELP_STATUS:
msg = ' '.join([
_("Electron Cash connects to several nodes in order to download block headers and find out the longest blockchain."),
_("This blockchain is used to verify the transactions sent by your transaction server.")
])
- elif tag is TAG_HELP_SERVER:
+ elif tag == TAG_HELP_SERVER:
msg = _("Electron Cash sends your wallet addresses to a single server, in order to receive your transaction history.")
- elif tag is TAG_HELP_BLOCKCHAIN:
+ elif tag == TAG_HELP_BLOCKCHAIN:
msg = _('This is the height of your local copy of the blockchain.')
- elif tag is TAG_HELP_AUTOSERVER:
+ elif tag == TAG_HELP_AUTOSERVER:
msg = ' '.join([
_("If auto-connect is enabled, Electron Cash will always use a server that is on the longest blockchain."),
_("If it is disabled, you have to choose a server you want to use. Electron Cash will warn you if your server is lagging.")
diff --git a/ios/ElectronCash/electroncash_gui/ios_native/receive.py b/ios/ElectronCash/electroncash_gui/ios_native/receive.py
index bcf739e84fbf..a297c4fc4ae8 100644
--- a/ios/ElectronCash/electroncash_gui/ios_native/receive.py
+++ b/ios/ElectronCash/electroncash_gui/ios_native/receive.py
@@ -12,7 +12,7 @@
from electroncash.util import timestamp_to_datetime, format_time
from electroncash.i18n import _, language
from electroncash.address import Address, ScriptOutput
-from electroncash.paymentrequest import PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID
+from electroncash.paymentrequest import PR_UNCONFIRMED, PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID
from electroncash import bitcoin
import electroncash.web as web
import sys, traceback, time
@@ -25,13 +25,15 @@
pr_icons = {
PR_UNPAID:"unpaid.png",
PR_PAID:"confirmed.png",
- PR_EXPIRED:"expired.png"
+ PR_EXPIRED:"expired.png",
+ PR_UNCONFIRMED:"unconfirmed.png",
}
pr_tooltips = {
PR_UNPAID:'Pending',
PR_PAID:'Paid',
- PR_EXPIRED:'Expired'
+ PR_EXPIRED:'Expired',
+ PR_UNCONFIRMED:'Unconfirmed',
}
ReqItem = namedtuple("ReqItem", "dateStr addrStr signedBy message amountStr statusStr addr iconSign iconStatus fiatStr timestamp expiration expirationStr amount fiat")
diff --git a/ios/ElectronCash/electroncash_gui/ios_native/send.py b/ios/ElectronCash/electroncash_gui/ios_native/send.py
index 311b1297e031..822a314d4f59 100644
--- a/ios/ElectronCash/electroncash_gui/ios_native/send.py
+++ b/ios/ElectronCash/electroncash_gui/ios_native/send.py
@@ -18,6 +18,7 @@
from electroncash import networks
from electroncash.address import Address, ScriptOutput
from electroncash.paymentrequest import PaymentRequest
+from electroncash.transaction import OPReturn
from electroncash import bitcoin
from .feeslider import FeeSlider
from .amountedit import BTCAmountEdit
@@ -60,6 +61,7 @@ class SendVC(SendBase):
dismissOnAppear = objc_property()
kbas = objc_property()
queuedPayTo = objc_property()
+ opReturnIsRaw = objc_property()
@objc_method
def init(self):
@@ -75,6 +77,7 @@ def init(self):
self.dismissOnAppear = False
self.kbas = None
self.queuedPayTo = None
+ self.opReturnIsRaw = False
self.navigationItem.leftItemsSupplementBackButton = True
bb = UIBarButtonItem.new().autorelease()
@@ -98,6 +101,7 @@ def dealloc(self) -> None:
self.excessiveFee = None
self.kbas = None
self.queuedPayTo = None
+ self.opReturnIsRaw = None
utils.nspy_pop(self)
for e in [self.amt, self.fiat, self.payTo]:
if e: utils.nspy_pop(e)
@@ -209,10 +213,16 @@ def onManualFee(t : ObjCInstance) -> None:
# Error Label
self.message.text = ""
+ self.opReturnDel.placeholderFont = UIFont.italicSystemFontOfSize_(14.0)
+ self.opReturnDel.tv = self.opReturn
+ self.opReturnDel.text = ""
+ self.opReturnDel.placeholderText = _("OP_RETURN data (optional).")
+
self.descDel.placeholderFont = UIFont.italicSystemFontOfSize_(14.0)
self.descDel.tv = self.desc
self.descDel.text = ""
self.descDel.placeholderText = _("Description of the transaction (not mandatory).")
+
feelbl = self.feeLbl
slider = self.feeSlider
@@ -260,7 +270,7 @@ def viewWillAppear_(self, animated : bool) -> None:
try:
qpt = list(self.queuedPayTo)
self.queuedPayTo = None
- self.onPayTo_message_amount_(qpt[0],qpt[1],qpt[2])
+ self.onPayTo_message_amount_opReturn_isRaw_(qpt[0],qpt[1],qpt[2],qpt[3],qpt[4])
except:
utils.NSLog("queuedPayTo.. failed with exception: %s",str(sys.exc_info()[1]))
@@ -403,9 +413,13 @@ def textFieldShouldReturn_(self, tf : ObjCInstance) -> bool:
@objc_method
def onPayTo_message_amount_(self, address, message, amount) -> None:
+ return self.onPayTo_message_amount_opReturn_isRaw_(address, message, amount, None, False)
+
+ @objc_method
+ def onPayTo_message_amount_opReturn_isRaw_(self, address, message, amount, op_return, op_return_is_raw) -> None:
# address
if not self.viewIfLoaded:
- self.queuedPayTo = [address, message, amount]
+ self.queuedPayTo = [address, message, amount, op_return, op_return_is_raw]
return
tf = self.payTo
pr = get_PR(self)
@@ -433,10 +447,15 @@ def onPayTo_message_amount_(self, address, message, amount) -> None:
tf.resignFirstResponder()
utils.uitf_redo_attrs(tf)
utils.uitf_redo_attrs(self.fiat)
+ # op_return
+ self.opReturnDel.text = str(op_return) if op_return is not None else ""
+ self.opReturnIsRaw = bool(op_return_is_raw)
+ self.opReturnToggle.setSelected_(self.opReturnIsRaw)
+ self.opReturn.resignFirstResponder()
self.qrScanErr = False
self.chkOk()
- utils.NSLog("OnPayTo %s %s %s",str(address), str(message), str(amount))
+ utils.NSLog("OnPayTo %s %s %s %s %s",str(address), str(message), str(amount), str(op_return), str(op_return_is_raw))
@objc_method
def chkOk(self) -> bool:
@@ -453,7 +472,7 @@ def chkOk(self) -> bool:
self.csPayToTop.constant = 0
f = self.desc.frame
- self.csContentHeight.constant = f.origin.y + f.size.height + 125
+ self.csContentHeight.constant = f.origin.y + f.size.height + 255
retVal = False
errLbl = self.message
@@ -527,6 +546,7 @@ def clearAllExceptSpendFrom(self) -> None:
tf = self.fiat
# label
self.descDel.text = ""
+ self.opReturnDel.text = ""
# slider
slider = self.feeSlider
slider.setValue_animated_(slider.minimumValue,True)
@@ -762,6 +782,11 @@ def get_dummy():
utils.uitf_redo_attrs(amount_e)
self.chkOk()
+ @objc_method
+ def onToggleRawOpReturn(self) -> None:
+ self.opReturnIsRaw = not bool(self.opReturnIsRaw)
+ self.opReturnToggle.setSelected_(self.opReturnIsRaw)
+
@objc_method
def onPreviewSendBut_(self, but) -> None:
self.view.endEditing_(True)
@@ -1021,6 +1046,20 @@ def read_send_form(send : ObjCInstance) -> tuple:
# if not self.question(msg):
# return
+ try:
+ opreturn_message = send.opReturnDel.text
+ if opreturn_message:
+ if send.opReturnIsRaw:
+ outputs.append(OPReturn.output_for_rawhex(opreturn_message))
+ else:
+ outputs.append(OPReturn.output_for_stringdata(opreturn_message))
+ except OPReturn.TooLarge as e:
+ utils.show_alert(send, _("Error"), str(e))
+ return None
+ except OPReturn.Error as e:
+ utils.show_alert(send, _("Error"), str(e))
+ return None
+
if not outputs:
utils.show_alert(send, _("Error"), _('No outputs'))
return None
diff --git a/ios/ElectronCash/electroncash_gui/ios_native/utils.py b/ios/ElectronCash/electroncash_gui/ios_native/utils.py
index 6031ca81eb7f..c7e60ab6fd66 100644
--- a/ios/ElectronCash/electroncash_gui/ios_native/utils.py
+++ b/ios/ElectronCash/electroncash_gui/ios_native/utils.py
@@ -127,6 +127,30 @@ def cleanup_tmp_dir():
if tot:
NSLog("Cleanup Tmp Dir: removed %d/%d files from tmp dir in %f ms",ct,tot,(time.time()-t0)*1e3)
+def cleanup_wallet_dir(wallet_dir: str):
+ t0 = time.time()
+ ct = tot = 0
+ import glob
+ import re
+ if os.path.isdir(wallet_dir):
+ it = glob.iglob(os.path.join(wallet_dir,'*.tmp.*'))
+ for f in it:
+ parts = f.split('.')
+ if (len(parts) >= 3 and re.match(r'\d+', parts[-1]) and parts[-2] == "tmp"
+ and os.path.exists('.'.join(parts[0:-2]))):
+ tot += 1
+ try:
+ os.remove(f)
+ ct += 1
+ except Exception as e:
+ NSLog("Cleanup Wallet Dir: failed to remove wallet file: %s -- exception: %s",
+ f, str(e))
+
+ if tot:
+ NSLog("Cleanup Wallet Dir: removed %d/%d tmp files from wallet dir in %f ms "
+ "(%d wallets left untouched)",
+ ct, tot, (time.time() - t0) * 1e3, wallet_ct)
+
def ios_version_string() -> str:
return "%s %s %s (%s)"%ios_version_tuple_full()
@@ -516,7 +540,8 @@ def onCompletion() -> None:
vc.presentViewController_animated_completion_(alert,animated,onCompletion)
if localRunLoop:
while not got_callback:
- NSRunLoop.currentRunLoop().runUntilDate_(NSDate.dateWithTimeIntervalSinceNow_(0.1))
+ crl = send_message(ObjCClass("NSRunLoop"), "currentRunLoop")
+ ObjCInstance(crl).runUntilDate_(NSDate.dateWithTimeIntervalSinceNow_(0.1))
return None
return alert
@@ -611,7 +636,8 @@ def OnTimer(t_in : objc_id) -> None:
func(*args)
if t: t.invalidate()
timer = NSTimer.timerWithTimeInterval_repeats_block_(timeout, False, OnTimer)
- NSRunLoop.mainRunLoop().addTimer_forMode_(timer, NSDefaultRunLoopMode)
+ mrl = send_message(ObjCClass("NSRunLoop"), "mainRunLoop")
+ ObjCInstance(mrl).addTimer_forMode_(timer, NSDefaultRunLoopMode)
return timer
###
@@ -1437,7 +1463,7 @@ def willShow_(self, sender) -> None:
entry = _kbcb_dict.get(self.handle, None)
if not entry: return
rect = py_from_ns(sender.userInfo)[str(UIKeyboardFrameEndUserInfoKey)].CGRectValue
- window = entry.view.window()
+ window = boilerplate.get_window(entry.view)
if window: rect = entry.view.convertRect_fromView_(rect, window)
if entry.onWillShow: entry.onWillShow(rect)
@objc_method
@@ -1445,7 +1471,7 @@ def didShow_(self, sender) -> None:
entry = _kbcb_dict.get(self.handle, None)
if not entry: return
rect = py_from_ns(sender.userInfo)[str(UIKeyboardFrameEndUserInfoKey)].CGRectValue
- window = entry.view.window()
+ window = boilerplate.get_window(entry.view)
if window: rect = entry.view.convertRect_fromView_(rect, window)
if entry.onDidShow: entry.onDidShow(rect)
@@ -1493,8 +1519,8 @@ def register_keyboard_autoscroll(sv : UIScrollView) -> int:
return None
def kbShow(r : CGRect) -> None:
resp = UIResponder.currentFirstResponder()
- window = sv.window()
- if resp and isinstance(resp, UIView) and window and resp.window():
+ window = boilerplate.get_window(sv)
+ if resp and isinstance(resp, UIView) and window and boilerplate.get_window(resp):
#r = sv.convertRect_toView_(r, window)
visible = sv.convertRect_toView_(sv.bounds, window)
visible.size.height -= r.size.height
@@ -1759,10 +1785,10 @@ def vc_highlight_button_then_do(vc : UIViewController, but : UIButton, func : C
# Layout constraint stuff.. programatically
@staticmethod
def layout_peg_view_to_superview(view : UIView) -> None:
- if not view.superview():
+ if not boilerplate.get_superview(view):
NSLog("Warning: layout_peg_view_to_superview -- passed-in view lacks a superview!")
return
- sv = view.superview()
+ sv = boilerplate.get_superview(view)
sv.addConstraint_(NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_(
sv, NSLayoutAttributeCenterX, NSLayoutRelationEqual, view, NSLayoutAttributeCenterX, 1.0, 0.0 ))
sv.addConstraint_(NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_(
@@ -1782,6 +1808,25 @@ def create_and_add_blur_view(parent : UIView, effectStyle = UIBlurEffectStyleReg
parent.addSubview_(blurView)
return blurView
+ @staticmethod
+ def get_superview(view: UIView) -> ObjCInstance:
+ """This used to be a method now it's a property, so we need to use send_message
+ in case on some older iOS it's still a method"""
+ ret = send_message(view, "superview")
+ if ret:
+ ret = ObjCInstance(ret)
+ return ret
+
+ @staticmethod
+ def get_window(view: UIView) -> ObjCInstance:
+ """This used to be a method now it's a property, so we need to use send_message
+ in case on some older iOS it's still a method"""
+ ret = send_message(view, "window")
+ if ret:
+ ret = ObjCInstance(ret)
+ return ret
+
+
###
### iOS13 Status Bar Workaround stuff
###
diff --git a/ios/README.rst b/ios/README.rst
index 3aca081e7665..bd5f5f03428d 100644
--- a/ios/README.rst
+++ b/ios/README.rst
@@ -22,7 +22,7 @@ Quick Start Instructions
python3 -m pip install 'cookiecutter==1.6.0' --user --upgrade
python3 -m pip install 'briefcase==0.2.6' --user --upgrade
python3 -m pip install 'pbxproj==2.5.1' --user --upgrade
- pyrhon3 -m pip install 'rubicon-objc==0.2.10' --user --upgrade
+ python3 -m pip install 'rubicon-objc==0.2.10' --user --upgrade
(NOTE: The exact versions specified above are known to work, but you may also try and use newer version as well.)
@@ -62,4 +62,4 @@ If you want to run the app to point to the BCH TestNet network:
Additional Notes
----------------
-The app built by this Xcode project is a fully running standalone Electron Cash as an iPhone app. It pulls in sources from ../lib and other places when generating the Xcode project, but everything that is needed (.py files, Python interpreter, etc) ends up packaged in the generated iOS .app!
+The app built by this Xcode project is a fully running standalone Electron Cash as an iPhone app. It pulls in sources from `../electroncash` and other places when generating the Xcode project, but everything that is needed (.py files, Python interpreter, etc) ends up packaged in the generated iOS .app!
diff --git a/ios/Resources/Send.xib b/ios/Resources/Send.xib
index 6f95a007c36b..39dfff9a9bc4 100644
--- a/ios/Resources/Send.xib
+++ b/ios/Resources/Send.xib
@@ -33,6 +33,9 @@