UIUX: Better balance display (#1841)

* Better alignment and introduction of spaces and colours for BTC amounts.

Co-authored-by: Kim Neunert <k9ert@gmx.de>
Co-authored-by: moneymanolis <moneymanolis@protonmail.com>
This commit is contained in:
relativisticelectron 2022-09-20 09:25:46 +02:00 committed by GitHub
parent 66d56a2c8e
commit 7f0ac699e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 723 additions and 251 deletions

View file

@ -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",

View file

@ -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
<th id="fullbalance_amount" class="right-align">
20.0
<span class="unselectable transparent-text">0</span>
<span class="thousand-digits-in-btc-amount">
<span class="unselectable transparent-text">0</span>
<span class="unselectable transparent-text">0</span>
<span class="unselectable transparent-text">0</span>
</span>
<span class="last-digits-in-btc-amount">
<span class="unselectable transparent-text">0</span>
<span class="unselectable transparent-text">0</span>
<span class="unselectable transparent-text">0</span>
</span>
</th>
*/
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
<th id="unconfirmed_amount" class="right-align">
0.05
<span class="thousand-digits-in-btc-amount">
<span class="unselectable transparent-text">0</span>
<span class="unselectable transparent-text">0</span>
<span class="unselectable transparent-text">0</span>
</span>
<span class="last-digits-in-btc-amount">
<span class="unselectable transparent-text">0</span>
<span class="unselectable transparent-text">0</span>
<span class="unselectable transparent-text">0</span>
</span>
</th>
*/
// 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
<th id="fullbalance_amount" class="right-align">
19.94
<span class="thousand-digits-in-btc-amount">999</span>
<span class="last-digits-in-btc-amount">291</span>
</th>
*/
// 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.
})

View file

@ -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
})
, {

View file

@ -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
):

View file

@ -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")

View file

@ -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):

View file

@ -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;
}

View file

@ -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) -%}
<style type="text/css">
table {
font-size: 1.1em;
border: none;
border-collapse: initial;
}
th {
text-align: left;
font-weight: inherit;
border-bottom: initial;
padding:initial;
padding-left:5px;
}
tr {
background: initial;
}
.btn {
font-size: 0.9em;
}
button.btn {
display: inline-flex;
font-size: 0.9em;
padding: 2px 10px 2px 10px;
height: initial;
min-height: initial;
width: initial;
min-width: initial;
}
.right-align {
text-align: right;
}
h4 {
font-weight: unset;
}
</style>
<div style="display: flex; flex-direction: column; min-width: 300px; align-items: center">
<span style="line-height:30px; font-size: 1.1em;">Balance:</span>
<div class="smaller-card">
<div>
Total amount: <span id="fullbalance_amount">{{ wallet.amount_total | btcunitamount }}</span>
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
<div class="smaller-card">
{% if title %}
<div style="text-align: center; width:100%; line-height:30px; font-size: 1.2em;">{{ title }}</div>
{% endif %}
<table>
<tr>
<th>Total amount:</th>
<th id="fullbalance_amount" class="right-align">{{ amount_total | btcunitamount_fixed_decimals | safe }}</th>
<th>
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}
{% if specter.price_check %}
<th class="right-align">
<span class="note">({{ amount_total | altunit }})</span>
</th>
{% endif %}
</th>
</tr>
{% if amount_unconfirmed > 0 or amount_immature > 0 %}
<tr>
<th>Confirmed:</th>
<th id="confirmed_amount" class="right-align">{{ amount_confirmed | btcunitamount_fixed_decimals | safe }}</th>
<th>
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}
{% if specter.price_check %}
<th class="right-align">
<span class="note">({{ amount_confirmed | altunit }})</span>
</th>
{% endif %}
</th>
</tr>
{% if amount_unconfirmed > 0 %}
<tr>
<th>Unconfirmed:</th>
<th id="unconfirmed_amount" class="right-align">{{ amount_unconfirmed | btcunitamount_fixed_decimals | safe }}</th>
<th>
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}
{% if specter.price_check %}
<th class="right-align">
<span class="note">({{ amount_unconfirmed | altunit }})</span>
</th>
{% endif %}
</th>
</tr>
{% endif %}
{% endif %}
{% if specter.price_check %}
<span class="note">({{ wallet.amount_total | altunit }})</span>
{% if amount_immature > 0 %}
<tr>
<th style="display: flex;">
<span>Immature:</span>
<tool-tip width="200px">
<h4 slot="title">{{ _("What are immature outputs?") }}</h4>
<span slot="paragraph">
{{ _('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 %}
</span>
</tool-tip>
<th class="right-align">{{ amount_immature | btcunitamount_fixed_decimals | safe }}</th>
</th>
<th>
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}
{% if specter.price_check %}
<th class="right-align">
<span class="note">({{ amount_immature | altunit }})</span>
</th>
{% endif %}
</th>
</tr>
{% endif %}
</div>
{% if wallet.amount_unconfirmed > 0 or wallet.amount_immature > 0 %}
<div>
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", {}) %}
<tr>
<th>Liquid Assets:</th>
<th colspan="2">
{% include 'includes/overlay/liquid_assets_registry.html' %}
<button type="button" class="btn" onclick="showPageOverlay('liquid-assets-registry')">{{ _("Show assets list") }}</button>
</th>
</tr>
{% for asset in balance.get("assets",{}).keys() | sort %}
<th></th>
{% set balance = balance.get("assets",{}).get(asset, {}) %}
<th class="right-align">
{{ (balance.get("trusted", 0) + balance.get("untrusted_pending", 0) + balance.get("immature", 0)) | btcunitamount_fixed_decimals | safe }}</th>
<th>
<asset-label data-asset="{{asset}}" data-label="{{asset | assetlabel}}" edit-mode="enabled"></asset-label>
</th>
{% endfor %}
{% endif %}
{% if specter.price_check %}
<span class="note">({{ wallet.amount_confirmed | altunit }})</span>
{% endif %}
</div>
{% if wallet.amount_unconfirmed > 0 %}
<div>
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 %}
<span class="note">({{ wallet.amount_unconfirmed | altunit }})</span>
{% endif %}
</div>
{% endif %}
{% if wallet.amount_immature > 0 and specter.chain == "regtest" %}
<div style="color: grey; display: flex;">
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 %}
<span class="note" style="color: grey">({{ wallet.amount_immature | altunit }})</span>
{% endif %}
<tool-tip width="200px">
<h4 slot="title">{{ _("What are immature outputs?") }}</h4>
<span slot="paragraph">
{{ _('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.') }}
</span>
</tool-tip>
</div>
{% endif %}
{% elif wallet.amount_immature > 0 and specter.chain == "main" %}
<div style="color: #ccc; display: flex">
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 %}
<span class="note" style="color: grey">({{ wallet.amount_immature | altunit }})</span>
{% endif %}
<tool-tip width="200px">
<h4 slot="title">{{ _("What are immature outputs?") }}</h4>
<span slot="paragraph">
{{ _('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.') }}
</span>
</tool-tip>
</div>
</table>
</div>
</div>
{% if rescan_progress or specter.utxorescanwallet == alias %}
<div id="wallet_rescan_data" style="display: contents;">
{% if rescan_progress %}
<h4>Rescanning blockchain: <span id="{{ alias }}_balances_wallet_rescan_percents">{{ rescan_progress * 100 }}</span>%</h4>
<span class="warning">
<img src="{{ url_for('static', filename='img/info_sign.svg') }}" style="width: 20px;"><br>
Total balance and transactions history may show outdated data during scanning.<br>Please wait until the scanning is complete before you start using the wallet.
</span>
{% endif %}
{% if specter.utxorescanwallet == alias %}
<h4>Scanning the UTXO set: <span id="{{ alias }}_balances_wallet_rescan_percents">{{ specter.info["utxorescan"] }}</span>%</h4>
<span class="warning">
<img src="{{ url_for('static', filename='img/info_sign.svg') }}" style="width: 20px;"><br>
Total balance may show outdated data during scanning.<br>Please wait until the scanning is complete before you start using the wallet.
</span>
{% endif %}
</div>
{% if specter.is_liquid %}
{% if wallet.balance.get("assets", {}) %}
<div style="margin-bottom: 15px;">
<small style="line-height:30px">Assets:<br>
{% for asset in wallet.balance.get("assets",{}).keys() | sort %}
{% set balance = wallet.balance.get("assets",{}).get(asset, {}) %}
<div style="margin: 0 10px; display: inline-block;">
{{ (balance.get("trusted", 0) + balance.get("untrusted_pending", 0) + balance.get("immature", 0)) | btcamount }}
<asset-label data-asset="{{asset}}" data-label="{{asset | assetlabel}}" edit-mode="enabled"></asset-label>
</div>
{% endfor %}
</small>
</div>
{% endif %}
{% include 'includes/overlay/liquid_assets_registry.html' %}
<small style="line-height: 30px;">
<button type="button" class="btn" style="margin: auto;" onclick="showPageOverlay('liquid-assets-registry')">{{ _("Show assets list") }}</button>
</small>
<br>
{% endif %}
</div>
{% if wallet.rescan_progress or specter.utxorescanwallet == wallet.alias %}
<span id="wallet_rescan_data" style="display: contents;">
<h2>Rescanning blockchain: <span id="{{ wallet.alias }}_balances_wallet_rescan_percents">{{ "%.2f"|format(specter.info["utxorescan"] if specter.utxorescanwallet == wallet.alias else wallet.rescan_progress * 100) }}</span>%</h2>
<span class="warning">
<img src="{{ url_for('static', filename='img/info_sign.svg') }}" style="width: 20px;"/><br>
Total balance and transactions history may show outdated data during scanning.<br>Please wait until the scanning is complete before you start using the wallet.
</span>
</span>
{% endif %}
{%- endmacro %}

View file

@ -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) }}
<div class="table-holder">
{% include "includes/services-data.html" %}
{% include "includes/tx-row.html" %}

View file

@ -18,48 +18,23 @@
{% include "includes/address-label.html" %}
<h1 id="title">{{ _("Wallets Overview") }}</h1>
<p style="width: 80%; text-align: center; margin-left: auto; margin-right: auto;">{{ _("Here you can see the combined balance and transactions history of all your Specter wallets.") }}</p>
<h1>
<small style="line-height:30px">Total balance:</small><br>
<span style="color: #fff">
{% set fullbalance = specter.wallet_manager.wallets.values() | sum(attribute='fullbalance') %}
{% set balance = specter.wallet_manager.joined_balance() %}
<span id="fullbalance_amount">{{ fullbalance | btcunitamount }}</span>
{% if specter.unit == 'sat' %}
sats
{% else %}
{% if specter.is_testnet %}t{%endif%}{% if specter.is_liquid %}L{%endif%}BTC
{% endif %}
</span><br>
<span class="note">{{ fullbalance | altunit }}</span>
{% if balance.get("untrusted_pending", 0) or balance.get("immature", 0) %}<br>
<small>( {{ 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 %}
)</small>
{% endif %}
{% if specter.is_liquid %}
{% if balance.get("assets", {}) %}
<div style="margin-bottom: 15px;">
<small style="line-height:30px">{{ _("Assets:") }}<br>
{% for asset in balance.get("assets",{}).keys() | sort %}
{% set asset_balance = balance.get("assets",{}).get(asset, {}) %}
<div style="margin: 0 10px; display: inline-block;">
{{ (asset_balance.get("trusted", 0) + asset_balance.get("untrusted_pending", 0) + asset_balance.get("immature", 0)) | btcamount }}
<asset-label data-asset="{{asset}}" data-label="{{asset | assetlabel}}" edit-mode="enabled"></asset-label>
</div>
{% endfor %}
</small>
</div>
{% endif %}
{% include 'includes/overlay/liquid_assets_registry.html' %}
<small><button type="button" class="btn" style="margin: auto;" onclick="showPageOverlay('liquid-assets-registry')">{{ _("Show assets list") }}</button></small>
<br>
{% endif %}
</h1>
{% 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) }}
<div class="table-holder">
{% include "includes/tx-row.html" %}
{% include "includes/tx-data.html" %}

View file

@ -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'<span class="thousand-digits-in-sats-amount">{array[left_index]}'
array[-4] = f"{array[-4]}</span>"
left_index = -3 if len(array) >= 3 else -len(array)
array[
left_index
] = f'<span class="last-digits-in-sats-amount">{array[left_index]}'
array[-1] = f"{array[-1]}</span>"
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'<span class="unselectable transparent-text">{array[i]}</span>'
# 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'<span class="unselectable transparent-text">{array[i]}</span>'
# 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'<span class="thousand-digits-in-btc-amount">{array[-6]}'
array[-4] = f"{array[-4]}</span>"
array[-3] = f'<span class="last-digits-in-btc-amount">{array[-3]}'
array[-1] = f"{array[-1]}</span>"
return "".join(array)
def robust_json_dumps(obj):
def default(o):
if isinstance(o, datetime):

View file

@ -0,0 +1,5 @@
/* This is the place to put all your styles */
pre{
overflow-x: visible;
}

View file

@ -0,0 +1,5 @@
{% extends "base.jinja" %}
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ url_for(service.id +'_endpoint' + '.static', filename='devhelp/css/styles.css') }}">
{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends "base.jinja" %}
{% extends "devhelp/base.jinja" %}
{% block main %}
<img src="{{ url_for(service.id +'_endpoint' + '.static', filename=service.logo) }}" width="50"/>
{% from 'devhelp/components/devhelp_menu.jinja' import devhelp_menu with context %}

