diff --git a/cypress.json b/cypress.json index ddd0b13439..d9878ea45d 100644 --- a/cypress.json +++ b/cypress.json @@ -11,6 +11,7 @@ "spec_qr_signing.js", "spec_balances_amounts.js", "spec_wallet_send.js", + "spec_global_search.js", "spec_wallet_utxo.js", "spec_plugins.js", "spec_elm_single_segwit_wallet.js", diff --git a/cypress/integration/spec_global_search.js b/cypress/integration/spec_global_search.js new file mode 100644 index 0000000000..62805e588f --- /dev/null +++ b/cypress/integration/spec_global_search.js @@ -0,0 +1,34 @@ +describe('Do global searches', () => { + before(() => { + Cypress.config('includeShadowDom', true) + }) + + // Keeps the session cookie alive, Cypress by default clears all cookies before each test + beforeEach(() => { + cy.viewport(1200,660) + cy.visit('/') + Cypress.Cookies.preserveOnce('session') + }) + + it('Search', () => { + cy.addHotDevice("Hot Device 1","bitcoin") + cy.addWallet('Test Hot Wallet 1', 'segwit', 'funded', 'btc', 'singlesig', 'Hot Device 1') + cy.selectWallet("Test Hot Wallet 1") + + // check the #0 Receive address is found + cy.get('#global-search-input').clear() + cy.get('#global-search-input').type("bcrt1", {force:true}) + cy.get('#global-search-dropdown-content', { timeout: 3000 }).should('be.visible') + cy.get('#global-search-dropdown-content').contains('Address #0', { matchCase: false }) + + // check varias names and alias' + var searchTerms = ['Address #0', 'Change #10', 'Test Hot Wallet 1', 'Test_Hot_Wallet_1', "Hot Device 1", "Hot_Device_1"]; + for (var i in searchTerms){ + cy.get('#global-search-input').clear() + cy.get('#global-search-input').type(searchTerms[i], {force:true}) + cy.get('#global-search-dropdown-content', { timeout: 3000 }).should('be.visible') + cy.get('#global-search-dropdown-content').contains(searchTerms[i], { matchCase: false }) + } + + }) +}) \ No newline at end of file diff --git a/src/cryptoadvance/specter/config.py b/src/cryptoadvance/specter/config.py index 37dad868db..6824e5cac2 100644 --- a/src/cryptoadvance/specter/config.py +++ b/src/cryptoadvance/specter/config.py @@ -179,6 +179,7 @@ class BaseConfig(object): "cryptoadvance.specterext.devhelp.service", "cryptoadvance.specterext.exfund.service", "cryptoadvance.specterext.faucet.service", + "cryptoadvance.specterext.globalsearch.service", ] # This is just a placeholder in order to be aware that you cannot set this diff --git a/src/cryptoadvance/specter/server_endpoints/wallets.py b/src/cryptoadvance/specter/server_endpoints/wallets.py index 12544f7fb7..36faa51792 100644 --- a/src/cryptoadvance/specter/server_endpoints/wallets.py +++ b/src/cryptoadvance/specter/server_endpoints/wallets.py @@ -386,9 +386,17 @@ def wallet(wallet_alias): @wallets_endpoint.route("/wallet//history/", methods=["GET", "POST"]) @login_required def history(wallet_alias): + return history_tx_list_type(wallet_alias, "txlist") + + +@wallets_endpoint.route( + "/wallet//history//", methods=["GET", "POST"] +) +@login_required +def history_tx_list_type(wallet_alias, tx_list_type): wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) - tx_list_type = "txlist" + txid_to_show_on_load = None if request.method == "POST": action = request.form["action"] if action == "freezeutxo": @@ -400,6 +408,8 @@ def history(wallet_alias): wallet.abandontransaction(txid) except SpecterError as e: flash(str(e), "error") + elif action == "show_tx_on_load": + txid_to_show_on_load = request.form["txid"] # update balances in the wallet app.specter.check_blockheight() @@ -411,6 +421,7 @@ def history(wallet_alias): wallet_alias=wallet_alias, wallet=wallet, tx_list_type=tx_list_type, + txid_to_show_on_load=txid_to_show_on_load, specter=app.specter, rand=rand, services=app.specter.service_manager.services, @@ -738,11 +749,25 @@ def import_psbt(wallet_alias): @wallets_endpoint.route("/wallet//addresses/", methods=["GET"]) @login_required def addresses(wallet_alias): + return addresses_with_type(wallet_alias, "receive") + + +@wallets_endpoint.route( + "/wallet//addresses//", methods=["GET", "POST"] +) +@login_required +def addresses_with_type(wallet_alias, address_type): """Show informations about cached addresses (wallet._addresses) of the . It updates balances in the wallet before renderization in order to show updated UTXO and balance of each address.""" wallet = app.specter.wallet_manager.get_by_alias(wallet_alias) + address_json_to_show_on_load = None + if request.method == "POST": + action = request.form["action"] + if action == "show_address_on_load": + address_json_to_show_on_load = request.form["address_dict"] + # update balances in the wallet app.specter.check_blockheight() wallet.update_balance() @@ -755,6 +780,8 @@ def addresses(wallet_alias): specter=app.specter, rand=rand, services=app.specter.service_manager.services, + address_type=address_type, + address_json_to_show_on_load=address_json_to_show_on_load, ) diff --git a/src/cryptoadvance/specter/server_endpoints/wallets_api.py b/src/cryptoadvance/specter/server_endpoints/wallets_api.py index 918f926212..f66ad10a29 100644 --- a/src/cryptoadvance/specter/server_endpoints/wallets_api.py +++ b/src/cryptoadvance/specter/server_endpoints/wallets_api.py @@ -27,6 +27,7 @@ from ..server_endpoints.filters import assetlabel from ..specter_error import SpecterError, handle_exception from ..util.base43 import b43_decode +from ..util.common import robust_json_dumps from ..util.descriptor import Descriptor from ..util.fee_estimation import FeeEstimationResultEncoder, get_fees from ..util.mnemonic import generate_mnemonic diff --git a/src/cryptoadvance/specter/static/helpers.js b/src/cryptoadvance/specter/static/helpers.js index 6375d0be85..9179d4f5dc 100644 --- a/src/cryptoadvance/specter/static/helpers.js +++ b/src/cryptoadvance/specter/static/helpers.js @@ -149,10 +149,51 @@ async function send_request(url, method_str, csrf_token, formData) { } const response = await fetch(url, d); - if(response.status != 200){ + if(!response.ok){ showError(await response.text()); console.log(`Error while calling ${url} with ${method_str} ${formData}`) return } return await response.json(); +} + + + +/** + * Takes a dictionary with string keys and string values, transferrs them into input fields in a form and submits this form + * @param {*} url : To which url should this form be submitted + * @param {*} csrf_token + * @param {*} formDataDict: Should be a dict with string keys and string values + * + * Example arguments: + * url = "/wallet//history//" + * formDataDict = { + "action": "txid_to_show_on_load", + "txid": tx_dict["txid"], + }, + */ +async function submitForm(url, csrf_token, formDataDict) { + var form = document.createElement("form"); + form.action = url; + form.type = "hidden"; + form.method = "POST"; + form.value = formDataDict["action"]; + + var input = document.createElement("input"); + input.name = "csrf_token"; + input.value = csrf_token; + form.appendChild(input); + + // transfer all values from the formDataDict into input fields. + for (var key in formDataDict){ + var input = document.createElement("input"); + input.type = "hidden"; + input.name = key; + input.value = formDataDict[key]; + form.appendChild(input); + } + + + document.body.appendChild(form); + form.submit(); } \ No newline at end of file diff --git a/src/cryptoadvance/specter/templates/includes/addresses-table.html b/src/cryptoadvance/specter/templates/includes/addresses-table.html index 023da51dd3..2eaa673636 100644 --- a/src/cryptoadvance/specter/templates/includes/addresses-table.html +++ b/src/cryptoadvance/specter/templates/includes/addresses-table.html @@ -241,19 +241,11 @@

{{ _("Export addresses to CSV") }}

// Setup tabs switching this.receiveAddressesViewBtn.onclick = () => { - if (!this.receiveAddressesViewBtn.classList.contains("checked")) { - this.changeAddressesViewBtn.classList.remove("checked"); - this.receiveAddressesViewBtn.classList.add("checked"); - this.setAttribute("type", "receive"); - } + this.setAttribute("type", "receive"); } this.changeAddressesViewBtn.onclick = () => { - if (!this.changeAddressesViewBtn.classList.contains("checked")) { - this.receiveAddressesViewBtn.classList.remove("checked"); - this.changeAddressesViewBtn.classList.add("checked"); - this.setAttribute("type", "change"); - } + this.setAttribute("type", "change"); } // Init call id to avoid fetch returning after another one triggered @@ -357,6 +349,19 @@

{{ _("Export addresses to CSV") }}

shadow.appendChild(clone); } + + updateCheckedButton () { + if (this.listType == "change") { + this.changeAddressesViewBtn.classList.add("checked"); + this.receiveAddressesViewBtn.classList.remove("checked"); + } else { + this.changeAddressesViewBtn.classList.remove("checked"); + this.receiveAddressesViewBtn.classList.add("checked"); + } + } + + + static get observedAttributes() { return ['type', 'wallet', 'btc-unit', 'price', 'symbol', 'hide-sensitive-info']; } @@ -420,6 +425,7 @@

{{ _("Export addresses to CSV") }}

} else { this.switchText.innerText = ""; } + this.updateCheckedButton(); // Prepare form data with all relevant parameters var formData = new FormData(); diff --git a/src/cryptoadvance/specter/templates/wallet/addresses/wallet_addresses.jinja b/src/cryptoadvance/specter/templates/wallet/addresses/wallet_addresses.jinja index 9136c620a2..46acf3b570 100644 --- a/src/cryptoadvance/specter/templates/wallet/addresses/wallet_addresses.jinja +++ b/src/cryptoadvance/specter/templates/wallet/addresses/wallet_addresses.jinja @@ -18,8 +18,17 @@ {% endif %} btc-unit="{{ specter.unit }}" hide-sensitive-info="{{ specter.hide_sensitive_info | lower }}" - type="receive" - wallet="{{ wallet.alias }}"> + type="{{ address_type }}" + wallet="{{ wallet.alias }}" + id="addresses-table-{{ wallet.alias }}"> + {% endblock %} diff --git a/src/cryptoadvance/specter/templates/wallet/history/wallet_history.jinja b/src/cryptoadvance/specter/templates/wallet/history/wallet_history.jinja index 40a4bd17b1..d0160b872a 100644 --- a/src/cryptoadvance/specter/templates/wallet/history/wallet_history.jinja +++ b/src/cryptoadvance/specter/templates/wallet/history/wallet_history.jinja @@ -1,5 +1,6 @@ {% extends "wallet/components/wallet_tab.jinja" %} {% set tab = 'history' %} +{% set tx_list_type = tx_list_type %} {% block content %} {% from 'wallet/history/components/total_wallet_balances.jinja' import total_wallet_balances %} {{ total_wallet_balances( @@ -27,9 +28,17 @@ blockhash="{{ specter.config.validate_merkle_proofs | lower }}" type="{{ tx_list_type }}" hide-sensitive-info="{{ specter.hide_sensitive_info | lower }}" - wallet="{{ wallet.alias }}"> + wallet="{{ wallet.alias }}" + id="tx-table-{{ wallet.alias }}"> + {% endblock %} diff --git a/src/cryptoadvance/specter/wallet.py b/src/cryptoadvance/specter/wallet.py index f7b6e7c0b2..a31c6ffb6a 100644 --- a/src/cryptoadvance/specter/wallet.py +++ b/src/cryptoadvance/specter/wallet.py @@ -175,6 +175,10 @@ def __init__( ): self.save_to_file() + @property + def transactions(self): + return self._transactions + @property def recv_descriptor(self): return add_checksum(str(self.descriptor.branch(0))) diff --git a/src/cryptoadvance/specterext/.gitignore b/src/cryptoadvance/specterext/.gitignore new file mode 100644 index 0000000000..31b2fdb3a9 --- /dev/null +++ b/src/cryptoadvance/specterext/.gitignore @@ -0,0 +1,10 @@ +__pycache__ +.pytest_cache +*.pyc +.env +*.egg-info +.DS_Store +node_modules +btcd-conn.json +elmd-conn.json +prevent_mining \ No newline at end of file diff --git a/src/cryptoadvance/specterext/globalsearch/__init__.py b/src/cryptoadvance/specterext/globalsearch/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/cryptoadvance/specterext/globalsearch/__main__.py b/src/cryptoadvance/specterext/globalsearch/__main__.py new file mode 100644 index 0000000000..c4ee32b71c --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/__main__.py @@ -0,0 +1,51 @@ +from cryptoadvance.specter.cli import entry_point +from cryptoadvance.specter.cli.cli_server import server +import logging +import click + +logger = logging.getLogger(__name__) + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.pass_context +@click.option( + "--host", + default="127.0.0.1", + help="if you specify --host 0.0.0.0 then Globalsearch will be available in your local LAN.", +) +@click.option( + "--ssl/--no-ssl", + is_flag=True, + default=False, + help="By default SSL encryption will not be used. Use -ssl to create a self-signed certificate for SSL encryption.", +) +@click.option("--debug/--no-debug", default=None) +@click.option("--filelog/--no-filelog", default=True) +@click.option( + "--config", + default=None, + help="A class which sets reasonable default values.", +) +def start(ctx, host, ssl, debug, filelog, config): + if config == None: + config = "cryptoadvance.specterext.globalsearch.config.AppProductionConfig" + ctx.invoke( + server, + host=host, + ssl=ssl, + debug=debug, + filelog=filelog, + port=8080, + config=config, + ) + + +entry_point.add_command(start) + +if __name__ == "__main__": + entry_point() diff --git a/src/cryptoadvance/specterext/globalsearch/app_config.py b/src/cryptoadvance/specterext/globalsearch/app_config.py new file mode 100644 index 0000000000..868ae6224f --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/app_config.py @@ -0,0 +1,17 @@ +""" +Here you can put the Configuration of your Application +""" + +import os +from cryptoadvance.specter.config import ProductionConfig as SpecterProductionConfig + + +class AppProductionConfig(SpecterProductionConfig): + """The AppProductionConfig class can be used to user this extension as application""" + + # Where should the User endup if he hits the root of that domain? + ROOT_URL_REDIRECT = "/spc/ext/globalsearch" + # I guess this is the only extension which should be available? + EXTENSION_LIST = ["cryptoadvance.specterext.globalsearch.service"] + # You might also want a different folder here + SPECTER_DATA_FOLDER = os.path.expanduser("~/.globalsearch") diff --git a/src/cryptoadvance/specterext/globalsearch/config.py b/src/cryptoadvance/specterext/globalsearch/config.py new file mode 100644 index 0000000000..90421c6be2 --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/config.py @@ -0,0 +1,15 @@ +""" +Here Configuration of your Extension takes place +""" + + +class BaseConfig: + """This is a extension-based Config which is used as Base""" + + pass + + +class ProductionConfig(BaseConfig): + """This is a extension-based Config for Production""" + + pass diff --git a/src/cryptoadvance/specterext/globalsearch/controller.py b/src/cryptoadvance/specterext/globalsearch/controller.py new file mode 100644 index 0000000000..159018e15b --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/controller.py @@ -0,0 +1,100 @@ +import logging +from flask import redirect, render_template, request, url_for, flash +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 GlobalsearchService +from cryptoadvance.specter.util.common import robust_json_dumps + +logger = logging.getLogger(__name__) + +globalsearch_endpoint = GlobalsearchService.blueprint + + +def ext() -> GlobalsearchService: + """convenience for getting the extension-object""" + return app.specter.ext["globalsearch"] + + +def specter() -> Specter: + """convenience for getting the specter-object""" + return app.specter + + +@globalsearch_endpoint.route("/") +@login_required +def index(): + return render_template( + "globalsearch/index.jinja", + ) + + +@globalsearch_endpoint.route("/transactions") +@login_required +def transactions(): + # The wallet currently configured for ongoing autowithdrawals + wallet: Wallet = GlobalsearchService.get_associated_wallet() + + return render_template( + "globalsearch/transactions.jinja", + wallet=wallet, + services=app.specter.service_manager.services, + ) + + +@globalsearch_endpoint.route("/settings", methods=["GET"]) +@login_required +@user_secret_decrypted_required +def settings_get(): + associated_wallet: Wallet = GlobalsearchService.get_associated_wallet() + + # Get the user's Wallet objs, sorted by Wallet.name + wallet_names = sorted(current_user.wallet_manager.wallets.keys()) + wallets = [current_user.wallet_manager.wallets[name] for name in wallet_names] + + return render_template( + "globalsearch/settings.jinja", + associated_wallet=associated_wallet, + wallets=wallets, + cookies=request.cookies, + ) + + +@globalsearch_endpoint.route("/settings", methods=["POST"]) +@login_required +@user_secret_decrypted_required +def settings_post(): + show_menu = request.form["show_menu"] + user = app.specter.user_manager.get_user() + if show_menu == "yes": + user.add_service(GlobalsearchService.id) + else: + user.remove_service(GlobalsearchService.id) + used_wallet_alias = request.form.get("used_wallet") + if used_wallet_alias != None: + wallet = current_user.wallet_manager.get_by_alias(used_wallet_alias) + GlobalsearchService.set_associated_wallet(wallet) + return redirect( + url_for(f"{ GlobalsearchService.get_blueprint_name()}.settings_get") + ) + + +@globalsearch_endpoint.route("/global_search", methods=["POST"]) +@login_required +def global_search(): + search_term = request.form.get("global-search-input") + user = app.specter.user_manager.get_user(current_user) + return robust_json_dumps( + app.specter.ext["globalsearch"].global_search_tree.do_global_search( + search_term.strip(), + current_user, + app.specter.hide_sensitive_info, + app.specter.wallet_manager.wallets, + app.specter.device_manager.devices, + locale=app.get_language_code(), + ) + ) diff --git a/src/cryptoadvance/specterext/globalsearch/global_search.py b/src/cryptoadvance/specterext/globalsearch/global_search.py new file mode 100644 index 0000000000..42002dcdbc --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/global_search.py @@ -0,0 +1,607 @@ +from locale import LC_TIME +import os +import logging, json +import types +from flask import url_for +from flask_babel import lazy_gettext as _ +from cryptoadvance.specter.util.common import robust_json_dumps +from datetime import datetime +from babel.dates import format_datetime + +logger = logging.getLogger(__name__) + + +class ClickAction: + """This contains the information to describe what should happen after a click. + In the simple case this is just an url for a link/href. + It can also be a form submission to the url. + """ + + def __init__(self, url, method_str="href", form_data=None): + """ + Args: + url (str): _description_ + method_str (str, optional): "href", "form". Defaults to "href". + "href" will make the url open as a simple link + "form" will create a form together with the formData and url and submit it. + form_data (dict, optional): _description_. Defaults to None. + """ + self.url = url + self.method_str = method_str + self.form_data = form_data + + def json(self): + return self.__dict__ + + +class SearchResult: + """ + Contains all information for 1 single search result + """ + + def __init__(self, value, title=None, key=None, click_action=None) -> None: + """ + Args: + value (any): E.g. the string in which the search_term was found + title (str, optional): The title of the search result, e.g. the "Address #2". Defaults to None. + key (str, optional): E.g. "Blockhash", meaning the search_term was found in the blockhash. Defaults to None. + click_action (ClickAction, optional): An instance of type ClickAction. Defaults to None. + """ + self.value = str(value) if value else value + self.title = str(title) if title else title + self.key = str(key).capitalize() if key else key + self.click_action = click_action + + def json(self): + d = self.__dict__ + d["click_action"] = d["click_action"].json() if d["click_action"] else None + return d + + +class SearchableCategory: + def __init__( + self, + structure_or_generator_function, + title_function=None, + click_action_function=None, + locale=None, + ) -> None: + """ + Args: + structure_or_generator_function (list, tuple, set, dict, returning types.GeneratorType, or function returning formers): + The structure_or_generator_function should be non-static, meaning when the wallet information changes, the structure_or_generator_function should be up-to-date. + This can be achieved with a function that returns an iterable + title_function (function, optional): A function that takes the entire dict (which contains a search hit in some value) + and returns a string, which is used as the title of the SearchResult, e.g, + title_function = lambda d: d.get("txid"). + Defaults to None. + click_action_function (_type_, optional): A function that takes the entire dict (which contains a search hit in some value) + and returns an instance of type ClickAction. + Defaults to None. + locale (str or `Locale` object, optional): a `Locale` object or a locale identifier, e.g. 'en_US' + """ + self.structure_or_generator_function = structure_or_generator_function + self.title_function = title_function + self.click_action_function = click_action_function + self.locale = locale + + def search( + self, + search_term, + structure=None, + _result_meta_data=None, + ): + """ + Recursively goes through the list/tuple/set/types.GeneratorType until it hits a dict. + It matches then the dict.values() with the search_term (case insensitive) + + Args: + search_term (str): A string (non-case-sensitive) which will be searched for in the structure + structure_or_generator_function (list, tuple, set, dict, returning types.GeneratorType, or function returning formers): + _result_meta_data (_type_, optional): Only for internal purposes. Leave None + + Returns: + results: list of SearchResult + """ + if structure is None: + structure = self.structure_or_generator_function + if callable(structure): + structure = structure() + + def get_search_hit(search_term, value, key=None): + # for dates search additionally the formatted date_time + if key in ["time", "blocktime"] and isinstance(value, (int, float)): + kwargs = {"locale": self.locale} if self.locale else {} + date_time_value = format_datetime( + datetime.fromtimestamp(value), **kwargs + ) + found = search_term.lower() in str(date_time_value).lower() + if found: + return date_time_value + + found = search_term.lower() in str(value).lower() + return value if found else None + + results = [] + if isinstance(structure, dict): + for key, value in structure.items(): + search_hit = get_search_hit(search_term, value, key=key) + if search_hit: + result = SearchResult( + search_hit, + title=self.title_function(structure), + key=key, + click_action=self.click_action_function(structure) + if self.click_action_function + else None, + ) + # avoid duplicate results + if result not in results: + results.append(result) + + elif isinstance(structure, (types.GeneratorType, list, tuple, set)): + for value in structure: + update_dict = {"parent_structure": structure} + if isinstance(_result_meta_data, dict): + _result_meta_data.update(update_dict) + else: + _result_meta_data = update_dict + + results += self.search( + search_term, + structure=value, + _result_meta_data=_result_meta_data, + ) + else: + # if it is not a list,dict,... then it is the final element, e.g., a string, that should be searched: + search_hit = get_search_hit(search_term, structure) + if search_hit: + result = SearchResult( + search_hit, + click_action=self.click_action_function(structure) + if self.click_action_function + else None, + ) + # avoid duplicate results + if result not in results: + results.append(result) + + return results + + +class UIElement: + """ + This is a logical struture representing an UI/enpoint/HTML element, e.g., a button, a tab, with at most 1 search function accociated. + E.g. the "Change Addresses" tab would be 1 UIElement + + Multiple of these elements can be attached to each other, by referencing the parent element during __init__, building a tree + This tree is not a full reconstruction of the HTML DOM tree, but only the most necessary part to represent the logical structure + the user sees as "Wallets > my multisig wallet > UTXOs" labeling all results found by the SearchableCategory. + """ + + def __init__( + self, + parent, + title, + click_action, + searchable_category=None, + children=None, + ): + """ + + Args: + parent (UIElement, None): + title (str): The title, e.g. "Receive Addresses" + click_action (str): e.g. url_for("wallets_endpoint.wallet", wallet_alias=wallet.alias) + searchable_category (_type_, optional): If this UIElement should be searchable (usually then it should not have children) + then an instance of SearchableCategory can be linked. Defaults to None. + children (set of UIElement, optional): A set of UIElements. This is usually not necessary to set, because any child linking + to this as a parent will automatically add itself in this set. Defaults to None. + """ + self.parent = parent + if self.parent: + self.parent.children.add(self) + self.title = title + self.click_action = click_action + self.searchable_category = searchable_category + self.children = children if children else set() + + def nodes_with_searchable_category(self): + "Typically the nodes having a search_function, are childless nodes." + nodes = [] + if self.searchable_category: + nodes += [self] + + for child in self.children: + nodes += child.nodes_with_searchable_category() + return nodes + + def flattened_parent_list(self): + parents = [] + if self.parent: + parents = self.parent.flattened_parent_list() + [self.parent] + + return parents + + def json(self, include_flattened_parent_list=False): + d = {} + if include_flattened_parent_list: + d["flattened_parent_list"] = [ + parent.json() for parent in self.flattened_parent_list() + ] + d["title"] = self.title + d["click_action"] = self.click_action.json() if self.click_action else None + return d + + +class GlobalSearchTree: + "Builds the Ui Tree and holds the different UI roots of different users" + + def __init__(self): + self.cache = {} + + def _wallet_ui_elements(self, ui_root, wallet, hide_sensitive_info, locale=None): + html_wallets = UIElement( + ui_root, + _("Wallets"), + ClickAction(url_for("wallets_endpoint.wallets_overview")), + ) + + sidebar_wallet_searchable_category = SearchableCategory( + {"alias": wallet.alias, "name": wallet.name}, + title_function=lambda d: d.get("name"), + locale=locale, + ) + sidebar_wallet = UIElement( + html_wallets, + wallet.name, + ClickAction(url_for("wallets_endpoint.wallet", wallet_alias=wallet.alias)), + searchable_category=sidebar_wallet_searchable_category, + ) + + transactions = UIElement( + sidebar_wallet, + _("Transactions"), + ClickAction(url_for("wallets_endpoint.history", wallet_alias=wallet.alias)), + ) + + def transactions_history_generator(): + for tx in wallet.txlist(): + yield tx + + def tx_click_action_function(tx_dict, tx_list_type): + return ClickAction( + url_for( + "wallets_endpoint.history_tx_list_type", + wallet_alias=wallet.alias, + tx_list_type=tx_list_type, + ), + method_str="form", + form_data={ + "action": "show_tx_on_load", + "txid": tx_dict["txid"], + }, + ) + + transactions_history_searchable_category = SearchableCategory( + transactions_history_generator, + title_function=lambda d: d.get("txid"), + click_action_function=lambda tx_dict: tx_click_action_function( + tx_dict, "txlist" + ), + locale=locale, + ) + if not hide_sensitive_info: + transactions_history = UIElement( + transactions, + _("History"), + ClickAction( + url_for( + "wallets_endpoint.history_tx_list_type", + wallet_alias=wallet.alias, + tx_list_type="txlist", + ) + ), + searchable_category=transactions_history_searchable_category, + ) + + def transactions_utxo_generator(): + for utxo in wallet.full_utxo: + yield utxo + + transactions_utxo_searchable_category = SearchableCategory( + transactions_utxo_generator, + title_function=lambda d: d.get("txid"), + click_action_function=lambda tx_dict: tx_click_action_function( + tx_dict, "utxo" + ), + locale=locale, + ) + if not hide_sensitive_info: + transactions_utxo = UIElement( + transactions, + _("UTXO"), + ClickAction( + url_for( + "wallets_endpoint.history_tx_list_type", + wallet_alias=wallet.alias, + tx_list_type="utxo", + ) + ), + searchable_category=transactions_utxo_searchable_category, + ) + + addresses = UIElement( + sidebar_wallet, + _("Addresses"), + ClickAction( + url_for("wallets_endpoint.addresses", wallet_alias=wallet.alias) + ), + ) + + def addresses_receive_generator(is_change): + for address in wallet.addresses_info(is_change=is_change): + yield address + + def address_receive_click_action_function(address_dict, address_type): + return ClickAction( + url_for( + "wallets_endpoint.addresses_with_type", + wallet_alias=wallet.alias, + address_type=address_type, + ), + method_str="form", + form_data={ + "action": "show_address_on_load", + "address_dict": robust_json_dumps(address_dict), + }, + ) + + addresses_receive_searchable_category = SearchableCategory( + lambda: addresses_receive_generator(is_change=False), + title_function=lambda d: d.get("label"), + click_action_function=lambda address_dict: address_receive_click_action_function( + address_dict, "receive" + ), + locale=locale, + ) + if not hide_sensitive_info: + addresses_receive = UIElement( + addresses, + _("Receive Addresses"), + ClickAction( + url_for( + "wallets_endpoint.addresses_with_type", + wallet_alias=wallet.alias, + address_type="receive", + ) + ), + searchable_category=addresses_receive_searchable_category, + ) + + addresses_change_searchable_category = SearchableCategory( + lambda: addresses_receive_generator(is_change=True), + title_function=lambda d: d.get("label"), + click_action_function=lambda address_dict: address_receive_click_action_function( + address_dict, "change" + ), + locale=locale, + ) + if not hide_sensitive_info: + addresses_change = UIElement( + addresses, + _("Change Addresses"), + ClickAction( + url_for( + "wallets_endpoint.addresses_with_type", + wallet_alias=wallet.alias, + address_type="change", + ) + ), + searchable_category=addresses_change_searchable_category, + ) + + receive_searchable_category = SearchableCategory( + {"address": wallet.address, "label": wallet.getlabel(wallet.address)}, + title_function=lambda d: d.get("label"), + locale=locale, + ) + receive = UIElement( + sidebar_wallet, + _("Receive"), + ClickAction(url_for("wallets_endpoint.receive", wallet_alias=wallet.alias)), + searchable_category=receive_searchable_category, + ) + + if not hide_sensitive_info: + send = UIElement( + sidebar_wallet, + _("Send"), + ClickAction( + url_for("wallets_endpoint.send_new", wallet_alias=wallet.alias) + ), + ) + + def unsigned_click_action_function(psbt_dict): + return ClickAction( + url_for("wallets_endpoint.send_pending", wallet_alias=wallet.alias), + method_str="form", + form_data={ + "action": "openpsbt", + "pending_psbt": json.dumps(psbt_dict), + }, + ) + + def unsigned_generator(): + for psbt in wallet.pending_psbts.values(): + psbt_dict = psbt.to_dict() + psbt_dict["PSBT Address label"] = wallet.getlabel( + psbt_dict["address"][0] + ) + yield psbt_dict + + unsigned_searchable_category = SearchableCategory( + unsigned_generator, + title_function=lambda d: d.get("PSBT Address label"), + click_action_function=unsigned_click_action_function, + locale=locale, + ) + if not hide_sensitive_info: + unsigned = UIElement( + send, + _("Unsigned"), + ClickAction( + url_for("wallets_endpoint.send_pending", wallet_alias=wallet.alias) + ), + searchable_category=unsigned_searchable_category, + ) + + def _device_ui_elements(self, ui_root, device, hide_sensitive_info, locale=None): + html_devices = UIElement( + ui_root, + _("Devices"), + ClickAction(url_for("wallets_endpoint.wallets_overview")), + ) + + sidebar_device_searchable_category = SearchableCategory( + {"alias": device.alias, "name": device.name}, + title_function=lambda d: d.get("name"), + locale=locale, + ) + sidebar_device = UIElement( + html_devices, + device.name, + ClickAction(url_for("devices_endpoint.device", device_alias=device.alias)), + searchable_category=sidebar_device_searchable_category, + ) + + def device_keys_generator(): + for key in device.keys: + yield key + + device_keys_searchable_category = SearchableCategory( + device_keys_generator, + title_function=lambda d: d.get("purpose"), + locale=locale, + ) + if not hide_sensitive_info: + device_keys = UIElement( + sidebar_device, + _("Keys"), + ClickAction( + url_for("devices_endpoint.device", device_alias=device.alias) + ), + searchable_category=device_keys_searchable_category, + ) + + def _build_ui_elements(self, user_config): + """ + This builds all UIElements that should be highlighted during a search. + It also encodes which functions will be used for searching. + + Returns: + UIElement: This is the ui_root, which has all children linked in a tree + """ + ui_root = UIElement(None, "root", ClickAction(url_for("setup_endpoint.start"))) + for wallet in user_config["wallets"]: + self._wallet_ui_elements( + ui_root, + wallet, + user_config["hide_sensitive_info"], + locale=user_config["locale"], + ) + for device in user_config["devices"]: + self._device_ui_elements( + ui_root, device, user_config["hide_sensitive_info"] + ) + return ui_root + + def _search_in_ui_element(self, search_term, ui_root): + """ + Searches all nodes, which have a searchable_category + + Args: + search_term (_type_): _description_ + ui_root (_type_): _description_ + + Returns: + list of dict: Example: + [{ + 'ui_element': { + 'flattened_parent_list': [{ + 'flattened_parent_list': [], + 'title': None, + 'click_action': None + }], + 'title': 'History', + 'click_action': '/wallets/wallet/tr/history/txlist/' + }, + 'search_results': [{ + 'title': '599a2780545f456b69feac58a1e4ef8271a81a367c08315cffd3e91e2e23f95a', + 'key': 'Blockhash', + 'value': '65dc072035e1f870963a111a188e14a7359454b02a09210ead68250a051f6b16' + }] + }] + """ + result_dicts = [] + for node in ui_root.nodes_with_searchable_category(): + result_dict = { + "ui_element": node.json(include_flattened_parent_list=True), + "search_results": [ + hit.json() for hit in node.searchable_category.search(search_term) + ], + } + if result_dict["search_results"]: + result_dicts.append(result_dict) + return result_dicts + + def user_config(self, hide_sensitive_info, wallets, devices, locale): + """A minimalist version of building a user configuration, + that if changed shows that the UI Tree needs to be rebuild""" + return { + "hide_sensitive_info": hide_sensitive_info, + "wallets": wallets, + "devices": devices, + "locale": locale, + } + + def do_global_search( + self, + search_term, + user_id, + hide_sensitive_info, + wallets, + devices, + locale=None, + force_build_ui_tree=False, + ): + "Builds the UI Tree if non-existent, or it the config changed and then calls the functions in it to search for the search_term" + user_config = self.user_config( + hide_sensitive_info, + list(wallets.values()), + list(devices.values()), + locale, + ) + if ( + force_build_ui_tree + or (user_id not in self.cache) + or self.cache[user_id]["user_config"] != user_config + ): + logger.debug( + f'Building GlobalSearchTree for user {user_id} with {len(user_config["wallets"])} wallets and {len(user_config["devices"])} devices' + ) + self.cache[user_id] = { + "user_config": user_config, + "ui_root": self._build_ui_elements(user_config), + } + + result_dicts = ( + self._search_in_ui_element(search_term, self.cache[user_id]["ui_root"]) + if len(search_term) > 1 + else [] + ) + + return { + "result_dicts": result_dicts, + "search_term": search_term, + } diff --git a/src/cryptoadvance/specterext/globalsearch/service.py b/src/cryptoadvance/specterext/globalsearch/service.py new file mode 100644 index 0000000000..ae4f26fce0 --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/service.py @@ -0,0 +1,92 @@ +import logging + +from cryptoadvance.specter.services.service import ( + Service, + devstatus_alpha, + devstatus_prod, + devstatus_beta, +) + +# 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 current_app as app +from flask import render_template +from cryptoadvance.specter.wallet import Wallet +from flask_apscheduler import APScheduler +from .global_search import GlobalSearchTree + +logger = logging.getLogger(__name__) + + +class GlobalsearchService(Service): + id = "globalsearch" + name = "Globalsearch Service" + icon = "globalsearch/img/ghost.png" + logo = "globalsearch/img/logo.jpeg" + desc = "Where a globalsearch grows bigger." + has_blueprint = True + blueprint_module = "cryptoadvance.specterext.globalsearch.controller" + devstatus = devstatus_prod + isolated_client = False + + # TODO: As more Services are integrated, we'll want more robust categorization and sorting logic + sort_priority = 2 + + # ServiceEncryptedStorage field names for this service + # Those will end up as keys in a json-file + SPECTER_WALLET_ALIAS = "wallet" + + def callback_after_serverpy_init_app(self, scheduler: APScheduler): + def every5seconds(hello, world="world"): + with scheduler.app.app_context(): + print(f"Called {hello} {world} every5seconds") + + # Here you can schedule regular jobs. triggers can be one of "interval", "date" or "cron" + # Examples: + # interval: https://apscheduler.readthedocs.io/en/3.x/modules/triggers/interval.html + # scheduler.add_job("every5seconds4", every5seconds, trigger='interval', seconds=5, args=["hello"]) + + # Date: https://apscheduler.readthedocs.io/en/3.x/modules/triggers/date.html + # scheduler.add_job("MyId", my_job, trigger='date', run_date=date(2009, 11, 6), args=['text']) + + # cron: https://apscheduler.readthedocs.io/en/3.x/modules/triggers/cron.html + # sched.add_job("anotherID", job_function, trigger='cron', day_of_week='mon-fri', hour=5, minute=30, end_date='2014-05-30') + + # Maybe you should store the scheduler for later use: + self.scheduler = scheduler + + # Global Search + self.global_search_tree = GlobalSearchTree() + + # 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 + + @classmethod + def get_associated_wallet(cls) -> Wallet: + """Get the Specter `Wallet` that is currently associated with this service""" + service_data = cls.get_current_user_service_data() + if not service_data or cls.SPECTER_WALLET_ALIAS not in service_data: + # Service is not initialized; nothing to do + return + try: + return app.specter.wallet_manager.get_by_alias( + service_data[cls.SPECTER_WALLET_ALIAS] + ) + except SpecterError as e: + logger.debug(e) + # Referenced an unknown wallet + # TODO: keep ignoring or remove the unknown wallet from service_data? + return + + @classmethod + def set_associated_wallet(cls, wallet: Wallet): + """Set the Specter `Wallet` that is currently associated with this Service""" + cls.update_current_user_service_data({cls.SPECTER_WALLET_ALIAS: wallet.alias}) + + @classmethod + def inject_in_basejinja_head(cls): + return render_template("globalsearch/global_search.jinja") + + @classmethod + def inject_in_basejinja_body_bottom(cls): + return render_template("globalsearch/html_inject_in_basejinja.jinja") diff --git a/src/cryptoadvance/specterext/globalsearch/static/globalsearch/css/styles.css b/src/cryptoadvance/specterext/globalsearch/static/globalsearch/css/styles.css new file mode 100644 index 0000000000..de6f013587 --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/static/globalsearch/css/styles.css @@ -0,0 +1 @@ +/* This is the place to put all your styles */ diff --git a/src/cryptoadvance/specterext/globalsearch/static/globalsearch/img/ghost.png b/src/cryptoadvance/specterext/globalsearch/static/globalsearch/img/ghost.png new file mode 100644 index 0000000000..ad3963f3f9 Binary files /dev/null and b/src/cryptoadvance/specterext/globalsearch/static/globalsearch/img/ghost.png differ diff --git a/src/cryptoadvance/specterext/globalsearch/static/globalsearch/img/logo.jpeg b/src/cryptoadvance/specterext/globalsearch/static/globalsearch/img/logo.jpeg new file mode 100644 index 0000000000..06407e9ae7 Binary files /dev/null and b/src/cryptoadvance/specterext/globalsearch/static/globalsearch/img/logo.jpeg differ diff --git a/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/base.jinja b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/base.jinja new file mode 100644 index 0000000000..e790b04b44 --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/base.jinja @@ -0,0 +1,4 @@ +{% extends "base.jinja" %} +{% block head %} + +{% endblock %} \ No newline at end of file diff --git a/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/components/globalsearch_menu.jinja b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/components/globalsearch_menu.jinja new file mode 100644 index 0000000000..1da21c126f --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/components/globalsearch_menu.jinja @@ -0,0 +1,17 @@ +{% from 'components/menu_item.jinja' import menu_item %} + +{# + globalsearch_menu - Tabs menu to navigate between the globalsearch screens. + Parameters: + - active_menuitem: Current active tab. Options: 'general', 'settings', ... + #} +{% macro globalsearch_menu(active_menuitem) -%} + +{%- endmacro %} \ No newline at end of file diff --git a/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/components/globalsearch_tab.jinja b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/components/globalsearch_tab.jinja new file mode 100644 index 0000000000..f8d1d0cd29 --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/components/globalsearch_tab.jinja @@ -0,0 +1,8 @@ +{% extends "globalsearch/base.jinja" %} +{% block main %} + + {% from 'globalsearch/components/globalsearch_menu.jinja' import globalsearch_menu with context %} + {{ globalsearch_menu(tab) }} + {% block content %} + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/global_search.jinja b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/global_search.jinja new file mode 100644 index 0000000000..1f1db13d3c --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/global_search.jinja @@ -0,0 +1,322 @@ + + + + \ No newline at end of file diff --git a/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/html_inject_in_basejinja.jinja b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/html_inject_in_basejinja.jinja new file mode 100644 index 0000000000..9dfc2f0fdb --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/html_inject_in_basejinja.jinja @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/index.jinja b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/index.jinja new file mode 100644 index 0000000000..a5c9bf084e --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/index.jinja @@ -0,0 +1,87 @@ +{% extends "globalsearch/components/globalsearch_tab.jinja" %} +{% block title %}Settings{% endblock %} +{% set tab = 'index' %} +{% block content %} + +

