diff --git a/contrib/build-wine/deterministic.spec b/contrib/build-wine/deterministic.spec index 8c8a5535cbce..ab6279778dfc 100644 --- a/contrib/build-wine/deterministic.spec +++ b/contrib/build-wine/deterministic.spec @@ -82,6 +82,8 @@ a = Analysis([home+'electron-cash', home+'plugins/keepkey/qt.py', home+'plugins/ledger/qt.py', home+'plugins/satochip/qt.py', # Satochip + home+'plugins/fusion/fusion.py', # CashFusion + home+'plugins/fusion/qt.py', # CashFusion #home+'packages/requests/utils.py' ], binaries=binaries, diff --git a/contrib/osx/osx.spec b/contrib/osx/osx.spec index 3b7f60e2cb10..d26e1e5eb6a9 100644 --- a/contrib/osx/osx.spec +++ b/contrib/osx/osx.spec @@ -73,6 +73,8 @@ a = Analysis([home+MAIN_SCRIPT, home+'plugins/keepkey/qt.py', home+'plugins/ledger/qt.py', home+'plugins/satochip/qt.py', # Satochip + home+'plugins/fusion/fusion.py', # CashFusion + home+'plugins/fusion/qt.py', # CashFusion ], binaries=binaries, datas=datas, diff --git a/gui/qt/main_window.py b/gui/qt/main_window.py index 196418fb96cf..c451c9fc2853 100644 --- a/gui/qt/main_window.py +++ b/gui/qt/main_window.py @@ -253,20 +253,12 @@ def showEvent(self, event): if event.isAccepted() and self._first_shown: self._first_shown = False weakSelf = Weak.ref(self) - - # - #try: - # # Amaury's recommendation -- only remind a subset of users to enable it. - # self.remind_cashshuffle_enabled = bool(int.from_bytes(bytes.fromhex(self.wallet.get_public_key(self.wallet.get_addresses()[0])), byteorder='big') & 0x3) - #except (AttributeError, ValueError, TypeError): - # # wallet lacks the get_public_key method - # self.remind_cashshuffle_enabled = False - self.remind_cashshuffle_enabled = False # For now globally disabled - #QTimer.singleShot(300, lambda: weakSelf() and weakSelf().do_cash_shuffle_reminder()) - # - # do this immediately after this event handler finishes -- noop on everything but linux - QTimer.singleShot(0, lambda: weakSelf() and weakSelf().gui_object.lin_win_maybe_show_highdpi_caveat_msg(weakSelf())) + def callback(): + strongSelf = weakSelf() + if strongSelf: + strongSelf.gui_object.lin_win_maybe_show_highdpi_caveat_msg(strongSelf) + QTimer.singleShot(0, callback) def on_history(self, event, *args): # NB: event should always be 'on_history' @@ -989,7 +981,7 @@ def update_status(self): self.balance_label.setText(text) self.status_button.setIcon( icon ) self.status_button.setStatusTip( status_tip ) - self.update_cashshuffle_icon() + run_hook('window_update_status', self) def update_wallet(self): @@ -2862,6 +2854,7 @@ def mkfunc(f, method): console.updateNamespace(methods) + def create_status_bar(self): sb = QStatusBar() @@ -2888,27 +2881,6 @@ def create_status_bar(self): self.password_button = StatusBarButton(self.lock_icon, _("Password"), self.change_password_dialog ) sb.addPermanentWidget(self.password_button) - - self.cashshuffle_status_button = StatusBarButton( - self.cashshuffle_icon(), - '', # ToolTip will be set in update_cashshuffle code - self.cashshuffle_icon_leftclick - ) - self.cashshuffle_toggle_action = QAction("", self.cashshuffle_status_button) # action text will get set in update_cashshuffle_icon() - self.cashshuffle_toggle_action.triggered.connect(self.toggle_cashshuffle) - self.cashshuffle_settings_action = QAction("", self.cashshuffle_status_button) - self.cashshuffle_settings_action.triggered.connect(self.show_cashshuffle_settings) - self.cashshuffle_viewpools_action = QAction(_("View pools..."), self.cashshuffle_status_button) - self.cashshuffle_viewpools_action.triggered.connect(self.show_cashshuffle_pools) - self.cashshuffle_status_button.addAction(self.cashshuffle_viewpools_action) - self.cashshuffle_status_button.addAction(self.cashshuffle_settings_action) - self.cashshuffle_separator_action = sep = QAction(self.cashshuffle_status_button); sep.setSeparator(True) - self.cashshuffle_status_button.addAction(sep) - self.cashshuffle_status_button.addAction(self.cashshuffle_toggle_action) - self.cashshuffle_status_button.setContextMenuPolicy(Qt.ActionsContextMenu) - - sb.addPermanentWidget(self.cashshuffle_status_button) - self.addr_converter_button = StatusBarButton( self.cashaddr_icon(), _("Toggle CashAddr Display"), @@ -4041,130 +4013,6 @@ def toggle_cashaddr(self, on): self.print_error('*** WARNING ElectrumWindow.toggle_cashaddr: This function is deprecated. Please do not call it!') self.gui_object.toggle_cashaddr(on) - def cashshuffle_plugin_if_loaded(self): - return self.gui_object.plugins.get_internal_plugin("shuffle", force_load = False) - - def is_cashshuffle_enabled(self): - plugin = self.cashshuffle_plugin_if_loaded() - return bool(plugin and plugin.is_enabled() and plugin.window_has_cashshuffle(self)) - - def cashshuffle_icon(self): - if self.is_cashshuffle_enabled(): - if self._cash_shuffle_flag == 1: - return QIcon(":icons/cashshuffle_on_error.svg") - else: - return QIcon(":icons/cashshuffle_on.svg") - else: - self._cash_shuffle_flag = 0 - return QIcon(":icons/cashshuffle_off.svg") - - def update_cashshuffle_icon(self): - self.cashshuffle_status_button.setIcon(self.cashshuffle_icon()) - loaded = bool(self.cashshuffle_plugin_if_loaded()) - en = self.is_cashshuffle_enabled() - if self._cash_shuffle_flag == 0: - self.cashshuffle_status_button.setStatusTip(_("CashShuffle") + " - " + _("ENABLED") if en else _("CashShuffle") + " - " + _("Disabled")) - rcfcm = _("Right-click for context menu") - self.cashshuffle_status_button.setToolTip( - (_("Toggle CashShuffle") + "\n" + rcfcm) - #(_("Left-click to view pools") + "\n" + rcfcm) if en - #else (_("Toggle CashShuffle") + "\n" + rcfcm) - ) - self.cashshuffle_toggle_action.setText(_("Enable CashShuffle") if not en else _("Disable CashShuffle")) - self.cashshuffle_settings_action.setText(_("CashShuffle Settings...")) - self.cashshuffle_viewpools_action.setEnabled(True) - elif self._cash_shuffle_flag == 1: # Network server error - self.cashshuffle_status_button.setStatusTip(_('CashShuffle Error: Could not connect to server')) - self.cashshuffle_status_button.setToolTip(_('Right-click to select a different CashShuffle server')) - self.cashshuffle_settings_action.setText(_("Resolve Server Problem...")) - self.cashshuffle_viewpools_action.setEnabled(False) - self.cashshuffle_settings_action.setVisible(en or loaded) - self.cashshuffle_viewpools_action.setVisible(en) - if en: - # ensure 'Disable CashShuffle' appears at the end of the context menu - self.cashshuffle_status_button.removeAction(self.cashshuffle_separator_action) - self.cashshuffle_status_button.removeAction(self.cashshuffle_toggle_action) - self.cashshuffle_status_button.addAction(self.cashshuffle_separator_action) - self.cashshuffle_status_button.addAction(self.cashshuffle_toggle_action) - else: - # ensure 'Enable CashShuffle' appears at the beginning of the context menu - self.cashshuffle_status_button.removeAction(self.cashshuffle_separator_action) - self.cashshuffle_status_button.removeAction(self.cashshuffle_toggle_action) - actions = self.cashshuffle_status_button.actions() - self.cashshuffle_status_button.insertAction(actions[0] if actions else None, self.cashshuffle_separator_action) - self.cashshuffle_status_button.insertAction(self.cashshuffle_separator_action, self.cashshuffle_toggle_action) - - - def show_cashshuffle_settings(self): - p = self.cashshuffle_plugin_if_loaded() - if p: - msg = None - if self._cash_shuffle_flag == 1: - # had error - msg = _("There was a problem connecting to this server.\nPlease choose a different CashShuffle server.") - p.settings_dialog(self, msg) - #else: # commented-out. Enable this if you want to use the non-modal network settings as the destination for this action - # # no error -- use the free-floating non-modal network dialog - # if not p.show_cashshuffle_tab_in_network_dialog(self): - # # Huh. Network dialog creation/show failed. Fall back to modal window - # p.settings_dialog(self, msg) - - def show_cashshuffle_pools(self): - p = self.cashshuffle_plugin_if_loaded() - if p: - p.view_pools(self) - - def cashshuffle_icon_leftclick(self): - self.toggle_cashshuffle() - return - # delete the above 2 lines if we want the left-click to revert to - # Josh's suggestion (leaving the code in here for now) - if self.is_cashshuffle_enabled(): - if self._cash_shuffle_flag != 0: - # Jump to settings. - self.cashshuffle_settings_action.trigger() - return - if self.cashshuffle_viewpools_action.isVisible(): - # New! We just let this icon be the "View pools..." action when - # the plugin is already loaded and enabled. This hopefully will - # discourage disabling. Also it's been found that "View pools..." - # is the most popular action anyway -- might as well make it - # convenient to access with 1-click. (@zquestz suggested this) - self.cashshuffle_viewpools_action.trigger() - return - #else... in all other cases just toggle cashshuffle - self.toggle_cashshuffle() - - def toggle_cashshuffle(self): - if not self.is_wallet_cashshuffle_compatible(): - self.show_warning(_("This wallet type cannot be used with CashShuffle."), parent=self) - return - plugins = self.gui_object.plugins - p0 = self.cashshuffle_plugin_if_loaded() - p = p0 or plugins.enable_internal_plugin("shuffle") - if not p: - raise RuntimeError("Could not find CashShuffle plugin") - was_enabled = p.window_has_cashshuffle(self) - if was_enabled and not p.warn_if_shuffle_disable_not_ok(self): - # user at nag screen said "no", so abort - self.update_cashshuffle_icon() - return - enable_flag = not was_enabled - self._cash_shuffle_flag = 0 - KillPopupLabel("CashShuffleError") - if not p0: - # plugin was not loaded -- so flag window as wanting cashshuffle and do init - p.window_set_wants_cashshuffle(self, enable_flag) - p.init_qt(self.gui_object) - else: - # plugin was already started -- just add the window to the plugin - p.window_set_cashshuffle(self, enable_flag) - self.update_cashshuffle_icon() - self.statusBar().showMessage(self.cashshuffle_status_button.statusTip(), 3000) - if enable_flag and self.config.get("show_utxo_tab") is None: - self.toggle_tab(self.utxo_tab) # toggle utxo tab to 'on' if user never specified it should be off. - - def settings_dialog(self): class SettingsModalDialog(WindowModalDialog): @@ -4565,6 +4413,8 @@ def on_notify_tx(b): usechange_cb.setEnabled(False) if isinstance(self.force_use_single_change_addr, str): usechange_cb.setToolTip(self.force_use_single_change_addr) + else: + usechange_cb.setToolTip('') else: usechange_cb.setChecked(self.wallet.use_change) usechange_cb.setToolTip(_('Using change addresses makes it more difficult for other people to track your transactions.')) @@ -4584,6 +4434,8 @@ def on_usechange(x): multiple_cb.setChecked(False) if isinstance(self.force_use_single_change_addr, str): multiple_cb.setToolTip(self.force_use_single_change_addr) + else: + multuple_cb.setToolTip('') else: multiple_cb.setEnabled(self.wallet.use_change) multiple_cb.setToolTip('\n'.join([ @@ -5107,100 +4959,6 @@ def on_rate(dyn, pos, fee_rate): return self.show_transaction(new_tx) - def is_wallet_cashshuffle_compatible(self): - from electroncash.wallet import ImportedWalletBase, Multisig_Wallet - if (self.wallet.is_watching_only() - or self.wallet.is_hardware() - or isinstance(self.wallet, (Multisig_Wallet, ImportedWalletBase))): - # wallet is watching-only, multisig, or hardware so.. not compatible - return False - return True - - _cs_reminder_pixmap = None - def do_cash_shuffle_reminder(self): - if not self.remind_cashshuffle_enabled: - # NB: This is now disabled. We return early from this function. - # Amaury recommended we do this prompting/reminder in a future - # release after the initial public release, or we roll it out - # for a subset of users (hence this flag). - return - if self.cleaned_up or not self.wallet or not self.is_wallet_cashshuffle_compatible(): - return - from electroncash_plugins.shuffle.conf_keys import ConfKeys - p = self.cashshuffle_plugin_if_loaded() - storage = self.wallet.storage - cashshuffle_flag = storage.get(ConfKeys.PerWallet.ENABLED, False) - enabled = cashshuffle_flag and p and p.is_enabled() - nagger_answer = storage.get(ConfKeys.PerWallet.MAIN_WINDOW_NAGGER_ANSWER, None) - if not enabled: - if nagger_answer is None: # nagger_answer is None if they've never said "Never ask" - if __class__._cs_reminder_pixmap is None: - # lazy init. Cache it to class level. - size = QSize(150, int(150/1.4419)) # Important to preserve aspect ratio in .svg file here - # NB: doing it this way, with a QIcon, will take into account devicePixelRatio and end up possibly producing a very hi quality image from the SVG, larger than size - __class__._cs_reminder_pixmap = QIcon(":icons/CashShuffleLogos/logo-vertical.svg").pixmap(size) - icon = __class__._cs_reminder_pixmap - message = ''' - {} -

{}

- '''.format(_("CashShuffle is disabled for this wallet.") if not cashshuffle_flag else _("CashShuffle is disabled."), - _("Would you like to enable CashShuffle for this wallet?")) - info = ' '.join([_("If you enable it, Electron Cash will shuffle your coins for greater privacy. However, you will pay fractions of a penny per shuffle in transaction fees."), - _("(You can always toggle it later using the CashShuffle button.)")]) - res, chkd = self.msg_box(icon=icon, - parent=self.top_level_window(), - title=_('Would you like to turn on CashShuffle?'), - text=message, rich_text=True, informative_text=info, - checkbox_text=_("Never ask for this wallet"), - buttons=(_('Enable CashShuffle'), _("Not now")), - defaultButton=_('Enable CashShuffle'), escapeButton=("Not now") ) - if chkd: - # they don't want to be asked again, so just remember what they answered and apply this answer each time. - storage.put(ConfKeys.PerWallet.MAIN_WINDOW_NAGGER_ANSWER, bool(res==0)) - else: - # They's specified "Never ask", so apply whatever button they pushed when they said that as the auto-setting. - res = 0 if nagger_answer else 1 # if nagge_answer was True, no prompt, just auto-enable, otherwise leave it disabled. - if res == 0: - self.toggle_cashshuffle() - - def restart_cashshuffle(self, msg = None, parent = None): - if (parent or self).question("{}{}".format(msg + "\n\n" if msg else "", _("Restart the CashShuffle plugin now?")), - app_modal=True): - p = self.cashshuffle_plugin_if_loaded() - if p: - p.restart_all() - self.notify(_("CashShuffle restarted")) - else: - self.notify(_("CashShuffle could not be restarted")) - - _cash_shuffle_flag = 0 - def cashshuffle_set_flag(self, flag): - flag = int(flag) - changed = flag != self._cash_shuffle_flag - if not changed: - return - if flag: - def onClick(): - KillPopupLabel("CashShuffleError") - self.show_cashshuffle_settings() - ShowPopupLabel(name = "CashShuffleError", - text="
{}
{}
".format(_("Server Error"),_("Right-click to resolve")), - target=self.cashshuffle_status_button, - timeout=20000, onClick=onClick, onRightClick=onClick, - dark_mode = ColorScheme.dark_scheme) - else: - KillPopupLabel("CashShuffleError") - self.print_error("Cash Shuffle flag is now {}".format(flag)) - oldTip = self.cashshuffle_status_button.statusTip() - self._cash_shuffle_flag = flag - self.update_status() - newTip = self.cashshuffle_status_button.statusTip() - if newTip != oldTip: - self.statusBar().showMessage(newTip, 7500) - - def cashshuffle_get_flag(self): - return self._cash_shuffle_flag - def rebuild_history(self): if self.gui_object.warn_if_no_network(self): # Don't allow if offline mode. diff --git a/lib/version.py b/lib/version.py index e91100d9615f..df0a176a5fa6 100644 --- a/lib/version.py +++ b/lib/version.py @@ -1,4 +1,4 @@ -PACKAGE_VERSION = '4.0.15' # version of the client package +PACKAGE_VERSION = '4.1.0' # version of the client package PROTOCOL_VERSION = '1.4' # protocol version requested # The hash of the Electrum mnemonic seed must begin with this diff --git a/lib/wallet.py b/lib/wallet.py index 54d43e1ee3a9..cbebcaea6c53 100644 --- a/lib/wallet.py +++ b/lib/wallet.py @@ -228,6 +228,11 @@ def __init__(self, storage): self.frozen_coins = set(storage.get('frozen_coins', [])) self.frozen_coins_tmp = set() # in-memory only + self.change_reserved = set(Address.from_string(a) for a in storage.get('change_reserved', ())) + self.change_reserved_default = [Address.from_string(a) for a in storage.get('change_reserved_default', ())] + self.change_unreserved = [Address.from_string(a) for a in storage.get('change_unreserved', ())] + self.change_reserved_tmp = set() # in-memory only + # address -> list(txid, height) history = storage.get('addr_history',{}) self._history = self.to_Address_dict(history) @@ -434,6 +439,13 @@ def save_verified_tx(self, write=False): if write: self.storage.write() + def save_change_reservations(self): + with self.lock: + self.storage.put('change_reserved_default', [a.to_storage_string() for a in self.change_reserved_default]) + self.storage.put('change_reserved', [a.to_storage_string() for a in self.change_reserved]) + unreserved = self.change_unreserved + list(self.change_reserved_tmp) + self.storage.put('change_unreserved', [a.to_storage_string() for a in unreserved]) + def clear_history(self): with self.lock: self.txi = {} @@ -1686,6 +1698,106 @@ def relayfee(self): def dust_threshold(self): return dust_threshold(self.network) + def reserve_change_addresses(self, count, temporary=False): + """ Reserve and return `count` change addresses. In order + of preference, this will return from: + + 1. addresses 'freed' by `.unreserve_change_address`, + 2. addresses in the last 20 (gap limit) of the change list, + 3. newly-created addresses. + + Of these, only unlabeled, unreserved addresses with no usage history + will be returned. If you pass temporary=False (default), this will + persist upon wallet saving, otherwise with temporary=True the address + will be made available again once the wallet is re-opened. + + On non-deterministic wallets, this returns an empty list. + """ + if count <= 0 or not hasattr(self, 'create_new_address'): + return [] + + with self.lock: + last_change_addrs = self.get_change_addresses()[-self.gap_limit_for_change:] + if not last_change_addrs: + # this happens in non-deterministic wallets but the above + # hasattr check should have caught those. + return [] + + def gen_change(): + try: + while True: + yield self.change_unreserved.pop(0) + except IndexError: + pass + for addr in last_change_addrs: + yield addr + while True: + yield self.create_new_address(for_change=True) + + result = [] + for addr in gen_change(): + if ( addr in self.change_reserved + or addr in self.change_reserved_tmp + or self.get_num_tx(addr) != 0 + or addr in result): + continue + + addr_str = addr.to_storage_string() + if self.labels.get(addr_str): + continue + + result.append(addr) + if temporary: + self.change_reserved_tmp.add(addr) + else: + self.change_reserved.add(addr) + if len(result) >= count: + return result + + raise RuntimeError("Unable to generate new addresses") # should not happen + + def unreserve_change_address(self, addr): + """ Unreserve an addr that was set by reserve_change_addresses, and + also explicitly reschedule this address to be usable by a future + reservation. Unreserving is appropriate when the address was never + actually shared or used in a transaction, and reduces empty gaps in + the change list. + """ + assert addr in self.get_change_addresses() + with self.lock: + self.change_reserved.discard(addr) + self.change_reserved_tmp.discard(addr) + self.change_unreserved.append(addr) + + def get_default_change_addresses(self, count): + """ Return `count` change addresses from the default reserved list, + ignoring and removing used addresses. Reserves more as needed. + + The same default change addresses keep getting repeated until they are + actually seen as used in a transaction from the network. Theoretically + this could hurt privacy if the user has multiple unsigned transactions + open at the same time, but practically this avoids address gaps for + normal usage. If you need non-repeated addresses, see + `reserve_change_addresses`. + + On non-deterministic wallets, this returns an empty list. + """ + result = [] + with self.lock: + for addr in list(self.change_reserved_default): + if len(result) >= count: + break + if self.get_num_tx(addr) != 0: + self.change_reserved_default.remove(addr) + continue + result.append(addr) + need_more = count - len(result) + if need_more > 0: + new_addrs = self.reserve_change_addresses(need_more) + self.change_reserved_default.extend(new_addrs) + result.extend(new_addrs) + return result + def make_unsigned_transaction(self, inputs, outputs, config, fixed_fee=None, change_addr=None, sign_schnorr=None): ''' sign_schnorr flag controls whether to mark the tx as signing with schnorr or not. Specify either a bool, or set the flag to 'None' to use @@ -1710,32 +1822,6 @@ def make_unsigned_transaction(self, inputs, outputs, config, fixed_fee=None, cha for item in inputs: self.add_input_info(item) - # change address - if change_addr: - change_addrs = [change_addr] - else: - # This new hook is for 'Cashshuffle Enabled' mode which will: - # Reserve a brand new change address if spending shuffled-only *or* unshuffled-only coins and - # disregard the "use_change" setting since to preserve privacy we must use a new change address each time. - # Pick and lock a new change address. This "locked" change address will not be used by the shuffle threads. - # Note that subsequent calls to this function will return the same change address until that address is involved - # in a tx and has a history, at which point a new address will get generated and "locked". - change_addrs = run_hook("get_change_addrs", self) - if not change_addrs: # hook gave us nothing, so find a change addr based on classic Electron Cash rules. - addrs = self.get_change_addresses()[-self.gap_limit_for_change:] - if self.use_change and addrs: - # New change addresses are created only after a few - # confirmations. Select the unused addresses within the - # gap limit; if none take one at random - change_addrs = [addr for addr in addrs if - self.get_num_tx(addr) == 0] - if not change_addrs: - change_addrs = [random.choice(addrs)] - else: - change_addrs = [inputs[0]['address']] - - assert all(isinstance(addr, Address) for addr in change_addrs) - # Fee estimator if fixed_fee is None: fee_estimator = config.estimate_fee @@ -1744,9 +1830,59 @@ def make_unsigned_transaction(self, inputs, outputs, config, fixed_fee=None, cha if i_max is None: # Let the coin chooser select the coins to spend - max_change = self.max_change_outputs if self.multiple_change else 1 + + change_addrs = [] + if change_addr: + change_addrs = [change_addr] + else: + # Currently the only code that uses this hook is the deprecated + # Cash Shuffle plugin + change_addrs = run_hook("get_change_addrs", self) or [] + + if not change_addrs: + # hook gave us nothing, so find a change addr from the change + # reservation subsystem + max_change = self.max_change_outputs if self.multiple_change else 1 + if self.use_change: + change_addrs = self.get_default_change_addresses(max_change) + else: + change_addrs = [] + + if not change_addrs: + # For some reason we couldn't get any autogenerated change + # address (non-deterministic wallet?). So, try to find an + # input address that belongs to us. + for inp in inputs: + backup_addr = inp['address'] + if self.is_mine(backup_addr): + change_addrs = [backup_addr] + break + else: + # ok, none of the inputs are "mine" (why?!) -- fall back + # to picking first max_change change_addresses that have + # no history + change_addrs = [] + for addr in self.get_change_addresses()[-self.gap_limit_for_change:]: + if self.get_num_tx(addr) == 0: + change_addrs.append(addr) + if len(change_addrs) >= max_change: + break + if not change_addrs: + # No unused wallet addresses or no change addresses. + # Fall back to picking ANY wallet address + try: + # Pick a random address + change_addrs = [random.choice(self.get_addresses())] + except IndexError: + change_addrs = [] # Address-free wallet?! + # This should never happen + if not change_addrs: + raise RuntimeError("Can't find a change address!") + + assert all(isinstance(addr, Address) for addr in change_addrs) + coin_chooser = coinchooser.CoinChooserPrivacy() - tx = coin_chooser.make_tx(inputs, outputs, change_addrs[:max_change], + tx = coin_chooser.make_tx(inputs, outputs, change_addrs, fee_estimator, self.dust_threshold(), sign_schnorr=sign_schnorr) else: sendable = sum(map(lambda x:x['value'], inputs)) @@ -1774,6 +1910,7 @@ def make_unsigned_transaction(self, inputs, outputs, config, fixed_fee=None, cha locktime = 0 tx.locktime = locktime run_hook('make_unsigned_transaction', self, tx) + return tx def mktx(self, outputs, password, config, fee=None, change_addr=None, domain=None, sign_schnorr=None): @@ -1914,6 +2051,7 @@ def stop_threads(self): self.save_transactions() self.save_verified_tx() # implicit cashacct.save self.storage.put('frozen_coins', list(self.frozen_coins)) + self.save_change_reservations() self.storage.write() def start_pruned_txo_cleaner_thread(self): @@ -2390,10 +2528,15 @@ def rebuild_history(self): # reset the address list to default too, just in case. New synchronizer will pick up the addresses again. self.receiving_addresses, self.change_addresses = self.receiving_addresses[:self.gap_limit], self.change_addresses[:self.gap_limit_for_change] do_addr_save = True + self.change_reserved.clear() + self.change_reserved_default.clear() + self.change_unreserved.clear() + self.change_reserved_tmp.clear() self.invalidate_address_set_cache() if do_addr_save: self.save_addresses() self.save_transactions() + self.save_change_reservations() self.save_verified_tx() # implicit cashacct.save self.storage.write() self.start_threads(network) diff --git a/plugins/fusion/Cash Fusion Logo - No Text Gray.svg b/plugins/fusion/Cash Fusion Logo - No Text Gray.svg new file mode 100644 index 000000000000..758a378db91c --- /dev/null +++ b/plugins/fusion/Cash Fusion Logo - No Text Gray.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/fusion/Cash Fusion Logo - No Text.svg b/plugins/fusion/Cash Fusion Logo - No Text.svg new file mode 100644 index 000000000000..41951cfcbab6 --- /dev/null +++ b/plugins/fusion/Cash Fusion Logo - No Text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/fusion/__init__.py b/plugins/fusion/__init__.py new file mode 100644 index 000000000000..f7fcbfb41b09 --- /dev/null +++ b/plugins/fusion/__init__.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from electroncash.i18n import _ + +fullname = _('CashFusion') +description = [ + _('Protect your privacy and anonymize your coins (UTXOs) by shuffling them with other users of CashFusion.'), + "\n\n", + _('A commitment and anonymous announcement scheme is used so that none of the participants know the inputs nor ' + 'outputs of the other participants.'), " ", + _('In addition, a blame protocol is used to mitigate time-wasting denial-of-service type attacks.') +] +description_delimiter = '' +available_for = ['qt', 'cmdline'] +# If default_on is set to True, this plugin is loaded by default on new installs +default_on = True diff --git a/plugins/fusion/cmdline.py b/plugins/fusion/cmdline.py new file mode 100644 index 000000000000..cbb468aaad5b --- /dev/null +++ b/plugins/fusion/cmdline.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from .plugin import FusionPlugin + +class Plugin(FusionPlugin): + pass diff --git a/plugins/fusion/comms.py b/plugins/fusion/comms.py new file mode 100644 index 000000000000..3e33d55b842e --- /dev/null +++ b/plugins/fusion/comms.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +Protobuf communications system and a generic server+client +""" +import queue +import socket +import sys +import threading +import traceback + +from . import fusion_pb2 as pb +from .connection import Connection, BadFrameError +from .util import FusionError +from .validation import ValidationError +from google.protobuf.message import DecodeError + +from weakref import WeakSet + +from electroncash import networks +from electroncash.util import PrintError + +# Make a small patch to the generated protobuf: +# We have some "outer" message types that simply contain a "oneof", with various +# submessages having unique types. So, we create an inverse mapping here from the +# submessage's type (its MessageDescriptor) to the field name. +for mtype in pb.ClientMessage, pb.ServerMessage, pb.CovertMessage, pb.CovertResponse: + mtype._messagedescriptor_names = {d.message_type : n for n,d in mtype.DESCRIPTOR.fields_by_name.items()} + +def send_pb(connection, pb_class, submsg, timeout=None): + # Wrap the submessage into an outer message. + # note - _messagedescriptor_names is patched in, see above + fieldname = pb_class._messagedescriptor_names[submsg.DESCRIPTOR] + msg = pb_class(**{fieldname: submsg}) + msgbytes = msg.SerializeToString() + try: + connection.send_message(msgbytes, timeout=timeout) + except ConnectionError as e: + raise FusionError('connection closed by remote') from e + except socket.timeout as e: + raise FusionError('timed out during send') from e + except OSError as exc: + raise FusionError('Communications error: {}: {}'.format(type(exc).__name__, exc)) from exc + # Other exceptions propagate up + +def recv_pb(connection, pb_class, *expected_field_names, timeout=None): + try: + blob = connection.recv_message(timeout = timeout) + except ConnectionError as e: + raise FusionError('connection closed by remote') from e + except BadFrameError as e: + raise FusionError('corrupted communication: ' + e.args[0]) from e + except socket.timeout as e: + raise FusionError('timed out during receive') from e + except OSError as exc: + if exc.errno == 9: + raise FusionError('connection closed by local') from exc + else: + raise FusionError('Communications error: {}: {}'.format(type(exc).__name__, exc)) from exc + # Other exceptions propagate up + + msg = pb_class() + try: + length = msg.ParseFromString(blob) + except DecodeError as e: + raise FusionError('message decoding error') from e + + if not msg.IsInitialized(): + raise FusionError('incomplete message received') + + mtype = msg.WhichOneof('msg') + if mtype is None: + raise FusionError('unrecognized message') + submsg = getattr(msg, mtype) + + if mtype not in expected_field_names: + raise FusionError('got {} message, expecting {}'.format(mtype, expected_field_names)) + + return submsg, mtype + +_last_net = None +_last_genesis_hash = None +def get_current_genesis_hash() -> bytes: + """Returns the genesis_hash of this Electron Cash install's current chain. + Note that it detects if the chain has changed, and otherwise caches the raw + 32-bytes value. This is suitable for putting into the ClientHello message. + Both server and client call this function.""" + global _last_net, _last_genesis_hash + if not _last_genesis_hash or _last_net != networks.net: + _last_genesis_hash = bytes(reversed(bytes.fromhex(networks.net.GENESIS))) + _last_net = networks.net + return _last_genesis_hash + +# Below stuff is used in the test server + +class ClientHandlerThread(threading.Thread, PrintError): + """A per-connection thread for running a series of queued jobs. + (this should be slaved to a controller) + + In case of ValidationError during a job, this will call `send_error` before + closing the connection. You can implement this in subclasses. + """ + noisy = True + class Disconnect(Exception): + pass + + def __init__(self, connection): + super().__init__(name=f"Fusion {type(self).__name__}") + self.connection = connection + self.dead = False + self.jobs = queue.Queue() + self.peername = None + + def diagnostic_name(self): + if self.peername is None and self.connection and self.connection.socket: + try: self.peername = ':'.join(str(x) for x in self.connection.socket.getpeername()) + except: pass # on some systems socket.getpeername() is not supported + peername = self.peername or '???' + return f'Client {peername}' + + def addjob(self, job, *args): + try: + self.jobs.put((job, args)) + except AttributeError: + pass # if tried to put job after cleanup + + def run(self,): + try: + while True: + try: + job, args = self.jobs.get(timeout=60) + except queue.Empty: + raise FusionError('timed out due to lack of work (BUG)') + try: + job(self, *args) + except ValidationError as e: + self.print_error(str(e)) + self.send_error(str(e)) + return + except self.Disconnect: + pass + except FusionError as exc: + if self.noisy: + self.print_error('failed: {}'.format(exc)) + except Exception: + self.print_error('failed with exception') + traceback.print_exc(file=sys.stderr) + finally: + self.dead = True + del self.jobs # gc + self.connection.close() + + def send_error(self, errormsg): + pass + + @staticmethod + def _killjob(c, reason): + if reason is not None: + c.send_error(reason) + raise FusionError(f'killed: {reason}') + raise FusionError(f'killed') + + def kill(self, reason = None): + """ Kill this connection. If no reason provided then the connection + will be closed immediately, otherwise job a with 'send_error' will + be eventually run (after current job finishes) then the connection + will be closed. """ + self.dead = True + # clear any other jobs + while True: + try: + self.jobs.get_nowait() + except (AttributeError, queue.Empty): + break + + if reason is None: + self.connection.close() + + self.addjob(self._killjob, reason) + +class GenericServer(threading.Thread, PrintError): + client_default_timeout = 5 + noisy = True + + def diagnostic_name(self): + return f'{type(self).__name__}({self.host}:{self.port})' + + def __init__(self, bindhost, port, clientclass, upnp = None): + """ If `port` is 0, then an ephemeral OS-selected port will be assigned. + + `bindhost` may be '0.0.0.0' in which case external connections will be + accepted. (use at your own risk of DoS attacks!) + + if `upnp` is provided it should be a miniupnpc.UPnP object which has + already been initialized with .discover() and .selectigd(). + + `clientclass` should be a subclass of `ClientHandlerThread`.""" + super().__init__() + self.daemon = True + self.clientclass = clientclass + self.bindhost = bindhost + self.upnp = upnp + + listensock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) + listensock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listensock.bind((bindhost, port)) + listensock.listen(20) + listensock.settimeout(1) + self.listensock = listensock + + self.local_port = listensock.getsockname()[1] + + if upnp is not None: + eport = self.local_port + # find a free port for the redirection + r = upnp.getspecificportmapping(eport, 'TCP') + while r != None and eport < 65536: + eport = eport + 1 + r = upnp.getspecificportmapping(eport, 'TCP') + + b = upnp.addportmapping(eport, 'TCP', upnp.lanaddr, self.local_port, + 'CashFusion', '') + + self.local_host = upnp.lanaddr + self.host = upnp.externalipaddress() + self.port = eport + else: + if bindhost == '0.0.0.0': + # discover local IP by making a 'connection' to internet. + # (no packets sent, it's UDP) + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(('8.8.8.8', 1)) + host = s.getsockname()[0] + except: + host = '127.0.0.1' + finally: + s.close() + else: + host = bindhost + self.host = self.local_host = host + self.port = self.local_port + + self.name = self.diagnostic_name() + + self.stopping = False + self.lock = threading.RLock() + self.spawned_clients = WeakSet() + + def stop(self, reason = None): + with self.lock: + self.stopping = True + for c in self.spawned_clients: + c.kill(reason = reason) + + def run(self,): + self.print_error("started") + try: + while True: + if self.stopping: + break + try: + sock, src = self.listensock.accept() + except socket.timeout: + continue + with self.lock: + if self.stopping: + sock.close() + break + if self.noisy: + srcstr = ':'.join(str(x) for x in src) + self.print_error(f'new client: {srcstr}') + del srcstr + connection = Connection(sock, self.client_default_timeout) + client = self.clientclass(connection) + client.noisy = self.noisy + self.spawned_clients.add(client) + client.addjob(self.new_client_job) + client.start() + except: + self.print_error('failed with exception') + traceback.print_exc(file=sys.stderr) + try: + self.listensock.close() + except: + pass + try: + self.upnp.deleteportmapping(self.port, 'TCP') + except: + pass + self.print_error("stopped") + + def new_client_job(self, client): + raise FusionError("client handler not implemented") diff --git a/plugins/fusion/compileproto.sh b/plugins/fusion/compileproto.sh new file mode 100755 index 000000000000..9f559086ae03 --- /dev/null +++ b/plugins/fusion/compileproto.sh @@ -0,0 +1,2 @@ +#!/bin/sh +protoc --python_out=. --proto_path=./protobuf fusion.proto diff --git a/plugins/fusion/conf.py b/plugins/fusion/conf.py new file mode 100644 index 000000000000..c45bdf3f4d2e --- /dev/null +++ b/plugins/fusion/conf.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +CashFusion - conf.py - configuration & settings management +""" +from collections import namedtuple +from typing import List, Optional, Tuple, Union + +class Conf: + """ + A class that's a simple wrapper around CashFusion per-wallet settings + stored in wallet.storage. The intended use-case is for outside code + to construct these object as needed to read a key, e.g.: + b = Conf(wallet).autofuse # getter + Conf(wallet).autofuse = True # setter + """ + + class Defaults: + Autofuse = False + AutofuseCoinbase = False + AutofuseConfirmedOnly = False + CoinbaseSeenLatch = False + FusionMode = 'normal' + QueudAutofuse = 4 + Selector = ('fraction', 0.1) # coin selector options + SelfFusePlayers = 1 # self-fusing control (1 = just self, more than 1 = self fuse up to N times) + + + def __init__(self, wallet): + assert wallet + self.wallet = wallet + + @property + def autofuse(self) -> bool: + return bool(self.wallet.storage.get('cashfusion_autofuse', self.Defaults.Autofuse)) + @autofuse.setter + def autofuse(self, b : Optional[bool]): + if b is not None: b = bool(b) + self.wallet.storage.put('cashfusion_autofuse', b) + + @property + def autofuse_coinbase(self) -> bool: + return bool(self.wallet.storage.get('cashfusion_autofuse_coinbase', self.Defaults.AutofuseCoinbase)) + @autofuse_coinbase.setter + def autofuse_coinbase(self, b : Optional[bool]): + if b is not None: b = bool(b) + self.wallet.storage.put('cashfusion_autofuse_coinbase', b) + + @property + def autofuse_confirmed_only(self) -> bool: + return bool(self.wallet.storage.get('cashfusion_autofuse_only_when_all_confirmed', self.Defaults.AutofuseConfirmedOnly)) + @autofuse_confirmed_only.setter + def autofuse_confirmed_only(self, b : Optional[bool]): + if b is not None: b = bool(b) + self.wallet.storage.put('cashfusion_autofuse_only_when_all_confirmed', b) + + @property + def coinbase_seen_latch(self) -> bool: + return bool(self.wallet.storage.get('cashfusion_coinbase_seen_latch', self.Defaults.CoinbaseSeenLatch)) + @coinbase_seen_latch.setter + def coinbase_seen_latch(self, b : Optional[bool]): + if b is not None: b = bool(b) + self.wallet.storage.put('cashfusion_coinbase_seen_latch', b) + + _valid_fusion_modes = frozenset(('normal', 'consolidate', 'fan-out', 'custom')) + @property + def fusion_mode(self) -> str: + """ Returns a string, one of self._valid_fusion_modes above. """ + ret = self.wallet.storage.get('cashfusion_fusion_mode', self.Defaults.FusionMode) + ret = ret.lower().strip() if isinstance(ret, str) else ret + if ret not in self._valid_fusion_modes: + return self.Defaults.FusionMode + return ret + @fusion_mode.setter + def fusion_mode(self, m : Optional[str]): + if m is not None: + assert isinstance(m, str) + m = m.lower().strip() + assert m in self._valid_fusion_modes + self.wallet.storage.put('cashfusion_fusion_mode', m) + + @property + def queued_autofuse(self) -> int: + return int(self.wallet.storage.get('cashfusion_queued_autofuse', self.Defaults.QueudAutofuse)) + @queued_autofuse.setter + def queued_autofuse(self, i : Optional[int]): + if i is not None: + assert i >= 1 + i = int(i) + self.wallet.storage.put('cashfusion_queued_autofuse', i) + + @property + def selector(self) -> Tuple[str, Union[int,float]]: + return tuple(self.wallet.storage.get('cashfusion_selector', self.Defaults.Selector)) + @selector.setter + def selector(self, t : Optional[ Tuple[str, Union[int,float]] ]): + """ Optional: Pass None to clear the key """ + assert t is None or (isinstance(t, (tuple, list)) and len(t) == 2) + self.wallet.storage.put('cashfusion_selector', t) + + @property + def self_fuse_players(self) -> int: + return int(self.wallet.storage.get('cashfusion_self_fuse_players', self.Defaults.SelfFusePlayers)) + @self_fuse_players.setter + def self_fuse_players(self, i : Optional[int]): + if i is not None: + assert i >= 1 + i = int(i) + return self.wallet.storage.put('cashfusion_self_fuse_players', i) + + +CashFusionServer = namedtuple("CashFusionServer", ('hostname', 'port', 'ssl')) + +def _get_default_server_list() -> List[Tuple[str, int, bool]]: + """ + Maybe someday this can come from a file or something. But can also + always be hard-coded. + + Tuple fields: (hostname: str, port: int, ssl: bool) + """ + return [ + # first one is the default + CashFusionServer('cashfusion.electroncash.dk', 8787, False), + CashFusionServer('server2.example.com', 3436, True), + ] + + +class Global: + """ + A class that's a simple wrapper around CashFusion global settings + stored in the app-wide config object. The intended use-case is for outside + code to construct these object as needed to read a key, e.g.: + h = Global(config).tor_host # getter + Global(config).tor_host = 'localhost' # setter + """ + class Defaults: + ServerList : List[Tuple[str, int, bool]] = _get_default_server_list() + TorHost = 'localhost' + TorPortAuto = True + TorPortManual = 9050 + + + def __init__(self, config): + assert config + self.config = config + + @property + def server(self) -> Tuple[str, int, bool]: + return tuple(self.config.get('cashfusion_server', self.Defaults.ServerList[0])) + @server.setter + def server(self, t : Optional[Tuple[str, int, bool]]): + if t is not None: + assert isinstance(t, (list, tuple)) and len(t) == 3 + t = CashFusionServer(*t) + assert isinstance(t.hostname, str) + assert isinstance(t.port, int) + assert isinstance(t.ssl, bool) + self.config.set_key('cashfusion_server', t) + + @property + def tor_host(self) -> str: + return str(self.config.get('cashfusion_tor_host', self.Defaults.TorHost)) + @tor_host.setter + def tor_host(self, h : Optional[str]): + if h is not None: + h = str(h) + assert h + self.config.set_key('cashfusion_tor_host', h) + + @property + def tor_port_auto(self) -> bool: + return bool(self.config.get('cashfusion_tor_port_auto', self.Defaults.TorPortAuto)) + @tor_port_auto.setter + def tor_port_auto(self, b : Optional[bool]): + if b is not None: + b = bool(b) + self.config.set_key('cashfusion_tor_port_auto', b) + + @property + def tor_port_manual(self) -> int: + return int(self.config.get('cashfusion_tor_port_manual', self.Defaults.TorPortManual)) + @tor_port_manual.setter + def tor_port_manual(self, i : Optional[int]): + if i is not None: + i = int(i) + assert 0 <= i <= 65535 + self.config.set_key('cashfusion_tor_port_manual', i) diff --git a/plugins/fusion/connection.py b/plugins/fusion/connection.py new file mode 100644 index 000000000000..1c466520334b --- /dev/null +++ b/plugins/fusion/connection.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +Message-based communications system for CashFusion. + +This only implements a framing protocol: + + <8 byte magic><4 byte length (big endian) of message> + <8 byte magic><4 byte length (big endian) of message> + ... + <8 byte magic><4 byte length (big endian) of message> +""" + +import certifi +import socket +import socks +import ssl +import time +from contextlib import suppress + +sslcontext = ssl.create_default_context(cafile=certifi.where()) + +class BadFrameError(Exception): + pass + +def open_connection(host, port, conn_timeout = 5.0, default_timeout = 5.0, ssl = False, socks_opts=None): + """Open a connection as client to the specified server. + + If `socks_opts` is None, a direct connection will be made using + `socket.create_connection`. Otherwise, a proxied connection will be + made using `socks.create_connection`, including socks_opts as keyword + arguments. Within that connection, an SSL tunnel will be established + if `ssl` is True. + """ + + if socks_opts is None: + bare_socket = socket.create_connection((host, port), timeout=conn_timeout) + else: + bare_socket = socks.create_connection((host, port), timeout=conn_timeout, **socks_opts) + + if ssl: + try: + conn_socket = sslcontext.wrap_socket(bare_socket, server_hostname=host) + except: + bare_socket.close() + raise + else: + conn_socket = bare_socket + + try: + return Connection(conn_socket, default_timeout) + except: + conn_socket.close() + raise + +class Connection: + # Message length limit. Anything longer is considered to be a malicious server. + # The all-initial-commitments and all-components messages can be big (~100 kB in large fusions). + MAX_MSG_LENGTH = 200*1024 + magic = bytes.fromhex("765be8b4e4396dcf") + + def __init__(self, socket, timeout): + self.socket = socket + self.timeout = timeout + + socket.settimeout(timeout) + self.recvbuf = bytearray() + + def __enter__(self): + self.socket.__enter__() + + def __exit__(self, etype, evalue, traceback): + self.socket.__exit__(etype, evalue, traceback) + + def send_message(self, msg, timeout = None): + """ Sends message; if this times out, the connection should be + abandoned since it's not possible to know how much data was sent. + """ + lengthbytes = len(msg).to_bytes(4, byteorder='big') + frame = self.magic + lengthbytes + msg + + if timeout is None: + timeout = self.timeout + self.socket.settimeout(timeout) + try: + self.socket.sendall(frame) + except (ssl.SSLWantWriteError, ssl.SSLWantReadError) as e: + raise socket.timeout from e + + def recv_message(self, timeout = None): + """ Read message, default timeout is self.timeout. + + If it times out, behaviour is well defined in that no data is lost, + and the next call will functions properly. + """ + if timeout is None: + timeout = self.timeout + + if timeout is None: + max_time = None + self.socket.settimeout(timeout) + else: + max_time = time.monotonic() + timeout + + recvbuf = self.recvbuf + + def fillbuf(n): + # read until recvbuf contains at least n bytes + while True: + if len(recvbuf) >= n: + return + + if max_time is not None: + remtime = max_time - time.monotonic() + if remtime < 0: + raise socket.timeout + self.socket.settimeout(remtime) + + try: + data = self.socket.recv(65536) + except (ssl.SSLWantWriteError, ssl.SSLWantReadError) as e: + # these SSL errors should be reported as a timeout + raise socket.timeout from e + + if not data: + if self.recvbuf: + raise ConnectionError("Connection ended mid-message.") + else: + raise ConnectionError("Connection ended while awaiting message.") + recvbuf.extend(data) + + try: + fillbuf(12) + magic = recvbuf[:8] + if magic != self.magic: + raise BadFrameError("Bad magic in frame: {}".format(magic.hex())) + message_length = int.from_bytes(recvbuf[8:12], byteorder='big') + if message_length > self.MAX_MSG_LENGTH: + raise BadFrameError("Got a frame with msg_length={} > {} (max)".format(message_length, self.MAX_MSG_LENGTH)) + fillbuf(12 + message_length) + + # we have a complete message + message = bytes(recvbuf[12:12 + message_length]) + del recvbuf[:12 + message_length] + return message + finally: + with suppress(OSError): + self.socket.settimeout(self.timeout) + + def close(self): + with suppress(OSError): + self.socket.settimeout(self.timeout) + self.socket.shutdown(socket.SHUT_RDWR) + with suppress(OSError): + self.socket.close() diff --git a/plugins/fusion/covert.py b/plugins/fusion/covert.py new file mode 100644 index 000000000000..155996666e05 --- /dev/null +++ b/plugins/fusion/covert.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +Covert submission mechanism + +- Open numerous connections at random times. +- Send data (component) at random times on random connections. +- Send more data (signature) at a later random time, but on the same connection. +- Close the connections at random times. +- Keep some spare connections in case of problems. + +Each connection gets its own thread. +""" + +import math +import random +import secrets +import socket +import socks +import threading +import time +from collections import deque + +from electroncash.util import PrintError +from .comms import send_pb, recv_pb, pb, FusionError +from .connection import open_connection + +# how long to remember attempting Tor connections +TOR_COOLDOWN_TIME = 660 # seconds + +# how long a covert connection is allowed to stay alive without anything happening (as a sanity check measure) +TIMEOUT_INACTIVE_CONNECTION = 120 + +# Used internally +class Unrecoverable(FusionError): + pass + +def is_tor_port(host, port): + if not 0 <= port < 65536: + return False + try: + socketclass = socket.socket + try: + # socket.socket could be monkeypatched (see lib/network.py), + # in which case we need to get the real one. + socketclass = socket._socketobject + except AttributeError: + pass + s = socketclass(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(0.1) + s.connect((host, port)) + # Tor responds uniquely to HTTP-like requests + s.send(b"GET\n") + if b"Tor is not an HTTP Proxy" in s.recv(1024): + return True + except socket.error: + pass + return False + +class TorLimiter: + # Holds a log of the times of connections during the last `lifetime` + # seconds. At any time you can query `.count` to see how many. + def __init__(self, lifetime): + self.deque = deque() + self.lifetime = lifetime + self.lock = threading.Lock() + self._count = 0 + + def cleanup(self,): + with self.lock: + tnow = time.monotonic() + while True: + try: + item = self.deque[0] + except IndexError: + return + if item > tnow: + return + self.deque.popleft() + self._count -= 1 + + @property + def count(self,): + self.cleanup() + return self._count + + def bump(self,): + t = time.monotonic() + self.lifetime + with self.lock: + self.deque.append(t) + self._count += 1 + +limiter = TorLimiter(TOR_COOLDOWN_TIME) + +def rand_trap(rng): + """ Random number between 0 and 1 according to trapezoid distribution. + a = 0 + b = 1/4 + c = 3/4 + d = 1 + Peak density is 1.333. + """ + sixth = 1./6 + f = rng.random() + fc = 1. - f + if f < sixth: + return math.sqrt(0.375 * f) + elif fc < sixth: + return 1. - math.sqrt(0.375 * fc) + else: + return 0.75*f + 0.125 + +class CovertConnection: + connection = None + slotnum = None + t_ping = None + conn_number = None + def __init__(self): + self.wakeup = threading.Event() + def wait_wakeup_or_time(self, t): + remtime = max(0., t - time.monotonic()) + was_set = self.wakeup.wait(remtime) + self.wakeup.clear() + return was_set + def ping(self): + send_pb(self.connection, pb.CovertMessage, pb.Ping(), 1) + self.t_ping = None + def inactive(self): + raise Unrecoverable("timed out from inactivity (this is a bug!)") + +class CovertSlot: + def __init__(self, submit_timeout): + self.submit_timeout = submit_timeout + self.t_submit = None # The requested start time of work. + self.submsg = None # The work to be done. + self.done = True # Whether last work requested is done. + self.covconn = None # which CovertConnection is assigned to work on this slot + def submit(self): + connection = self.covconn.connection + send_pb(connection, pb.CovertMessage, self.submsg, timeout=self.submit_timeout) + resmsg, mtype = recv_pb(connection, pb.CovertResponse, 'ok', 'error', timeout=self.submit_timeout) + if mtype == 'error': + raise Unrecoverable('error from server: ' + repr(resmsg.message)) + self.done = True + self.t_submit = None + self.covconn.t_ping = None # if a submission is done, no ping is needed. + +class CovertSubmitter(PrintError): + stopping = False + + def __init__(self, dest_addr, dest_port, ssl, tor_host, tor_port, num_slots, randspan, submit_timeout): + self.dest_addr = dest_addr + self.dest_port = dest_port + self.ssl = ssl + + if tor_host is None or tor_port is None: + self.proxy_opts = None + else: + self.proxy_opts = dict(proxy_type = socks.SOCKS5, proxy_addr=tor_host, proxy_port = tor_port, proxy_rdns = True) + + # The timespan (in s) used for randomizing action times on established + # connections. Each connection chooses a delay between 0 and randspan, + # and every action (submitting data, pinging, or closing) on that + # connection gets performed with the same delay relative to the scheduled + # timeframe. The connection establishment itself happens with an + # unrelated random time. + self.randspan = randspan + # We don't let submissions take too long, in order to make sure a spare can still be tried. + self.submit_timeout = submit_timeout + + # If .stop() is called, it will use this timeframe (settable with .set_stop_time) + # to randomize the disconnection times. Note that .stop() may be internally + # invoked at any time in case of a failure where there are no more spare + # connections left. + self.stop_tstart = time.monotonic() - randspan + + # Our internal logic is as follows: + # - Each connection is its own thread, which starts with opening the socket and ends once the socket is dead. + # - Pending connections are also connections. + # - There are N slots and M connections, N <= M in normal operation. Each slot has a connection, but some connections are spare. + # - Sending of data happens on a "slot". That way, related data (with same slot number) gets sent on the same connection whenever possible. + # - Each connection has its own random offset parameter, which it uses to offset its actions during each covert phase. + # In other words, each channel leaks minimal information about the actual timeframe it belongs to, even after many actions. + # - When a connection dies / times out and it was assigned to a slot, it immediately reassigns the slot to another connection. + # If reassignment is not possible, then the entire covert submission mechanism stops itself. + + self.slots = [CovertSlot(self.submit_timeout) for _ in range(num_slots)] + + self.spare_connections = [] + + # This will be set to the exception that caused a stoppage: + # - the first connection error where a spare was not available + # - the first unrecoverable error from server + self.failure_exception = None + + self.randtag = secrets.token_urlsafe(12) # for proxy login + self.rng = random.Random(secrets.token_bytes(32)) # for timings + + self.count_attempted = 0 # how many connections have been attempted (or are being attempted) + self.count_established = 0 # how many connections were made successfully + self.count_failed = 0 # how many connections could not be made + + self.lock = threading.RLock() + + def wake_all(self,): + with self.lock: + for s in self.slots: + if s.covconn: + s.covconn.wakeup.set() + for c in self.spare_connections: + c.wakeup.set() + + def set_stop_time(self, tstart): + self.stop_tstart = tstart + if self.stopping: + self.wake_all() + + def stop(self, _exception=None): + """ Schedule any established connections to close at random times, and + stop any pending connections and pending work. + """ + with self.lock: + if self.stopping: + # already requested! + return + self.failure_exception = _exception + self.stopping = True + self.print_error(f"stopping; connections will close in ~{self.stop_tstart - time.monotonic():.3f}s") + self.wake_all() + + def schedule_connections(self, tstart, tspan, num_spares = 0, connect_timeout = 10): + """ Schedule connections to start. For any slots without a connection, + they will have one allocated. Additionally, new spare connections will + be started until the number of remaining spares is >= num_spares. + + This gets called after instance creation, but can be called again + later on, if new spares are needed. + """ + with self.lock: + newconns = [] + for snum, s in enumerate(self.slots): + if s.covconn is None: + s.covconn = CovertConnection() + s.covconn.slotnum = snum + newconns.append(s.covconn) + + num_new_spares = max(0, num_spares - len(self.spare_connections)) + new_spares = [CovertConnection() for _ in range(num_new_spares)] + self.spare_connections = new_spares + self.spare_connections + + newconns.extend(new_spares) + for covconn in newconns: + covconn.conn_number = self.count_attempted + self.count_attempted += 1 + conn_time = tstart + tspan * rand_trap(self.rng) + rand_delay = self.randspan * rand_trap(self.rng) + thread = threading.Thread(name=f'CovertSubmitter-{covconn.conn_number}', + target=self.run_connection, + args=(covconn, conn_time, rand_delay, connect_timeout,), + ) + thread.daemon = True + thread.start() + # GC note - no reference is kept to the thread. When it dies, + # the target bound method dies. If all threads die and the + # CovertSubmitter has no external references, then refcounts + # should all drop to 0. + + def schedule_submit(self, slot_num, tstart, submsg): + """ Schedule a submission on a specific slot. """ + slot = self.slots[slot_num] + assert slot.done, "tried to set new work when prior work not done" + slot.submsg = submsg + slot.done = False + slot.t_submit = tstart + covconn = slot.covconn + if covconn is not None: + covconn.wakeup.set() + + def schedule_submissions(self, tstart, slot_messages): + """ Schedule submissions on all slots. For connections without a message, + optionally send them a ping.""" + slot_messages = tuple(slot_messages) + assert len(slot_messages) == len(self.slots) + + # note we don't take a lock; because of this we step carefully by + # first touching spares, then slots. + + # first we tell the spare connections that they will need to make a ping. + for c in tuple(self.spare_connections): # copy in case of mutation mid-iteration + c.t_ping = tstart + c.wakeup.set() + + # then we tell the slots that there is a message to submit. + for slot, submsg in zip(self.slots, slot_messages): + covconn = slot.covconn + if submsg is None: + covconn.t_ping = tstart + else: + slot.submsg = submsg + slot.done = False + slot.t_submit = tstart + covconn.wakeup.set() + + def run_connection(self, covconn, conn_time, rand_delay, connect_timeout): + # Main loop for connection thread + + while covconn.wait_wakeup_or_time(conn_time): + # if we are woken up before connection and stopping is happening, then just don't make a connection at all + if self.stopping: + return + tbegin = time.monotonic() + try: + # STATE 1 - connecting + if self.proxy_opts is None: + proxy_opts = None + else: + unique = f'CF{self.randtag}_{covconn.conn_number}' + proxy_opts = dict(proxy_username = unique, proxy_password = unique) + proxy_opts.update(self.proxy_opts) + limiter.bump() + try: + connection = open_connection(self.dest_addr, self.dest_port, conn_timeout=connect_timeout, ssl=self.ssl, socks_opts = proxy_opts) + covconn.connection = connection + except Exception as e: + with self.lock: + self.count_failed += 1 + tend = time.monotonic() + self.print_error(f"could not establish connection (after {(tend-tbegin):.3f}s): {e}") + raise + with self.lock: + self.count_established += 1 + tend = time.monotonic() + self.print_error(f"[{covconn.conn_number}] connection established after {(tend-tbegin):.3f}s") + + covconn.delay = rand_trap(self.rng) * self.randspan + last_action_time = time.monotonic() + + # STATE 2 - working + while not self.stopping: + # (First preference: stop) + nexttime = None + slotnum = covconn.slotnum + # Second preference: submit something + if slotnum is not None: + slot = self.slots[slotnum] + nexttime = slot.t_submit + action = slot.submit + # Third preference: send a ping + if nexttime is None and covconn.t_ping is not None: + nexttime = covconn.t_ping + action = covconn.ping + # Last preference: wait doing nothing + if nexttime is None: + nexttime = last_action_time + TIMEOUT_INACTIVE_CONNECTION + action = covconn.inactive + + nexttime += rand_delay + + if covconn.wait_wakeup_or_time(nexttime): + # got woken up ... let's go back and reevaluate what to do + continue + + # reached action time, time to do it + label = f"[{covconn.conn_number}-{slotnum}-{action.__name__}]" + try: + action() + except Unrecoverable as e: + self.print_error(f"{label} unrecoverable {e}") + self.stop(_exception=e) + raise + except Exception as e: + self.print_error(f"{label} error {e}") + raise + else: + self.print_error(f"{label} done") + last_action_time = time.monotonic() + + # STATE 3 - stopping + while True: + stoptime = self.stop_tstart + rand_delay + if not covconn.wait_wakeup_or_time(stoptime): + break + self.print_error(f"[{covconn.conn_number}] closing from stop") + except Exception as e: + # in case of any problem, record the exception and if we have a slot, reassign it. + exception = e + with self.lock: + slotnum = covconn.slotnum + if slotnum is not None: + try: + spare = self.spare_connections.pop() + except IndexError: + # We failed, and there are no spares. Party is over! + self.stop(_exception = exception) + else: + # Found a spare. + self.slots[slotnum].covconn = spare + spare.slotnum = slotnum + spare.wakeup.set() + covconn.slotnum = None + finally: + if covconn.connection: + covconn.connection.close() + + def check_ok(self): + """ Make sure that an error hasn't occurred yet. """ + e = self.failure_exception + if e is not None: + raise FusionError('Covert connections failed: {} {}'.format(type(e).__name__, e)) from e + + def check_connected(self): + """ Make sure that condition is good, and all slots have an active connection. """ + self.check_ok() + num_missing = sum(1 for s in self.slots if s.covconn.connection is None) + if num_missing > 0: + raise FusionError(f"Covert connections were too slow ({num_missing} incomplete out of {len(self.slots)}).") + + def check_done(self): + """ Make sure that condition is good, and all slots have completed the work. """ + self.check_ok() + num_missing = sum(1 for s in self.slots if not s.done) + if num_missing > 0: + raise FusionError(f"Covert submissions were too slow ({num_missing} incomplete out of {len(self.slots)}).") diff --git a/plugins/fusion/encrypt.py b/plugins/fusion/encrypt.py new file mode 100644 index 000000000000..f14cac4af9d3 --- /dev/null +++ b/plugins/fusion/encrypt.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +An encryption / decryption scheme + +Format of encrypted blob: + + <33 byte ephemeral secp256k1 compressed point><16N byte ciphertext><16 byte HMAC> + +key is sha256(diffie hellman secp256k1 compressed point) +ciphertext is AES256 in CBC mode (iv=0) from the following plaintext string: + + <32-bit length of message, big endian> + +The reason for flexible padding is to allow a variety of messages to be +all padded to equivalent size. + +Also a special facility is provided for decryption: you can get the symmetric +key, or provide the symmetric key (in which case you don't need to know the +private key). +""" + +import pyaes +try: + from Cryptodome.Cipher import AES +except ImportError: + AES = None + +import hashlib, hmac +import ecdsa +from electroncash.bitcoin import ser_to_point, point_to_ser + +try: + hmacdigest = hmac.digest # python 3.7+ +except AttributeError: + def hmacdigest(key, msg, digest): + return hmac.new(key, msg, digest).digest() + + +G = ecdsa.SECP256k1.generator +order = ecdsa.SECP256k1.generator.order() + +class EncryptionFailed(Exception): + pass +class DecryptionFailed(Exception): + pass + +def encrypt(message, pubkey, pad_to_length = None): + """ + pad_to_length must be a multiple of 16, and equal to or larger than + len(message)+4. Default is to choose the smallest possible value. + + If the `pubkey` is not a valid point, raises EncryptionFailed. + """ + try: + pubpoint = ser_to_point(pubkey) + except: + raise EncryptionFailed + nonce_sec = ecdsa.util.randrange(order) + nonce_pub = point_to_ser(nonce_sec*G, comp=True) + key = hashlib.sha256(point_to_ser(nonce_sec*pubpoint, comp=True)).digest() + + plaintext = len(message).to_bytes(4,'big') + message + if pad_to_length is None: + plaintext += b'\0' * ( -len(plaintext) % 16 ) + else: + if pad_to_length % 16 != 0: + raise ValueError(f'{pad_to_length} not multiple of 16') + need = pad_to_length - len(plaintext) + if need < 0: + raise ValueError(f'{pad_to_length} < {len(plaintext)}') + plaintext += b'\0' * need + iv = b'\0'*16 + if AES: + ciphertext = AES.new(key, AES.MODE_CBC, iv).encrypt(plaintext) + else: + aes_cbc = pyaes.AESModeOfOperationCBC(key, iv=iv) + aes = pyaes.Encrypter(aes_cbc, padding=pyaes.PADDING_NONE) + ciphertext = aes.feed(plaintext) + aes.feed() # empty aes.feed() flushes buffer + mac = hmacdigest(key, ciphertext, 'sha256')[:16] + return nonce_pub + ciphertext + mac + +def decrypt_with_symmkey(data, key): + """ Decrypt but using the symmetric key directly. The first 33 bytes are + ignored entirely. """ + if len(data) < 33+16+16: # key, at least 1 block, and mac + raise DecryptionFailed + ciphertext = data[33:-16] + if len(ciphertext) % 16 != 0: + raise DecryptionFailed + mac = hmacdigest(key, ciphertext, 'sha256')[:16] + if not hmac.compare_digest(data[-16:], mac): + raise DecryptionFailed + + iv = b'\0'*16 + if AES: + plaintext = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext) + else: + aes_cbc = pyaes.AESModeOfOperationCBC(bytes(key), iv=iv) + aes = pyaes.Decrypter(aes_cbc, padding=pyaes.PADDING_NONE) + plaintext = aes.feed(bytes(ciphertext)) + aes.feed() # empty aes.feed() flushes buffer + assert len(plaintext) > 4 + msglen = int.from_bytes(plaintext[:4], 'big') + if 4 + msglen > len(plaintext): + raise DecryptionFailed + + return plaintext[4:4+msglen] + +def decrypt(data, privkey): + """ Decrypt using the private key; returns (message, key) or raises + DecryptionFailed on failure. """ + if len(data) < 33+16+16: # key, at least 1 block, and mac + raise DecryptionFailed + try: + nonce_pub = ser_to_point(data[:33]) + except: + raise DecryptionFailed + sec = int.from_bytes(privkey, 'big') + key = hashlib.sha256(point_to_ser(sec*nonce_pub, comp=True)).digest() + return decrypt_with_symmkey(data, key), key diff --git a/plugins/fusion/fusion.py b/plugins/fusion/fusion.py new file mode 100644 index 000000000000..a6ff3e784ae7 --- /dev/null +++ b/plugins/fusion/fusion.py @@ -0,0 +1,1064 @@ +#!/usr/bin/#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +Client-side fusion logic. See `class Fusion` for the main exposed API. + +This module has no GUI dependency. +""" + +from electroncash import schnorr +from electroncash.bitcoin import public_key_from_private_key +from electroncash.i18n import _, ngettext, pgettext +from electroncash.util import format_satoshis, do_in_main_thread, PrintError, ServerError, TxHashMismatch +from electroncash.wallet import Standard_Wallet, Multisig_Wallet + +from . import encrypt +from . import fusion_pb2 as pb +from . import pedersen +from .comms import send_pb, recv_pb, get_current_genesis_hash +from .connection import open_connection +from .conf import Conf +from .covert import CovertSubmitter, is_tor_port +from .protocol import Protocol +from .util import (FusionError, sha256, calc_initial_hash, calc_round_hash, size_of_input, size_of_output, + component_fee, gen_keypair, tx_from_components, rand_position) +from .validation import validate_proof_internal, ValidationError, check_input_electrumx + +from google.protobuf.message import DecodeError + +import copy +import itertools +import secrets +import sys +import threading +import time +import weakref +from collections import defaultdict +from math import ceil + +# used for tagging fusions in a way privately derived from wallet name +tag_seed = secrets.token_bytes(16) + + +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)) + +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) + + + +# Some internal stuff + +# not cryptographically secure! +# we only use it to generate a few floating point numbers, with cryptographically secure seed. +from random import Random + +def random_outputs_for_tier(rng, input_amount, scale, offset, max_count, allow_extra_change=False): + """ Make up to `max_number` random output values, chosen using exponential + distribution function. All parameters should be positive `int`s. + + None can be returned for expected types of failures, which will often occur + when the input_amount is too small or too large, since it becomes uncommon + to find a random assortment of values that satisfy the desired constraints. + + On success, this returns a list of length 1 to max_count, of non-negative + integer values that sum up to exactly input_amount. + + The returned values will always exactly sum up to input_amount. This is done + by renormalizing them, which means the actual effective `scale` will vary + depending on random conditions. + + If `allow_extra_change` is passed (this is abnormal!) then this may return + max_count+1 outputs; the last output will be the leftover change if all + max_counts outputs were exhausted. + """ + if input_amount < offset: + return None + + lambd = 1./scale + + remaining = input_amount + values = [] # list of fractional random values without offset + for _ in range(max_count+1): + val = rng.expovariate(lambd) + # A ceil here makes sure rounding errors won't sometimes put us over the top. + # Provided that scale is much larger than 1, the impact is negligible. + remaining -= ceil(val) + offset + if remaining < 0: + break + values.append(val) + else: + if allow_extra_change: + result = [(round(v) + offset) for v in values[:-1]] + result.append(input_amount - sum(result)) + return result + # Fail because we would need too many outputs + # (most likely, scale was too small) + return None + assert len(values) <= max_count + + if not values: + # Our first try put us over the limit, so we have nothing to work with. + # (most likely, scale was too large) + return None + + desired_random_sum = input_amount - len(values) * offset + assert desired_random_sum >= 0 + + # Now we need to rescale and round the values so they fill up the desired. + # input amount exactly. We perform rounding in cumulative space so that the + # sum is exact, and the rounding is distributed fairly. + cumsum = list(itertools.accumulate(values)) + rescale = desired_random_sum / cumsum[-1] + normed_cumsum = [round(rescale * v) for v in cumsum] + assert normed_cumsum[-1] == desired_random_sum + + differences = ((a - b) for a,b in zip(normed_cumsum, itertools.chain((0,),normed_cumsum))) + result = [(offset + d) for d in differences] + assert sum(result) == input_amount + + return result + +def gen_components(num_blanks, inputs, outputs, feerate): + """ + Generate a full set of fusion components, commitments, keys, and proofs. + + count: int + inputs: dict of {(prevout_hash, prevout_n): (pubkey, integer value in sats)} + outputs: list of [(value, addr), (value, addr) ...] + feerate: int (sat/kB) + + Returns: + list of InitialCommitment, + list of component original indices (inputs then outputs then blanks), + list of serialized Component, + list of Proof, + list of communication privkey, + Pedersen amount for total, (== excess fee) + Pedersen nonce for total, + """ + assert num_blanks >= 0 + + components = [] + for (phash, pn), (pubkey, value) in inputs: + fee = component_fee(size_of_input(pubkey), feerate) + comp = pb.Component() + comp.input.prev_txid = bytes.fromhex(phash)[::-1] + comp.input.prev_index = pn + comp.input.pubkey = pubkey + comp.input.amount = value + components.append((comp, +value-fee)) + for value, addr in outputs: + script = addr.to_script() + fee = component_fee(size_of_output(script), feerate) + comp = pb.Component() + comp.output.scriptpubkey = script + comp.output.amount = value + components.append((comp, -value-fee)) + for _ in range(num_blanks): + comp = pb.Component(blank={}) + components.append((comp, 0)) + + # Generate commitments and (partial) proofs + resultlist = [] + sum_nonce = 0 + sum_amounts = 0 + for cnum, (comp, commitamount) in enumerate(components): + salt = secrets.token_bytes(32) + comp.salt_commitment = sha256(salt) + compser = comp.SerializeToString() + + pedersencommitment = Protocol.PEDERSEN.commit(commitamount) + sum_nonce += pedersencommitment.nonce + sum_amounts += commitamount + + privkey, pubkeyU, pubkeyC = gen_keypair() + + commitment = pb.InitialCommitment() + commitment.salted_component_hash = sha256(salt+compser) + commitment.amount_commitment = pedersencommitment.P_uncompressed + commitment.communication_key = pubkeyC + + commitser = commitment.SerializeToString() + + proof = pb.Proof() + # proof.component_idx = + proof.salt = salt + proof.pedersen_nonce = pedersencommitment.nonce.to_bytes(32, 'big') + + resultlist.append((commitser, cnum, compser, proof, privkey)) + + # Sort by the commitment bytestring, in order to forget the original order. + resultlist.sort(key=lambda x:x[0]) + + sum_nonce = sum_nonce % pedersen.order + pedersen_total_nonce = sum_nonce.to_bytes(32, 'big') + + return zip(*resultlist), sum_amounts, pedersen_total_nonce + + +class Fusion(threading.Thread, PrintError): + """ Represents a single connection to the fusion server and a fusion attempt. + This happens in its own thread, in the background. + + Usage: + + 1. Create Fusion object. + 2. Use add_coins* methods to add inputs. + 3. Call .start() -- this will connect, register, and fuse. + 4. To request stopping the fusion before completion, call .stop(). then wait + for the thread to stop (call .join() to wait). This may take some time. + """ + stopping=False + stopping_if_not_running=False + status=('setup', None) # will always be 2-tuple; second param has extra details + + def __init__(self, plugin, target_wallet, server_host, server_port, server_ssl, tor_host, tor_port): + super().__init__() + + assert can_fuse_to(target_wallet) + self.weak_plugin = weakref.ref(plugin) + self.target_wallet = target_wallet + self.network = target_wallet.network + assert self.network + + self.server_host = server_host + self.server_port = server_port + self.server_ssl = server_ssl + self.tor_host = tor_host + self.tor_port = tor_port + + self.coins = dict() # full input info + self.keypairs = dict() + self.outputs = [] + # for detecting spends (and finally unfreezing coins) we remember for each wallet: + # - which coins we have from that wallet ("txid:n"), + # - which coin txids we have, and + # - which txids we've already scanned for spends of our coins. + self.source_wallet_info = defaultdict(lambda:(set(), set(), set())) + self.distinct_inputs = 0 + self.roundcount = 0 + self.txid = None # set iff completed ok + + @property + def strong_plugin(self): + return self.weak_plugin and self.weak_plugin() + + def add_coins(self, coins, keypairs): + """ Add given P2PKH coins to be used as inputs in a fusion. + + - coins: dict of {(prevout_hash, prevout_n): (bytes pubkey, integer value in sats)} + + - keypairs: dict of {hex pubkey: bytes privkey} + """ + assert self.status[0] == 'setup' + for hpub, priv in keypairs.items(): + assert isinstance(hpub, str) + assert isinstance(priv, tuple) and len(priv) == 2 + sec, compressed = priv + assert isinstance(sec, bytes) and len(sec) == 32 + self.keypairs.update(keypairs) + for coin, (pub, value) in coins.items(): + assert pub[0] in (2,3,4), "expecting a realized pubkey" + assert coin not in self.coins, "already added" + assert pub.hex() in self.keypairs, f"missing private key for {pub.hex()}" + self.coins.update(coins) + + def add_coins_from_wallet(self, wallet, password, coins): + """ + Add coins from given wallet. `coins` should be an iterable like that + returned from `wallet.get_utxos`. No checks are done that the coins are + unfrozen, confirmed, matured, etc... + + The coins will be set to frozen in the wallet, and a subsequent call to + `clear_coins` will unfreeze them. Once the fusion is started using + .start(), it is guaranteed to unfreeze the coins when it finishes. But, + if the wallet is closed first or crashes then coins will remain frozen. + """ + assert can_fuse_from(wallet) + if len(self.source_wallet_info) >= 5 and wallet not in self.source_wallet_info: + raise RuntimeError("too many source wallets") + if not hasattr(wallet, 'cashfusion_tag'): + wallet.cashfusion_tag = sha256(tag_seed + wallet.diagnostic_name().encode())[:20] + xpubkeys_set = set() + for c in coins: + wallet.add_input_info(c) + xpubkey, = c['x_pubkeys'] + xpubkeys_set.add(xpubkey) + + # get private keys and convert x_pubkeys to real pubkeys + keypairs = dict() + pubkeys = dict() + for xpubkey in xpubkeys_set: + derivation = wallet.keystore.get_pubkey_derivation(xpubkey) + privkey = wallet.keystore.get_private_key(derivation, password) + pubkeyhex = public_key_from_private_key(*privkey) + pubkey = bytes.fromhex(pubkeyhex) + keypairs[pubkeyhex] = privkey + pubkeys[xpubkey] = pubkey + + coindict = {(c['prevout_hash'], c['prevout_n']): (pubkeys[c['x_pubkeys'][0]], c['value']) for c in coins} + self.add_coins(coindict, keypairs) + + coinstrs = set(t + ':' + str(i) for t,i in coindict) + txids = set(t for t,i in coindict) + self.source_wallet_info[wallet][0].update(coinstrs) + self.source_wallet_info[wallet][1].update(txids) + wallet.set_frozen_coin_state(coinstrs, True, temporary = True) + self.notify_coins_ui(wallet) + + def add_chooser(self, chooser): + """ Add a coin-chooser function. This will be used for initial coin + selection and used to reselect coins on every round. """ + raise NotImplementedError + + def check_coins(self): + for wallet, (coins, mytxids, checked_txids) in self.source_wallet_info.items(): + with wallet.lock: + wallet_txids = frozenset(wallet.transactions.keys()) + txids_to_scan = wallet_txids.difference(checked_txids) + for txid in txids_to_scan: + txi = wallet.txi.get(txid, None) + if not txi: + continue + txspends = (c for addrtxi in txi.values() for c,v in addrtxi) + spent = coins.intersection(txspends) + if spent: + raise FusionError(f"input spent: {spent.pop()} spent in {txid}") + + checked_txids.update(txids_to_scan) + + missing = mytxids.difference(wallet_txids) + if missing: + raise FusionError(f"input missing: {missing.pop()}") + + def clear_coins(self): + """ Clear the inputs list and release frozen coins. """ + for wallet, (coins, mytxids, checked_txids) in self.source_wallet_info.items(): + wallet.set_frozen_coin_state(coins, False) + self.notify_coins_ui(wallet) + self.source_wallet_info.clear() # save some memory as the checked_txids set can be big + self.coins.clear() + self.keypairs.clear() + + def notify_coins_ui(self, wallet): + strong_plugin = self.strong_plugin # property get from self.weak_plugin + if strong_plugin: + strong_plugin.update_coins_ui(wallet) + + def notify_server_status(self, b, tup=None): + '''True means server is good, False it's bad. This ultimately makes + its way to the UI to tell the user there is a connectivity or other + problem. ''' + strong_plugin = self.strong_plugin # hold strong ref for duration of function + if strong_plugin: + if not isinstance(tup, (tuple, list)) or len(tup) != 2: + tup = "Ok" if b else "Error", '' + strong_plugin.notify_server_status(b, tup) + + def start(self, inactive_timeout = None): + if inactive_timeout is None: + self.inactive_time_limit = None + else: + self.inactive_time_limit = time.monotonic() + inactive_timeout + assert self.coins + super().start() + + def run(self): + server_connected_and_greeted = False + try: + if not schnorr.has_fast_sign() or not schnorr.has_fast_verify(): + raise FusionError("Fusion requires libsecp256k1") + if (self.tor_host is not None and self.tor_port is not None + and not is_tor_port(self.tor_host, self.tor_port)): + raise FusionError(f"Can't connect to Tor proxy at {self.tor_host}:{self.tor_port}") + + self.check_coins() + + # Connect to the server + self.status = ('connecting', '') + try: + self.connection = open_connection(self.server_host, self.server_port, conn_timeout=5.0, default_timeout=5.0, ssl=self.server_ssl) + except OSError as e: + self.print_error("Connect failed:", repr(e)) + sslstr = ' SSL ' if self.server_ssl else '' + raise FusionError(f'Could not connect to {sslstr}{self.server_host}:{self.server_port}') from e + + with self.connection: + # Version check and download server params. + self.greet() + + server_connected_and_greeted = True + self.notify_server_status(True) + + # In principle we can hook a pause in here -- user can insert coins after seeing server params. + + if not self.coins: + raise FusionError('Started with no coins') + self.allocate_outputs() + + # In principle we can hook a pause in here -- user can tweak tier_outputs, perhaps cancelling some unwanted tiers. + + # Register for tiers, wait for a pool. + self.register_and_wait() + + # launch the covert submitter + covert = self.start_covert() + try: + # Pool started. Keep running rounds until fail or complete. + while True: + self.roundcount += 1 + if self.run_round(covert): + break + finally: + covert.stop() + + self.status = ('complete', 'time_wait') + + # wait up to a minute before unfreezing coins + for _ in range(60): + if self.stopping: + break # not an error + for w in self.source_wallet_info: + if self.txid not in w.transactions: + break + else: + break + time.sleep(1) + + self.status = ('complete', 'txid: ' + self.txid) + except FusionError as err: + self.print_error('Failed: {}'.format(err)) + self.status = ('failed', err.args[0] if err.args else 'Unknown error') + except Exception as exc: + import traceback + traceback.print_exc(file=sys.stderr) + self.status = ('failed', 'Exception {}: {}'.format(type(exc).__name__, exc)) + finally: + self.clear_coins() + if self.status[0] != 'complete': + for amount, addr in self.outputs: + self.target_wallet.unreserve_change_address(addr) + if not server_connected_and_greeted: + self.notify_server_status(False, self.status) + + def stop(self, reason = 'stopped', not_if_running = False): + self.stop_reason = reason + if not_if_running: + self.stopping_if_not_running = True + else: + self.stopping = True + + def check_stop(self, running=True): + """ Gets called occasionally from fusion thread to allow a stop point. """ + if self.stopping or (not running and self.stopping_if_not_running): + raise FusionError(self.stop_reason) + + def recv(self, *expected_msg_names, timeout=None): + submsg, mtype = recv_pb(self.connection, pb.ServerMessage, 'error', *expected_msg_names, timeout=timeout) + + if mtype == 'error': + raise FusionError('server error: {!r}'.format(submsg.message)) + + return submsg + + def send(self, submsg, timeout=None): + send_pb(self.connection, pb.ClientMessage, submsg, timeout=timeout) + + ## Rough phases of protocol + + def greet(self,): + self.print_error('greeting server') + self.send(pb.ClientHello(version=Protocol.VERSION, genesis_hash=get_current_genesis_hash())) + reply = self.recv('serverhello') + self.num_components = reply.num_components + self.component_feerate = reply.component_feerate + self.min_excess_fee = reply.min_excess_fee + self.max_excess_fee = reply.max_excess_fee + self.available_tiers = tuple(reply.tiers) + strong_plugin = self.strong_plugin + if strong_plugin: + strong_plugin.set_remote_donation_address(reply.donation_address) + + # Enforce some sensible limits, in case server is crazy + if self.component_feerate > 5000: + raise FusionError('excessive component feerate from server') + if self.min_excess_fee > 400: + raise FusionError('excessive min excess fee from server') + + def allocate_outputs(self,): + assert self.status[0] in ('setup', 'connecting') + num_inputs = len(self.coins) + + # fix the input selection + self.inputs = tuple(self.coins.items()) + + max_outputs = self.num_components - num_inputs + if max_outputs < 1: + raise FusionError('Too many inputs (%d >= %d)'%(num_inputs, self.num_components)) + + # For obfuscation, when there are few inputs we want to have many outputs, + # and vice versa. Many of both is even better, of course. + min_outputs = max(11 - num_inputs, 1) + + # how much input value do we bring to the table (after input & player fees) + sum_inputs_value = sum(v for (_,_), (p,v) in self.inputs) + input_fees = sum(component_fee(size_of_input(p), self.component_feerate) for (_,_), (p,v) in self.inputs) + avail_for_outputs = (sum_inputs_value + - input_fees + - self.min_excess_fee) + + # each P2PKH output will need at least this much allocated to it + fee_per_output = component_fee(34, self.component_feerate) + offset_per_output = Protocol.MIN_OUTPUT + fee_per_output + + # + # TODO Here we can perform fuzzing of the avail_for_outputs amount, keeping in + # mind the max_excess_fee limit... + # For now, just throw on a few extra sats. + # + fuzz_fee = secrets.randbelow(10) + assert fuzz_fee < 100, 'sanity check: example fuzz fee should be small' + avail_for_outputs -= fuzz_fee + + self.excess_fee = sum_inputs_value - input_fees - avail_for_outputs + + if avail_for_outputs < offset_per_output: + # our input amounts are so small that we can't even manage a single output. + raise FusionError('Selected inputs had too little value') + + rng = Random() + rng.seed(secrets.token_bytes(32)) + + tier_outputs = {} + for scale in self.available_tiers: + outputs = random_outputs_for_tier(rng, avail_for_outputs, scale, offset_per_output, max_outputs) + if not outputs or len(outputs) < min_outputs: + # this tier is no good for us. + continue + # subtract off the per-output fees that we provided for, above. + outputs = tuple(o - fee_per_output for o in outputs) + tier_outputs[scale] = outputs + + self.tier_outputs = tier_outputs + self.print_error(f"Possible tiers: {tier_outputs}") + + def register_and_wait(self,): + tier_outputs = self.tier_outputs + tiers_sorted = sorted(tier_outputs) + + if not tier_outputs: + raise FusionError('No outputs available at any tier.') + + self.print_error('registering for tiers: {}'.format(', '.join(str(t) for t in tier_outputs))) + + tags = [] + for wallet in self.source_wallet_info: + selffuse = Conf(wallet).self_fuse_players + tags.append(pb.JoinPools.PoolTag(id = wallet.cashfusion_tag, limit = selffuse)) + + ## Join waiting pools + self.check_stop(running=False) + self.check_coins() + self.send(pb.JoinPools(tiers = tier_outputs, tags=tags)) + + self.status = ('waiting', 'Registered for tiers') + + # make nicer strings for UI + tiers_strings = {t: '{:.8f}'.format(t * 1e-8).rstrip('0') for t, s in tier_outputs.items()} + + while True: + # We should get a status update every 5 seconds. + msg = self.recv('tierstatusupdate', 'fusionbegin', timeout=10) + + if isinstance(msg, pb.FusionBegin): + break + + self.check_stop(running=False) + self.check_coins() + + assert isinstance(msg, pb.TierStatusUpdate) + + statuses = msg.statuses + maxfraction = 0. + maxtiers = [] + besttime = None + besttimetier = None + for t,s in statuses.items(): + try: + frac = s.players / s.min_players + except ZeroDivisionError: + frac = -1. + if frac >= maxfraction: + if frac > maxfraction: + maxfraction = frac + maxtiers.clear() + maxtiers.append(t) + if s.HasField('time_remaining'): + tr = s.time_remaining + if besttime is None or tr < besttime: + besttime = tr + besttimetier = t + + maxtiers = set(maxtiers) + + display_best = [] + display_mid = [] + display_queued = [] + for t in tiers_sorted: + try: + ts = tiers_strings[t] + except KeyError: + raise FusionError('server reported status on tier we are not registered for') + if t in statuses: + if t == besttimetier: + display_best.insert(0, '**' + ts + '**') + elif t in maxtiers: + display_best.append('[' + ts + ']') + else: + display_mid.append(ts) + else: + display_queued.append(ts) + + parts = [] + if display_best or display_mid: + parts.append(_("Tiers:") + ' ' + ', '.join(display_best + display_mid)) + if display_queued: + parts.append(_("Queued:") + ' ' + ', '.join(display_queued)) + tiers_string = ' '.join(parts) + + if besttime is None and self.inactive_time_limit is not None: + if time.monotonic() > self.inactive_time_limit: + raise FusionError('stopping due to inactivity') + + if besttime is not None: + self.status = ('waiting', 'Starting in {}s. {}'.format(besttime, tiers_string)) + elif maxfraction >= 1: + self.status = ('waiting', 'Starting soon. {}'.format(tiers_string)) + elif display_best or display_mid: + self.status = ('waiting', '{:d}% full. {}'.format(round(maxfraction*100), tiers_string)) + else: + self.status = ('waiting', tiers_string) + + # msg is FusionBegin + # Record the time we got it. Later in run_round we will check that the + # first round comes at a very particular time relative to this message. + self.t_fusionbegin = time.monotonic() + + # Check the server's declared unix time, which will be committed. + clock_mismatch = msg.server_time - time.time() + if abs(clock_mismatch) > Protocol.MAX_CLOCK_DISCREPANCY: + raise FusionError(f"Clock mismatch too large: {clock_mismatch:+.3f}.") + + self.tier = msg.tier + self.covert_domain_b = msg.covert_domain + self.covert_port = msg.covert_port + self.covert_ssl = msg.covert_ssl + self.begin_time = msg.server_time + + self.last_hash = calc_initial_hash(self.tier, msg.covert_domain, msg.covert_port, msg.covert_ssl, msg.server_time) + + out_amounts = tier_outputs[self.tier] + out_addrs = self.target_wallet.reserve_change_addresses(len(out_amounts), temporary=True) + self.reserved_addresses = out_addrs + self.outputs = list(zip(out_amounts, out_addrs)) + self.print_error(f"starting fusion rounds at tier {self.tier}: {len(self.inputs)} inputs and {len(self.outputs)} outputs") + + def start_covert(self, ): + self.status = ('running', 'Setting up Tor connections') + try: + covert_domain = self.covert_domain_b.decode('ascii') + except: + raise FusionError('badly encoded covert domain') + covert = CovertSubmitter(covert_domain, self.covert_port, self.covert_ssl, self.tor_host, self.tor_port, self.num_components, Protocol.COVERT_SUBMIT_WINDOW, Protocol.COVERT_SUBMIT_TIMEOUT) + try: + covert.schedule_connections(self.t_fusionbegin, Protocol.COVERT_CONNECT_WINDOW, Protocol.COVERT_CONNECT_SPARES, Protocol.COVERT_CONNECT_TIMEOUT) + + # loop until a just a bit before we're expecting startround, watching for status updates + tend = self.t_fusionbegin + (Protocol.WARMUP_TIME - Protocol.WARMUP_SLOP - 1) + while time.monotonic() < tend: + num_connected = sum(1 for s in covert.slots if s.covconn.connection is not None) + num_spare_connected = sum(1 for c in tuple(covert.spare_connections) if c.connection is not None) + self.status = ('running', f'Setting up Tor connections ({num_connected}+{num_spare_connected} out of {self.num_components})') + time.sleep(1) + + covert.check_ok() + self.check_stop() + self.check_coins() + + except: + covert.stop() + raise + + return covert + + def run_round(self, covert): + self.status = ('running', 'Starting round {}'.format(self.roundcount)) + msg = self.recv('startround', timeout = 2 * Protocol.WARMUP_SLOP + Protocol.STANDARD_TIMEOUT) + # record the time we got this message; it forms the basis time for all + # covert activities. + clock = time.monotonic + covert_T0 = clock() + covert_clock = lambda: clock() - covert_T0 + + round_time = msg.server_time + + # Check the server's declared unix time, which will be committed. + clock_mismatch = msg.server_time - time.time() + if abs(clock_mismatch) > Protocol.MAX_CLOCK_DISCREPANCY: + raise FusionError(f"Clock mismatch too large: {clock_mismatch:+.3f}.") + + if self.t_fusionbegin is not None: + # On the first startround message, check that the warmup time + # was within acceptable bounds. + lag = covert_T0 - self.t_fusionbegin - Protocol.WARMUP_TIME + if abs(lag) > Protocol.WARMUP_SLOP: + raise FusionError(f"Warmup period too different from expectation (|{lag:.3f}s| > {Protocol.WARMUP_SLOP:.3f}s).") + self.t_fusionbegin = None + + self.print_error(f"round starting at {time.time()}") + + round_pubkey = msg.round_pubkey + + blind_nonce_points = msg.blind_nonce_points + if len(blind_nonce_points) != self.num_components: + raise FusionError('blind nonce miscount') + + num_blanks = self.num_components - len(self.inputs) - len(self.outputs) + (mycommitments, mycomponentslots, mycomponents, myproofs, privkeys), pedersen_amount, pedersen_nonce = gen_components(num_blanks, self.inputs, self.outputs, self.component_feerate) + + assert self.excess_fee == pedersen_amount # sanity check that we didn't mess up the above + assert len(set(mycomponents)) == len(mycomponents) # no duplicates + + blindsigrequests = [schnorr.BlindSignatureRequest(round_pubkey, R, sha256(m)) + for R,m in zip(blind_nonce_points, mycomponents)] + + random_number = secrets.token_bytes(32) + + # our final chance to leave in the nicest way possible: before sending commitments / requesting signatures. + covert.check_ok() + self.check_stop() + self.check_coins() + + self.send(pb.PlayerCommit(initial_commitments = mycommitments, + excess_fee = self.excess_fee, + pedersen_total_nonce = pedersen_nonce, + random_number_commitment = sha256(random_number), + blind_sig_requests = [r.get_request() for r in blindsigrequests], + )) + + msg = self.recv('blindsigresponses', timeout=Protocol.T_START_COMPS) + assert len(msg.scalars) == len(blindsigrequests) + blindsigs = [r.finalize(sbytes, check=True) + for r,sbytes in zip(blindsigrequests, msg.scalars)] + + # sleep until the covert component phase really starts, to catch covert connection failures. + remtime = Protocol.T_START_COMPS - covert_clock() + if remtime < 0: + raise FusionError('Arrived at covert-component phase too slowly.') + time.sleep(remtime) + + # Our final check to leave the fusion pool, before we start telling our + # components. This is much more annoying since it will cause the round + # to fail, but since we would end up killing the round anyway then it's + # best for our privacy if we just leave now. + # (This also is our first call to check_connected.) + covert.check_connected() + self.check_coins() + + ### Start covert component submissions + self.print_error("starting covert component submission") + self.status = ('running', 'covert submission: components') + + # If we fail after this point, we want to stop connections gradually and + # randomly. We don't want to stop them + # all at once, since if we had already provided our input components + # then it would be a leak to have them all drop at once. + covert.set_stop_time(covert_T0 + Protocol.T_START_CLOSE) + + # Schedule covert submissions. + messages = [None] * len(mycomponents) + for i, (comp, sig) in enumerate(zip(mycomponents, blindsigs)): + messages[mycomponentslots[i]] = pb.CovertComponent(round_pubkey = round_pubkey, signature = sig, component = comp) + assert all(messages) + covert.schedule_submissions(covert_T0 + Protocol.T_START_COMPS, messages) + + # While submitting, we download the (large) full commitment list. + msg = self.recv('allcommitments', timeout=Protocol.T_START_SIGS) + all_commitments = tuple(msg.initial_commitments) + + # Quick check on the commitment list. + if len(set(all_commitments)) != len(all_commitments): + raise FusionError('Commitments list includes duplicates.') + try: + my_commitment_idxes = [all_commitments.index(c) for c in mycommitments] + except ValueError: + raise FusionError('One or more of my commitments missing.') + + remtime = Protocol.T_START_SIGS - covert_clock() + if remtime < 0: + raise FusionError('took too long to download commitments list') + + # Once all components are received, the server shares them with us: + msg = self.recv('sharecovertcomponents', timeout=Protocol.T_START_SIGS) + all_components = tuple(msg.components) + skip_signatures = bool(msg.skip_signatures) + + # Critical check on server's response timing. + if covert_clock() > Protocol.T_START_SIGS: + raise FusionError('Shared components message arrived too slowly.') + + covert.check_done() + + # Find my components + try: + mycomponent_idxes = [all_components.index(c) for c in mycomponents] + except ValueError: + raise FusionError('One or more of my components missing.') + + + # TODO: check the components list and see if there are enough inputs/outputs + # for there to be significant privacy. + + + # The session hash includes all relevant information that the server + # should have told equally to all the players. If the server tries to + # sneakily spy on players by saying different things to them, then the + # users will sign different transactions and the fusion will fail. + self.last_hash = session_hash = calc_round_hash(self.last_hash, round_pubkey, round_time, all_commitments, all_components) + if msg.HasField('session_hash') and msg.session_hash != session_hash: + raise FusionError('Session hash mismatch (bug!)') + + ### Start covert signature submissions (or skip) + + if not skip_signatures: + self.print_error("starting covert signature submission") + self.status = ('running', 'covert submission: signatures') + + if len(set(all_components)) != len(all_components): + raise FusionError('Server component list includes duplicates.') + + tx, input_indices = tx_from_components(all_components, session_hash) + + # iterate over my inputs and sign them + # We don't use tx.sign() here since: + # - it doesn't currently allow us to use sighash cache. + # - it's a bit dangerous to sign all inputs since this invites + # attackers to try to get us to sign coins we own but that we + # didn't submit. (with other bugs, could lead to funds loss!). + # - we'd need to extract out the result anyway, so doesn't win + # anything in simplicity. + messages = [None] * len(mycomponents) + for i, (cidx, inp) in enumerate(zip(input_indices, tx.inputs())): + try: + mycompidx = mycomponent_idxes.index(cidx) + except ValueError: + continue # not my input + sec, compressed = self.keypairs[inp['pubkeys'][0]] + sighash = sha256(sha256(bytes.fromhex(tx.serialize_preimage(i, 0x41, use_cache = True)))) + sig = schnorr.sign(sec, sighash) + + messages[mycomponentslots[mycompidx]] = pb.CovertTransactionSignature(txsignature = sig, which_input = i) + covert.schedule_submissions(covert_T0 + Protocol.T_START_SIGS, messages) + + # wait for result + msg = self.recv('fusionresult', timeout=Protocol.T_EXPECTING_CONCLUSION - Protocol.TS_EXPECTING_COVERT_COMPONENTS) + + # Critical check on server's response timing. + if covert_clock() > Protocol.T_EXPECTING_CONCLUSION: + raise FusionError('Fusion result message arrived too slowly.') + + covert.check_done() + + if msg.ok: + allsigs = msg.txsignatures + # assemble the transaction. + if len(allsigs) != len(tx.inputs()): + raise FusionError('Server gave wrong number of signatures.') + for i, (sig, inp) in enumerate(zip(allsigs, tx.inputs())): + if len(sig) != 64: + raise FusionError('server relayed bad signature') + inp['signatures'] = [sig.hex() + '41'] + + assert tx.is_complete() + txhex = tx.serialize() + + self.txid = txid = tx.txid() + sum_in = sum(amt for (_, _), (pub, amt) in self.inputs) + sum_out = sum(amt for amt, addr in self.outputs) + sum_in_str = format_satoshis(sum_in, num_zeros=8) + fee_str = str(sum_in - sum_out) + feeloc = _('fee') + label = f"CashFusion {len(self.inputs)}⇢{len(self.outputs)}, {sum_in_str} BCH (−{fee_str} sats {feeloc})" + wallets = set(self.source_wallet_info.keys()) + wallets.add(self.target_wallet) + if len(wallets) > 1: + label += f" {sorted(str(w) for w in self.source_wallet_info.keys())!r} ➡ {str(self.target_wallet)!r}" + # If we have any sweep-inputs, should also modify label + # If we have any send-outputs, should also modify label + def update_wallet_label_in_main_thread_paranoia(wallets, txid, label): + '''We do it this way because run_hook may be invoked as a + result of set_label and that's not well defined if not done + in the main (GUI) thread. ''' + for w in wallets: + with w.lock: + existing_label = w.labels.get(txid, None) + if existing_label is not None: + label = existing_label + '; ' + label + w.set_label(txid, label) + + do_in_main_thread(update_wallet_label_in_main_thread_paranoia, + wallets, txid, label) + + try: + # deep copy here is extra (possibly unnecessary) paranoia to + # freeze the tx that is broadcast and not allow it to point + # to our all_components data, etc. + self.network.broadcast_transaction2(copy.deepcopy(tx),) + except ServerError as e: + nice_msg, = e.args + server_msg = str(e.server_msg) + if isinstance(e, TxHashMismatch): + # This should never actually happen. The TxHashMismatch + # bug is believed to have been fixed as of + # commit 4582391276a377c7a91a7a4285f4336abce2014b. + self.print_error("Server responded with:", server_msg, ", we expected:", txid) + acceptable_substrings = ( + r"txn-already-in-mempool", r"txn-already-known", + r"transaction already in block chain", + ) + if not any(s in server_msg for s in acceptable_substrings): + server_msg = server_msg.replace(txhex, "<...tx hex...>") + self.print_error("tx broadcast failed:", repr(server_msg)) + raise FusionError(f"could not broadcast the transaction! {nice_msg}") from e + + self.print_error(f"successful broadcast of {txid}") + return True + + else: + bad_components = set(msg.bad_components) + if not bad_components.isdisjoint(mycomponent_idxes): + self.print_error(f"bad components: {sorted(bad_components)} mine: {sorted(mycomponent_idxes)}") + raise FusionError("server thinks one of my components is bad!") + else: # skip_signatures True + bad_components = set() + + + ### Blame phase ### + + covert.set_stop_time(covert_T0 + Protocol.T_START_CLOSE_BLAME) + self.print_error("sending proofs") + self.status = ('running', 'round failed - sending proofs') + + # create a list of commitment indexes, but leaving out mine. + others_commitment_idxes = [i for i in range(len(all_commitments)) if i not in my_commitment_idxes] + N = len(others_commitment_idxes) + assert N == len(all_commitments) - len(mycommitments) + if N == 0: + raise FusionError("Fusion failed with only me as player -- I can only blame myself.") + + # where should I send my proofs? + dst_commits = [all_commitments[others_commitment_idxes[rand_position(random_number, N, i)]] for i in range(len(mycommitments))] + # generate the encrypted proofs + encproofs = [b'']*len(mycommitments) + for i, (dst_commit, proof) in enumerate(zip(dst_commits, myproofs)): + msg = pb.InitialCommitment() + try: + msg.ParseFromString(dst_commit) + except DecodeError: + raise FusionError("Server relayed a bad commitment; can't proceed with blame.") + proof.component_idx = mycomponent_idxes[i] + try: + encproofs[i] = encrypt.encrypt(proof.SerializeToString(), msg.communication_key, pad_to_length = 80) + except encrypt.EncryptionFailed: + # The communication key was bad (probably invalid x coordinate). + # We will just send a blank. They can't even blame us since there is no private key! :) + continue + + self.send(pb.MyProofsList(encrypted_proofs = encproofs, + random_number = random_number, + )) + + self.status = ('running', 'round failed - checking proofs') + + self.print_error("receiving proofs") + msg = self.recv('theirproofslist', timeout = 2 * Protocol.STANDARD_TIMEOUT) + blames = [] + for i, rp in enumerate(msg.proofs): + try: + privkey = privkeys[rp.dst_key_idx] + commitmentblob = all_commitments[rp.src_commitment_idx] + except IndexError: + raise FusionError("Server relayed bad proof indices") + try: + proofblob, skey = encrypt.decrypt(rp.encrypted_proof, privkey) + except encrypt.DecryptionFailed: + self.print_error("found an undecryptable proof") + blames.append(pb.Blames.BlameProof(which_proof = i, privkey = privkey, blame_reason = 'undecryptable')) + continue + try: + commitment = pb.InitialCommitment() + commitment.ParseFromString(commitmentblob) + except DecodeError: + raise FusionError("Server relayed bad commitment") + try: + inpcomp = validate_proof_internal(proofblob, commitment, all_components, bad_components, self.component_feerate) + except ValidationError as e: + self.print_error(f"found an erroneous proof: {e.args[0]}") + blames.append(pb.Blames.BlameProof(which_proof = i, session_key = skey, blame_reason = e.args[0])) + continue + + if inpcomp is None: + self.print_error("verified an output / blank") + else: + try: + res = check_input_electrumx(self.network, inpcomp) + except ValidationError as e: + self.print_error(f"found a bad input [{rp.src_commitment_idx}]: {e.args[0]} ({inpcomp.prev_txid[::-1].hex()}:{inpcomp.prev_index})") + blames.append(pb.Blames.BlameProof(which_proof = i, session_key = skey, blame_reason = 'input does not match blockchain: ' + e.args[0], + need_lookup_blockchain = True)) + + continue + if res: + self.print_error("verified an input fully") + else: + self.print_error("verified an input internally, but was unable to check it against blockchain!") + + self.print_error("sending blames") + self.send(pb.Blames(blames = blames)) + + self.status = ('running', 'awaiting restart') + + # Await the final 'restartround' message. It might take some time + # to arrive since other players might be slow, and then the server + # itself needs to check blockchain. + self.recv('restartround', timeout = 2 * (Protocol.STANDARD_TIMEOUT + Protocol.BLAME_VERIFY_TIME)) diff --git a/plugins/fusion/fusion_pb2.py b/plugins/fusion/fusion_pb2.py new file mode 100644 index 000000000000..0f4faac4246e --- /dev/null +++ b/plugins/fusion/fusion_pb2.py @@ -0,0 +1,1955 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: fusion.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='fusion.proto', + package='fusion', + syntax='proto2', + serialized_options=None, + serialized_pb=_b('\n\x0c\x66usion.proto\x12\x06\x66usion\"W\n\x0eInputComponent\x12\x11\n\tprev_txid\x18\x01 \x02(\x0c\x12\x12\n\nprev_index\x18\x02 \x02(\r\x12\x0e\n\x06pubkey\x18\x03 \x02(\x0c\x12\x0e\n\x06\x61mount\x18\x04 \x02(\x04\"7\n\x0fOutputComponent\x12\x14\n\x0cscriptpubkey\x18\x01 \x02(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x02(\x04\"\x10\n\x0e\x42lankComponent\"\xae\x01\n\tComponent\x12\x17\n\x0fsalt_commitment\x18\x01 \x02(\x0c\x12\'\n\x05input\x18\x02 \x01(\x0b\x32\x16.fusion.InputComponentH\x00\x12)\n\x06output\x18\x03 \x01(\x0b\x32\x17.fusion.OutputComponentH\x00\x12\'\n\x05\x62lank\x18\x04 \x01(\x0b\x32\x16.fusion.BlankComponentH\x00\x42\x0b\n\tcomponent\"h\n\x11InitialCommitment\x12\x1d\n\x15salted_component_hash\x18\x01 \x02(\x0c\x12\x19\n\x11\x61mount_commitment\x18\x02 \x02(\x0c\x12\x19\n\x11\x63ommunication_key\x18\x03 \x02(\x0c\"D\n\x05Proof\x12\x15\n\rcomponent_idx\x18\x01 \x02(\x07\x12\x0c\n\x04salt\x18\x02 \x02(\x0c\x12\x16\n\x0epedersen_nonce\x18\x03 \x02(\x0c\"4\n\x0b\x43lientHello\x12\x0f\n\x07version\x18\x01 \x02(\x0c\x12\x14\n\x0cgenesis_hash\x18\x02 \x01(\x0c\"\x99\x01\n\x0bServerHello\x12\r\n\x05tiers\x18\x01 \x03(\x04\x12\x16\n\x0enum_components\x18\x02 \x02(\r\x12\x19\n\x11\x63omponent_feerate\x18\x04 \x02(\x04\x12\x16\n\x0emin_excess_fee\x18\x05 \x02(\x04\x12\x16\n\x0emax_excess_fee\x18\x06 \x02(\x04\x12\x18\n\x10\x64onation_address\x18\x0f \x01(\t\"x\n\tJoinPools\x12\r\n\x05tiers\x18\x01 \x03(\x04\x12\'\n\x04tags\x18\x02 \x03(\x0b\x32\x19.fusion.JoinPools.PoolTag\x1a\x33\n\x07PoolTag\x12\n\n\x02id\x18\x01 \x02(\x0c\x12\r\n\x05limit\x18\x02 \x02(\r\x12\r\n\x05no_ip\x18\x03 \x01(\x08\"\x83\x02\n\x10TierStatusUpdate\x12\x38\n\x08statuses\x18\x01 \x03(\x0b\x32&.fusion.TierStatusUpdate.StatusesEntry\x1a_\n\nTierStatus\x12\x0f\n\x07players\x18\x01 \x01(\r\x12\x13\n\x0bmin_players\x18\x02 \x01(\r\x12\x13\n\x0bmax_players\x18\x03 \x01(\r\x12\x16\n\x0etime_remaining\x18\x04 \x01(\r\x1aT\n\rStatusesEntry\x12\x0b\n\x03key\x18\x01 \x01(\x04\x12\x32\n\x05value\x18\x02 \x01(\x0b\x32#.fusion.TierStatusUpdate.TierStatus:\x02\x38\x01\"p\n\x0b\x46usionBegin\x12\x0c\n\x04tier\x18\x01 \x02(\x04\x12\x15\n\rcovert_domain\x18\x02 \x02(\x0c\x12\x13\n\x0b\x63overt_port\x18\x03 \x02(\r\x12\x12\n\ncovert_ssl\x18\x04 \x01(\x08\x12\x13\n\x0bserver_time\x18\x05 \x02(\x06\"S\n\nStartRound\x12\x14\n\x0cround_pubkey\x18\x01 \x02(\x0c\x12\x1a\n\x12\x62lind_nonce_points\x18\x02 \x03(\x0c\x12\x13\n\x0bserver_time\x18\x05 \x02(\x06\"\x9b\x01\n\x0cPlayerCommit\x12\x1b\n\x13initial_commitments\x18\x01 \x03(\x0c\x12\x12\n\nexcess_fee\x18\x02 \x02(\x04\x12\x1c\n\x14pedersen_total_nonce\x18\x03 \x02(\x0c\x12 \n\x18random_number_commitment\x18\x04 \x02(\x0c\x12\x1a\n\x12\x62lind_sig_requests\x18\x05 \x03(\x0c\"$\n\x11\x42lindSigResponses\x12\x0f\n\x07scalars\x18\x01 \x03(\x0c\"-\n\x0e\x41llCommitments\x12\x1b\n\x13initial_commitments\x18\x01 \x03(\x0c\"M\n\x0f\x43overtComponent\x12\x14\n\x0cround_pubkey\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x02(\x0c\x12\x11\n\tcomponent\x18\x03 \x02(\x0c\"Z\n\x15ShareCovertComponents\x12\x12\n\ncomponents\x18\x04 \x03(\x0c\x12\x17\n\x0fskip_signatures\x18\x05 \x01(\x08\x12\x14\n\x0csession_hash\x18\x06 \x01(\x0c\"\\\n\x1a\x43overtTransactionSignature\x12\x14\n\x0cround_pubkey\x18\x01 \x01(\x0c\x12\x13\n\x0bwhich_input\x18\x02 \x02(\r\x12\x13\n\x0btxsignature\x18\x03 \x02(\x0c\"H\n\x0c\x46usionResult\x12\n\n\x02ok\x18\x01 \x02(\x08\x12\x14\n\x0ctxsignatures\x18\x02 \x03(\x0c\x12\x16\n\x0e\x62\x61\x64_components\x18\x03 \x03(\r\"?\n\x0cMyProofsList\x12\x18\n\x10\x65ncrypted_proofs\x18\x01 \x03(\x0c\x12\x15\n\rrandom_number\x18\x02 \x02(\x0c\"\xa1\x01\n\x0fTheirProofsList\x12\x34\n\x06proofs\x18\x01 \x03(\x0b\x32$.fusion.TheirProofsList.RelayedProof\x1aX\n\x0cRelayedProof\x12\x17\n\x0f\x65ncrypted_proof\x18\x01 \x02(\x0c\x12\x1a\n\x12src_commitment_idx\x18\x02 \x02(\r\x12\x13\n\x0b\x64st_key_idx\x18\x03 \x02(\r\"\xc4\x01\n\x06\x42lames\x12)\n\x06\x62lames\x18\x01 \x03(\x0b\x32\x19.fusion.Blames.BlameProof\x1a\x8e\x01\n\nBlameProof\x12\x13\n\x0bwhich_proof\x18\x01 \x02(\r\x12\x15\n\x0bsession_key\x18\x02 \x01(\x0cH\x00\x12\x11\n\x07privkey\x18\x03 \x01(\x0cH\x00\x12\x1e\n\x16need_lookup_blockchain\x18\x04 \x01(\x08\x12\x14\n\x0c\x62lame_reason\x18\x05 \x01(\tB\x0b\n\tdecrypter\"\x0e\n\x0cRestartRound\"\x18\n\x05\x45rror\x12\x0f\n\x07message\x18\x01 \x01(\t\"\x06\n\x04Ping\"\x04\n\x02OK\"\xe8\x01\n\rClientMessage\x12*\n\x0b\x63lienthello\x18\x01 \x01(\x0b\x32\x13.fusion.ClientHelloH\x00\x12&\n\tjoinpools\x18\x02 \x01(\x0b\x32\x11.fusion.JoinPoolsH\x00\x12,\n\x0cplayercommit\x18\x03 \x01(\x0b\x32\x14.fusion.PlayerCommitH\x00\x12,\n\x0cmyproofslist\x18\x05 \x01(\x0b\x32\x14.fusion.MyProofsListH\x00\x12 \n\x06\x62lames\x18\x06 \x01(\x0b\x32\x0e.fusion.BlamesH\x00\x42\x05\n\x03msg\"\xa8\x04\n\rServerMessage\x12*\n\x0bserverhello\x18\x01 \x01(\x0b\x32\x13.fusion.ServerHelloH\x00\x12\x34\n\x10tierstatusupdate\x18\x02 \x01(\x0b\x32\x18.fusion.TierStatusUpdateH\x00\x12*\n\x0b\x66usionbegin\x18\x03 \x01(\x0b\x32\x13.fusion.FusionBeginH\x00\x12(\n\nstartround\x18\x04 \x01(\x0b\x32\x12.fusion.StartRoundH\x00\x12\x36\n\x11\x62lindsigresponses\x18\x05 \x01(\x0b\x32\x19.fusion.BlindSigResponsesH\x00\x12\x30\n\x0e\x61llcommitments\x18\x06 \x01(\x0b\x32\x16.fusion.AllCommitmentsH\x00\x12>\n\x15sharecovertcomponents\x18\x07 \x01(\x0b\x32\x1d.fusion.ShareCovertComponentsH\x00\x12,\n\x0c\x66usionresult\x18\x08 \x01(\x0b\x32\x14.fusion.FusionResultH\x00\x12\x32\n\x0ftheirproofslist\x18\t \x01(\x0b\x32\x17.fusion.TheirProofsListH\x00\x12,\n\x0crestartround\x18\x0e \x01(\x0b\x32\x14.fusion.RestartRoundH\x00\x12\x1e\n\x05\x65rror\x18\x0f \x01(\x0b\x32\r.fusion.ErrorH\x00\x42\x05\n\x03msg\"\x9b\x01\n\rCovertMessage\x12,\n\tcomponent\x18\x01 \x01(\x0b\x32\x17.fusion.CovertComponentH\x00\x12\x37\n\tsignature\x18\x02 \x01(\x0b\x32\".fusion.CovertTransactionSignatureH\x00\x12\x1c\n\x04ping\x18\x03 \x01(\x0b\x32\x0c.fusion.PingH\x00\x42\x05\n\x03msg\"Q\n\x0e\x43overtResponse\x12\x18\n\x02ok\x18\x01 \x01(\x0b\x32\n.fusion.OKH\x00\x12\x1e\n\x05\x65rror\x18\x0f \x01(\x0b\x32\r.fusion.ErrorH\x00\x42\x05\n\x03msg') +) + + + + +_INPUTCOMPONENT = _descriptor.Descriptor( + name='InputComponent', + full_name='fusion.InputComponent', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='prev_txid', full_name='fusion.InputComponent.prev_txid', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='prev_index', full_name='fusion.InputComponent.prev_index', index=1, + number=2, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='pubkey', full_name='fusion.InputComponent.pubkey', index=2, + number=3, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='amount', full_name='fusion.InputComponent.amount', index=3, + number=4, type=4, cpp_type=4, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=24, + serialized_end=111, +) + + +_OUTPUTCOMPONENT = _descriptor.Descriptor( + name='OutputComponent', + full_name='fusion.OutputComponent', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='scriptpubkey', full_name='fusion.OutputComponent.scriptpubkey', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='amount', full_name='fusion.OutputComponent.amount', index=1, + number=2, type=4, cpp_type=4, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=113, + serialized_end=168, +) + + +_BLANKCOMPONENT = _descriptor.Descriptor( + name='BlankComponent', + full_name='fusion.BlankComponent', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=170, + serialized_end=186, +) + + +_COMPONENT = _descriptor.Descriptor( + name='Component', + full_name='fusion.Component', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='salt_commitment', full_name='fusion.Component.salt_commitment', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='input', full_name='fusion.Component.input', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='output', full_name='fusion.Component.output', index=2, + number=3, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='blank', full_name='fusion.Component.blank', index=3, + number=4, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + _descriptor.OneofDescriptor( + name='component', full_name='fusion.Component.component', + index=0, containing_type=None, fields=[]), + ], + serialized_start=189, + serialized_end=363, +) + + +_INITIALCOMMITMENT = _descriptor.Descriptor( + name='InitialCommitment', + full_name='fusion.InitialCommitment', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='salted_component_hash', full_name='fusion.InitialCommitment.salted_component_hash', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='amount_commitment', full_name='fusion.InitialCommitment.amount_commitment', index=1, + number=2, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='communication_key', full_name='fusion.InitialCommitment.communication_key', index=2, + number=3, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=365, + serialized_end=469, +) + + +_PROOF = _descriptor.Descriptor( + name='Proof', + full_name='fusion.Proof', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='component_idx', full_name='fusion.Proof.component_idx', index=0, + number=1, type=7, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='salt', full_name='fusion.Proof.salt', index=1, + number=2, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='pedersen_nonce', full_name='fusion.Proof.pedersen_nonce', index=2, + number=3, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=471, + serialized_end=539, +) + + +_CLIENTHELLO = _descriptor.Descriptor( + name='ClientHello', + full_name='fusion.ClientHello', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='version', full_name='fusion.ClientHello.version', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='genesis_hash', full_name='fusion.ClientHello.genesis_hash', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=541, + serialized_end=593, +) + + +_SERVERHELLO = _descriptor.Descriptor( + name='ServerHello', + full_name='fusion.ServerHello', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='tiers', full_name='fusion.ServerHello.tiers', index=0, + number=1, type=4, cpp_type=4, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='num_components', full_name='fusion.ServerHello.num_components', index=1, + number=2, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='component_feerate', full_name='fusion.ServerHello.component_feerate', index=2, + number=4, type=4, cpp_type=4, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='min_excess_fee', full_name='fusion.ServerHello.min_excess_fee', index=3, + number=5, type=4, cpp_type=4, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='max_excess_fee', full_name='fusion.ServerHello.max_excess_fee', index=4, + number=6, type=4, cpp_type=4, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='donation_address', full_name='fusion.ServerHello.donation_address', index=5, + number=15, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=596, + serialized_end=749, +) + + +_JOINPOOLS_POOLTAG = _descriptor.Descriptor( + name='PoolTag', + full_name='fusion.JoinPools.PoolTag', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='id', full_name='fusion.JoinPools.PoolTag.id', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='limit', full_name='fusion.JoinPools.PoolTag.limit', index=1, + number=2, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='no_ip', full_name='fusion.JoinPools.PoolTag.no_ip', index=2, + number=3, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=820, + serialized_end=871, +) + +_JOINPOOLS = _descriptor.Descriptor( + name='JoinPools', + full_name='fusion.JoinPools', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='tiers', full_name='fusion.JoinPools.tiers', index=0, + number=1, type=4, cpp_type=4, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='tags', full_name='fusion.JoinPools.tags', index=1, + number=2, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[_JOINPOOLS_POOLTAG, ], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=751, + serialized_end=871, +) + + +_TIERSTATUSUPDATE_TIERSTATUS = _descriptor.Descriptor( + name='TierStatus', + full_name='fusion.TierStatusUpdate.TierStatus', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='players', full_name='fusion.TierStatusUpdate.TierStatus.players', index=0, + number=1, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='min_players', full_name='fusion.TierStatusUpdate.TierStatus.min_players', index=1, + number=2, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='max_players', full_name='fusion.TierStatusUpdate.TierStatus.max_players', index=2, + number=3, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='time_remaining', full_name='fusion.TierStatusUpdate.TierStatus.time_remaining', index=3, + number=4, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=952, + serialized_end=1047, +) + +_TIERSTATUSUPDATE_STATUSESENTRY = _descriptor.Descriptor( + name='StatusesEntry', + full_name='fusion.TierStatusUpdate.StatusesEntry', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='key', full_name='fusion.TierStatusUpdate.StatusesEntry.key', index=0, + number=1, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='value', full_name='fusion.TierStatusUpdate.StatusesEntry.value', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=_b('8\001'), + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1049, + serialized_end=1133, +) + +_TIERSTATUSUPDATE = _descriptor.Descriptor( + name='TierStatusUpdate', + full_name='fusion.TierStatusUpdate', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='statuses', full_name='fusion.TierStatusUpdate.statuses', index=0, + number=1, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[_TIERSTATUSUPDATE_TIERSTATUS, _TIERSTATUSUPDATE_STATUSESENTRY, ], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=874, + serialized_end=1133, +) + + +_FUSIONBEGIN = _descriptor.Descriptor( + name='FusionBegin', + full_name='fusion.FusionBegin', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='tier', full_name='fusion.FusionBegin.tier', index=0, + number=1, type=4, cpp_type=4, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='covert_domain', full_name='fusion.FusionBegin.covert_domain', index=1, + number=2, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='covert_port', full_name='fusion.FusionBegin.covert_port', index=2, + number=3, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='covert_ssl', full_name='fusion.FusionBegin.covert_ssl', index=3, + number=4, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='server_time', full_name='fusion.FusionBegin.server_time', index=4, + number=5, type=6, cpp_type=4, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1135, + serialized_end=1247, +) + + +_STARTROUND = _descriptor.Descriptor( + name='StartRound', + full_name='fusion.StartRound', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='round_pubkey', full_name='fusion.StartRound.round_pubkey', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='blind_nonce_points', full_name='fusion.StartRound.blind_nonce_points', index=1, + number=2, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='server_time', full_name='fusion.StartRound.server_time', index=2, + number=5, type=6, cpp_type=4, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1249, + serialized_end=1332, +) + + +_PLAYERCOMMIT = _descriptor.Descriptor( + name='PlayerCommit', + full_name='fusion.PlayerCommit', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='initial_commitments', full_name='fusion.PlayerCommit.initial_commitments', index=0, + number=1, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='excess_fee', full_name='fusion.PlayerCommit.excess_fee', index=1, + number=2, type=4, cpp_type=4, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='pedersen_total_nonce', full_name='fusion.PlayerCommit.pedersen_total_nonce', index=2, + number=3, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='random_number_commitment', full_name='fusion.PlayerCommit.random_number_commitment', index=3, + number=4, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='blind_sig_requests', full_name='fusion.PlayerCommit.blind_sig_requests', index=4, + number=5, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1335, + serialized_end=1490, +) + + +_BLINDSIGRESPONSES = _descriptor.Descriptor( + name='BlindSigResponses', + full_name='fusion.BlindSigResponses', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='scalars', full_name='fusion.BlindSigResponses.scalars', index=0, + number=1, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1492, + serialized_end=1528, +) + + +_ALLCOMMITMENTS = _descriptor.Descriptor( + name='AllCommitments', + full_name='fusion.AllCommitments', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='initial_commitments', full_name='fusion.AllCommitments.initial_commitments', index=0, + number=1, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1530, + serialized_end=1575, +) + + +_COVERTCOMPONENT = _descriptor.Descriptor( + name='CovertComponent', + full_name='fusion.CovertComponent', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='round_pubkey', full_name='fusion.CovertComponent.round_pubkey', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='signature', full_name='fusion.CovertComponent.signature', index=1, + number=2, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='component', full_name='fusion.CovertComponent.component', index=2, + number=3, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1577, + serialized_end=1654, +) + + +_SHARECOVERTCOMPONENTS = _descriptor.Descriptor( + name='ShareCovertComponents', + full_name='fusion.ShareCovertComponents', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='components', full_name='fusion.ShareCovertComponents.components', index=0, + number=4, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='skip_signatures', full_name='fusion.ShareCovertComponents.skip_signatures', index=1, + number=5, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='session_hash', full_name='fusion.ShareCovertComponents.session_hash', index=2, + number=6, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1656, + serialized_end=1746, +) + + +_COVERTTRANSACTIONSIGNATURE = _descriptor.Descriptor( + name='CovertTransactionSignature', + full_name='fusion.CovertTransactionSignature', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='round_pubkey', full_name='fusion.CovertTransactionSignature.round_pubkey', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='which_input', full_name='fusion.CovertTransactionSignature.which_input', index=1, + number=2, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='txsignature', full_name='fusion.CovertTransactionSignature.txsignature', index=2, + number=3, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1748, + serialized_end=1840, +) + + +_FUSIONRESULT = _descriptor.Descriptor( + name='FusionResult', + full_name='fusion.FusionResult', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='ok', full_name='fusion.FusionResult.ok', index=0, + number=1, type=8, cpp_type=7, label=2, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='txsignatures', full_name='fusion.FusionResult.txsignatures', index=1, + number=2, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='bad_components', full_name='fusion.FusionResult.bad_components', index=2, + number=3, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1842, + serialized_end=1914, +) + + +_MYPROOFSLIST = _descriptor.Descriptor( + name='MyProofsList', + full_name='fusion.MyProofsList', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='encrypted_proofs', full_name='fusion.MyProofsList.encrypted_proofs', index=0, + number=1, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='random_number', full_name='fusion.MyProofsList.random_number', index=1, + number=2, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1916, + serialized_end=1979, +) + + +_THEIRPROOFSLIST_RELAYEDPROOF = _descriptor.Descriptor( + name='RelayedProof', + full_name='fusion.TheirProofsList.RelayedProof', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='encrypted_proof', full_name='fusion.TheirProofsList.RelayedProof.encrypted_proof', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='src_commitment_idx', full_name='fusion.TheirProofsList.RelayedProof.src_commitment_idx', index=1, + number=2, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='dst_key_idx', full_name='fusion.TheirProofsList.RelayedProof.dst_key_idx', index=2, + number=3, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2055, + serialized_end=2143, +) + +_THEIRPROOFSLIST = _descriptor.Descriptor( + name='TheirProofsList', + full_name='fusion.TheirProofsList', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='proofs', full_name='fusion.TheirProofsList.proofs', index=0, + number=1, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[_THEIRPROOFSLIST_RELAYEDPROOF, ], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1982, + serialized_end=2143, +) + + +_BLAMES_BLAMEPROOF = _descriptor.Descriptor( + name='BlameProof', + full_name='fusion.Blames.BlameProof', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='which_proof', full_name='fusion.Blames.BlameProof.which_proof', index=0, + number=1, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='session_key', full_name='fusion.Blames.BlameProof.session_key', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='privkey', full_name='fusion.Blames.BlameProof.privkey', index=2, + number=3, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='need_lookup_blockchain', full_name='fusion.Blames.BlameProof.need_lookup_blockchain', index=3, + number=4, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='blame_reason', full_name='fusion.Blames.BlameProof.blame_reason', index=4, + number=5, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + _descriptor.OneofDescriptor( + name='decrypter', full_name='fusion.Blames.BlameProof.decrypter', + index=0, containing_type=None, fields=[]), + ], + serialized_start=2200, + serialized_end=2342, +) + +_BLAMES = _descriptor.Descriptor( + name='Blames', + full_name='fusion.Blames', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='blames', full_name='fusion.Blames.blames', index=0, + number=1, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[_BLAMES_BLAMEPROOF, ], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2146, + serialized_end=2342, +) + + +_RESTARTROUND = _descriptor.Descriptor( + name='RestartRound', + full_name='fusion.RestartRound', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2344, + serialized_end=2358, +) + + +_ERROR = _descriptor.Descriptor( + name='Error', + full_name='fusion.Error', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='message', full_name='fusion.Error.message', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2360, + serialized_end=2384, +) + + +_PING = _descriptor.Descriptor( + name='Ping', + full_name='fusion.Ping', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2386, + serialized_end=2392, +) + + +_OK = _descriptor.Descriptor( + name='OK', + full_name='fusion.OK', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2394, + serialized_end=2398, +) + + +_CLIENTMESSAGE = _descriptor.Descriptor( + name='ClientMessage', + full_name='fusion.ClientMessage', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='clienthello', full_name='fusion.ClientMessage.clienthello', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='joinpools', full_name='fusion.ClientMessage.joinpools', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='playercommit', full_name='fusion.ClientMessage.playercommit', index=2, + number=3, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='myproofslist', full_name='fusion.ClientMessage.myproofslist', index=3, + number=5, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='blames', full_name='fusion.ClientMessage.blames', index=4, + number=6, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + _descriptor.OneofDescriptor( + name='msg', full_name='fusion.ClientMessage.msg', + index=0, containing_type=None, fields=[]), + ], + serialized_start=2401, + serialized_end=2633, +) + + +_SERVERMESSAGE = _descriptor.Descriptor( + name='ServerMessage', + full_name='fusion.ServerMessage', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='serverhello', full_name='fusion.ServerMessage.serverhello', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='tierstatusupdate', full_name='fusion.ServerMessage.tierstatusupdate', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='fusionbegin', full_name='fusion.ServerMessage.fusionbegin', index=2, + number=3, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='startround', full_name='fusion.ServerMessage.startround', index=3, + number=4, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='blindsigresponses', full_name='fusion.ServerMessage.blindsigresponses', index=4, + number=5, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='allcommitments', full_name='fusion.ServerMessage.allcommitments', index=5, + number=6, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='sharecovertcomponents', full_name='fusion.ServerMessage.sharecovertcomponents', index=6, + number=7, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='fusionresult', full_name='fusion.ServerMessage.fusionresult', index=7, + number=8, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='theirproofslist', full_name='fusion.ServerMessage.theirproofslist', index=8, + number=9, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='restartround', full_name='fusion.ServerMessage.restartround', index=9, + number=14, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='error', full_name='fusion.ServerMessage.error', index=10, + number=15, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + _descriptor.OneofDescriptor( + name='msg', full_name='fusion.ServerMessage.msg', + index=0, containing_type=None, fields=[]), + ], + serialized_start=2636, + serialized_end=3188, +) + + +_COVERTMESSAGE = _descriptor.Descriptor( + name='CovertMessage', + full_name='fusion.CovertMessage', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='component', full_name='fusion.CovertMessage.component', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='signature', full_name='fusion.CovertMessage.signature', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='ping', full_name='fusion.CovertMessage.ping', index=2, + number=3, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + _descriptor.OneofDescriptor( + name='msg', full_name='fusion.CovertMessage.msg', + index=0, containing_type=None, fields=[]), + ], + serialized_start=3191, + serialized_end=3346, +) + + +_COVERTRESPONSE = _descriptor.Descriptor( + name='CovertResponse', + full_name='fusion.CovertResponse', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='ok', full_name='fusion.CovertResponse.ok', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='error', full_name='fusion.CovertResponse.error', index=1, + number=15, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + _descriptor.OneofDescriptor( + name='msg', full_name='fusion.CovertResponse.msg', + index=0, containing_type=None, fields=[]), + ], + serialized_start=3348, + serialized_end=3429, +) + +_COMPONENT.fields_by_name['input'].message_type = _INPUTCOMPONENT +_COMPONENT.fields_by_name['output'].message_type = _OUTPUTCOMPONENT +_COMPONENT.fields_by_name['blank'].message_type = _BLANKCOMPONENT +_COMPONENT.oneofs_by_name['component'].fields.append( + _COMPONENT.fields_by_name['input']) +_COMPONENT.fields_by_name['input'].containing_oneof = _COMPONENT.oneofs_by_name['component'] +_COMPONENT.oneofs_by_name['component'].fields.append( + _COMPONENT.fields_by_name['output']) +_COMPONENT.fields_by_name['output'].containing_oneof = _COMPONENT.oneofs_by_name['component'] +_COMPONENT.oneofs_by_name['component'].fields.append( + _COMPONENT.fields_by_name['blank']) +_COMPONENT.fields_by_name['blank'].containing_oneof = _COMPONENT.oneofs_by_name['component'] +_JOINPOOLS_POOLTAG.containing_type = _JOINPOOLS +_JOINPOOLS.fields_by_name['tags'].message_type = _JOINPOOLS_POOLTAG +_TIERSTATUSUPDATE_TIERSTATUS.containing_type = _TIERSTATUSUPDATE +_TIERSTATUSUPDATE_STATUSESENTRY.fields_by_name['value'].message_type = _TIERSTATUSUPDATE_TIERSTATUS +_TIERSTATUSUPDATE_STATUSESENTRY.containing_type = _TIERSTATUSUPDATE +_TIERSTATUSUPDATE.fields_by_name['statuses'].message_type = _TIERSTATUSUPDATE_STATUSESENTRY +_THEIRPROOFSLIST_RELAYEDPROOF.containing_type = _THEIRPROOFSLIST +_THEIRPROOFSLIST.fields_by_name['proofs'].message_type = _THEIRPROOFSLIST_RELAYEDPROOF +_BLAMES_BLAMEPROOF.containing_type = _BLAMES +_BLAMES_BLAMEPROOF.oneofs_by_name['decrypter'].fields.append( + _BLAMES_BLAMEPROOF.fields_by_name['session_key']) +_BLAMES_BLAMEPROOF.fields_by_name['session_key'].containing_oneof = _BLAMES_BLAMEPROOF.oneofs_by_name['decrypter'] +_BLAMES_BLAMEPROOF.oneofs_by_name['decrypter'].fields.append( + _BLAMES_BLAMEPROOF.fields_by_name['privkey']) +_BLAMES_BLAMEPROOF.fields_by_name['privkey'].containing_oneof = _BLAMES_BLAMEPROOF.oneofs_by_name['decrypter'] +_BLAMES.fields_by_name['blames'].message_type = _BLAMES_BLAMEPROOF +_CLIENTMESSAGE.fields_by_name['clienthello'].message_type = _CLIENTHELLO +_CLIENTMESSAGE.fields_by_name['joinpools'].message_type = _JOINPOOLS +_CLIENTMESSAGE.fields_by_name['playercommit'].message_type = _PLAYERCOMMIT +_CLIENTMESSAGE.fields_by_name['myproofslist'].message_type = _MYPROOFSLIST +_CLIENTMESSAGE.fields_by_name['blames'].message_type = _BLAMES +_CLIENTMESSAGE.oneofs_by_name['msg'].fields.append( + _CLIENTMESSAGE.fields_by_name['clienthello']) +_CLIENTMESSAGE.fields_by_name['clienthello'].containing_oneof = _CLIENTMESSAGE.oneofs_by_name['msg'] +_CLIENTMESSAGE.oneofs_by_name['msg'].fields.append( + _CLIENTMESSAGE.fields_by_name['joinpools']) +_CLIENTMESSAGE.fields_by_name['joinpools'].containing_oneof = _CLIENTMESSAGE.oneofs_by_name['msg'] +_CLIENTMESSAGE.oneofs_by_name['msg'].fields.append( + _CLIENTMESSAGE.fields_by_name['playercommit']) +_CLIENTMESSAGE.fields_by_name['playercommit'].containing_oneof = _CLIENTMESSAGE.oneofs_by_name['msg'] +_CLIENTMESSAGE.oneofs_by_name['msg'].fields.append( + _CLIENTMESSAGE.fields_by_name['myproofslist']) +_CLIENTMESSAGE.fields_by_name['myproofslist'].containing_oneof = _CLIENTMESSAGE.oneofs_by_name['msg'] +_CLIENTMESSAGE.oneofs_by_name['msg'].fields.append( + _CLIENTMESSAGE.fields_by_name['blames']) +_CLIENTMESSAGE.fields_by_name['blames'].containing_oneof = _CLIENTMESSAGE.oneofs_by_name['msg'] +_SERVERMESSAGE.fields_by_name['serverhello'].message_type = _SERVERHELLO +_SERVERMESSAGE.fields_by_name['tierstatusupdate'].message_type = _TIERSTATUSUPDATE +_SERVERMESSAGE.fields_by_name['fusionbegin'].message_type = _FUSIONBEGIN +_SERVERMESSAGE.fields_by_name['startround'].message_type = _STARTROUND +_SERVERMESSAGE.fields_by_name['blindsigresponses'].message_type = _BLINDSIGRESPONSES +_SERVERMESSAGE.fields_by_name['allcommitments'].message_type = _ALLCOMMITMENTS +_SERVERMESSAGE.fields_by_name['sharecovertcomponents'].message_type = _SHARECOVERTCOMPONENTS +_SERVERMESSAGE.fields_by_name['fusionresult'].message_type = _FUSIONRESULT +_SERVERMESSAGE.fields_by_name['theirproofslist'].message_type = _THEIRPROOFSLIST +_SERVERMESSAGE.fields_by_name['restartround'].message_type = _RESTARTROUND +_SERVERMESSAGE.fields_by_name['error'].message_type = _ERROR +_SERVERMESSAGE.oneofs_by_name['msg'].fields.append( + _SERVERMESSAGE.fields_by_name['serverhello']) +_SERVERMESSAGE.fields_by_name['serverhello'].containing_oneof = _SERVERMESSAGE.oneofs_by_name['msg'] +_SERVERMESSAGE.oneofs_by_name['msg'].fields.append( + _SERVERMESSAGE.fields_by_name['tierstatusupdate']) +_SERVERMESSAGE.fields_by_name['tierstatusupdate'].containing_oneof = _SERVERMESSAGE.oneofs_by_name['msg'] +_SERVERMESSAGE.oneofs_by_name['msg'].fields.append( + _SERVERMESSAGE.fields_by_name['fusionbegin']) +_SERVERMESSAGE.fields_by_name['fusionbegin'].containing_oneof = _SERVERMESSAGE.oneofs_by_name['msg'] +_SERVERMESSAGE.oneofs_by_name['msg'].fields.append( + _SERVERMESSAGE.fields_by_name['startround']) +_SERVERMESSAGE.fields_by_name['startround'].containing_oneof = _SERVERMESSAGE.oneofs_by_name['msg'] +_SERVERMESSAGE.oneofs_by_name['msg'].fields.append( + _SERVERMESSAGE.fields_by_name['blindsigresponses']) +_SERVERMESSAGE.fields_by_name['blindsigresponses'].containing_oneof = _SERVERMESSAGE.oneofs_by_name['msg'] +_SERVERMESSAGE.oneofs_by_name['msg'].fields.append( + _SERVERMESSAGE.fields_by_name['allcommitments']) +_SERVERMESSAGE.fields_by_name['allcommitments'].containing_oneof = _SERVERMESSAGE.oneofs_by_name['msg'] +_SERVERMESSAGE.oneofs_by_name['msg'].fields.append( + _SERVERMESSAGE.fields_by_name['sharecovertcomponents']) +_SERVERMESSAGE.fields_by_name['sharecovertcomponents'].containing_oneof = _SERVERMESSAGE.oneofs_by_name['msg'] +_SERVERMESSAGE.oneofs_by_name['msg'].fields.append( + _SERVERMESSAGE.fields_by_name['fusionresult']) +_SERVERMESSAGE.fields_by_name['fusionresult'].containing_oneof = _SERVERMESSAGE.oneofs_by_name['msg'] +_SERVERMESSAGE.oneofs_by_name['msg'].fields.append( + _SERVERMESSAGE.fields_by_name['theirproofslist']) +_SERVERMESSAGE.fields_by_name['theirproofslist'].containing_oneof = _SERVERMESSAGE.oneofs_by_name['msg'] +_SERVERMESSAGE.oneofs_by_name['msg'].fields.append( + _SERVERMESSAGE.fields_by_name['restartround']) +_SERVERMESSAGE.fields_by_name['restartround'].containing_oneof = _SERVERMESSAGE.oneofs_by_name['msg'] +_SERVERMESSAGE.oneofs_by_name['msg'].fields.append( + _SERVERMESSAGE.fields_by_name['error']) +_SERVERMESSAGE.fields_by_name['error'].containing_oneof = _SERVERMESSAGE.oneofs_by_name['msg'] +_COVERTMESSAGE.fields_by_name['component'].message_type = _COVERTCOMPONENT +_COVERTMESSAGE.fields_by_name['signature'].message_type = _COVERTTRANSACTIONSIGNATURE +_COVERTMESSAGE.fields_by_name['ping'].message_type = _PING +_COVERTMESSAGE.oneofs_by_name['msg'].fields.append( + _COVERTMESSAGE.fields_by_name['component']) +_COVERTMESSAGE.fields_by_name['component'].containing_oneof = _COVERTMESSAGE.oneofs_by_name['msg'] +_COVERTMESSAGE.oneofs_by_name['msg'].fields.append( + _COVERTMESSAGE.fields_by_name['signature']) +_COVERTMESSAGE.fields_by_name['signature'].containing_oneof = _COVERTMESSAGE.oneofs_by_name['msg'] +_COVERTMESSAGE.oneofs_by_name['msg'].fields.append( + _COVERTMESSAGE.fields_by_name['ping']) +_COVERTMESSAGE.fields_by_name['ping'].containing_oneof = _COVERTMESSAGE.oneofs_by_name['msg'] +_COVERTRESPONSE.fields_by_name['ok'].message_type = _OK +_COVERTRESPONSE.fields_by_name['error'].message_type = _ERROR +_COVERTRESPONSE.oneofs_by_name['msg'].fields.append( + _COVERTRESPONSE.fields_by_name['ok']) +_COVERTRESPONSE.fields_by_name['ok'].containing_oneof = _COVERTRESPONSE.oneofs_by_name['msg'] +_COVERTRESPONSE.oneofs_by_name['msg'].fields.append( + _COVERTRESPONSE.fields_by_name['error']) +_COVERTRESPONSE.fields_by_name['error'].containing_oneof = _COVERTRESPONSE.oneofs_by_name['msg'] +DESCRIPTOR.message_types_by_name['InputComponent'] = _INPUTCOMPONENT +DESCRIPTOR.message_types_by_name['OutputComponent'] = _OUTPUTCOMPONENT +DESCRIPTOR.message_types_by_name['BlankComponent'] = _BLANKCOMPONENT +DESCRIPTOR.message_types_by_name['Component'] = _COMPONENT +DESCRIPTOR.message_types_by_name['InitialCommitment'] = _INITIALCOMMITMENT +DESCRIPTOR.message_types_by_name['Proof'] = _PROOF +DESCRIPTOR.message_types_by_name['ClientHello'] = _CLIENTHELLO +DESCRIPTOR.message_types_by_name['ServerHello'] = _SERVERHELLO +DESCRIPTOR.message_types_by_name['JoinPools'] = _JOINPOOLS +DESCRIPTOR.message_types_by_name['TierStatusUpdate'] = _TIERSTATUSUPDATE +DESCRIPTOR.message_types_by_name['FusionBegin'] = _FUSIONBEGIN +DESCRIPTOR.message_types_by_name['StartRound'] = _STARTROUND +DESCRIPTOR.message_types_by_name['PlayerCommit'] = _PLAYERCOMMIT +DESCRIPTOR.message_types_by_name['BlindSigResponses'] = _BLINDSIGRESPONSES +DESCRIPTOR.message_types_by_name['AllCommitments'] = _ALLCOMMITMENTS +DESCRIPTOR.message_types_by_name['CovertComponent'] = _COVERTCOMPONENT +DESCRIPTOR.message_types_by_name['ShareCovertComponents'] = _SHARECOVERTCOMPONENTS +DESCRIPTOR.message_types_by_name['CovertTransactionSignature'] = _COVERTTRANSACTIONSIGNATURE +DESCRIPTOR.message_types_by_name['FusionResult'] = _FUSIONRESULT +DESCRIPTOR.message_types_by_name['MyProofsList'] = _MYPROOFSLIST +DESCRIPTOR.message_types_by_name['TheirProofsList'] = _THEIRPROOFSLIST +DESCRIPTOR.message_types_by_name['Blames'] = _BLAMES +DESCRIPTOR.message_types_by_name['RestartRound'] = _RESTARTROUND +DESCRIPTOR.message_types_by_name['Error'] = _ERROR +DESCRIPTOR.message_types_by_name['Ping'] = _PING +DESCRIPTOR.message_types_by_name['OK'] = _OK +DESCRIPTOR.message_types_by_name['ClientMessage'] = _CLIENTMESSAGE +DESCRIPTOR.message_types_by_name['ServerMessage'] = _SERVERMESSAGE +DESCRIPTOR.message_types_by_name['CovertMessage'] = _COVERTMESSAGE +DESCRIPTOR.message_types_by_name['CovertResponse'] = _COVERTRESPONSE +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +InputComponent = _reflection.GeneratedProtocolMessageType('InputComponent', (_message.Message,), dict( + DESCRIPTOR = _INPUTCOMPONENT, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.InputComponent) + )) +_sym_db.RegisterMessage(InputComponent) + +OutputComponent = _reflection.GeneratedProtocolMessageType('OutputComponent', (_message.Message,), dict( + DESCRIPTOR = _OUTPUTCOMPONENT, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.OutputComponent) + )) +_sym_db.RegisterMessage(OutputComponent) + +BlankComponent = _reflection.GeneratedProtocolMessageType('BlankComponent', (_message.Message,), dict( + DESCRIPTOR = _BLANKCOMPONENT, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.BlankComponent) + )) +_sym_db.RegisterMessage(BlankComponent) + +Component = _reflection.GeneratedProtocolMessageType('Component', (_message.Message,), dict( + DESCRIPTOR = _COMPONENT, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.Component) + )) +_sym_db.RegisterMessage(Component) + +InitialCommitment = _reflection.GeneratedProtocolMessageType('InitialCommitment', (_message.Message,), dict( + DESCRIPTOR = _INITIALCOMMITMENT, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.InitialCommitment) + )) +_sym_db.RegisterMessage(InitialCommitment) + +Proof = _reflection.GeneratedProtocolMessageType('Proof', (_message.Message,), dict( + DESCRIPTOR = _PROOF, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.Proof) + )) +_sym_db.RegisterMessage(Proof) + +ClientHello = _reflection.GeneratedProtocolMessageType('ClientHello', (_message.Message,), dict( + DESCRIPTOR = _CLIENTHELLO, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.ClientHello) + )) +_sym_db.RegisterMessage(ClientHello) + +ServerHello = _reflection.GeneratedProtocolMessageType('ServerHello', (_message.Message,), dict( + DESCRIPTOR = _SERVERHELLO, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.ServerHello) + )) +_sym_db.RegisterMessage(ServerHello) + +JoinPools = _reflection.GeneratedProtocolMessageType('JoinPools', (_message.Message,), dict( + + PoolTag = _reflection.GeneratedProtocolMessageType('PoolTag', (_message.Message,), dict( + DESCRIPTOR = _JOINPOOLS_POOLTAG, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.JoinPools.PoolTag) + )) + , + DESCRIPTOR = _JOINPOOLS, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.JoinPools) + )) +_sym_db.RegisterMessage(JoinPools) +_sym_db.RegisterMessage(JoinPools.PoolTag) + +TierStatusUpdate = _reflection.GeneratedProtocolMessageType('TierStatusUpdate', (_message.Message,), dict( + + TierStatus = _reflection.GeneratedProtocolMessageType('TierStatus', (_message.Message,), dict( + DESCRIPTOR = _TIERSTATUSUPDATE_TIERSTATUS, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.TierStatusUpdate.TierStatus) + )) + , + + StatusesEntry = _reflection.GeneratedProtocolMessageType('StatusesEntry', (_message.Message,), dict( + DESCRIPTOR = _TIERSTATUSUPDATE_STATUSESENTRY, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.TierStatusUpdate.StatusesEntry) + )) + , + DESCRIPTOR = _TIERSTATUSUPDATE, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.TierStatusUpdate) + )) +_sym_db.RegisterMessage(TierStatusUpdate) +_sym_db.RegisterMessage(TierStatusUpdate.TierStatus) +_sym_db.RegisterMessage(TierStatusUpdate.StatusesEntry) + +FusionBegin = _reflection.GeneratedProtocolMessageType('FusionBegin', (_message.Message,), dict( + DESCRIPTOR = _FUSIONBEGIN, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.FusionBegin) + )) +_sym_db.RegisterMessage(FusionBegin) + +StartRound = _reflection.GeneratedProtocolMessageType('StartRound', (_message.Message,), dict( + DESCRIPTOR = _STARTROUND, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.StartRound) + )) +_sym_db.RegisterMessage(StartRound) + +PlayerCommit = _reflection.GeneratedProtocolMessageType('PlayerCommit', (_message.Message,), dict( + DESCRIPTOR = _PLAYERCOMMIT, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.PlayerCommit) + )) +_sym_db.RegisterMessage(PlayerCommit) + +BlindSigResponses = _reflection.GeneratedProtocolMessageType('BlindSigResponses', (_message.Message,), dict( + DESCRIPTOR = _BLINDSIGRESPONSES, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.BlindSigResponses) + )) +_sym_db.RegisterMessage(BlindSigResponses) + +AllCommitments = _reflection.GeneratedProtocolMessageType('AllCommitments', (_message.Message,), dict( + DESCRIPTOR = _ALLCOMMITMENTS, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.AllCommitments) + )) +_sym_db.RegisterMessage(AllCommitments) + +CovertComponent = _reflection.GeneratedProtocolMessageType('CovertComponent', (_message.Message,), dict( + DESCRIPTOR = _COVERTCOMPONENT, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.CovertComponent) + )) +_sym_db.RegisterMessage(CovertComponent) + +ShareCovertComponents = _reflection.GeneratedProtocolMessageType('ShareCovertComponents', (_message.Message,), dict( + DESCRIPTOR = _SHARECOVERTCOMPONENTS, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.ShareCovertComponents) + )) +_sym_db.RegisterMessage(ShareCovertComponents) + +CovertTransactionSignature = _reflection.GeneratedProtocolMessageType('CovertTransactionSignature', (_message.Message,), dict( + DESCRIPTOR = _COVERTTRANSACTIONSIGNATURE, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.CovertTransactionSignature) + )) +_sym_db.RegisterMessage(CovertTransactionSignature) + +FusionResult = _reflection.GeneratedProtocolMessageType('FusionResult', (_message.Message,), dict( + DESCRIPTOR = _FUSIONRESULT, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.FusionResult) + )) +_sym_db.RegisterMessage(FusionResult) + +MyProofsList = _reflection.GeneratedProtocolMessageType('MyProofsList', (_message.Message,), dict( + DESCRIPTOR = _MYPROOFSLIST, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.MyProofsList) + )) +_sym_db.RegisterMessage(MyProofsList) + +TheirProofsList = _reflection.GeneratedProtocolMessageType('TheirProofsList', (_message.Message,), dict( + + RelayedProof = _reflection.GeneratedProtocolMessageType('RelayedProof', (_message.Message,), dict( + DESCRIPTOR = _THEIRPROOFSLIST_RELAYEDPROOF, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.TheirProofsList.RelayedProof) + )) + , + DESCRIPTOR = _THEIRPROOFSLIST, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.TheirProofsList) + )) +_sym_db.RegisterMessage(TheirProofsList) +_sym_db.RegisterMessage(TheirProofsList.RelayedProof) + +Blames = _reflection.GeneratedProtocolMessageType('Blames', (_message.Message,), dict( + + BlameProof = _reflection.GeneratedProtocolMessageType('BlameProof', (_message.Message,), dict( + DESCRIPTOR = _BLAMES_BLAMEPROOF, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.Blames.BlameProof) + )) + , + DESCRIPTOR = _BLAMES, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.Blames) + )) +_sym_db.RegisterMessage(Blames) +_sym_db.RegisterMessage(Blames.BlameProof) + +RestartRound = _reflection.GeneratedProtocolMessageType('RestartRound', (_message.Message,), dict( + DESCRIPTOR = _RESTARTROUND, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.RestartRound) + )) +_sym_db.RegisterMessage(RestartRound) + +Error = _reflection.GeneratedProtocolMessageType('Error', (_message.Message,), dict( + DESCRIPTOR = _ERROR, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.Error) + )) +_sym_db.RegisterMessage(Error) + +Ping = _reflection.GeneratedProtocolMessageType('Ping', (_message.Message,), dict( + DESCRIPTOR = _PING, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.Ping) + )) +_sym_db.RegisterMessage(Ping) + +OK = _reflection.GeneratedProtocolMessageType('OK', (_message.Message,), dict( + DESCRIPTOR = _OK, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.OK) + )) +_sym_db.RegisterMessage(OK) + +ClientMessage = _reflection.GeneratedProtocolMessageType('ClientMessage', (_message.Message,), dict( + DESCRIPTOR = _CLIENTMESSAGE, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.ClientMessage) + )) +_sym_db.RegisterMessage(ClientMessage) + +ServerMessage = _reflection.GeneratedProtocolMessageType('ServerMessage', (_message.Message,), dict( + DESCRIPTOR = _SERVERMESSAGE, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.ServerMessage) + )) +_sym_db.RegisterMessage(ServerMessage) + +CovertMessage = _reflection.GeneratedProtocolMessageType('CovertMessage', (_message.Message,), dict( + DESCRIPTOR = _COVERTMESSAGE, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.CovertMessage) + )) +_sym_db.RegisterMessage(CovertMessage) + +CovertResponse = _reflection.GeneratedProtocolMessageType('CovertResponse', (_message.Message,), dict( + DESCRIPTOR = _COVERTRESPONSE, + __module__ = 'fusion_pb2' + # @@protoc_insertion_point(class_scope:fusion.CovertResponse) + )) +_sym_db.RegisterMessage(CovertResponse) + + +_TIERSTATUSUPDATE_STATUSESENTRY._options = None +# @@protoc_insertion_point(module_scope) diff --git a/plugins/fusion/pedersen.py b/plugins/fusion/pedersen.py new file mode 100644 index 000000000000..82d1eee89847 --- /dev/null +++ b/plugins/fusion/pedersen.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +Pedersen commitments on secp256k1 -- + + commitment = nonce*G + amount*H + +where G is the regular base point and H is a secondary base point with an +unknown discrete logarithm with respect to G. One should choose H as a +"nothing-up-my-sleeve" point with an obviously chosen x coordinate. + +nonce and amount are scalars. The nonce should be randomly and uniformly +selected for each commitment, and the amount committed is a modular number. + +Note that commitments to negative amounts are indistinguishable from +commitments to very large amounts. In practice, you probably need some kind +of additional mechanism (commitment reveal, range proof) to make sure the +amount is sensible. + +""" + +from electroncash import secp256k1 +import ecdsa +from electroncash.bitcoin import ser_to_point, point_to_ser +from ctypes import create_string_buffer, c_void_p, c_char_p, c_int, c_size_t, byref, cast + +order = ecdsa.SECP256k1.generator.order() +seclib = secp256k1.secp256k1 + +class NonceRangeError(ValueError): + pass + +class ResultAtInfinity(Exception): + pass + +class InsecureHPoint(Exception): + # This exception gets thrown when the H point has a known discrete + # logarithm, which means the commitment setup is broken. + pass + +class PedersenSetup: + """ + You need to make one of these objects to set up the Pedersen scheme, + before making any Commitment objects. One Setup object can be used for + many Commitments. + + This stores uncompressed serializations of H and H+G as .H and .HG, + respectively. It also stores ECC-library-specific internal representations + of these two points. + + (Why H and H+G? To be able to blind the elliptic curve math -- see + Commitment class comments for details) + """ + def __init__(self, H): + assert isinstance(H, bytes) + + if not seclib: + try: + Hpoint = ser_to_point(H) + except: + raise ValueError("H could not be parsed") + HGpoint = Hpoint + ecdsa.SECP256k1.generator + if HGpoint == ecdsa.ellipticcurve.INFINITY: + # this happens if H = -G + raise InsecureHPoint(-1) + self._ecdsa_H = Hpoint + self._ecdsa_HG = HGpoint + + self.H = point_to_ser(Hpoint, comp=False) + self.HG = point_to_ser(HGpoint, comp=False) + else: + ctx = seclib.ctx + H_buf = create_string_buffer(64) + res = seclib.secp256k1_ec_pubkey_parse(ctx, H_buf, H, c_size_t(len(H))) + if not res: + raise ValueError('H could not be parsed by the secp256k1 library') + + self._seclib_H = H_buf.raw + + G = point_to_ser(ecdsa.SECP256k1.generator, comp=False) + G_buf = create_string_buffer(64) + res = seclib.secp256k1_ec_pubkey_parse(ctx, G_buf, G, c_size_t(len(G))) + assert res, "G point should always deserialize without issue" + + HG_buf = create_string_buffer(64) + publist = (c_void_p*2)(*(cast(x, c_void_p) for x in (H_buf, G_buf))) + res = seclib.secp256k1_ec_pubkey_combine(ctx, HG_buf, publist, 2) + if res != 1: + # this happens if H = -G + raise InsecureHPoint(-1) + + self._seclib_HG = HG_buf.raw + + # now serialize H and HG as uncompressed bytes + serpoint = create_string_buffer(65) + sersize = c_size_t(65) + + res = seclib.secp256k1_ec_pubkey_serialize(ctx, serpoint, byref(sersize), H_buf, secp256k1.SECP256K1_EC_UNCOMPRESSED) + assert res == 1 + assert sersize.value == 65 + self.H = serpoint.raw + + res = seclib.secp256k1_ec_pubkey_serialize(ctx, serpoint, byref(sersize), HG_buf, secp256k1.SECP256K1_EC_UNCOMPRESSED) + assert res == 1 + assert sersize.value == 65 + self.HG = serpoint.raw + + def commit(self, amount, nonce=None): + return Commitment(self, amount, nonce=nonce) + +class Commitment: + """ + This represents a single commitment. Upon construction it calculates the + commitment point, and stores the random secret nonce value. + """ + def __init__(self, setup, amount, nonce=None, _P_uncompressed=None): + """ setup should be a PedersenSetup object. + + amount should be an integer, may be negative or positive. The provided + value is stored as .amount and its normal form (mod order) is stored in + amount_mod. There is no restriction on the size nor sign of amount. + + You can also use this class to test a revealed commitment, by providing + the nonce value. Provided nonces must be in the range 0 < nonce < order, + or else a NonceRangeError will result. + + _P_uncompressed is an internal API variable, do not use. + """ + assert isinstance(setup, PedersenSetup) + self.setup = setup + + self.amount = int(amount) + self.amount_mod = amount % order + + if nonce is None: + self.nonce = ecdsa.util.randrange(order) + else: + nonce = int(nonce) + self.nonce = nonce + if self.nonce <= 0 or self.nonce >= order: + raise NonceRangeError + + if _P_uncompressed: + assert len(_P_uncompressed) == 65 + assert _P_uncompressed[0] == 4 + self.P_uncompressed = _P_uncompressed + self.P_compressed = bytes([2 + (_P_uncompressed[-1]&1)]) + _P_uncompressed[1:33] + return + + try: + if seclib: + self._calc_initial_fast() + else: + self._calc_initial() + except ResultAtInfinity: + # We have to exclude P = infinity which can't be serialized. If + # this happens, we have discovered a serious problem. + # + # First, note that if it does happen, then we can trivially compute + # the discrete logarithm of H relative to G. So we have just cracked + # the commitment scheme. + # + # Most likely, this has happened intentionally: + # + # - if H was chosen from the start to have a known discrete log + # with respect to G. + # - if someone has cracked the H point's discrete log + # + # Thus not only do we know the discrete log, but the big conclusion + # to draw here is that someone else does, too! + # + # (Because 0 < nonce < order, this is basically impossible to cause + # in a normal setup (only ~2^256 chance).) + + # As it's easy to calculate the discrete log, let's do it. + dlog = (pow(self.amount_mod, order-2, order) * self.nonce) % order + raise InsecureHPoint(dlog) + + def _calc_initial(self): + Hpoint = self.setup._ecdsa_H + HGpoint = self.setup._ecdsa_HG + + k = self.nonce + a = self.amount_mod + + # We don't want to calculate (a * Hpoint) since the time to execute + # would reveal information about size / bitcount of a. So, we use + # the nonce as a blinding offset factor. + Ppoint = ((a - k) % order) * Hpoint + k * HGpoint + + if Ppoint == ecdsa.ellipticcurve.INFINITY: + raise ResultAtInfinity + + self.P_uncompressed = point_to_ser(Ppoint, comp=False) + self.P_compressed = point_to_ser(Ppoint, comp=True) + + def _calc_initial_fast(self): + # Fast version of _calc_initial, using libsecp256k1. + # Like in the slow version above, we need to perform a blinding of the + # amount for timing reasons. Why? + # - libsecp's scalar*pubkey multiplication is not constant time, though + # of course since it's so much faster, it's harder to attack this. + # - amount=0 is going to be popular, and libsecp returns fail + # immediately if we ask it to compute 0*H. + ctx = seclib.ctx + + k = self.nonce + a = self.amount_mod + + # calculate k * (G + H) + k_bytes = k.to_bytes(32,'big') + kHG_buf = create_string_buffer(64) + kHG_buf.raw = self.setup._seclib_HG #copy + res = seclib.secp256k1_ec_pubkey_tweak_mul(ctx, kHG_buf, k_bytes) + assert res == 1, "must never fail since 0 < k < order" + + a_k = (a - k) % order + if a_k != 0: + result_buf = create_string_buffer(64) + + # calculate (a - k) * H + a_k_bytes = a_k.to_bytes(32,'big') + akH_buf = create_string_buffer(64) + akH_buf.raw = self.setup._seclib_H #copy + res = seclib.secp256k1_ec_pubkey_tweak_mul(ctx, akH_buf, a_k_bytes) + assert res == 1, "must never fail since a != k here" + + # add the two points together. + publist = (c_void_p*2)(*(cast(x, c_void_p) for x in (kHG_buf, akH_buf))) + res = seclib.secp256k1_ec_pubkey_combine(ctx, result_buf, publist, 2) + if res != 1: + raise ResultAtInfinity + else: + # a == k. this executes much faster but will almost never happen in + # normal practice. + result_buf = kHG_buf + + # serialize the result! + serpoint = create_string_buffer(65) + sersize = c_size_t(65) + + res = seclib.secp256k1_ec_pubkey_serialize(ctx, serpoint, byref(sersize), result_buf, secp256k1.SECP256K1_EC_UNCOMPRESSED) + assert res == 1 + assert sersize.value == 65 + self.P_uncompressed = serpoint.raw + + res = seclib.secp256k1_ec_pubkey_serialize(ctx, serpoint, byref(sersize), result_buf, secp256k1.SECP256K1_EC_COMPRESSED) + assert res == 1 + assert sersize.value == 33 + self.P_compressed = serpoint.raw[:33] + +def add_points(points_iterable): + """ Adds one or more serialized points together. This is fastest if the + points are already uncompressed. Returns uncompressed point. + + Note: intermediate sums are allowed to be the point at infinity, but not the + final result. + """ + plist = [] + if seclib: + ctx = seclib.ctx + for pser in points_iterable: + P_buf = create_string_buffer(64) + _b = bytes(pser) + res = seclib.secp256k1_ec_pubkey_parse(ctx, P_buf, _b, c_size_t(len(_b))) + if not res: + raise ValueError('point could not be parsed by the secp256k1 library') + plist.append(P_buf) + if not plist: + raise ValueError('empty list') + + num = len(plist) + result_buf = create_string_buffer(64) + publist = (c_void_p*num)(*(cast(x, c_void_p) for x in plist)) + res = seclib.secp256k1_ec_pubkey_combine(ctx, result_buf, publist, num) + if res != 1: + raise ResultAtInfinity + + serpoint = create_string_buffer(65) + sersize = c_size_t(65) + res = seclib.secp256k1_ec_pubkey_serialize(ctx, serpoint, byref(sersize), result_buf, secp256k1.SECP256K1_EC_UNCOMPRESSED) + assert res == 1 + assert sersize.value == 65 + return serpoint.raw + else: + for pser in points_iterable: + plist.append(ser_to_point(pser)) + if not plist: + raise ValueError('empty list') + Psum = sum(plist[1:], plist[0]) + if Psum == ecdsa.ellipticcurve.INFINITY: + raise ResultAtInfinity + return point_to_ser(Psum, comp=False) + +def add_commitments(commitment_iterable): + """ Adds any number of Pedersen commitments together, resulting in + another Commitment. + + commitment_list is a list of Commitment objects; they must all share + the same PedersenSetup (else the result wouldn't make any sense).""" + ktotal = 0 + atotal = 0 + points = [] + setups = [] + for c in commitment_iterable: + ktotal += c.nonce + atotal += c.amount + points.append(c.P_uncompressed) + setups.append(c.setup) + + if len(points) < 1: + raise ValueError('empty list') + + setup = setups[0] + if not all(s is setup for s in setups): + raise ValueError('mismatched setups') + + # atotal is not computed from modulo quantities. + + ktotal = ktotal % order + + if ktotal == 0: + # this improbable to happen by accident, but very easily occurs with + # deliberate nonce choices. + raise NonceRangeError + + if len(points) < 512: + # Point addition is quite fast, when compared to doing two + # scalar.point multiplications. + try: + P_uncompressed = add_points(points) + except ResultAtInfinity: + P_uncompressed = None # will raise exception below + else: + # So many points, we are better off just doing it from scalars. + P_uncompressed = None + + return Commitment(setup, atotal, nonce=ktotal, _P_uncompressed = P_uncompressed) diff --git a/plugins/fusion/plugin.py b/plugins/fusion/plugin.py new file mode 100644 index 000000000000..a589a309eac8 --- /dev/null +++ b/plugins/fusion/plugin.py @@ -0,0 +1,617 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +Base plugin (non-GUI) +""" +import math +import threading +import time +import weakref + +from typing import Optional, Tuple + +from electroncash.address import Address +from electroncash.bitcoin import COINBASE_MATURITY +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 + +from .conf import Conf, Global +from .fusion import Fusion, can_fuse_from, can_fuse_to, is_tor_port +from .server import FusionServer, Params +from .covert import limiter + +import random # only used to select random coins + +TOR_PORTS = [9050, 9150] +# if more than tor connections have been made recently (see covert.py) then don't start auto-fuses. +AUTOFUSE_RECENT_TOR_LIMIT_LOWER = 60 +# if more than tor connections have been made recently (see covert.py) then shut down auto-fuses that aren't yet started +AUTOFUSE_RECENT_TOR_LIMIT_UPPER = 120 + +# heuristic factor: guess that expected number of coins in wallet in equilibrium is = (this number) / fraction +COIN_FRACTION_FUDGE_FACTOR = 10 +# for semi-linked addresses (that share txids in their history), allow linking them with this probability: +KEEP_LINKED_PROBABILITY = 0.1 + +# how long an auto-fusion may stay in 'waiting' state (without starting-soon) before it cancels itself +AUTOFUSE_INACTIVE_TIMEOUT = 600 + +# how many random coins to select max in 1 batch -- used by select_random_coins +DEFAULT_MAX_COINS = 20 +assert DEFAULT_MAX_COINS > 5 + +pnp = None +def get_upnp(): + """ return an initialized UPnP singleton """ + global pnp + if pnp is not None: + return pnp + try: + import miniupnpc + except ImportError: + raise RuntimeError("python miniupnpc module not installed") + u = miniupnpc.UPnP() + if u.discover() < 1: + raise RuntimeError("can't find UPnP server") + try: + u.selectigd() + except Exception as e: + raise RuntimeError("failed to connect to UPnP IGD") + pnp = u + return u + +def select_coins(wallet): + """ Sort the wallet's coins into address buckets, returning two lists: + - Eligible addresses and their coins. + - Ineligible addresses and their coins. + + An address is eligible if it satisfies all conditions: + - the address is unfrozen + - has 1, 2, or 3 utxo + - all utxo are confirmed (or matured in case of coinbases) + - has no SLP utxo or frozen utxo + """ + # First, select all the coins + eligible = [] + ineligible = [] + has_unconfirmed = False + has_coinbase = False + sum_value = 0 + mincbheight = (wallet.get_local_height() + 1 - COINBASE_MATURITY if Conf(wallet).autofuse_coinbase + else -1) # -1 here causes coinbase coins to always be rejected + for addr in wallet.get_addresses(): + acoins = list(wallet.get_addr_utxo(addr).values()) + if not acoins: + continue # prevent inserting empty lists into eligible/ineligible + good = True + if addr in wallet.frozen_addresses: + good = False + for i,c in enumerate(acoins): + sum_value += c['value'] # tally up values regardless of eligibility + # If too many coins, any SLP tokens, any frozen coins, or any + # immature coinbase on the address -> flag all address coins as + # ineligible if not already flagged as such. + good = good and ( + i < 3 # must not have too many coins on the same address* + and not c['slp_token'] # must not be SLP + and not c['is_frozen_coin'] # must not be frozen + and (not c['coinbase'] or c['height'] <= mincbheight) # if coinbase -> must be mature coinbase + ) + # * = We skip addresses with too many coins, since they take up lots + # of 'space' for consolidation. TODO: there is possibility of + # disruption here, if we get dust spammed. Need to deal with + # 'dusty' addresses by ignoring / consolidating dusty coins. + + # Next, detect has_unconfirmed & has_coinbase: + if c['height'] <= 0: + # Unconfirmed -> Flag as not eligible and set the has_unconfirmed flag. + good = False + has_unconfirmed = True + # Update has_coinbase flag if not already set + has_coinbase = has_coinbase or c['coinbase'] + if good: + eligible.append((addr,acoins)) + else: + ineligible.append((addr,acoins)) + + return eligible, ineligible, int(sum_value), bool(has_unconfirmed), bool(has_coinbase) + +def select_random_coins(wallet, fraction, eligible): + """ + Grab wallet coins with a certain probability, while also paying attention + to obvious linkages and possible linkages. + Returns list of list of coins (bucketed by obvious linkage). + """ + # First, we want to bucket coins together when they have obvious linkage. + # Coins that are linked together should be spent together. + # Currently, just look at address. + addr_coins = eligible + random.shuffle(addr_coins) + + # While fusing we want to pay attention to semi-correlations among coins. + # When we fuse semi-linked coins, it increases the linkage. So we try to + # avoid doing that (but rarely, we just do it anyway :D). + # Currently, we just look at all txids touched by the address. + # (TODO this is a disruption vector: someone can spam multiple fusions' + # output addrs with massive dust transactions (2900 outputs in 100 kB) + # that make the plugin think that all those addresses are linked.) + result_txids = set() + + result = [] + num_coins = 0 + for addr, acoins in addr_coins: + if num_coins >= DEFAULT_MAX_COINS: + break + elif num_coins + len(acoins) > DEFAULT_MAX_COINS: + continue + + # For each bucket, we give a separate chance of joining. + if random.random() > fraction: + continue + + # Semi-linkage check: + # We consider all txids involving the address, historical and current. + ctxids = {txid for txid, height in wallet.get_address_history(addr)} + collisions = ctxids.intersection(result_txids) + # Note each collision gives a separate chance of discarding this bucket. + if random.random() > KEEP_LINKED_PROBABILITY**len(collisions): + continue + # OK, no problems: let's include this bucket. + num_coins += len(acoins) + result.append(acoins) + result_txids.update(ctxids) + + if not result: + # nothing was selected, just try grabbing first nonempty bucket + try: + res = next(coins for addr,coins in addr_coins if coins) + result = [res] + except StopIteration: + # all eligible buckets were cleared. + pass + + return result + +def get_target_params_1(wallet, eligible): + """ WIP -- TODO: Rename this function. """ + def inner(wallet, eligible): + wallet_conf = Conf(wallet) + mode = wallet_conf.fusion_mode + + get_n_coins = lambda: sum(len(acoins) for addr,acoins in eligible) + if mode == 'normal': + n_coins = get_n_coins() + return max(2, round(n_coins / DEFAULT_MAX_COINS)), False + elif mode == 'fan-out': + n_coins = get_n_coins() + return max(4, math.ceil(n_coins / (COIN_FRACTION_FUDGE_FACTOR*0.65))), False + elif mode == 'consolidate': + n_coins = get_n_coins() + num_threads = math.trunc(n_coins / (COIN_FRACTION_FUDGE_FACTOR*1.5)) + return num_threads, num_threads <= 1 + else: # 'custom' + target_num_auto = wallet_conf.queued_autofuse + confirmed_only = wallet_conf.autofuse_confirmed_only + return target_num_auto, confirmed_only + def sanitize(num_threads, conf_only): + num_threads = min(num_threads, Params.max_tier_client_tags) + return num_threads, bool(conf_only) + return sanitize(*inner(wallet, eligible)) + + +def get_target_params_2(wallet, eligible, sum_value): + """ WIP -- TODO: Rename this function. """ + wallet_conf = Conf(wallet) + mode = wallet_conf.fusion_mode + + fraction = 0.1 + + if mode == 'custom': + # Determine the fraction that should be used + select_type, select_amount = wallet_conf.selector + + if select_type == 'size' and int(sum_value) != 0: + # user wants to get a typical output of this size (in sats) + fraction = COIN_FRACTION_FUDGE_FACTOR * select_amount / sum_value + elif select_type == 'count' and int(select_amount) != 0: + # user wants this number of coins + fraction = COIN_FRACTION_FUDGE_FACTOR / select_amount + elif select_type == 'fraction': + # user wants this fraction + fraction = select_amount + # note: fraction at this point could be <0 or >1 but doesn't matter. + elif mode == 'consolidate': + fraction = 1.0 + elif mode == 'normal': + fraction = 0.5 + elif mode == 'fan-out': + fraction = 0.1 + + return fraction + + +class FusionPlugin(BasePlugin): + fusion_server = None + active = True + _run_iter = 0 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) # gives us self.config + self.fusions = weakref.WeakKeyDictionary() + # Do an initial check on the tor port + t = threading.Thread(name = 'Fusion-scan_torport_initial', target = self.scan_torport) + t.start() + self.scan_torport_thread = weakref.ref(t) + self.autofusing_wallets = weakref.WeakKeyDictionary() # wallet -> password + self.lock = threading.RLock() # always order: plugin.lock -> wallet.lock -> fusion.lock + + self.remote_donation_address: str = '' # optionally announced by the remote server in 'serverhello' message + + def on_close(self,): + super().on_close() + self.stop_fusion_server() + self.active = False + + def fullname(self): + return 'CashFusion' + + def description(self): + return _("CashFusion Protocol") + + def set_remote_donation_address(self, address : str): + self.remote_donation_address = ((isinstance(address, str) and address) or '')[:100] + + def get_server(self, ): + return Global(self.config).server + + def set_server(self, host, port, ssl): + gconf = Global(self.config) + old = gconf.server + gconf.server = (host, port, ssl) # type/sanity checking done in setter + if old != gconf.server: + self.on_server_changed() + + def get_torhost(self): + if self.has_auto_torport(): + return Global.Defaults.TorHost + else: + return Global(self.config).tor_host + + def set_torhost(self, host): + ''' host should be a valid hostname ''' + if not host: return + Global(self.config).tor_host = host + + def has_auto_torport(self, ): + return Global(self.config).tor_port_auto + + def get_torport(self, ): + ''' Retreive either manual port or autodetected port; may return None + if 'auto' mode and no Tor port has been autodetected. (this is non-blocking) ''' + if self.has_auto_torport(): + return self.tor_port_good + else: + return Global(self.config).tor_port_manual + + def set_torport(self, port): + # port may be 'auto' or 'manual' or an int + gconf = Global(self.config) + if port == 'auto': + gconf.tor_port_auto = True + return + else: + gconf.tor_port_auto = False + if port == 'manual': + return # we're simply going to use whatever manual port was already set + assert isinstance(port, int) + gconf.tor_port_manual = port + + def scan_torport(self, ): + ''' Scan for Tor proxy on either the manual port or on a series of + automatic ports. This is blocking. Returns port if it's up, or None if + down / can't find. ''' + host = self.get_torhost() + + if self.has_auto_torport(): + portlist = [] + + network = Network.get_instance() + if network: + tc = network.tor_controller + if tc and tc.is_enabled() and tc.active_socks_port: + portlist.append(tc.active_socks_port) + + portlist.extend(TOR_PORTS) + else: + portlist = [ Global(self.config).tor_port_manual ] + + for port in portlist: + if is_tor_port(host, port): + self.tor_port_good = port + break + else: + self.tor_port_good = None + return self.tor_port_good + + def on_server_changed(self): + """ When the server is changed, we stop all extant fusions that are not + already 'running' in order to allow for the new change to take effect + immediately. """ + self.remote_donation_address = '' + for wallet in list(self.autofusing_wallets): + self._stop_fusions(wallet, 'Server changed', which='all') + # FIXME here: restart non-auto fusions on the new server! + + def _stop_fusions(self, wallet, reason, *, not_if_running=True, which='auto'): + # which may be 'all' or 'auto' + running = [] + assert which in ('all', 'auto') + fusions = list(wallet._fusions_auto) if which == 'auto' else list(wallet._fusions) + for f in fusions: + f.stop(reason, not_if_running = not_if_running) + if f.status[0] == 'running': + running.append(f) + return running + + def disable_autofusing(self, wallet): + self.autofusing_wallets.pop(wallet, None) + Conf(wallet).autofuse = False + return self._stop_fusions(wallet, 'Autofusing disabled', which='auto') + + def enable_autofusing(self, wallet, password): + if password is None and wallet.has_password(): + raise InvalidPassword() + else: + wallet.check_password(password) + self.autofusing_wallets[wallet] = password + Conf(wallet).autofuse = True + + def is_autofusing(self, wallet): + return (wallet in self.autofusing_wallets) + + def add_wallet(self, wallet, password=None): + ''' Attach the given wallet to fusion plugin, allowing it to be used in + fusions with clean shutdown. Also start auto-fusions for wallets that want + it (if no password). + ''' + # all fusions relating to this wallet, in particular the fuse-from type (which have frozen coins!) + wallet._fusions = weakref.WeakSet() + # fusions that were auto-started. + wallet._fusions_auto = weakref.WeakSet() + + if Conf(wallet).autofuse: + try: + self.enable_autofusing(wallet, password) + except InvalidPassword: + self.disable_autofusing(wallet) + + def remove_wallet(self, wallet): + ''' Detach the provided wallet; returns list of active fusions. ''' + with self.lock: + self.autofusing_wallets.pop(wallet, None) + with wallet.lock: + fusions = tuple(getattr(wallet, '_fusions', ())) + try: del wallet._fusions + except AttributeError: pass + try: del wallet._fusions_auto + except AttributeError: pass + return [f for f in fusions if f.status[0] not in ('complete', 'failed')] + + + def create_fusion(self, source_wallet, password, coins, target_wallet = None): + # Should be called with plugin.lock and wallet.lock + if target_wallet is None: + target_wallet = source_wallet # self-fuse + assert can_fuse_from(source_wallet) + assert can_fuse_to(target_wallet) + host, port, ssl = self.get_server() + if host == 'localhost': + # as a special exemption for the local fusion server, we don't use Tor. + torhost = None + torport = None + else: + torhost = self.get_torhost() + torport = self.get_torport() + if torport is None: + torport = self.scan_torport() # may block for a very short time ... + if torport is None: + self.notify_server_status(False, ("failed", _("Invalid Tor proxy or no Tor proxy found"))) + raise RuntimeError("can't find tor port") + fusion = Fusion(self, target_wallet, host, port, ssl, torhost, torport) + target_wallet._fusions.add(fusion) + source_wallet._fusions.add(fusion) + fusion.add_coins_from_wallet(source_wallet, password, coins) + self.fusions[fusion] = time.time() + return fusion + + + def thread_jobs(self, ): + return [self] + def run(self, ): + # this gets called roughly every 0.1 s in the Plugins thread; downclock it to 5 s. + run_iter = self._run_iter + 1 + if run_iter < 50: + self._run_iter = run_iter + return + else: + self._run_iter = 0 + + with self.lock: + if not self.active: + return + torcount = limiter.count + if torcount > AUTOFUSE_RECENT_TOR_LIMIT_UPPER: + # need tor cooldown, stop the waiting fusions + for wallet, password in tuple(self.autofusing_wallets.items()): + with wallet.lock: + autofusions = set(wallet._fusions_auto) + for f in autofusions: + if f.status[0] in ('complete', 'failed'): + wallet._fusions_auto.discard(f) + continue + if not f.stopping: + f.stop('Tor cooldown', not_if_running = True) + + if torcount > AUTOFUSE_RECENT_TOR_LIMIT_LOWER: + return + for wallet, password in tuple(self.autofusing_wallets.items()): + num_auto = 0 + with wallet.lock: + autofusions = set(wallet._fusions_auto) + for f in autofusions: + if f.status[0] in ('complete', 'failed'): + wallet._fusions_auto.discard(f) + else: + num_auto += 1 + eligible, ineligible, sum_value, has_unconfirmed, has_coinbase = select_coins(wallet) + target_num_auto, confirmed_only = get_target_params_1(wallet, eligible) + #self.print_error("params1", target_num_auto, confirmed_only) + if num_auto < target_num_auto: + # we don't have enough auto-fusions running, so start one + if confirmed_only and has_unconfirmed: + for f in list(wallet._fusions_auto): + f.stop('Wallet has unconfirmed coins... waiting.', not_if_running = True) + continue + fraction = get_target_params_2(wallet, eligible, sum_value) + #self.print_error("params2", fraction) + coins = [c for l in select_random_coins(wallet, fraction, eligible) for c in l] + if not coins: + self.print_error("auto-fusion skipped due to lack of coins") + continue + try: + f = self.create_fusion(wallet, password, coins) + f.start(inactive_timeout = AUTOFUSE_INACTIVE_TIMEOUT) + self.print_error("started auto-fusion") + except RuntimeError as e: + self.print_error(f"auto-fusion skipped due to error: {e}") + return + wallet._fusions_auto.add(f) + elif confirmed_only and has_unconfirmed: + for f in list(wallet._fusions_auto): + f.stop('Wallet has unconfirmed coins... waiting.', not_if_running = True) + + def start_fusion_server(self, network, bindhost, port, upnp = None, announcehost = None, donation_address = None): + if self.fusion_server: + raise RuntimeError("server already running") + donation_address = (isinstance(donation_address, Address) and donation_address) or None + self.fusion_server = FusionServer(self.config, network, bindhost, port, upnp = upnp, announcehost = announcehost, donation_address = donation_address) + self.fusion_server.start() + return self.fusion_server.host, self.fusion_server.port + + def stop_fusion_server(self): + try: + self.fusion_server.stop('server stopped by operator') + self.fusion_server = None + except Exception: + pass + + def update_coins_ui(self, wallet): + ''' Default implementation does nothing. Qt plugin subclass overrides + this, which sends a signal to the main thread to update the coins tab. + This is called by the Fusion thread (in its thread context) when it + freezes & unfreezes coins. ''' + + def notify_server_status(self, b, tup : tuple = None): + ''' The Qt plugin subclass implements this to tell the GUI about bad + servers. ''' + if not b: self.print_error("notify_server_status:", b, str(tup)) + + @hook + def donation_address(self, window) -> Optional[Tuple[str,Address]]: + ''' Plugin API: Returns a tuple of (description, Address) or None. This + is the donation address that we as a client got from the remote server + (as opposed to the donation address we announce if we are a server). ''' + 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)) + + @daemon_command + def fusion_server_start(self, daemon, config): + # Usage: + # ./electron-cash daemon fusion_server_start (,) + # ./electron-cash daemon fusion_server_start (,) upnp + # ./electron-cash daemon fusion_server_start (,) + # ./electron-cash daemon fusion_server_start (,) upnp + # e.g.: + # ./electron-cash daemon fusion_server_start 0.0.0.0,myfusionserver.com 8787 upnp bitcoincash:qpxiweuqoiweweqeweqw + # + # The main server port will be bound on :. + # Covert submissions will be bound on : (the port is chosen by the OS) + # The main server will tell clients to connect to : . + # The default announcehost is based on an autodetection system, which may not work for some server networking setups. + network = daemon.network + if not network: + return "error: cannot run fusion server without an SPV server connection" + def invoke(firstarg = '0.0.0.0', sport='8787', upnp_str = None, addr_str = None): + bindhost, *extrahosts = firstarg.split(',') + if len(extrahosts) > 1: + raise Exception("too many hosts") + elif len(extrahosts) == 1: + [announcehost,] = extrahosts + else: + announcehost = None + port = int(sport) + pnp = get_upnp() if upnp_str == 'upnp' else None + if not pnp and not addr_str: + # third arg may be addr_str, so swap the args + addr_str = upnp_str + upnp_str = None + addr = None + if addr_str: + assert Address.is_valid(addr_str), "Invalid donation address specified" + addr = Address.from_string(addr_str) + return self.start_fusion_server(network, bindhost, port, upnp = pnp, announcehost = announcehost, donation_address = addr) + + try: + host, port = invoke(*config.get('subargs', ())) + except Exception as e: + import traceback, sys; traceback.print_exc(file=sys.stderr) + return f'error: {str(e)}' + return (host, port) + + @daemon_command + def fusion_server_stop(self, daemon, config): + self.stop_fusion_server() + return 'ok' + + @daemon_command + def fusion_server_status(self, daemon, config): + if not self.fusion_server: + return "fusion server not running" + return dict(poolsizes = {t: len(pool.pool) for t,pool in self.fusion_server.waiting_pools.items()}) + + @daemon_command + def fusion_server_fuse(self, daemon, config): + if self.fusion_server is None: + return + subargs = config.get('subargs', ()) + if len(subargs) != 1: + return "expecting tier" + tier = int(subargs[0]) + num_clients = self.fusion_server.start_fuse(tier) + return num_clients diff --git a/plugins/fusion/protobuf/fusion.proto b/plugins/fusion/protobuf/fusion.proto new file mode 100644 index 000000000000..f2273f78f5aa --- /dev/null +++ b/plugins/fusion/protobuf/fusion.proto @@ -0,0 +1,281 @@ +/* + * Electron Cash - a lightweight Bitcoin Cash client + * CashFusion - an advanced coin anonymizer + * + * Copyright (C) 2020 Mark B. Lundeberg + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation files + * (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, + * publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +syntax = "proto2"; + +package fusion; + +// Some primitives + +message InputComponent { + required bytes prev_txid = 1; // in 'reverse' order, just like in tx + required uint32 prev_index = 2; + required bytes pubkey = 3; + required uint64 amount = 4; + } + +message OutputComponent { + required bytes scriptpubkey = 1; + required uint64 amount = 2; + } + +message BlankComponent { + } + +message Component { + required bytes salt_commitment = 1; // 32 bytes + oneof component { + InputComponent input = 2; + OutputComponent output = 3; + BlankComponent blank = 4; + } + } + +message InitialCommitment { + required bytes salted_component_hash = 1; // 32 byte hash + required bytes amount_commitment = 2; // uncompressed point + required bytes communication_key = 3; // compressed point + } + +message Proof { + // During blame phase, messages of this form are encrypted and sent + // to a different player. It is already known which commitment this + // should apply to, so we only need to point at the component. + required fixed32 component_idx = 1; + required bytes salt = 2; // 32 bytes + required bytes pedersen_nonce = 3; // 32 bytes +} + + + +// Primary communication message types (and flow) + +// Setup phase + +message ClientHello { // from client + required bytes version = 1; + optional bytes genesis_hash = 2; // 32 byte hash (bitcoind little-endian memory order) +} + +message ServerHello { // from server + repeated uint64 tiers = 1; + required uint32 num_components = 2; + required uint64 component_feerate = 4; // sats/kB + required uint64 min_excess_fee = 5; // sats + required uint64 max_excess_fee = 6; // sats + + optional string donation_address = 15; // BCH Address "bitcoincash:qpx..." +} + +message JoinPools { // from client + message PoolTag { + // These tags can be used to client to stop the server from including + // the client too many times in the same fusion. Thus, the client can + // connect many times without fear of fusing with themselves. + required bytes id = 1; // allowed up to 20 bytes + required uint32 limit = 2; // between 1 and 5 inclusive + optional bool no_ip = 3; // whether to do an IP-less tag -- this will collide with all other users, make sure it's random so you can't get DoSed. + } + repeated uint64 tiers = 1; + repeated PoolTag tags = 2; // at most five tags. +} + +message TierStatusUpdate { // from server + message TierStatus { + // in future, we will want server to indicate 'remaining time' and mask number of players. + // note: if player is in queue then a status will be ommitted. + optional uint32 players = 1; + optional uint32 min_players = 2; // minimum required to start (may have delay to allow extra) + optional uint32 max_players = 3; // maximum allowed (immediate start) + optional uint32 time_remaining = 4; + } + map statuses = 1; +} + +message FusionBegin { // from server + required uint64 tier = 1; + required bytes covert_domain = 2; + required uint32 covert_port = 3; + optional bool covert_ssl = 4; + required fixed64 server_time = 5; // server unix time when sending this message; can't be too far off from recipient's clock. +} + + +// Fusion round (repeatable multiple times per connection) + +message StartRound { // from server + required bytes round_pubkey = 1; + repeated bytes blind_nonce_points = 2; + required fixed64 server_time = 5; // server unix time when sending this message; can't be too far off from recipient's clock. + } + +// Phase 3 +message PlayerCommit { // from client + repeated bytes initial_commitments = 1; // serialized InitialCommitment messages; server will repeat them later, verbatim. + required uint64 excess_fee = 2; + required bytes pedersen_total_nonce = 3; // 32 bytes + required bytes random_number_commitment = 4; // 32 bytes + repeated bytes blind_sig_requests = 5; // 32 byte scalars + } + +// Phase 4 +message BlindSigResponses { // from server + repeated bytes scalars = 1; // 32 byte scalars +} + +message AllCommitments { + // All the commitments from all players. At ~140 bytes per commitment and hundreds of commitments, this can be quite large, so it gets sent in its own message during the covert phase. + repeated bytes initial_commitments = 1; + } + +//Phase 5 +message CovertComponent { // from covert client + // The round key is used to identify the pool if needed + optional bytes round_pubkey = 1; + required bytes signature = 2; + required bytes component = 3; // bytes so that it can be signed and hashed verbatim + } + +//Phase 6 +message ShareCovertComponents { // from server + // This is a large message! 168 bytes per initial commitment, ~112 bytes per input component. + // Can easily reach 100 kB or more. + repeated bytes components = 4; + optional bool skip_signatures = 5; // if the server already sees a problem in submitted components + optional bytes session_hash = 6; // the server's calculation of session hash, so clients can crosscheck. +} + +// Phase 7A +message CovertTransactionSignature { // from covert client + // The round key is used to identify the pool if needed + optional bytes round_pubkey = 1; + required uint32 which_input = 2; + required bytes txsignature = 3; + } + +// Phase 8 +message FusionResult { // from server + required bool ok = 1; + repeated bytes txsignatures = 2; // if ok + repeated uint32 bad_components = 3; // if not ok + } + +// Phase 9 +message MyProofsList { // from client + repeated bytes encrypted_proofs = 1; + required bytes random_number = 2; // the number we committed to, back in phase 3 + } + +message TheirProofsList { // from server + message RelayedProof { + required bytes encrypted_proof = 1; + required uint32 src_commitment_idx = 2; // which of the commitments is being proven (index in full list) + required uint32 dst_key_idx = 3; // which of the recipient's keys will unlock the encryption (index in player list) + } + repeated RelayedProof proofs = 1; + } + +// Phase 10 +message Blames { // from client + message BlameProof { + required uint32 which_proof = 1; + oneof decrypter { + bytes session_key = 2; // 32 byte, preferred if the proof decryption works at all + bytes privkey = 3; // 32 byte scalar + } + + // Some errors can only be discovered by checking the blockchain, + // Namely, if an input UTXO is missing/spent/unconfirmed/different + // scriptpubkey/different amount, than indicated. + optional bool need_lookup_blockchain = 4; + + // The client can indicate why it thinks the blame is deserved. In + // case the server finds no issue, this string might help for debugging. + optional string blame_reason = 5; + } + repeated BlameProof blames = 1; + } + +// Final message of the round +message RestartRound { +} + +// Fatal error from server, likely we did something wrong (it will disconnect us, but the message may help debugging). +message Error { + optional string message = 1; +} + +// Simple ping, as a keepalive. +message Ping { +} + +// Simple acknowledgement, nothing more to say. +message OK { +} + +// Primary communication channel types + +message ClientMessage { + oneof msg { + ClientHello clienthello = 1; + JoinPools joinpools = 2; + PlayerCommit playercommit = 3; + MyProofsList myproofslist = 5; + Blames blames = 6; + } + } + +message ServerMessage { + oneof msg { + ServerHello serverhello = 1; + TierStatusUpdate tierstatusupdate = 2; + FusionBegin fusionbegin = 3; + StartRound startround = 4; + BlindSigResponses blindsigresponses = 5; + AllCommitments allcommitments = 6; + ShareCovertComponents sharecovertcomponents = 7; + FusionResult fusionresult = 8; + TheirProofsList theirproofslist = 9; + + RestartRound restartround = 14; + Error error = 15; + } + } + +message CovertMessage { // client -> server, covertly + oneof msg { + CovertComponent component = 1; + CovertTransactionSignature signature = 2; + Ping ping = 3; + } + } + +message CovertResponse { // server -> a covert client + oneof msg { + OK ok = 1; + Error error = 15; + } +} diff --git a/plugins/fusion/protocol.py b/plugins/fusion/protocol.py new file mode 100644 index 000000000000..b0159740ddba --- /dev/null +++ b/plugins/fusion/protocol.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +Magic parameters for the protocol that need to be followed uniformly by +participants either for functionality or for privacy reasons. Unlike +flexible server params these do need to be fixed and implicitly shared. + +Any time the values are changed, the version should be bumped to avoid +having loss of function, or theoretical privacy loss. +""" + +from . import pedersen + +# this class doesn't get instantiated, it's just a bag of values. +class Protocol: + VERSION = b'alpha13' + PEDERSEN = pedersen.PedersenSetup(b'\x02CashFusion gives us fungibility.') + + # 4-byte 'lokad' identifier at start of OP_RETURN + FUSE_ID = b'FUZ\x00' + + # The server only enforces dust limits, but clients should not make outputs + # smaller than this. + MIN_OUTPUT = 10000 + + # Covert connection timescales + # don't let connection attempts take longer than this, since they need to be finished early enough that a spare can be tried. + COVERT_CONNECT_TIMEOUT = 15.0 + # What timespan to make connections over + COVERT_CONNECT_WINDOW = 15.0 + # likewise for submitted data (which is quite small), we don't want it going too late. + COVERT_SUBMIT_TIMEOUT = 3.0 + # What timespan to make covert submissions over. + COVERT_SUBMIT_WINDOW = 5.0 + + COVERT_CONNECT_SPARES = 6 # how many spare connections to make + + MAX_CLOCK_DISCREPANCY = 5.0 # how much the server's time is allowed to differ from client + + ### Critical timeline ### + # (For early phases in a round) + # For client privacy, it is critical that covert submissions happen within + # very specific windows so that they know the server is not able to pull + # off a strong timing partition. + + # Parameters for the 'warmup period' during which clients attempt Tor connections. + # It is long since Tor circuits can take a while to establish. + WARMUP_TIME = 30. # time interval between fusionbegin and first startround message. + WARMUP_SLOP = 3. # allowed discrepancy in warmup interval, and in clock sync. + + # T_* are client times measured from receipt of startround message. + # TS_* are server times measured from send of startround message. + + # The server expects all commitments by this time, so it can start uploading them. + TS_EXPECTING_COMMITMENTS = +3.0 + + # when to start submitting covert components; the BlindSigResponses must have been received by this time. + T_START_COMPS = +5.0 + # submission nominally stops at +10.0, but could be lagged if timeout and spares need to be used. + + # the server will reject all components received after this time. + TS_EXPECTING_COVERT_COMPONENTS = +15.0 + + # At this point the server needs to generate the tx template and calculate + # all sighashes in order to prepare for receiving signatures, and then send + # ShareCovertComponents (a large message, may need time for clients to download). + + # when to start submitting signatures; the ShareCovertComponents must be received by this time. + T_START_SIGS = +20.0 + # submission nominally stops at +25.0, but could be lagged if timeout and spares need to be used. + + # the server will reject all signatures received after this time. + TS_EXPECTING_COVERT_SIGNATURES = +30.0 + + # At this point the server assembles the tx and tries to broadcast it. + # It then informs clients of success or fail. + + # After submitting sigs, clients expect to hear back a result by this time. + T_EXPECTING_CONCLUSION = 35.0 + + # When to start closing covert connections if .stop() is called. It is + # likely the server has already closed, but client needs to do this just + # in case. + T_START_CLOSE = +45.0 # before conclusion + T_START_CLOSE_BLAME = +80.0 # after conclusion, during blame phase. + + ### (End critical timeline) ### + + + # For non-critical messages like during blame phase, just regular relative timeouts are needed. + # Note that when clients send a result and expect a 'gathered' response from server, they wait + # twice this long to allow for other slow clients. + STANDARD_TIMEOUT = 3. + # How much extra time to allow for a peer to check blames (this may involve querying blockchain). + BLAME_VERIFY_TIME = 5. + + +del pedersen diff --git a/plugins/fusion/qt.py b/plugins/fusion/qt.py new file mode 100644 index 000000000000..5892af026abc --- /dev/null +++ b/plugins/fusion/qt.py @@ -0,0 +1,1380 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import threading +import weakref + +from functools import partial + +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from electroncash.i18n import _, ngettext, pgettext +from electroncash.plugins import hook, run_hook +from electroncash.util import ( + do_in_main_thread, finalization_print_error, format_satoshis_plain, InvalidPassword, inv_dict, print_error, + PrintError, profiler) +from electroncash.wallet import Abstract_Wallet +from electroncash_gui.qt.amountedit import BTCAmountEdit +from electroncash_gui.qt.main_window import ElectrumWindow, StatusBarButton +from electroncash_gui.qt.popup_widget import ShowPopupLabel, KillPopupLabel +from electroncash_gui.qt.util import ( + Buttons, CancelButton, CloseButton, ColorScheme, OkButton, WaitingDialog, WindowModalDialog) +from electroncash_gui.qt.utils import PortValidator, UserPortValidator + +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 pathlib import Path +heredir = Path(__file__).parent +icon_fusion_logo = QIcon(str(heredir / 'Cash Fusion Logo - No Text.svg')) +icon_fusion_logo_gray = QIcon(str(heredir / 'Cash Fusion Logo - No Text Gray.svg')) +image_red_exclamation = QImage(str(heredir / 'red_exclamation.png')) + + +class Plugin(FusionPlugin, QObject): + server_status_changed_signal = pyqtSignal(bool, tuple) + + fusions_win = None + weak_settings_tab = None + gui = None + initted = False + last_server_status = (True, ("Ok", '')) + + def __init__(self, *args, **kwargs): + QObject.__init__(self) # parentless top-level QObject. We need this type for the signal. + FusionPlugin.__init__(self, *args, **kwargs) # gives us self.config + self.widgets = weakref.WeakSet() # widgets we made, that need to be hidden & deleted when plugin is disabled + + def on_close(self): + super().on_close() + # Shut down plugin. + # This can be triggered from one wallet's window while + # other wallets' windows have plugin-related modals open. + for window in self.gui.windows: + # this could be slow since it touches windows one by one... could optimize this by dispatching simultaneously. + self.on_close_window(window) + # Clean up + for w in self.widgets: + try: + w.setParent(None) + w.close() + w.hide() + w.deleteLater() + except Exception: + # could be but really we just want to suppress all exceptions + pass + # clean up member attributes to be tidy + self.fusions_win = None # should trigger a deletion of object if not already dead + self.weak_settings_tab = None + self.gui = None + self.initted = False + + @hook + def init_qt(self, gui): + # This gets called when this plugin is initialized, but also when + # any other plugin is initialized after us. + if self.initted: + return + self.initted = self.active = True # self.active is declared in super + self.gui = gui + if self.gui.nd: + # since a network dialog already exists, let's create the settings + # tab now. + self.on_network_dialog(self.gui.nd) + + # We also have to find which windows are already open, and make + # them work with fusion. + for window in self.gui.windows: + self.on_new_window(window) + + @hook + def address_list_context_menu_setup(self, address_list, menu, addrs): + if not self.active: + return + wallet = address_list.wallet + window = address_list.parent + network = wallet.network + if not (can_fuse_from(wallet) and can_fuse_to(wallet) and network): + return + if not hasattr(wallet, '_fusions'): + # that's a bug... all wallets should have this + return + + coins = wallet.get_utxos(addrs, exclude_frozen=True, mature=True, confirmed_only=True, exclude_slp=True) + + def start_fusion(): + def do_it(password): + try: + with wallet.lock: + if not hasattr(wallet, '_fusions'): + return + fusion = self.create_fusion(wallet, password, coins) + fusion.start() + except RuntimeError as e: + window.show_error(_('CashFusion failed: {error_message}').format(error_message=str(e))) + return + window.show_message(ngettext("One coin has been sent to CashFusion for fusing.", + "{count} coins have been sent to CashFusion for fusing.", + len(coins)).format(count=len(coins))) + + has_pw, password = Plugin.get_cached_pw(wallet) + if has_pw and password is None: + d = PasswordDialog(wallet, _("Enter your password to fuse these coins"), do_it) + d.show() + self.widgets.add(d) + else: + do_it(password) + + if coins: + menu.addAction(ngettext("Input one coin to CashFusion", "Input {count} coins to CashFusion", len(coins)).format(count = len(coins)), + start_fusion) + + @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 + + if not (can_fuse_from(wallet) and can_fuse_to(wallet)): + # don't do anything with non-fusable wallets + # (if inter-wallet fusing is added, this should change.) + return + + want_autofuse = Conf(wallet).autofuse + self.add_wallet(wallet, window.gui_object.get_cached_password(wallet)) + + # bit of a dirty hack, to insert our status bar icon (always using index 4, should put us just after the password-changer icon) + sb = window.statusBar() + sbbtn = FusionButton(self, wallet) + self.server_status_changed_signal.connect(sbbtn.update_server_error) + sb.insertPermanentWidget(4, sbbtn) + self.widgets.add(sbbtn) + window._cashfusion_button = weakref.ref(sbbtn) + + # prompt for password if auto-fuse was enabled + if want_autofuse and not self.is_autofusing(wallet): + def callback(password): + self.enable_autofusing(wallet, password) + button = window._cashfusion_button() + if button: button.update_state() + d = PasswordDialog(wallet, _("Previously you had auto-fusion enabled on this wallet. If you would like to keep auto-fusing in the background, enter your password."), + callback_ok = callback) + d.show() + self.widgets.add(d) + + # this runs at most once and only if absolutely no Tor ports are found + self._maybe_prompt_user_if_they_want_integrated_tor_if_no_tor_found() + + @hook + def on_close_window(self, window): + # Invoked when closing wallet or entire application + # Also called by on_close, above. + wallet = window.wallet + + fusions = self.remove_wallet(wallet) + if not fusions: + return + + for f in fusions: + f.stop('Closing wallet') + + # Soft-stop background fuse if running. + # We avoid doing a hard disconnect in the middle of a fusion round. + def task(): + for f in fusions: + f.join() + d = WaitingDialog(window.top_level_window(), _('Shutting down active CashFusions (may take a minute to finish)'), task) + d.exec_() + + @hook + def on_new_password(self, window, old, new): + wallet = window.wallet + if self.is_autofusing(wallet): + try: + self.enable_autofusing(wallet, new) + self.print_error(wallet, "updated autofusion password") + except InvalidPassword: + self.disable_autofusing(wallet) + self.print_error(wallet, "disabled autofusion due to incorrect password - BUG") + + def show_util_window(self, ): + if self.fusions_win is None: + # keep a singleton around + self.fusions_win = FusionsWindow(self) + self.widgets.add(self.fusions_win) + self.fusions_win.show() + self.fusions_win.raise_() + + def requires_settings(self): + # called from main_window.py internal_plugins_dialog + return True + def settings_widget(self, window): + # called from main_window.py internal_plugins_dialog + btn = QPushButton(_('Settings')) + btn.clicked.connect(self.show_settings_dialog) + return btn + + def show_settings_dialog(self): + self.gui.show_network_dialog(None, jumpto='fusion') + + @hook + def on_network_dialog(self, network_dialog): + if self.weak_settings_tab and self.weak_settings_tab(): + return # already exists + settings_tab = SettingsWidget(self) + self.server_status_changed_signal.connect(settings_tab.update_server_error) + tabs = network_dialog.nlayout.tabs + tabs.addTab(settings_tab, icon_fusion_logo, _('CashFusion')) + self.widgets.add(settings_tab) + self.weak_settings_tab = weakref.ref(settings_tab) + + @hook + def on_network_dialog_jumpto(self, nlayout, location): + settings_tab = self.weak_settings_tab and self.weak_settings_tab() + if settings_tab and location in ('fusion', 'cashfusion'): + nlayout.tabs.setCurrentWidget(settings_tab) + return True + + def update_coins_ui(self, wallet): + ''' Overrides super, the Fusion thread calls this in its thread context + to indicate it froze/unfroze some coins. We must update the coins tab, + but only in the main thread.''' + def update_coins_tab(wallet): + strong_window = wallet and wallet.weak_window and wallet.weak_window() + if strong_window: + strong_window.utxo_list.update() # this is rate_limited so it's ok to call it many times in rapid succession. + + do_in_main_thread(update_coins_tab, wallet) + + def notify_server_status(self, b, tup): + ''' Reimplemented from super ''' + super().notify_server_status(b, tup) + status_tup = (b, tup) + if self.last_server_status != status_tup: + self.last_server_status = status_tup + self.server_status_changed_signal.emit(b, tup) + + def get_server_error(self) -> tuple: + ''' Returns a 2-tuple of strings for the last server error, or None + if there is no extant server error. ''' + if not self.last_server_status[0]: + return self.last_server_status[1] + + @classmethod + def window_for_wallet(cls, wallet): + ''' Convenience: Given a wallet instance, derefernces the weak_window + attribute of the wallet and returns a strong reference to the window. + May return None if the window is gone (deallocated). ''' + assert isinstance(wallet, Abstract_Wallet) + return (wallet.weak_window and wallet.weak_window()) or None + + @classmethod + def get_suitable_dialog_window_parent(cls, wallet_or_window): + ''' Convenience: Given a wallet or a window instance, return a suitable + 'top level window' parent to use for dialog boxes. ''' + if isinstance(wallet_or_window, Abstract_Wallet): + wallet = wallet_or_window + window = cls.window_for_wallet(wallet) + return (window and window.top_level_window()) or None + elif isinstance(wallet_or_window, ElectrumWindow): + window = wallet_or_window + return window.top_level_window() + else: + raise TypeError(f"Expected a wallet or a window instance, instead got {type(wallet_or_window)}") + + @classmethod + def get_cached_pw(cls, wallet): + ''' Will return a tuple: (bool, password) for the given wallet. The + boolean is whether the wallet is password protected and the second + item is the cached password, if it's known, otherwise None if it is not + known. If the wallet has no password protection the tuple is always + (False, None). ''' + if not wallet.has_password(): + return False, None + window = cls.window_for_wallet(wallet) + if not window: + raise RuntimeError(f'Wallet {wallet.diagnostic_name()} lacks a valid ElectrumWindow instance!') + pw = window.gui_object.get_cached_password(wallet) + if pw is not None: + try: + wallet.check_password(pw) + except InvalidPassword: + pw = None + return True, pw + + @classmethod + def cache_pw(cls, wallet, password): + window = cls.window_for_wallet(wallet) + if window: + window.gui_object.cache_password(wallet, password) + + _integrated_tor_asked = [False, None] + def _maybe_prompt_user_if_they_want_integrated_tor_if_no_tor_found(self): + if any(self._integrated_tor_asked): + # timer already active or already prompted user + return + weak_self = weakref.ref(self) + def chk_tor_ok(): + self = weak_self() + if not self: + return + self._integrated_tor_asked[1] = None # kill QTimer reference + if self.active and self.gui and self.gui.windows and self.tor_port_good is None and not self._integrated_tor_asked[0]: + # prompt user to enable automatic Tor if not enabled and no auto-detected Tor ports were found + window = self.gui.windows[-1] + network = self.gui.daemon.network + if network and not network.tor_controller.is_enabled(): + self._integrated_tor_asked[0] = True + icon_pm = icon_fusion_logo.pixmap(32) + answer = window.question( + _('CashFusion requires Tor to operate anonymously. Would' + ' you like to enable the integrated Tor client now?'), + icon = icon_pm, + title = _("Tor Required"), + parent = None, + app_modal = True, + rich_text = True, + defaultButton = QMessageBox.Yes + ) + if answer: + def on_status(controller): + try: network.tor_controller.status_changed.remove(on_status) # remove the callback immediately + except ValueError: pass + if controller.status == controller.Status.STARTED: + buttons = [ _('Settings...'), _('Ok') ] + index = window.show_message( + _('The integrated Tor client has been successfully started.'), + detail_text = ( + _("The integrated Tor client can be stopped at any time from the Network Settings -> Proxy Tab" + ", however CashFusion does require Tor in order to operate correctly.") + ), + icon = icon_pm, + rich_text = True, + buttons = buttons, + defaultButton = buttons[1], + escapeButton = buttons[1] + ) + if index == 0: + # They want to go to "Settings..." so send + # them to the Tor settings (in Proxy tab) + self.gui.show_network_dialog(window, jumpto='tor') + else: + controller.set_enabled(False) # latch it back to False so we may prompt them again in the future + window.show_error(_('There was an error starting the integrated Tor client')) + network.tor_controller.status_changed.append(on_status) + network.tor_controller.set_enabled(True) + self._integrated_tor_asked[1] = t = QTimer() + # if in 5 seconds no tor port, ask user if they want to enable the integrated Tor + t.timeout.connect(chk_tor_ok) + t.setSingleShot(True) + t.start(5000) + + +class PasswordDialog(WindowModalDialog): + """ Slightly fancier password dialog -- can be used non-modal (asynchronous) and has internal password checking. + To run non-modally, use .show with the callbacks; to run modally, use .run. """ + def __init__(self, wallet, message, callback_ok = None, callback_cancel = None): + parent = Plugin.get_suitable_dialog_window_parent(wallet) + super().__init__(parent=parent, title=_("Enter Password")) + self.setWindowIcon(icon_fusion_logo) + self.wallet = wallet + self.callback_ok = callback_ok + self.callback_cancel = callback_cancel + self.password = None + + vbox = QVBoxLayout(self) + self.msglabel = QLabel(message) + self.msglabel.setWordWrap(True) + self.msglabel.setMinimumWidth(250) + self.msglabel.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding) + hbox = QHBoxLayout() + iconlabel = QLabel(); iconlabel.setPixmap(icon_fusion_logo.pixmap(32)) + hbox.addWidget(iconlabel) + hbox.addWidget(self.msglabel, 1, Qt.AlignLeft|Qt.AlignVCenter) + cmargins = hbox.contentsMargins(); cmargins.setBottom(10); hbox.setContentsMargins(cmargins) # pad the bottom a bit + vbox.addLayout(hbox, 1) + self.pwle = QLineEdit() + self.pwle.setEchoMode(2) + grid_for_hook_api = QGridLayout() + grid_for_hook_api.setContentsMargins(0,0,0,0) + grid_for_hook_api.addWidget(self.pwle, 0, 0) + run_hook('password_dialog', self.pwle, grid_for_hook_api, 0) # this is for the virtual keyboard plugin + vbox.addLayout(grid_for_hook_api) + self.badpass_msg = "" + _("Incorrect password entered. Please try again.") + "" + + buttons = QHBoxLayout() + buttons.addStretch(1) + buttons.addWidget(CancelButton(self)) + okbutton = OkButton(self) + okbutton.clicked.disconnect() + okbutton.clicked.connect(self.pw_entered) + buttons.addWidget(okbutton) + vbox.addLayout(buttons) + + def _on_pw_ok(self, password): + self.password = password + Plugin.cache_pw(self.wallet, password) # to remember it for a time so as to not keep bugging the user + self.accept() + if self.callback_ok: + self.callback_ok(password) + + def _chk_pass(self, password): + pw_ok = not self.wallet.has_password() + if not pw_ok: + try: + self.wallet.check_password(password) + pw_ok = True + except InvalidPassword: + pass + return pw_ok + + def pw_entered(self, ): + password = self.pwle.text() + if self._chk_pass(password): + self._on_pw_ok(password) + else: + self.msglabel.setText(self.badpass_msg) + self.pwle.clear() + self.pwle.setFocus() + + def closeEvent(self, event): + ''' This happens if .run() is called, then dialog is closed. ''' + super().closeEvent(event) + if event.isAccepted(): + self._close_hide_common() + + def hideEvent(self, event): + ''' This happens if .show() is called, then dialog is closed. ''' + super().hideEvent(event) + if event.isAccepted(): + self._close_hide_common() + + def _close_hide_common(self): + if not self.result() and self.callback_cancel: + self.callback_cancel(self) + self.setParent(None) + self.deleteLater() + + def run(self): + self.exec_() + return self.password + + +class FusionButton(StatusBarButton): + def __init__(self, plugin, wallet): + super().__init__(QIcon(), 'Fusion', self.toggle_autofuse) + + self.plugin = plugin + self.wallet = wallet + + self.server_error : tuple = None + + self.icon_autofusing_on = icon_fusion_logo + self.icon_autofusing_off = icon_fusion_logo_gray + self.icon_fusing_problem = self.style().standardIcon(QStyle.SP_MessageBoxWarning) + +# title = QWidgetAction(self) +# title.setDefaultWidget(QLabel("" + _("CashFusion") + "")) + self.action_toggle = QAction(_("Auto-Fuse in Background")) + self.action_toggle.setCheckable(True) + self.action_toggle.triggered.connect(self.toggle_autofuse) + action_separator1 = QAction(self); action_separator1.setSeparator(True) + action_wsettings = QAction(_("Wallet Fusion Settings..."), self) + action_wsettings.triggered.connect(self.show_wallet_settings) + action_settings = QAction(_("Server Settings..."), self) + action_settings.triggered.connect(self.plugin.show_settings_dialog) + action_separator2 = QAction(self); action_separator2.setSeparator(True) + action_util = QAction(_("Fusions..."), self) + action_util.triggered.connect(self.plugin.show_util_window) + + self.addActions([self.action_toggle, action_separator1, + action_wsettings, action_settings, + action_separator2, action_util]) + + self.setContextMenuPolicy(Qt.ActionsContextMenu) + + self.update_state() + + def update_state(self): + autofuse = self.plugin.is_autofusing(self.wallet) + self.action_toggle.setChecked(autofuse) + if autofuse: + self.setIcon(self.icon_autofusing_on) + self.setToolTip(_('CashFusion is fusing in the background for this wallet')) + self.setStatusTip(_('CashFusion Auto-fusion - Enabled')) + else: + self.setIcon(self.icon_autofusing_off) + self.setToolTip(_('Auto-fusion is paused for this wallet (click to enable)')) + self.setStatusTip(_('CashFusion Auto-fusion - Disabled (click to enable)')) + if self.server_error: + self.setToolTip(_('CashFusion') + ": " + ', '.join(self.server_error)) + self.setStatusTip(_('CashFusion') + ": " + ', '.join(self.server_error)) + + def paintEvent(self, event): + super().paintEvent(event) + if event.isAccepted() and self.server_error: + # draw error overlay if we are in an error state + p = QPainter(self) + try: + p.setClipRegion(event.region()) + r = self.rect() + r -= QMargins(4,6,4,6) + r.moveCenter(r.center() + QPoint(4,4)) + p.drawImage(r, image_red_exclamation) + finally: + # paranoia. The above never raises but.. if it does.. PyQt will + # crash hard if we don't end the QPainter properly before + # returning. + p.end() + del p + + def toggle_autofuse(self): + plugin = self.plugin + autofuse = plugin.is_autofusing(self.wallet) + if not autofuse: + has_pw, password = Plugin.get_cached_pw(self.wallet) + if has_pw and password is None: + # Fixme: See if we can not use a blocking password dialog here. + pd = PasswordDialog(self.wallet, _("To perform auto-fusion in the background, please enter your password.")) + self.plugin.widgets.add(pd) # just in case this plugin is unloaded while this dialog is up + password = pd.run() + del pd + if password is None or not plugin.active: # must check plugin.active because user can theoretically kill plugin from another window while the above password dialog is up + return + try: + plugin.enable_autofusing(self.wallet, password) + except InvalidPassword: + ''' Somehow the password changed from underneath us. Silenty ignore. ''' + else: + running = plugin.disable_autofusing(self.wallet) + if running: + res = QMessageBox.question(Plugin.get_suitable_dialog_window_parent(self.wallet), + _("Disabling automatic Cash Fusions"), + _("New automatic fusions will not be started, but you have {num} currently in progress." + " Would you like to signal them to stop?").format(num=len(running)) ) + if res == QMessageBox.Yes: + for f in running: + f.stop('Stop requested by user') + self.update_state() + + def show_wallet_settings(self): + win = getattr(self.wallet, '_cashfusion_settings_window', None) + if not win: + win = WalletSettingsDialog(Plugin.get_suitable_dialog_window_parent(self.wallet), + self.plugin, self.wallet) + self.plugin.widgets.add(win) # ensures if plugin is unloaded while dialog is up, that the dialog will be killed. + win.show() + win.raise_() + + def update_server_error(self): + tup = self.plugin.get_server_error() + changed = tup != self.server_error + if not changed: + return + self.server_error = tup + name = "CashFusionError;" + str(id(self)) # make sure name is unique per FusionButton widget + if self.server_error: + weak_plugin = weakref.ref(self.plugin) + def onClick(): + KillPopupLabel(name) + plugin = weak_plugin() + if plugin: + plugin.show_settings_dialog() + ShowPopupLabel(name = name, + text="
{}
{}
".format(_("Server Error"),_("Click here to resolve")), + target=self, + timeout=20000, onClick=onClick, onRightClick=onClick, + dark_mode = ColorScheme.dark_scheme) + else: + KillPopupLabel(name) + + self.update() # causes a repaint + + window = self.wallet.weak_window and self.wallet.weak_window() + if window: + window.print_error("CashFusion server_error is now {}".format(self.server_error)) + oldTip = self.statusTip() + self.update_state() + newTip = self.statusTip() + if newTip != oldTip: + window.statusBar().showMessage(newTip, 7500) + + +class SettingsWidget(QWidget): + torscanthread = None + torscanthread_update = pyqtSignal(object) + + def __init__(self, plugin, parent=None): + super().__init__(parent) + self.plugin = plugin + self.torscanthread_ping = threading.Event() + self.torscanthread_update.connect(self.torport_update) + + main_layout = QVBoxLayout(self) + + box = QGroupBox(_("Network")) + main_layout.addWidget(box, 0, Qt.AlignTop | Qt.AlignHCenter) + slayout = QVBoxLayout(box) + + grid = QGridLayout() ; slayout.addLayout(grid) + + grid.addWidget(QLabel(_("Server")), 0, 0) + hbox = QHBoxLayout(); grid.addLayout(hbox, 0, 1) + self.combo_server_host = QComboBox() + self.combo_server_host.setEditable(True) + self.combo_server_host.setInsertPolicy(QComboBox.NoInsert) + self.combo_server_host.setCompleter(None) + self.combo_server_host.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.combo_server_host.activated.connect(self.combo_server_activated) + self.combo_server_host.lineEdit().textEdited.connect(self.user_changed_server) + self.combo_server_host.addItems([f'{s[0]} ({s[1]}{" - ssl" if s[2] else ""})' for s in Global.Defaults.ServerList]) + hbox.addWidget(self.combo_server_host) + hbox.addWidget(QLabel(_("P:"))) + self.le_server_port = QLineEdit() + self.le_server_port.setMaximumWidth(50) + self.le_server_port.setValidator(PortValidator(self.le_server_port)) + self.le_server_port.textEdited.connect(self.user_changed_server) + + hbox.addWidget(self.le_server_port) + self.cb_server_ssl = QCheckBox(_('SSL')) + self.cb_server_ssl.clicked.connect(self.user_changed_server) + hbox.addWidget(self.cb_server_ssl) + + self.server_error_label = QLabel() + self.server_error_label.setAlignment(Qt.AlignTop|Qt.AlignJustify) + grid.addWidget(self.server_error_label, 1, 0, 1, -1) + + grid.addWidget(QLabel(_("Tor")), 2, 0) + hbox = QHBoxLayout(); grid.addLayout(hbox, 2, 1) + self.le_tor_host = QLineEdit('localhost') + self.le_tor_host.textEdited.connect(self.user_edit_torhost) + hbox.addWidget(self.le_tor_host) + hbox.addWidget(QLabel(_("P:"))) + self.le_tor_port = QLineEdit() + self.le_tor_port.setMaximumWidth(50) + self.le_tor_port.setValidator(UserPortValidator(self.le_tor_port)) + self.le_tor_port.textEdited.connect(self.user_edit_torport) + hbox.addWidget(self.le_tor_port) + self.l_tor_status = QLabel() + hbox.addWidget(self.l_tor_status) + self.b_tor_refresh = QPushButton() + self.b_tor_refresh.clicked.connect(self.torscanthread_ping.set) + self.b_tor_refresh.setIcon(self.style().standardIcon(QStyle.SP_BrowserReload)) + self.b_tor_refresh.setDefault(False); self.b_tor_refresh.setAutoDefault(False) + hbox.addWidget(self.b_tor_refresh) + self.cb_tor_auto = QCheckBox(_('Autodetect')) + self.cb_tor_auto.clicked.connect(self.cb_tor_auto_clicked) + hbox.addWidget(self.cb_tor_auto) + + btn = QPushButton(_("Fusions...")); btn.setDefault(False); btn.setAutoDefault(False) + btn.clicked.connect(self.plugin.show_util_window) + buts = Buttons(btn) + buts.setAlignment(Qt.AlignRight | Qt.AlignTop) + main_layout.addLayout(buts) + + main_layout.addStretch(1) + self.stretch_item_index = main_layout.count()-1 + + + self.server_widget = ServerWidget(self.plugin) + self.server_widget.layout().setContentsMargins(0,0,0,0) + main_layout.addWidget(self.server_widget) + self.timer_server_widget_visibility = QTimer(self.server_widget) + self.timer_server_widget_visibility.setSingleShot(False) + self.timer_server_widget_visibility.timeout.connect(self.update_server_widget_visibility) + + self.server_widget_index = main_layout.count()-1 + + self.pm_good_proxy = QIcon(":icons/status_connected_proxy.svg").pixmap(24) + self.pm_bad_proxy = QIcon(":icons/status_disconnected.svg").pixmap(24) + + def update_server(self): + # called initially / when config changes + host, port, ssl = self.plugin.get_server() + try: # see if it's in default list, if so we can set it ... + index = Global.Defaults.ServerList.index((host,port,ssl)) + except ValueError: # not in list + index = -1 + self.combo_server_host.setCurrentIndex(index) + self.combo_server_host.setEditText(host) + self.le_server_port.setText(str(port)) + self.cb_server_ssl.setChecked(ssl) + + def update_server_error(self): + errtup = self.plugin.get_server_error() + self.server_error_label.setHidden(errtup is None) + if errtup: + color = ColorScheme.RED.get_html() + self.server_error_label.setText(f'{errtup[0]}: {errtup[1]}') + + + def combo_server_activated(self, index): + # only triggered when user selects a combo item + self.plugin.set_server(*Global.Defaults.ServerList[index]) + self.update_server() + + def user_changed_server(self, *args): + # user edited the host / port / ssl + host = self.combo_server_host.currentText() + try: + port = int(self.le_server_port.text()) + except ValueError: + port = 0 + ssl = self.cb_server_ssl.isChecked() + self.plugin.set_server(host, port, ssl) + + def update_tor(self,): + # called on init an switch of auto + autoport = self.plugin.has_auto_torport() + host = self.plugin.get_torhost() + port = self.plugin.get_torport() + self.l_tor_status.clear() + self.torport_update(port) + self.cb_tor_auto.setChecked(autoport) + self.le_tor_host.setEnabled(not autoport) + self.le_tor_host.setText(str(host)) + self.le_tor_port.setEnabled(not autoport) + if not autoport: + self.le_tor_port.setText(str(port)) + + def torport_update(self, goodport): + # signalled from the tor checker thread + autoport = self.plugin.has_auto_torport() + port = self.plugin.get_torport() + if autoport: + sport = '?' if port is None else str(port) + self.le_tor_port.setText(sport) + if goodport is None: + self.l_tor_status.setPixmap(self.pm_bad_proxy) + if autoport: + self.l_tor_status.setToolTip(_('Cannot find a Tor proxy on ports %(ports)s.')%dict(ports=TOR_PORTS)) + else: + self.l_tor_status.setToolTip(_('Cannot find a Tor proxy on port %(port)d.')%dict(port=port)) + else: + self.l_tor_status.setToolTip(_('Found a valid Tor proxy on this port.')) + self.l_tor_status.setPixmap(self.pm_good_proxy) + + def user_edit_torhost(self, host): + self.plugin.set_torhost(host) + self.torscanthread_ping.set() + + def user_edit_torport(self, sport): + try: + port = int(sport) + except ValueError: + return + self.plugin.set_torport(port) + self.torscanthread_ping.set() + + def cb_tor_auto_clicked(self, state): + self.plugin.set_torport('auto' if state else 'manual') + port = self.plugin.get_torport() + if port is not None: + self.le_tor_port.setText(str(port)) + self.torscanthread_ping.set() + self.update_tor() + + def refresh(self): + self.update_server() + self.update_tor() + self.update_server_widget_visibility() + self.update_server_error() + + def update_server_widget_visibility(self): + if not self.server_widget.is_server_running(): + self.server_widget.setHidden(True) + self.layout().setStretch(self.stretch_item_index, 1) + self.layout().setStretch(self.server_widget_index, 0) + else: + self.server_widget.setHidden(False) + self.layout().setStretch(self.stretch_item_index, 0) + self.layout().setStretch(self.server_widget_index, 1) + + def showEvent(self, event): + super().showEvent(event) + if not event.isAccepted(): + return + self.refresh() + self.timer_server_widget_visibility.start(2000) + if self.torscanthread is None: + self.torscanthread = threading.Thread(name='Fusion-scan_torport_settings', target=self.scan_torport_loop) + self.torscanthread.daemon = True + self.torscanthread_stopping = False + self.torscanthread.start() + + def _hide_close_common(self): + self.timer_server_widget_visibility.stop() + self.torscanthread_stopping = True + self.torscanthread_ping.set() + self.torscanthread = None + + def closeEvent(self, event): + super().closeEvent(event) + if not event.isAccepted(): + return + self._hide_close_common() + + def hideEvent(self, event): + super().hideEvent(event) + if not event.isAccepted(): + return + self._hide_close_common() + + def scan_torport_loop(self, ): + while not self.torscanthread_stopping: + goodport = self.plugin.scan_torport() + self.torscanthread_update.emit(goodport) + self.torscanthread_ping.wait(10) + self.torscanthread_ping.clear() + + +class WalletSettingsDialog(WindowModalDialog): + def __init__(self, parent, plugin, wallet): + super().__init__(parent=parent, title=_("CashFusion - Wallet Settings")) + self.setWindowIcon(icon_fusion_logo) + self.plugin = plugin + self.wallet = wallet + self.conf = Conf(self.wallet) + + self.idx2confkey = dict() # int -> 'normal', 'consolidate', etc.. + self.confkey2idx = dict() # str 'normal', 'consolidate', etc -> int + + assert not hasattr(self.wallet, '_cashfusion_settings_window') + main_window = self.wallet.weak_window() + assert main_window + self.wallet._cashfusion_settings_window = self + + main_layout = QVBoxLayout(self) + + hbox = QHBoxLayout() + hbox.addWidget(QLabel(_('Fusion mode:'))) + self.mode_cb = mode_cb = QComboBox() + + hbox.addWidget(mode_cb) + + main_layout.addLayout(hbox) + + 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. + gb.setHidden(True) + main_layout.addWidget(gb) + + + box = QGroupBox(_("Self-Fusing")) + main_layout.addWidget(box) + slayout = QVBoxLayout(box) + + lbl = QLabel(_("Allow this wallet to participate multiply in the same fusion round?")) + lbl.setWordWrap(True) + slayout.addWidget(lbl) + box = QHBoxLayout(); box.setContentsMargins(0,0,0,0) + self.combo_self_fuse = QComboBox() + self.combo_self_fuse.addItem(_('No'), 1) + self.combo_self_fuse.addItem(_('Yes - as up to two players'), 2) + box.addStretch(1) + box.addWidget(self.combo_self_fuse) + slayout.addLayout(box) ; del box + + self.combo_self_fuse.activated.connect(self.chose_self_fuse) + + + self.stacked_layout = stacked_layout = QStackedLayout() + main_layout.addLayout(stacked_layout) + + # Stacked Layout pages ... + + # Normal + normal_page_w = QWidget() + normal_page_layout = QVBoxLayout(normal_page_w) + self.confkey2idx['normal'] = stacked_layout.addWidget(normal_page_w) + mode_cb.addItem(_('Normal')) + lbl = QLabel("- " + _("Normal mode") + " -") + lbl.setAlignment(Qt.AlignCenter) + normal_page_layout.addWidget(lbl) + + # Consolidate + consolidate_page_w = QWidget() + consolidate_page_layout = QVBoxLayout(consolidate_page_w) + self.confkey2idx['consolidate'] = stacked_layout.addWidget(consolidate_page_w) + mode_cb.addItem(_('Consolidate')) + lbl = QLabel("- " + _("Consolidation mode") + " -") + lbl.setAlignment(Qt.AlignCenter) + consolidate_page_layout.addWidget(lbl) + + # Fan-out + fanout_page_w = QWidget() + fanout_page_layout = QVBoxLayout(fanout_page_w) + self.confkey2idx['fan-out'] = stacked_layout.addWidget(fanout_page_w) + mode_cb.addItem(_('Fan-out')) + lbl = QLabel("- " + _("Fan-out mode") + " -") + lbl.setAlignment(Qt.AlignCenter) + fanout_page_layout.addWidget(lbl) + + # Custom + self.custom_page_w = custom_page_w = QWidget() + custom_page_layout = QVBoxLayout(custom_page_w) + custom_page_layout.setContentsMargins(0,0,0,0) + self.confkey2idx['custom'] = stacked_layout.addWidget(custom_page_w) + mode_cb.addItem(_('Custom')) + + mode_cb.currentIndexChanged.connect(self._on_mode_changed) # intentionally connected after all items already added + + box = QGroupBox(_("Auto-Fusion Coin Selection")) ; custom_page_layout.addWidget(box) + slayout = QVBoxLayout(box) + + grid = QGridLayout() ; slayout.addLayout(grid) + + self.radio_select_size = QRadioButton(_("Target typical output amount")) + grid.addWidget(self.radio_select_size, 0, 0) + self.radio_select_fraction = QRadioButton(_("Per-coin random chance")) + grid.addWidget(self.radio_select_fraction, 1, 0) + self.radio_select_count = QRadioButton(_("Target number of coins in wallet")) + grid.addWidget(self.radio_select_count, 2, 0) + + self.radio_select_size.clicked.connect(self.edited_size) + self.radio_select_fraction.clicked.connect(self.edited_fraction) + self.radio_select_count.clicked.connect(self.edited_count) + + self.amt_selector_size = BTCAmountEdit(main_window.get_decimal_point) + grid.addWidget(self.amt_selector_size, 0, 1) + self.sb_selector_fraction = QDoubleSpinBox() + self.sb_selector_fraction.setRange(0.1, 100.) + self.sb_selector_fraction.setSuffix("%") + self.sb_selector_fraction.setDecimals(1) + grid.addWidget(self.sb_selector_fraction, 1, 1) + self.sb_selector_count = QSpinBox() + self.sb_selector_count.setRange(COIN_FRACTION_FUDGE_FACTOR, 9999) # Somewhat hardcoded limit of 9999 is arbitrary, have this come from constants? + grid.addWidget(self.sb_selector_count, 2, 1) + + self.amt_selector_size.editingFinished.connect(self.edited_size) + self.sb_selector_fraction.valueChanged.connect(self.edited_fraction) + self.sb_selector_count.valueChanged.connect(self.edited_count) + + # Clicking the radio button should bring its corresponding widget buddy into focus + self.radio_select_size.clicked.connect(self.amt_selector_size.setFocus) + self.radio_select_fraction.clicked.connect(self.sb_selector_fraction.setFocus) + self.radio_select_count.clicked.connect(self.sb_selector_count.setFocus) + + low_warn_blurb = _("Are you trying to consolidate?") + low_warn_tooltip = _("Click for consolidation tips") + low_warn_blurb_link = '' + low_warn_blurb + '' + self.l_warn_selection = QLabel("
" + low_warn_blurb_link + "
") + self.l_warn_selection.setToolTip(low_warn_tooltip) + self.l_warn_selection.linkActivated.connect(self._show_low_warn_help) + self.l_warn_selection.setAlignment(Qt.AlignJustify|Qt.AlignVCenter) + qs = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + qs.setRetainSizeWhenHidden(True) + self.l_warn_selection.setSizePolicy(qs) + slayout.addWidget(self.l_warn_selection) + slayout.setAlignment(self.l_warn_selection, Qt.AlignCenter) + + box = QGroupBox(_("Auto-Fusion Limits")) ; custom_page_layout.addWidget(box) + slayout = QVBoxLayout(box) + grid = QGridLayout() ; slayout.addLayout(grid) + grid.addWidget(QLabel(_("Number of queued fusions")), 0, 0) + self.sb_queued_autofuse = QSpinBox() + self.sb_queued_autofuse.setRange(1, 10) # hard-coded rande 1-10, maybe have this come from some constants? + self.sb_queued_autofuse.setMinimumWidth(50) # just so it doesn't end up too tiny + grid.addWidget(self.sb_queued_autofuse, 0, 1) + self.cb_autofuse_only_all_confirmed = QCheckBox(_("Only auto-fuse when all coins are confirmed")) + slayout.addWidget(self.cb_autofuse_only_all_confirmed) + grid.addWidget(QWidget(), 0, 2); grid.setColumnStretch(2, 1) # spacer + + self.sb_queued_autofuse.valueChanged.connect(self.edited_queued_autofuse) + self.cb_autofuse_only_all_confirmed.clicked.connect(self.clicked_confirmed_only) + + # / end pages + + cbut = CloseButton(self) + main_layout.addLayout(Buttons(cbut)) + cbut.setDefault(False) + cbut.setAutoDefault(False) + + self.idx2confkey = inv_dict(self.confkey2idx) # This must be set-up before this function returns + + # We do this here in addition to in showEvent because on some platforms + # (such as macOS), the window animates-in before refreshing properly and + # then it refreshes, leading to a jumpy glitch. If we do this, it + # slides-in already looking as it should. + self.refresh() + + def _show_low_warn_help(self): + low_warn_message = ( + _("If you wish to consolidate coins:") + + "
    " + + "
  • " + _("Specify a maximum of 1 queued fusion") + + "
  • " + _("Set 'self-fusing' to 'No'") + + "
  • " + _("Check the 'only when all coins are confirmed' checkbox") + + "
" + + _("If you do not wish to necessarily consolidate coins, then it's" + " perfectly acceptable to ignore this tip.") + ) + self.show_message(low_warn_message, title=_('Help'), rich_text=True) + + def _on_mode_changed(self, idx : int): + self.conf.fusion_mode = self.idx2confkey[idx] # will raise on bad idx, which indicates programming error. + self.refresh() + + def _on_cb_coinbase(self, checked : bool): + self.conf.autofuse_coinbase = checked + self.refresh() + + def _maybe_switch_page(self): + mode = self.conf.fusion_mode + oldidx = self.stacked_layout.currentIndex() + try: + idx = self.confkey2idx[mode] + idx_custom = self.confkey2idx['custom'] + # The below conditional ensures that the custom page always + # disappears from the layout if not selected. We do this because it + # is rather large and makes this window unnecessarily big. Note this + # only works if the 'custom' page is last.. otherwise bad things + # happen! + assert idx_custom == max(self.confkey2idx.values()) # ensures custom is last page otherwise this code breaks + if idx == idx_custom: + if not self.stacked_layout.itemAt(idx_custom): + self.stacked_layout.insertWidget(idx_custom, self.custom_page_w) + elif self.stacked_layout.count()-1 == idx_custom: + self.stacked_layout.takeAt(idx_custom) + self.stacked_layout.setCurrentIndex(idx) + self.mode_cb.setCurrentIndex(idx) + except KeyError as e: + # should never happen because settings object filters out unknown modes + raise RuntimeError(f"INTERNAL ERROR: Unknown fusion mode: '{mode}'") from e + + self.updateGeometry() + self.resize(self.sizeHint()) + + return idx == idx_custom + + def refresh(self): + eligible, ineligible, sum_value, has_unconfirmed, has_coinbase = select_coins(self.wallet) + + select_type, select_amount = self.conf.selector + + 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] + try: + for w in edit_widgets: + # Block spurious editingFinished signals and valueChanged signals as + # we modify the state and focus of widgets programatically below. + # On macOS not doing this led to a very strange/spazzy UI. + w.blockSignals(True) + + self.cb_coinbase.setChecked(self.conf.autofuse_coinbase) + if not self.gb_coinbase.isVisible(): + cb_latch = self.conf.coinbase_seen_latch + if cb_latch or self.cb_coinbase.isChecked() or has_coinbase: + if not cb_latch: + # Once latched to true, this UI element will forever be + # visible for this wallet. It means the wallet is a miner's + # wallet and they care about coinbase coins. + self.conf.coinbase_seen_latch = True + self.gb_coinbase.setHidden(False) + del cb_latch + + is_custom_page = self._maybe_switch_page() + + idx = 0 + if self.conf.self_fuse_players > 1: + idx = 1 + self.combo_self_fuse.setCurrentIndex(idx) + del idx + + if is_custom_page: + self.amt_selector_size.setEnabled(select_type == 'size') + self.sb_selector_count.setEnabled(select_type == 'count') + self.sb_selector_fraction.setEnabled(select_type == 'fraction') + if select_type == 'size': + self.radio_select_size.setChecked(True) + sel_size = select_amount + if sum_value > 0: + sel_fraction = min(COIN_FRACTION_FUDGE_FACTOR * select_amount / sum_value, 1.) + else: + sel_fraction = 1. + elif select_type == 'count': + self.radio_select_count.setChecked(True) + sel_size = max(sum_value / max(select_amount, 1), 10000) + sel_fraction = COIN_FRACTION_FUDGE_FACTOR / max(select_amount, 1) + elif select_type == 'fraction': + self.radio_select_fraction.setChecked(True) + sel_size = max(sum_value * select_amount / COIN_FRACTION_FUDGE_FACTOR, 10000) + sel_fraction = select_amount + else: + self.conf.selector = None + return self.refresh() + sel_count = 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) + try: self.sb_queued_autofuse.setValue(self.conf.queued_autofuse) + except (TypeError, ValueError): pass # should never happen but paranoia pays off in the long-term + conf_only = self.conf.autofuse_confirmed_only + self.cb_autofuse_only_all_confirmed.setChecked(conf_only) + self.l_warn_selection.setVisible(sel_fraction > 0.2 and (not conf_only or self.sb_queued_autofuse.value() > 1)) + finally: + # re-enable signals + for w in edit_widgets: w.blockSignals(False) + + + def edited_size(self,): + size = self.amt_selector_size.get_amount() + if size is None or size < 10000: + size = 10000 + self.conf.selector = ('size', size) + self.refresh() + + def edited_fraction(self,): + fraction = max(self.sb_selector_fraction.value() / 100., 0.0) + self.conf.selector = ('fraction', round(fraction, 3)) + self.refresh() + + def edited_count(self,): + count = self.sb_selector_count.value() + self.conf.selector = ('count', count) + self.refresh() + + def edited_queued_autofuse(self,): + prevval = self.conf.queued_autofuse + numfuse = self.sb_queued_autofuse.value() + self.conf.queued_autofuse = numfuse + if prevval > numfuse: + for f in list(self.wallet._fusions_auto): + f.stop('User decreased queued-fuse limit', not_if_running = True) + self.refresh() + + def clicked_confirmed_only(self, checked): + self.conf.autofuse_confirmed_only = checked + self.refresh() + + def chose_self_fuse(self,): + sel = self.combo_self_fuse.currentData() + oldsel = self.conf.self_fuse_players + if oldsel != sel: + self.conf.self_fuse_players = sel + for f in self.wallet._fusions: + # we have to stop waiting fusions since the tags won't overlap. + # otherwise, the user will end up self fusing way too much. + f.stop('User changed self-fuse limit', not_if_running = True) + self.refresh() + + def closeEvent(self, event): + super().closeEvent(event) + if event.isAccepted(): + self.setParent(None) + del self.wallet._cashfusion_settings_window + + def showEvent(self, event): + super().showEvent(event) + if event.isAccepted(): + self.refresh() + + +class ServerFusionsBaseMixin: + def __init__(self, plugin, refresh_interval=2000): + assert isinstance(self, QWidget) + self.plugin = plugin + self.refresh_interval = refresh_interval + + self.timer_refresh = QTimer(self) + self.timer_refresh.setSingleShot(False) + self.timer_refresh.timeout.connect(self.refresh) + + def _on_show(self): + self.timer_refresh.start(self.refresh_interval) + self.refresh() + + def _on_hide(self): + self.timer_refresh.stop() + + def showEvent(self, event): + super().showEvent(event) + if event.isAccepted(): + self._on_show() + + def hideEvent(self, event): + super().hideEvent(event) + if event.isAccepted(): + self._on_hide() + + def closeEvent(self, event): + super().closeEvent(event) + if event.isAccepted(): + self._on_hide() + + def refresh(self): + raise NotImplementedError('ServerFusionsBaseMixin refresh() needs an implementation') + + +class ServerWidget(ServerFusionsBaseMixin, QWidget): + def __init__(self, plugin, parent=None): + QWidget.__init__(self, parent) + ServerFusionsBaseMixin.__init__(self, plugin) + + main_layout = QVBoxLayout(self) + + self.serverbox = QGroupBox(_("Server")) + main_layout.addWidget(self.serverbox) + #self.serverbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + + slayout = QVBoxLayout(self.serverbox) + + self.l_server_status = QLabel() + slayout.addWidget(self.l_server_status) + + self.t_server_waiting = QTableWidget() + self.t_server_waiting.setColumnCount(3) + self.t_server_waiting.setRowCount(len(Params.tiers)) + self.t_server_waiting.setHorizontalHeaderLabels([_('Tier (sats)'), _('Num players'), '']) + for i, t in enumerate(Params.tiers): + button = QPushButton(_("Start")) + button.setDefault(False); button.setAutoDefault(False) # on some platforms if we don't do this, one of the buttons traps "Enter" key + button.clicked.connect(partial(self.clicked_start_fuse, t)) + self.t_server_waiting.setCellWidget(i, 2, button) + slayout.addWidget(self.t_server_waiting) + + def sizeHint(self): + return QSize(300, 150) + + def refresh(self): + if self.is_server_running(): + self.t_server_waiting.setEnabled(True) + self.l_server_status.setText(_('Server status: ACTIVE') + f' {self.plugin.fusion_server.host}:{self.plugin.fusion_server.port}') + table = self.t_server_waiting + table.setRowCount(len(self.plugin.fusion_server.waiting_pools)) + for i,(t,pool) in enumerate(self.plugin.fusion_server.waiting_pools.items()): + table.setItem(i,0,QTableWidgetItem(str(t))) + table.setItem(i,1,QTableWidgetItem(str(len(pool.pool)))) + else: + self.t_server_waiting.setEnabled(False) + self.l_server_status.setText(_('Server status: NOT RUNNING')) + + def is_server_running(self): + return bool(self.plugin.fusion_server) + + def clicked_start_fuse(self, tier, event): + if self.plugin.fusion_server is None: + return + self.plugin.fusion_server.start_fuse(tier) + + +class FusionsWindow(ServerFusionsBaseMixin, QDialog): + def __init__(self, plugin): + QDialog.__init__(self, parent=None) + ServerFusionsBaseMixin.__init__(self, plugin, refresh_interval=1000) + + self.setWindowTitle(_("CashFusion - Fusions")) + self.setWindowIcon(icon_fusion_logo) + + main_layout = QVBoxLayout(self) + + clientbox = QGroupBox(_("Fusions")) + main_layout.addWidget(clientbox) + + clayout = QVBoxLayout(clientbox) + + self.t_active_fusions = QTreeWidget() + self.t_active_fusions.setHeaderLabels([_('Wallet'), _('Status'), _('Status Extra')]) + self.t_active_fusions.setContextMenuPolicy(Qt.CustomContextMenu) + self.t_active_fusions.customContextMenuRequested.connect(self.create_menu_active_fusions) + self.t_active_fusions.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.t_active_fusions.itemDoubleClicked.connect(self.on_double_clicked) + clayout.addWidget(self.t_active_fusions) + + self.resize(520, 240) # TODO: Have this somehow not be hard-coded + + def refresh(self): + tree = self.t_active_fusions + reselect_fusions = set(i.data(0, Qt.UserRole)() for i in tree.selectedItems()) + reselect_fusions.discard(None) + reselect_items = [] + tree.clear() + fusions_and_times = sorted(self.plugin.fusions.items(), key=lambda x:x[1], reverse=True) + for fusion,t in fusions_and_times: + wname = fusion.target_wallet.diagnostic_name() + status, status_ext = fusion.status + item = QTreeWidgetItem( [ wname, status, status_ext] ) + item.setToolTip(0, wname) # this doesn't always fit in the column + item.setToolTip(2, status_ext or '') # neither does this + item.setData(0, Qt.UserRole, weakref.ref(fusion)) + if fusion in reselect_fusions: + reselect_items.append(item) + tree.addTopLevelItem(item) + for item in reselect_items: + item.setSelected(True) + + def create_menu_active_fusions(self, position): + selected = self.t_active_fusions.selectedItems() + if not selected: + return + + fusions = set(i.data(0, Qt.UserRole)() for i in selected) + fusions.discard(None) + statuses = set(f.status[0] for f in fusions) + selection_of_1_fusion = list(fusions)[0] if len(fusions) == 1 else None + has_live = 'running' in statuses or 'waiting' in statuses + + menu = QMenu() + def cancel(): + for fusion in fusions: + fusion.stop(_('Stop requested by user')) + if has_live: + if 'running' in statuses: + msg = _('Cancel (at end of round)') + else: + msg = _('Cancel') + menu.addAction(msg, cancel) + if selection_of_1_fusion and selection_of_1_fusion.txid: + menu.addAction(_("View Tx..."), lambda: self._open_tx_for_fusion(selection_of_1_fusion)) + if not menu.isEmpty(): + menu.exec_(self.t_active_fusions.viewport().mapToGlobal(position)) + + def on_double_clicked(self, item, column): + self._open_tx_for_fusion( item.data(0, Qt.UserRole)() ) + + def _open_tx_for_fusion(self, fusion): + if not fusion or not fusion.txid or not fusion.target_wallet: + return + wallet = fusion.target_wallet + window = wallet.weak_window and wallet.weak_window() + txid = fusion.txid + if window: + tx = window.wallet.transactions.get(txid) + if tx: + window.show_transaction(tx, wallet.get_label(txid)) + else: + window.show_error(_("Transaction not yet in wallet")) diff --git a/plugins/fusion/red_exclamation.png b/plugins/fusion/red_exclamation.png new file mode 100644 index 000000000000..22059186d2aa Binary files /dev/null and b/plugins/fusion/red_exclamation.png differ diff --git a/plugins/fusion/server.py b/plugins/fusion/server.py new file mode 100644 index 000000000000..e96dc41d8251 --- /dev/null +++ b/plugins/fusion/server.py @@ -0,0 +1,996 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +A basic server implementation for CashFusion. Does not natively offer SSL +support, however a server admin may run an SSL server proxy such as nginx for +that purpose. +""" + +import secrets +import sys +import threading +import time +import traceback +from collections import defaultdict + +import electroncash.schnorr as schnorr +from electroncash.address import Address +from electroncash.util import PrintError, ServerErrorResponse +from . import fusion_pb2 as pb +from .comms import send_pb, recv_pb, ClientHandlerThread, GenericServer, get_current_genesis_hash +from .protocol import Protocol +from .util import (FusionError, sha256, calc_initial_hash, calc_round_hash, gen_keypair, tx_from_components, + rand_position) +from .validation import (check_playercommit, check_covert_component, validate_blame, ValidationError, + check_input_electrumx) + +# Resistor "E series" values -- round numbers that are almost geometrically uniform +E6 = [1.0, 1.5, 2.2, 3.3, 4.7, 6.8] +E12 = [1.0, 1.2, 1.5, 1.8, 2.2, 2.7, 3.3, 3.9, 4.7, 5.6, 6.8, 8.2] +E24 = [1.0, 1.1, 1.2, 1.3, 1.5, 1.6, 1.8, 2.0, 2.2, 2.4, 2.7, 3.0, 3.3, 3.6, 3.9, 4.3, 4.7, 5.1, 5.6, 6.2, 6.8, 7.5, 8.2, 9.1] + +# TODO - make these configurable +class Params: + num_components = 23 + component_feerate = 1000 # sats/kB + max_excess_fee = 300000 # sats + tiers = [round(b*s) for b in [10000, 100000, 1000000, 10000000] for s in E12] + + # How many clients do we want before starting a fusion? + min_clients = 8 + # If all clients submitted largest possible component (uncompressed p2pkh input), how many could we take until the result would exceed 100 kB standard tx size limitation? + max_clients = (100000 - 12) // (num_components * 173) + + # Every round, clients leave ... How many clients do we need as an absolute minimum (for privacy)? + min_safe_clients = 6 + + # Choose the minimum excess fee based on dividing the overhead amongst players, in the smallest fusion + # (these overhead numbers assume op_return script size of 1 + 5 (lokad) + 33 (session hash) ) + if min_safe_clients * num_components >= 2 * 0xfc: + # the smallest fusion could require 3-byte varint for both inputs and outputs lists + overhead = 62 + elif min_safe_clients * num_components >= 0xfc: + # the smallest fusion could require 3-byte varint for either inputs or outputs lists + overhead = 60 + else: + # the smallest fusion will use 1-byte varint for both inputs and outputs lists + overhead = 58 + min_excess_fee = (overhead + min_safe_clients - 1) // min_safe_clients + + # How many clients can share same tag on a given tier (if more try to join, reject) + max_tier_client_tags = 100 + + # For a given IP, how many players can they represent in the same fuse? + ip_max_simul_fuse = 3 + + # Guaranteed time to launch a fusion if the pool has stayed at or above min_clients for this long. + start_time_max = 1200 + # Inter-fusion delay -- after starting any fusion, wait this long before starting the next one (unless hit max time or pool is full). + start_time_spacing = 120 + # But don't start a fusion if it has only been above min_clients for a short time (unless pool is full). + start_time_min = 400 + + # whether to print a lot of logs + noisy = False + + +# How long covert connections are allowed to stay open without activity. +# note this needs to consider the maximum interval between messages: +# - how long from first connection to last possible Tor component submission? +# - how long from one round's component submission to the next round's component submission? +COVERT_CLIENT_TIMEOUT = 40 + +# used for non-cryptographic purposes +import random +rng = random.Random() +rng.seed(secrets.token_bytes(32)) + +def clientjob_send(client, msg, timeout = Protocol.STANDARD_TIMEOUT): + client.send(msg, timeout=timeout) +def clientjob_goodbye(client, text): + # a gentler goodbye than killing + if text is not None: + client.send_error(text) + raise client.Disconnect + +class ClientThread(ClientHandlerThread): + """Basic thread per connected client.""" + def recv(self, *expected_msg_names, timeout=Protocol.STANDARD_TIMEOUT): + submsg, mtype = recv_pb(self.connection, pb.ClientMessage, *expected_msg_names, timeout=timeout) + return submsg + + def send(self, submsg, timeout=Protocol.STANDARD_TIMEOUT): + send_pb(self.connection, pb.ServerMessage, submsg, timeout=timeout) + + def send_error(self, msg): + self.send(pb.Error(message = msg), timeout=Protocol.STANDARD_TIMEOUT) + + def error(self, msg): + self.send_error(msg) + raise FusionError(f'Rejected client: {msg}') + +class ClientTag(bytes): + """ enhanced bytes object to represent a pool tag """ + __slots__ = () + def __new__(cls, ipstr, tagbytes, maxsimul): + ipb = ipstr.encode() + b = bytes([maxsimul, len(ipb)]) + ipb + tagbytes + return super().__new__(cls, b) + @property + def maxsimul(self): + return self[0] +class TagStatus: + __slots__ = ('pool', 'all_') + def __init__(self): + self.pool = 0 + self.all_ = 0 + +class WaitingPool: + """ a waiting pool for a specific tier """ + def __init__(self, fill_threshold, tag_max): + self.pool = set() # clients who will be put into fusion round if started at this tier + self.queue = list() # clients who are waiting due to tags being full + self.tags = defaultdict(TagStatus) # how are the various tags + self.fill_threshold = fill_threshold # minimum number of pool clients to trigger setting fill_time + self.fill_time = None # when did pool exceed fill_threshold + self.tag_max = tag_max # how many clients can share same tag (in pool and queue) + def check_add(self, client): + for t in client.tags: + ts = self.tags.get(t) + if ts is not None and ts.all_ >= self.tag_max: + return "too many clients with same tag" + def _add_pool(self, client): + self.pool.add(client) + for t in client.tags: + ts = self.tags[t] + ts.pool += 1 + if len(self.pool) == self.fill_threshold: + self.fill_time = time.monotonic() + def add(self, client): + can_pool = True + for t in client.tags: + ts = self.tags[t] + ts.all_ += 1 + if ts.pool >= t.maxsimul: + can_pool = False + if can_pool: + self._add_pool(client) + else: + self.queue.append(client) + return can_pool + def remove(self, client): + # make sure to call try_move_from_queue() after calling this + try: + self.pool.remove(client) + except KeyError: + in_pool = False + try: + self.queue.remove(client) + except ValueError: + return False + else: + in_pool = True + if len(self.pool) < self.fill_threshold: + self.fill_time = None + + for t in client.tags: + ts = self.tags[t] + ts.all_ -= 1 + if in_pool: + ts.pool -= 1 + if ts.all_ == 0: # cleanup for no-longer-used tags + del self.tags[t] + return True + def try_move_from_queue(self): + # attempt to move clients from queue into pool + moved = [] + for client in self.queue: + for t in client.tags: + ts = self.tags[t] + if ts.pool >= t.maxsimul: + break + else: + self._add_pool(client) + moved.append(client) + for client in moved: + self.queue.remove(client) + +class FusionServer(GenericServer): + """Server for clients waiting to start a fusion. New clients get a + ClientThread made for them, and they are put into the waiting pools. + Once a Fusion thread is started, the ClientThreads are passed over to + a FusionController to run the rounds.""" + def __init__(self, config, network, bindhost, port, upnp = None, announcehost = None, donation_address = None): + assert network + assert isinstance(donation_address, (Address, type(None))) + super().__init__(bindhost, port, ClientThread, upnp = upnp) + self.config = config + self.network = network + 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} + self.t_last_fuse = time.monotonic() # when the last fuse happened; as a placeholder, set this to startup time. + self.reset_timer() + + def run(self): + try: + super().run() + finally: + self.waiting_pools.clear() # gc clean + + def reset_timer(self, ): + """ Scan pools for the favoured fuse: + - Out of the pool(s) with the most number of players, + - Choose the pool with the earliest fill time; + - If no pools are filled then there is no favoured fuse. + (since fill time is a float, this will almost always be unique) + """ + with self.lock: + time_best = None + tier_best = None + size_best = 0 + for t, pool in self.waiting_pools.items(): + ft = pool.fill_time + if ft is None: + continue + size = len(pool.pool) + if size >= size_best: + if time_best is None or ft < time_best or size > size_best: + time_best = ft + tier_best = t + size_best = size + if time_best is None: + self.tier_best_starttime = None + else: + self.tier_best_starttime = max(time_best + Params.start_time_min, self.t_last_fuse + Params.start_time_spacing) + self.tier_best = tier_best + + def start_fuse(self, tier): + """ Immediately launch Fusion at the selected tier. """ + with self.lock: + chosen_clients = list(self.waiting_pools[tier].pool) + + # Notify that we will start. + for c in chosen_clients: + c.start_ev.set() + + # Remove those clients from all pools + for t, pool in self.waiting_pools.items(): + for c in chosen_clients: + pool.remove(c) + pool.try_move_from_queue() + + # Update timing info + self.t_last_fuse = time.monotonic() + self.reset_timer() + + # Uncomment the following to: Remove from spawned clients list, so that the fusion can continue independently of waiting server. + # self.spawned_clients.difference_update(chosen_clients) + + # Kick off the fusion. + rng.shuffle(chosen_clients) + fusion = FusionController(self. network, tier, chosen_clients, self.bindhost, upnp = self.upnp, announcehost = self.announcehost) + fusion.start() + return len(chosen_clients) + + def new_client_job(self, client): + client_ip = client.connection.socket.getpeername()[0] + + msg = client.recv('clienthello') + if msg.version != Protocol.VERSION: + client.error("Mismatched protocol version, please upgrade") + + if msg.genesis_hash: + if msg.genesis_hash != get_current_genesis_hash(): + # For now, msg.genesis_hash is optional and we tolerate it + # missing. However, if the client declares the genesis_hash, we + # do indeed disallow them connecting if they are e.g. on testnet + # and we are mainnet, etc. + client.error("This server is on a different chain, please switch servers") + else: + client.print_error("👀 No genesis hash declared by client, we'll let them slide...") + + + if self.stopping: + return + + donation_address = '' + if isinstance(self.donation_address, Address): + donation_address = self.donation_address.to_full_ui_string() + + client.send(pb.ServerHello( num_components = Params.num_components, + component_feerate = Params.component_feerate, + min_excess_fee = Params.min_excess_fee, + max_excess_fee = Params.max_excess_fee, + tiers = Params.tiers, + donation_address = donation_address + )) + + # We allow a long timeout for clients to choose their pool. + msg = client.recv('joinpools', timeout=120) + if len(msg.tiers) == 0: + client.error("No tiers") + if len(msg.tags) > 5: + client.error("Too many tags") + + # Event for signalling us that a pool started. + start_ev = threading.Event() + client.start_ev = start_ev + + if client_ip.startswith('127.'): + # localhost is whitelisted to allow unlimited access + client.tags = [] + else: + # Default tag: this IP cannot be present in too many fuses. + client.tags = [ClientTag(client_ip, b'', Params.ip_max_simul_fuse)] + + for tag in msg.tags: + if len(tag.id) > 20: + client.error("Tag id too long") + if not (0 < tag.limit < 6): + client.error("Tag limit out of range") + ip = '' if tag.no_ip else client_ip + client.tags.append(ClientTag(ip, tag.id, tag.limit)) + + try: + mytierpools = {t: self.waiting_pools[t] for t in msg.tiers} + except KeyError: + if self.stopping: + return + client.error(f"Invalid tier selected: {t}") + try: + mytiers = list(mytierpools) + rng.shuffle(mytiers) # shuffle the adding order so that if filling more than one pool, we don't have bias towards any particular tier + with self.lock: + if self.stopping: + return + # add this client to waiting pools + for pool in mytierpools.values(): + res = pool.check_add(client) + if res is not None: + client.error(res) + for t in mytiers: + pool = mytierpools[t] + pool.add(client) + if len(pool.pool) >= Params.max_clients: + # pool filled up to the maximum size, so start immediately + self.start_fuse(t) + return + + # we have added to pools, which may have changed the favoured tier + self.reset_timer() + + inftime = float('inf') + + while True: + with self.lock: + if self.stopping or start_ev.is_set(): + return + tnow = time.monotonic() + + # scan through tiers and collect statuses, also check start times. + statuses = dict() + tfill_thresh = tnow - Params.start_time_max + for t, pool in mytierpools.items(): + if client not in pool.pool: + continue + status = pb.TierStatusUpdate.TierStatus(players = len(pool.pool), min_players = Params.min_clients) + + remtime = inftime + if pool.fill_time is not None: + # a non-favoured pool will start eventually + remtime = pool.fill_time - tfill_thresh + if t == self.tier_best: + # this is the favoured pool, can start at a special time + remtime = min(remtime, self.tier_best_starttime - tnow) + if remtime <= 0: + self.start_fuse(t) + return + elif remtime != inftime: + status.time_remaining = round(remtime) + statuses[t] = status + client.send(pb.TierStatusUpdate(statuses = statuses)) + start_ev.wait(2) + except: + # Remove client from waiting pools on failure (on success, we are already removed; on stop we don't care.) + with self.lock: + for t, pool in mytierpools.items(): + if pool.remove(client): + pool.try_move_from_queue() + if self.tier_best in mytierpools: + # we left from best pool, so it might not be best anymore. + self.reset_timer() + raise + +class ResultsCollector: + # Collect submissions from different sources, with a deadline. + def __init__(self, num_results, done_on_fail = True): + self.num_results = int(num_results) + self.done_on_fail = bool(done_on_fail) + self.done_ev = threading.Event() + self.lock = threading.Lock() + self.results = [] + self.fails = [] + def __enter__(self, ): + return self + def __exit__(self, exc_type, exc_value, traceback): + if exc_type is not None: + self.fails.append(exc_value) + if self.done_on_fail: + self.done_ev.set() + elif len(self.fails) + len(getattr(self, 'results', ())) >= self.num_results: + self.done_ev.set() + def gather(self, *, deadline): + remtime = deadline - time.monotonic() + self.done_ev.wait(max(0., remtime)) + with self.lock: + ret = self.results + del self.results + return ret + def add(self, result): + with self.lock: + try: + self.results.append(result) + except AttributeError: + return False + else: + if len(self.fails) + len(self.results) >= self.num_results: + self.done_ev.set() + return True + +class FusionController(threading.Thread, PrintError): + """ This controls the Fusion rounds running from server side. """ + def __init__(self, network, tier, clients, bindhost, upnp = None, announcehost = None): + super().__init__(name="FusionController") + self.network = network + self.tier = tier + self.clients = list(clients) + self.bindhost = bindhost + self.upnp = upnp + self.announcehost = announcehost + self.daemon = True + + def sendall(self, msg, timeout = Protocol.STANDARD_TIMEOUT): + for client in self.clients: + client.addjob(clientjob_send, msg, timeout) + + def check_client_count(self,): + live = [c for c in self.clients if not c.dead] + if len(live) < Params.min_safe_clients: + for c in live: + c.kill("too few remaining live players") + raise FusionError("too few remaining live players") + + def run (self, ): + self.print_error(f'Starting fusion with {len(self.clients)} players at tier={self.tier}') + covert_server = CovertServer(self.bindhost, upnp = self.upnp) + try: + annhost = covert_server.host if self.announcehost is None else self.announcehost + annhost_b = annhost.encode('ascii') + annport = covert_server.port + covert_server.noisy = Params.noisy + covert_server.start() + + self.print_error(f'Covert server started @ {covert_server.host}:{covert_server.port} (announcing as: {annhost_b}:{annport})') + + begin_time = round(time.time()) + self.sendall(pb.FusionBegin(tier = self.tier, + covert_domain = annhost_b, + covert_port = annport, + covert_ssl = False, + server_time = begin_time)) + + self.last_hash = calc_initial_hash(self.tier, annhost_b, annport, False, begin_time) + + time.sleep(Protocol.WARMUP_TIME) + + # repeatedly run rounds until successful or exception + while True: + covert_server.reset() + # Clean up dead clients + self.clients = [c for c in self.clients if not c.dead] + self.check_client_count() + if self.run_round(covert_server): + break + + self.print_error('Ended successfully!') + except FusionError as e: + self.print_error(f"Ended with error: {e}") + except Exception as e: + self.print_error('Failed with exception!') + traceback.print_exc(file=sys.stderr) + for c in self.clients: + c.addjob(clientjob_goodbye, 'internal server error') + finally: + covert_server.stop() + for c in self.clients: + c.addjob(clientjob_goodbye, None) + self.clients = [] # gc + + def kick_missing_clients(self, goodclients, reason = None): + baddies = set(self.clients).difference(goodclients) + for c in baddies: + c.kill(reason) + + def run_round(self, covert_server): + covert_priv, covert_Upub, covert_Cpub = gen_keypair() + round_pubkey = covert_Cpub + + # start to accept covert components + covert_server.start_components(round_pubkey, Params.component_feerate) + + # generate blind nonces (slow!) + for c in self.clients: + c.blinds = [schnorr.BlindSigner() for _co in range(Params.num_components)] + + lock = threading.Lock() + seen_salthashes = set() + + # Send start message to players; record the time we did this + round_time = round(time.time()) + + collector = ResultsCollector(len(self.clients), done_on_fail = False) + def client_start(c, collector): + with collector: + c.send(pb.StartRound(round_pubkey = round_pubkey, + blind_nonce_points = [b.get_R() for b in c.blinds], + server_time = round_time + )) + msg = c.recv('playercommit') + + commit_messages = check_playercommit(msg, Params.min_excess_fee, Params.max_excess_fee, Params.num_components) + + newhashes = set(m.salted_component_hash for m in commit_messages) + with lock: + expected_len = len(seen_salthashes) + len(newhashes) + seen_salthashes.update(newhashes) + if len(seen_salthashes) != expected_len: + c.error('duplicate component commitment') + + if not collector.add((c, msg.initial_commitments, msg.excess_fee)): + c.error("late commitment") + + # record for later + c.blind_sig_requests = msg.blind_sig_requests + c.random_number_commitment = msg.random_number_commitment + + for client in self.clients: + client.addjob(client_start, collector) + + # Record the time that we sent 'startround' message to players; this + # will form the basis of our covert timeline. + covert_T0 = time.monotonic() + self.print_error(f"startround sent at {time.time()}; accepting covert components") + + # Await commitment messages then process results + results = collector.gather(deadline = covert_T0 + Protocol.TS_EXPECTING_COMMITMENTS) + + # Filter clients who didn't manage to give a good commitment. + prev_client_count = len(self.clients) + self.clients = [c for c, _, _ in results] + self.check_client_count() + self.print_error(f"got commitments from {len(self.clients)} clients (dropped {prev_client_count - len(self.clients)})") + + total_excess_fees = sum(f for _,_,f in results) + # Generate scrambled commitment list, but remember exactly where each commitment originated. + commitment_master_list = [(commit, ci, cj) for ci, (_, commitments, _) in enumerate(results) for cj,commit in enumerate(commitments)] + rng.shuffle(commitment_master_list) + all_commitments = tuple(commit for commit,ci,cj in commitment_master_list) + + # Send blind signatures + for c in self.clients: + scalars = [b.sign(covert_priv, e) for b,e in zip(c.blinds, c.blind_sig_requests)] + c.addjob(clientjob_send, pb.BlindSigResponses(scalars = scalars)) + del c.blinds, c.blind_sig_requests + del results, collector + + # Sleep a bit before uploading commitments, as clients are doing this. + remtime = covert_T0 + Protocol.T_START_COMPS - time.monotonic() + if remtime > 0: + time.sleep(remtime) + + # Upload the full commitment list; we're a bit generous with the timeout but that's OK. + self.sendall(pb.AllCommitments(initial_commitments = all_commitments), + timeout=Protocol.TS_EXPECTING_COVERT_SIGNATURES) + + # Sleep until end of covert components phase + remtime = covert_T0 + Protocol.TS_EXPECTING_COVERT_COMPONENTS - time.monotonic() + assert remtime > 0, "timings set up incorrectly" + time.sleep(remtime) + + component_master_list = list(covert_server.end_components().items()) + self.print_error(f"ending covert component acceptance. {len(component_master_list)} received.") + + # Sort the components & contribs list, then separate it out. + component_master_list.sort(key=lambda x:x[1][0]) + all_components = [comp for comp, (sort_key, contrib) in component_master_list] + component_contribs = [contrib for comp, (sort_key, contrib) in component_master_list] + del component_master_list + + # Do some preliminary checks to see whether we should just skip the + # signing phase and go directly to blame, or maybe even restart / end + # without sharing components. + + skip_signatures = False + if len(all_components) != len(self.clients)*Params.num_components: + skip_signatures = True + self.print_error("problem detected: too few components submitted") + if total_excess_fees != sum(component_contribs): + skip_signatures = True + self.print_error("problem detected: excess fee mismatch") + + self.last_hash = session_hash = calc_round_hash(self.last_hash, round_pubkey, round_time, all_commitments, all_components) + + #TODO : Check the inputs and outputs to see if we even have reasonable + # privacy with what we have. + + bad_components = set() + ### + if skip_signatures: + self.print_error("skipping covert signature acceptance") + self.sendall(pb.ShareCovertComponents(components = all_components, skip_signatures = True)) + else: + self.print_error("starting covert signature acceptance") + + tx, input_indices = tx_from_components(all_components, session_hash) + + sighashes = [sha256(sha256(bytes.fromhex(tx.serialize_preimage(i, 0x41, use_cache = True)))) + for i in range(len(tx.inputs()))] + pubkeys = [bytes.fromhex(inp['pubkeys'][0]) for inp in tx.inputs()] + + covert_server.start_signatures(sighashes,pubkeys) + + self.sendall(pb.ShareCovertComponents(components = all_components, session_hash = session_hash)) + + # Sleep until end of covert signatures phase + remtime = covert_T0 + Protocol.TS_EXPECTING_COVERT_SIGNATURES - time.monotonic() + if remtime < 0: + # really shouldn't happen, we had plenty of time + raise FusionError("way too slow") + time.sleep(remtime) + + signatures = list(covert_server.end_signatures()) + missing_sigs = len([s for s in signatures if s is None]) + + ### + self.print_error(f"ending covert signature acceptance. {missing_sigs} missing :{'(' if missing_sigs else ')'}") + + # mark all missing-signature components as bad. + bad_inputs = set(i for i,sig in enumerate(signatures) if sig is None) + + # further, search for duplicated inputs (through matching the prevout and claimed pubkey). + prevout_spenders = defaultdict(list) + for i, inp in enumerate(tx.inputs()): + prevout_spenders[f"{inp['prevout_hash']}:{inp['prevout_n']} {inp['pubkeys'][0]}"].append(i) + for prevout, spenders in prevout_spenders.items(): + if len(spenders) == 1: + continue + self.print_error(f"multi-spend of f{prevout} detected") + # If exactly one of the inputs is signed, we don't punish him + # because he's the honest guy and all the other components were + # just imposters who didn't have private key. If more than one + # signed, then it's malicious behaviour! + if sum((signatures[i] is not None) for i in spenders) != 1: + bad_inputs.update(spenders) + + if bad_inputs: + bad_components.update(input_indices[i] for i in bad_inputs) + else: + for i, (inp, sig) in enumerate(zip(tx.inputs(), signatures)): + inp['signatures'][0] = sig.hex() + '41' + + assert tx.is_complete() + txid = tx.txid() + self.print_error("completed the transaction! " + txid) + + try: + self.network.broadcast_transaction2(tx,) + except ServerErrorResponse as e: + nice_msg, = e.args + server_msg = e.server_msg + self.print_error(f"could not broadcast the transaction! {nice_msg}") + else: + self.print_error("broadcast was successful!") + # Give our transaction a small head start in relaying, before sharing the + # signatures. This makes it slightly harder for one of the players to + # broadcast a malleated version by re-signing one of their inputs. + time.sleep(2) + self.sendall(pb.FusionResult(ok = True, txsignatures = signatures)) + return True + + self.sendall(pb.FusionResult(ok = False, bad_components = sorted(bad_components))) + + ### + self.print_error(f"entering blame phase. bad components: {bad_components}") + + if len(self.clients) < 2: + # Sanity check for testing -- the proof sharing thing doesn't even make sense with one player. + for c in self.clients: + c.kill('blame yourself!') + return + + # scan the commitment list and note where each client's commitments ended up + client_commit_indexes = [[None]*Params.num_components for _ in self.clients] + for i, (commit, ci, cj) in enumerate(commitment_master_list): + client_commit_indexes[ci][cj] = i + + collector = ResultsCollector(len(self.clients), done_on_fail = False) + def client_get_proofs(client, collector): + with collector: + msg = client.recv('myproofslist') + seed = msg.random_number + if sha256(seed) != client.random_number_commitment: + client.error("seed did not match commitment") + proofs = msg.encrypted_proofs + if len(proofs) != Params.num_components: + client.error("wrong number of proofs") + if any(len(p) > 200 for p in proofs): + client.error("too-long proof") # they should only be 129 bytes long. + + # generate the possible destinations list (all commitments, but leaving out the originating client's commitments). + myindex = self.clients.index(client) + possible_commitment_destinations = [(ci,cj) for commit, ci, cj in commitment_master_list if ci != myindex] + N = len(possible_commitment_destinations) + assert N == len(all_commitments) - Params.num_components + + # calculate the randomly chosen destinations, same way as client did. + relays = [] + for i, proof in enumerate(proofs): + dest_client_idx, dest_key_idx = possible_commitment_destinations[rand_position(seed, N, i)] + src_commitment_idx = client_commit_indexes[myindex][i] + relays.append((proof, src_commitment_idx, dest_client_idx, dest_key_idx)) + if not collector.add((client, relays)): + client.error("late proofs") + for client in self.clients: + client.addjob(client_get_proofs, collector) + results = collector.gather(deadline = time.monotonic() + Protocol.STANDARD_TIMEOUT) + + # Now, repackage the proofs according to destination. + proofs_to_relay = [list() for _ in self.clients] + for src_client, relays in results: + for proof, src_commitment_idx, dest_client_idx, dest_key_idx in relays: + proofs_to_relay[dest_client_idx].append((proof, src_commitment_idx, dest_key_idx, src_client)) + + live_clients = len(results) + collector = ResultsCollector(live_clients, done_on_fail = False) + def client_get_blames(client, myindex, proofs, collector): + with collector: + # an in-place sort by source commitment idx removes ordering correlations about which client sent which proof + proofs.sort(key = lambda x:x[1]) + client.send(pb.TheirProofsList(proofs = [ + dict(encrypted_proof=x, src_commitment_idx=y, dst_key_idx=z) + for x,y,z, _ in proofs])) + msg = client.recv('blames', timeout = Protocol.STANDARD_TIMEOUT + Protocol.BLAME_VERIFY_TIME) + + # More than one blame per proof is malicious. Boot client + # immediately since client may be trying to DoS us by + # making us check many inputs against blockchain. + if len(msg.blames) > len(proofs): + client.error('too many blames') + if len(set(blame.which_proof for blame in msg.blames)) != len(msg.blames): + client.error('multiple blames point to same proof') + + # Note, the rest of this function might run for a while if many + # checks against blockchain need to be done, perhaps even still + # running after run_round has exited. For this reason we try to + # not reference self. that may change. + for blame in msg.blames: + try: + encproof, src_commitment_idx, dest_key_idx, src_client = proofs[blame.which_proof] + except IndexError: + client.kill(f'bad proof index {blame.which_proof} / {len(proofs)}') + continue + src_commit_blob, src_commit_client_idx, _ = commitment_master_list[src_commitment_idx] + dest_commit_blob = all_commitments[client_commit_indexes[myindex][dest_key_idx]] + + try: + ret = validate_blame(blame, encproof, src_commit_blob, dest_commit_blob, all_components, bad_components, Params.component_feerate) + except ValidationError as e: + self.print_error("got bad blame; clamed reason was: "+repr(blame.blame_reason)) + client.kill(f'bad blame message: {e} (you claimed: {blame.blame_reason!r})') + continue + + if isinstance(ret, str): + self.print_error(f"verified a bad proof (for {src_commitment_idx}): {ret}") + src_client.kill(f'bad proof (for {src_commitment_idx}): {ret}') + continue + + if src_client.dead: + # If the blamed client is already dead, don't waste more time. + # Since nothing after this point can report back to the + # verifier, there is no privacy leak by the ommission. + continue + + assert ret, 'expecting input component' + outpoint = ret.prev_txid[::-1].hex() + ':' + str(ret.prev_index) + try: + inputchecked = check_input_electrumx(self.network, ret) + except ValidationError as e: + reason = f'{e.args[0]} ({outpoint})' + self.print_error(f"blaming[{src_commitment_idx}] for bad input: {reason}") + src_client.kill('you provided a bad input: ' + reason) + continue + if inputchecked: + self.print_error(f"player indicated bad input but it was fine ({outpoint})") + # At this point we could blame the originator, however + # blockchain checks are somewhat subjective. It would be + # appropriate to add some 'ban score' to the player. + else: + self.print_error(f"player indicated bad input but checking failed ({outpoint})") + + # we aren't collecting any results, rather just marking that + # 'checking finished' so that if all blames are checked, we + # can start next round right away. + collector.add(None) + + for idx, (client, proofs) in enumerate(zip(self.clients, proofs_to_relay)): + client.addjob(client_get_blames, idx, proofs, collector) + _ = collector.gather(deadline = time.monotonic() + Protocol.STANDARD_TIMEOUT + Protocol.BLAME_VERIFY_TIME * 2) + + self.sendall(pb.RestartRound()) + + +class CovertClientThread(ClientHandlerThread): + def recv(self, *expected_msg_names, timeout=None): + submsg, mtype = recv_pb(self.connection, pb.CovertMessage, *expected_msg_names, timeout=timeout) + return submsg, mtype + + def send(self, submsg, timeout=None): + send_pb(self.connection, pb.CovertResponse, submsg, timeout=timeout) + + def send_ok(self,): + self.send(pb.OK(), timeout=5) + + def send_error(self, msg): + self.send(pb.Error(message = msg), timeout=5) + + def error(self, msg): + self.send_error(msg) + raise FusionError(f'Rejected client: {msg}') + + +class CovertServer(GenericServer): + """ + Server for covert submissions. How it works: + - Launch the server at any time. By default, will bind to an ephemeral port. + - Before start of covert components phase, call start_components. + - To signal the end of covert components phase, owner calls end_components, which returns a dict of {component: contrib}, where contrib is (+- amount - fee). + - Before start of covert signatures phase, owner calls start_signatures. + - To signal the end of covert signatures phase, owner calls end_signatures, which returns a list of signatures (which will have None at positions of missing signatures). + - To reset the server for a new round, call .reset(); to kill all connections, call .stop(). + """ + def __init__(self, bindhost, port=0, upnp = None): + super().__init__(bindhost, port, CovertClientThread, upnp = upnp) + self.round_pubkey = None + + def start_components(self, round_pubkey, feerate): + self.components = dict() + self.feerate = feerate + self.round_pubkey = round_pubkey + for c in self.spawned_clients: + c.got_submit = False + + def end_components(self): + with self.lock: + ret = self.components + del self.components + return ret + + def start_signatures(self, sighashes, pubkeys): + num_inputs = len(sighashes) + assert num_inputs == len(pubkeys) + self.signatures = [None]*num_inputs + self.sighashes = sighashes + self.pubkeys = pubkeys + for c in self.spawned_clients: + c.got_submit = False + + def end_signatures(self): + with self.lock: + ret = self.signatures + del self.signatures + return ret + + def reset(self): + try: + del self.round_pubkey + del self.components + del self.feerate + except AttributeError: + pass + try: + del self.sighashes + del self.pubkeys + except AttributeError: + pass + + def new_client_job(self, client): + client.got_submit = False + while True: + msg, mtype = client.recv('component', 'signature', 'ping', timeout = COVERT_CLIENT_TIMEOUT) + if mtype == 'ping': + continue + + if client.got_submit: + # We got a second submission before a new phase started. As + # an anti-spam measure we only allow one submission per connection + # per phase. + client.error('multiple submission in same phase') + + if mtype == 'component': + try: + round_pubkey = self.round_pubkey + feerate = self.feerate + _ = self.components + except AttributeError: + client.error('component submitted at wrong time') + sort_key, contrib = check_covert_component(msg, round_pubkey, feerate) + + with self.lock: + try: + self.components[msg.component] = (sort_key, contrib) + except AttributeError: + client.error('component submitted at wrong time') + + else: + assert mtype == 'signature' + try: + sighash = self.sighashes[msg.which_input] + pubkey = self.pubkeys[msg.which_input] + existing_sig = self.signatures[msg.which_input] + except AttributeError: + client.error('signature submitted at wrong time') + except IndexError: + raise ValidationError('which_input too high') + + sig = msg.txsignature + if len(sig) != 64: + raise ValidationError('signature length is wrong') + + # It might be we already have this signature. This is fine + # since it might be a resubmission after ack failed delivery, + # but we don't allow it to consume our CPU power. + + if sig != existing_sig: + if not schnorr.verify(pubkey, sig, sighash): + raise ValidationError('bad transaction signature') + if existing_sig: + # We received a distinct valid signature. This is not + # allowed and we break the connection as a result. + # Note that we could have aborted earlier but this + # way third parties can't abuse us to find out the + # timing of a given input's signature submission. + raise ValidationError('conflicting valid signature') + + with self.lock: + try: + self.signatures[msg.which_input] = sig + except AttributeError: + client.error('signature submitted at wrong time') + + client.send_ok() + client.got_submit = True diff --git a/plugins/fusion/tests/test_encrypt.py b/plugins/fusion/tests/test_encrypt.py new file mode 100644 index 000000000000..27031afb7a4a --- /dev/null +++ b/plugins/fusion/tests/test_encrypt.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# -*- mode: python3 -*- +# This file (c) 2019 Mark Lundeberg +# Part of the Electron Cash SPV Wallet +# License: MIT +import unittest + +if False: + import os, sys, imp + sys.path.append(os.path.realpath(os.path.dirname(__file__)+"/../../../")) + + imp.load_module('electroncash', *imp.find_module('lib')) + imp.load_module('electroncash_gui', *imp.find_module('gui/qt')) + imp.load_module('electroncash_plugins', *imp.find_module('plugins')) + +from plugins.fusion import encrypt + + +def fastslowcase(testmethod): + """ method -> class decorator to run with pycryptodomex's fast AES enabled/disabled """ + class _TestClass(unittest.TestCase): + def test_slow(self): + saved = encrypt.AES + encrypt.AES = None + try: + testmethod(self) + finally: + encrypt.AES = saved + def test_fast(self): + if not encrypt.AES: + self.skipTest("accelerated AES library not available") + testmethod(self) + + _TestClass.__name__ = testmethod.__name__ + return _TestClass + +@fastslowcase +def TestNormal(self): + Apriv = bytes.fromhex('0000000000000000000000000000000000000000000000000000000000000005') + Apub = bytes.fromhex('022f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4') + + # short message + msg12 = b'test message' + assert len(msg12) == 12 + + e12 = encrypt.encrypt(msg12, Apub) + self.assertEqual(len(e12), 65) # since it's only 12 bytes, it and length fit into one block + e12 = encrypt.encrypt(msg12, Apub, pad_to_length = 16) + self.assertEqual(len(e12), 65) + d12, k = encrypt.decrypt(e12, Apriv) + self.assertEqual(d12, msg12) + d12 = encrypt.decrypt_with_symmkey(e12, k) + self.assertEqual(d12, msg12) + + # tweak the nonce point's oddness bit + e12_bad = bytearray(e12) ; e12_bad[0] ^= 1 + with self.assertRaises(encrypt.DecryptionFailed): + encrypt.decrypt(e12_bad, Apriv) + d12 = encrypt.decrypt_with_symmkey(e12_bad, k) # works because it doesn't care about nonce point + self.assertEqual(d12, msg12) + + # tweak the hmac + e12_bad = bytearray(e12) ; e12_bad[-1] ^= 1 + with self.assertRaises(encrypt.DecryptionFailed): + encrypt.decrypt(e12_bad, Apriv) + with self.assertRaises(encrypt.DecryptionFailed): + encrypt.decrypt_with_symmkey(e12_bad, k) + + # tweak the message + e12_bad = bytearray(e12) ; e12_bad[35] ^= 1 + with self.assertRaises(encrypt.DecryptionFailed): + encrypt.decrypt(e12_bad, Apriv) + with self.assertRaises(encrypt.DecryptionFailed): + encrypt.decrypt_with_symmkey(e12_bad, k) + + # drop a byte + e12_bad = bytearray(e12) ; e12_bad.pop() + with self.assertRaises(encrypt.DecryptionFailed): + encrypt.decrypt(e12_bad, Apriv) + with self.assertRaises(encrypt.DecryptionFailed): + encrypt.decrypt_with_symmkey(e12_bad, k) + + msg13 = msg12 + b'!' + e13 = encrypt.encrypt(msg13, Apub) + self.assertEqual(len(e13), 81) # need another block + with self.assertRaises(ValueError): + encrypt.encrypt(msg13, Apub, pad_to_length = 16) + e13 = encrypt.encrypt(msg13, Apub, pad_to_length = 32) + self.assertEqual(len(e13), 81) + encrypt.decrypt(e13, Apriv) + + msgbig = b'a'*1234 + ebig = encrypt.encrypt(msgbig, Apub) + self.assertEqual(len(ebig), 33 + (1234+4+10) + 16) + dbig, k = encrypt.decrypt(ebig, Apriv) + self.assertEqual(dbig, msgbig) + + + self.assertEqual(len(encrypt.encrypt(b'', Apub)), 65) + self.assertEqual(len(encrypt.encrypt(b'', Apub, pad_to_length = 1248)), 1297) + with self.assertRaises(ValueError): + encrypt.encrypt(b'', Apub, pad_to_length = 0) diff --git a/plugins/fusion/tests/test_pedersen.py b/plugins/fusion/tests/test_pedersen.py new file mode 100644 index 000000000000..4c077bf06ceb --- /dev/null +++ b/plugins/fusion/tests/test_pedersen.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# -*- mode: python3 -*- +# This file (c) 2019 Mark Lundeberg +# Part of the Electron Cash SPV Wallet +# License: MIT +import unittest + +if False: + import os, sys, imp + sys.path.append(os.path.realpath(os.path.dirname(__file__)+"/../../../")) + + imp.load_module('electroncash', *imp.find_module('lib')) + imp.load_module('electroncash_gui', *imp.find_module('gui/qt')) + imp.load_module('electroncash_plugins', *imp.find_module('plugins')) + +from plugins.fusion import pedersen + +from electroncash.bitcoin import regenerate_key + +order = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141 +assert order == pedersen.order + +def fastslowcase(testmethod): + """ method -> class decorator to run with libsecp enabled/disabled in + pedersen module """ + class _TestClass(unittest.TestCase): + def test_slow(self): + saved = pedersen.seclib + pedersen.seclib = None + try: + testmethod(self) + finally: + pedersen.seclib = saved + def test_fast(self): + if not pedersen.seclib: + self.skipTest("accelerated ECC library not available") + testmethod(self) + + _TestClass.__name__ = testmethod.__name__ + return _TestClass + +@fastslowcase +def TestBadSetup(self): + # a particularly bad choice: H = -G + with self.assertRaises(pedersen.InsecureHPoint): + setup = pedersen.PedersenSetup(bytes.fromhex("0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")) + with self.assertRaises(pedersen.InsecureHPoint): + setup = pedersen.PedersenSetup(bytes.fromhex("0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798b7c52588d95c3b9aa25b0403f1eef75702e84bb7597aabe663b82f6f04ef2777")) + + # a non-point + with self.assertRaises(ValueError): + setup = pedersen.PedersenSetup(bytes.fromhex("030000000000000000000000000000000000000000000000000000000000000007")) + +@fastslowcase +def TestNormal(self): + setup = pedersen.PedersenSetup(b'\x02The scalar for this x is unknown') + commit0 = setup.commit(0) + commit5 = pedersen.Commitment(setup, 5) + commit10m = setup.commit(-10) + + sumnonce = (commit0.nonce+commit5.nonce+commit10m.nonce)%order + + sumA = pedersen.add_commitments([commit0, commit5, commit10m]) + sumB = pedersen.Commitment(setup, -5, nonce=sumnonce) # manual + + self.assertEqual(sumA.nonce, sumB.nonce) + self.assertEqual(sumA.amount_mod, sumB.amount_mod) + self.assertEqual(sumA.P_uncompressed, sumB.P_uncompressed) + self.assertEqual(sumA.P_compressed, sumB.P_compressed) diff --git a/plugins/fusion/util.py b/plugins/fusion/util.py new file mode 100644 index 000000000000..1cb0b1b92ab3 --- /dev/null +++ b/plugins/fusion/util.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +Some pieces of fusion that can be reused in the server. +""" + +from electroncash.transaction import Transaction, TYPE_SCRIPT, TYPE_ADDRESS, get_address_from_output_script +from electroncash.address import Address, ScriptOutput, hash160, OpCodes + +from . import fusion_pb2 as pb +from .protocol import Protocol + +import hashlib +import ecdsa + +# Internally used exceptions, shouldn't leak out of this plugin. +class FusionError(Exception): + '''This represents an "expected" type of error, having to do with protocol + errors and degraded conditions that cause a fusion round to fail. It should + not be used to mask programming errors.''' + + +def sha256(x): + return hashlib.sha256(x).digest() + +def size_of_input(pubkey): + # Sizes of inputs after signing: + # 32+8+1+1+[length of sig]+1+[length of pubkey] + # == 141 for compressed pubkeys, 173 for uncompressed. + # (we use schnorr signatures, always) + assert 1 < len(pubkey) < 76 # need to assume regular push opcode + return 108 + len(pubkey) + +def size_of_output(scriptpubkey): + # == 34 for P2PKH, 32 for P2SH + assert len(scriptpubkey) < 253 # need to assume 1-byte varint + return 9 + len(scriptpubkey) + +def component_fee(size, feerate): + # feerate in sat/kB + # size and feerate should both be integer + # fee is always rounded up + return (size * feerate + 999) // 1000 + +def dust_limit(lenscriptpubkey): + return 3*(lenscriptpubkey + 148) + +def pubkeys_from_privkey(privkey): + P = int.from_bytes(privkey, 'big') * ecdsa.SECP256k1.generator + return (b'\x04' + P.x().to_bytes(32,'big') + P.y().to_bytes(32,'big'), + bytes((2 + (P.y()&1),)) + P.x().to_bytes(32,'big'), + ) + +def gen_keypair(): + # Returns privkey (32 bytes), pubkey (65 bytes, uncompressed), pubkey (33 bytes, compressed) + privkey = ecdsa.util.randrange(ecdsa.SECP256k1.order) + P = privkey * ecdsa.SECP256k1.generator + return (privkey.to_bytes(32,'big'), + b'\x04' + P.x().to_bytes(32,'big') + P.y().to_bytes(32,'big'), + bytes((2 + (P.y()&1),)) + P.x().to_bytes(32,'big'), + ) + +def listhash(iterable): + """Hash a list of bytes arguments with well-defined boundaries.""" + h = hashlib.sha256() + for x in iterable: + h.update(len(x).to_bytes(4,'big')) + h.update(x) + return h.digest() + +def calc_initial_hash(tier, covert_domain_b, covert_port, covert_ssl, begin_time): + return listhash([b'Cash Fusion Session', + Protocol.VERSION, + tier.to_bytes(8,'big'), + covert_domain_b, + covert_port.to_bytes(4,'big'), + b'\x01' if covert_ssl else b'\0', + begin_time.to_bytes(8,'big'), + ]) + +def calc_round_hash(last_hash, round_pubkey, round_time, all_commitments, all_components): + return listhash([b'Cash Fusion Round', + last_hash, + round_pubkey, + round_time.to_bytes(8,'big'), + listhash(all_commitments), + listhash(all_components), + ]) + + +def tx_from_components(all_components, session_hash): + """ Returns the tx and a list of indices matching inputs with components""" + input_indices = [] + assert len(session_hash) == 32 + if Protocol.FUSE_ID is None: + prefix = [] + else: + assert len(Protocol.FUSE_ID) == 4 + prefix = [4, *Protocol.FUSE_ID] + inputs = [] + outputs = [(TYPE_SCRIPT, ScriptOutput(bytes([OpCodes.OP_RETURN, *prefix, 32]) + session_hash), 0)] + for i,compser in enumerate(all_components): + comp = pb.Component() + comp.ParseFromString(compser) + ctype = comp.WhichOneof('component') + if ctype == 'input': + inp = comp.input + if len(inp.prev_txid) != 32: + raise FusionError("bad component prevout") + inputs.append(dict(address = Address.from_P2PKH_hash(hash160(inp.pubkey)), + prevout_hash = inp.prev_txid[::-1].hex(), + prevout_n = inp.prev_index, + num_sig = 1, + signatures = [None], + type = 'p2pkh', + x_pubkeys = [inp.pubkey.hex()], + pubkeys = [inp.pubkey.hex()], + sequence = 0xffffffff, + value = inp.amount)) + input_indices.append(i) + elif ctype == 'output': + out = comp.output + atype, addr = get_address_from_output_script(out.scriptpubkey) + if atype != TYPE_ADDRESS: + raise FusionError("bad component address") + outputs.append((TYPE_ADDRESS, addr, out.amount)) + elif ctype != 'blank': + raise FusionError("bad component") + tx = Transaction.from_io(inputs, outputs, locktime=0, sign_schnorr=True) + tx.version = 1 + return tx, input_indices + + +def rand_position(seed, num_positions, counter): + """ + Generate a uniform number in the range [0 ... `num_positions` - 1] by hashing + `seed` (bytes) and `counter` (int). Note that proper uniformity requires that + num_positions should be much less than 2**64. + + (see https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/) + """ + int64 = int.from_bytes(sha256(seed + counter.to_bytes(4, 'big'))[:8], 'big') + return (int64 * num_positions) >> 64 diff --git a/plugins/fusion/validation.py b/plugins/fusion/validation.py new file mode 100644 index 000000000000..d2cf43e66429 --- /dev/null +++ b/plugins/fusion/validation.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +# +# Electron Cash - a lightweight Bitcoin Cash client +# CashFusion - an advanced coin anonymizer +# +# Copyright (C) 2020 Mark B. Lundeberg +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +""" +Some basic validation primitives +""" + +from . import fusion_pb2 as pb +from . import pedersen +from .util import FusionError, sha256, size_of_input, size_of_output, component_fee, dust_limit, pubkeys_from_privkey +from . import encrypt +from .protocol import Protocol + +from electroncash.address import Address +from electroncash.transaction import TYPE_ADDRESS, get_address_from_output_script +import electroncash.schnorr as schnorr + +from google.protobuf.message import DecodeError + + +class ValidationError(FusionError): + # This specifically regards malformed requests. + def __str__(self): + return f'Validation error: {self.args[0]}' + + +def component_contrib(component, feerate): + ctype = component.WhichOneof('component') + if ctype == 'input': + inp = component.input + return inp.amount - component_fee(size_of_input(inp.pubkey), feerate) + elif ctype == 'output': + out = component.output + return - out.amount - component_fee(size_of_output(out.scriptpubkey), feerate) + elif ctype == 'blank': + return 0 + +def check(boolean, fail_message): + if not boolean: + raise ValidationError(fail_message) + +def proto_strict_parse(msg, blob): + """Perform a very strict parsing of the binary blob into the given protobuf + type.""" + try: + if msg.ParseFromString(blob) != len(blob): + raise DecodeError + except DecodeError: + raise ValidationError("decode error") + check(msg.IsInitialized(), "missing fields") + check(not msg.UnknownFields(), "has extra fields") + # Protobuf silently ignores unwanted repeated tags, and gives no direct way + # to detect this. This sucks because it means someone could send us a giant + # message even if we check all fields' lengths. This is the only way to + # detect it. + check(msg.ByteSize() == len(blob), "encoding too long") + return msg + + +def check_playercommit(msg, min_excess_fee, max_excess_fee, num_components): + # validate a PlayerCommit message; return the parsed InitialCommitments + check(len(msg.initial_commitments) == num_components, "wrong number of component commitments") + check(len(msg.blind_sig_requests) == num_components, "wrong number of blind sig requests") + + check(min_excess_fee <= msg.excess_fee <= max_excess_fee, "bad excess fee") + check(len(msg.random_number_commitment) == 32, "bad random commit") + check(len(msg.pedersen_total_nonce) == 32, "bad nonce") + check(all(len(r) == 32 for r in msg.blind_sig_requests), "bad blind sig request") + + commit_messages = [] + for cblob in msg.initial_commitments: + cmsg = proto_strict_parse(pb.InitialCommitment(), cblob) + check(len(cmsg.salted_component_hash) == 32, "bad salted hash") + P = cmsg.amount_commitment + check(len(P) == 65 and P[0] == 4, "bad commitment point") + check(len(cmsg.communication_key) == 33 and cmsg.communication_key[0] in (2,3), "bad communication key") + commit_messages.append(cmsg) + + # Verify pedersen commitment + try: + pointsum = pedersen.add_points([m.amount_commitment for m in commit_messages]) + claimed_commit = Protocol.PEDERSEN.commit(msg.excess_fee, int.from_bytes(msg.pedersen_total_nonce,'big')) + except Exception as e: + raise ValidationError("pedersen commitment verification error") + check(pointsum == claimed_commit.P_uncompressed, "pedersen commitment mismatch") + + return commit_messages + +def check_covert_component(msg, round_pubkey, component_feerate): + message_hash = sha256(msg.component) + check(len(msg.signature) == 64, "bad message signature") + check(schnorr.verify(round_pubkey, msg.signature, message_hash), "bad message signature") + + cmsg = proto_strict_parse(pb.Component(), msg.component) + check(len(cmsg.salt_commitment) == 32, "bad salt commitment") + ctype = cmsg.WhichOneof('component') + if ctype == 'input': + inp = cmsg.input + check(len(inp.prev_txid) == 32, "bad txid") + check( (len(inp.pubkey) == 33 and inp.pubkey[0] in (2,3)) + or (len(inp.pubkey) == 65 and inp.pubkey[0] == 4), + "bad pubkey") + sort_key = ('i', inp.prev_txid[::-1], inp.prev_index, cmsg.salt_commitment) + elif ctype == 'output': + out = cmsg.output + atype, addr = get_address_from_output_script(out.scriptpubkey) + check(atype == TYPE_ADDRESS, "output is not address") + check(out.amount >= dust_limit(len(out.scriptpubkey)), "dust output") + sort_key = ('o', out.amount, out.scriptpubkey, cmsg.salt_commitment) + elif ctype == 'blank': + sort_key = ('b', cmsg.salt_commitment) + else: + raise ValidationError('missing component details') + + # Note: for each sort type we use salt_commitment as a tie-breaker, just to + # make sure that original ordering is forgotten. Of course salt_commitment + # doesn't have to be unique, but it's unique for all honest players. + + return sort_key, component_contrib(cmsg, component_feerate) + +def validate_proof_internal(proofblob, commitment, all_components, bad_components, component_feerate): + """ Validate a proof as far as we can without checking blockchain. + + Returns the deserialized InputComponent for further checking, if it was an + input. """ + msg = proto_strict_parse(pb.Proof(), proofblob) + + try: + componentblob = all_components[msg.component_idx] + except IndexError: + raise ValidationError("component index out of range") + + check(msg.component_idx not in bad_components, "component in bad list") + + # these deserializations should always succeed since we've already done them before. + comp = pb.Component() + comp.ParseFromString(componentblob) + assert comp.IsInitialized() + + check(len(msg.salt) == 32, "salt wrong length") + check(sha256(msg.salt) == comp.salt_commitment, "salt commitment mismatch") + check(sha256(msg.salt + componentblob) == commitment.salted_component_hash, "salted component hash mismatch") + + contrib = component_contrib(comp, component_feerate) + + P_committed = commitment.amount_commitment + claimed_commit = Protocol.PEDERSEN.commit(contrib, int.from_bytes(msg.pedersen_nonce,'big')) + check(P_committed == claimed_commit.P_uncompressed, "pedersen commitment mismatch") + + if comp.WhichOneof('component') == 'input': + return comp.input + else: + return None + + +def validate_blame(blame, encproof, src_commit_blob, dest_commit_blob, all_components, bad_components, component_feerate): + """ Validate a BlameProof. Can: + - return string indicating why the accused (src) is guilty + - raise ValidationError, if the accuser (dest) was blatantly wrong. + - return input component for further investigation, if everything internal checked out. + """ + dest_commit = pb.InitialCommitment() + dest_commit.ParseFromString(dest_commit_blob) + dest_pubkey = dest_commit.communication_key + + src_commit = pb.InitialCommitment() + src_commit.ParseFromString(src_commit_blob) + + decrypter = blame.WhichOneof('decrypter') + if decrypter == 'privkey': + privkey = blame.privkey + check(len(privkey) == 32, 'bad blame privkey') + pubU, pubC = pubkeys_from_privkey(privkey) + check(dest_commit.communication_key == pubC, 'bad blame privkey') + try: + encrypt.decrypt(encproof, privkey) + except encrypt.DecryptionFailed: + # good! the blame was telling us about decryption failure and they were right. + return 'undecryptable' + raise ValidationError('blame gave privkey but decryption worked') + elif decrypter != 'session_key': + raise ValidationError('unknown blame decrypter') + key = blame.session_key + check(len(key) == 32, 'bad blame session key') + try: + proofblob = encrypt.decrypt_with_symmkey(encproof, key) + except encrypt.DecryptionFailed: + raise ValidationError('bad blame session key') + + try: + inpcomp = validate_proof_internal(proofblob, src_commit, all_components, bad_components, component_feerate) + except ValidationError as e: + # good! the blame told us something was wrong, and it was right + return e.args[0] + + # OK so the proof was good and internally consistent, that means the only + # reason they should be sending us a blame is if it's an inconsistency with + # blockchain. + if not blame.need_lookup_blockchain: + raise ValidationError('blame indicated internal inconsistency, none found!') + + if inpcomp is None: + raise ValidationError('blame indicated blockchain error on a non-input component') + + return inpcomp + + +def check_input_electrumx(network, inpcomp): + """ Check an InputComponent against electrumx service. This can be a bit slow + since it gets all utxos on that address. """ + address = Address.from_pubkey(inpcomp.pubkey) + prevhash = inpcomp.prev_txid[::-1].hex() + prevn = inpcomp.prev_index + sh = address.to_scripthash_hex() + u = network.synchronous_get(('blockchain.scripthash.listunspent', [sh]), timeout=5) + for item in u: + if prevhash == item['tx_hash'] and prevn == item['tx_pos']: + break + else: + raise ValidationError('missing or spent or scriptpubkey mismatch') + + check(item['height'] > 0, 'not confirmed') + check(item['value'] == inpcomp.amount, 'amount mismatch') + # Not checked: is it a coinbase? is it matured? + # A feasible strategy to identify unmatured coinbase is to cache the results + # of blockchain.transaction.id_from_pos(height, 0) from the last 100 blocks. + return True diff --git a/plugins/shuffle/__init__.py b/plugins/shuffle_deprecated/__init__.py similarity index 100% rename from plugins/shuffle/__init__.py rename to plugins/shuffle_deprecated/__init__.py diff --git a/plugins/shuffle/client.py b/plugins/shuffle_deprecated/client.py similarity index 99% rename from plugins/shuffle/client.py rename to plugins/shuffle_deprecated/client.py index b578fda72709..46911b631872 100644 --- a/plugins/shuffle/client.py +++ b/plugins/shuffle_deprecated/client.py @@ -503,8 +503,8 @@ def check_idle_threads(self): self.logger.send("forget {}".format(utxo), "MAINLOG") def check_delayed_unreserve_addresses(self): - ''' Expire addresses put in the "delayed unreserve" dict which are - >600 seconds old.''' + """ Expire addresses put in the "delayed unreserve" dict which are + >600 seconds old. """ if self.stop_flg.is_set(): return now = time.time() diff --git a/plugins/shuffle/coin_utils.py b/plugins/shuffle_deprecated/coin_utils.py similarity index 100% rename from plugins/shuffle/coin_utils.py rename to plugins/shuffle_deprecated/coin_utils.py diff --git a/plugins/shuffle/comms.py b/plugins/shuffle_deprecated/comms.py similarity index 100% rename from plugins/shuffle/comms.py rename to plugins/shuffle_deprecated/comms.py diff --git a/plugins/shuffle/conf_keys.py b/plugins/shuffle_deprecated/conf_keys.py similarity index 100% rename from plugins/shuffle/conf_keys.py rename to plugins/shuffle_deprecated/conf_keys.py diff --git a/plugins/shuffle/crypto.py b/plugins/shuffle_deprecated/crypto.py similarity index 100% rename from plugins/shuffle/crypto.py rename to plugins/shuffle_deprecated/crypto.py diff --git a/plugins/shuffle/message_pb2.py b/plugins/shuffle_deprecated/message_pb2.py similarity index 100% rename from plugins/shuffle/message_pb2.py rename to plugins/shuffle_deprecated/message_pb2.py diff --git a/plugins/shuffle/messages.py b/plugins/shuffle_deprecated/messages.py similarity index 100% rename from plugins/shuffle/messages.py rename to plugins/shuffle_deprecated/messages.py diff --git a/plugins/shuffle/protobuf/message.proto b/plugins/shuffle_deprecated/protobuf/message.proto similarity index 100% rename from plugins/shuffle/protobuf/message.proto rename to plugins/shuffle_deprecated/protobuf/message.proto diff --git a/plugins/shuffle/qt.py b/plugins/shuffle_deprecated/qt.py similarity index 97% rename from plugins/shuffle/qt.py rename to plugins/shuffle_deprecated/qt.py index 968edb85f602..ee8792f52614 100644 --- a/plugins/shuffle/qt.py +++ b/plugins/shuffle_deprecated/qt.py @@ -48,10 +48,10 @@ from electroncash_gui.qt.main_window import ElectrumWindow from electroncash_gui.qt.amountedit import BTCAmountEdit from electroncash_gui.qt.utils import FixedAspectRatioSvgWidget -from electroncash_plugins.shuffle.client import BackgroundShufflingThread, ERR_SERVER_CONNECT, ERR_BAD_SERVER_PREFIX, MSG_SERVER_OK -from electroncash_plugins.shuffle.comms import query_server_for_stats, verify_ssl_socket -from electroncash_plugins.shuffle.conf_keys import ConfKeys # config keys per wallet and global -from electroncash_plugins.shuffle.coin_utils import CoinUtils +from .client import BackgroundShufflingThread, ERR_SERVER_CONNECT, ERR_BAD_SERVER_PREFIX, MSG_SERVER_OK +from .comms import query_server_for_stats, verify_ssl_socket +from .conf_keys import ConfKeys # config keys per wallet and global +from .coin_utils import CoinUtils def is_coin_busy_shuffling(window, utxo_or_name): ''' Convenience wrapper for BackgroundShufflingThread.is_coin_busy_shuffling ''' @@ -590,6 +590,12 @@ def on_network_dialog(self, nd): st = nd.__shuffle_settings__ st.refreshFromSettings() + @hook + def window_update_status(self, window): + but = getattr(window, '__shuffle__status__button__', None) + if but: + but.update_cashshuffle_icon() + def show_cashshuffle_tab_in_network_dialog(self, window): window.gui_object.show_network_dialog(window) nd = Plugin.network_dialog @@ -646,13 +652,37 @@ def _window_clear_disabled_extra(self, window): del extra # hopefully object refct goes immediately to 0 and this widget dies quickly. return True + @classmethod + def is_wallet_cashshuffle_compatible(cls, window): + from electroncash.wallet import ImportedWalletBase, Multisig_Wallet + if (window.wallet.is_watching_only() + or window.wallet.is_hardware() + or isinstance(window.wallet, (Multisig_Wallet, ImportedWalletBase))): + # wallet is watching-only, multisig, or hardware so.. not compatible + return False + return True + + def add_button_to_window(self, window): + if not hasattr(window, '__shuffle__status__button__'): + from .qt_status_bar_mgr import ShuffleStatusBarButtonMgr + window.__shuffle__status__button__ = ShuffleStatusBarButtonMgr(self, window) + window.print_error("Added cashshuffle status button") + + @classmethod + def remove_button_from_window(cls, window): + if hasattr(window, '__shuffle__status__button__'): + window.__shuffle__status__button__.remove() + delattr(window, '__shuffle__status__button__') + window.print_error("Removed cashshuffle status button") + @hook def on_new_window(self, window): - if not window.is_wallet_cashshuffle_compatible(): + if not self.is_wallet_cashshuffle_compatible(window): # wallet is watching-only, multisig, or hardware so.. mark it permanently for no cashshuffle self.window_set_cashshuffle(window, False) window.update_status() # this has the side-effect of refreshing the cash shuffle status bar button's context menu (which has actions even for disabled/incompatible windows) return + self.add_button_to_window(window) # unconditionally add the button if compatible -- they may want to enable it later if window.wallet and not self.window_has_cashshuffle(window): if self.window_wants_cashshuffle(window): self._enable_for_window(window) or self._window_add_to_disabled(window) @@ -799,6 +829,7 @@ def refresh_history_lists(gui): def on_close_window(self, window): def didRemove(window): self.print_error("Window '{}' removed".format(window.wallet.basename())) + self.remove_button_from_window(window) if self._window_remove_from_disabled(window): didRemove(window) return @@ -831,6 +862,7 @@ def _window_add_to_disabled(self, window): if window not in self.disabled_windows: self._window_set_disabled_extra(window) self.disabled_windows.append(window) + window.update_status() # ensure cashshuffle icon has the right menus, etc return True def _window_remove_from_disabled(self, window): @@ -1008,6 +1040,12 @@ def view_pools(self, window): # this should not normally be reachable in the UI, hence why we don't i18n the error string. window.show_error("CashShuffle is not properly set up -- no server defined! Please select a server from the settings.") + def restart_cashshuffle(self, window, msg = None, parent = None): + if (parent or window).question("{}{}".format(msg + "\n\n" if msg else "", _("Restart the CashShuffle plugin now?")), + app_modal=True): + self.restart_all() + window.notify(_("CashShuffle restarted")) + def settings_dialog(self, window, msg=None, restart_ask = True): def window_parent(w): # this is needed because WindowModalDialog overrides window.parent @@ -1034,7 +1072,7 @@ def window_parent(w): if ns: Plugin.save_network_settings(window.config, ns) if restart_ask: - window.restart_cashshuffle(msg = _("CashShuffle must be restarted for the server change to take effect.")) + self.restart_cashshuffle(window, msg = _("CashShuffle must be restarted for the server change to take effect.")) return ns finally: d.deleteLater() @@ -1063,10 +1101,11 @@ def try_to_apply_network_dialog_settings(settings_tab): # If that fails, get any old window... window = gui.windows[-1] # NB: if no window at this point, settings will take effect next time CashShuffle is enabled for a window - if window: + if window and instance: # window will raise itself. - window.restart_cashshuffle(msg = _("CashShuffle must be restarted for the server change to take effect."), - parent = Plugin.network_dialog) + instance.restart_cashshuffle(window, + msg = _("CashShuffle must be restarted for the server change to take effect."), + parent = Plugin.network_dialog) @staticmethod def save_network_settings(config, network_settings): diff --git a/plugins/shuffle_deprecated/qt_status_bar_mgr.py b/plugins/shuffle_deprecated/qt_status_bar_mgr.py new file mode 100644 index 000000000000..24fde9c9d9bd --- /dev/null +++ b/plugins/shuffle_deprecated/qt_status_bar_mgr.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +# +# Cash Shuffle - CoinJoin for Bitcoin Cash +# Copyright (C) 2018-2020 Electron Cash LLC +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import weakref + +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + +from electroncash.i18n import _ +from electroncash.plugins import hook +from electroncash_gui.qt.main_window import ElectrumWindow, StatusBarButton +from electroncash_gui.qt.popup_widget import ShowPopupLabel, KillPopupLabel +from electroncash_gui.qt.util import ColorScheme + +class ShuffleStatusBarButtonMgr: + ''' Apologies for how contorted this is. All this code used to live inside + the ElectrumWindow instance in Electron Cash 4.0.x, before CashFusion. + We moved it out into a separate "manager" class, for managing the + StatusBarButton state. ''' + + def __init__(self, plugin : object, window : ElectrumWindow): + from .qt import Plugin # <--- we lazy-load this each time becasue the Plugin class may go away and come back as a different class for each plugin load/unload cycle + assert isinstance(plugin, Plugin) + assert isinstance(window, ElectrumWindow) + self.weak_window = weakref.ref(window) + self.weak_plugin = weakref.ref(plugin) + self._create_button() + + @property + def window(self): + return self.weak_window() + + @property + def plugin(self): + return self.weak_plugin() + + def require_good_window_first_arg(func, *args, **kwargs): + ''' Verifies that self.window is good, and then passes it as the first + arg to func. If it is not good, returns with no-op. ''' + def inner(self, *args, **kwargs): + w = self.window + if w: + return func(self, w, *args, **kwargs) + return inner + + @require_good_window_first_arg + def remove(self, window): + attrs2del = [ 'cashshuffle_set_flag', 'cashshuffle_get_flag' ] + for attr in attrs2del: + if hasattr(window, attr): + delattr(window, attr) + sb = window.statusBar() + sb.removeWidget(self.cashshuffle_status_button) + self.cashshuffle_status_button.setParent(None) + self.cashshuffle_status_button.deleteLater() + self.cashshuffle_status_button = None + self.cashshuffle_toggle_action = None + self.cashshuffle_settings_action = None + self.cashshuffle_viewpools_action = None + self.cashshuffle_separator_action = None + + + @require_good_window_first_arg + def _create_button(self, window): + sb = window.statusBar() + self.cashshuffle_status_button = StatusBarButton( + self.cashshuffle_icon(), + '', # ToolTip will be set in update_cashshuffle code + self.cashshuffle_icon_leftclick + ) + self.cashshuffle_toggle_action = QAction("", self.cashshuffle_status_button) # action text will get set in update_cashshuffle_icon() + self.cashshuffle_toggle_action.triggered.connect(self.toggle_cashshuffle) + self.cashshuffle_settings_action = QAction("", self.cashshuffle_status_button) + self.cashshuffle_settings_action.triggered.connect(self.show_cashshuffle_settings) + self.cashshuffle_viewpools_action = QAction(_("View pools..."), self.cashshuffle_status_button) + self.cashshuffle_viewpools_action.triggered.connect(self.show_cashshuffle_pools) + self.cashshuffle_status_button.addAction(self.cashshuffle_viewpools_action) + self.cashshuffle_status_button.addAction(self.cashshuffle_settings_action) + self.cashshuffle_separator_action = sep = QAction(self.cashshuffle_status_button); sep.setSeparator(True) + self.cashshuffle_status_button.addAction(sep) + self.cashshuffle_status_button.addAction(self.cashshuffle_toggle_action) + self.cashshuffle_status_button.setContextMenuPolicy(Qt.ActionsContextMenu) + + # monkey patch window because client.py code expects these method + window.cashshuffle_set_flag = self.cashshuffle_set_flag + window.cashshuffle_get_flag = self.cashshuffle_get_flag + + sb.insertPermanentWidget(4, self.cashshuffle_status_button) + + @require_good_window_first_arg + def is_cashshuffle_enabled(self, window): + plugin = self.plugin + return bool(plugin and plugin.is_enabled() and plugin.window_has_cashshuffle(window)) + + def cashshuffle_icon(self): + if self.is_cashshuffle_enabled(): + if self._cash_shuffle_flag == 1: + return QIcon(":icons/cashshuffle_on_error.svg") + else: + return QIcon(":icons/cashshuffle_on.svg") + else: + self._cash_shuffle_flag = 0 + return QIcon(":icons/cashshuffle_off.svg") + + @require_good_window_first_arg + def update_cashshuffle_icon(self, window): + self.cashshuffle_status_button.setIcon(self.cashshuffle_icon()) + loaded = bool(self.plugin) + en = self.is_cashshuffle_enabled() + if self._cash_shuffle_flag == 0: + self.cashshuffle_status_button.setStatusTip(_("CashShuffle") + " - " + _("ENABLED") if en else _("CashShuffle") + " - " + _("Disabled")) + rcfcm = _("Right-click for context menu") + self.cashshuffle_status_button.setToolTip( + (_("Toggle CashShuffle") + "\n" + rcfcm) + ) + self.cashshuffle_toggle_action.setText(_("Enable CashShuffle") if not en else _("Disable CashShuffle")) + self.cashshuffle_settings_action.setText(_("CashShuffle Settings...")) + self.cashshuffle_viewpools_action.setEnabled(True) + elif self._cash_shuffle_flag == 1: # Network server error + self.cashshuffle_status_button.setStatusTip(_('CashShuffle Error: Could not connect to server')) + self.cashshuffle_status_button.setToolTip(_('Right-click to select a different CashShuffle server')) + self.cashshuffle_settings_action.setText(_("Resolve Server Problem...")) + self.cashshuffle_viewpools_action.setEnabled(False) + self.cashshuffle_settings_action.setVisible(en or loaded) + self.cashshuffle_viewpools_action.setVisible(en) + if en: + # ensure 'Disable CashShuffle' appears at the end of the context menu + self.cashshuffle_status_button.removeAction(self.cashshuffle_separator_action) + self.cashshuffle_status_button.removeAction(self.cashshuffle_toggle_action) + self.cashshuffle_status_button.addAction(self.cashshuffle_separator_action) + self.cashshuffle_status_button.addAction(self.cashshuffle_toggle_action) + else: + # ensure 'Enable CashShuffle' appears at the beginning of the context menu + self.cashshuffle_status_button.removeAction(self.cashshuffle_separator_action) + self.cashshuffle_status_button.removeAction(self.cashshuffle_toggle_action) + actions = self.cashshuffle_status_button.actions() + self.cashshuffle_status_button.insertAction(actions[0] if actions else None, self.cashshuffle_separator_action) + self.cashshuffle_status_button.insertAction(self.cashshuffle_separator_action, self.cashshuffle_toggle_action) + + @require_good_window_first_arg + def show_cashshuffle_settings(self, window, *args): + p = self.plugin + if p: + msg = None + if self._cash_shuffle_flag == 1: + # had error + msg = _("There was a problem connecting to this server.\nPlease choose a different CashShuffle server.") + p.settings_dialog(window, msg) + + @require_good_window_first_arg + def show_cashshuffle_pools(self, window, *args): + p = self.plugin + if p: + p.view_pools(window) + + @require_good_window_first_arg + def cashshuffle_icon_leftclick(self, window, *args): + self.toggle_cashshuffle() + + @require_good_window_first_arg + def toggle_cashshuffle(self, window, *args): + from .qt import Plugin + if not Plugin.is_wallet_cashshuffle_compatible(window): + window.show_warning(_("This wallet type cannot be used with CashShuffle."), parent=window) + return + plugins = window.gui_object.plugins + p0 = self.plugin + p = p0 or plugins.enable_internal_plugin("shuffle") + if not p: + raise RuntimeError("Could not find CashShuffle plugin") + was_enabled = p.window_has_cashshuffle(window) + if was_enabled and not p.warn_if_shuffle_disable_not_ok(window): + # user at nag screen said "no", so abort + self.update_cashshuffle_icon() + return + enable_flag = not was_enabled + self._cash_shuffle_flag = 0 + KillPopupLabel("CashShuffleError") + if not p0: + # plugin was not loaded -- so flag window as wanting cashshuffle and do init + p.window_set_wants_cashshuffle(window, enable_flag) + p.init_qt(window.gui_object) + else: + # plugin was already started -- just add the window to the plugin + p.window_set_cashshuffle(window, enable_flag) + self.update_cashshuffle_icon() + window.statusBar().showMessage(self.cashshuffle_status_button.statusTip(), 3000) + if enable_flag and window.config.get("show_utxo_tab") is None: + window.toggle_tab(window.utxo_tab) # toggle utxo tab to 'on' if user never specified it should be off. + + _cash_shuffle_flag = 0 + @require_good_window_first_arg + def cashshuffle_set_flag(self, window, flag): + flag = int(flag) + changed = flag != self._cash_shuffle_flag + if not changed: + return + if flag: + def onClick(): + KillPopupLabel("CashShuffleError") + self.show_cashshuffle_settings() + ShowPopupLabel(name = "CashShuffleError", + text="
{}
{}
".format(_("Server Error"),_("Click here to resolve")), + target=self.cashshuffle_status_button, + timeout=20000, onClick=onClick, onRightClick=onClick, + dark_mode = ColorScheme.dark_scheme) + else: + KillPopupLabel("CashShuffleError") + window.print_error("Cash Shuffle flag is now {}".format(flag)) + oldTip = self.cashshuffle_status_button.statusTip() + self._cash_shuffle_flag = flag + window.update_status() # ultimately leads to a call to self.update_cashshuffle_icon() + newTip = self.cashshuffle_status_button.statusTip() + if newTip != oldTip: + window.statusBar().showMessage(newTip, 7500) + + def cashshuffle_get_flag(self): + return self._cash_shuffle_flag diff --git a/plugins/shuffle/round.py b/plugins/shuffle_deprecated/round.py similarity index 100% rename from plugins/shuffle/round.py rename to plugins/shuffle_deprecated/round.py diff --git a/plugins/shuffle/servers.json b/plugins/shuffle_deprecated/servers.json similarity index 100% rename from plugins/shuffle/servers.json rename to plugins/shuffle_deprecated/servers.json diff --git a/plugins/shuffle/tests/config.ini b/plugins/shuffle_deprecated/tests/config.ini similarity index 100% rename from plugins/shuffle/tests/config.ini rename to plugins/shuffle_deprecated/tests/config.ini diff --git a/plugins/shuffle/tests/functional/test_coin_shuffle.py b/plugins/shuffle_deprecated/tests/functional/test_coin_shuffle.py similarity index 95% rename from plugins/shuffle/tests/functional/test_coin_shuffle.py rename to plugins/shuffle_deprecated/tests/functional/test_coin_shuffle.py index 86a59567ec2c..6279cb11c937 100644 --- a/plugins/shuffle/tests/functional/test_coin_shuffle.py +++ b/plugins/shuffle_deprecated/tests/functional/test_coin_shuffle.py @@ -4,13 +4,13 @@ imp.load_module('electroncash', *imp.find_module('lib')) imp.load_module('electroncash_plugins', *imp.find_module('plugins')) -from electroncash_plugins.shuffle.coin import Coin, address_from_public_key -from electroncash_plugins.shuffle.crypto import Crypto -from electroncash_plugins.shuffle.messages import Messages -from electroncash_plugins.shuffle.round import Round -from electroncash_plugins.shuffle.tests.test import testNetwork, random_sk, make_fake_public_key, make_fake_address, fake_hash -from electroncash_plugins.shuffle.comms import Channel, ChannelWithPrint -# from electroncash_plugins.shuffle.phase import Phase +from electroncash_plugins.shuffle_deprecated.coin import Coin, address_from_public_key +from electroncash_plugins.shuffle_deprecated.crypto import Crypto +from electroncash_plugins.shuffle_deprecated.messages import Messages +from electroncash_plugins.shuffle_deprecated.round import Round +from electroncash_plugins.shuffle_deprecated.tests.test import testNetwork, random_sk, make_fake_public_key, make_fake_address, fake_hash +from electroncash_plugins.shuffle_deprecated.comms import Channel, ChannelWithPrint +# from electroncash_plugins.shuffle_deprecated.phase import Phase class TestRound(unittest.TestCase): diff --git a/plugins/shuffle/tests/functional/test_coins.py b/plugins/shuffle_deprecated/tests/functional/test_coins.py similarity index 96% rename from plugins/shuffle/tests/functional/test_coins.py rename to plugins/shuffle_deprecated/tests/functional/test_coins.py index cf631ef82af8..9b7a159d2ef4 100644 --- a/plugins/shuffle/tests/functional/test_coins.py +++ b/plugins/shuffle_deprecated/tests/functional/test_coins.py @@ -8,9 +8,9 @@ imp.load_module('electroncash', *imp.find_module('lib')) imp.load_module('electroncash_plugins', *imp.find_module('plugins')) -from electroncash_plugins.shuffle.coin import Coin, address_from_public_key -from electroncash_plugins.shuffle.tests.test import testNetwork, random_sk, make_fake_public_key, make_fake_address, fake_hash -from electroncash_plugins.shuffle.messages import Messages +from electroncash_plugins.shuffle_deprecated.coin import Coin, address_from_public_key +from electroncash_plugins.shuffle_deprecated.tests.test import testNetwork, random_sk, make_fake_public_key, make_fake_address, fake_hash +from electroncash_plugins.shuffle_deprecated.messages import Messages from electroncash.bitcoin import Hash from electroncash.address import Address diff --git a/plugins/shuffle/tests/functional/test_messages.py b/plugins/shuffle_deprecated/tests/functional/test_messages.py similarity index 99% rename from plugins/shuffle/tests/functional/test_messages.py rename to plugins/shuffle_deprecated/tests/functional/test_messages.py index 46b3b10ad4ee..8a698d7e32f8 100644 --- a/plugins/shuffle/tests/functional/test_messages.py +++ b/plugins/shuffle_deprecated/tests/functional/test_messages.py @@ -4,7 +4,7 @@ imp.load_module('electroncash', *imp.find_module('lib')) imp.load_module('electroncash_plugins', *imp.find_module('plugins')) -from electroncash_plugins.shuffle.messages import Messages +from electroncash_plugins.shuffle_deprecated.messages import Messages class FakeEck(object): diff --git a/plugins/shuffle/tests/test.py b/plugins/shuffle_deprecated/tests/test.py similarity index 98% rename from plugins/shuffle/tests/test.py rename to plugins/shuffle_deprecated/tests/test.py index f0243db3d054..138c2cfb55cb 100644 --- a/plugins/shuffle/tests/test.py +++ b/plugins/shuffle_deprecated/tests/test.py @@ -17,12 +17,12 @@ from electroncash.address import Address from electroncash.util import InvalidPassword -from electroncash_plugins.shuffle.client import ProtocolThread -from electroncash_plugins.shuffle.comms import (ChannelWithPrint, Channel) -from electroncash_plugins.shuffle.coin import Coin -from electroncash_plugins.shuffle.crypto import Crypto -# from electroncash_plugins.shuffle.phase import Phase -from electroncash_plugins.shuffle.round import Round +from electroncash_plugins.shuffle_deprecated.client import ProtocolThread +from electroncash_plugins.shuffle_deprecated.comms import (ChannelWithPrint, Channel) +from electroncash_plugins.shuffle_deprecated.coin import Coin +from electroncash_plugins.shuffle_deprecated.crypto import Crypto +# from electroncash_plugins.shuffle_deprecated.phase import Phase +from electroncash_plugins.shuffle_deprecated.round import Round from electroncash.bitcoin import (regenerate_key, deserialize_privkey, EC_KEY, generator_secp256k1, number_to_string ,public_key_to_p2pkh, point_to_ser, Hash) @@ -450,7 +450,7 @@ class TestProtocolCase(unittest.TestCase): def __init__(self, *args, **kwargs): super(TestProtocolCase,self).__init__(*args, **kwargs) config = configparser.ConfigParser() - config.read_file(open('plugins/shuffle/tests/config.ini')) + config.read_file(open('plugins/shuffle_deprecated/tests/config.ini')) self.HOST = config["CashShuffle"]["address"] self.PORT = int(config["CashShuffle"]["port"]) self.fee = int(config["Clients"]["fee"]) diff --git a/plugins/shuffle/tests/test_announcement.py b/plugins/shuffle_deprecated/tests/test_announcement.py similarity index 96% rename from plugins/shuffle/tests/test_announcement.py rename to plugins/shuffle_deprecated/tests/test_announcement.py index 676a7daad8fe..0cdc53d2796e 100644 --- a/plugins/shuffle/tests/test_announcement.py +++ b/plugins/shuffle_deprecated/tests/test_announcement.py @@ -25,7 +25,7 @@ def test_001_same_keys_appears(self): self.assertIn('Error: The same keys appears!', last_messages) def test_002_insufficient_funds(self): - from electroncash_plugins.shuffle.coin import Coin + from electroncash_plugins.shuffle_deprecated.coin import Coin coin = Coin(self.network) protocolThreads = self.make_clients_threads(with_print = True) coins_1 = coin.get_coins(protocolThreads[0].inputs) diff --git a/plugins/shuffle/tests/test_bad_connections.py b/plugins/shuffle_deprecated/tests/test_bad_connections.py similarity index 97% rename from plugins/shuffle/tests/test_bad_connections.py rename to plugins/shuffle_deprecated/tests/test_bad_connections.py index 6169135b9e38..72619f25a62c 100644 --- a/plugins/shuffle/tests/test_bad_connections.py +++ b/plugins/shuffle_deprecated/tests/test_bad_connections.py @@ -1,5 +1,5 @@ from test import TestProtocolCase -# from electroncash_plugins.shuffle.messages import Messages() +# from electroncash_plugins.shuffle_deprecated.messages import Messages() class TestProtocol(TestProtocolCase): diff --git a/plugins/shuffle/tests/test_bad_hash.py b/plugins/shuffle_deprecated/tests/test_bad_hash.py similarity index 100% rename from plugins/shuffle/tests/test_bad_hash.py rename to plugins/shuffle_deprecated/tests/test_bad_hash.py diff --git a/plugins/shuffle/tests/test_bad_shuffling.py b/plugins/shuffle_deprecated/tests/test_bad_shuffling.py similarity index 100% rename from plugins/shuffle/tests/test_bad_shuffling.py rename to plugins/shuffle_deprecated/tests/test_bad_shuffling.py diff --git a/plugins/shuffle/tests/test_correct_execution.py b/plugins/shuffle_deprecated/tests/test_correct_execution.py similarity index 100% rename from plugins/shuffle/tests/test_correct_execution.py rename to plugins/shuffle_deprecated/tests/test_correct_execution.py diff --git a/plugins/shuffle/tests/test_network_fault.py b/plugins/shuffle_deprecated/tests/test_network_fault.py similarity index 100% rename from plugins/shuffle/tests/test_network_fault.py rename to plugins/shuffle_deprecated/tests/test_network_fault.py diff --git a/setup.py b/setup.py index 3c15456c43f8..b6b0d8fa479d 100755 --- a/setup.py +++ b/setup.py @@ -167,9 +167,10 @@ def run(self): 'electroncash_plugins.trezor', 'electroncash_plugins.digitalbitbox', 'electroncash_plugins.virtualkeyboard', - 'electroncash_plugins.shuffle', + 'electroncash_plugins.shuffle_deprecated', 'electroncash_plugins.satochip', 'electroncash_plugins.satochip_2FA', + 'electroncash_plugins.fusion', ], package_dir={ 'electroncash': 'lib', @@ -188,9 +189,12 @@ def run(self): 'locale/*/LC_MESSAGES/electron-cash.mo', 'tor/bin/*' ], - 'electroncash_plugins.shuffle' : [ + 'electroncash_plugins.shuffle_deprecated' : [ 'servers.json' ], + 'electroncash_plugins.fusion' : [ + '*.svg', '*.png' + ], # On Linux and Windows this means adding gui/qt/data/*.ttf # On Darwin we don't use that font, so we don't add it to save space. **platform_package_data diff --git a/tox.ini b/tox.ini index a40e3710a59a..150c7a6d8590 100644 --- a/tox.ini +++ b/tox.ini @@ -11,4 +11,4 @@ commands= coverage report [pytest] -norecursedirs=plugins/shuffle contrib ios android +norecursedirs=plugins/shuffle_deprecated contrib ios android