{{ _("Bitcoin Core version is outdated.")}} {{ _('Some features might not work...')}} {{ _("(minimum required: v20.0.0).") }}
+ {% endif %}
{% include node.node_logo_template() %}
{% include "includes/sidebar/components/bitcoin_core_info.jinja" %}
diff --git a/src/cryptoadvance/specter/templates/includes/sidebar/sidebar.jinja b/src/cryptoadvance/specter/templates/includes/sidebar/sidebar.jinja
index fdf2f9fe91..1dce255afa 100644
--- a/src/cryptoadvance/specter/templates/includes/sidebar/sidebar.jinja
+++ b/src/cryptoadvance/specter/templates/includes/sidebar/sidebar.jinja
@@ -63,12 +63,6 @@
{% endif %}
- {% if specter.node_manager.nodes | length > 1 and node_alias %}
+ {% if specter.node_manager.nodes | length > 0 and node_alias %}
{% endif %}
diff --git a/src/cryptoadvance/specter/templates/services/sidebar_services_list.jinja b/src/cryptoadvance/specter/templates/services/sidebar_services_list.jinja
index 8eba07899a..ce1eb76b66 100644
--- a/src/cryptoadvance/specter/templates/services/sidebar_services_list.jinja
+++ b/src/cryptoadvance/specter/templates/services/sidebar_services_list.jinja
@@ -3,7 +3,7 @@
{% for _,plugin in specter.service_manager.services.items() %}
- {% if plugin.id in current_user.services %}
+ {% if plugin.id in current_user.services and plugin.visible_in_sidebar %}
{{ plugin.name }}
diff --git a/src/cryptoadvance/specter/txlist.py b/src/cryptoadvance/specter/txlist.py
index 4d8f42b11f..1cf3327505 100644
--- a/src/cryptoadvance/specter/txlist.py
+++ b/src/cryptoadvance/specter/txlist.py
@@ -16,6 +16,7 @@
from .specter_error import SpecterError, SpecterInternalException
from embit.descriptor import Descriptor
from embit.liquid.descriptor import LDescriptor
+from .util.common import str2bool
from .util.psbt import (
AbstractTxContext,
SpecterInputScope,
@@ -24,6 +25,7 @@
SpecterTx,
)
from .util.tx import decoderawtransaction
+from threading import RLock
logger = logging.getLogger(__name__)
@@ -84,6 +86,7 @@ class TxItem(dict, AbstractTxListContext):
"vsize",
"address",
]
+ # type_converter will be used to _read_csv to have a proper mapping
type_converter = [
str,
int,
@@ -289,13 +292,14 @@ def __dict__(self):
class WalletAwareTxItem(TxItem):
PSBTCls = SpecterPSBT
+
+ # Columns for writing CSVs, type_converter for reading
columns = TxItem.columns.copy()
columns.extend(
["category", "flow_amount", "utxo_amount", "ismine"],
)
-
type_converter = TxItem.type_converter.copy()
- type_converter.extend([str, float, float, bool])
+ type_converter.extend([str, float, float, str2bool])
def __init__(self, parent, addresses, rawdir, **kwargs):
super().__init__(parent, addresses, rawdir, **kwargs)
@@ -323,6 +327,24 @@ def psbt(self) -> SpecterPSBT:
self._psbt.update(updated)
return self._psbt
+ @property
+ def is_taproot(self):
+ return str(self.descriptor).startswith("tr(")
+
+ def decode_psbt(self, mode="embit") -> SpecterPSBT:
+ """Utility function which decodes this tx as psbt
+ as in the core rpc-call 'decodepsbt'.
+ However, it uses embit to calculate the details
+ use mode=core to ask core directly.
+ embit might support taproot, core might not.
+ """
+ if mode == "core":
+ return self.rpc.decodepsbt(str(self.psbt))
+ elif mode == "embit":
+ return self.psbt.to_dict()
+ else:
+ raise SpecterInternalException("Mode not existing")
+
@property
def category(self):
"""One of mixed (default), generate, selftransfer, receive or send"""
@@ -385,8 +407,11 @@ def flow_amount(self) -> float:
def ismine(self) -> bool:
if self.get("ismine"):
return self["ismine"]
- inputs = self.psbt.inputs
- outputs = self.psbt.outputs
+ if self.is_taproot:
+ # This is a bug mitigation, see #2078
+ return True
+ inputs: List[SpecterInputScope] = self.psbt.inputs
+ outputs: List[SpecterOutputScope] = self.psbt.outputs
any_inputs_mine = any([inp.is_mine for inp in inputs])
any_outputs_mine = any([out.is_mine for out in outputs])
self["ismine"] = any_inputs_mine or any_outputs_mine
@@ -417,13 +442,13 @@ def address(self):
self["address"] = addresses[0]
return self["address"]
- def __dict__(self):
- super_dict = dict(self)
- super_dict["category"] = self.category
- super_dict["flow_amount"] = self.flow_amount
- super_dict["utxo_amount"] = self.utxo_amount
- super_dict["ismine"] = (self["ismine"] or self.ismine,)
- return super_dict
+ # def __dict__(self):
+ # super_dict = dict(self)
+ # super_dict["category"] = self.category
+ # super_dict["flow_amount"] = self.flow_amount
+ # super_dict["utxo_amount"] = self.utxo_amount
+ # super_dict["ismine"] = (self["ismine"] or self.ismine,)
+ # return super_dict
class TxList(dict, AbstractTxListContext):
@@ -432,6 +457,8 @@ class TxList(dict, AbstractTxListContext):
ItemCls = WalletAwareTxItem # for inheritance
PSBTCls = SpecterPSBT
+ lock = RLock()
+
def __init__(self, path, parent, addresses):
self.parent = parent
self.path = path
@@ -474,6 +501,8 @@ def clear_cache(self):
tx.clear_cache()
delete_file(self.path)
self._file_exists = False
+ self.clear()
+
logger.info(f"Cleared the Cache for {self.path} (and rawdir)")
def getfetch(self, txid):
@@ -504,7 +533,11 @@ def get_transactions(self, current_blockheight=None) -> WalletAwareTxItem:
# Make a copy of all txs if the tx.ismine (which should be all of them)
# As TxItem is derived from Dict, the __Dict__ will return a TxItem
- transactions: List(TxItem) = [tx.copy() for tx in self.values() if tx.ismine]
+ with self.lock:
+ tx_values = self.values()
+ transactions: List(TxItem) = [
+ tx.copy() for tx in self.values() if tx.ismine
+ ]
# 1. sorted
transactions = sorted(transactions, key=lambda tx: tx["time"], reverse=True)
@@ -588,42 +621,43 @@ def add(self, txs):
}
(format of listtransactions)
"""
- # here we store all addresses in transactions
- # to set them used later
- addresses = []
- # first we add all transactions to cache
- for txid in txs:
- tx = txs[txid]
- # find minimal from 3 times:
- maxtime = 10445238000 # TODO: change after 31 dec 2300 lol
- time = min(
- tx.get("blocktime", maxtime),
- tx.get("timereceived", maxtime),
- tx.get("time", maxtime),
- )
- obj = {
- "txid": txid,
- "fee": tx.get("fee", None),
- "blockheight": tx.get("blockheight", None),
- "blockhash": tx.get("blockhash", None),
- "time": time,
- "blocktime": tx.get("blocktime", None),
- "conflicts": tx.get("walletconflicts", []),
- "bip125-replaceable": tx.get("bip125-replaceable", "no"),
- "hex": tx.get("hex", None),
- }
- txitem = self.ItemCls(self, self._addresses, self.rawdir, **obj)
- self[txid] = txitem
- if txitem.tx:
- for vout in txitem.tx.vout:
- try:
- addr = vout.script_pubkey.address(get_network(self.chain))
- if addr not in addresses:
- addresses.append(addr)
- except:
- pass # maybe not an address, but a raw script?
- self._addresses.set_used(addresses)
- self._save()
+ with self.lock:
+ # here we store all addresses in transactions
+ # to set them used later
+ addresses = []
+ # first we add all transactions to cache
+ for txid in txs:
+ tx = txs[txid]
+ # find minimal from 3 times:
+ maxtime = 10445238000 # TODO: change after 31 dec 2300 lol
+ time = min(
+ tx.get("blocktime", maxtime),
+ tx.get("timereceived", maxtime),
+ tx.get("time", maxtime),
+ )
+ obj = {
+ "txid": txid,
+ "fee": tx.get("fee", None),
+ "blockheight": tx.get("blockheight", None),
+ "blockhash": tx.get("blockhash", None),
+ "time": time,
+ "blocktime": tx.get("blocktime", None),
+ "conflicts": tx.get("walletconflicts", []),
+ "bip125-replaceable": tx.get("bip125-replaceable", "no"),
+ "hex": tx.get("hex", None),
+ }
+ txitem = self.ItemCls(self, self._addresses, self.rawdir, **obj)
+ self[txid] = txitem
+ if txitem.tx:
+ for vout in txitem.tx.vout:
+ try:
+ addr = vout.script_pubkey.address(get_network(self.chain))
+ if addr not in addresses:
+ addresses.append(addr)
+ except:
+ pass # maybe not an address, but a raw script?
+ self._addresses.set_used(addresses)
+ self._save()
def load(self, arr):
"""
diff --git a/src/cryptoadvance/specter/user.py b/src/cryptoadvance/specter/user.py
index 1f7ae9bfa5..28aef4d765 100644
--- a/src/cryptoadvance/specter/user.py
+++ b/src/cryptoadvance/specter/user.py
@@ -293,7 +293,6 @@ def check_wallet_manager(self):
):
wallet_manager = WalletManager(
- self.specter.bitcoin_core_version_raw,
wallets_folder,
self.specter.rpc,
self.specter.chain,
diff --git a/src/cryptoadvance/specter/util/common.py b/src/cryptoadvance/specter/util/common.py
index d5cf555472..a071bde31e 100644
--- a/src/cryptoadvance/specter/util/common.py
+++ b/src/cryptoadvance/specter/util/common.py
@@ -4,6 +4,7 @@
import json
from flask_babel.speaklater import LazyString
from typing import Union
+from distutils.util import strtobool
logger = logging.getLogger(__name__)
@@ -12,11 +13,9 @@ def str2bool(my_str):
"""returns a reasonable boolean from a string so that "False" will result in False"""
if my_str is None:
return False
- elif isinstance(my_str, str) and my_str.lower() == "false":
- return False
- elif isinstance(my_str, str) and my_str.lower() == "off":
- return False
- return bool(my_str)
+ elif isinstance(my_str, bool):
+ return my_str
+ return bool(strtobool(my_str))
def camelcase2snake_case(name):
diff --git a/src/cryptoadvance/specter/wallet.py b/src/cryptoadvance/specter/wallet.py
index bf4066000d..6920334d9e 100644
--- a/src/cryptoadvance/specter/wallet.py
+++ b/src/cryptoadvance/specter/wallet.py
@@ -875,7 +875,7 @@ def check_utxo(self):
except Exception as e:
logger.exception(e)
self._full_utxo = []
- raise SpecterError(f"Failed to load utxos, {e}")
+ raise SpecterError(f"Failed to load utxos, {type(e).__name__}: {e}")
def check_utxo_orig(self):
"""fetches the utxo-set from core and stores the result in self.__full_utxo which is
diff --git a/src/cryptoadvance/specterext/devhelp/templates/devhelp/dev-console.jinja b/src/cryptoadvance/specterext/devhelp/templates/devhelp/dev-console.jinja
index b3b5dbc64b..2f9389791d 100644
--- a/src/cryptoadvance/specterext/devhelp/templates/devhelp/dev-console.jinja
+++ b/src/cryptoadvance/specterext/devhelp/templates/devhelp/dev-console.jinja
@@ -27,9 +27,10 @@
- you can also do that in any javascript.console with:
+ Press F12 to open the dev-console (helpful to see objects)
+ You can also do that in any javascript.console with:
@@ -109,6 +110,7 @@
} else {
+ console.log(result)
if (result == "") {
result = "(empty string)"
}
diff --git a/src/cryptoadvance/specterext/devhelp/templates/devhelp/html/notification-system.jinja b/src/cryptoadvance/specterext/devhelp/templates/devhelp/html/notification-system.jinja
new file mode 100644
index 0000000000..2c56b27d3e
--- /dev/null
+++ b/src/cryptoadvance/specterext/devhelp/templates/devhelp/html/notification-system.jinja
@@ -0,0 +1,136 @@
+{% extends "base.jinja" %}
+
+{% block main %}
+
+
Notification System
+
+
+ The notification System is a flexible way to Notify the user. Other than flask's flash, it's working instantly via WebSockets
+ and takes advantage of Web Api Notifications.
+ It has been introduced with #1766
+
+
+ It can e.g. be used in javascript like this(see below for the options):
+
+
+
+
+
+ In python, you would rather use somehing like:
+
+
+
+
+
+
+
+
+
+ Let's create a Notification:
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/cryptoadvance/specterext/devhelp/templates/devhelp/index.jinja b/src/cryptoadvance/specterext/devhelp/templates/devhelp/index.jinja
index a63e38f010..cb0d9bff17 100644
--- a/src/cryptoadvance/specterext/devhelp/templates/devhelp/index.jinja
+++ b/src/cryptoadvance/specterext/devhelp/templates/devhelp/index.jinja
@@ -19,7 +19,8 @@
diff --git a/src/cryptoadvance/specterext/notifications/__init__.py b/src/cryptoadvance/specterext/notifications/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/cryptoadvance/specterext/notifications/config.py b/src/cryptoadvance/specterext/notifications/config.py
new file mode 100644
index 0000000000..cc633f56b5
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/config.py
@@ -0,0 +1,25 @@
+"""
+Here Configuration of your Extension takes place
+"""
+
+
+class BaseConfig:
+ """This is a extension-based Config which is used as Base"""
+
+ SPECTER_NOTIFICATIONS_WEBSOCKETS_ENABLED = True
+
+
+class ProductionConfig(BaseConfig):
+ """This is a extension-based Config for Production"""
+
+ pass
+
+
+class TestConfig(BaseConfig):
+ """This is a extension-based Config for Production"""
+
+ SPECTER_NOTIFICATIONS_WEBSOCKETS_ENABLED = False
+
+
+class CypressTestConfig(TestConfig):
+ SPECTER_NOTIFICATIONS_WEBSOCKETS_ENABLED = True
diff --git a/src/cryptoadvance/specterext/notifications/controller.py b/src/cryptoadvance/specterext/notifications/controller.py
new file mode 100644
index 0000000000..d9537d9674
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/controller.py
@@ -0,0 +1,90 @@
+import logging
+import json
+from flask import redirect, render_template, request, url_for, Response
+from flask import current_app as app
+from flask_login import login_required, current_user
+
+from cryptoadvance.specter.specter import Specter
+from cryptoadvance.specter.services.controller import user_secret_decrypted_required
+from cryptoadvance.specter.user import User
+from cryptoadvance.specter.wallet import Wallet
+from .service import NotificationsService
+
+logger = logging.getLogger(__name__)
+
+notifications_endpoint = NotificationsService.blueprint
+
+
+def ext() -> NotificationsService:
+ """convenience for getting the extension-object"""
+ return app.specter.ext["notifications"]
+
+
+def specter() -> Specter:
+ """convenience for getting the specter-object"""
+ return app.specter
+
+
+@notifications_endpoint.route("/")
+@login_required
+def index():
+ return render_template(
+ "notifications/index.jinja",
+ )
+
+
+@notifications_endpoint.route("/settings", methods=["GET"])
+@login_required
+def settings_get():
+ user = app.specter.user_manager.get_user()
+ return render_template(
+ "notifications/settings.jinja",
+ cookies=request.cookies,
+ show_menu=NotificationsService.id in user.services,
+ )
+
+
+@notifications_endpoint.route("/settings", methods=["POST"])
+@login_required
+def settings_post():
+ show_menu = request.form["show_menu"]
+ user = app.specter.user_manager.get_user()
+ if show_menu == "yes":
+ user.add_service(NotificationsService.id)
+ else:
+ user.remove_service(NotificationsService.id)
+ return redirect(
+ url_for(f"{ NotificationsService.get_blueprint_name()}.settings_get")
+ )
+
+
+@notifications_endpoint.route("/websocket", websocket=True)
+def websocket():
+ logger.debug("websocket route called. This will start a new websocket connection.")
+ # this function will run forever. That is ok, because a stream is expected, similar to https://maxhalford.github.io/blog/flask-sse-no-deps/
+ # flask.Response(stream(), mimetype='text/event-stream')
+ if app.specter.ext["notifications"].notification_manager.websockets_server:
+ app.specter.ext["notifications"].notification_manager.websockets_server.serve(
+ request.environ
+ )
+ else:
+ logger.warning(
+ "/websocket route accessed, but no websockets_server is initialized."
+ )
+ # returning something solved some error message when the function ends: https://stackoverflow.com/questions/25034123/flask-value-error-view-function-did-not-return-a-response
+ # and this mimetype does not trigger slow_request_detection_stop
+ # however when connected via ssl the following error still occurs
+ # ssl.SSLEOFError: EOF occurred in violation of protocol (_ssl.c:2384)
+ return Response(json.dumps({}), mimetype="application/json")
+
+
+@notifications_endpoint.route("/get_websockets_info/", methods=["GET"])
+@login_required
+def get_websockets_info():
+ return json.dumps(
+ {
+ "user_token": app.specter.ext[
+ "notifications"
+ ].notification_manager.get_websocket_token(current_user.username),
+ }
+ )
diff --git a/src/cryptoadvance/specterext/notifications/notification_manager.py b/src/cryptoadvance/specterext/notifications/notification_manager.py
new file mode 100644
index 0000000000..9bdb57e401
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/notification_manager.py
@@ -0,0 +1,485 @@
+import logging
+import secrets
+
+logger = logging.getLogger(__name__)
+
+from .notifications import Notification
+from ..notifications import ui_notifications
+from ..notifications import websockets_server_client
+
+
+INTERNAL_NOTIFICATION_TITLES = {"set_target_ui_availability", "on_show", "on_close"}
+
+
+class NotificationManager:
+ """
+ This class allows to register ui_notifications (like JSNotifications).
+
+ 1. Notifications can be created and broadcasted with self.create_and_show
+ This will forward the notification to all appropriate ui_notifications
+ The notifications are also stored in self.notifications.
+
+ Notifications has optimistic notification delivery, meaning:
+ - the ui_notifications (like JSNotifications) immediately returns True, meaning the notification
+ was delivered, if the ui_notification is not deactivated or has other reasons to deny delivery immediately.
+ - If the ui_notifications (like JSNotifications) then later detects it failed to deliver
+ after all, the ui_notification has to send a set_target_ui_availability (data.is_available=False, data.notification_id),
+ which deactivates the ui_notification and rebroadcasts the notification on other available ui_notifications
+ - This optimistic notification delivery was chosen, mainly because the ui_notifications vary greatly
+ preventing a common timeout. Only the ui_notification knows how long and best it should retry displaying the notification.
+ The best example is Notification_API, which may not have the permission to display yet, but can ask for permission
+ repeatedly. The user has the choice to grant it or to deny it, or to wait.
+ The print notification on the other hand will always work.
+ - final confirmation that the message was seen by the user in the on_show notification sent by the ui_notification
+
+
+ 2. Internal notifications will not be shown, but
+ a) deletes the message again from self.notifications
+ b) trigger functions like ui_notifications.on_close
+
+ If the ui_notification noticed later (async), that the notification was not delivered, then the ui_notification call
+ create_notification('set_target_ui_availability', user_id, target_uis=["internal_notification"], is_available=False)
+ and the ui_notification will deactivate and the notification will be rebroadcasted
+
+
+
+ 1. Notifications to UINotifications
+ ┌───────────────────────┐
+ ┌─────────────────────► │ FlashNotifications │
+ │ └───────────────────────┘
+ ┌───────────────────────┐ │ ┌───────────────────────┐
+ │ NotificationManager │ ├─────────────────────► │ JSConsoleNotifications│
+ │ .create_and_show │ │ │ │
+ └───────────────────────┘ │ └───────────────────────┘
+ │ ┌───────────────────────┐
+ └──────────────────────►│ JSNotifications │
+ └───────────────────────┘
+
+ 2. internal_notification at the example of on_close
+
+ ┌───────────────────────┐
+ │ │ if "internal_notification" in notification.target_uis:
+ │ NotificationManager │ ──────────────────────────────────────►┐
+ │ .create_and_show │ │
+ └───────────────────────┘ ▼
+ ┌────────────────────────────┐
+ │ NotificationManager │
+ │ ._treat_internal_message │
+ │ e.g. on_close │
+ └─────────────────┬──────────┘
+ │ on_close
+ ▼
+ ┌───────────────────────┐ ┌───────────────────────┐
+ │ JSNotifications │ ◄─────────────────────┐ │ NotificationManager │
+ │ .on_close │ │ │ ._internal_on_close │
+ └───────────────────────┘ │ └───────────────────────┘
+ ┌───────────────────────┐ │
+ │ NotificationManager │ │
+ │ ._delete_notification│ ◄─────────────────────┘
+ └───────────────────────┘
+
+ """
+
+ def __init__(
+ self,
+ host,
+ port,
+ ssl_cert=None,
+ ssl_key=None,
+ ui_notifications=None,
+ enable_websockets=True,
+ notifications_endpoint_url="svc/notifications",
+ verbose_debug=False,
+ ):
+ """
+ Arguments:
+ - ui_notifications: {user_id: [list of ui_notifications]}
+ The "default" ui_notifications is at position 0
+ """
+ self.ui_notifications = ui_notifications if ui_notifications else []
+ self.notifications = []
+ self._websocket_tokens = {}
+ self.ssl_cert, self.ssl_key = ssl_cert, ssl_key
+ self._register_default_ui_notifications()
+
+ self.verbose_debug = verbose_debug
+ self.websockets_server = None
+ self.websockets_client = None
+ if enable_websockets:
+ self.websockets_server = websockets_server_client.WebsocketServer(self)
+
+ # '/'.join([notifications_endpoint_url, "websocket"]) should be replaced by url_for('notifications_endpoint.websocket')
+ # but this leads to an error
+ self.websockets_client = websockets_server_client.WebsocketClient(
+ host,
+ port,
+ "/".join([notifications_endpoint_url, "websocket"]),
+ self.ssl_cert,
+ self.ssl_key,
+ )
+
+ # setting this client as broadcaster, meaning it is allowed to send to all
+ # connected websocket connections without restrictions
+ self.websockets_server.set_as_broadcaster(self.websockets_client.user_token)
+ self.websockets_client.start()
+
+ def get_websocket_token(self, user_id):
+ if user_id not in self._websocket_tokens:
+ self._websocket_tokens[user_id] = secrets.token_urlsafe(128)
+
+ return self._websocket_tokens[user_id]
+
+ @property
+ def websocket_tokens(self):
+ return self._websocket_tokens
+
+ def quit(self):
+ if self.websockets_client and self.websockets_client.is_connected():
+ self.websockets_client.quit_server()
+ logger.debug(f"{self.__class__.__name__} quit was called.")
+
+ def _register_default_ui_notifications(self):
+ "Registers up the logging and print UINotifications, that can be used by alll users (user_id=None)"
+ self.register_ui_notification(ui_notifications.LoggingNotifications())
+ self.register_ui_notification(ui_notifications.PrintNotifications())
+ # the flask context handles here the user assignment, such that no user_id should be given here
+ self.register_ui_notification(ui_notifications.FlashNotifications())
+
+ def register_user_ui_notifications(self, user_id):
+ "Registers up the (default) UINotifications for this user"
+ if self.websockets_server:
+ self.register_ui_notification(
+ ui_notifications.WebAPINotifications(user_id, self.websockets_client)
+ )
+
+ if self.websockets_server:
+ self.register_ui_notification(
+ ui_notifications.JSNotifications(user_id, self.websockets_client)
+ )
+
+ if self.websockets_server:
+ self.register_ui_notification(
+ ui_notifications.JSConsoleNotifications(user_id, self.websockets_client)
+ )
+
+ def register_ui_notification(self, ui_notification):
+ """
+ Appends the ui_notification at the end of the self.ui_notifications.
+
+ It can then be explicitly used via notification.target_uis = {ui_notification.name}
+ or is used automatically as a fallback if all previous ui_notifications are not available
+ """
+ logger.debug(
+ f'Registering "{ui_notification.name}" for user "{ui_notification.user_id}" in {self.__class__.__name__}'
+ )
+ self.ui_notifications.append(ui_notification)
+
+ def _find_target_ui(self, target_ui, user_id):
+ "Returns the ui_notification matching (target_ui, user_id)"
+ for ui_notification in self.ui_notifications:
+ if (ui_notification.name == target_ui) and (
+ (ui_notification.user_id == user_id or ui_notification.user_id is None)
+ ):
+ return ui_notification
+
+ def get_default_target_ui_name(self):
+ "Returns the first ui_notifications"
+ return self.ui_notifications[0].name if self.ui_notifications else None
+
+ def get_all_target_ui_names(self):
+ "Returns the names of all ui_notifications"
+ return {ui_notification.name for ui_notification in self.ui_notifications}
+
+ def get_notification_by_id(self, notification_id):
+ "Finds and returns the notification with notification_id"
+ for notification in self.notifications:
+ if notification.id == notification_id:
+ return notification
+
+ def _get_ui_notifications_of_user(
+ self, user_id, callable_from_any_session_required=False
+ ):
+ "Gives a back a [ui_notifications that belong to the user_id] + [ui_notifications that belong to user_id == None]"
+ user_targeted_ui_notification = (
+ [
+ ui_notification
+ for ui_notification in self.ui_notifications
+ if ui_notification.user_id == user_id
+ and (
+ ui_notification.callable_from_any_session
+ or not callable_from_any_session_required
+ )
+ ]
+ if user_id
+ else []
+ )
+
+ public_ui_notification = [
+ ui_notification
+ for ui_notification in self.ui_notifications
+ if ui_notification.user_id is None
+ and (
+ ui_notification.callable_from_any_session
+ or not callable_from_any_session_required
+ )
+ ]
+
+ return user_targeted_ui_notification + public_ui_notification
+
+ def set_notification_shown(self, notification_id, target_ui):
+ "Calls notification.set_shown"
+ notification = self.get_notification_by_id(notification_id)
+ if not notification:
+ logging.warning(
+ f"set_notification_shown: Notification with id {notification_id} not found"
+ )
+ return
+ notification.set_shown(target_ui)
+
+ def _delete_notification(self, notification):
+ "Deletes the notification from self.notifications"
+ if notification not in self.notifications:
+ logging.warning(
+ f"_delete_notification: notification {notification} was not found in self.notifications"
+ )
+ return
+
+ del self.notifications[self.notifications.index(notification)]
+ if self.verbose_debug:
+ logger.debug(f"Deleted {notification}")
+
+ def set_target_ui_availability(self, target_ui, user_id, is_available):
+ "Sets ui_notification.is_available"
+ ui_notification = self._find_target_ui(target_ui, user_id)
+ if not ui_notification:
+ logger.warning(
+ f"set_target_ui_availability: target_ui {target_ui} could not be found"
+ )
+ return
+ ui_notification.is_available = is_available
+ logger.debug(
+ f"Setting {ui_notification.name} of user {ui_notification.user_id} available = {is_available}"
+ )
+
+ def _internal_set_target_ui_availability(
+ self, internal_notification, referenced_notification
+ ):
+ "Calls self.set_target_ui_availability and rebroadcasts the referenced_notification if necessary"
+ logger.debug(
+ f'{internal_notification.data["target_ui"]} is unavailable, now deactivating this target_ui and rebroadcasting'
+ )
+ self.set_target_ui_availability(
+ internal_notification.data["target_ui"],
+ internal_notification.user_id,
+ internal_notification.data["is_available"],
+ )
+ if not internal_notification.data["is_available"]:
+ # if it is not available rebroadcast
+ if not referenced_notification and internal_notification.data.get("id"):
+ logger.warning(
+ f'Rebrodcasting of {internal_notification.data.get("id")} not possible because referenced_notification={referenced_notification}'
+ )
+ return
+ logger.debug(f"Rebrodcasting {referenced_notification.id}")
+ self.show(referenced_notification)
+
+ def _internal_on_show(self, internal_notification, referenced_notification):
+ "Calls self.set_notification_shown and ui_notification.on_show"
+ if not referenced_notification:
+ return
+
+ self.set_notification_shown(
+ referenced_notification.id,
+ internal_notification.data["target_ui"],
+ )
+ ui_notification = self._find_target_ui(
+ internal_notification.data["target_ui"], referenced_notification.user_id
+ )
+ # perhaps the target_ui was not available and it was displayed in another ui_notification.
+ # However still call on_show of the original target_ui
+ if ui_notification.on_show:
+ ui_notification.on_show(
+ referenced_notification.id,
+ internal_notification.data["target_ui"],
+ )
+
+ def _notification_can_be_deleted(self, notification, on_close_was_called=False):
+ "Checks if the target_ui was set in was_closed_in_target_uis, if the target_ui is available"
+ available_target_uis = {
+ target_ui
+ for target_ui in notification.target_uis
+ if self._find_target_ui(target_ui, notification.user_id)
+ and self._find_target_ui(target_ui, notification.user_id).is_available
+ }
+
+ if not on_close_was_called and not available_target_uis:
+ logger.debug(
+ f"None of the notification.target_uis {notification.target_uis} are available and on_close was not called yet."
+ " Keeping this transaction, such that it can be rebroadcastes later."
+ )
+ return False
+
+ if self.verbose_debug:
+ logger.debug(
+ f"Notification {notification.id} of user {notification.user_id} was_closed_in_target_uis = {notification.was_closed_in_target_uis}"
+ )
+ # it it was closed in all available_target_uis, then go ahead and delete the notification
+ return not (available_target_uis - notification.was_closed_in_target_uis)
+
+ def _internal_on_close(self, internal_notification, referenced_notification):
+ "calls on_close and deletes the notification afterwards"
+ if not referenced_notification:
+ return
+
+ ui_notification = self._find_target_ui(
+ internal_notification.data["target_ui"], referenced_notification.user_id
+ )
+ # perhaps the target_ui was not available and it was displayed in another ui_notification.
+ # However still call on_close of the original target_ui
+ if ui_notification.on_close:
+ ui_notification.on_close(
+ referenced_notification.id,
+ internal_notification.data["target_ui"],
+ )
+
+ referenced_notification.set_closed(internal_notification.data["target_ui"])
+ if self._notification_can_be_deleted(
+ referenced_notification, on_close_was_called=True
+ ):
+ self._delete_notification(referenced_notification)
+
+ def _treat_internal_message(self, internal_notification):
+ """
+ Notifications with the title='internal_notification' are not displayed to the user.
+ It allows calling the internal functions for:
+ - "on_close"
+ - "on_show"
+ - "set_target_ui_availability" (if not available, the notification is rebroadcasted)
+ """
+ if "internal_notification" not in internal_notification.target_uis:
+ return internal_notification
+ if self.verbose_debug:
+ logger.debug(f"_treat_internal_message {internal_notification}")
+
+ referenced_notification = self.get_notification_by_id(
+ internal_notification.data.get("id")
+ )
+
+ if internal_notification.title in INTERNAL_NOTIFICATION_TITLES:
+ method = getattr(self, f"_internal_{internal_notification.title}", None)
+ if method:
+ method(internal_notification, referenced_notification)
+ else:
+ logger.warning(
+ f"Could not call the method _internal_{internal_notification.title}"
+ )
+
+ def create_notification(self, title, user_id, **kwargs):
+ """
+ Creates a notification (which adds it to self.notifications) and also broadcasts it to ui_notifications.
+ kwargs are the optional arguments of Notification
+ """
+ if self.verbose_debug:
+ logger.debug(
+ f"Starting to create notification with title, **kwargs {title, kwargs}"
+ )
+
+ notification = Notification(
+ title,
+ self.get_default_target_ui_name(),
+ self.get_all_target_ui_names(),
+ user_id,
+ **kwargs,
+ )
+
+ # treat an internal (notification) message
+ if "internal_notification" in notification.target_uis:
+ # in case _treat_internal_message returns a notification, then proceed with that
+ return self._treat_internal_message(notification)
+
+ self.notifications.append(notification)
+ if self.verbose_debug:
+ logger.debug(f"Created notification {notification}")
+ return notification
+
+ def show(self, notification):
+ """
+ forwards the notification to ui_notifications, that are in notification['target_uis']
+
+ If a target_ui of notification['target_uis'] is not is_available, then try with the next target_ui
+ """
+ if notification.target_uis == ["internal_notification"]:
+ return
+
+ ui_notifications_of_user = self._get_ui_notifications_of_user(
+ notification.user_id
+ )
+ if self.verbose_debug:
+ logger.debug(f"ui_notifications_of_user {ui_notifications_of_user}")
+ broadcast_on_ui_notifications = [
+ ui_notification
+ for ui_notification in ui_notifications_of_user
+ if (ui_notification.name in notification.target_uis)
+ ]
+
+ for ui_notification in broadcast_on_ui_notifications:
+ logger.debug(
+ f"Showing notification {notification.id} on {ui_notification.name}"
+ )
+
+ notification_broadcasted = ui_notification.show(notification)
+
+ # if not possible, then try show it with a ui_notifications that is NOT already in notification['target_uis']
+ if not notification_broadcasted:
+ if self.verbose_debug:
+ logger.debug(
+ f"Trying with other ui_notifications to broadcast {notification}"
+ )
+ # I have to restrict the ui_notifications that are used as a backup to callable_from_any_session_required
+ # because it it not possible to call a flash notification from another thread (that failed doing a webapi notification)
+ for backup_ui_notification in self._get_ui_notifications_of_user(
+ notification.user_id, callable_from_any_session_required=True
+ ):
+ # if it is already broadcasted on this backup_ui_notification by default anyway, no need to do it twice
+ if backup_ui_notification in broadcast_on_ui_notifications:
+ continue
+ notification_broadcasted = backup_ui_notification.show(notification)
+ if self.verbose_debug:
+ logger.debug(
+ f"Rebroadcasted on {backup_ui_notification.name} of {notification} "
+ )
+ if notification_broadcasted:
+ break
+ # for some ui_notifications, the last_shown_date and was_closed_in_target_uis is set immediately.
+ # then the message can also be delted immediately
+ if self._notification_can_be_deleted(notification):
+ self._delete_notification(notification)
+
+ def flash(self, message: str, user_id, category: str = "message"):
+ "Creates and shows a flask flash message"
+
+ # translate categories
+ if category == "info":
+ category = "information"
+ if category == "warn":
+ category = "warning"
+
+ return self.create_and_show(
+ message, user_id, notification_type=category, target_uis={"flash"}
+ )
+
+ def create_and_show(self, title, user_id, **kwargs):
+ """Creates a notification (which adds it to self.notifications) and also broadcasts it to ui_notifications.
+
+ Args:
+ title (str): _description_
+ user_id (str): The user.username (or flask.current_user), to whom the notification should be sent
+ kwargs: are the optional arguments and will be passed to Notification()
+
+ Returns:
+ Notification(): _description_
+ """
+ notification = self.create_notification(title, user_id, **kwargs)
+ if notification:
+ self.show(notification)
+ return notification
diff --git a/src/cryptoadvance/specterext/notifications/notifications.py b/src/cryptoadvance/specterext/notifications/notifications.py
new file mode 100644
index 0000000000..985b1866de
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/notifications.py
@@ -0,0 +1,166 @@
+import logging
+
+logger = logging.getLogger(__name__)
+import datetime
+import hashlib
+
+
+class NotificationTypes:
+ debug = "debug"
+ information = "information"
+ warning = "warning"
+ error = "error"
+ exception = "exception"
+
+
+class Notification:
+ """
+ A Notification is a datastructure to store title, body, ...
+ The field names should be ideally identical to https://notifications.spec.whatwg.org/#api
+ Additional fields, like id, can be added
+ """
+
+ def __init__(
+ self,
+ title,
+ default_target_ui,
+ all_target_uis,
+ user_id,
+ target_uis="default", # "default" will be replaced by default_target_ui
+ notification_type=None,
+ body=None,
+ data=None,
+ image=None,
+ icon=None,
+ timeout=None,
+ date=None,
+ verbose_debug=False,
+ ):
+ self.title = str(title)
+ self.user_id = user_id
+ self.date = date if date else datetime.datetime.now()
+ self.last_shown_date = dict() # structure {'target_ui' : date}
+ self.was_closed_in_target_uis = set() # example: {'webapi', 'logging'}
+
+ if not target_uis:
+ target_uis = "default"
+ self.target_uis = (
+ {target_uis} if isinstance(target_uis, str) else set(target_uis)
+ )
+ self.substitute_special_target_uis(default_target_ui, all_target_uis)
+
+ # clean up invalid NotificationTypes
+ self.notification_type = (
+ notification_type if notification_type else NotificationTypes.information
+ )
+ if self.notification_type not in {
+ NotificationTypes.debug,
+ NotificationTypes.information,
+ NotificationTypes.warning,
+ NotificationTypes.error,
+ NotificationTypes.exception,
+ }:
+ self.notification_type = NotificationTypes.information
+
+ self.body = body
+ self.data = data
+ self.image = image
+ self.icon = icon
+ self.timeout = timeout # [ms]
+
+ self.verbose_debug = verbose_debug
+ # set id (dependent on (almost) all other properties, so must be set last)
+ self.id = None
+ self._set_id()
+
+ def __str__(self):
+ # .copy() is essential here, otherwise one actually deletes verbose_debug from the object
+ reduced_dict = self.__dict__.copy()
+ del reduced_dict["verbose_debug"]
+ return str(reduced_dict)
+
+ def _set_id(self):
+ # .copy() is essential here, otherwise one actually deletes id, verbose_debug from the object
+ reduced_dict = self.__dict__.copy()
+ del reduced_dict["id"]
+ del reduced_dict["verbose_debug"]
+ self.id = hashlib.sha256(str(reduced_dict).encode()).hexdigest()
+
+ def set_shown(self, target_ui, date=None):
+ self.last_shown_date[target_ui] = date if date else datetime.datetime.now()
+ if self.verbose_debug:
+ logger.debug(f"set_notification_shown {self}")
+
+ def set_closed(self, target_ui):
+ self.was_closed_in_target_uis.add(target_ui)
+ if self.verbose_debug:
+ logger.debug(f"set_closed {self}")
+
+ def substitute_special_target_uis(self, default_target_ui, all_target_uis):
+ """
+ It replaces the valid target_uis:
+ - "default" by the default_target_ui
+ - "all" by all_target_uis
+
+ Args:
+ default_target_ui (_type_): _description_
+ all_target_uis (_type_): _description_
+ """
+ # clean up the notification['target_uis']
+ if "internal_notification" in self.target_uis:
+ # no cleanup for internal_notification
+ return
+
+ if "all" in self.target_uis:
+ target_uis = all_target_uis
+
+ # replace the "default" target_ui with the 0.th ui_notifications
+ if "default" in self.target_uis:
+ self.target_uis.remove("default")
+ if default_target_ui:
+ self.target_uis.add(default_target_ui)
+
+ def to_js_notification(self):
+ """
+ Returns the following data structure:
+ {
+ "title": title,
+ "id": id,
+ "notification_type": notification_type,
+ "timeout": timeout,
+ "options": {
+ ....
+ },
+ }
+
+ The "options" dict is contains self.__dict__.items(), unless they are included in the main dict.
+ The "options" dict could contain all fields as https://notifications.spec.whatwg.org/#api ,
+ however not even for target_uis = 'web_api' are all fields supported
+
+ The following circular call , will preserve only the (optional) arguments supported by Notification.__init__()
+ (python) js_notification = notification.to_js_notification()
+ --> (javascript) createNotification(js_notification['title'], js_notification['options']) --> title, options
+ --> (python) notification = Notification(title, ..., **options)
+ """
+ js_notification = {
+ "title": self.title,
+ "id": self.id,
+ "notification_type": self.notification_type,
+ "timeout": self.timeout,
+ "options": {},
+ }
+
+ for key, value in self.__dict__.items():
+ if key in js_notification or value is None:
+ continue
+ js_notification["options"][key] = value
+
+ return js_notification
+
+ def to_text(self):
+ s = self.title
+ if self.body:
+ s += f"\n{self.body}"
+ if self.data:
+ s += f"\nData: {self.data}"
+ return s
diff --git a/src/cryptoadvance/specterext/notifications/service.py b/src/cryptoadvance/specterext/notifications/service.py
new file mode 100644
index 0000000000..8a30e4507c
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/service.py
@@ -0,0 +1,88 @@
+import logging
+
+from cryptoadvance.specter.services.service import (
+ Service,
+ devstatus_alpha,
+ devstatus_prod,
+ devstatus_beta,
+ ServiceOptionality,
+)
+
+# A SpecterError can be raised and will be shown to the user as a red banner
+from cryptoadvance.specter.specter_error import SpecterError
+from flask import render_template
+from cryptoadvance.specter.wallet import Wallet
+from flask_apscheduler import APScheduler
+from .notification_manager import NotificationManager
+from flask_login import current_user, AnonymousUserMixin
+
+logger = logging.getLogger(__name__)
+
+
+class NotificationsService(Service):
+ id = "notifications"
+ name = "Notifications Service"
+ icon = "notifications/img/notification.png"
+ logo = "notifications/img/notification.png"
+ desc = "Where a notifications grows bigger."
+ has_blueprint = True
+ blueprint_module = "cryptoadvance.specterext.notifications.controller"
+ devstatus = devstatus_alpha
+ isolated_client = False
+
+ # TODO: As more Services are integrated, we'll want more robust categorization and sorting logic
+ sort_priority = 2
+ optionality = ServiceOptionality.opt_in
+ visible_in_sidebar = False
+
+ def callback_after_serverpy_init_app(self, scheduler: APScheduler):
+ """
+ Args:
+ scheduler (APScheduler): _description_
+ app (_type_): While in other services app is optional, it is required here. python will automatically map the kwargs['app'] to this app
+ """
+ self.scheduler = scheduler
+
+ notifications_endpoint_url = "/".join(
+ [scheduler.app.config["EXT_URL_PREFIX"], self.id]
+ )
+ # remove leading /
+ if notifications_endpoint_url.startswith("/"):
+ notifications_endpoint_url = notifications_endpoint_url[1:]
+
+ self.notification_manager = NotificationManager(
+ scheduler.app.config.get("HOST", "127.0.0.1"),
+ scheduler.app.config["PORT"],
+ scheduler.app.config["CERT"],
+ scheduler.app.config["KEY"],
+ enable_websockets=scheduler.app.config[
+ "SPECTER_NOTIFICATIONS_WEBSOCKETS_ENABLED"
+ ],
+ notifications_endpoint_url=notifications_endpoint_url,
+ )
+ for user in scheduler.app.specter.user_manager.users:
+ self.notification_manager.register_user_ui_notifications(user.id)
+
+ # There might be other callbacks you're interested in. Check the callbacks.py in the specter-desktop source.
+ # if you are, create a method here which is "callback_" + callback_id
+
+ def callback_flash(self, message: str, category: str = "message"):
+ username = (
+ current_user if not isinstance(current_user, AnonymousUserMixin) else None
+ )
+ return self.notification_manager.flash(message, username, category)
+
+ def callback_create_and_show_notification(self, title, **kwargs):
+ username = (
+ current_user if not isinstance(current_user, AnonymousUserMixin) else None
+ )
+
+ return self.notification_manager.create_and_show(title, username, **kwargs)
+
+ def callback_cleanup_on_exit(self, signum=0, frame=0):
+ logger.debug(f"callback_cleanup_on_exit called of {self.__class__.__name__}")
+ self.notification_manager.quit()
+
+ @classmethod
+ def inject_in_basejinja_body_top(cls):
+ return render_template("notifications/html_inject_in_basejinja.jinja")
diff --git a/src/cryptoadvance/specterext/notifications/static/notifications/css/styles.css b/src/cryptoadvance/specterext/notifications/static/notifications/css/styles.css
new file mode 100644
index 0000000000..45ea5cc98e
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/static/notifications/css/styles.css
@@ -0,0 +1,105 @@
+/* This is the place to put all your styles */
+
+
+html, body{
+ /* msgbox colors*/
+ --cmap-msgbox-debug: #000000;
+ --cmap-msgbox-information: #4a90e2;
+ --cmap-msgbox-warning: #FF9A00;
+ --cmap-msgbox-error: #951E2D;
+ --cmap-msgbox-exception: #951E2D;
+}
+
+
+/*****************************/
+/* MESSAGE BOX RELATED CLASS */
+/* (START) */
+/*****************************/
+.msgbox-area {
+ font-size: inherit;
+ max-height: 100%;
+ position: fixed;
+ bottom: 15px;
+ left: 20px;
+ right: 20px;
+ z-index: 10;
+}
+.msgbox-box {
+ font-size: inherit;
+ color: #fff;
+ background-color: var(--cmap-msgbox-information);
+ border: 2px var(--cmap-border) solid;
+ margin: 0 0 1rem;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ border-radius: 12px;
+ box-shadow: 0 10px 15px rgba(0, 0, 0, 1);
+ backdrop-filter: blur(4px);
+ transition: opacity 256ms ease-in;
+}
+.msgbox-box.msgbox-box-hide {
+ opacity: 0;
+}
+.msgbox-box:last-child {
+ margin: 0;
+}
+.msgbox-content {
+ font-size: inherit;
+ padding-top: 10px;
+ padding-left: 10px;
+ padding-right: 10px;
+ display: flex;
+}
+.msgbox-img {
+ margin-right: 5px;
+}
+.msgbox-text {
+ width: 100%;
+}
+.msgbox-title {
+ width: 100%;
+}
+.msgbox-wrapper {
+ width: 100%;
+}
+.msgbox-command {
+ padding-right: 10px;
+ padding-bottom: 10px;
+ display: flex;
+}
+.msgbox-close {
+ color: #fff;
+ font-size: inherit;
+ text-decoration: none;
+ margin: 0 10px;
+ flex-grow: 0;
+ flex-shrink: 0;
+ position: relative;
+ transition: color 64ms ease-out, text-shadow 64ms ease-out;
+}
+.msgbox-close:hover {
+ color: #efefef;
+ text-shadow: 0 0 4px #fff;
+}
+.msgbox-close:first-child {
+ margin-left: auto;
+}
+.msgbox-close:last-child {
+ margin-right: 0;
+}
+@media (min-width: 481px) and (max-width: 767px) {
+ .msgbox-area {
+ left: 80px;
+ right: 80px;
+ }
+}
+@media (min-width: 768px) {
+ .msgbox-area {
+ width: 480px;
+ height: 0;
+ top: 15px;
+ left: auto;
+ right: 15px;
+ }
+}
diff --git a/src/cryptoadvance/specterext/notifications/static/notifications/img/notification.png b/src/cryptoadvance/specterext/notifications/static/notifications/img/notification.png
new file mode 100644
index 0000000000..4bd721aae3
Binary files /dev/null and b/src/cryptoadvance/specterext/notifications/static/notifications/img/notification.png differ
diff --git a/src/cryptoadvance/specterext/notifications/templates/notifications/base.jinja b/src/cryptoadvance/specterext/notifications/templates/notifications/base.jinja
new file mode 100644
index 0000000000..59d29acd73
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/templates/notifications/base.jinja
@@ -0,0 +1,4 @@
+{% extends "base.jinja" %}
+{% block head %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/cryptoadvance/specterext/notifications/templates/notifications/components/notifications_menu.jinja b/src/cryptoadvance/specterext/notifications/templates/notifications/components/notifications_menu.jinja
new file mode 100644
index 0000000000..79c5a66e61
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/templates/notifications/components/notifications_menu.jinja
@@ -0,0 +1,16 @@
+{% from 'components/menu_item.jinja' import menu_item %}
+
+{#
+ notifications_menu - Tabs menu to navigate between the notifications screens.
+ Parameters:
+ - active_menuitem: Current active tab. Options: 'general', 'settings', ...
+ #}
+{% macro notifications_menu(active_menuitem) -%}
+
+{%- endmacro %}
\ No newline at end of file
diff --git a/src/cryptoadvance/specterext/notifications/templates/notifications/components/notifications_tab.jinja b/src/cryptoadvance/specterext/notifications/templates/notifications/components/notifications_tab.jinja
new file mode 100644
index 0000000000..d308d7ba69
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/templates/notifications/components/notifications_tab.jinja
@@ -0,0 +1,8 @@
+{% extends "notifications/base.jinja" %}
+{% block main %}
+
+ {% from 'notifications/components/notifications_menu.jinja' import notifications_menu with context %}
+ {{ notifications_menu(tab) }}
+ {% block content %}
+ {% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/src/cryptoadvance/specterext/notifications/templates/notifications/html_inject_in_basejinja.jinja b/src/cryptoadvance/specterext/notifications/templates/notifications/html_inject_in_basejinja.jinja
new file mode 100644
index 0000000000..d145485479
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/templates/notifications/html_inject_in_basejinja.jinja
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/src/cryptoadvance/specterext/notifications/templates/notifications/index.jinja b/src/cryptoadvance/specterext/notifications/templates/notifications/index.jinja
new file mode 100644
index 0000000000..0a7a24d82c
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/templates/notifications/index.jinja
@@ -0,0 +1,88 @@
+{% extends "notifications/components/notifications_tab.jinja" %}
+{% block title %}Settings{% endblock %}
+{% set tab = 'index' %}
+{% block content %}
+
+
+
Advanced notifications.
+ This service provides advanced notifications, like javascript popups and push-style notifications.
+{% endblock %}
+
+
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/src/cryptoadvance/specterext/notifications/templates/notifications/message_box.js b/src/cryptoadvance/specterext/notifications/templates/notifications/message_box.js
new file mode 100644
index 0000000000..edda3dc5ad
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/templates/notifications/message_box.js
@@ -0,0 +1,161 @@
+// see https://coder-question.com/cq-blog/525543 and https://dev.to/takaneichinose/responsive-message-box-with-javascript-class-25ie
+class MessageBox {
+ constructor(option) {
+ this.option = option;
+
+ this.msgBoxArea = document.querySelector("#msgbox-area");
+
+ if (this.msgBoxArea === null) {
+ this.msgBoxArea = document.createElement("DIV");
+ this.msgBoxArea.setAttribute("id", "msgbox-area");
+ this.msgBoxArea.classList.add("msgbox-area");
+
+ document.body.appendChild(this.msgBoxArea);
+ }
+ }
+
+ show(title) {
+ if (title === "" || title === undefined || title === null) {
+ throw "title is empty or not defined.";
+ }
+
+
+ const msgboxBox = document.createElement("DIV");
+ const msgboxImage = document.createElement("DIV");
+ const msgboxContent = document.createElement("DIV");
+ const msgboxTextWrapper = document.createElement("DIV");
+ const msgboxTitle = document.createElement("DIV");
+ const msgboxText = document.createElement("DIV");
+ const msgboxCommand = document.createElement("DIV");
+ const msgboxClose = document.createElement("A");
+
+
+ if (this.option.image != null){
+ msgboxImage.classList.add("msgbox-img");
+ var img = document.createElement("img");
+ img.width = 100;
+ img.src = this.option.image;
+ msgboxImage.width = '100px';
+ msgboxImage.appendChild(img);
+ msgboxContent.appendChild(msgboxImage);
+ }
+
+ // Content area of the message box
+ msgboxContent.classList.add("msgbox-content");
+
+ // title and text div
+ msgboxTextWrapper.classList.add("msgbox-wrapper");
+ msgboxContent.appendChild(msgboxTextWrapper);
+
+ // title
+ msgboxTitle.classList.add("msgbox-title");
+ msgboxTitle.setAttribute('style', 'font-weight: bold; margin-bottom:8px;');
+ msgboxTitle.innerText = title;
+ msgboxTextWrapper.appendChild(msgboxTitle);
+
+ // Text area
+ if (this.option.body != null){
+ msgboxText.classList.add("msgbox-text");
+ msgboxText.innerText = this.option.body;
+ msgboxTextWrapper.appendChild(msgboxText);
+ }
+
+ // Command box or the button container
+ msgboxCommand.classList.add("msgbox-command");
+
+ // Close button of the message box
+ msgboxClose.classList.add("msgbox-close");
+ msgboxClose.setAttribute("href", "#");
+ if (this.option.closeLabel === undefined || this.option.closeLabel === null) {
+ this.option.closeLabel = "Close";
+ }
+ msgboxClose.innerText = this.option.closeLabel;
+
+ // Container of the Message Box element
+ msgboxBox.classList.add("msgbox-box");
+ if (this.option.backgroundColor){
+ msgboxBox.style.backgroundColor = this.option.backgroundColor;
+ }
+ msgboxBox.setAttribute("timeout", this.option.timeout);
+ msgboxBox.appendChild(msgboxContent);
+
+ if (this.option.hideCloseButton === false
+ || this.option.hideCloseButton === undefined) {
+ // If the hideCloseButton flag is false, or if it is undefined
+
+ // Append the close button to the container
+ msgboxCommand.appendChild(msgboxClose);
+ msgboxBox.appendChild(msgboxCommand);
+ }
+
+ this.msgBoxArea.appendChild(msgboxBox);
+
+ msgboxClose.onclick = (evt) => {
+ evt.preventDefault();
+
+ if (msgboxBox.classList.contains("msgbox-box-hide")) {
+ return;
+ }
+
+ clearTimeout(this.msgboxTimeout);
+
+ this.msgboxTimeout = null;
+
+ this.hide(msgboxBox, this.option.onClose);
+ };
+
+ if (this.option.timeout > 0) {
+ this.msgboxTimeout = setTimeout(() => {
+ this.hide(msgboxBox, this.option.onClose);
+ }, this.option.timeout);
+ }
+ }
+
+ hideMessageBox(msgboxBox) {
+ return new Promise(resolve => {
+ msgboxBox.ontransitionend = () => {
+ resolve();
+ };
+ });
+ }
+
+ async hide(msgboxBox, callback) {
+ if (msgboxBox !== null) {
+ // If the Message Box is not yet closed
+
+ msgboxBox.classList.add("msgbox-box-hide");
+ }
+
+ await this.hideMessageBox(msgboxBox);
+
+ this.msgBoxArea.removeChild(msgboxBox);
+
+ clearTimeout(this.msgboxTimeout);
+
+ if (typeof callback === "function") {
+ // If the callback parameter is a function
+
+ callback();
+ }
+ }
+}
+
+const msgboxShowMessage = document.querySelector("#msgboxShowMessage");
+const msgboxHiddenClose = document.querySelector("#msgboxHiddenClose");
+
+// Creation of Message Box class, and the sample usage
+/*
+const msgbox = new MessageBox({
+ timeout: 10000,
+ hideCloseButton: false
+});
+const msgboxPersistent = new MessageBox({
+ timeout: 0
+});
+const msgboxNoClose = new MessageBox({
+ timeout: 5000,
+ hideCloseButton: true
+});
+
+
+*/
\ No newline at end of file
diff --git a/src/cryptoadvance/specterext/notifications/templates/notifications/notifications.js b/src/cryptoadvance/specterext/notifications/templates/notifications/notifications.js
new file mode 100644
index 0000000000..71d3e07cc8
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/templates/notifications/notifications.js
@@ -0,0 +1,396 @@
+{% include "notifications/message_box.js" %}
+
+
+var userToken = null;
+
+/**
+ * creating a notification from JS
+ *
+ * Example:
+ * createNotification('this is the title', {target_uis:['js_message_box', 'webapi'], body:'body line 1\nline 2', image:'/static/img/ghost_3d.png', timeout:3000})
+ *
+ * Options dictionary can include:
+ target_uis: list of target_uis, e.g., ['js_message_box', 'webapi'] // "default" will be replaced by default_target_ui
+ notification_type: string,e.g. "information", all possibilities are: "debug", "information", "warning", "error", "exception"
+ body: string
+ data: any kind of data, which will be stored in the notification
+ image: image url
+ icon: currently only used for web_api (see https://notifications.spec.whatwg.org/#api ) , and not even on all platforms there
+ timeout: [ms] after which the notification will close automatically.
+ */
+ async function createNotification(title, options){
+ if (!websocket){return}
+ if (websocket.readyState === WebSocket.OPEN) {
+ websocket.send(JSON.stringify( {'user_token': userToken, 'title':title, 'options': options}));
+ } else {
+ // If the socket is not open yet, retry in 1 s
+ setTimeout(createNotification, 1000, title, options);
+ }
+}
+
+
+
+function onClose(id, targetUi){
+ //console.log('closed message')
+ createNotification('on_close', {'target_uis':['internal_notification'], 'data':{'id':id, 'target_ui':targetUi}});
+}
+
+
+function onShow(id, targetUi, success=true){
+ //console.log('closed message')
+ createNotification('on_show', {'target_uis':['internal_notification'], 'data':{'id':id, 'target_ui':targetUi, 'success':success}});
+}
+
+
+function setTargetUiAvailability(targetUi, isAvailable, id=null) {
+ createNotification('set_target_ui_availability', {'target_uis':['internal_notification'], 'data':{'id':id, 'target_ui':targetUi, 'is_available':isAvailable}});
+}
+
+
+
+
+var webapiHasPermission = false; //global variable
+/**
+ * This function just checks if Notification_API is granted and writes the result
+ * into the global variable webapiHasPermission.
+ * webapiHasPermission has 3 states: true, false, "default"
+ * true --> permission was given
+ * false --> permission was denied
+ * "default" --> no decision was made by the user
+ *
+ *
+ * This function and the global variable is useful because then changes in
+ * Notification.permission can be detected.
+ *
+ * @returns webapiHasPermission
+ */
+ function checkWebapiHasPermission(){
+ if (!("Notification" in window)) {
+ return false
+ }
+ // Let's check whether notification permissions have already been granted
+ else if (Notification.permission === "granted") {
+ webapiHasPermission = true;
+ } else if (Notification.permission === "denied") {
+ webapiHasPermission = false;
+ } else if (Notification.permission === "default") {
+ // the user has not chosen anything, and the permission could be requested
+ webapiHasPermission = "default";
+ }
+
+ return webapiHasPermission
+}
+
+
+/**
+ * Checks and if necessary requests the Notification_API permission
+ * @param {*} fGranted A function that is called if fGranted == true
+ * @param {*} fDenied A function that is called if fGranted == false
+ * @param {*} fDefault A function that is called if fGranted == "default"
+ * @param {*} retries_if_permission_default : How often should it request Notification_API permission?
+ * @param {*} retry_time_distance : After how many milliseconds should it ask again for permission?
+ * @returns webapiHasPermission
+ */
+function checkAndRequestWebapiPermission(fGranted, fDenied, fDefault, retries_if_permission_default=5, retry_time_distance=2000){
+ if (!("Notification" in window)) {
+ return false
+ }
+ // console.log(`checkAndRequestWebapiPermission ${retries_if_permission_default}`);
+ checkWebapiHasPermission()
+
+ if (webapiHasPermission == true) {
+ fGranted();
+ } else if (webapiHasPermission == false) {
+ fDenied();
+ } else // if the status is undecided, then request permission
+ if (webapiHasPermission == "default") {
+ Notification.requestPermission().then(function (permission) {
+ checkWebapiHasPermission();
+
+
+ if (webapiHasPermission == true) {
+ fGranted();
+ } else if (webapiHasPermission == false) {
+ fDenied();
+ } else if (webapiHasPermission == "default")
+ // if the status is still undecided, then request permission after retry_time_distance (by recursion)
+ if (webapiHasPermission == "default") {
+ // retry request permission through a recursive call:
+ if (retries_if_permission_default>0){
+ // create recursion loop, that breaks if user grants or blocks the notification
+ // console.log(`Start recursion ${retries_if_permission_default-1}`);
+ setTimeout(checkAndRequestWebapiPermission, retry_time_distance, fGranted, fDenied, fDefault, retries_if_permission_default-1, retry_time_distance);
+ } else
+ // if it reached the end of the recursion without any user interaction, then
+ // deactivate the ui_notification
+ if (retries_if_permission_default==0){
+ fDefault();
+ }
+ }
+ });
+ }
+ return webapiHasPermission
+}
+
+
+
+
+
+
+
+/**
+ * Created a Notifications_API notification similar to a push-notification.
+ * It also asks for permission, if possible.
+ * @param {*} jsNotification
+ *
+ *
+ In prinicple the jsNotification['options'] in can include all items listed in https://notifications.spec.whatwg.org/#api .
+ they are however not all used on all platforms, see https://developer.mozilla.org/en-US/docs/Web/API/notification#browser_compatibility
+
+ *
+ */
+function webapiNotification(jsNotification) {
+ // console.log('webapiNotification')
+ // https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
+ var title = jsNotification['title'];
+ var options = jsNotification['options'];
+
+ function createWebapiNotification(){
+ let notification = new Notification(title, options);
+ // see https://flaviocopes.com/notifications-api/#add-an-image
+ // see https://levelup.gitconnected.com/use-the-javascript-notification-api-to-display-native-popups-43f6227b9980
+ // see https://www.htmlgoodies.com/html5/creating-your-own-notifications-with-the-html5-notifications-api/
+ notification.onclick = (() => {
+ // not implemented yet
+ });
+ notification.onclose = (() => {
+ onClose(jsNotification['id'], 'webapi');
+ });
+ notification.onshow = (() => {
+ // do something
+ onShow(jsNotification['id'], 'webapi');
+ });
+
+ function closeNotification(){notification.close()}
+ if (jsNotification['timeout']>0) {
+ setTimeout(closeNotification, jsNotification['timeout']);
+ }
+ };
+
+
+ function fGranted(){
+ createWebapiNotification();
+ }
+ function fDenied(){
+ console.debug(`Notification.requestPermission() = ${webapiHasPermission}`);
+ setTargetUiAvailability('webapi', false, jsNotification['id']);
+ }
+ function fDefault(){
+ console.debug(`Notification.requestPermission() = ${webapiHasPermission}`);
+ setTargetUiAvailability('webapi', false, jsNotification['id']);
+ }
+
+ checkAndRequestWebapiPermission(fGranted, fDenied, fDefault);
+};
+
+
+
+
+/**
+ * Creates a javascript popup message.
+ *
+ * @param {*} jsNotification
+ *
+ * Used options:
+ - notification_type
+ - body
+ - image
+ - timeout
+ */
+function jsMessageBox(jsNotification){
+ function thisNotificationClose(){
+ onClose(jsNotification['id'], 'js_message_box')
+ }
+
+ var backgroundColor = null;
+ if (jsNotification['notification_type']){
+ backgroundColor = `var(--cmap-msgbox-${jsNotification['notification_type']})`
+ }
+
+ msgbox = new MessageBox({
+ body: jsNotification['options']['body'],
+ image: jsNotification['options']['image'],
+ timeout: jsNotification['timeout'],
+ closeLabel: 'Close',
+ 'onClose': thisNotificationClose,
+ hideCloseButton: false,
+ backgroundColor: backgroundColor,
+ });
+ msgbox.show(jsNotification['title']);
+
+ onShow(jsNotification['id'], 'js_message_box');
+}
+
+
+/**
+ * Returns the full jsNotification in the javascript console
+ * @param {*} jsNotification
+ */
+function js_console(jsNotification){
+ if (jsNotification['notification_type'] == 'error'){
+ console.error(jsNotification);
+ } else if (jsNotification['notification_type'] == 'exception'){
+ console.error(jsNotification);
+ } else if (jsNotification['notification_type'] == 'warning'){
+ console.warn(jsNotification);
+ } else {
+ console.log(jsNotification);
+ }
+ onShow(jsNotification['id'], 'js_console');
+ onClose(jsNotification['id'], 'js_console');
+}
+
+
+/**
+ * Shows the jsNotification in the targetUi
+ * @param {*} targetUi
+ * @param {*} jsNotification
+ *
+ *
+ *
+ jsNotification is the data structure usually created in python by notification.to_js_notification()
+ An example jsNotification looks like:
+ {
+ "title": title,
+ "id": id,
+ "notification_type": notification_type,
+ "timeout": timeout,
+ "options": {
+ body = "",
+ image = None,
+ },
+ }
+
+ In prinicple the options dict can include all items listed in https://notifications.spec.whatwg.org/#api .
+ - Only some of the are used in js_message_box (see jsMessageBox),
+ and even in web_api they are not all used on all platforms, see https://developer.mozilla.org/en-US/docs/Web/API/notification#browser_compatibility
+ - js_logging will display the entire jsNotification data structure.
+
+ */
+async function show_notification(targetUi, jsNotification){
+ if (targetUi == 'js_message_box'){
+ jsMessageBox(jsNotification);
+ } else if (targetUi == 'webapi'){
+ webapiNotification(jsNotification);
+ } else if (targetUi == 'js_console'){
+ js_console(jsNotification);
+ }
+
+}
+
+
+
+/**
+ * Check the status of Notification_API permission call
+ * setTargetUiAvailability to activate or deactivate the WebAPINotifications
+ * This allows to activate the e.g. webapi again, if the user first denied it, and then allowed it again.
+ */
+async function sendUpdatedWebapiPermission(){
+ var beforeWebapiHasPermission = webapiHasPermission;
+ var newWebapiHasPermission = checkWebapiHasPermission();
+
+ // if something has changed
+ if (beforeWebapiHasPermission != newWebapiHasPermission){
+
+
+ // case: webapi was deactivated, but now is "granted"
+ if ((newWebapiHasPermission == 'default')|| (newWebapiHasPermission == true)){
+ setTargetUiAvailability('webapi', true);
+ console.log('Activating webapi');
+ }
+ // case: webapi was active, but now is "denied"
+ else if (newWebapiHasPermission == false) {
+ setTargetUiAvailability('webapi', false);
+ console.log('Deactivating webapi');
+ }
+
+ }
+
+}
+
+
+
+
+var websocket = null;
+
+function connectWebsocket() {
+ // get necessary info for the opening of the websocket
+ send_request("{{ url_for('notifications_endpoint.get_websockets_info') }}", 'GET', "{{ csrf_token() }}").then(function (websocketsInfo) {
+ // Create the websocket
+ var url = "{{ url_for('notifications_endpoint.websocket') }}";
+ console.log(url)
+ userToken = websocketsInfo['user_token'];
+ websocket = new WebSocket(url);
+
+ // Authenticate and add listeners when the websocket connection is open
+ websocket.onopen = function(e) {
+ // Sends a message to the server, that does nothing, but enables the server to register the user_token to the websocket_client
+ websocket.send(JSON.stringify({'user_token': userToken, "title": 'IGNORE_NOTIFICATION_TITLE'}));
+
+ // set the api_permissions
+ sendUpdatedWebapiPermission()
+ setInterval(sendUpdatedWebapiPermission, 3000);
+
+ console.debug(`Websocket connection to ${url} is open`);
+ };
+
+ websocket.onmessage = function(message) {
+ var jsNotification = null;
+ try{
+ jsNotification = JSON.parse(message.data);
+ } catch(e) {
+ console.warn(`Json could not be parsed: ` + e.message)
+ return
+ }
+
+
+ var targetUis = jsNotification["options"]['target_uis'];
+ for (let i in targetUis) {
+ show_notification(targetUis[i], jsNotification);
+ }
+ };
+
+ websocket.onclose = function(e) {
+ console.debug('Websocket was closed. Reconnect will be attempted in 10 seconds.', e.reason);
+ setTimeout(function() {
+ connectWebsocket();
+ }, 10000);
+ };
+
+ websocket.onerror = function(err) {
+ console.error('Socket encountered error: ', err.message);
+ // websocket.close();
+ };
+
+
+ })
+ .catch(function (error) {
+ console.error('Connection to the websocket failed. Reconnect will be attempted in 10 seconds.', error.reason);
+ setTimeout(function() {
+ connectWebsocket();
+ }, 10000);
+ });
+
+}
+
+
+
+
+/**
+ * If a user is logged in then regularly check if the webapi notification permission was changed
+ */
+if ('{{ current_user.username }}'){
+ connectWebsocket()
+}else{
+ // no user logged in
+}
\ No newline at end of file
diff --git a/src/cryptoadvance/specterext/notifications/templates/notifications/settings.jinja b/src/cryptoadvance/specterext/notifications/templates/notifications/settings.jinja
new file mode 100644
index 0000000000..4ea4bbe83a
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/templates/notifications/settings.jinja
@@ -0,0 +1,98 @@
+{% extends "notifications/components/notifications_tab.jinja" %}
+{% block title %}Settings{% endblock %}
+{% set tab = 'settings_get' %}
+{% block content %}
+
+
+
+
+
{{ _("Configure your extension") }}
+
+
+
+ {{ _("You can disable the notifications.") }}
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/cryptoadvance/specterext/notifications/ui_notifications.py b/src/cryptoadvance/specterext/notifications/ui_notifications.py
new file mode 100644
index 0000000000..9c5732b817
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/ui_notifications.py
@@ -0,0 +1,234 @@
+import logging
+
+logger = logging.getLogger(__name__)
+
+from .notifications import NotificationTypes
+from flask import flash
+
+
+class BaseUINotifications:
+ "A base class defining functions that every user faced UI Notification display system should have"
+
+ def __init__(self, on_close=None, on_show=None):
+ self.compatible_notification_types = {
+ NotificationTypes.debug,
+ NotificationTypes.information,
+ NotificationTypes.warning,
+ NotificationTypes.error,
+ NotificationTypes.exception,
+ }
+ self.name = "base"
+ self.is_available = True
+ self.user_id = None
+ self.callable_from_any_session = True
+ self.on_close = on_close
+ self.on_show = on_show
+
+ def show(self, notification):
+ pass
+
+ def __repr__(self):
+ return str({"class name": self.__class__.__name__, "attributes": self.__dict__})
+
+
+class PrintNotifications(BaseUINotifications):
+ """
+ Notifications are shown in print()
+
+ Callback functions are:
+ - on_close(notification_id, target_ui), should be assigned to delete the message (called immediately after showing)
+ - on_show(notification_id, target_ui) (called immediately after showing)
+ """
+
+ def __init__(self, on_close=None, on_show=None):
+ super().__init__(on_close=on_close, on_show=on_show)
+ self.name = "print"
+
+ def show(self, notification):
+ if (
+ not self.is_available
+ or notification.notification_type not in self.compatible_notification_types
+ ):
+ return
+ print(notification)
+
+ notification.set_shown(self.name)
+ if self.on_show:
+ self.on_show(notification.id, self.name)
+ notification.set_closed(self.name)
+ if self.on_close:
+ self.on_close(notification.id, self.name)
+ return True # successfully broadcasted
+
+
+class LoggingNotifications(BaseUINotifications):
+ """
+ Notifications are shown in logger.info()
+
+ Callback functions are:
+ - on_close(notification_id, target_ui), should be assigned to delete the message (called immediately after showing)
+ - on_show(notification_id, target_ui) (called immediately after showing)
+ """
+
+ def __init__(self, on_close=None, on_show=None):
+ super().__init__(on_close=on_close, on_show=on_show)
+ self.name = "logging"
+
+ def show(self, notification):
+ if (
+ not self.is_available
+ or notification.notification_type not in self.compatible_notification_types
+ ):
+ return
+
+ logger_func = logger.info
+ if notification.notification_type == NotificationTypes.debug:
+ logger_func = logger.debug
+ elif notification.notification_type == NotificationTypes.information:
+ logger_func = logger.info
+ elif notification.notification_type == NotificationTypes.warning:
+ logger_func = logger.warning
+ elif notification.notification_type == NotificationTypes.error:
+ logger_func = logger.error
+ elif notification.notification_type == NotificationTypes.exception:
+ logger_func = logger.exception
+
+ logger_func(
+ notification.to_text(),
+ exc_info=notification.notification_type
+ in {NotificationTypes.error, NotificationTypes.exception},
+ )
+ notification.set_shown(self.name)
+ if self.on_show:
+ self.on_show(notification.id, self.name)
+ notification.set_closed(self.name)
+ if self.on_close:
+ self.on_close(notification.id, self.name)
+ return True # successfully broadcasted
+
+
+class FlashNotifications(BaseUINotifications):
+ """
+ Flask flash notifications. They only appears after render_template is called.
+
+ Flask flash are dependent on the current flask.session. This prevents flash and with it FlashNotifications.show
+ from showing up in the current session, if called from outside the current session.
+
+ Example what will work (because of the same flask.session):
+ - and endpoint is called triggering flash or FlashNotifications.show
+
+ Example what will NOT work:
+ - flash or FlashNotifications.show is called from via a websocket connection, e.g.,
+ createNotification('flash title', {target_uis:'flash'})
+ The websocket connection does not have access to the current flask.session.
+
+ Callback functions are:
+ - on_close(notification_id, target_ui), should be assigned to delete the message (called immediately after showing)
+ - on_show(notification_id, target_ui) (called immediately after showing)
+ """
+
+ def __init__(self, on_close=None, on_show=None):
+ super().__init__(on_close=on_close, on_show=on_show)
+ self.compatible_notification_types = {
+ NotificationTypes.information,
+ NotificationTypes.warning,
+ NotificationTypes.error,
+ NotificationTypes.exception,
+ }
+ self.name = "flash"
+
+ # Flash notifications will not be used as a fallback UINotification,
+ # because they are not callable_from_any_session.
+ self.callable_from_any_session = False
+
+ def show(self, notification):
+ if (
+ not self.is_available
+ or notification.notification_type not in self.compatible_notification_types
+ ):
+ return
+ flash(
+ notification.to_text(),
+ notification.notification_type,
+ )
+ notification.set_shown(self.name)
+ if self.on_show:
+ self.on_show(notification.id, self.name)
+ notification.set_closed(self.name)
+ if self.on_close:
+ self.on_close(notification.id, self.name)
+ return True # successfully broadcasted
+
+
+class JSConsoleNotifications(BaseUINotifications):
+ """
+ Shows the notifications in the javascript console. The logic is mostly in notifications.js
+
+ Callback functions are:
+ - on_close(notification_id, target_ui), should be assigned to delete the message
+ - on_show(notification_id, target_ui)
+ """
+
+ def __init__(self, user_id, websockets_client, on_close=None, on_show=None):
+ super().__init__(on_close=on_close, on_show=on_show)
+ if not websockets_client:
+ raise Exception("websockets_client not set")
+ self.name = "js_console"
+ self.user_id = user_id
+ self.websockets_client = websockets_client
+
+ def show(self, notification):
+ if (
+ not self.is_available
+ or notification.notification_type not in self.compatible_notification_types
+ ):
+ return
+
+ # convert to json object and set the target_ui as only self.name.
+ # The Notification manager handles sending to other target_uis
+ js_notification = notification.to_js_notification()
+ js_notification["options"]["target_uis"] = [self.name]
+ if not self.websockets_client:
+ logger.info(
+ f"{self.__class__.__name__}.websockets_client of user {self.user_id} returned {self.websockets_client} --> Setting is_available = false. "
+ )
+ self.is_available = False
+ return False
+ self.websockets_client.send(js_notification)
+ return True # successfully broadcasted
+
+
+class JSNotifications(JSConsoleNotifications):
+ """
+ A javascript message box. The logic is mostly in notifications.js
+
+ Callback functions are:
+ - on_close(notification_id, target_ui), should be assigned to delete the message
+ - on_show(notification_id, target_ui)
+ """
+
+ def __init__(self, user_id, websockets_client, on_close=None, on_show=None):
+ super().__init__(user_id, websockets_client, on_close=on_close, on_show=on_show)
+ self.compatible_notification_types = {
+ NotificationTypes.information,
+ NotificationTypes.warning,
+ NotificationTypes.error,
+ NotificationTypes.exception,
+ }
+ self.name = "js_message_box"
+
+
+class WebAPINotifications(JSNotifications):
+ """
+ Calls push-notification-style notification, that is realized via Notification_API
+ https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
+
+
+ Callback functions are:
+ - on_close(notification_id, target_ui), should be assigned to delete the message
+ - on_show(notification_id, target_ui)
+ """
+
+ def __init__(self, user_id, websockets_client, on_close=None, on_show=None):
+ super().__init__(user_id, websockets_client, on_close=on_close, on_show=on_show)
+ self.name = "webapi" # see https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
diff --git a/src/cryptoadvance/specterext/notifications/websockets_server_client.py b/src/cryptoadvance/specterext/notifications/websockets_server_client.py
new file mode 100644
index 0000000000..c4e7016f83
--- /dev/null
+++ b/src/cryptoadvance/specterext/notifications/websockets_server_client.py
@@ -0,0 +1,444 @@
+"""
+This file enabled to keep an open websocket connection with the browser sessions.
+"""
+import logging, threading, time, secrets
+import time, json
+from cryptoadvance.specter.util.common import robust_json_dumps
+import simple_websocket, ssl
+from cryptoadvance.specter.specter_error import SpecterError
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+IGNORE_NOTIFICATION_TITLE = "IGNORE_NOTIFICATION_TITLE"
+
+
+class WebsocketServer:
+ """
+ A forever lived websockets server in a different thread.
+ The server has 2 main functions:
+ 1. Recieve messages from webbrowser websocket connections and call notification_manager.create_and_show
+ 2. Recieve messages (notifications) from python websocket connection (broadcaster) and send them to the webbrowser websocket connections
+ Each message must contain a user_token, which is checked against user_manager.user.websocket_token to make sure this is a legitimate user.
+ Otherwise the user_token will not be found in user_manager.user.websocket_token and rejected.
+ Before the python websocket connection is established, the set_as_broadcaster method should be called to inform self that this user_token will be a broadcaster
+ 1. Javascript creates a message
+ ┌───────────────────────┐ ┌───────────────────────┐
+ │ │ websocket.send │ │
+ │ Browser javascript ├─────────────────────────► │ WebsocketsServer │
+ │ │ │ │
+ └───────────────────────┘ └───────────┬───────────┘
+ │
+ │ notification_manager.create_and_show
+ │
+ ▼
+ ┌───────────────────────┐
+ │ │
+ │ NotificationManager │
+ │ │
+ └───────────────────────┘
+ 2. A UI_Notification creates a message for the browser to show
+ ┌───────────────────────┐ ┌───────────────────────┐
+ │ │ websockets_client.send │ │
+ │ JSConsoleNotifications├─────────────────────────► │ WebsocketsClient │
+ │ │ │ │
+ └───────────────────────┘ └───────────┬───────────┘
+ │
+ │
+ │ websockets_client.send
+ │
+ ▼
+ ┌───────────────────────┐ ┌───────────────────────┐
+ │ │ │ │
+ │ Browser javascript │ websocket.send │ │
+ │ │◄──────────────────────────┤ WebsocketsServer │
+ │ websocket.on_message │ │ │
+ │ │ │ │
+ └───────────────────────┘ └───────────────────────┘
+ """
+
+ def __init__(self, notification_manager, verbose_debug=False):
+ logger.info(f"Create {self.__class__.__name__}")
+
+ # a broadcaster has special rights, and can send potentially harmful messages to the websocket server,
+ # such at a Notification("quit_server"), which will quit the server
+ # It is also the only connection which makes the server send notifications to other websocket connections
+ self.broadcaster_tokens = list()
+ # self.connections matches user_tokens to websocket connections, such that a
+ # Notification can be sent to all websocket connections that are associated to this user_tokens
+ self.connections = list()
+ self.notification_manager = notification_manager
+ self.verbose_debug = verbose_debug
+
+ def __str__(self):
+ return str(self.__dict__)
+
+ def get_broadcaster_tokens(self):
+ return [d["user_token"] for d in self.broadcaster_tokens]
+
+ def _get_connection_dict_of_websocket(self, websocket):
+ for d in self.connections:
+ if d["websocket"] == websocket:
+ return d
+ return None
+
+ def get_token_of_websocket(self, websocket):
+ connection_dict = self._get_connection_dict_of_websocket(websocket)
+ if not connection_dict:
+ logger.warning(f"user_token of websocket {websocket} could not be found.")
+ return
+ return connection_dict["user_token"]
+
+ def get_connections_by_token(self, user_token):
+ connections = []
+ for d in self.connections:
+ if d["user_token"] == user_token:
+ connections.append(d["websocket"])
+ return connections
+
+ def get_user_of_user_token(self, user_token):
+ for (
+ known_username,
+ known_token,
+ ) in self.notification_manager.websocket_tokens.items():
+ if known_token == user_token:
+ return known_username
+ return None
+
+ def set_as_broadcaster(self, user_token):
+ new_entry = {"user_token": user_token}
+ logger.debug(f"set_as_broadcaster {f'...{user_token[-5:]}'}")
+ self.broadcaster_tokens.append(new_entry)
+
+ def remove_broadcaster(self, user_token):
+ logger.debug(f"remove_broadcaster ...{user_token[-5:]}")
+ self.broadcaster_tokens = [
+ d for d in self.broadcaster_tokens if d["user_token"] != user_token
+ ]
+
+ def _register(self, user_token, websocket):
+ if not user_token:
+ logger.warning(f"no user_token given")
+ return
+
+ d = {
+ "user_token": user_token,
+ "websocket": websocket,
+ "opening_time": datetime.now(),
+ }
+
+ # check if this connection exists already
+ connection_dict = self._get_connection_dict_of_websocket(websocket)
+ if connection_dict and connection_dict["user_token"] == user_token:
+ # no need to add the connection multiple times if (websocket, user_token) are identical
+ return
+
+ if user_token in self.get_broadcaster_tokens():
+ logger.info(
+ f"python-websocket-client --> python-websocket-server was first used and registered."
+ )
+ else:
+ user = self.get_user_of_user_token(user_token)
+ # If it is not a broadcaster AND the token is unknown, then reject connection
+ if not user:
+ logger.warning(f"user_token ...{user_token[-5:]} not found in users")
+ return
+ logger.info(
+ f"python-websocket-server --> javascript websocket-client for flask user '{user}' was first used and registered."
+ )
+
+ self.connections.append(d)
+ if self.verbose_debug:
+ logger.debug(self.connection_report())
+
+ def _unregister(self, websocket):
+ connection_dict = self._get_connection_dict_of_websocket(websocket)
+ if not connection_dict:
+ logger.warning(
+ f"_unregister failed, because {websocket} could not be found in self.connections."
+ )
+ return
+ user_token = connection_dict["user_token"]
+ user = self.get_user_of_user_token(user_token)
+
+ username = (
+ user
+ if user
+ else (
+ "Python broadcaster Client"
+ if user_token in self.get_broadcaster_tokens()
+ else "unknown"
+ )
+ )
+ self.connections = [d for d in self.connections if d["websocket"] != websocket]
+ logger.debug(
+ f"Unregistered {websocket} belonging to {username}, started at {connection_dict['opening_time']}"
+ )
+ if self.verbose_debug:
+ logger.debug(self.connection_report())
+
+ def connection_report(self):
+ s = f"{len(self.connections)} open connections:\n"
+ for i, connection_dict in enumerate(self.connections):
+ simplified_dict = connection_dict.copy()
+ simplified_dict["opening_time"] = connection_dict[
+ "opening_time"
+ ].isoformat()
+ simplified_dict["user_token"] = f"...{connection_dict['user_token'][-5:]}"
+ simplified_dict["user"] = (
+ None
+ if connection_dict["user_token"] in self.get_broadcaster_tokens()
+ else self.get_user_of_user_token(connection_dict["user_token"])
+ )
+ simplified_dict["broadcaster"] = (
+ connection_dict["user_token"] in self.get_broadcaster_tokens()
+ )
+ s += f"{i}: {simplified_dict}\n"
+ return s
+
+ def serve(self, environ, ping_interval=30):
+ """
+ Start a server. This is an endless loop.
+ It will automatically detect and close unresponsive connections.
+
+ Args:
+ environ (_type_): a flask/werkzeug environ
+ """
+ # ping_interval!=None ensures closing of connections where there is no counterpart any more
+ websocket = simple_websocket.Server(environ, ping_interval=ping_interval)
+ try:
+ logger.info(
+ f"Started websocket connection {websocket} between the server and a new client"
+ )
+ while True:
+ # timeout is needed here otherwise the simple_websocket.Server ping_interval does not work
+ data = websocket.receive(timeout=ping_interval)
+ # If the timeout was triggered then data=None, and it should just restart receiving
+ if not data:
+ continue
+ try:
+ message_dictionary = json.loads(data)
+ except:
+ logger.warning(f"Could not decode the json data in {data}")
+ continue
+
+ self._register(message_dictionary.get("user_token"), websocket)
+
+ preprocessed_instruction = self._preprocess(message_dictionary)
+ if preprocessed_instruction == "quit":
+ logger.debug("quit_server was called.")
+ break
+ elif preprocessed_instruction == "continue":
+ continue
+
+ self._process_incoming_message(message_dictionary)
+ except simple_websocket.ConnectionClosed:
+ logger.info(f"Websocket connection {websocket} closed")
+ finally:
+ self._unregister(websocket)
+
+ return ""
+
+ def _preprocess(self, message_dictionary):
+ """
+ Processes special commands to manipulate the server.
+ A title 'quit_server' sent from and broadcaster can make the websocket connection close.
+ """
+ user_token = message_dictionary.get("user_token")
+ # if there was no user_token given, then prevent any further action with this message
+ if not user_token:
+ logger.warning(
+ f"Notification {message_dictionary} did not contain a user_token. Disregarding notification."
+ )
+ return "continue"
+ if message_dictionary.get("title") == IGNORE_NOTIFICATION_TITLE:
+ return "continue"
+ if message_dictionary.get("title") == "quit_server":
+ # Accept the command from an broadcaster, but disregard the command from a user
+ return "quit" if user_token in self.get_broadcaster_tokens() else "continue"
+
+ def _process_incoming_message(self, message_dictionary):
+ """
+ This listens to messages. They can come from connections with and without broadcaster tokens.
+ If this is a websocket authentication, it will so self._register,
+ otherwise just forward to to the notification_manager via self._create_notification
+ """
+
+ user_token = message_dictionary.get("user_token")
+ user = self.get_user_of_user_token(user_token)
+
+ if user_token in self.get_broadcaster_tokens():
+ if self.verbose_debug:
+ logger.debug(
+ f"message from user with broadcaster_token recieved. Sending to websockets"
+ )
+ self._send_to_websockets(message_dictionary, user_token)
+ elif user:
+ if self.verbose_debug:
+ logger.debug(f"message from user recieved. Creating Notification")
+ self._create_notification(message_dictionary, user)
+ else:
+ logger.warning(
+ f"user_token ...{user_token[-5:]} is not valid. Please provide a user_token in the message"
+ )
+
+ def _create_notification(self, message_dictionary, user):
+ """Creates a notification based on the title, options contained in message_dictionary
+ Example of message_dictionary:
+
+ {
+ 'title' : title,
+ 'options':{
+ 'timeout' : timeout,
+ 'notification_type' : notification_type,
+ 'target_uis' : target_uis,
+ 'body' : body,
+ 'image' : image_url,
+ 'icon' : icon,
+ }
+ }
+ Only 'title' is mandatory
+ The options are the optional arguments of Notification()
+ """
+ if "title" not in message_dictionary:
+ logger.warning(f"No title in {message_dictionary}")
+ return
+
+ title = message_dictionary["title"]
+ options = message_dictionary.get("options", {})
+
+ if self.verbose_debug:
+ logger.debug(
+ f"_create_notification with title {title}, user {user, type(user)} and options {options}"
+ )
+
+ notification = self.notification_manager.create_and_show(
+ title,
+ user,
+ **options,
+ )
+ return notification
+
+ def _send(self, websocket, message_dictionary):
+ "Starts a new thread and sends the data to websocket"
+
+ def target():
+ try:
+ if self.verbose_debug:
+ logger.debug(
+ f"_send_to_websockets {websocket} message: {message_dictionary}"
+ )
+ websocket.send(robust_json_dumps(message_dictionary))
+ except simple_websocket.ConnectionClosed:
+ self._unregister(websocket)
+
+ thread = threading.Thread(target=target)
+ thread.daemon = True # die when the main thread dies
+ thread.start()
+ thread.join()
+
+ def _send_to_websockets(self, message_dictionary, broadcaster_token):
+ """
+ This sends out messages to the connected websockets, which are associated with message_dictionary['options']['user_id']
+ This method shall only called by a broadcaster
+ """
+ assert broadcaster_token in self.get_broadcaster_tokens()
+
+ if not message_dictionary["options"]["user_id"]:
+ logger.warning("No options.user_id given")
+ return
+
+ user_token = self.notification_manager.get_websocket_token(
+ message_dictionary["options"]["user_id"]
+ )
+ connections = self.get_connections_by_token(user_token)
+ if not connections:
+ logger.warning(
+ f"No websocket for this recipient_user.websocket_token could be found"
+ )
+ return
+ for websocket in connections:
+ self._send(websocket, message_dictionary)
+
+
+class WebsocketClient:
+ """
+ Connects to the WebsocketServer; and then the self.send method can be used to send messages to the WebsocketServer
+
+
+ To ensure this client is allowed to broadcast notifications to all
+ websocket connections of the server we need to set
+ websockets_server.set_as_broadcaster(websockets_client.user_token)
+ """
+
+ def __init__(self, host, port, path, ssl_cert, ssl_key, verbose_debug=False):
+ self.protocol = "wss" if ssl_cert and ssl_cert else "ws"
+ self.url = f"{self.protocol}://{host}:{port}/{path}"
+ self.user_token = secrets.token_urlsafe(128)
+
+ self.verbose_debug = verbose_debug
+ self.ssl_context = None
+ if ssl_cert and ssl_key:
+ self.ssl_context = ssl._create_unverified_context(ssl.PROTOCOL_TLS_CLIENT)
+ # see https://pythontic.com/ssl/sslcontext/load_cert_chain
+ self.ssl_context.load_cert_chain(certfile=ssl_cert, keyfile=ssl_key)
+
+ def __str__(self):
+ return str(self.__dict__)
+
+ def send(self, message_dictionary):
+ message_dictionary["user_token"] = self.user_token
+ if self.verbose_debug:
+ logger.debug(f"{self.__class__.__name__} sending {message_dictionary}")
+ self.websocket.send(robust_json_dumps(message_dictionary))
+
+ def start_client_server_in_other_thread(self):
+ delay = 0.1
+ success = False
+ retries = 100
+ for i in range(retries):
+ try:
+ # if successfull this process will run forever. This is why this should run in a dedicated thread.
+ # Only for the first connection, this will not block this thread.
+ # In practice it doesn't make any difference, as long as this function is run in a separate thread.
+ logger.info(f"Connecting {self.__class__.__name__}")
+ self.websocket = simple_websocket.Client(
+ self.url, ssl_context=self.ssl_context
+ )
+ success = True
+ logger.debug(
+ f"Created {self.__class__.__name__} connection to url {self.url}"
+ )
+ self._initialize_connection_to_server()
+ break
+ except ConnectionRefusedError:
+ logger.debug(
+ f"Connection of {self.__class__.__name__} to websocket-server failed in loop {i}. Retrying in {delay}s..."
+ )
+ time.sleep(delay)
+ if not success:
+ logger.error(
+ f"Connection of {self.__class__.__name__} to websocket-server failed despite {retries} attempts."
+ f"\nConfiguration: {self}"
+ )
+
+ def start(self):
+ self.thread = threading.Thread(target=self.start_client_server_in_other_thread)
+ self.thread.daemon = True # die when the main thread dies
+ self.thread.start()
+
+ def is_connected(self):
+ return bool(self.websocket)
+
+ def _close(self):
+ self.websocket.close()
+ self.websocket = None
+
+ def quit_server(self):
+ "Sends the command 'quit_server' to the websocket server. which then shuts down the connection."
+ message_dictionary = {"title": "quit_server"}
+ self.send(message_dictionary)
+ self._close()
+
+ def _initialize_connection_to_server(self):
+ "Sends a message to the server, that does nothing, but enables the server to register the user_token to the websocket_client"
+ self.send({"title": IGNORE_NOTIFICATION_TITLE})
diff --git a/src/cryptoadvance/specterext/spectrum/service.py b/src/cryptoadvance/specterext/spectrum/service.py
index 0d9861700d..43a39be81a 100644
--- a/src/cryptoadvance/specterext/spectrum/service.py
+++ b/src/cryptoadvance/specterext/spectrum/service.py
@@ -41,8 +41,10 @@ class SpectrumService(Service):
def spectrum_node(self):
"""Iterates all nodes and returns the spectrum Node or None if it doesn't exist"""
for node in app.specter.node_manager.nodes.values():
+
if (
- node.fqcn
+ hasattr(node, "fqcn")
+ and node.fqcn
== "cryptoadvance.specterext.spectrum.spectrum_node.SpectrumNode"
):
return node
diff --git a/src/cryptoadvance/specterext/spectrum/spectrum_node.py b/src/cryptoadvance/specterext/spectrum/spectrum_node.py
index 8d0c969b0c..fe5bc82224 100644
--- a/src/cryptoadvance/specterext/spectrum/spectrum_node.py
+++ b/src/cryptoadvance/specterext/spectrum/spectrum_node.py
@@ -224,3 +224,7 @@ def node_logo_template(self):
def node_connection_template(self):
return "spectrum/components/spectrum_node_connection.jinja"
+
+ @property
+ def taproot_support(self):
+ return False
diff --git a/src/cryptoadvance/specterext/spectrum/templates/spectrum/components/spectrum_node_connection.jinja b/src/cryptoadvance/specterext/spectrum/templates/spectrum/components/spectrum_node_connection.jinja
index c80c045acf..39e161a2b8 100644
--- a/src/cryptoadvance/specterext/spectrum/templates/spectrum/components/spectrum_node_connection.jinja
+++ b/src/cryptoadvance/specterext/spectrum/templates/spectrum/components/spectrum_node_connection.jinja
@@ -1,5 +1,8 @@
{% set node = specter.node %}
{% if specter.node.is_running %}
+ {% if specter.info.get("initialblockdownload") %}
+
{{ _("Spectrum is still syncing...")}} {{ _('(data might be outdated)')}}
+ {% endif %}
{% include "includes/sidebar/components/bitcoin_core_info.jinja" %}
diff --git a/tests/conftest.py b/tests/conftest.py
index 54f24dfba7..b5082a2a9b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,17 +3,19 @@
import json
import logging
import os
+import shutil
import signal
import sys
import tempfile
import traceback
import pytest
+
from cryptoadvance.specter.config import TestConfig
-from cryptoadvance.specter.node import Node
from cryptoadvance.specter.managers.device_manager import DeviceManager
from cryptoadvance.specter.managers.node_manager import NodeManager
from cryptoadvance.specter.managers.user_manager import UserManager
+from cryptoadvance.specter.node import Node
from cryptoadvance.specter.process_controller.bitcoind_controller import (
BitcoindPlainController,
)
@@ -209,7 +211,7 @@ def node(empty_data_folder, bitcoin_regtest):
if not os.path.isdir(nodes_folder):
os.makedirs(nodes_folder)
nm = NodeManager(data_folder=nodes_folder)
- node = nm.add_external_node(
+ node: Node = nm.add_external_node(
"BTC",
"Standard node",
False,
@@ -219,8 +221,8 @@ def node(empty_data_folder, bitcoin_regtest):
bitcoin_regtest.rpcconn.rpcport,
bitcoin_regtest.rpcconn._ipaddress,
"http",
- "standard_node",
)
+ assert node.rpc.test_connection()
return node
@@ -240,7 +242,6 @@ def node_with_different_port(empty_data_folder, bitcoin_regtest):
18333,
bitcoin_regtest.rpcconn._ipaddress,
"http",
- "satoshis_node",
)
return node
@@ -290,119 +291,15 @@ def devices_filled_data_folder(empty_data_folder):
devices_folder = empty_data_folder + "/devices"
if not os.path.isdir(devices_folder):
os.makedirs(devices_folder)
- with open(empty_data_folder + "/devices/trezor.json", "w") as text_file:
- text_file.write(
- """
-{
- "name": "Trezor",
- "type": "trezor",
- "keys": [
- {
- "derivation": "m/49h/0h/0h",
- "original": "ypub6XFn7hfb676MLm6ZsAviuQKXeRDNgT9Bs32KpRDPnkKgKDjKcrhYCXJ88aBfy8co2k9eujugJX5nwq7RPG4sj6yncDEPWN9dQGmFWPy4kFB",
- "fingerprint": "1ef4e492",
- "type": "sh-wpkh",
- "xpub": "xpub6CRWp2zfwRYsVTuT2p96hKE2UT4vjq9gwvW732KWQjwoG7v6NCXyaTdz7NE5yDxsd72rAGK7qrjF4YVrfZervsJBjsXxvTL98Yhc7poBk7K"
- },
- {
- "derivation": "m/84h/0h/0h",
- "original": "zpub6rGoJTXEhKw7hUFkjMqNctTzojkzRPa3VFuUWAirqpuj13mRweRmnYpGD1aQVFpxNfp17zVU9r7F6oR3c4zL3DjXHdewVvA7kjugHSqz5au",
- "fingerprint": "1ef4e492",
- "type": "wpkh",
- "xpub": "xpub6CcGh8BQPxr9zssX4eG8CiGzToU6Y9b3f2s2wNw65p9xtr8ySL6eYRVzAbfEVSX7ZPaPd3JMEXQ9LEBvAgAJSkNKYxG6L6X9DHnPWNQud4H"
- },
- {
- "derivation": "m/48h/0h/0h/1h",
- "original": "Ypub6jtWQ1r2D7EwqNoxERU28MWZH4WdL3pWdN8guFJRBTmGwstJGzMXJe1VaNZEuAAVsZwpKPhs5GzNPEZR77mmX1mjwzEiouxmQYsrxFBNVNN",
- "fingerprint": "1ef4e492",
- "type": "sh-wsh",
- "xpub": "xpub6EA9y7SfVU96ZWTTTQDR6C5FPJKvB59RPyxoCb8zRgYzGbWAFvogbTVRkTeBLpHgETm2hL7BjQFKNnL66CCoaHyUFBRtpbgHF6YLyi7fr6m"
- },
- {
- "derivation": "m/48h/0h/0h/2h",
- "original": "Zpub74imhgWwMnnRkSPkiNavCQtSBu1fGo8RP96h9eT2GHCgN5eFU9mZVPhGphvGnG26A1cwJxtkmbHR6nLeTw4okpCDjZCEj2HRLJoVHAEsch9",
- "fingerprint": "1ef4e492",
- "type": "wsh",
- "xpub": "xpub6EA9y7SfVU96dGr96zYgxAMd8AgWBCTqEeQafbPi8VcWdhStCS4AA9X4yb3dE1VM7GKLwRhWy4BpD3VkjK5q1riMAQgz9oBSu8QKv5S7KzD"
- },
- {
- "derivation": "m/49h/1h/0h",
- "original": "upub5EKoQv21nQNkhdt4yuLyRnWitA3EGhW1ru1Y8VTG8gdys2JZhqiYkhn4LHp2heHnH41kz95bXPvrYVRuFUrdUMik6YdjFV4uL4EubnesttQ",
- "fingerprint": "1ef4e492",
- "type": "sh-wpkh",
- "xpub": "tpubDDCDr9rSwixeXKeGwAgwFy8bjBaE5wya9sAVqEC4ccXWmcQxY34KmLRJdwmaDsCnHsu5r9P9SUpYtXmCoRwukWDqmAUJgkBbjC2FXUzicn6"
- },
- {
- "derivation": "m/84h/1h/0h",
- "original": "vpub5Y35MNUT8sUR2SnRCU9A9S6z1JDACMTuNnM8WHXvuS7hCwuVuoRAWJGpi66Yo8evGPiecN26oLqx19xf57mqVQjiYb9hbb4QzbNmFfsS9ko",
- "fingerprint": "1ef4e492",
- "type": "wpkh",
- "xpub": "tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc"
- },
- {
- "derivation": "m/48h/1h/0h/1h",
- "original": "Upub5Tk9tZtdzVaTGWtygRTKDDmaN5vfB59pn2L5MQyH6BkVpg2Y5J95rtpQndjmXNs3LNFiy8zxpHCTtvxxeePjgipF7moTHQZhe3E5uPzDXh8",
- "fingerprint": "1ef4e492",
- "type": "sh-wsh",
- "xpub": "tpubDFiVCZzdarbyfdVoh2LJDL3eVKRPmxwnkiqN8tSYCLod75a2966anQbjHajqVAZ97j54xZJPr9hf7ogVuNL4pPCfwvXdKGDQ9SjZF7vXQu1"
- },
- {
- "derivation": "m/48h/1h/0h/2h",
- "original": "Vpub5naRCEZZ9B7wCKLWuqoNdg6ddWEx8ruztUygXFZDJtW5LRMqUP5HV2TsNw1nc74Ba3QPDSH7qzauZ8LdfNmnmofpfmztCGPgP7vaaYSmpgN",
- "fingerprint": "1ef4e492",
- "type": "wsh",
- "xpub": "tpubDFiVCZzdarbyk8kE65tjRhHCambEo8iTx4xkXL8b33BKZj66HWsDnUb3rg4GZz6Mwm6vTNyzRCjYtiScCQJ77ENedb2deDDtcoNQXiUouJQ"
- }
- ]
-}
-"""
- )
- with open(empty_data_folder + "/devices/specter.json", "w") as text_file:
- text_file.write(
- """
-{
- "name": "Specter",
- "type": "specter",
- "keys": [
- {
- "derivation": "m/48h/1h/0h/2h",
- "original": "Vpub5n9kKePTPPGtw3RddeJWJe29epEyBBcoHbbPi5HhpoG2kTVsSCUzsad33RJUt3LktEUUPPofcZczuudnwR7ZgkAkT6N2K2Z7wdyjYrVAkXM",
- "fingerprint": "08686ac6",
- "type": "wsh",
- "xpub": "tpubDFHpKypXq4kwUrqLotPs6fCic5bFqTRGMBaTi9s5YwwGymE8FLGwB2kDXALxqvNwFxB1dLWYBmmeFVjmUSdt2AsaQuPmkyPLBKRZW8BGCiL"
- },
- {
- "derivation": "m/84h/1h/0h",
- "original": "vpub5ZSem3mLXiSJzgDX6pJb2N9L6sJ8m6ejaksLPLSuB53LBzCi2mMsBg19eEUSDkHtyYp75GATjLgt5p3S43WjaVCXAWU9q9H5GhkwJBrMiAb",
- "fingerprint": "08686ac6",
- "type": "wpkh",
- "xpub": "tpubDDUotcvrYMUiy4ncDirveTfhmvggdj8nxcW5JgHpGzYz3UVscJY5aEzFvgUPk4YyajadBnsTBmE2YZmAtJC14Q21xncJgVaHQ7UdqMRVRbU"
- },
- {
- "derivation": "m/84h/1h/1h",
- "original": "vpub5ZSem3mLXiSK55jPzfLVhbHbTEwGzEFZv3xrGFCw1vGHSNw7WcVuJXysJLWcgENQd3iXSNQaeSXUBW55Hy4GAjSTjrWP4vpKKkUN9jiU1Tc",
- "fingerprint": "08686ac6",
- "type": "wpkh",
- "xpub": "tpubDDUotcvrYMUj3UJV7ZtqKgoy8JKprrjdHubbBb3r7qmwHsEH69g7h6xyanWaCYdVEEV3Yu7a6s4ceFnp8DjXeeFxY8eXvH7XTAC4gxfDNEW"
- },
- {
- "derivation": "m/84h/1h/2h",
- "original": "vpub5ZSem3mLXiSK64v64deytnDCoYqbUSYHvmVurUGVMEnXMyEybtF3FEnNuiFDDC6J18a81fv5ptQXaQaaRiYx8MRxahipgxPLdxubpYt1dkD",
- "fingerprint": "08686ac6",
- "type": "wpkh",
- "xpub": "tpubDDUotcvrYMUj4TVBBYDKWsjaUcE9M52MJd8emp7QTAJBDTY9BRRFdomVCAFAjWMNcKLe8Cd5HJwg3AJKFyEDcGFTNyryYJgYmNdJMhwB2RG"
- },
- {
- "derivation": "m/84h/1h/3h",
- "original": "vpub5ZSem3mLXiSK8cKzh4sHxTvN7mgYQA29HfoAZeCDtX1M2zdejN5XVAtVyqhk8eui18JTtZ9M3VD3AiWCz8VwrybhBUh3HxzS8js3mLVybDT",
- "fingerprint": "08686ac6",
- "type": "wpkh",
- "xpub": "tpubDDUotcvrYMUj6zu5oyRdaZSjnq56GnWCfXRuUz38zSWztUvpJuFjsjscGHhheyAncK4z15rLVukBdUDwpPBDLtRBykqC9KHeG9akJWRipKK"
- }
- ]
-}
-"""
- )
+
+ shutil.copy2(
+ "./tests/misc_testdata/trezor_device.json",
+ empty_data_folder + "/devices/trezor.json",
+ )
+ shutil.copy2(
+ "./tests/misc_testdata/specter_device.json",
+ empty_data_folder + "/devices/specter.json",
+ )
return empty_data_folder # no longer empty, though
@@ -540,9 +437,11 @@ def specter_regtest_configured(bitcoin_regtest, devices_filled_data_folder, node
"allow_threading_for_testing": False,
},
}
- specter = Specter(
+ specter: Specter = Specter(
data_folder=devices_filled_data_folder, config=config, checker_threads=False
)
+ assert specter.active_node_alias == "bitcoin_core"
+ assert specter.node_manager.active_node.alias == "bitcoin_core"
assert specter.chain == "regtest"
# Create a User
someuser = specter.user_manager.add_user(
diff --git a/tests/fix_devices_and_wallets.py b/tests/fix_devices_and_wallets.py
index 310187e2a2..2fff51fc28 100644
--- a/tests/fix_devices_and_wallets.py
+++ b/tests/fix_devices_and_wallets.py
@@ -184,18 +184,88 @@ def funded_taproot_wallet(
return funded_taproot_wallet
-@pytest.fixture
-def wallet(devices_filled_data_folder, device_manager, node):
+def create_trezor_wallet_with_account(
+ devices_filled_data_folder,
+ device_manager,
+ node,
+ account_number: int,
+ checkbalance=True,
+):
"""An ordinary wallet without private keys"""
wm = WalletManager(
- 200100,
devices_filled_data_folder,
node._get_rpc(),
"regtest",
device_manager,
)
- device = device_manager.get_by_alias("trezor")
+ device: Device = device_manager.get_by_alias("trezor")
wallet_name = f"test_wallet_{random.randint(0, 999999)}"
- wm.create_wallet(wallet_name, 1, "wpkh", [device.keys[5]], [device])
- wallet = wm.wallets[wallet_name]
+ ss_segwit_index = account_number * 4 + 1
+ assert device.keys[ss_segwit_index].derivation.startswith(
+ f"m/84h/1h/{account_number}h"
+ ), f"At index ss_segwit_index has weird derivation {device.keys[ss_segwit_index].derivation}"
+ wm.create_wallet(wallet_name, 1, "wpkh", [device.keys[ss_segwit_index]], [device])
+ wallet: Wallet = wm.wallets[wallet_name]
+ if checkbalance:
+ assert (
+ wallet.rpc.getbalance() == 0
+ ), f"account {account_number} does have a non-zero balance: {wallet.rpc.getbalance()}"
return wallet
+
+
+@pytest.fixture
+def trezor_wallet_acc0(devices_filled_data_folder, device_manager, node):
+ """This wallet might have a nonzero balance"""
+ return create_trezor_wallet_with_account(
+ devices_filled_data_folder, device_manager, node, 0, checkbalance=False
+ )
+ # raise Exception("Do not use this fixture!")
+
+
+@pytest.fixture
+def trezor_wallet_acc1(devices_filled_data_folder, device_manager, node):
+ return create_trezor_wallet_with_account(
+ devices_filled_data_folder, device_manager, node, 1
+ )
+
+
+@pytest.fixture
+def trezor_wallet_acc2(devices_filled_data_folder, device_manager, node):
+ return create_trezor_wallet_with_account(
+ devices_filled_data_folder, device_manager, node, 2
+ )
+
+
+@pytest.fixture
+def trezor_wallet_acc3(devices_filled_data_folder, device_manager, node):
+ return create_trezor_wallet_with_account(
+ devices_filled_data_folder, device_manager, node, 3
+ )
+
+
+@pytest.fixture
+def trezor_wallet_acc4(devices_filled_data_folder, device_manager, node):
+ return create_trezor_wallet_with_account(
+ devices_filled_data_folder, device_manager, node, 4
+ )
+
+
+@pytest.fixture
+def trezor_wallet_acc5(devices_filled_data_folder, device_manager, node):
+ return create_trezor_wallet_with_account(
+ devices_filled_data_folder, device_manager, node, 5
+ )
+
+
+@pytest.fixture
+def trezor_wallet_acc6(devices_filled_data_folder, device_manager, node):
+ return create_trezor_wallet_with_account(
+ devices_filled_data_folder, device_manager, node, 6
+ )
+
+
+@pytest.fixture
+def trezor_wallet_acc7(devices_filled_data_folder, device_manager, node):
+ return create_trezor_wallet_with_account(
+ devices_filled_data_folder, device_manager, node, 7
+ )
diff --git a/tests/misc_testdata/specter_device.json b/tests/misc_testdata/specter_device.json
new file mode 100644
index 0000000000..6f8c208f5b
--- /dev/null
+++ b/tests/misc_testdata/specter_device.json
@@ -0,0 +1,41 @@
+{
+ "name": "Specter",
+ "type": "specter",
+ "keys": [
+ {
+ "derivation": "m/48h/1h/0h/2h",
+ "original": "Vpub5n9kKePTPPGtw3RddeJWJe29epEyBBcoHbbPi5HhpoG2kTVsSCUzsad33RJUt3LktEUUPPofcZczuudnwR7ZgkAkT6N2K2Z7wdyjYrVAkXM",
+ "fingerprint": "08686ac6",
+ "type": "wsh",
+ "xpub": "tpubDFHpKypXq4kwUrqLotPs6fCic5bFqTRGMBaTi9s5YwwGymE8FLGwB2kDXALxqvNwFxB1dLWYBmmeFVjmUSdt2AsaQuPmkyPLBKRZW8BGCiL"
+ },
+ {
+ "derivation": "m/84h/1h/0h",
+ "original": "vpub5ZSem3mLXiSJzgDX6pJb2N9L6sJ8m6ejaksLPLSuB53LBzCi2mMsBg19eEUSDkHtyYp75GATjLgt5p3S43WjaVCXAWU9q9H5GhkwJBrMiAb",
+ "fingerprint": "08686ac6",
+ "type": "wpkh",
+ "xpub": "tpubDDUotcvrYMUiy4ncDirveTfhmvggdj8nxcW5JgHpGzYz3UVscJY5aEzFvgUPk4YyajadBnsTBmE2YZmAtJC14Q21xncJgVaHQ7UdqMRVRbU"
+ },
+ {
+ "derivation": "m/84h/1h/1h",
+ "original": "vpub5ZSem3mLXiSK55jPzfLVhbHbTEwGzEFZv3xrGFCw1vGHSNw7WcVuJXysJLWcgENQd3iXSNQaeSXUBW55Hy4GAjSTjrWP4vpKKkUN9jiU1Tc",
+ "fingerprint": "08686ac6",
+ "type": "wpkh",
+ "xpub": "tpubDDUotcvrYMUj3UJV7ZtqKgoy8JKprrjdHubbBb3r7qmwHsEH69g7h6xyanWaCYdVEEV3Yu7a6s4ceFnp8DjXeeFxY8eXvH7XTAC4gxfDNEW"
+ },
+ {
+ "derivation": "m/84h/1h/2h",
+ "original": "vpub5ZSem3mLXiSK64v64deytnDCoYqbUSYHvmVurUGVMEnXMyEybtF3FEnNuiFDDC6J18a81fv5ptQXaQaaRiYx8MRxahipgxPLdxubpYt1dkD",
+ "fingerprint": "08686ac6",
+ "type": "wpkh",
+ "xpub": "tpubDDUotcvrYMUj4TVBBYDKWsjaUcE9M52MJd8emp7QTAJBDTY9BRRFdomVCAFAjWMNcKLe8Cd5HJwg3AJKFyEDcGFTNyryYJgYmNdJMhwB2RG"
+ },
+ {
+ "derivation": "m/84h/1h/3h",
+ "original": "vpub5ZSem3mLXiSK8cKzh4sHxTvN7mgYQA29HfoAZeCDtX1M2zdejN5XVAtVyqhk8eui18JTtZ9M3VD3AiWCz8VwrybhBUh3HxzS8js3mLVybDT",
+ "fingerprint": "08686ac6",
+ "type": "wpkh",
+ "xpub": "tpubDDUotcvrYMUj6zu5oyRdaZSjnq56GnWCfXRuUz38zSWztUvpJuFjsjscGHhheyAncK4z15rLVukBdUDwpPBDLtRBykqC9KHeG9akJWRipKK"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/misc_testdata/trezor_device.json b/tests/misc_testdata/trezor_device.json
new file mode 100644
index 0000000000..e36bfa3903
--- /dev/null
+++ b/tests/misc_testdata/trezor_device.json
@@ -0,0 +1,360 @@
+{
+ "name": "Trezor",
+ "alias": "mytrezor",
+ "type": "trezor",
+ "keys": [
+ {
+ "original": "upub5EKoQv21nQNkhdt4yuLyRnWitA3EGhW1ru1Y8VTG8gdys2JZhqiYkhn4LHp2heHnH41kz95bXPvrYVRuFUrdUMik6YdjFV4uL4EubnesttQ",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/49h/1h/0h",
+ "type": "sh-wpkh",
+ "purpose": "#0 Single Sig (Nested)",
+ "xpub": "tpubDDCDr9rSwixeXKeGwAgwFy8bjBaE5wya9sAVqEC4ccXWmcQxY34KmLRJdwmaDsCnHsu5r9P9SUpYtXmCoRwukWDqmAUJgkBbjC2FXUzicn6"
+ },
+ {
+ "original": "vpub5Y35MNUT8sUR2SnRCU9A9S6z1JDACMTuNnM8WHXvuS7hCwuVuoRAWJGpi66Yo8evGPiecN26oLqx19xf57mqVQjiYb9hbb4QzbNmFfsS9ko",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/84h/1h/0h",
+ "type": "wpkh",
+ "purpose": "#0 Single Sig (Segwit)",
+ "xpub": "tpubDC5EUwdy9WWpzqMWKNhVmXdMgMbi4ywxkdysRdNr1MdM4SCfVLbNtsFvzY6WKSuzsaVAitj6FmP6TugPuNT6yKZDLsHrSwMd816TnqX7kuc"
+ },
+ {
+ "original": "Upub5Tk9tZtdzVaTGWtygRTKDDmaN5vfB59pn2L5MQyH6BkVpg2Y5J95rtpQndjmXNs3LNFiy8zxpHCTtvxxeePjgipF7moTHQZhe3E5uPzDXh8",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/0h/1h",
+ "type": "sh-wsh",
+ "purpose": "#0 Multisig Sig (Nested)",
+ "xpub": "tpubDFiVCZzdarbyfdVoh2LJDL3eVKRPmxwnkiqN8tSYCLod75a2966anQbjHajqVAZ97j54xZJPr9hf7ogVuNL4pPCfwvXdKGDQ9SjZF7vXQu1"
+ },
+ {
+ "original": "Vpub5naRCEZZ9B7wCKLWuqoNdg6ddWEx8ruztUygXFZDJtW5LRMqUP5HV2TsNw1nc74Ba3QPDSH7qzauZ8LdfNmnmofpfmztCGPgP7vaaYSmpgN",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/0h/2h",
+ "type": "wsh",
+ "purpose": "#0 Multisig Sig (Segwit)",
+ "xpub": "tpubDFiVCZzdarbyk8kE65tjRhHCambEo8iTx4xkXL8b33BKZj66HWsDnUb3rg4GZz6Mwm6vTNyzRCjYtiScCQJ77ENedb2deDDtcoNQXiUouJQ"
+ },
+ {
+ "original": "upub5EKoQv21nQNkkbeX7RLSUgcjnR6nTWFudhmGo5Nxq48FqkKxgPBkWiAwKazG3cd1KjENnTbeGJtNB7iqyuH4QXpxFnVvsRtbGkN2Fg9wStD",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/49h/1h/1h",
+ "type": "sh-wpkh",
+ "purpose": "#1 Single Sig (Nested)",
+ "xpub": "tpubDDCDr9rSwixeaHQj4ggQJsEcdSdnGkjTvfvEVp7mJz1nkLSMWaXXXLpBdEwoZqY1LZ7heTuCBPn4XA49XrNLggL3vQLWJh1Hft9NBQDrZ29"
+ },
+ {
+ "original": "vpub5Y35MNUT8sUR6SVwH1nkeiUwWky58XopeJaFWLwJfQj2GgcGFbwmkmhp3yBMEVTqw2xfvzpvMqSUzCXiGZdxAiWyvmxyyrErPBwPoKSJzus",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/84h/1h/1h",
+ "type": "wpkh",
+ "purpose": "#1 Single Sig (Segwit)",
+ "xpub": "tpubDC5EUwdy9WWq4q52PvM6Gp1KBpMd1AHt2ACzRgnDmLEg8AuRq97z9LgvLRBJkoivYDjC3XXupFydSxFT6pKDedLUj478qCY4Wbf6LZHaEuo"
+ },
+ {
+ "original": "Upub5SNDKzLBXW3QBrbxK14LDuVtxxR2MWYxChNjShS338xKtx1tgwp1tZoM5Prts4J2DXpPKATS1vfntAHrorEXZA7VFBCaUNhemfWYejJRPHg",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/1h/1h",
+ "type": "sh-wsh",
+ "purpose": "#1 Multisig Sig (Nested)",
+ "xpub": "tpubDELYdzSB7s4vayCnKbwKE1my6BukxQLvBPt2EAuJ9J1TBMZNkjmWp5afaLrxpqz7ztdjJaks3oAz731Q4aArgpVv5KvkWEMMH521zaj118Z"
+ },
+ {
+ "original": "Vpub5mCUdf16gBat7LhtqCQne7zV3en2XV2nodPGR85jhHqv9E4A7UXNnZ9UsA5x3H2fxhc38t5gXcp1XvqGp8ASa3ErgXhyFKNAt1cSMdWGXNN",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/1h/2h",
+ "type": "wsh",
+ "purpose": "#1 Multisig Sig (Segwit)",
+ "xpub": "tpubDELYdzSB7s4vfA7c1SW9S9B3zv8KBkqFsDNLRCf7RSXANXnQvcKK61GfLu8S1A4rLRJaNpnZ6pxesWwFM9gkuTwgeLjihGCP7h4GJuCxsd3"
+ },
+ {
+ "original": "upub5EKoQv21nQNknik1KU1syMoEgo8NwkDkYeX6hYSTexRhSdCXqNHHHKsd9W1o8qvqRgqfBP9KmuWBxuMWvmCMqMSicMBqwCJGGa5TVau3eD1",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/49h/1h/2h",
+ "type": "sh-wpkh",
+ "purpose": "#2 Single Sig (Nested)",
+ "xpub": "tpubDDCDr9rSwixecQWDGjMqoYR7XpfNkzhJqcg4QHBG8tKEMDJvfZd4HxWsT9yLf4qqSWiz3PSsgzPtJwgpUiHe7VwpGy2RNTQxfhroRPqfBfM"
+ },
+ {
+ "original": "vpub5Y35MNUT8sUR85w9G2bVdZQrHWXLdxPcsi75fgnBWkJNATPfgbv4Qpabv59fMTG5cVdePeveifFk5TPePFdU7jvPm1qb9fVYcxXKXSvUtgz",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/84h/1h/2h",
+ "type": "wpkh",
+ "purpose": "#2 Single Sig (Segwit)",
+ "xpub": "tpubDC5EUwdy9WWq6UWENw9qFewDxZutWasgFZjpb2d6cfp21wgqG96GoPZiCX9csmXADgQAWBdeB5ntYD7PDWJjbejtZHyk11nkkNF24i7RnLV"
+ },
+ {
+ "original": "Upub5TVK2qPsm6eMaSm9JpaDCqGACPHtXMmn6reTthgdvpqnV7qcyHD3awVpEawcMxRKDbEyvMLr1pKNWFAQYBhzfwzrAo8BpMefcymgMsYvdw4",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/2h/1h",
+ "type": "sh-wsh",
+ "purpose": "#2 Multisig Sig (Nested)",
+ "xpub": "tpubDFTeLqVsMTfsyZMyKRTCCwYEKcnd8FZk5Z9kgB9u2ytumXP735AYWTH8jXwgKk7Qzx4KumeH3gpZj7swnueKocPGzwrMrDJN8PH9hh7AnfJ"
+ },
+ {
+ "original": "Vpub5nKaLW4nunBqSzRVKf4oTaGySWh8kTJUd3wQHvVMnNp4ksukeC7spEmCyKRfAEHz31UPAkBGQwrEwS5iFpQbuNB4EuigfoTLTSwYmXXP2JK",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/2h/2h",
+ "type": "wsh",
+ "purpose": "#2 Multisig Sig (Segwit)",
+ "xpub": "tpubDFTeLqVsMTfszoqCVuAAFbTYPn3RQj6wgdvUJ14jWXVJzBe1TKup7gtPT4U987LAQjAvQgt8z9ztH2BgnqvvEnstCikS7kHYh8PNimvL3mt"
+ },
+ {
+ "original": "upub5EKoQv21nQNkquuCusEL8ofnQaM4V8rsdzPuWe48FD4rRWvmF61hEqVMwwNje2zxpma7ju39zT1ub1hogbGaKdAvmMfPJtdqCSR5FovbUSF",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/49h/1h/3h",
+ "type": "sh-wpkh",
+ "purpose": "#3 Single Sig (Nested)",
+ "xpub": "tpubDDCDr9rSwixefbfQs8aHxzHfFbt4JPLRvxYsDNnvj8xPL73A5HMUFU8cFbLHAFuxqbTSbuLhuXubw437EYMrbmg2RyVxk9kXbaCRBYbHftq"
+ },
+ {
+ "original": "vpub5Y35MNUT8sURA6hs4HJw67NHTFmMsmjDSu9pQS3abpJDrkUdSpXn7n5WEVHvU2uuJWAKHSkdpDYKuFBvhQAJ2xtezVurh2WfBMsP8HorJxH",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/84h/1h/3h",
+ "type": "wpkh",
+ "purpose": "#3 Single Sig (Segwit)",
+ "xpub": "tpubDC5EUwdy9WWq8VGxBBsGiCtf8K9ukQDGpknZKmtVhjosiEmo2MhzWM4cWwHszMAyugvqPyTdGe5UMzufXeqZWsi9nn41YNosJmb5fYoCqFy"
+ },
+ {
+ "original": "Upub5TYty5hhC1kpHss1TPBrhzsP7Jy2UScv6VF8HeEN1jbvVhryoPsrN9sBGxWjcvJp4TZLdkaqKmEvLqFP2CVtnSFw4Vv4HcG1pcp66PCAxnr",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/3h/1h",
+ "type": "sh-wsh",
+ "purpose": "#3 Multisig Sig (Nested)",
+ "xpub": "tpubDFXEH5ognNnLgzTqTz4qi79TEYTm5LQt5BkR57hd7tf3n7QTsBqMHfeVmuWoahzuqpNgdAtGMdk7ZhxvGvSDv6eMteeEKTuiL2KZS7oDNBc"
+ },
+ {
+ "original": "Vpub5nPAGkNcLhJJCHZhGRt47wH5mBab9m5PfJmUjSazw1XAkbax3vG9RCJQVMei2Z84PgirRvTUHU93uUXynBUXwiiPVT4VwG45Xas6KWDPh5T",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/3h/2h",
+ "type": "wsh",
+ "purpose": "#3 Multisig Sig (Segwit)",
+ "xpub": "tpubDFXEH5ognNnLk6yQSfyQuxTeiSvsp2sritkYjXANfACQyuKCs445ieRay6hBzSAEmQRPfsALrgHhF4dxKCzrH9RDTG6FPCtHmGJvGi6sSC8"
+ },
+ {
+ "original": "upub5EKoQv21nQNktcVEn7ormrgocRuFrKvU6wum7ZRfXcTKYtfUfG1RXihAgq4cWyTVM4smEV5VTejiCXot2akjMDr6sYCghMnqUSVuNctuCbm",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/49h/1h/4h",
+ "type": "sh-wpkh",
+ "purpose": "#4 Single Sig (Nested)",
+ "xpub": "tpubDDCDr9rSwixeiJFSjP9pc3JgTTSFfaQ2Pv4ipJAU1YLrTUmsVTMCYMLQzV2A3CNVMtm66VP3NjdQYa9BaXr1dNMCYA3G8cuXsaHFJMZNVDP"
+ },
+ {
+ "original": "vpub5Y35MNUT8sUREDYApTNH7Y188zckaDj6NHaK8tkgRFLsjF2a8cQRXrSzgvJbMoEgHgqZm8259DGsHUXmUA8P3FKEFkjNeMh7aMaivei27JR",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/84h/1h/4h",
+ "type": "wpkh",
+ "purpose": "#4 Single Sig (Segwit)",
+ "xpub": "tpubDC5EUwdy9WWqCc7FwMvcjdXVp41JSrD9k9D44EbbXArXajKji9advRS6yNJYt7Vktsc5sej4bdp1kEFWJQoeXA8j42sXVhzKhmJRTmvjiZL"
+ },
+ {
+ "original": "Upub5TNPzsZrx1ZhKLgEqViJqi6cWUrUyQdPpCwFQ1fbBqtVBGC6CwCscoqnH34TEGGbWnEL4C2eJDPnsqcM2sGnPZXuyH7Dy511H3qnCVK7P5f",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/4h/1h",
+ "type": "sh-wsh",
+ "purpose": "#4 Multisig Sig (Nested)",
+ "xpub": "tpubDFLjJsfrYNbDiTH4r6bHqpNgdiMDaJRMnuSYBV8rHzwcTfjaGjANYKd6mz4XC3xhJ93g3cL5L5tz6iKtHbD7XDvLoRqPzvehnTMFYEJv8rh"
+ },
+ {
+ "original": "Vpub5nCfJYEn6h7BDYF3ZiuhYa6RgEb6FnVb2ewjDpiYEWKyQVuuHuise1Ygbo1r5EggzF1zfyDSCmywMieJKCR3q1Ergim9UQSUZEWrpGkeBvk",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/4h/2h",
+ "type": "wsh",
+ "purpose": "#4 Multisig Sig (Segwit)",
+ "xpub": "tpubDFLjJsfrYNbDmMekjy14LbGzdVwNv4J46EvoDuHuxf1DdoeA73WowTfs5Y4L37isMxiXuuvJmz8ahJkGrDwNARwgeXntvMGgnuxgmXxNsnU"
+ },
+ {
+ "original": "upub5EKoQv21nQNkx9AMEjSYNDSic2p59T2uGrJQYD5URuDZbaF3itK2mjDD9mJutuYmVnzjQuSSCvxE6xjpwXCY8YNpzr3GLqSjWH4n2Cuokvy",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/49h/1h/5h",
+ "type": "sh-wpkh",
+ "purpose": "#5 Single Sig (Nested)",
+ "xpub": "tpubDDCDr9rSwixempvZBznWCQ4bT4M4xhWTZpTNEwpGuq76WAMSZ5eonMrTTRGTR8TmWct4Gujz81qvT158VUHpQgsvfTsqn6ZRuQr7x22dD1E"
+ },
+ {
+ "original": "vpub5Y35MNUT8sURH2nXVtZGcoWLn6CRrkNkFCoYpvbeEvxFqigBvaT8ExBqPPNVzrnCjBq9meh2Adci4WmLrmDBTfpztnpxq1Sa3sAcMK3ub5d",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/84h/1h/5h",
+ "type": "wpkh",
+ "purpose": "#5 Single Sig (Segwit)",
+ "xpub": "tpubDC5EUwdy9WWqFRMcco7cEu2iT9ayjNrod4SHkGSZLrTuhCyMW7dLdXAwfqNTXB3HLNbftBQ1d49rXGV5h1tSwaeVh4y7gMjnBGtJtRw2UE8"
+ },
+ {
+ "original": "Upub5SbGvWuQjHSN9SNQSCQ7x1EeV5iidqbmjzqR6aBmThDNCQqWSZ3R8HmY8vLTcwYUt5PLSZC5hjp3VHTq9hek56Qkka8pPqFNGKAoJRddAPg",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/5h/1h",
+ "type": "sh-wsh",
+ "purpose": "#5 Multisig Sig (Nested)",
+ "xpub": "tpubDEZcEX1QKeTtYYyESoH6x7WicKDTEjPjihLht3f2ZrGVUpNzWLzv3oYrdsLXajEafSCgRyVWjcKEiABNQRb5CkoBairzRgu4migGeGKXkCR"
+ },
+ {
+ "original": "Vpub5mRYEBaKsxyr2NFZRzrstJ9EKSMsfT7tLWQv6Cm1DNN4XMQJkTmQvVnvp6cEqb9vTvEF8xZTmGSGLevopuuaoCxWnD9m26NTSXdHMTYwU3V",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/5h/2h",
+ "type": "wsh",
+ "purpose": "#5 Multisig Sig (Segwit)",
+ "xpub": "tpubDEZcEX1QKeTtaBfGcExEgKKoGhiAKivMQ6Pz6HLNwX3Jkf8ZZbZMDwv7HqeioUC6qdvnNuGLLUaugF2nMwRu8dfLk2BWU3CfgD57JiADegZ"
+ },
+ {
+ "original": "upub5EKoQv21nQNm1DqJZU9wsGAQHT9vZofiwYVKih5SNWA5TDCKmiPzabATbmeFcvQQ7MKKPFHovRqnnVX9a9KZYQzXiLFKGDLbGoQ8AuuMGc9",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/49h/1h/6h",
+ "type": "sh-wpkh",
+ "purpose": "#6 Single Sig (Nested)",
+ "xpub": "tpubDDCDr9rSwixepubWWjVuhSnH8UgvP49HEWeHRRpErS3cMoJibujmbDohuRbo99KQ8BCeFFbMqWjV8XrT86QqpZVdNx5thUTHfwBU6fdP51e"
+ },
+ {
+ "original": "vpub5Y35MNUT8sURKFuKEy9mp1mH9AwEsNMDmF1JzvmD61CkPmsiL2ufvzKTDNNsG7QLKN6RwNSowYVW5g7uJqMFL9thVW9QChJ2KxXagZ2UdJE",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/84h/1h/6h",
+ "type": "wpkh",
+ "purpose": "#6 Single Sig (Segwit)",
+ "xpub": "tpubDC5EUwdy9WWqHeUQMsi7S7HepEKnjzqH96e3vGc8BviQFGAsua5tKZJZVpNpnRfQvYrx3u9oPy2eYRqe962Wp4iCHnHZ43bETNFHDfyLoem"
+ },
+ {
+ "original": "Upub5SibFFj1EPmNEfxxZPKFet6rGVDAdiAXStukEA6AywfBgFrhoQAkkVjYgCbk4VXYUEypFmCsuR5QZSnRtF7s3pREQkKkHCkeHy8oM8GiSk5",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/6h/1h",
+ "type": "sh-wsh",
+ "purpose": "#6 Multisig Sig (Nested)",
+ "xpub": "tpubDEgvZFpzpkntdnZnZzCEezNvPihuEbxVRbR31dZS66iJxfQBsC8Fg1WsB9bp2HDeFboAFBWJwHabnKVy8y4CBUofEu3vK4QLoNeGgwgVMZi"
+ },
+ {
+ "original": "Vpub5mYrYvPvP5Jr8draW5U6M7m9xCPukVXQtTCp6h5HKDQRQb2W8Ce2Vsc4KbQRuTuaJhyPSJRB7koQ6sTtM4jj4Mi6GN1vHAXZSdQ8SG55ihw",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/6h/2h",
+ "type": "wsh",
+ "purpose": "#6 Multisig Sig (Segwit)",
+ "xpub": "tpubDEgvZFpzpkntgTGHgKZT98wiuTkCQmKsx3Bt6mef3N5fdtkkwLRxoKjEoLSusLwkgRfvgF83gxx3STZrt6G3PnQvEB3fj7MmgJqxPWbWPxa"
+ },
+ {
+ "original": "upub5EKoQv21nQNm1q8NAC2rewfMBmq19fvRPM7mwo1A7agcwMHGBEfD6RWz5ucCZSpeQXGjLJPFxN7K838YyVtqwzc1DKYfxtv8UatsPoYeTHC",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/49h/1h/7h",
+ "type": "sh-wpkh",
+ "purpose": "#7 Single Sig (Nested)",
+ "xpub": "tpubDDCDr9rSwixeqWta7TNpV8HE2oMzxvPygKGjeXjxbWa9qwPf1Rzz74AEPZZk5fjeRMA4CJgosT11U5TrXSz8E976swPFQA2psigDKUJemiv"
+ },
+ {
+ "original": "vpub5Y35MNUT8sURLyMhyndVrEKhoKYv3hzjhYFoMGTdK6nm3jtXkBG1u45WrLf2u5fKRyR64Uao5Xs1DnGNgvrpAVv8xojSLET69caKBg7ypie",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/84h/1h/7h",
+ "type": "wpkh",
+ "purpose": "#7 Single Sig (Segwit)",
+ "xpub": "tpubDC5EUwdy9WWqKMvo6hBqUKr5UNwTvLUo5PtYGcJYR2JQuEBhKiSEHd4d8nezRPvQ3ABcB1HnXxQ9gXz7XBY5eQjdm5sbBakJH2J1iq8UvZc"
+ },
+ {
+ "original": "Upub5T4M7Sm4okSNqdSmt1TxbsmWpLczVoot5cmLjw7EbSUoQ13Yh622XmvjTphUgr2MMqWymfGijavxRPo3ZnvRiPteKHSWVFz5KBgMdBVGHMz",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/7h/1h",
+ "type": "sh-wsh",
+ "purpose": "#7 Multisig Sig (Nested)",
+ "xpub": "tpubDF2gRSs4Q7TuEk3btcLwbz3awa7j6hbr4KGdXQaVhbXvgQb2ksyXTHi3xmhYediT9CLKm5a9mTS9eGWapWrkr4H59SAgX7dmpbBpy24rrEq"
+ },
+ {
+ "original": "Vpub5mtcR7RyxRyrhxjb1ZVmHvzadnmMk6k8PvfyvtosdaA1jVrnCGv73wmkKWk3v8SBrBDNz98wkLAVTmeSfp9xPFKeo7KdM5TbZPCzw7vZoTt",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/7h/2h",
+ "type": "wsh",
+ "purpose": "#7 Multisig Sig (Segwit)",
+ "xpub": "tpubDF2gRSs4Q7TuFn9JBob85xB9b47eQNYbTWf3vyPFMiqFxob31Qi3MPtvoFnXt1UNDtuvE5qpKYK8oMkRCqgGig2UkvMNo2Hoo4eptLhCiST"
+ },
+ {
+ "original": "upub5EKoQv21nQNm4XeNALECoMbF21HFcyfrXQb24kQu1LVq8pFpozWp5Uridk19VLT4o6J7Q6RAauT22crjYtDX8zYPYrrreRbcW6X4EpX9Mpn",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/49h/1h/8h",
+ "type": "sh-wpkh",
+ "purpose": "#8 Single Sig (Nested)",
+ "xpub": "tpubDDCDr9rSwixetDQa7baAdYD7s2pFSE9QpNjymV9hVGPN3QNDeBrb67VxwPxh1ZN4ovBSG6iiVzLiNfC36qJoR93VDUhS5giJuEJQAZdV1ip"
+ },
+ {
+ "original": "vpub5Y35MNUT8sURPk9bgmUzqMDNXgyTtNUrTMX56xf4MaYLJhSC8uWGsj1QYeemMAEeJ3dCcUbHSRhpdSsH8cVeD559N6tLpwXKQjJD1eKenwu",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/84h/1h/8h",
+ "type": "wpkh",
+ "purpose": "#8 Single Sig (Segwit)",
+ "xpub": "tpubDC5EUwdy9WWqN8igog3LTSjkCkN1kzxuqD9p2JVyTW3zABjMiSgVGHzWq6eisUViuEPij1JGtrEy6Cb1xsAugyteAP2VgHpXY91uYpkixb8"
+ },
+ {
+ "original": "Upub5SAJAYu4nm5wC2JZT4Pq67xQ7vLsVLqYJgoVqLoyQvWDWkw1XpbhyezkrSmfs6qM28gJ8hnHVnmvtaA4j5PNBHxG5NeWneL6TLdWxva7533",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/8h/1h",
+ "type": "sh-wsh",
+ "purpose": "#8 Multisig Sig (Nested)",
+ "xpub": "tpubDE8dUZ14P87Tb8uPTfGp6EEUF9qc6EdWHPJncpHEX5ZLoAUVbcZCuAn5MPmjptXSoVVe885iXfH87SsbyoKhJxLguXNgpVynxk8zJiSXwxa"
+ },
+ {
+ "original": "Vpub5kzZUDZywSdR7T4wKcXz61aTTjHQP6Qd19qGsgwjzg4Gi7Wzf26wpq38tX2Kx9qLvUVZbsvULrLSTuFsKHcMhh5YHTmhF48m2qhqiakyF6e",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/8h/2h",
+ "type": "wsh",
+ "purpose": "#8 Multisig Sig (Segwit)",
+ "xpub": "tpubDE8dUZ14P87TfGUeVrdLt2m2Qzdh3ND64jpLsmX7ipjWwRFFU9tt8HAKNG4ov2sXJCC6qpdLv4V5oVMqrK8g37nNFGoSgzxyGX9ffsxL8Cz"
+ },
+ {
+ "original": "upub5EKoQv21nQNm6Y8Na9Ep6m8H7A6z9PhAoW8dF1BfLBAvuVYb1xgEqaXRYrRpkxe7vXYw1AaRSB1JE9WuKAxvRMoSCRcpae1CRsSRaWbh43y",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/49h/1h/9h",
+ "type": "sh-wpkh",
+ "purpose": "#9 Single Sig (Nested)",
+ "xpub": "tpubDDCDr9rSwixevDtaXQamvwk9xBdyxeAj6UHawjvTp74Tp5eyrA21rDAfrWPNHBZ7wMSFsAsyMFtzaBrCs84ChWJXs3TQ1u7tq1DmWERYJhX"
+ },
+ {
+ "original": "vpub5Y35MNUT8sURRVraj7fgYjPgp15hqEAjm8jMW6DBMHKfSywQY2GCoAKqUjSzWXzE6KcWdCgKju4vmiPYEa8L3wtdcPSPe1EGi7EgHABMg2o",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/84h/1h/9h",
+ "type": "wpkh",
+ "purpose": "#9 Single Sig (Segwit)",
+ "xpub": "tpubDC5EUwdy9WWqPtRfr2E2Apv4V4UFhreo8zN6RS46TCqKJUEa7ZSRBjJwmBSx2rFJhWP2jjPKCKc5EU7H4pobXri8QfaYVMXUqWxNpM4Thp3"
+ },
+ {
+ "original": "Upub5SZqPpGQLXLrZiASDasMaoUae2fRr6frs17aAWyEpKmnrytb3uDK2TyPJawmuayNdpaheEjLjMBxAfg2j9M2zte5iFwjxSo4tvnuKsryBZW",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/9h/1h",
+ "type": "sh-wsh",
+ "purpose": "#9 Multisig Sig (Nested)",
+ "xpub": "tpubDEYAhpNPvtNNxpmGEBkLaukemGAASzTpqhcrwzSVvUpv9PS57hAowykhoXwqsNfURBQ3df2mmDh9PYPZysHN8Z2WYQfuzJSmQLJNfeBysrh"
+ },
+ {
+ "original": "Vpub5mQ6hUwKVCtLSmZFysmVTUeMfkSgYdwFYipCf6zaLfyXdVRGY4LjUuKvM7fi5zJKuA7VoZ5gKkuHUdjkS5HH81V9owruYMvZM5JPHgpU94i",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/9h/2h",
+ "type": "wsh",
+ "purpose": "#9 Multisig Sig (Segwit)",
+ "xpub": "tpubDEYAhpNPvtNNzaxyA7rrFVpvd1nyCujicJoGfBZx4pemro9XMC8fnMT6priC3sLWGsp33VnYty3vpDqiy6obTSBymktezJkmakkDEwVb9e2"
+ },
+ {
+ "original": "upub5EKoQv21nQNmBMp8zqKWqi4exGTsvswkeDNbexHNursybdRAiYoXprZFy6yfZDwKQgHmLxPpKh9BwNa1fJdyjRcE3a51s11UVCLaRkmbE3U",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/49h/1h/10h",
+ "type": "sh-wpkh",
+ "purpose": "#10 Single Sig (Nested)",
+ "xpub": "tpubDDCDr9rSwixf13aLx6fUftgXoHzsk8RJwBXZMh2BPnmWWDXZYk9JqVCWGkwD5SrKRWB6CxhNEn2tHQuKDFjG1a7KiBubJG8AtL7vMZ2ccQc"
+ },
+ {
+ "original": "vpub5Y35MNUT8sURVkrRjbTjecBsrupUkvEJMVu7z6xkYoRMMnorxKeBAPbRjAzk3s8qwPJeFbEWi6BFSuVYV46Z1YnEQJ1wuwMpoA1m4LyZ17p",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/84h/1h/10h",
+ "type": "wpkh",
+ "purpose": "#10 Single Sig (Segwit)",
+ "xpub": "tpubDC5EUwdy9WWqU9RWrW25GhiFXyD2dYiMjMXruSofeiw1DH72XrpPYxaY1czhaBPvYa5AN7wWAWiPufDHKJmpVTbjCaA6mHf2vZjTbYS944S"
+ },
+ {
+ "original": "Upub5TmeuYoRtjgjZWavZYFmc7rnHgUWw2kVw6N4Y19u5xjXPu8K5ZM3YT6kZyxwAD2tMHa5tbpJJi1B7zkpzTu6cCE3rRppmFy3BU9DPcLU9AS",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/10h/1h",
+ "type": "sh-wsh",
+ "purpose": "#10 Multisig Sig (Nested)",
+ "xpub": "tpubDFjzDYuRV6iFxdBka98kcE8rQuyFXvYTunsMKUdAC7negJfo9MJYTxt54vy17ziz8ePRt27jLaWNLsUNFBqRjrcUgaYzo7cjgsegjT4EYhP"
+ },
+ {
+ "original": "Vpub5nbvDDUM3REDStVR5hGfkf6DtJAFsWhyg9c2ZAFrBx9sTE16sJeoUe1cWG1UaSZVMn9FFf8hzoQtUMMfLK6Li5AiKgDYS6JSxJ3LuZBRA4K",
+ "fingerprint": "1ef4e492",
+ "derivation": "m/48h/1h/10h/2h",
+ "type": "wsh",
+ "purpose": "#10 Multisig Sig (Segwit)",
+ "xpub": "tpubDFjzDYuRV6iFzhu8FwN2YgGnqZWYXnWSjjb6ZEqDv6q7gXjMgSSjn68nz13xYKbfjVqnVbqaa1ZXowTdsLcf3VsYHVFHt38fByVArkpZ5ZP"
+ }
+
+ ]
+}
\ No newline at end of file
diff --git a/tests/test_cli_server.py b/tests/test_cli_server.py
index 3e02301253..b47f4da7e8 100644
--- a/tests/test_cli_server.py
+++ b/tests/test_cli_server.py
@@ -8,6 +8,7 @@
from mock import MagicMock, call, patch
mock_config_dict = {
+ "HOST": "127.0.0.1",
"PORT": "123",
"DEBUG": "WURSTBROT",
"SPECTER_SSL_CERT_SUBJECT_C": "AT",
@@ -139,6 +140,7 @@ def test_server_config(init_app, create_app, caplog):
mock_app = MagicMock()
mock_app.config = MagicMock()
d = {
+ "HOST": "127.0.0.1",
"PORT": "123",
"DEBUG": "WURSTBROT",
"SPECTER_SSL_CERT_SUBJECT_C": "AT",
diff --git a/tests/test_ep_wallet_api.py b/tests/test_ep_wallet_api.py
index decc391d82..34fafb9a1c 100644
--- a/tests/test_ep_wallet_api.py
+++ b/tests/test_ep_wallet_api.py
@@ -178,10 +178,10 @@ def test_addressinfo(caplog, client, funded_ghost_machine_wallet):
res = client.post(
url, data={"address": invalid_address}, follow_redirects=True, headers=headers
)
- assert (
- res.data.decode()
- == '{"error":"Request error for method getaddressinfo: Invalid address format"}\n'
+ assert res.data.decode().startswith(
+ '{"error":"Request error for method getaddressinfo'
)
+ assert res.data.decode().endswith('Invalid address format"}\n')
# send post request with address, not belonging to wallet
# this recreates an edge case, see https://github.com/cryptoadvance/specter-desktop/issues/2000
diff --git a/tests/test_managers_device.py b/tests/test_managers_device.py
index 1a52ac85a2..aa2ea5a7a6 100644
--- a/tests/test_managers_device.py
+++ b/tests/test_managers_device.py
@@ -126,7 +126,6 @@ def test_device_wallets(
):
caplog.set_level(logging.DEBUG)
wm = WalletManager(
- 200100,
devices_filled_data_folder,
bitcoin_regtest.get_rpc(),
"regtest",
diff --git a/tests/test_managers_node.py b/tests/test_managers_node.py
index 331dd69524..005bc649f8 100644
--- a/tests/test_managers_node.py
+++ b/tests/test_managers_node.py
@@ -23,8 +23,8 @@ def test_node_manager_basics(
nm = specter_regtest_configured.node_manager
# # Load from disk to get the other two nodes
assert sorted(list(nm.nodes.keys())) == [
- "default",
- "satoshis_node",
+ "bitcoin_core",
+ "node_with_a_different_port",
"standard_node",
]
assert nm.nodes_names == [
@@ -39,23 +39,25 @@ def test_node_manager_basics(
"Bitcoin Core",
]
# Checking some standard methods and properties
- assert nm.get_by_alias("satoshis_node") == nm.get_by_name(
+ assert nm.get_by_alias("node_with_a_different_port") == nm.get_by_name(
"Node with a different port"
)
- default_node = nm.get_by_alias("default")
- satoshis_node = nm.get_by_alias("satoshis_node")
- assert nm.default_node() == default_node
+ default_node = nm.get_by_alias("bitcoin_core")
+ node_with_a_different_port = nm.get_by_alias("node_with_a_different_port")
assert nm.active_node == default_node
- assert specter_regtest_configured.config["active_node_alias"] == "default"
+ assert specter_regtest_configured.config["active_node_alias"] == "bitcoin_core"
# Switching the node via the node manager does not change the active_node_alias in the config, only specter.update_active_node() does
- nm.switch_node("satoshis_node")
- assert nm.active_node == satoshis_node
- assert specter_regtest_configured.config["active_node_alias"] == "default"
- specter_regtest_configured.update_active_node("satoshis_node")
- assert specter_regtest_configured.config["active_node_alias"] == "satoshis_node"
- assert nm.active_node == satoshis_node
+ nm.switch_node("node_with_a_different_port")
+ assert nm.active_node == node_with_a_different_port
+ assert specter_regtest_configured.config["active_node_alias"] == "bitcoin_core"
+ specter_regtest_configured.update_active_node("node_with_a_different_port")
+ assert (
+ specter_regtest_configured.config["active_node_alias"]
+ == "node_with_a_different_port"
+ )
+ assert nm.active_node == node_with_a_different_port
# Deleting a node
- nm.delete_node(satoshis_node, specter_regtest_configured)
+ nm.delete_node(node_with_a_different_port, specter_regtest_configured)
assert nm.nodes_names == ["Standard node", "Bitcoin Core"]
# Check that with the deletion of the active node the switch to the next node work, the first node in the list, here the Standard node, is switched to
assert specter_regtest_configured.config["active_node_alias"] == "standard_node"
@@ -65,9 +67,11 @@ def test_node_manager_basics(
SpecterError,
match="Node with a different port not found, node could not be deleted.",
):
- nm.delete_node(satoshis_node, specter_regtest_configured)
- with pytest.raises(SpecterError, match="Node alias satoshis_node does not exist!"):
- nm.switch_node("satoshis_node")
+ nm.delete_node(node_with_a_different_port, specter_regtest_configured)
+ with pytest.raises(
+ SpecterError, match="Node alias node_with_a_different_port does not exist!"
+ ):
+ nm.switch_node("node_with_a_different_port")
@pytest.mark.elm
@@ -89,10 +93,9 @@ def test_switch_nodes_across_chains(
bitcoin_regtest.rpcconn.rpcport,
bitcoin_regtest.rpcconn._ipaddress,
"http",
- "bitcoin_regtest_alias",
)
- assert nm.nodes_names == ["Bitcoin Core", "bitcoin_regtest"]
- nm.switch_node("bitcoin_regtest_alias")
+ assert nm.nodes_names == ["bitcoin_regtest"]
+ nm.switch_node("bitcoin_regtest")
assert nm.active_node.rpc.getblockchaininfo()["chain"] == "regtest"
nm.add_external_node(
"ELM",
@@ -105,6 +108,6 @@ def test_switch_nodes_across_chains(
elements_elreg.rpcconn._ipaddress,
"http",
)
- assert nm.nodes_names == ["Bitcoin Core", "bitcoin_regtest", "elements_elreg"]
+ assert nm.nodes_names == ["bitcoin_regtest", "elements_elreg"]
nm.switch_node("elements_elreg")
assert nm.active_node.rpc.getblockchaininfo()["chain"] == "elreg"
diff --git a/tests/test_managers_service.py b/tests/test_managers_service.py
index 41e40bbae1..c58513f44f 100644
--- a/tests/test_managers_service.py
+++ b/tests/test_managers_service.py
@@ -86,7 +86,7 @@ def test_ServiceManager_get_service_packages(caplog):
# This needs to be adjusted with each new extension
# We don't need to assert every single package but we also ensure with that, that we don't
# loose anything on the way of changing something in the service_manager
- assert len(packages) == 28
+ assert len(packages) == 31
def test_ServiceManager_make_path_relative(caplog):
diff --git a/tests/test_managers_wallet.py b/tests/test_managers_wallet.py
index 4a58b25b3b..a244a77275 100644
--- a/tests/test_managers_wallet.py
+++ b/tests/test_managers_wallet.py
@@ -2,7 +2,7 @@
import logging
import os
import time
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
import pytest
from cryptoadvance.specter.devices.bitcoin_core import BitcoinCore
@@ -21,6 +21,7 @@
logger = logging.getLogger(__name__)
+@patch("cryptoadvance.specter.util.wallet_importer.flash", print)
@pytest.mark.slow
def test_WalletManager(
request,
@@ -31,7 +32,6 @@ def test_WalletManager(
node_with_empty_datadir,
):
wm = WalletManager(
- 200100,
devices_filled_data_folder,
bitcoin_regtest.get_rpc(),
"regtest",
@@ -127,7 +127,6 @@ def test_WalletManager_2_nodes(
):
caplog.set_level(logging.INFO)
wm = WalletManager(
- 200100,
devices_filled_data_folder,
bitcoin_regtest.get_rpc(),
"regtest",
@@ -144,27 +143,30 @@ def test_WalletManager_2_nodes(
assert wm.chain == "regtest"
assert wm.working_folder.endswith("regtest")
assert wm.rpc.port == 18543
- # Change the rpc - this only works with a different chain!
- wm.update(rpc=bitcoin_regtest2.get_rpc(), chain="regtest2")
+ # Change the rpc - this works differently with a different chain!
+ # If we use something different that regtest, unfortunately a liquid address gets
+ # generated.
+ # So we don't test that scanrio here of different chains. We test the scenario with the same chain.
+ # but different node
+ wm.update(rpc=bitcoin_regtest2.get_rpc(), chain="regtest")
# A WalletManager uses the chain as an index
assert list(wm.rpcs.keys()) == [
"regtest",
- "regtest2",
] # wm.rpcs looks like this: {'regtest': , 'regtest2': }
assert wm.rpc.port == 18544
- assert wm.wallets_names == []
- assert wm.chain == "regtest2"
- assert wm.working_folder.endswith("regtest2")
+ assert wm.wallets_names == ["a_test_wallet"]
+ assert wm.chain == "regtest"
+ assert wm.working_folder.endswith("test")
second_wallet = wm.create_wallet(
"a_regtest2_test_wallet", 1, "wpkh", [device.keys[5]], [device]
)
# Note: "regtest2" is recognised by the get_network() from embit as Liquid, that is why there is an error in the logs saying the Bitcoin address is not valid since a Liquid address is derived.
- assert wm.wallets_names == ["a_regtest2_test_wallet"]
+ assert len(wm.wallets_names) == 2
+ assert wm.wallets_names == ["a_regtest2_test_wallet", "a_test_wallet"]
def test_WalletManager_check_duplicate_keys(empty_data_folder):
wm = WalletManager(
- 200100,
empty_data_folder,
MagicMock(), # needs rpc
"regtest",
@@ -231,7 +233,6 @@ def test_wallet_sortedmulti(
bitcoin_regtest, devices_filled_data_folder, device_manager
):
wm = WalletManager(
- 200100,
devices_filled_data_folder,
bitcoin_regtest.get_rpc(),
"regtest",
@@ -285,7 +286,6 @@ def test_wallet_sortedmulti(
def test_wallet_labeling(bitcoin_regtest, devices_filled_data_folder, device_manager):
wm = WalletManager(
- 200100,
devices_filled_data_folder,
bitcoin_regtest.get_rpc(),
"regtest",
@@ -344,7 +344,6 @@ def test_wallet_change_addresses(
bitcoin_regtest, devices_filled_data_folder, device_manager
):
wm = WalletManager(
- 200100,
devices_filled_data_folder,
bitcoin_regtest.get_rpc(),
"regtest",
@@ -354,14 +353,14 @@ def test_wallet_change_addresses(
device = device_manager.get_by_alias("specter")
key = Key.from_json(
{
- "derivation": "m/48h/1h/0h/2h",
- "original": "Vpub5n9kKePTPPGtw3RddeJWJe29epEyBBcoHbbPi5HhpoG2kTVsSCUzsad33RJUt3LktEUUPPofcZczuudnwR7ZgkAkT6N2K2Z7wdyjYrVAkXM",
+ "derivation": "m/84h/1h/0h",
+ "original": "vpub5ZSem3mLXiSJzgDX6pJb2N9L6sJ8m6ejaksLPLSuB53LBzCi2mMsBg19eEUSDkHtyYp75GATjLgt5p3S43WjaVCXAWU9q9H5GhkwJBrMiAb",
"fingerprint": "08686ac6",
- "type": "wsh",
- "xpub": "tpubDFHpKypXq4kwUrqLotPs6fCic5bFqTRGMBaTi9s5YwwGymE8FLGwB2kDXALxqvNwFxB1dLWYBmmeFVjmUSdt2AsaQuPmkyPLBKRZW8BGCiL",
+ "type": "wpkh",
+ "xpub": "tpubDDUotcvrYMUiy4ncDirveTfhmvggdj8nxcW5JgHpGzYz3UVscJY5aEzFvgUPk4YyajadBnsTBmE2YZmAtJC14Q21xncJgVaHQ7UdqMRVRbU",
}
)
- wallet = wm.create_wallet("a_second_test_wallet", 1, "wpkh", [key], [device])
+ wallet: wallet = wm.create_wallet("a_third_test_wallet", 1, "wpkh", [key], [device])
address = wallet.address
change_address = wallet.change_address
diff --git a/tests/test_node.py b/tests/test_node.py
index 4c63274d12..f2664a85a8 100644
--- a/tests/test_node.py
+++ b/tests/test_node.py
@@ -10,7 +10,8 @@
from mock import MagicMock, call, patch
-def test_Node_btc(bitcoin_regtest, wallet):
+def test_Node_btc(bitcoin_regtest, trezor_wallet_acc2):
+ wallet = trezor_wallet_acc2
with tempfile.TemporaryDirectory("_some_datafolder_tmp") as data_folder:
node = Node.from_json(
{
diff --git a/tests/test_notifications.py b/tests/test_notifications.py
new file mode 100644
index 0000000000..660bb68987
--- /dev/null
+++ b/tests/test_notifications.py
@@ -0,0 +1,152 @@
+import logging
+from mock import MagicMock, call, patch
+import pytest
+from cryptoadvance.specter.specter import Specter, UserManager
+from cryptoadvance.specterext.notifications.notification_manager import (
+ NotificationManager,
+)
+import datetime
+
+logger = logging.getLogger(__name__)
+
+
+@pytest.fixture
+def specter_with_user(empty_data_folder):
+ """This assumes a bitcoin-testnet-node is running on loalhost"""
+
+ specter = Specter(data_folder=empty_data_folder)
+ user_manager = UserManager(specter=specter)
+
+ password = "somepassword"
+ user_id = "someuser"
+ username = "someuser"
+ config = {}
+
+ user = user_manager.create_user(
+ user_id=user_id, username=username, plaintext_password=password, config=config
+ )
+ yield specter
+
+
+def test_sending_logging_notification(specter_with_user: Specter, caplog):
+
+ notification_manager = NotificationManager(
+ host="localhost",
+ port="1234",
+ ssl_cert=None,
+ ssl_key=None,
+ enable_websockets=False,
+ )
+
+ # do not take the admin user but the "someuser", just to make sure "someuser" can use the default_ui_notifications
+ user = specter_with_user.user_manager.get_user("someuser")
+ ui_notifications_of_user = notification_manager._get_ui_notifications_of_user(
+ user.username
+ )
+
+ # _register_default_ui_notifications should have created 3 ui_notifications accessible for all users
+ assert len(ui_notifications_of_user) == 3
+ assert ui_notifications_of_user[0].name == "logging"
+ assert ui_notifications_of_user[1].name == "print"
+ assert ui_notifications_of_user[2].name == "flash"
+
+ notification = notification_manager.create_notification(
+ "testing title",
+ user.username,
+ target_uis="default",
+ date=datetime.datetime(2022, 7, 31, 20, 23, 49, 541516),
+ body="testing body",
+ data={"key": 1},
+ image="someurl",
+ icon="someurl",
+ timeout=3000,
+ )
+ # the notification was stored in notification_manager.notifications
+ assert len(notification_manager.notifications) == 1
+ assert notification_manager.notifications[0].id == notification.id
+
+ # check if the Notification was created correctly
+ notification_str = """{'title': 'testing title', 'user_id': 'someuser', 'date': datetime.datetime(2022, 7, 31, 20, 23, 49, 541516), 'last_shown_date': {}, 'was_closed_in_target_uis': set(), 'target_uis': {'logging'}, 'notification_type': 'information', 'body': 'testing body', 'data': {'key': 1}, 'image': 'someurl', 'icon': 'someurl', 'timeout': 3000, 'id': 'c8b752e0cb679ee13b44497c4b02b11cfafdd378757666b1f9ee805d4a5e7c5a'}"""
+ assert str(notification) == notification_str
+
+ # capture if the notification was actually shown
+ with caplog.at_level(logging.INFO):
+ notification_manager.show(notification)
+
+ # check if any of the INFO messages was the notification
+ notification_found = False
+ logger.debug(caplog.records[-1])
+ for record in caplog.records:
+ if record.message == notification.to_text() and record.levelname == "INFO":
+ notification_found = True
+ assert notification_found
+
+ # the notification was deleted again
+ assert len(notification_manager.notifications) == 0
+
+
+def mock_flash(*args, **kwargs):
+ flash_message = str((args, kwargs))
+ print(flash_message)
+ logger.warning(flash_message)
+
+
+# check that register_user_ui_notifications registers the flash message
+# check that flash messages would send the correct message to flash
+@patch("cryptoadvance.specterext.notifications.ui_notifications.flash", mock_flash)
+def test_sending_flash_notification(specter_with_user: Specter, caplog):
+
+ notification_manager = NotificationManager(
+ host="localhost",
+ port="1234",
+ ssl_cert=None,
+ ssl_key=None,
+ enable_websockets=False,
+ )
+
+ # do not take the admin user but the "someuser", just to make sure "someuser" can use the default_ui_notifications
+ user = specter_with_user.user_manager.get_user("someuser")
+ notification_manager.register_user_ui_notifications(user.username)
+
+ ui_notifications_of_user = notification_manager._get_ui_notifications_of_user(
+ user.username
+ )
+
+ # _register_default_ui_notifications should have created 3 ui_notifications accessible for all users
+ assert len(ui_notifications_of_user) == 3
+ assert ui_notifications_of_user[0].name == "logging"
+ assert ui_notifications_of_user[1].name == "print"
+ assert ui_notifications_of_user[2].name == "flash"
+
+ notification = notification_manager.create_notification(
+ "testing title",
+ user.username,
+ target_uis="flash",
+ date=datetime.datetime(2022, 7, 31, 20, 23, 49, 541516),
+ body="testing body",
+ data={"key": 1},
+ image="someurl",
+ icon="someurl",
+ timeout=3000,
+ )
+ # the notification was stored in notification_manager.notifications
+ assert len(notification_manager.notifications) == 1
+ assert notification_manager.notifications[0].id == notification.id
+
+ # check if the Notification was created correctly
+ notification_str = """{'title': 'testing title', 'user_id': 'someuser', 'date': datetime.datetime(2022, 7, 31, 20, 23, 49, 541516), 'last_shown_date': {}, 'was_closed_in_target_uis': set(), 'target_uis': {'flash'}, 'notification_type': 'information', 'body': 'testing body', 'data': {'key': 1}, 'image': 'someurl', 'icon': 'someurl', 'timeout': 3000, 'id': '56daf971f214d75949a8888654f398d8b8efe14ab027afa1b3393835d4d3edd8'}"""
+ assert str(notification) == notification_str
+
+ # capture if the notification was actually shown. Set it it warning, to only cature the mock_flash message
+ with caplog.at_level(logging.WARNING):
+ notification_manager.show(notification)
+
+ # check if any of the INFO messages was the notification
+ assert (
+ caplog.records[-1].message
+ == """(("testing title\\ntesting body\\nData: {\'key\': 1}", \'information\'), {})"""
+ )
+ assert caplog.records[-1].levelname == "WARNING"
+
+ # the notification was deleted again
+ assert len(notification_manager.notifications) == 0
diff --git a/tests/test_services.py b/tests/test_services.py
index 3b88ae6eb4..edb03d0b84 100644
--- a/tests/test_services.py
+++ b/tests/test_services.py
@@ -417,29 +417,30 @@ def test_reserve_addresses_with_mocks(empty_data_folder, caplog):
assert addresses == ["a", "b"]
-def test_reserve_addresses_with_an_actual_wallet(wallet):
+def test_reserve_addresses_with_an_actual_wallet(trezor_wallet_acc6):
+ wallet = trezor_wallet_acc6
specter_mock = MagicMock()
test_service = MyTestService(True, specter_mock)
# Reserve first address
test_service.reserve_address(
- wallet, "bcrt1qcatuhg0gll3h7py4cmn53rjjn9xlsqfwj3zcej", "reserved_for_john_nash"
+ wallet, "bcrt1qqvqdt5nsjhzrxcvhyz3m29f8qwyd4lrcpmtzy9", "reserved_for_john_nash"
)
first_address_address_obj = wallet.get_address_obj(
- "bcrt1qcatuhg0gll3h7py4cmn53rjjn9xlsqfwj3zcej"
+ "bcrt1qqvqdt5nsjhzrxcvhyz3m29f8qwyd4lrcpmtzy9"
)
# Check that labeling works
assert first_address_address_obj["label"] == "reserved_for_john_nash"
# Simulating that the address has been used (for the definition of "usage" see the check_unused() method in wallet.py)
- wallet._addresses.set_used(["bcrt1qcatuhg0gll3h7py4cmn53rjjn9xlsqfwj3zcej"])
+ wallet._addresses.set_used(["bcrt1qqvqdt5nsjhzrxcvhyz3m29f8qwyd4lrcpmtzy9"])
wallet.getnewaddress()
assert wallet.address_index == 1
assert first_address_address_obj["used"] == True
# Check that the correct addresses are reserved, should be #2, #4, #6 - since #0 has been used and there is a gap of one address in between
addresses = test_service.reserve_addresses(wallet, "satoshi_dice", 3)
assert addresses == [
- "bcrt1qxak08ykhf7r4js9yncysy5p05xp0fwxhamewc8",
- "bcrt1q2zv9963acq3g7a62mdjgj60rr3hgmyykaccca7",
- "bcrt1qpys58dndrn9sxnk0z7ngm6wsxskpvs9jsjq7q6",
+ "bcrt1qyqta5muj054x43cmk8rv43up84kefz9ej3y27n",
+ "bcrt1qcswaeygm5w0y7xysqkn4uy9d6u0x6yxtlyntn6",
+ "bcrt1qwjz0ez6g763cfty6pf984247yngfs75h2n6ccz",
]
address_obj_list = wallet.get_associated_addresses("test_service")
# Reserving 3 addresses results in an empty list since we already have 3 unused addresses (the first one was used) reserved
diff --git a/tests/test_specter.py b/tests/test_specter.py
index fa5a17c160..0baf0105d8 100644
--- a/tests/test_specter.py
+++ b/tests/test_specter.py
@@ -91,7 +91,6 @@ def test_abandon_purged_tx(caplog, request, devices_filled_data_folder, device_m
# TODO: Make a test fixture in conftest.py that sets up already funded wallets
# for a bitcoin core hot wallet.
wallet_manager = WalletManager(
- 210100,
devices_filled_data_folder,
rpc,
"regtest",
diff --git a/tests/test_specter_migrator.py b/tests/test_specter_migrator.py
index 71fa1c43bf..3d4324bf29 100644
--- a/tests/test_specter_migrator.py
+++ b/tests/test_specter_migrator.py
@@ -88,7 +88,7 @@ def _check_port_free(port=8332):
def test_SpecterMigrator(empty_data_folder, caplog):
caplog.set_level(logging.DEBUG)
- assert _check_port_free()
+ assert _check_port_free(), "You probably have a bitcoind running on port 8332"
assert MigDataManager.initial_data()["events"] == []
assert MigDataManager.initial_data()["migration_executions"] == []
assert len(os.listdir(empty_data_folder)) == 0
diff --git a/tests/test_specterext_swan_service.py b/tests/test_specterext_swan_service.py
index 7dc5af52c1..8e0ffa2f2e 100644
--- a/tests/test_specterext_swan_service.py
+++ b/tests/test_specterext_swan_service.py
@@ -1,5 +1,6 @@
from mock import patch
from cryptoadvance.specterext.swan.service import SwanService
+from cryptoadvance.specter.wallet import Wallet
import json
@@ -11,7 +12,10 @@ class SwanServiceNoEncryption(SwanService):
@patch(
"cryptoadvance.specterext.swan.client.SwanClient.update_autowithdrawal_addresses"
)
-def test_reserve_addresses(mocked_update_autowithdrawal_addresses, app_no_node, wallet):
+def test_reserve_addresses(
+ mocked_update_autowithdrawal_addresses, app_no_node, trezor_wallet_acc5: Wallet
+):
+ wallet = trezor_wallet_acc5
mocked_update_autowithdrawal_addresses.return_value = "some_id"
specter = app_no_node.specter
storage_manager = specter.service_unencrypted_storage_manager
@@ -25,11 +29,11 @@ def test_reserve_addresses(mocked_update_autowithdrawal_addresses, app_no_node,
swan.reserve_addresses(wallet, label="Swan withdrawals", num_addresses=5)
# Check that the correct addresse list was passed to the client's update_autowithdrawal_addresses-method, should be addresses #1, #3, #5, #7, #9 from the test_wallet (the first address is skipped)
addresses = [
- "bcrt1qsqnuk9hulcfta7kj7687favjv66d5e9yy0lr7t",
- "bcrt1qee494mauu3fv5aje0t4p6e52hvwq6d5hcqfxqt",
- "bcrt1qpnem6p9vr8rmjsf7k49p9sleu0h020g34ggn6k",
- "bcrt1q8534jsqkympwaelaqxhvfr6hc3g8y4kjtgr6d6",
- "bcrt1qxd6ndd7mt7jqut7797l84675fz4kqhs4fcfgny",
+ "bcrt1qrfdnsdhmp5chxxexywdz37ppre7s5f0y4z4ykn",
+ "bcrt1qp8hq4ngf0uy4r5atackw9ak5ngl8vfd54dz226",
+ "bcrt1qsdzhz4q8y32maeay899jyfdw03pdlrrktx838g",
+ "bcrt1qc65gchplxw57e7hdzdxcudjq90y0y3mxm9m96l",
+ "bcrt1qasuqqj5u7t5e8zr3ug68yzfr2fjj4eg4u4ucj4",
]
assert (
mocked_update_autowithdrawal_addresses.call_args_list[0].kwargs["addresses"]
@@ -48,8 +52,8 @@ def test_reserve_addresses(mocked_update_autowithdrawal_addresses, app_no_node,
swan.reserve_addresses(wallet, label="Swan withdrawals", num_addresses=7)
# Adding address #11 and #13
additional_addresses = [
- "bcrt1q32gd5s7rk9ptkv8e74q4c64ntf48u4sza6c9d9",
- "bcrt1q463mg67f3tj5d223vf6387ty30qlx2wep4s5gp",
+ "bcrt1qa4n6687f53recfcthfu2xpgcwcqvmzz4pdfw98",
+ "bcrt1qcxq5md4jsnldpc6fswld8edaxgzpstamswt3r6",
]
addresses.extend(additional_addresses)
assert (
@@ -81,7 +85,10 @@ def reserve_addresses(cls, wallet, label: str = None, num_addresses: int = 10):
@patch("cryptoadvance.specterext.swan.client.SwanClient.set_autowithdrawal")
-def test_set_autowithdrawal_settings(mocked_set_autowithdrawal, app_no_node, wallet):
+def test_set_autowithdrawal_settings(
+ mocked_set_autowithdrawal, app_no_node, trezor_wallet_acc3
+):
+ wallet = trezor_wallet_acc3
autowithdrawal_api_response = """
{
"entity": "automaticWithdrawal",
diff --git a/tests/test_util_reflection.py b/tests/test_util_reflection.py
index 1a3a34c18d..098e2a1dc8 100644
--- a/tests/test_util_reflection.py
+++ b/tests/test_util_reflection.py
@@ -90,7 +90,7 @@ def test_get_subclasses_for_class(caplog):
classlist = get_subclasses_for_clazz(SpecterMigration)
assert len(classlist) >= 3
classlist = get_subclasses_for_clazz(Service)
- assert len(classlist) >= 4
+ assert len(classlist) >= 5
# checking naively for certain Services would be counterproductive if you import
# the class. THis needs to work without importing the class!
# But we can test like this: