From 7f0ac699e99796fee7b90e0f4f3cc7ed51eb5085 Mon Sep 17 00:00:00 2001 From: relativisticelectron <60378539+relativisticelectron@users.noreply.github.com> Date: Tue, 20 Sep 2022 09:25:46 +0200 Subject: [PATCH] UIUX: Better balance display (#1841) * Better alignment and introduction of spaces and colours for BTC amounts. Co-authored-by: Kim Neunert Co-authored-by: moneymanolis --- cypress.json | 1 + cypress/integration/spec_balances_amounts.js | 115 ++++++++ cypress/support/commands.js | 10 +- src/cryptoadvance/specter/cli/cli_noded.py | 109 ++++--- .../process_controller/node_controller.py | 2 - .../specter/server_endpoints/filters.py | 38 +++ src/cryptoadvance/specter/static/styles.css | 28 ++ .../components/total_wallet_balances.jinja | 274 +++++++++++------- .../wallet/history/wallet_history.jinja | 11 +- .../templates/wallet/wallets_overview.jinja | 59 ++-- src/cryptoadvance/specter/util/common.py | 95 ++++++ .../devhelp/static/devhelp/css/styles.css | 5 + .../devhelp/templates/devhelp/base.jinja | 5 + .../devhelp/components/devhelp_tab.jinja | 2 +- .../html/macro_total_wallet_balances.jinja | 88 ++++++ .../devhelp/templates/devhelp/index.jinja | 12 +- tests/test_cli_bitcoind.py | 48 --- tests/test_util_common.py | 70 +++++ utils/test-cypress.sh | 2 +- 19 files changed, 723 insertions(+), 251 deletions(-) create mode 100644 cypress/integration/spec_balances_amounts.js create mode 100644 src/cryptoadvance/specterext/devhelp/static/devhelp/css/styles.css create mode 100644 src/cryptoadvance/specterext/devhelp/templates/devhelp/base.jinja create mode 100644 src/cryptoadvance/specterext/devhelp/templates/devhelp/html/macro_total_wallet_balances.jinja delete mode 100644 tests/test_cli_bitcoind.py diff --git a/cypress.json b/cypress.json index 5da6ad5e7..ddd0b1343 100644 --- a/cypress.json +++ b/cypress.json @@ -9,6 +9,7 @@ "spec_fees.js", "spec_rescan.js", "spec_qr_signing.js", + "spec_balances_amounts.js", "spec_wallet_send.js", "spec_wallet_utxo.js", "spec_plugins.js", diff --git a/cypress/integration/spec_balances_amounts.js b/cypress/integration/spec_balances_amounts.js new file mode 100644 index 000000000..98589a034 --- /dev/null +++ b/cypress/integration/spec_balances_amounts.js @@ -0,0 +1,115 @@ +// +describe('Test the rendering of balances and amounts', () => { + before(() => { + Cypress.config('includeShadowDom', true) + cy.visit('/') + }) + + // Keeps the session cookie alive, Cypress by default clears all cookies before each test + beforeEach(() => { + cy.viewport(1200,660) + Cypress.Cookies.preserveOnce('session') + }) + + it('Total balance of 20 BTC', () => { + /* This is how the DOM looks like + + 20.0 + 0 + + 0 + 0 + 0 + + + 0 + 0 + 0 + + + */ + cy.selectWallet('Ghost wallet') + cy.get('#fullbalance_amount').should('have.text', '20.00000000') // should('have.text') returns ALL textContents (descendants and unvisible text) + cy.get('#fullbalance_amount').find('span').first().should('have.text', '0').and('not.be.visible') + cy.get('#fullbalance_amount').find('.thousand-digits-in-btc-amount').children().each((element) => { + cy.wrap(element).should('have.text', '0') + cy.wrap(element).should('not.be.visible') + }); + cy.get('#fullbalance_amount').find('.last-digits-in-btc-amount').children().each((element) => { + cy.wrap(element).should('have.text', '0') + cy.wrap(element).should('not.be.visible') + }); + }) + + it('Unconfirmed balance of 0.05 BTC', () => { + /* This is how the DOM looks like + + 0.05 + + 0 + 0 + 0 + + + 0 + 0 + 0 + + + */ + // We get 5 mio. sats from a funding wallet + // TODO: If this funding wallet is used more, move it to a seperate spec file + cy.addHotDevice('Satoshis hot keys','bitcoin') + cy.addWallet('Funding wallet', 'segwit', 'funded', 'btc', 'singlesig', 'Satoshis hot keys') + cy.selectWallet('Funding wallet') + cy.get('#btn_send').click() + cy.get('#recipient_0').find('#address').invoke('val', 'bcrt1qvtdx75y4554ngrq6aff3xdqnvjhmct5wck95qs') + cy.get('#recipient_0').find('#amount').type(0.05, { force: true }) + cy.get('#toggle_advanced').click() + cy.get('.fee_container').find('#fee_option_manual').click() + cy.get('#fee_manual').find('#fee_rate').clear( { force: true }) + cy.get('#fee_manual').find('#fee_rate').type(5, { force: true }) // Should be a fee of 709 sats. + cy.get('#create_psbt_btn').click() + cy.get('body').contains("Paste signed transaction") + cy.get('#satoshis_hot_keys_tx_sign_btn').click() + cy.get('#satoshis_hot_keys_hot_sign_btn').click() + cy.get('#hot_enter_passphrase__submit').click() + cy.get('#broadcast_local_btn').click() + cy.reload() + cy.selectWallet('Ghost wallet') + cy.get('#unconfirmed_amount').should('have.text', '0.05000000') + cy.get('#unconfirmed_amount').find('.thousand-digits-in-btc-amount').children().each((element) => { + cy.wrap(element).should('have.text', '0') + cy.wrap(element).should('not.be.visible') + }); + cy.get('#unconfirmed_amount').find('.last-digits-in-btc-amount').children().each((element) => { + cy.wrap(element).should('have.text', '0') + cy.wrap(element).should('not.be.visible') + }); + }) + + it('Total balance with all digits', () => { + /* This is how the DOM looks like + + 19.94 + 999 + 291 + + */ + // Let's use the funding wallet + // Works as long as the fee was 709 and the original balance of the funding wallet was 20 BTC + cy.selectWallet('Funding wallet') + cy.get('#fullbalance_amount').should('have.text', '19.94999291') + cy.get('#fullbalance_amount').find('.thousand-digits-in-btc-amount').should('have.text', '999') + cy.get('#fullbalance_amount').find('.thousand-digits-in-btc-amount').should('have.css', 'color','rgb(145, 145, 145)') + cy.get('#fullbalance_amount').find('.last-digits-in-btc-amount').should('have.text', '291') + cy.get('#fullbalance_amount').find('.last-digits-in-btc-amount').should('have.css', 'color','rgb(121, 121, 121)') + cy.get('#fullbalance_amount').children().each((element) => { + cy.wrap(element).should('be.visible') + cy.log(element) + }); + }) + + // TODO: Test (new) amount display once implemented, e.g. in sending dialogue, could probably be done in one of the tests above. + +}) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index bd9f1e997..769026940 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -216,7 +216,7 @@ Cypress.Commands.add("deleteWallet", (name) => { Cypress.Commands.add("selectWallet", (name) => { cy.get('body').then(($body) => { if ($body.text().includes(name)) { - cy.contains(name).click() + cy.contains(name).click( {force: true} ) } }) }) @@ -225,8 +225,8 @@ Cypress.Commands.add("mine2wallet", (chain) => { // Fund it and check the balance // Only works if a wallet is selected, use addHotWallet / selectWallet commands before if needed cy.get('#btn_transactions').click() - cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }).then(($span) => { - const oldBalance = parseFloat($span.text()) + cy.get('#fullbalance_amount', { timeout: Cypress.env("broadcast_timeout") }).then(($header) => { + const oldBalance = parseFloat($header.text()) if (chain=="elm" || chain=="elements") { cy.task("elm:mine") } else if (chain=="btc" || chain=="bitcoin") { @@ -235,8 +235,8 @@ Cypress.Commands.add("mine2wallet", (chain) => { throw new Error("Unknown chain: " + chain) } cy.waitUntil( () => cy.reload().get('#fullbalance_amount', { timeout: 3000 }) - .then(($span) => { - const n = parseFloat($span.text()) + .then(($header) => { + const n = parseFloat($header.text()) return n > oldBalance }) , { diff --git a/src/cryptoadvance/specter/cli/cli_noded.py b/src/cryptoadvance/specter/cli/cli_noded.py index 9ab8c182e..82534eef2 100644 --- a/src/cryptoadvance/specter/cli/cli_noded.py +++ b/src/cryptoadvance/specter/cli/cli_noded.py @@ -8,10 +8,12 @@ import sys import time from pathlib import Path from threading import Event +from xmlrpc.client import Boolean import click import psutil from flask import Config +from requests.exceptions import ConnectionError from ..config import DEFAULT_CONFIG from ..process_controller.elementsd_controller import ElementsPlainController @@ -352,15 +354,15 @@ def noded( if node_impl == "elements": prepare_elements_default_wallet(my_node) - - if mining: - miner_loop( - node_impl, - my_node, - config_obj["SPECTER_DATA_FOLDER"], - mining_every_x_seconds, - echo, - ) + # Mining/NOP loop (necessary to keep the Python process running) + endless_loop( + node_impl, + my_node, + mining, + config_obj["SPECTER_DATA_FOLDER"], + mining_every_x_seconds, + echo, + ) def prepare_elements_default_wallet(my_node): @@ -386,61 +388,75 @@ def prepare_elements_default_wallet(my_node): rpc.generatetoaddress(101, unconfidential) -def miner_loop(node_impl, my_node, data_folder, mining_every_x_seconds, echo): - "An endless loop mining bitcoin" +def endless_loop( + node_impl, my_node, mining: Boolean, data_folder, mining_every_x_seconds, echo +): + """This loop can enable continuous mining""" - echo( - "Now, mining a block every %f seconds, avoid it via --no-mining" - % mining_every_x_seconds - ) - mine_2_specter_wallets(node_impl, my_node, data_folder, echo) - - # make them spendable - my_node.mine(block_count=100) - echo( - f"height: {my_node.rpcconn.get_rpc().getblockchaininfo()['blocks']} | ", - nl=False, - ) + # To stop the Python process exit = Event() - def exit_now(signo, _frame): + def exit_now(signum, frame): + echo(f"Signal {signum} received. Terminating the Python process. Bye, bye!") exit.set() - for sig in ("HUP", "INT"): - signal.signal(getattr(signal, "SIG" + sig), exit_now) + signal.signal(signal.SIGINT, exit_now) + signal.signal(signal.SIGHUP, exit_now) + signal.signal(signal.SIGTERM, exit_now) + # SIGKILL cannot be caught + + if mining: + echo( + "Now, mining a block every %f seconds, avoid it via --no-mining" + % mining_every_x_seconds + ) + mine_2_specter_wallets(node_impl, my_node, data_folder, echo) + # make them spendable + my_node.mine(block_count=100) + echo( + f"height: {my_node.rpcconn.get_rpc().getblockchaininfo()['blocks']} | ", + nl=False, + ) + else: + echo("Press Ctrl-C to abort and stop the node") + prevent_mining_file = Path("prevent_mining") i = 0 - while True: + while not exit.is_set(): try: current_height = my_node.rpcconn.get_rpc().getblockchaininfo()["blocks"] exit.wait(mining_every_x_seconds) - if not prevent_mining_file.is_file(): + # Having a prevent_mining_file overrides the mining cli option + if mining and not prevent_mining_file.is_file(): my_node.mine() - else: + echo("%i" % (i % 10), prefix=False, nl=False) + if i % 10 == 9: + echo(" ", prefix=False, nl=False) + i += 1 + if i >= 50: + i = 0 + echo("", prefix=False) + echo( + f"height: {current_height} | ", + nl=False, + ) + elif mining: echo("X", prefix=False, nl=False) continue - echo("%i" % (i % 10), prefix=False, nl=False) - if i % 10 == 9: - echo(" ", prefix=False, nl=False) - i += 1 - if i >= 50: - i = 0 - echo("", prefix=False) - echo( - f"height: {current_height} | ", - nl=False, - ) - + except ConnectionError as nce: + # This terminates the Python processes if the bitcoind / elementsd (child) processes are somehow terminated + echo("Exiting endless loop due to lost RPC connection.") + break except Exception as e: logger.debug( - f"Caught {e}, Couldn't mine, assume SIGTERM occured => exiting!" + f"Caught {e.__module__}, Couldn't mine, assume SIGTERM occured => exiting!" ) - echo(f"THE_END(@height:{current_height})") - if prevent_mining_file.is_file(): - echo("Deleting file prevent_mining") - prevent_mining_file.unlink() break + if prevent_mining_file.is_file(): + echo("Deleting file prevent_mining") + prevent_mining_file.unlink() + echo(f"THE_END(@height:{current_height})") def mine_2_specter_wallets(node_impl, my_node, data_folder, echo): @@ -451,6 +467,7 @@ def mine_2_specter_wallets(node_impl, my_node, data_folder, echo): # Using the dict key, not the wallet name exception = "fresh_wallet" try: + logger.debug(f"Funding wallets in {data_folder}/wallets") for address in fetch_wallet_addresses_for_mining( node_impl, data_folder, exception ): diff --git a/src/cryptoadvance/specter/process_controller/node_controller.py b/src/cryptoadvance/specter/process_controller/node_controller.py index 6b86dc898..a923611c6 100644 --- a/src/cryptoadvance/specter/process_controller/node_controller.py +++ b/src/cryptoadvance/specter/process_controller/node_controller.py @@ -560,8 +560,6 @@ def fetch_wallet_addresses_for_mining(node_impl, data_folder, exception=None): Parses all the wallet jsons in the folder (default ~/.specter/wallets/regtest) and returns an array with the addresses. Pass a wallet name via the exception argument so that this wallet's addresses are not included. """ - print(f"{data_folder}/wallets") - print(os.listdir(f"{data_folder}")) addresses_all = [] for folder in [ folder for folder in os.listdir(data_folder) if folder.startswith("wallets") diff --git a/src/cryptoadvance/specter/server_endpoints/filters.py b/src/cryptoadvance/specter/server_endpoints/filters.py index 9f0fbda53..a115f20a1 100644 --- a/src/cryptoadvance/specter/server_endpoints/filters.py +++ b/src/cryptoadvance/specter/server_endpoints/filters.py @@ -3,6 +3,7 @@ from flask import current_app as app from flask import Blueprint from jinja2 import pass_context from ..helpers import to_ascii20 +from ..util.common import format_btc_amount_as_sats, format_btc_amount filters_bp = Blueprint("filters", __name__) @@ -27,6 +28,43 @@ def timedatetime(context, s): return format(datetime.fromtimestamp(s), "%d.%m.%Y %H:%M") +@pass_context +@filters_bp.app_template_filter("average_of_attribute") +def average_of_attribute(context, values, attribute): + dicts = [ + getattr(value, attribute) + for value in values + if getattr(value, attribute) is not None + ] + return sum(dicts) / len(dicts) if dicts else None + + +@pass_context +@filters_bp.app_template_filter("btcunitamount_fixed_decimals") +def btcunitamount_fixed_decimals( + context, + value, + maximum_digits_to_strip=7, + minimum_digits_to_strip=6, + enable_digit_formatting=True, +): + if app.specter.hide_sensitive_info: + return "#########" + if value is None: + return "Unknown" + if value < 0 and app.specter.is_liquid: + return "Confidential" + if app.specter.unit == "sat": + return format_btc_amount_as_sats(value) + + return format_btc_amount( + value, + maximum_digits_to_strip=maximum_digits_to_strip, + minimum_digits_to_strip=minimum_digits_to_strip, + enable_digit_formatting=enable_digit_formatting, + ) + + @pass_context @filters_bp.app_template_filter("btcamount") def btcamount(context, value): diff --git a/src/cryptoadvance/specter/static/styles.css b/src/cryptoadvance/specter/static/styles.css index 483bc82e6..f69b1700f 100644 --- a/src/cryptoadvance/specter/static/styles.css +++ b/src/cryptoadvance/specter/static/styles.css @@ -1310,3 +1310,31 @@ input:checked + .slider:before { padding-left: 6px; padding-right: 6px; } + +/************** Styles for formatting btc amounts ********************************/ +.unselectable { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.transparent-text { + opacity: 0; +} +.thousand-digits-in-btc-amount{ + color: #919191; + padding-left: 2px; +} +.last-digits-in-btc-amount{ + color: #797979; + padding-left: 2px; +} +.thousand-digits-in-sats-amount{ + color: #919191; +} +.last-digits-in-sats-amount{ + color: #797979; +} \ No newline at end of file diff --git a/src/cryptoadvance/specter/templates/wallet/history/components/total_wallet_balances.jinja b/src/cryptoadvance/specter/templates/wallet/history/components/total_wallet_balances.jinja index 864254fcd..acb1addb5 100644 --- a/src/cryptoadvance/specter/templates/wallet/history/components/total_wallet_balances.jinja +++ b/src/cryptoadvance/specter/templates/wallet/history/components/total_wallet_balances.jinja @@ -1,117 +1,183 @@ {# total_wallet_balances - Title showing the wallet total balance + amount confirmed and unconfirmed (if there is any unconfirmed amount). - Parameters: - - wallet: Wallet object - - specter: Specter object + + total_wallet_balances( + _("Some title"), + amount_total, + amount_confirmed, + amount_unconfirmed, + amount_immature, + balance, # needs to be passed for liquid + rescan_progress, # Possible values: True, False, 0..1. Disable: False or 0, Enable otherwise. + wallet_alias, # wallet_alias to check the rescan_progress. + # If the wallet alias exists it shows specter.info["utxorescan"], otherwise rescan_progress is used + specter) + + Calling example for a single wallet: + total_wallet_balances("", wallet.amount_total, wallet.amount_confirmed, wallet.amount_unconfirmed, wallet.amount_immature, wallet.balance, wallet.rescan_progress, wallet.alias, specter) #} -{% macro total_wallet_balances(wallet, specter) -%} +{% macro total_wallet_balances(title, amount_total, amount_confirmed, amount_unconfirmed, amount_immature, balance, rescan_progress, alias, specter) -%} +
- Balance: -
-
- Total amount: {{ wallet.amount_total | btcunitamount }} - {% if specter.unit == 'sat' %} - sats - {% else %} - {% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC +
+ {% if title %} +
{{ title }}
+ {% endif %} + + + + + + {% endif %} + + + {% if amount_unconfirmed > 0 or amount_immature > 0 %} + + + + + {% endif %} + + + {% if amount_unconfirmed > 0 %} + + + + + {% endif %} + + + {% endif %} {% endif %} - {% if specter.price_check %} - ({{ wallet.amount_total | altunit }}) + {% if amount_immature > 0 %} + + + + + {% endif %} + + {% endif %} - - {% if wallet.amount_unconfirmed > 0 or wallet.amount_immature > 0 %} -
- Confirmed: {{ wallet.amount_confirmed | btcunitamount }} - {% if specter.unit == 'sat' %} - sats - {% else %} - {% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC + {% if specter.is_liquid %} + {% if balance.get("assets", {}) %} +
+ + + + {% for asset in balance.get("assets",{}).keys() | sort %} + + {% set balance = balance.get("assets",{}).get(asset, {}) %} + + + {% endfor %} {% endif %} - {% if specter.price_check %} - ({{ wallet.amount_confirmed | altunit }}) - {% endif %} - - {% if wallet.amount_unconfirmed > 0 %} -
- Unconfirmed: {{ wallet.amount_unconfirmed | btcunitamount }} - {% if specter.unit == 'sat' %} - sats - {% else %} - {% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC - {% endif %} - {% if specter.price_check %} - ({{ wallet.amount_unconfirmed | altunit }}) - {% endif %} -
{% endif %} - {% if wallet.amount_immature > 0 and specter.chain == "regtest" %} -
- Immature: {{ wallet.amount_immature | btcunitamount }} - {% if specter.unit == 'sat' %} - sats - {% else %} - {% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC - {% endif %} - {% if specter.price_check %} - ({{ wallet.amount_immature | altunit }}) - {% endif %} - -

{{ _("What are immature outputs?") }}

- - {{ _('The UTXO of coinbase transactions (mining rewards) have the special condition that they cannot be spent for at least 100 blocks. Their amount is not included in the total balance here. You see these outputs because you are using regtest and have mined blocks.') }} - -
-
- {% endif %} - {% elif wallet.amount_immature > 0 and specter.chain == "main" %} -
- Immature: {{ wallet.amount_immature | btcunitamount }} - {% if specter.unit == 'sat' %} - sats - {% else %} - {% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC - {% endif %} - {% if specter.price_check %} - ({{ wallet.amount_immature | altunit }}) - {% endif %} - -

{{ _("What are immature outputs?") }}

- - {{ _('The UTXO of coinbase transactions (mining rewards) have the special condition that they cannot be spent for at least 100 blocks. Their amount is not included in the total balance here.') }} - -
-
+
Total amount:{{ amount_total | btcunitamount_fixed_decimals | safe }} + {% if specter.unit == 'sat' %} + sats + {% else %} + {% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC + {% endif %} + {% if specter.price_check %} + + ({{ amount_total | altunit }}) +
Confirmed:{{ amount_confirmed | btcunitamount_fixed_decimals | safe }} + {% if specter.unit == 'sat' %} + sats + {% else %} + {% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC + {% endif %} + {% if specter.price_check %} + + ({{ amount_confirmed | altunit }}) +
Unconfirmed:{{ amount_unconfirmed | btcunitamount_fixed_decimals | safe }} + {% if specter.unit == 'sat' %} + sats + {% else %} + {% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC + {% endif %} + {% if specter.price_check %} + + ({{ amount_unconfirmed | altunit }}) +
+ Immature: + +

{{ _("What are immature outputs?") }}

+ + {{ _('The UTXO of coinbase transactions (mining rewards) have the special condition that they cannot be spent for at least 100 blocks. Their amount is not included in the total balance here.') }} + {% if specter.chain == "regtest" %}{{ _('You see these outputs because you are using regtest and have mined blocks.')}}{% endif %} + +
+
{{ amount_immature | btcunitamount_fixed_decimals | safe }} + {% if specter.unit == 'sat' %} + sats + {% else %} + {% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC + {% endif %} + {% if specter.price_check %} + + ({{ amount_immature | altunit }}) +
Liquid Assets: + {% include 'includes/overlay/liquid_assets_registry.html' %} + +
+ {{ (balance.get("trusted", 0) + balance.get("untrusted_pending", 0) + balance.get("immature", 0)) | btcunitamount_fixed_decimals | safe }} + +
+
+
+ {% if rescan_progress or specter.utxorescanwallet == alias %} +
+ {% if rescan_progress %} +

Rescanning blockchain: {{ rescan_progress * 100 }}%

+ +
+ Total balance and transactions history may show outdated data during scanning.
Please wait until the scanning is complete before you start using the wallet. +
+ {% endif %} + {% if specter.utxorescanwallet == alias %} +

Scanning the UTXO set: {{ specter.info["utxorescan"] }}%

+ +
+ Total balance may show outdated data during scanning.
Please wait until the scanning is complete before you start using the wallet. +
{% endif %}
- {% if specter.is_liquid %} - {% if wallet.balance.get("assets", {}) %} -
- Assets:
- {% for asset in wallet.balance.get("assets",{}).keys() | sort %} - {% set balance = wallet.balance.get("assets",{}).get(asset, {}) %} -
- {{ (balance.get("trusted", 0) + balance.get("untrusted_pending", 0) + balance.get("immature", 0)) | btcamount }} - -
- {% endfor %} -
-
- {% endif %} - {% include 'includes/overlay/liquid_assets_registry.html' %} - - - -
- {% endif %} -
- {% if wallet.rescan_progress or specter.utxorescanwallet == wallet.alias %} - -

Rescanning blockchain: {{ "%.2f"|format(specter.info["utxorescan"] if specter.utxorescanwallet == wallet.alias else wallet.rescan_progress * 100) }}%

- -
- Total balance and transactions history may show outdated data during scanning.
Please wait until the scanning is complete before you start using the wallet. -
-
{% endif %} {%- endmacro %} diff --git a/src/cryptoadvance/specter/templates/wallet/history/wallet_history.jinja b/src/cryptoadvance/specter/templates/wallet/history/wallet_history.jinja index dd3d455b3..40a4bd17b 100644 --- a/src/cryptoadvance/specter/templates/wallet/history/wallet_history.jinja +++ b/src/cryptoadvance/specter/templates/wallet/history/wallet_history.jinja @@ -2,7 +2,16 @@ {% set tab = 'history' %} {% block content %} {% from 'wallet/history/components/total_wallet_balances.jinja' import total_wallet_balances %} - {{ total_wallet_balances(wallet, specter) }} + {{ total_wallet_balances( + "", + wallet.amount_total, + wallet.amount_confirmed, + wallet.amount_unconfirmed, + wallet.amount_immature, + wallet.balance, + wallet.rescan_progress, + wallet.alias, + specter) }}
{% include "includes/services-data.html" %} {% include "includes/tx-row.html" %} diff --git a/src/cryptoadvance/specter/templates/wallet/wallets_overview.jinja b/src/cryptoadvance/specter/templates/wallet/wallets_overview.jinja index f654539d6..a9fee12e1 100644 --- a/src/cryptoadvance/specter/templates/wallet/wallets_overview.jinja +++ b/src/cryptoadvance/specter/templates/wallet/wallets_overview.jinja @@ -18,48 +18,23 @@ {% include "includes/address-label.html" %}

{{ _("Wallets Overview") }}

{{ _("Here you can see the combined balance and transactions history of all your Specter wallets.") }}

-

- Total balance:
- - {% set fullbalance = specter.wallet_manager.wallets.values() | sum(attribute='fullbalance') %} - {% set balance = specter.wallet_manager.joined_balance() %} - {{ fullbalance | btcunitamount }} - {% if specter.unit == 'sat' %} - sats - {% else %} - {% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC - {% endif %} -
- {{ fullbalance | altunit }} - {% if balance.get("untrusted_pending", 0) or balance.get("immature", 0) %}
- ( {{ balance.get("trusted", 0) | btcunitamount }} {{ _("confirmed") }}, - {% if balance.get("untrusted_pending", 0) %} - {{ balance.get("untrusted_pending", 0) | btcunitamount }} {{ _("pending") }} - {% endif %} - {% if balance.get("immature", 0) %} - {{ balance.get("immature", 0) | btcunitamount }} {{ _("immature") }} - {% endif %} - ) - {% endif %} - {% if specter.is_liquid %} - {% if balance.get("assets", {}) %} -
- {{ _("Assets:") }}
- {% for asset in balance.get("assets",{}).keys() | sort %} - {% set asset_balance = balance.get("assets",{}).get(asset, {}) %} -
- {{ (asset_balance.get("trusted", 0) + asset_balance.get("untrusted_pending", 0) + asset_balance.get("immature", 0)) | btcamount }} - -
- {% endfor %} -
-
- {% endif %} - {% include 'includes/overlay/liquid_assets_registry.html' %} - -
- {% endif %} -

+ {% from 'wallet/history/components/total_wallet_balances.jinja' import total_wallet_balances %} + {% set amount_total = specter.wallet_manager.wallets.values() | sum(attribute='amount_total') %} + {% set amount_confirmed = specter.wallet_manager.wallets.values() | sum(attribute='amount_confirmed') %} + {% set amount_unconfirmed = specter.wallet_manager.wallets.values() | sum(attribute='amount_unconfirmed') %} + {% set amount_immature = specter.wallet_manager.wallets.values() | sum(attribute='amount_immature') %} + {% set balance = specter.wallet_manager.joined_balance() %} + {% set rescan_progress = specter.wallet_manager.wallets.values() | average_of_attribute(attribute='rescan_progress') %} + {{ total_wallet_balances( + _("Combined Wallet Balances"), + amount_total, + amount_confirmed, + amount_unconfirmed, + amount_immature, + balance, + rescan_progress, + "", + specter) }}
{% include "includes/tx-row.html" %} {% include "includes/tx-data.html" %} diff --git a/src/cryptoadvance/specter/util/common.py b/src/cryptoadvance/specter/util/common.py index 8589b7fe4..d5cf55547 100644 --- a/src/cryptoadvance/specter/util/common.py +++ b/src/cryptoadvance/specter/util/common.py @@ -3,6 +3,7 @@ import re from datetime import datetime import json from flask_babel.speaklater import LazyString +from typing import Union logger = logging.getLogger(__name__) @@ -29,6 +30,100 @@ def snake_case2camelcase(word): return "".join(x.capitalize() or "_" for x in word.split("_")) +def format_btc_amount_as_sats( + value: Union[float, str], + enable_digit_formatting=False, +) -> str: + s = "{:,.0f}".format(round(float(value) * 1e8)) + + # combine the "," with the left number to an array + array = [] + for letter in s: + if letter == ",": + array[-1] += letter + else: + array.append(letter) + + if enable_digit_formatting: + if len(array) >= 4: + left_index = -6 if len(array) >= 6 else -len(array) + array[ + left_index + ] = f'{array[left_index]}' + array[-4] = f"{array[-4]}" + + left_index = -3 if len(array) >= 3 else -len(array) + array[ + left_index + ] = f'{array[left_index]}' + array[-1] = f"{array[-1]}" + + return "".join(array) + + +def format_btc_amount( + value: Union[float, str], + maximum_digits_to_strip=7, + minimum_digits_to_strip=6, + enable_digit_formatting=True, +) -> str: + """ + Formats the btc amount such that it can be right aligned such + that the decimal separator will be always at the same x position. + + Stripping trailing 0's is done via just making the 0's transparent. + + Args: + value (Union[float, str]): Will convert string to float. + The float is expected to be in the unit (L)BTC with 8 relevant digits + maximum_digits_to_strip (int, optional): No more than maximum_digits_to_strip + trailing 0's will be stripped. Defaults to 7. + minimum_digits_to_strip (int, optional): Only strip any trailing 0's if + there are at least minimum_digits_to_strip. Defaults to 6. + enable_digit_formatting (bool, optional): Will group the Satoshis into blocks of 3, + e.g. 0.03 123 456, and color the blocks. Defaults to True. + + Returns: + str: The formatted btc amount as html code. + """ + value = round(float(value), 8) + formatted_amount = "{:,.8f}".format(value) + + count_digits_that_can_be_stripped = 0 + for i in reversed(range(len(formatted_amount))): + if formatted_amount[i] == "0": + count_digits_that_can_be_stripped += 1 + continue + break + + array = list(formatted_amount) + if count_digits_that_can_be_stripped >= minimum_digits_to_strip: + # loop through the float number, e.g. 0.03 000 000, from the right and replace 0's or the '.' until you hit anything != 0 + for i in reversed(range(len(array))): + if array[i] == "0" and len(array) - i <= maximum_digits_to_strip: + array[ + i + ] = f'{array[i]}' + # since this digit == 0, then continue the loop and check the next digit + continue + # the following if branch is only relevant if last_digits_to_strip == 8, i.e. all digits can be stripped + elif formatted_amount[i] == ".": + array[ + i + ] = f'{array[i]}' + # since this character == '.', then the loop must be broken now + # always break the loop. Only the digit == 0 can prevent this break + break + + if enable_digit_formatting: + array[-6] = f'{array[-6]}' + array[-4] = f"{array[-4]}" + array[-3] = f'{array[-3]}' + array[-1] = f"{array[-1]}" + + return "".join(array) + + def robust_json_dumps(obj): def default(o): if isinstance(o, datetime): diff --git a/src/cryptoadvance/specterext/devhelp/static/devhelp/css/styles.css b/src/cryptoadvance/specterext/devhelp/static/devhelp/css/styles.css new file mode 100644 index 000000000..7ee977073 --- /dev/null +++ b/src/cryptoadvance/specterext/devhelp/static/devhelp/css/styles.css @@ -0,0 +1,5 @@ +/* This is the place to put all your styles */ + +pre{ + overflow-x: visible; +} \ No newline at end of file diff --git a/src/cryptoadvance/specterext/devhelp/templates/devhelp/base.jinja b/src/cryptoadvance/specterext/devhelp/templates/devhelp/base.jinja new file mode 100644 index 000000000..abe331658 --- /dev/null +++ b/src/cryptoadvance/specterext/devhelp/templates/devhelp/base.jinja @@ -0,0 +1,5 @@ + +{% extends "base.jinja" %} +{% block head %} + +{% endblock %} \ No newline at end of file diff --git a/src/cryptoadvance/specterext/devhelp/templates/devhelp/components/devhelp_tab.jinja b/src/cryptoadvance/specterext/devhelp/templates/devhelp/components/devhelp_tab.jinja index 38355b299..0a52eb231 100644 --- a/src/cryptoadvance/specterext/devhelp/templates/devhelp/components/devhelp_tab.jinja +++ b/src/cryptoadvance/specterext/devhelp/templates/devhelp/components/devhelp_tab.jinja @@ -1,4 +1,4 @@ -{% extends "base.jinja" %} +{% extends "devhelp/base.jinja" %} {% block main %} {% from 'devhelp/components/devhelp_menu.jinja' import devhelp_menu with context %} diff --git a/src/cryptoadvance/specterext/devhelp/templates/devhelp/html/macro_total_wallet_balances.jinja b/src/cryptoadvance/specterext/devhelp/templates/devhelp/html/macro_total_wallet_balances.jinja new file mode 100644 index 000000000..64e66c851 --- /dev/null +++ b/src/cryptoadvance/specterext/devhelp/templates/devhelp/html/macro_total_wallet_balances.jinja @@ -0,0 +1,88 @@ +{% extends "devhelp/components/devhelp_tab.jinja" %} +{% block title %}address-data{% endblock %} +{% set tab = 'html' %} +{% block content %} + +

total_wallet_balances

+ +{% from 'wallet/history/components/total_wallet_balances.jinja' import total_wallet_balances %} + +

Usage

+
+     total_wallet_balances(
+        _("Some title"),
+        amount_total, 
+        amount_confirmed,
+        amount_unconfirmed, 
+        amount_immature, 
+        balance,            # needs to be passed for liquid
+        rescan_progress,    # Possible values: True, False, 0..1.    Disable: False or 0,  Enable otherwise.
+        wallet_alias,       # wallet_alias to check the rescan_progress.   
+                            # If the wallet alias exists it shows specter.info["utxorescan"], otherwise rescan_progress is used
+        specter) 
+
+
+
+ +

Example without rescanning

+

For the macro to correctly display the strings, the values for the amounts have to have 8 decimals.
+ Truncation of 0s depends on the defaults chosen in filters.py +

+ +
+    total_wallet_balances(
+        _("Some customizable text"),
+        2.02067075, 
+        1.02065575,
+        1.00001500, 
+        0.00020000, 
+        0, 
+        False, 
+        "",
+        specter
+    )
+
+ +{{ total_wallet_balances( + _("Some customizable text"), + 2.02067075, + 1.02065575, + 1.00001500, + 0.00020000, + None, + False, + "", + specter) }} + +

Example with full blockchain rescan

+

Here we are simulating the rendering of the display if a full rescan is under way (thus the additional box below).

+ +
+    total_wallet_balances(
+        _("Some customizable text"),
+        2.02067075, 
+        1.02065575,
+        1.00001500, 
+        2.00000000, 
+        None, 
+        0.33, 
+        "some_existent_wallet_alias", 
+        specter
+    )
+
+ +{{ total_wallet_balances( + _("Some customizable text"), + 2.02067075, + 1.02065575, + 1.00001500, + 2.00000000, + None, + 0.33, + "some_existent_wallet_alias", + specter) }} + +{% 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 48963654e..b8c883b62 100644 --- a/src/cryptoadvance/specterext/devhelp/templates/devhelp/index.jinja +++ b/src/cryptoadvance/specterext/devhelp/templates/devhelp/index.jinja @@ -7,7 +7,7 @@ This Extension is only useful for Developing and testing purposes.

- You can checkout and test the different HTML-Components. Nothing else to see here. + You can checkout and test the different HTML-Components.

+ +

+ Same for some macros. +

+ + + + {% endblock %} \ No newline at end of file diff --git a/tests/test_cli_bitcoind.py b/tests/test_cli_bitcoind.py deleted file mode 100644 index af30cf83b..000000000 --- a/tests/test_cli_bitcoind.py +++ /dev/null @@ -1,48 +0,0 @@ -import logging -import mock -import pytest -import sys -import traceback - -from cryptoadvance.specter.cli import bitcoind, elementsd -from click.testing import CliRunner -from mock import patch, MagicMock, call - - -def test_bitcoind(caplog): - caplog.set_level(logging.DEBUG) - - runner = CliRunner() - result = runner.invoke(bitcoind, ["--no-mining", "--nodocker", "--cleanuphard"]) - print(result.output) - if result.exception != None: - # Makes searching for issues much more convenient - traceback.print_tb(result.exception.__traceback__) - print(result.exception, file=sys.stderr) - assert result.exit_code == 0 - assert ( - "bitcoin-cli: bitcoin-cli -regtest -rpcport=18443 -rpcuser=bitcoin -rpcpassword=secret getblockchaininfo" - in result.output - ) - # This might take a lot of time because we're waiting on the bitcoind to terminate - - -def test_elements(caplog): - caplog.set_level(logging.DEBUG) - - runner = CliRunner() - result = runner.invoke(elementsd, ["--no-mining", "--cleanuphard"]) - print(result.output) - if result.exception != None: - if "Couldn't find executable elementsd" in str(result.exception): - pytest.skip(str(result.exception)) - - # Makes searching for issues much more convenient - traceback.print_tb(result.exception.__traceback__) - print(result.exception, file=sys.stderr) - assert result.exit_code == 0 - assert ( - "elements-cli: elements-cli -regtest -rpcport=18884 -rpcuser=liquid -rpcpassword=secret getblockchaininfo" - in result.output - ) - # This might take a lot of time because we're waiting on the bitcoind to terminate diff --git a/tests/test_util_common.py b/tests/test_util_common.py index 03bfd15d0..a2a2e0696 100644 --- a/tests/test_util_common.py +++ b/tests/test_util_common.py @@ -2,6 +2,8 @@ from cryptoadvance.specter.util.common import ( camelcase2snake_case, snake_case2camelcase, str2bool, + format_btc_amount, + format_btc_amount_as_sats, ) @@ -29,3 +31,71 @@ def test_camelcase2snake_case(): def test_snake_case2camelcase(): assert snake_case2camelcase("service") == "Service" assert snake_case2camelcase("device_Type") == "DeviceType" + + +def test_format_btc_amount(): + btc_amount = 1.05678000 + assert ( + format_btc_amount(btc_amount) + == """1.05678\ +000""" + ) + # All 0s stripped + btc_amount = 1.05000000 # 1.05 + assert ( + format_btc_amount(btc_amount) + == """1.05\ +00\ +0\ +\ +00\ +0""" + ) + # Maximum amount of 0s stripped + btc_amount = 40.00000000 # 40.0 + assert ( + format_btc_amount(btc_amount) + == """40.00\ +00\ +0\ +\ +00\ +0""" + ) + # Last three 0s stripped + btc_amount = 1.05678000 # 1.05678 + assert ( + format_btc_amount(btc_amount, minimum_digits_to_strip=3) + == """1.05678\ +\ +0\ +0\ +0""" + ) + + +def test_format_btc_amount_as_sats(): + btc_amount = 0.00560000 + assert ( + format_btc_amount_as_sats(btc_amount, enable_digit_formatting=True) + == '560,000' + ) + assert format_btc_amount_as_sats(btc_amount) == "560,000" + btc_amount = 0.10560000 + assert ( + format_btc_amount_as_sats(btc_amount, enable_digit_formatting=True) + == '10,560,000' + ) + assert format_btc_amount_as_sats(btc_amount) == "10,560,000" + btc_amount = 1.0 + assert ( + format_btc_amount_as_sats(btc_amount, enable_digit_formatting=True) + == '100,000,000' + ) + assert format_btc_amount_as_sats(btc_amount) == "100,000,000" + btc_amount = 1.56000000 + assert ( + format_btc_amount_as_sats(btc_amount, enable_digit_formatting=True) + == '156,000,000' + ) + assert format_btc_amount_as_sats(btc_amount) == "156,000,000" diff --git a/utils/test-cypress.sh b/utils/test-cypress.sh index 440c2b3b0..7443736f1 100755 --- a/utils/test-cypress.sh +++ b/utils/test-cypress.sh @@ -151,7 +151,7 @@ function start_node { fi fi echo "--> Starting $node_impl with $addopts ..." - python3 -m cryptoadvance.specter $DEBUG $node_impl $addopts --port $node_port --create-conn-json --config $SPECTER_CONFIG & + python3 -m cryptoadvance.specter $DEBUG $node_impl $addopts --no-mining --port $node_port --create-conn-json --config $SPECTER_CONFIG & if [ "$node_impl" = "bitcoind" ]; then bitcoind_pid=$! else