View file

@ -0,0 +1,88 @@
{% extends "devhelp/components/devhelp_tab.jinja" %}
{% block title %}address-data{% endblock %}
{% set tab = 'html' %}
{% block content %}
<h1>total_wallet_balances</h1>
{% from 'wallet/history/components/total_wallet_balances.jinja' import total_wallet_balances %}
<h3>Usage</h3>
<pre>
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)
</pre>
<h2>Example without rescanning</h2>
<p>For the macro to correctly display the strings, the values for the amounts have to have 8 decimals.<br>
Truncation of 0s depends on the defaults chosen in filters.py
</p>
<pre>
total_wallet_balances(
_("Some customizable text"),
2.02067075,
1.02065575,
1.00001500,
0.00020000,
0,
False,
"",
specter
)
</pre>
{{ total_wallet_balances(
_("Some customizable text"),
2.02067075,
1.02065575,
1.00001500,
0.00020000,
None,
False,
"",
specter) }}
<h2 style="margin-top: 20px">Example with full blockchain rescan</h2>
<p>Here we are simulating the rendering of the display if a full rescan is under way (thus the additional box below).</p>
<pre>
total_wallet_balances(
_("Some customizable text"),
2.02067075,
1.02065575,
1.00001500,
2.00000000,
None,
0.33,
"some_existent_wallet_alias",
specter
)
</pre>
{{ 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 %}

View file

@ -7,7 +7,7 @@
This Extension is only useful for Developing and testing purposes.
</p>
<p>
You can checkout and test the different HTML-Components. Nothing else to see here.
You can checkout and test the different HTML-Components.
</p>
<ul>
@ -17,4 +17,14 @@
<li><a href="html/tx-table.jinja">tx-table</a></li>
<li><a href="html/address-row.jinja">address-row</a></li>
</ul>
<p>
Same for some macros.
</p>
<ul>
<li><a href="html/macro_total_wallet_balances.jinja">total_wallet_balances</a></li>
</ul>
{% endblock %}

View file

@ -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

View file

@ -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.05<span class="thousand-digits-in-btc-amount">678</span>\
<span class="last-digits-in-btc-amount">000</span>"""
)
# All 0s stripped
btc_amount = 1.05000000 # 1.05
assert (
format_btc_amount(btc_amount)
== """1.05<span class="thousand-digits-in-btc-amount">\
<span class="unselectable transparent-text">0</span><span class="unselectable transparent-text">0</span>\
<span class="unselectable transparent-text">0</span></span>\
<span class="last-digits-in-btc-amount">\
<span class="unselectable transparent-text">0</span><span class="unselectable transparent-text">0</span>\
<span class="unselectable transparent-text">0</span></span>"""
)
# Maximum amount of 0s stripped
btc_amount = 40.00000000 # 40.0
assert (
format_btc_amount(btc_amount)
== """40.0<span class="unselectable transparent-text">0</span><span class="thousand-digits-in-btc-amount">\
<span class="unselectable transparent-text">0</span><span class="unselectable transparent-text">0</span>\
<span class="unselectable transparent-text">0</span></span>\
<span class="last-digits-in-btc-amount">\
<span class="unselectable transparent-text">0</span><span class="unselectable transparent-text">0</span>\
<span class="unselectable transparent-text">0</span></span>"""
)
# Last three 0s stripped
btc_amount = 1.05678000 # 1.05678
assert (
format_btc_amount(btc_amount, minimum_digits_to_strip=3)
== """1.05<span class="thousand-digits-in-btc-amount">678</span>\
<span class="last-digits-in-btc-amount">\
<span class="unselectable transparent-text">0</span>\
<span class="unselectable transparent-text">0</span>\
<span class="unselectable transparent-text">0</span></span>"""
)
def test_format_btc_amount_as_sats():
btc_amount = 0.00560000
assert (
format_btc_amount_as_sats(btc_amount, enable_digit_formatting=True)
== '<span class="thousand-digits-in-sats-amount">560,</span><span class="last-digits-in-sats-amount">000</span>'
)
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,<span class="thousand-digits-in-sats-amount">560,</span><span class="last-digits-in-sats-amount">000</span>'
)
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,<span class="thousand-digits-in-sats-amount">000,</span><span class="last-digits-in-sats-amount">000</span>'
)
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,<span class="thousand-digits-in-sats-amount">000,</span><span class="last-digits-in-sats-amount">000</span>'
)
assert format_btc_amount_as_sats(btc_amount) == "156,000,000"

View file

@ -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