+
“ GlobalsearchService 4thewin.”
+{% endblock %} + + + +{% block scripts %} + +{% endblock %} diff --git a/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/settings.jinja b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/settings.jinja new file mode 100644 index 0000000000..ebf1814f80 --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/settings.jinja @@ -0,0 +1,109 @@ +{% extends "globalsearch/components/globalsearch_tab.jinja" %} +{% block title %}Settings{% endblock %} +{% set tab = 'settings_get' %} +{% block content %} +
+ + +
+

{{ _("Configure your extension") }}

+
+ +
+ {{ _("- Maybe you want to put some notes for the user") }}
+ {{ _("- One important setting is whether your extension gets a menu-point on the left") }}
+ {{ _("- Let us assume you want the user to use a specific wallet. That should go here. ") }}
+
+
+ +
+ + +
Show Menu Item:
+ +
+
+
+ + {{ _("Choose which wallet should be used:") }}:
+ +
+
+
+ +
+ +
+ +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/transactions.jinja b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/transactions.jinja new file mode 100644 index 0000000000..698e02018d --- /dev/null +++ b/src/cryptoadvance/specterext/globalsearch/templates/globalsearch/transactions.jinja @@ -0,0 +1,81 @@ +{% extends "globalsearch/components/globalsearch_tab.jinja" %} +{% block title %}Transactions{% endblock %} +{% set tab = 'transactions' %} +{% block content %} + + + +

Globalsearch Transactions

+ {% if wallet %} +
Linked wallet: + {{ wallet.name }}
+
+ + {% else %} +
+
{{ _("Linked Wallet Not Configured") }}
+
+ {{ _("Go to Settings to set up which wallet should be linked to this extension.") }} +
+
+ {% endif %} + + {# TODO: List total withdrawal value? Or just current value of withdrawn utxos? #} + +
+ {% include "includes/services-data.html" %} + {% include "includes/address-label.html" %} + {% include "includes/tx-row.html" %} + {% include "includes/tx-data.html" %} + {% include "includes/explorer-link.html" %} + {% include "includes/tx-table.html" %} + +
+ + +
+ The Transactions shown here are only the ones associated with your extension.
+ You can manually associate addresses to your extension in a specific address via the
+ button or you can do it programmatically via
+
+            # somewhere in your controller.py
+            ext().reserve_address(wallet, address, "some Label")
+        
+ To show all transactions in the list, remove the
service-id="myext"
in transactions.jinja +
+ +{% endblock %} \ No newline at end of file diff --git a/tests/test_global_search.py b/tests/test_global_search.py new file mode 100644 index 0000000000..e246f9a8ec --- /dev/null +++ b/tests/test_global_search.py @@ -0,0 +1,249 @@ +from cryptoadvance.specter.specter import Specter +from cryptoadvance.specter.managers.user_manager import UserManager +from cryptoadvance.specter.wallet import Wallet +import logging + +logger = logging.getLogger(__name__) +from cryptoadvance.specterext.globalsearch.global_search import GlobalSearchTree +from unittest.mock import MagicMock, patch +from fix_devices_and_wallets import create_hot_wallet_device, create_hot_wallet_with_ID + + +def mock_url_for(url, **kwargs): + return f"{url}/".replace(".", "/") + "/".join(kwargs.values()) + + +@patch("cryptoadvance.specterext.globalsearch.global_search.url_for", mock_url_for) +@patch("cryptoadvance.specterext.globalsearch.global_search._", str) +def test_transactions(specter_regtest_configured: Specter, funded_hot_wallet_1): + user = specter_regtest_configured.user_manager.user + global_search_tree = GlobalSearchTree() + + # test wallet name + search_term = funded_hot_wallet_1.alias.upper()[3:8] + results = global_search_tree.do_global_search( + search_term, + user, + specter_regtest_configured.hide_sensitive_info, + specter_regtest_configured.wallet_manager.wallets, + specter_regtest_configured.device_manager.devices, + ) + assert len(results["result_dicts"]) == 1 + assert len(results["result_dicts"][0]["search_results"]) == 2 + assert results["result_dicts"][0]["search_results"][0]["key"] == "Alias" + assert ( + results["result_dicts"][0]["search_results"][0]["value"] + == funded_hot_wallet_1.alias + ) + assert results["result_dicts"][0]["search_results"][1]["key"] == "Name" + assert ( + results["result_dicts"][0]["search_results"][1]["value"] + == funded_hot_wallet_1.name + ) + + unspent_list = funded_hot_wallet_1.rpc.listunspent() + assert unspent_list # otherwise the test will not test anything + # logger.info(unspent_list) + + # test ALL utxos, to ensure that the search really can find ALL information + logger.info(f"Searching for {len(unspent_list)} unspent transactions and utxo") + for tx in unspent_list: + # test txids + search_term = tx["txid"].upper() # checks the case insensitive search + results = global_search_tree.do_global_search( + search_term, + user, + specter_regtest_configured.hide_sensitive_info, + specter_regtest_configured.wallet_manager.wallets, + specter_regtest_configured.device_manager.devices, + ) + + # logger.info(results) + assert results["search_term"] == search_term + assert ( + len(results["result_dicts"]) == 2 + ) # 1 result in utxo and 1 result in tx history + + sorted_result_dicts = sorted( + results["result_dicts"], + key=lambda result_dict: result_dict["search_results"][0]["click_action"][ + "url" + ], + ) + + expectations = [ + { + "value": tx["txid"], + "title": tx["txid"], + "key": "Txid", + "click_action": { + "url": f"wallets_endpoint/history_tx_list_type/{funded_hot_wallet_1.alias}/txlist", + "method_str": "form", + "form_data": {"action": "show_tx_on_load", "txid": tx["txid"]}, + }, + }, + { + "value": tx["txid"], + "title": tx["txid"], + "key": "Txid", + "click_action": { + "url": f"wallets_endpoint/history_tx_list_type/{funded_hot_wallet_1.alias}/utxo", + "method_str": "form", + "form_data": {"action": "show_tx_on_load", "txid": tx["txid"]}, + }, + }, + ] + for result_dict, expectation in zip(sorted_result_dicts, expectations): + assert len(result_dict["search_results"]) == 1 + assert result_dict["search_results"][0] == expectation + + # test amount search of the 1. utxo + search_amount = "1.0" + # count how many of the other utxos also have this amount + number_of_utxos_with_this_amount = len( + [ + utxo + for utxo in unspent_list + if search_amount.lower() in str(utxo["amount"]).lower() + ] + ) + assert number_of_utxos_with_this_amount > 0 + results = global_search_tree.do_global_search( + str(search_amount), + user, + specter_regtest_configured.hide_sensitive_info, + specter_regtest_configured.wallet_manager.wallets, + specter_regtest_configured.device_manager.devices, + ) + + sorted_result_dicts = sorted( + results["result_dicts"], + key=lambda result_dict: result_dict["search_results"][0]["click_action"]["url"], + ) + tx_result = sorted_result_dicts[0] + len(tx_result["search_results"]) == number_of_utxos_with_this_amount + + +@patch("cryptoadvance.specterext.globalsearch.global_search.url_for", mock_url_for) +@patch("cryptoadvance.specterext.globalsearch.global_search._", str) +def test_addresses(specter_regtest_configured: Specter, unfunded_hot_wallet_1): + user = specter_regtest_configured.user_manager.user + global_search_tree = GlobalSearchTree() + + # change addresses + addresses = unfunded_hot_wallet_1.addresses_info(is_change=True) + logger.info(f"Searching for {len(addresses)} change addresses") + assert addresses + for i, address in enumerate(addresses): + search_term = address["address"].upper() # checks the case insensitive search + results = global_search_tree.do_global_search( + search_term, + user, + specter_regtest_configured.hide_sensitive_info, + specter_regtest_configured.wallet_manager.wallets, + specter_regtest_configured.device_manager.devices, + ) + assert results["search_term"] == search_term + + expectation = { + "value": address["address"], + "title": f"Change #{i}", + "key": "Address", + "click_action": { + "url": f"wallets_endpoint/addresses_with_type/{unfunded_hot_wallet_1.alias}/change", + "method_str": "form", + "form_data": { + "action": "show_address_on_load", + "address_dict": f'{{"index": {i}, "address": "{address["address"]}", "label": "Change #{i}", "amount": 0, "used": false, "utxo": 0, "type": "change", "service_id": null}}', + }, + }, + } + assert len(results["result_dicts"]) == 1 + assert len(results["result_dicts"][0]["search_results"]) == 1 + assert results["result_dicts"][0]["search_results"][0] == expectation + + # receive addresses + addresses = unfunded_hot_wallet_1.addresses_info(is_change=False) + logger.info(f"Searching for {len(addresses)} receive addresses") + assert addresses + for i, address in enumerate(addresses): + search_term = address["address"].upper() # checks the case insensitive search + results = global_search_tree.do_global_search( + search_term, + user, + specter_regtest_configured.hide_sensitive_info, + specter_regtest_configured.wallet_manager.wallets, + specter_regtest_configured.device_manager.devices, + ) + + assert results["search_term"] == search_term + + expectation = { + "value": address["address"], + "title": f"Address #{i}", + "key": "Address", + "click_action": { + "url": f"wallets_endpoint/addresses_with_type/{unfunded_hot_wallet_1.alias}/receive", + "method_str": "form", + "form_data": { + "action": "show_address_on_load", + "address_dict": f'{{"index": {i}, "address": "{address["address"]}", "label": "Address #{i}", "amount": 0, "used": false, "utxo": 0, "type": "receive", "service_id": null}}', + }, + }, + } + + assert ( + len(results["result_dicts"]) == 2 + if unfunded_hot_wallet_1.address == address["address"] + else 1 + ) + assert len(results["result_dicts"][0]["search_results"]) == 1 + # the expectation can be in results["result_dicts"][0]["search_results"][0] or in results["result_dicts"][1]["search_results"][0] + assert expectation in [ + result_dict["search_results"][0] for result_dict in results["result_dicts"] + ] + + +@patch("cryptoadvance.specterext.globalsearch.global_search.url_for", mock_url_for) +@patch("cryptoadvance.specterext.globalsearch.global_search._", str) +def test_devices(specter_regtest_configured: Specter, unfunded_hot_wallet_1): + user = specter_regtest_configured.user_manager.user + global_search_tree = GlobalSearchTree() + + # change addresses + devices = specter_regtest_configured.device_manager.devices.values() + assert devices + for device in devices: + logger.info(f"Searching for {len(device.keys)} keys in device {device.alias}") + + search_term = device.alias[3:8] + results = global_search_tree.do_global_search( + search_term, + user, + specter_regtest_configured.hide_sensitive_info, + specter_regtest_configured.wallet_manager.wallets, + specter_regtest_configured.device_manager.devices, + ) + assert len(results["result_dicts"][0]["search_results"]) == 2 + assert results["result_dicts"][0]["search_results"][0]["key"] == "Alias" + assert results["result_dicts"][0]["search_results"][0]["value"] == device.alias + assert results["result_dicts"][0]["search_results"][1]["key"] == "Name" + assert results["result_dicts"][0]["search_results"][1]["value"] == device.name + + for key in device.keys: + search_term = key.original + results = global_search_tree.do_global_search( + search_term, + user, + specter_regtest_configured.hide_sensitive_info, + specter_regtest_configured.wallet_manager.wallets, + specter_regtest_configured.device_manager.devices, + ) + assert len(results["result_dicts"][0]["search_results"]) == 1 + assert ( + search_term in results["result_dicts"][0]["search_results"][0]["value"] + ) + assert ( + key.fingerprint + in results["result_dicts"][0]["search_results"][0]["value"] + )