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